Leçon 4 sur 8
Unité · Une épidémie, un cas à la fois
Taux d'attaque et le 1 % que personne n'atteint
Le taux d'attaque exige la population ; la létalité exige un dénominateur qui exclut le cas encore hospitalisé. 3,90 % contre une cible de 1 %, 6,30 % dans un district — et l'explication repose sur une statistique de délai que la leçon précédente a montrée cassée.
Le taux d’attaque
Le taux d’attaque, ce sont les cas sur la population à risque, sur la période de l’épidémie. Malgré son nom c’est une proportion, et il exige le fichier de population que la liste linéaire livre avec elle.
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))
library(dplyr)
cases |> count(district, name = "cases") |>
left_join(summarise(population, population = sum(population), .by = district),
by = "district") |>
mutate(per_1000 = 1000 * cases / population)
| District | Cas | Population | Pour 1 000 |
|---|---|---|---|
| Nord | 381 | 48 000 | 7,94 |
| Centre | 401 | 62 000 | 6,47 |
| Sud | 193 | 35 000 | 5,51 |
Nord est le plus touché à 7,94 et Sud le moins à 5,51, soit un écart de 2,43 pour 1 000. Retenez ce nombre : la leçon 6 montre que la moitié n’est pas ce qu’elle paraît.
Remarquez aussi que les effectifs de cas ne se classent pas comme les taux — Centre a le plus de cas et n’est pas le plus touché. Un effectif répond à « où envoyer les intrants » ; un taux répond à « où le risque est le plus élevé ». Rapportez les deux, et ne laissez jamais un diagramme en barres d’effectifs se lire comme une carte du risque.
Taux d’attaque par âge
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))
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)
| Tranche d’âge | Nord | Centre | Sud |
|---|---|---|---|
| 0-4 | 14,77 | 12,69 | 11,69 |
| 5-14 | 8,26 | 8,06 | 6,29 |
| 15-44 | 3,93 | 4,03 | 3,51 |
| 45+ | 6,41 | 4,75 | 5,71 |
Les moins de cinq ans sont trois à quatre fois plus touchés que les adultes en âge de travailler, dans chaque district. Ce gradient est l’épidémiologie de fond, et c’est aussi la raison pour laquelle la comparaison brute ci-dessus induit en erreur — Nord compte deux fois plus de moins de cinq ans que Sud.
La létalité
La létalité, ce sont les décès parmi les cas. Deux décisions au dénominateur, et les deux ont déjà été prises dans ce cours.
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%}")
cases |> filter(!is.na(outcome)) |>
summarise(n = n(), cfr = mean(outcome == "died"))
3,90 % sur 974 cas à issue enregistrée.
Un cas était encore hospitalisé à la date d’arrêt et n’a pas d’issue. Ce n’est ni un décès ni une guérison, et le raisonnement est le même que pour les soixante et onze enfants encore en traitement PCIMA dans le cours nutrition — une issue en attente n’est pas une issue, et le dénominateur doit dire lequel il a employé. Ici cela ne déplace rien ; dans une épidémie encore en cours cela déplace beaucoup.
Le seuil
Sphere et l’OMS retiennent une létalité sous 1 % comme marque d’une réponse choléra bien conduite. Le choléra non traité tue une grande part des cas sévères ; traité rapidement par réhydratation orale il ne tue presque personne. Le seuil est donc un énoncé sur l’accès au traitement plutôt que sur l’agent pathogène.
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))
cases |> filter(!is.na(outcome)) |>
summarise(cfr = mean(outcome == "died"), n = n(), .by = district)
| District | Létalité | n |
|---|---|---|
| Nord | 6,30 % | 381 |
| Sud | 3,11 % | 193 |
| Centre | 2,00 % | 400 |
Tous les districts dépassent 1 %, et Nord de plus de six fois. C’est le constat de l’épidémie — non le taux d’attaque, qui est un fait sur l’exposition, mais la létalité, qui est un fait sur la réponse.
L’explication, et pourquoi elle est indisponible
L’explication classique d’une létalité élevée du choléra est le délai d’accès au traitement, et la liste linéaire a les champs pour la tester.
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))
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)
Parmi les cas admis, le gradient existe mais reste modeste — environ 1,9 % de zéro à trois jours et 3,3 % à quatre jours ou plus. Il est plus faible que l’écart entre districts, et il y a deux raisons à cela, l’une de fond et l’autre non.
La raison de fond — l’admission elle-même est la protection. Les cas jamais admis portent l’essentiel de la mortalité, et la variable de délai n’existe que pour ceux qui ont été admis. Comparer des bandes de délai à l’intérieur des cas admis conditionne sur ce qui compte le plus.
Celle qui n’en est pas — le délai de Nord est fabriqué par son registre. La leçon précédente a trouvé 80 % de ses cas avec début égal à admission. Le district ayant la plus forte létalité rapporte donc le délai le plus court, et la comparaison qui expliquerait sa mortalité est précisément celle que ses données ne peuvent pas soutenir.
print("Nord CFR 6.30% with a median recorded delay of 0 days.")
print("The delay is a register artefact; the CFR is not.")
# One of these two numbers is real. Say which, in the report.
Consignez cela comme une limite, non comme un résultat. « La létalité est la plus élevée à Nord ; le délai entre début et admission qui permettrait de tester l’explication habituelle n’y est pas fiable, car 80 % de ses cas enregistrent début et admission le même jour » est la phrase honnête, et elle engendre aussi l’action corrective — réparer le registre — qu’une explication fabriquée ne produirait pas.
Le bloc de rapportage
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.
Dix lignes, et ce sont les trois dernières qui en font un constat plutôt qu’un tableau.
La suite
Les taux d’attaque par district ressemblent à une comparaison et n’en sont pas encore une, car les districts ne contiennent pas les mêmes gens. L’unité suivante y remédie, après avoir d’abord réglé une question de couverture ouverte depuis le module 2.