cassionData Analysis

Back to the lessonLesson 4 of 8An outbreak, one case at a time

Attack rates and the 1% nobody meets

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 / 23

    What this lesson covers

    • Attack rate
    • Age-specific attack rates
    • Case fatality
    • The threshold
    • The explanation, and why it is unavailable
    • The reporting block
    • What comes next
    Speaker notes
    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.
  2. Slide 2 / 23

    Attack rate — In Python

    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))
    Speaker notes
    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.
  3. Slide 3 / 23

    Attack rate — In R

    library(dplyr)
    
    cases |> count(district, name = "cases") |>
      left_join(summarise(population, population = sum(population), .by = district),
                by = "district") |>
      mutate(per_1000 = 1000 * cases / population)
  4. Slide 4 / 23

    Attack rate

    DistrictCasesPopulationPer 1,000
    Nord38148,0007.94
    Centre40162,0006.47
    Sud19335,0005.51
    Speaker notes
    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.
  5. Slide 5 / 23

    Age-specific attack rates — In Python

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

    Age-specific attack rates — In R

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

    Age-specific attack rates

    Age bandNordCentreSud
    0-414.7712.6911.69
    5-148.268.066.29
    15-443.934.033.51
    45+6.414.755.71
  8. Slide 8 / 23

    Age-specific attack rates

    • Under-fives are hit three to four times as hard as adults of working age — in every district
    Speaker notes
    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.
  9. Slide 9 / 23

    Case fatality — In Python

    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%}")
    Speaker notes
    Case fatality is deaths among cases. Two decisions in the denominator, and both have been made in this course before.
  10. Slide 10 / 23

    Case fatality — In R

    cases |> filter(!is.na(outcome)) |>
      summarise(n = n(), cfr = mean(outcome == "died"))
  11. Slide 11 / 23

    Case fatality

    • 3.90% on 974 cases with a recorded outcome
    Speaker notes
    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.
  12. Slide 12 / 23

    The threshold — In Python

    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))
    Speaker notes
    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.
  13. Slide 13 / 23

    The threshold — In R

    cases |> filter(!is.na(outcome)) |>
      summarise(cfr = mean(outcome == "died"), n = n(), .by = district)
  14. Slide 14 / 23

    The threshold

    DistrictCFRn
    Nord6.30%381
    Sud3.11%193
    Centre2.00%400
    Speaker notes
    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.
  15. Slide 15 / 23

    The explanation, and why it is unavailable — In Python

    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))
    Speaker notes
    The standard explanation for high cholera case fatality is delay to treatment, and the line list has the fields to test it.
  16. Slide 16 / 23

    The explanation, and why it is unavailable — In R

    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)
  17. Slide 17 / 23

    The explanation, and why it is unavailable

    • The substantive one: admission itself is the protection — Cases never admitted carry most of the mortality, and the…
    • The one that is not: Nord's delay is fabricated by its register — The previous lesson found 80% of its cases recorded…
    Speaker notes
    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.
  18. Slide 18 / 23

    The explanation, and why it is unavailable — In Python

    print("Nord CFR 6.30% with a median recorded delay of 0 days.")
    print("The delay is a register artefact; the CFR is not.")
  19. Slide 19 / 23

    The explanation, and why it is unavailable — In R

    # One of these two numbers is real. Say which, in the report.
  20. Slide 20 / 23

    The explanation, and why it is unavailable

    • Write that up as a limitation, not as a result — "Case fatality is highest in Nord; the onset-to-admission delay that…
    Speaker notes
    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.
  21. Slide 21 / 23

    The reporting block — Example

    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.
    Speaker notes
    Ten lines, and the last three are the ones that make it a finding rather than a table.
  22. Slide 22 / 23

    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.
    Speaker notes
    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.
  23. Slide 23 / 23

    Where this goes next

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