Leçon 4 sur 8
Unité · Ce que Sphère demande et qu'une échelle ne dit pas
Testé sur un tiers, censuré sur un cinquième
E. coli a été testé sur 33,4 % des ménages, le chlore sur 40,0 %, et les deux sur 13,3 %. Dans le registre des points d'eau, 271 lectures de chlore sont la chaîne "<0.1" — et les supprimer relève la conformité apparente de seize points.
Le dénominateur change, et le rapport généralement non
La qualité de l’eau coûte cher à mesurer. Une enquête qui interroge 2 403 ménages en teste une fraction, et tout indicateur de qualité tourne donc sur un dénominateur différent de tout indicateur d’accès.
import pandas as pd
households = pd.read_csv("wash-household-survey-2024.v1.csv")
ecoli = households["ecoli_cfu_100ml"].notna()
chlorine = households["free_residual_chlorine_mgl"].notna()
print(f"E. coli tested: {ecoli.sum():>5} = {ecoli.mean():.1%}")
print(f"chlorine tested: {chlorine.sum():>5} = {chlorine.mean():.1%}")
print(f"both: {(ecoli & chlorine).sum():>5} = {(ecoli & chlorine).mean():.1%}")
library(dplyr)
households |> summarise(
ecoli = mean(!is.na(ecoli_cfu_100ml)),
chlorine = mean(!is.na(free_residual_chlorine_mgl)),
both = mean(!is.na(ecoli_cfu_100ml) & !is.na(free_residual_chlorine_mgl))
)
802 ménages pour E. coli, 961 pour le chlore, et 320 pour les deux. Le dernier nombre est celui qui compte : toute question liant les deux porte sur 13,3 % de l’enquête, et son intervalle sera large.
Imprimez le n analysable pour chaque chiffre de qualité, dans le tableau et non en note de bas de page. Un pourcentage de qualité voisin d’un pourcentage d’accès sur la même ligne se lit comme le même dénominateur, et ici ce n’est jamais le cas.
Les classes de risque du JMP
tested = households[ecoli]
classes = pd.cut(
tested["ecoli_cfu_100ml"],
[-1, 0, 10, 100, 10**9],
labels=["safe <1", "low 1-10", "moderate 11-100", "high >100"],
)
print(classes.value_counts(normalize=True).round(3))
households |>
filter(!is.na(ecoli_cfu_100ml)) |>
mutate(risk = cut(ecoli_cfu_100ml, c(-1, 0, 10, 100, Inf),
labels = c("safe", "low", "moderate", "high"))) |>
count(risk) |> mutate(share = n / sum(n))
| Classe de risque | Ménages | Part des testés |
|---|---|---|
| Sûre (<1 UFC/100 mL) | 430 | 53,6 % |
| Faible (1–10) | 173 | 21,6 % |
| Modérée (11–100) | 139 | 17,3 % |
| Élevée (>100) | 60 | 7,5 % |
Rapportez les classes, non une moyenne. Les dénombrements d’E. coli sont fortement asymétriques et une moyenne de 14 UFC/100 mL ne décrit rien — les classes sont ce dans quoi la directive est écrite et ce sur quoi une décision se prend.
Amélioré n’est pas sûr, et c’est ici que cela se démontre
IMPROVED = {"piped-into-dwelling", "piped-into-yard", "public-tap",
"borehole", "protected-well", "protected-spring"}
tested = tested.assign(improved=tested["water_source"].isin(IMPROVED))
print(tested.groupby("improved")["ecoli_cfu_100ml"].agg(
n="size", safe=lambda s: (s < 1).mean(), high=lambda s: (s > 100).mean()
).round(3))
households |>
filter(!is.na(ecoli_cfu_100ml)) |>
mutate(improved = water_source %in% improved) |>
summarise(n = n(), safe = mean(ecoli_cfu_100ml < 1),
high = mean(ecoli_cfu_100ml > 100), .by = improved)
| Source | Testés | Sûre | Risque élevé |
|---|---|---|---|
| Améliorée | 625 | 61,1 % | 3,8 % |
| Non améliorée ou de surface | 177 | 27,1 % | 20,3 % |
Le type de source prédit fortement la qualité, et 38,9 % des ménages sur une source améliorée ont malgré tout de l’E. coli détectable. La leçon 1 disait que l’échelon supérieur exige l’absence de contamination en plus d’une source améliorée ; voici le nombre qui montre pourquoi c’est une condition distincte et non une formalité.
La valeur censurée, et pourquoi elle n’est pas manquante
Le registre des points d’eau mesure le chlore sur le terrain, et le kit a une limite de détection.
points = pd.read_csv("water-point-monitoring-2024.v1.csv")
readings = points["free_residual_chlorine_mgl"].dropna()
censored = readings.astype(str).str.startswith("<")
print(f"tested {len(readings)}, censored {censored.sum()}")
print(readings[censored].unique())
points |>
filter(!is.na(free_residual_chlorine_mgl)) |>
count(censored = startsWith(free_residual_chlorine_mgl, "<"))
811 visites portent une lecture et 271 d’entre elles sont la chaîne <0.1.
C’est une mesure : la vraie valeur se situe entre zéro et la limite de détection.
Ce n’est pas une valeur manquante, et les deux choses en lesquelles on la
transforme habituellement sont fausses toutes les deux.
numeric = pd.to_numeric(readings, errors="coerce") # <-- the silent one
print(f"after coercion: {numeric.isna().sum()} became NaN")
in_range = readings[~censored].astype(float).between(0.2, 0.5)
print(f"in target range, over all tested: {in_range.sum() / len(readings):.1%}")
print(f"in target range, dropping censored: {in_range.mean():.1%}")
points |>
filter(!is.na(free_residual_chlorine_mgl),
!startsWith(free_residual_chlorine_mgl, "<")) |>
summarise(in_range = mean(between(as.numeric(free_residual_chlorine_mgl), 0.2, 0.5)))
30,9 % contre 46,5 %. Supprimer les lectures censurées relève la conformité apparente à la cible de chloration de près de seize points, et le fait dans une seule direction — toute valeur censurée est un échec, si bien que les retirer ne retire que des échecs.
pd.to_numeric(..., errors="coerce") le fait en silence et ressemble à une
correction de type.
Que faire d’une valeur censurée
Trois options, par ordre de préférence pour cet indicateur.
Classer plutôt que moyenner. La cible est une plage, si bien qu’une lecture
censurée répond parfaitement à la question : <0.1 est en dessous de 0,2 et donc
non conforme. La conformité se calcule sur les 811 lectures sans aucune
substitution.
Substituer la limite, ou sa moitié, s’il vous faut vraiment une moyenne.
Dites laquelle vous avez employée — LD/2 est la convention courante — et
rapportez à combien de valeurs elle s’est appliquée.
Rapporter la part sous la limite comme un nombre à part entière. 33,4 % de ces lectures sont sous le seuil de détection, ce qui est un constat sur la pratique de chloration et non une gêne dans les données.
compliant = pd.to_numeric(readings.where(~censored), errors="coerce").between(0.2, 0.5)
result = pd.DataFrame({
"readings": [len(readings)],
"below detection": [censored.sum()],
"in 0.2-0.5 range": [compliant.sum()],
"compliance": [f"{compliant.sum() / len(readings):.1%}"],
})
print(result)
# The denominator is every reading, including the ones below the limit.
Ne laissez jamais une limite de détection rétrécir un dénominateur. C’est la façon la plus courante dont un rapport de qualité de l’eau devient optimiste, et elle survit à la relecture parce que l’arithmétique sur les valeurs restantes est juste.
L’incohérence entre champs qu’il faut nommer
treats = households["water_treated_at_home"]
print(f"treat at home: {treats.sum()}")
print(f" of which no chlorine reading: {(treats & ~chlorine).sum()}")
households |> filter(water_treated_at_home) |>
count(no_reading = is.na(free_residual_chlorine_mgl))
533 ménages déclarent traiter leur eau et n’ont aucune mesure de chlore. Le cours sur le nettoyage a établi de quoi il s’agit — une absence corrélée à la chose mesurée — et la conséquence ici est précise : supprimer ces lignes retire des ménages qui traitent leur eau plus souvent que la moyenne, si bien que le sous-échantillon testé n’est pas représentatif de l’enquête dont il vient.
Rapportez-le comme un bloc à part
Water quality
E. coli, point of collection 802 households tested (33.4% of survey)
Safe <1 CFU/100 mL 53.6%
Low 1-10 21.6%
Moderate 11-100 17.3%
High >100 7.5%
Improved sources: 61.1% safe (n=625)
Unimproved and surface: 27.1% safe (n=177)
Free residual chlorine, water points 811 visits tested (30.8% of visits)
Below detection limit (<0.1 mg/L) 33.4% counted as non-compliant
Within 0.2-0.5 mg/L target 30.9%
Quality was tested on a subsample and does not share the denominator of the
access indicators above. 533 households reporting home treatment have no
chlorine reading, so the tested subsample over-represents untreated water.
Son propre bloc, ses propres dénominateurs, sa propre limite. Un chiffre de qualité placé dans un tableau de chiffres d’accès sera lu contre leur dénominateur, et rien dans la mise en page ne l’en empêchera.
La suite
Tout ce qui précède est une coupe transversale : ce qui était vrai le jour où quelqu’un est passé. L’unité suivante porte sur le registre qui visite les mêmes points d’eau douze fois, et sa première conséquence est de transformer un taux de fonctionnalité en trois.