Lesson 4 of 8
Unit · An outbreak, one case at a time
Attack rates and the 1% nobody meets
Attack rate needs the population; case fatality needs a denominator that excludes the case still admitted. 3.90% against a 1% target, 6.30% in one district — and the explanation is a delay statistic the previous lesson showed is broken.
Attack rate
The attack rate is cases over the population at risk, over the outbreak period. Despite the name it is a proportion, and it needs the population file the line list ships with.
import pandas as pd
cases = pd.read_csv("cholera-line-list-2024.v1.csv")
population = pd.read_csv("district-population-2024.v1.csv")
by_district = (
cases.groupby("district").size().rename("cases")
.to_frame()
.join(population.groupby("district")["population"].sum())
)
by_district["per_1000"] = 1000 * by_district["cases"] / by_district["population"]
print(by_district.round(2))
library(dplyr)
cases |> count(district, name = "cases") |>
left_join(summarise(population, population = sum(population), .by = district),
by = "district") |>
mutate(per_1000 = 1000 * cases / population)
| District | Cases | Population | Per 1,000 |
|---|---|---|---|
| Nord | 381 | 48,000 | 7.94 |
| Centre | 401 | 62,000 | 6.47 |
| Sud | 193 | 35,000 | 5.51 |
Nord is the worst at 7.94 and Sud the best at 5.51, a gap of 2.43 per 1,000. Hold that number: lesson 6 shows that half of it is not what it appears to be.
Note also that the case counts do not rank the same way as the rates — Centre has the most cases and is not the worst-affected. A count answers “where do we send supplies”; a rate answers “where is the risk highest”. Report both, and never let a bar chart of counts be read as a map of risk.
Age-specific attack rates
by_band = (
cases.groupby(["district", "age_band"]).size().rename("cases")
.to_frame()
.join(population.set_index(["district", "age_band"])["population"])
)
by_band["per_1000"] = 1000 * by_band["cases"] / by_band["population"]
print(by_band["per_1000"].unstack().round(2))
cases |> count(district, age_band, name = "cases") |>
left_join(population, by = c("district", "age_band")) |>
mutate(per_1000 = 1000 * cases / population) |>
tidyr::pivot_wider(id_cols = age_band, names_from = district, values_from = per_1000)
| Age band | Nord | Centre | Sud |
|---|---|---|---|
| 0-4 | 14.77 | 12.69 | 11.69 |
| 5-14 | 8.26 | 8.06 | 6.29 |
| 15-44 | 3.93 | 4.03 | 3.51 |
| 45+ | 6.41 | 4.75 | 5.71 |
Under-fives are hit three to four times as hard as adults of working age, in every district. That gradient is the substantive epidemiology, and it is also the reason the crude comparison above is misleading — Nord has twice the share of under-fives that Sud has.
Case fatality
Case fatality is deaths among cases. Two decisions in the denominator, and both have been made in this course before.
with_outcome = cases[cases["outcome"].notna()]
print(f"cases: {len(cases)}, with an outcome: {len(with_outcome)}")
print(f"CFR: {(with_outcome['outcome'] == 'died').mean():.2%}")
cases |> filter(!is.na(outcome)) |>
summarise(n = n(), cfr = mean(outcome == "died"))
3.90% on 974 cases with a recorded outcome.
One case was still admitted at the cut-off and has no outcome. It is neither a death nor a recovery, and the same reasoning applies as to the seventy-one children still in CMAM treatment in the nutrition course: a pending outcome is not an outcome, and the denominator has to say which it used. Here it moves the figure by nothing; in an outbreak still running it moves it a great deal.
The threshold
Sphere and WHO treat case fatality below 1% as the mark of a well-managed cholera response. Untreated cholera kills a large share of severe cases; treated promptly with oral rehydration it kills almost nobody. The threshold is therefore a statement about access to treatment rather than about the pathogen.
TARGET = 0.01
cfr = (with_outcome["outcome"] == "died").mean()
print(f"CFR {cfr:.2%} against a target of {TARGET:.0%}: "
f"{'meets' if cfr < TARGET else 'does not meet'} the standard")
by_district_cfr = with_outcome.groupby("district")["outcome"].apply(
lambda s: (s == "died").mean()
)
print((by_district_cfr * 100).round(2))
cases |> filter(!is.na(outcome)) |>
summarise(cfr = mean(outcome == "died"), n = n(), .by = district)
| District | CFR | n |
|---|---|---|
| Nord | 6.30% | 381 |
| Sud | 3.11% | 193 |
| Centre | 2.00% | 400 |
Every district is above 1%, and Nord is more than six times it. This is the finding of the outbreak — not the attack rate, which is a fact about exposure, but the case fatality, which is a fact about the response.
The explanation, and why it is unavailable
The standard explanation for high cholera case fatality is delay to treatment, and the line list has the fields to test it.
delay = (
pd.to_datetime(cases["admission_date"], errors="coerce")
- pd.to_datetime(cases["onset_date"], errors="coerce")
).dt.days
testable = cases.assign(delay=delay).dropna(subset=["delay", "outcome"])
banded = testable.assign(
band=pd.cut(testable["delay"], [-1, 1, 3, 99], labels=["0-1", "2-3", "4+"])
)
print(banded.groupby("band")["outcome"].apply(lambda s: (s == "died").mean()).round(3))
cases |>
filter(!is.na(onset_date), !is.na(admission_date), !is.na(outcome)) |>
mutate(delay = as.integer(admission_date - onset_date),
band = cut(delay, c(-1, 1, 3, 99), labels = c("0-1", "2-3", "4+"))) |>
summarise(cfr = mean(outcome == "died"), n = n(), .by = band)
Among admitted cases the gradient is there but modest — about 1.9% at nought to three days and 3.3% at four or more. It is smaller than the gap between districts, and there are two reasons why, one substantive and one not.
The substantive one: admission itself is the protection. Cases never admitted carry most of the mortality, and the delay variable exists only for those who were admitted. Comparing delay bands within admitted cases conditions on the thing that matters most.
The one that is not: Nord’s delay is fabricated by its register. The previous lesson found 80% of its cases recorded with onset equal to admission. So the district with the highest case fatality reports the shortest delay, and the comparison that would explain its mortality is precisely the comparison its data cannot support.
print("Nord CFR 6.30% with a median recorded delay of 0 days.")
print("The delay is a register artefact; the CFR is not.")
# One of these two numbers is real. Say which, in the report.
Write that up as a limitation, not as a result. “Case fatality is highest in Nord; the onset-to-admission delay that would test the usual explanation is not reliable in that district, because 80% of its cases record onset and admission on the same day” is the honest sentence, and it also generates the corrective action — fix the register — that a fabricated explanation would not.
The reporting block
Cholera outbreak, weeks 1-16
Cases 975 attack rate 6.7 per 1,000 (145,000 population)
Attack rate by district 7.94 / 6.47 / 5.51 per 1,000 (Nord / Centre / Sud)
crude; see standardised rates before comparing
Deaths 38 case fatality 3.90%
Denominator 974 one case still admitted at cut-off, excluded
Against Sphere target 1% not met in any district
Highest CFR: Nord at 6.30% (n=381). Onset-to-admission delay is not usable
in Nord (80% of cases record onset = admission), so the usual explanation
cannot be tested there from this register.
Ten lines, and the last three are the ones that make it a finding rather than a table.
What comes next
Attack rates by district look like a comparison and are not one yet, because the districts do not have the same people in them. The next unit fixes that, after first settling a coverage question that has been open since module 2.