cassionData Analysis

Back to the lessonLesson 6 of 8Comparing places

Half the difference was who lives there

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

    What this lesson covers

    • Two districts are not comparable as they stand
    • Direct standardisation
    • What the standard population is, and why it matters
    • Indirect standardisation, and when you need it
    • Age is not the only confounder
    • Report the pair
    • What comes next
    Speaker notes
    Nord's crude attack rate is 7.94 per 1,000 and Sud's is 5.51. Standardised to a common population they are 7.25 and 5.95, so the gap falls from 2.43 to 1.30 — and the missing half was age structure.
  2. Slide 2 / 19

    Two districts are not comparable as they stand — In Python

    import pandas as pd
    
    population = pd.read_csv("district-population-2024.v1.csv")
    
    structure = (
        population.pivot(index="district", columns="age_band", values="population")
    )
    shares = structure.div(structure.sum(axis=1), axis=0)
    print((shares * 100).round(1))
    Speaker notes
    Lesson 4 put three crude attack rates side by side and the comparison looked straightforward. It is not, and the reason is in the population file.
  3. Slide 3 / 19

    Two districts are not comparable as they stand — In R

    library(dplyr)
    
    population |>
      mutate(share = population / sum(population), .by = district) |>
      tidyr::pivot_wider(id_cols = district, names_from = age_band, values_from = share)
  4. Slide 4 / 19

    Two districts are not comparable as they stand

    District0-45-1415-4445+
    Nord22%30%35%13%
    Centre15%25%42%18%
    Sud11%20%44%25%
  5. Slide 5 / 19

    Two districts are not comparable as they stand

    • Nord has twice the share of under-fives that Sud has — and lesson 4 established that under-fives have three to four…
    Speaker notes
    Nord has twice the share of under-fives that Sud has, and lesson 4 established that under-fives have three to four times the attack rate of working-age adults. So Nord would have a higher crude rate than Sud even if every age-specific rate in the two districts were identical. The crude comparison mixes two things: how risky each district is, and who lives in it.
  6. Slide 6 / 19

    Direct standardisation — In Python (cont.)

    cases = pd.read_csv("cholera-line-list-2024.v1.csv")
    
    observed = (
        cases.groupby(["district", "age_band"]).size().rename("cases").to_frame()
        .join(population.set_index(["district", "age_band"])["population"])
    )
    observed["rate"] = observed["cases"] / observed["population"]
    
    standard = population.groupby("age_band")["population"].sum()
    standard_share = standard / standard.sum()
    
    standardised = (
        observed["rate"].unstack()          # district x age_band
        .mul(standard_share, axis=1).sum(axis=1)
    )
    crude = (
    Speaker notes
    The method is one line of arithmetic applied consistently: compute each district's age-specific rates, then apply them to one common population.
  7. Slide 7 / 19

    Direct standardisation — In Python (cont.)

        cases.groupby("district").size()
        / population.groupby("district")["population"].sum()
    )
    
    comparison = pd.DataFrame({
        "crude_per_1000": 1000 * crude,
        "standardised_per_1000": 1000 * standardised,
    })
    comparison["difference"] = (
        comparison["standardised_per_1000"] - comparison["crude_per_1000"]
    )
    print(comparison.round(2))
  8. Slide 8 / 19

    Direct standardisation — In R

    observed <- cases |> count(district, age_band, name = "cases") |>
      left_join(population, by = c("district", "age_band")) |>
      mutate(rate = cases / population)
    
    standard <- population |> summarise(pop = sum(population), .by = age_band) |>
      mutate(share = pop / sum(pop))
    
    observed |>
      left_join(standard, by = "age_band") |>
      summarise(standardised = sum(rate * share), .by = district)
  9. Slide 9 / 19

    Direct standardisation

    DistrictCrudeStandardisedChange
    Nord7.947.25−0.69
    Centre6.476.60+0.13
    Sud5.515.95+0.44
  10. Slide 10 / 19

    Direct standardisation

    • Nord falls, Sud rises, and the gap between them narrows from 2.43 to 1.30 per 1,000 — About half the apparent…
    Speaker notes
    Nord falls, Sud rises, and the gap between them narrows from 2.43 to 1.30 per 1,000. About half the apparent difference between the worst and the best district was age structure rather than risk. The remaining 1.30 is real. Nord is genuinely worse — its age-specific rates are higher in three of four bands — and standardisation is what lets you say that rather than assert it.
  11. Slide 11 / 19

    What the standard population is, and why it matters

    • The combined study population, as above. Simple, defensible, and internal — the standardised rates are comparable…
    • A national population. Lets you compare against other districts standardised the same way.
    • A published world standard — the WHO or Segi world standard population. Lets you compare internationally, and…
    • State the standard — A standardised rate is only interpretable against others standardised to the same population, and…
    Speaker notes
    The standard is whatever population you apply every district's rates to. Three common choices: State the standard. A standardised rate is only interpretable against others standardised to the same population, and two reports using different standards produce incomparable numbers that both look official.
  12. Slide 12 / 19

    What the standard population is, and why it matters — In Python

    print("Standard: combined population of the three districts, 145,000")
  13. Slide 13 / 19

    What the standard population is, and why it matters — In R

    # Say it in the table caption, every time.
  14. Slide 14 / 19

    Indirect standardisation, and when you need it — In Python

    standard_rates = observed.groupby("age_band").apply(
        lambda g: g["cases"].sum() / g["population"].sum()
    )
    
    expected = (
        population.assign(rate=population["age_band"].map(standard_rates))
        .assign(expected=lambda d: d["population"] * d["rate"])
        .groupby("district")["expected"].sum()
    )
    smr = cases.groupby("district").size() / expected
    print(smr.round(3))
    Speaker notes
    Direct standardisation needs age-specific rates for every district, which needs enough cases in every cell. Where a district has four cases in a band, its age-specific rate is unstable and the direct method propagates that instability. The indirect method inverts the problem: apply a standard set of rates to each district's own population, and compare observed cases to expected.
  15. Slide 15 / 19

    Indirect standardisation, and when you need it — In R

    standard_rates <- observed |>
      summarise(rate = sum(cases) / sum(population), .by = age_band)
    
    population |>
      left_join(standard_rates, by = "age_band") |>
      summarise(expected = sum(population * rate), .by = district) |>
      left_join(count(cases, district, name = "observed"), by = "district") |>
      mutate(smr = observed / expected)
    Speaker notes
    The result is a standardised morbidity ratio: observed over expected, where 1.0 means the district has exactly the cases its age structure predicts. Above 1 is worse than expected, below is better. Use indirect standardisation when cells are small, and say which method you used — the two answer slightly different questions and their numbers are not interchangeable.
  16. Slide 16 / 19

    Age is not the only confounder

    • Standardising on age and then claiming the remaining difference is programme performance is the error this lesson…
    Speaker notes
    Standardisation removes the variable you standardise on and nothing else. Cholera attack rates also vary with water source, population density, distance to a treatment centre and displacement status, and none of those is in the population file. Standardising on age and then claiming the remaining difference is programme performance is the error this lesson creates the opportunity for, and the next lesson is entirely about it.
  17. Slide 17 / 19

    Report the pair — Example

    Cholera attack rate by district, weeks 1-16
    
      District   Crude    Age-standardised   Population
      Nord       7.94     7.25               48,000
      Centre     6.47     6.60               62,000
      Sud        5.51     5.95               35,000
    
      Standardised directly to the combined population of the three districts.
      About half the crude Nord-Sud difference is age structure: Nord has 22% of
      its population under five against Sud's 11%, and under-fives have three to
      four times the attack rate of adults aged 15-44.
    Speaker notes
    Both columns, the standard named, and one sentence saying how much moved and why. A table with only the standardised column hides the fact that anything was adjusted; a table with only the crude column invites a comparison that is half demographic.
  18. Slide 18 / 19

    What comes next

    • Standardisation removed one alternative explanation.
    Speaker notes
    Standardisation removed one alternative explanation. The next lesson lists the others — and asks what a difference between two districts is allowed to be attributed to when none of them can be removed.
  19. Slide 19 / 19

    Where this goes next

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