cassionData Analysis

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.

PythonR135 minSphere StandardsUNICEF indicator definitions

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.

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.