Lesson 5 of 8
Unit · Making the pieces line up
Attaching a population denominator
Where a denominator comes from, how to join it without matching on a name, and why catchment populations do not add up to a district. Plus the coverage figure above 100% and what it is really telling you.
The numerator is the easy half
Counting what your programme did is bookkeeping. Deciding what to divide it by is the analysis, and it is where a coverage figure is won or lost.
The vaccination extract makes this concrete: it ships target_population in the
file, one figure per facility, constant across the twelve months. That is
convenient and it is also somebody’s estimate, made at some point, by some
method, and the whole of this lesson is about not treating it as a fact just
because it arrived in a column.
Where a denominator actually comes from
Four sources, in roughly descending order of how often they are argued about.
- A census projection. The national statistics office publishes a census and a growth rate; every district population you use is a projection forward from the census year. In much of this sector that census is a decade old or older.
- An administrative population frame. The health ministry’s own denominators
by facility catchment, which is what
target_populationhere is. Usually derived from the projection with a coefficient — 3.5% of the population for children under one, 20% for children under five, 4% for pregnant women. - A programme target. How many people the programme planned to reach. A legitimate denominator for “did we do what we said”, never for “what share of the population is covered”.
- A survey. The most defensible and the least available, because a survey gives you a proportion with a confidence interval rather than a count.
Name the source in the column. target_population_moh_2024 and
target_population_census_projection are different numbers, and the moment two
of them are in the same folder without labels somebody will average them.
Join on the code, never on the name
The cleaning course made this argument about place names. It is worth repeating here because a population frame is where a name join does the most damage: the denominator is the one column where a failed match does not look like a failed match, it looks like a facility with no coverage.
population = pd.read_csv("admin-population-2024.csv") # admin2_pcode, year, pop_total, pop_under1
vax["admin2_pcode"] = vax["admin2_pcode"].astype("string").str.strip().str.upper()
with_denominator = vax.merge(
population.query("year == 2024"),
on="admin2_pcode", how="left", validate="many_to_one",
)
missing = with_denominator["pop_under1"].isna().sum()
assert missing == 0, f"{missing} rows have no population figure"
population <- read_csv("admin-population-2024.csv")
with_denominator <- vax |>
mutate(admin2_pcode = toupper(stringr::str_squish(admin2_pcode))) |>
left_join(filter(population, year == 2024),
by = "admin2_pcode", relationship = "many-to-one")
stopifnot(!any(is.na(with_denominator$pop_under1)))
The assertion is the important line. A left join that fails to match leaves a missing denominator; a missing denominator makes a coverage figure missing; and a missing coverage figure gets excluded from a mean, so the district average silently becomes the average of the places that matched.
The projection, and the year nobody states
A census projection is a base population and a growth rate. Compute it in the open:
GROWTH = 0.024 # national annual growth rate, published with the census
CENSUS_YEAR = 2015
population["pop_2024"] = population["pop_census"] * (1 + GROWTH) ** (2024 - CENSUS_YEAR)
GROWTH <- 0.024
CENSUS_YEAR <- 2015
population <- population |>
mutate(pop_2024 = pop_census * (1 + GROWTH)^(2024 - CENSUS_YEAR))
Nine years at 2.4% is a factor of 1.24. A quarter of your denominator is an assumption, and it compounds: two districts projected from the same census with the same national rate will preserve their relative sizes exactly, which is precisely what does not happen where there has been displacement.
So when a coverage figure is challenged, the projection is usually the honest answer. Three habits that keep it defensible:
- Write the census year, the growth rate and its source next to the number.
- Use the same projection for every indicator in a report. Two indicators on two projections cannot be compared, and nobody will notice.
- Where displacement has happened, say that the projection does not account for it, and give the direction of the error rather than pretending to correct it.
Catchment populations do not add up
A facility’s catchment is the population it is supposed to serve. Sum the catchments of every facility in a district and you will not get the district.
by_facility = vax.query("antigen == 'penta3' and period == '2024-01-01'")
print("sum of facility targets:", by_facility["target_population"].sum())
vax |>
filter(antigen == "penta3", period == "2024-01-01") |>
summarise(total = sum(target_population))
Here that sum is 11,774 for the month. It is a usable district denominator only if the catchments partition the district — no overlap, no gaps — and they almost never do. Two facilities on either side of a town both count the town. A population between two catchments is counted twice or not at all.
The consequence is specific and common: facility-level coverage is unreliable and district-level coverage is fine. People cross catchment boundaries to be vaccinated, so a facility’s numerator includes children from its neighbour’s denominator. Aggregate to the level where the boundary crossing happens inside the unit, and the noise cancels.
district = (
vax.query("antigen == 'penta3'")
.groupby("period")
.agg(doses=("doses_administered", "sum"),
target=("target_population", "sum"))
)
district["coverage"] = district["doses"] / district["target"]
district <- vax |>
filter(antigen == "penta3") |>
summarise(doses = sum(doses_administered),
target = sum(target_population), .by = period) |>
mutate(coverage = doses / target)
Coverage above 100%
It happens constantly and it is never a programme exceeding its population. Four causes, in the order worth checking:
- The denominator is too small. An out-of-date projection, or a catchment coefficient that assumes fewer under-ones than there are.
- The numerator includes people from outside. Boundary crossing, or a campaign that drew people in from a neighbouring district.
- The grain is wrong. Doses counted where children were meant — a child receiving three doses of a three-dose antigen is one child.
- A join multiplied something. Everything in lesson 1.
over = district[district["coverage"] > 1]
print(over)
district |> filter(coverage > 1)
Report it rather than capping it. A coverage figure clipped at 100% has thrown away the evidence that the denominator is wrong, and the denominator being wrong is the finding.
Rates per thousand, and the annualisation trap
district["per_1000"] = 1000 * district["doses"] / district["target"]
district <- district |> mutate(per_1000 = 1000 * doses / target)
Two traps live in the same place. First, a monthly coverage figure is not an
annual one, and multiplying by twelve assumes every month reported — which the
cleaning course showed is false in August and September here. Second, a rate per
thousand needs its period stated in the label: doses_per_1000_under1_per_month
is unambiguous, rate is not.
Write the denominator down
Every indicator you publish should carry a reference row:
| Field | Value |
|---|---|
| Indicator | Penta3 coverage, district |
| Numerator | Penta3 doses administered, facilities reporting |
| Denominator | Surviving infants, MoH catchment figures summed to district |
| Source | MoH population estimates 2024, projected from 2015 census at 2.4% |
| Disaggregation | Month, facility type |
| Caveat | Reporting rate 29% in August; coverage computed on reporting facilities only |
That table takes two minutes and settles the question permanently. It is the same move as the definitions file the foundations course writes beside a results table, and it is what the Indicator Design and the LogFrame course later builds into a full reference sheet.
What comes next
The denominator answers “out of how many people”. The next lesson answers “out of how many periods” — building a complete calendar so that a facility which never reported in August appears as a gap rather than not appearing at all.