Back to the lesson·Lesson 3 of 8·An outbreak, one case at a time
The curve is not the outbreak
The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.
What this lesson covers
- What the curve is for
- Draw it
- What the missing dates do
- The district whose curve is not a curve
- Reading the shape
- Plot cases, not rates, on the curve
- What comes next
Speaker notes
975 cases, 47 without an onset date, and one district whose register is back-filled from the admission book — so its median onset-to-admission delay reads as zero while its case fatality is the highest of the three.What the curve is for
- An epidemic curve is cases by date of onset.
Speaker notes
An epidemic curve is cases by date of onset. It is the first thing drawn in any outbreak and it answers three questions no table answers as fast: is it growing, where is the peak, and is the shape consistent with a point source or with person-to-person spread? It is also the analysis most easily broken by a missing field, which is what this lesson is really about.Draw it — In Python
import pandas as pd cases = pd.read_csv("cholera-line-list-2024.v1.csv", parse_dates=["onset_date"]) with_onset = cases[cases["onset_date"].notna()] print(f"{len(with_onset)} of {len(cases)} cases have an onset date") curve = ( with_onset.assign(week=with_onset["onset_date"].dt.to_period("W").dt.start_time) .groupby("week").size() ) print(curve) print(f"peak week {curve.idxmax().date()} with {curve.max()} cases")Draw it — In R
library(dplyr) cases <- readr::read_csv("cholera-line-list-2024.v1.csv") curve <- cases |> filter(!is.na(onset_date)) |> mutate(week = lubridate::floor_date(onset_date, "week")) |> count(week)Speaker notes
928 of 975 cases carry an onset date, and the curve peaks in the seventh week — the week ending 23 June — with 111 cases. By onset, not by admission, not by date of entry — the whole point of the curve is when people fell ill, and each of the other two dates is shifted by a different, unknown amount. Use weekly bins for an outbreak of this length. Daily is too noisy to read; monthly hides the peak entirely.What the missing dates do — In Python
missing = cases[cases["onset_date"].isna()] print(missing.groupby("district").size()) print(f"{len(missing) / len(cases):.1%} of all cases")Speaker notes
Forty-seven cases have no onset date, and they are not scattered at random.What the missing dates do
- Exclude them — The default and usually right
- Impute from admission date — Tempting and it shifts the whole curve later by the median delay, which flattens the rise…
- Plot them as a separate band — Honest and rarely done: a stacked bar with "onset unknown" in a distinct shade, placed…
Speaker notes
Three ways to handle them, and they give three different curves. Exclude them. The default and usually right. The curve is then of 928 cases and the label must say so, because 975 will appear elsewhere in the same report. Impute from admission date. Tempting and it shifts the whole curve later by the median delay, which flattens the rise and moves the apparent peak. Plot them as a separate band. Honest and rarely done: a stacked bar with "onset unknown" in a distinct shade, placed at the admission week, so the reader sees both the curve and its uncertainty.What the missing dates do — In Python
imputed = cases.copy() fallback = pd.to_datetime(imputed["admission_date"], errors="coerce") imputed["onset_or_admission"] = imputed["onset_date"].fillna(fallback) both = pd.DataFrame({ "excluded": curve, "imputed": (imputed.assign( week=imputed["onset_or_admission"].dt.to_period("W").dt.start_time) .groupby("week").size()), }).fillna(0).astype(int) print(both)What the missing dates do — In R
cases |> mutate(onset_or_admission = coalesce(onset_date, admission_date), week = lubridate::floor_date(onset_or_admission, "week")) |> count(week)What the missing dates do
- Whichever you choose, state the denominator on the chart — "n = 928 of 975 cases with a recorded onset date" is six…
Speaker notes
Whichever you choose, state the denominator on the chart. "n = 928 of 975 cases with a recorded onset date" is six words and it is the difference between a curve and a claim.The district whose curve is not a curve — In Python
delay = ( pd.to_datetime(cases["admission_date"], errors="coerce") - cases["onset_date"] ).dt.days by_district = cases.assign(delay=delay).dropna(subset=["delay"]).groupby("district") print(by_district["delay"].median()) print((by_district["delay"].apply(lambda s: (s == 0).mean()) * 100).round(0))Speaker notes
Now the finding this dataset was built for.The district whose curve is not a curve — In R
cases |> filter(!is.na(onset_date), !is.na(admission_date)) |> mutate(delay = as.integer(admission_date - onset_date)) |> summarise(median_delay = median(delay), zero_day = mean(delay == 0), .by = district)The district whose curve is not a curve
District Median delay Same-day Nord 0 days 80% Centre 2 days 13% Sud 2 days 20% The district whose curve is not a curve
- Nord's cases appear to reach treatment the same day they fall ill, four times as often as anywhere else — That is not a…
- Nord's epidemic curve is an admission curve — It is shifted right by however long its cases actually took to arrive, so…
- Nord's delay statistic is unusable — and it is the statistic the next lesson uses to explain case fatality — where Nord…
Speaker notes
Nord's cases appear to reach treatment the same day they fall ill, four times as often as anywhere else. That is not a strong health system. It is a register back-filled from the admission book: somebody enters the admission date in both fields because the onset date was never asked. Two things follow, and both are serious. Nord's epidemic curve is an admission curve. It is shifted right by however long its cases actually took to arrive, so comparing peak timing across districts compares two different quantities. Nord's delay statistic is unusable, and it is the statistic the next lesson uses to explain case fatality — where Nord is worst at 6.30% against 2.00% in Centre. The district that appears to have the fastest access has the highest mortality, and the resolution is that the appearance is an artefact.The district whose curve is not a curve — In Python
print("Nord: median delay 0 days, case fatality 6.30%") print("Centre: median delay 2 days, case fatality 2.00%")The district whose curve is not a curve — In R
# The contradiction is the finding: the fastest-looking district dies most.The district whose curve is not a curve
- A contradiction between two indicators from the same register is a data question before it is an epidemiological one —…
Speaker notes
A contradiction between two indicators from the same register is a data question before it is an epidemiological one. The cleaning course's rule about columns that should agree, arriving in a setting where getting it wrong misdirects an outbreak response.Reading the shape
- A single sharp peak suggests a point source — one contaminated water point, one event — with cases appearing within…
- A series of peaks at roughly one incubation period apart suggests person-to-person propagation.
- A long plateau suggests continuing common-source exposure, which for cholera usually means the water supply has not…
Speaker notes
Once the curve is trustworthy, three readings are standard. This outbreak rises over six weeks and decays over ten, which is a propagated shape rather than a point source. Say which reading you are making and why, and be careful: the shape is also affected by when the response started, and an intervention that works truncates the curve in a way that looks like natural decay.Plot cases, not rates, on the curve
- A last practical point.
Speaker notes
A last practical point. The epidemic curve is a count over time, not a rate. Dividing by population makes districts comparable and destroys the thing the curve is for, which is the absolute burden arriving at treatment centres each week. Plot counts. Put the rates in the table beside it, which is the next lesson.What comes next
- The curve says when.
Speaker notes
The curve says when. The next lesson says how much and how badly: attack rates with the population denominator this dataset ships, and case fatality against the 1% the response is judged on.