Lesson 1 of 8
Unit · The denominator is the epidemiology
Rate, ratio, proportion — and person-time
Three shapes that get called "rate" and only one of them is, plus the denominator that makes incidence mean something when people enter and leave a population at different times.
Three shapes, one word
The indicator design course established four families of measure. Epidemiology uses three of them constantly and calls all three “rate”, which is where most misreading starts.
| Shape | Form | Range | Example |
|---|---|---|---|
| Proportion | Part over whole, both same units | 0 to 1 | Case fatality: deaths among cases |
| Ratio | Two quantities, not nested | any | Sex ratio of cases; odds ratio |
| Rate | Events over population-time | any positive | Incidence per 1,000 person-years |
Only the third is a rate, and the difference is the time in the denominator. A proportion asks “what share”; a rate asks “how fast”.
Prevalence and incidence
The pair this whole course turns on.
- Prevalence — how many people have the condition now. A proportion, from a survey, a snapshot.
- Incidence — how many new cases arise per unit of population-time. A rate, from surveillance, a flow.
They answer different questions and they move for different reasons. Prevalence rises if incidence rises or if people survive longer with the condition, which is why a successful treatment programme can raise HIV prevalence while lowering incidence — the most misread pair of numbers in this sector.
import pandas as pd
cases = pd.read_csv("cholera-line-list-2024.v1.csv", parse_dates=["onset_date"])
population = pd.read_csv("district-population-2024.v1.csv")
total_population = population["population"].sum()
print(f"{len(cases)} cases in a population of {total_population:,}")
print(f"attack rate over the outbreak: {1000 * len(cases) / total_population:.2f} per 1,000")
library(dplyr)
cases <- readr::read_csv("cholera-line-list-2024.v1.csv")
population <- readr::read_csv("district-population-2024.v1.csv")
c(cases = nrow(cases),
population = sum(population$population),
per_1000 = 1000 * nrow(cases) / sum(population$population))
975 cases in 145,000 people — 6.7 per 1,000 over the outbreak. Strictly that is an attack rate, which is a proportion despite its name: cases over the population at risk, over a defined outbreak period. Lesson 4 takes it seriously.
What person-time adds
A headcount denominator assumes everyone was present and at risk for the whole period. In this sector they routinely were not — people arrive, leave, are born, die, or enter a programme mid-year.
Person-time counts each person for as long as they were actually at risk.
# One person followed for 6 months contributes 0.5 person-years
follow_up = pd.DataFrame({
"person_id": ["A", "B", "C"],
"entered": pd.to_datetime(["2024-01-01", "2024-01-01", "2024-07-01"]),
"exited": pd.to_datetime(["2024-12-31", "2024-06-30", "2024-12-31"]),
})
follow_up["person_years"] = (
(follow_up["exited"] - follow_up["entered"]).dt.days / 365.25
)
events = 1
print(f"{events / follow_up['person_years'].sum():.3f} events per person-year")
print(f"naive headcount rate: {events / len(follow_up):.3f} per person")
follow_up <- tibble::tibble(
person_id = c("A", "B", "C"),
entered = as.Date(c("2024-01-01", "2024-01-01", "2024-07-01")),
exited = as.Date(c("2024-12-31", "2024-06-30", "2024-12-31"))
) |>
mutate(person_years = as.numeric(exited - entered) / 365.25)
c(per_person_year = 1 / sum(follow_up$person_years),
naive = 1 / nrow(follow_up))
Three people, two of whom were present for half the year. The headcount denominator is 3; the person-time denominator is 2.0 person-years. The two answers differ by a third, and the second is the one that compares across periods and places with different amounts of follow-up.
Use person-time whenever:
- Follow-up varies — a treatment cohort where people enrol through the year.
- The population changes — displacement, camp arrivals, a catchment that grows.
- You are comparing periods of different length — a nine-month response against a twelve-month one.
Use a headcount denominator when everyone was present throughout and the period is fixed, which is what makes an outbreak attack rate legitimate.
Say the denominator and the multiplier in the name
indicators = {
"cholera_attack_rate_per_1000_outbreak": 1000 * len(cases) / total_population,
"case_fatality_percent_of_cases_with_outcome": None,
"incidence_per_1000_person_years": None,
}
# The same discipline: the name carries the denominator and the multiplier
Two things belong in the name and are usually missing.
The multiplier. Per 100, per 1,000, per 100,000. Cholera attack rates are conventionally per 1,000; maternal mortality per 100,000; immunisation coverage per 100. Getting the convention wrong is a factor of a hundred and it happens.
The denominator’s population. “Per 1,000 population” and “per 1,000 children under five” are different indicators. The indicator course made this general point; here the conventions are published and departing from one silently is worse than inventing one.
The at-risk population is not always the whole population
The denominator of a rate is the population at risk of the event.
- Maternal mortality ratio — deaths per 100,000 live births, not per population, because only pregnancies are at risk.
- Neonatal mortality — deaths in the first 28 days per 1,000 live births.
- Cholera attack rate — cases per 1,000 population, because everyone is at risk.
- Measles attack rate — arguably per 1,000 susceptible people, and the susceptible population is exactly what an immunisation programme changes.
under_five = population.loc[population["age_band"] == "0-4", "population"].sum()
u5_cases = (cases["age_band"] == "0-4").sum()
print(f"under-five attack rate: {1000 * u5_cases / under_five:.2f} per 1,000")
print(f"all-age attack rate: {1000 * len(cases) / total_population:.2f} per 1,000")
under5 <- population |> filter(age_band == "0-4") |> summarise(sum(population)) |> pull()
u5_cases <- sum(cases$age_band == "0-4")
c(under_five = 1000 * u5_cases / under5,
all_age = 1000 * nrow(cases) / sum(population$population))
Under-fives have roughly twice the all-age attack rate here. That gap is the whole reason lesson 6 exists: two districts with different age structures will differ on the all-age rate even if every age-specific rate is identical.
The check to run on any rate
Four questions, and a rate that cannot answer all four is not usable.
- What is the numerator counting — events, people, or episodes?
- Who is in the denominator, and were they all at risk?
- Over what period, and was everyone present for it?
- What is the multiplier, and is it the convention for this indicator?
def describe_rate(numerator, denominator, period, multiplier, at_risk_note):
return {
"value": multiplier * numerator / denominator,
"numerator": numerator, "denominator": denominator,
"period": period, "multiplier": multiplier, "at_risk": at_risk_note,
}
describe_rate <- function(numerator, denominator, period, multiplier, at_risk) {
list(value = multiplier * numerator / denominator, numerator = numerator,
denominator = denominator, period = period, at_risk = at_risk)
}
That is the indicator reference sheet from module 3, compressed to the five fields an epidemiological measure cannot do without.
What comes next
One denominator is straightforward. The next lesson chains several together — the HIV and TB care cascades, where each step’s denominator is the previous step’s numerator, and the interesting number is which link loses the most.