Lesson 7 of 8
Unit · What the comparison can claim
What else could explain it
A confounder causes the outcome and differs between the groups. Four of them sit between the districts in this outbreak, one has been removed, one is unmeasured, and one is not a confounder at all — it is the mechanism.
The definition, and why it is worth being strict about
A confounder is a variable that satisfies three conditions at once:
- it is associated with the exposure — it differs between the groups being compared;
- it is a cause of the outcome, independently of the exposure;
- it is not on the causal path between the exposure and the outcome.
All three matter. A variable that differs between groups but does not cause the outcome is irrelevant. A variable that causes the outcome but is the same in both groups cannot explain a difference. And a variable on the causal path is not a confounder — it is the mechanism, and adjusting for it destroys the finding.
The four candidates in this outbreak
Nord has a standardised attack rate of 7.25 per 1,000 against Sud’s 5.95, and case fatality of 6.30% against 3.11%. What else could explain those gaps?
| Candidate | Differs between districts? | Causes the outcome? | On the path? |
|---|---|---|---|
| Age structure | Yes, sharply | Yes | No — a confounder, already removed |
| Water source and density | Almost certainly | Yes | No — a confounder, unmeasured |
| Distance to treatment | Yes | Yes, for fatality | Yes — it is the mechanism |
| Register quality | Yes | No | No — not a confounder, a bias |
Work down that table and each row needs a different response.
Age structure has been handled. Lesson 6 removed it and quantified what it was worth — about half the crude gap.
Water source, sanitation and crowding are the actual causes of cholera transmission, they certainly differ between a camp-like district and a rural one, and they are not in this dataset. That is the most important sentence in this lesson. An unmeasured confounder cannot be adjusted for, and its existence has to be stated rather than ignored.
print("Available for adjustment: age, sex, district")
print("Known to matter and unavailable: water source, sanitation, crowding, "
"displacement status")
# The list of what you could not adjust for belongs in the limitations section.
Distance to treatment is the interesting one and it is not a confounder at all. The chain is: Nord is further from treatment centres → its cases arrive later → more of them die. Distance causes fatality through delay, so delay is on the causal path. Adjusting for delay would remove the very effect you are trying to demonstrate, which is the classic over-adjustment error.
Register quality is not a confounder either. Nord’s back-filled onset dates do not cause deaths; they distort the measurement of the explanation. That is information bias, and it is fixed by fixing the register, not by adjusting.
Stratify to see it
The simplest adjustment, and the one that shows its working.
import pandas as pd
cases = pd.read_csv("cholera-line-list-2024.v1.csv")
with_outcome = cases[cases["outcome"].notna()]
stratified = (
with_outcome.assign(died=with_outcome["outcome"] == "died")
.groupby(["district", "age_band"])
.agg(cases=("died", "size"), deaths=("died", "sum"))
)
stratified["cfr"] = stratified["deaths"] / stratified["cases"]
print((stratified["cfr"].unstack() * 100).round(1))
library(dplyr)
cases |>
filter(!is.na(outcome)) |>
summarise(cases = n(), cfr = mean(outcome == "died"), .by = c(district, age_band)) |>
tidyr::pivot_wider(id_cols = age_band, names_from = district, values_from = cfr)
Two things to read off a stratified table, and only one of them is the adjusted estimate.
Does the difference persist within strata? If Nord’s case fatality is higher in every age band, age is not the explanation. If it reverses in some bands, something more interesting is happening.
Are the strata consistent? A difference that is large in one band and absent in others is effect modification — the exposure genuinely acts differently in different groups — and it must be reported by stratum rather than averaged into a single adjusted figure.
Effect modification is a finding; confounding is a nuisance. Collapsing the first into a single number destroys the result; failing to remove the second manufactures one.
When stratification runs out
Two variables and the cells empty fast — the disaggregation lesson from module 3, arriving with a different consequence. Three districts by four age bands by two sexes is twenty-four cells on 974 outcomes, and cholera deaths are rare.
cells = with_outcome.groupby(["district", "age_band", "sex"]).size()
print(f"{len(cells)} cells, smallest {cells.min()}, {(cells < 30).sum()} below 30")
cases |> filter(!is.na(outcome)) |> count(district, age_band, sex) |>
summarise(cells = n(), smallest = min(n), under_30 = sum(n < 30))
That is where regression takes over, and Regression for Programme Data later in the programme is about exactly this — adjusting for several variables at once without running out of cells. Its purpose is the same as stratification’s, and a model that adjusts for a mechanism makes the same error as a stratification that does.
The three questions to ask of any comparison
Before attributing a difference to anything:
1. What differs between these groups other than the thing I am studying? List them. The list is the limitations section, and the ones you cannot measure belong in it too.
2. Which of them cause the outcome? Only those are confounders. A district having more schools does not confound a cholera comparison unless schools cause cholera.
3. Which of them are on the causal path? Do not adjust for those. Delay to treatment, in this outbreak, is how distance kills.
adjustment_plan = pd.DataFrame({
"variable": ["age", "sex", "water source", "delay to treatment", "register quality"],
"differs": [True, False, True, True, True],
"causes_outcome": [True, True, True, True, False],
"on_causal_path": [False, False, False, True, False],
"action": ["adjust", "no need", "unmeasured; state it", "do not adjust",
"fix the register"],
})
print(adjustment_plan)
tibble::tribble(
~variable, ~action,
"age", "adjust",
"water source", "unmeasured; state it",
"delay", "do not adjust; it is the mechanism",
"register", "fix it; this is bias, not confounding"
)
Write that table before the analysis, not after. Deciding what to adjust for once you have seen which adjustment gives a nicer answer is the thing that makes observational epidemiology untrustworthy, and it is indistinguishable from honest work after the fact.
What comes next
You have named what else could explain a difference and removed what you could. The last lesson is what remains sayable — the sentence an observational comparison is entitled to, and the several it is not.