cassionData Analysis

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.

PythonR90 minUNICEF indicator definitionsSustainable Development Goals (SDG)Results-Based Management (RBM)

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_population here 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:

  1. The denominator is too small. An out-of-date projection, or a catchment coefficient that assumes fewer under-ones than there are.
  2. The numerator includes people from outside. Boundary crossing, or a campaign that drew people in from a neighbouring district.
  3. The grain is wrong. Doses counted where children were meant — a child receiving three doses of a three-dose antigen is one child.
  4. 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.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.