cassionData Analysis

Lesson 3 of 8

Unit · An outbreak, one case at a time

The curve is not the outbreak

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.

PythonR135 minSphere StandardsUNICEF indicator definitions

What the curve is for

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

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")
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)

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

Forty-seven cases have no onset date, and they are not scattered at random.

missing = cases[cases["onset_date"].isna()]
print(missing.groupby("district").size())
print(f"{len(missing) / len(cases):.1%} of all cases")
cases |> filter(is.na(onset_date)) |> count(district)

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.

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)
cases |>
  mutate(onset_or_admission = coalesce(onset_date, admission_date),
         week = lubridate::floor_date(onset_or_admission, "week")) |>
  count(week)

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

Now the finding this dataset was built for.

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))
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)
District Median delay Same-day
Nord 0 days 80%
Centre 2 days 13%
Sud 2 days 20%

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.

print("Nord: median delay 0 days, case fatality 6.30%")
print("Centre: median delay 2 days, case fatality 2.00%")
# The contradiction is the finding: the fastest-looking district dies most.

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

Once the curve is trustworthy, three readings are standard.

  • A single sharp peak suggests a point source — one contaminated water point, one event — with cases appearing within one incubation period of each other.
  • 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 been fixed.

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. 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. 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.

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.