cassionData Analysis

Back to the lessonLesson 3 of 8An 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.

Slides · PDFSlides · PowerPoint

  1. Slide 1 / 21

    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.
  2. Slide 2 / 21

    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.
  3. Slide 3 / 21

    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")
  4. Slide 4 / 21

    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.
  5. Slide 5 / 21

    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.
  6. Slide 6 / 21

    What the missing dates do — In R

    cases |> filter(is.na(onset_date)) |> count(district)
  7. Slide 7 / 21

    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.
  8. Slide 8 / 21

    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)
  9. Slide 9 / 21

    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)
  10. Slide 10 / 21

    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.
  11. Slide 11 / 21

    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.
  12. Slide 12 / 21

    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)
  13. Slide 13 / 21

    The district whose curve is not a curve

    DistrictMedian delaySame-day
    Nord0 days80%
    Centre2 days13%
    Sud2 days20%
  14. Slide 14 / 21

    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.
  15. Slide 15 / 21

    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%")
  16. Slide 16 / 21

    The district whose curve is not a curve — In R

    # The contradiction is the finding: the fastest-looking district dies most.
  17. Slide 17 / 21

    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.
  18. Slide 18 / 21

    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.
  19. Slide 19 / 21

    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.
  20. Slide 20 / 21

    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.
  21. Slide 21 / 21

    Where this goes next

    Read the full lesson, with runnable code Back to the lesson