cassionData Analysis

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.

PythonR135 minUNICEF indicator definitionsSustainable Development Goals (SDG)

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.

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.