Leçon 7 sur 8
Unité · Ce que le suivi n'a pas vu
La tournée que personne n'a conduite
2 629 visites faites sur 2 904 dues. Nord-Ouest se lit comme le meilleur des trois districts à 76,4 % et sa réponse est incertaine à treize points près, contre deux et demi pour le district qui a presque tout visité.
Le dénominateur que personne n’a interrogé
Tous les chiffres de fonctionnalité des deux dernières leçons divisaient par les
visites faites. Personne n’a choisi ce dénominateur ; c’est ce qu’un groupby
produit à partir d’un fichier dont les lignes sont des visites.
Le dénominateur que vous vouliez est celui des visites dues, et le registre sait ce que c’est : 242 points, douze tournées prévues.
import pandas as pd
points = pd.read_csv("water-point-monitoring-2024.v1.csv", parse_dates=["visit_date"])
due = points["water_point_id"].nunique() * 12
made = len(points)
print(f"due {due:,}, made {made:,}, coverage {made / due:.1%}")
library(dplyr)
points |> summarise(
due = n_distinct(water_point_id) * 12,
made = n(),
coverage = n() / (n_distinct(water_point_id) * 12)
)
2 629 sur 2 904 — une couverture de 90,5 %. Deux cent soixante-quinze tournées n’ont jamais été conduites, et le registre ne contient aucune ligne pour le dire. C’est le taux de rapportage du cours DHIS2 qui revient sous un autre format de fichier : une absence n’est pas une valeur, et rien dans les données ne vous rappellera qu’elle a eu lieu.
La couverture n’est pas répartie uniformément, et c’est tout le problème
coverage = points.groupby("district").agg(
points=("water_point_id", "nunique"),
visits=("water_point_id", "size"),
)
coverage["due"] = coverage["points"] * 12
coverage["coverage"] = coverage["visits"] / coverage["due"]
print(coverage.round(3))
points |>
summarise(points = n_distinct(water_point_id), visits = n(), .by = district) |>
mutate(due = points * 12, coverage = visits / due)
| District | Points | Visites faites | Dues | Couverture |
|---|---|---|---|---|
| Sud-Est | 79 | 913 | 948 | 96,3 % |
| Centre | 79 | 882 | 948 | 93,0 % |
| Nord-Ouest | 84 | 834 | 1 008 | 82,7 % |
Les tournées de Nord-Ouest s’effondrent quand les routes se ferment.
monthly = points.pivot_table(
index=points["visit_date"].dt.month, columns="district",
values="water_point_id", aggfunc="size",
)
print(monthly)
points |> count(district, month = lubridate::month(visit_date)) |>
tidyr::pivot_wider(names_from = district, values_from = n)
D’environ 77 points par mois à entre 51 et 60 en juin, juillet, août et septembre. Centre et Sud-Est bougent à peine.
Encadrez la réponse
La réponse honnête à un dénominateur que vous ne pouvez pas observer entièrement n’est pas un facteur de correction. C’est une fourchette, calculée à partir des deux hypothèses qui encadrent la vérité.
- Borne haute : chaque visite manquée aurait trouvé un point en service. C’est le taux observé, qui suppose déjà que les visites manquées ressemblent aux visites faites.
- Borne basse : chaque visite manquée aurait trouvé un point en panne.
WORKING = {"functional", "partially-functional"}
ok = points["functional_status"].isin(WORKING)
bounds = points.assign(ok=ok).groupby("district").agg(
ok=("ok", "sum"), made=("ok", "size"), points=("water_point_id", "nunique"),
)
bounds["due"] = bounds["points"] * 12
bounds["observed"] = bounds["ok"] / bounds["made"]
bounds["lower"] = bounds["ok"] / bounds["due"]
bounds["band"] = bounds["observed"] - bounds["lower"]
print((bounds[["observed", "lower", "band"]] * 100).round(1))
points |>
mutate(ok = functional_status %in% c("functional", "partially-functional")) |>
summarise(ok = sum(ok), made = n(), pts = n_distinct(water_point_id),
.by = district) |>
mutate(due = pts * 12, observed = ok / made, lower = ok / due)
| District | Observé | Borne basse | Fourchette |
|---|---|---|---|
| Nord-Ouest | 76,4 % | 63,2 % | 13,2 |
| Centre | 75,7 % | 70,5 % | 5,3 |
| Sud-Est | 71,4 % | 68,8 % | 2,6 |
Nord-Ouest est le meilleur district sur le chiffre observé et le seul dont le classement n’est pas sûr. Sa fourchette recouvre entièrement celles des deux autres. Sud-Est paraît le plus mauvais et c’est le seul dont on puisse être sûr, parce qu’il a visité presque tout.
Un nombre calculé sur 96 % de son dénominateur et un nombre calculé sur 83 % ne sont pas comparables, et rien dans le tableau ne le dira si vous ne l’y mettez pas.
L’absence est-elle aléatoire ?
Les bornes sont larges parce qu’elles ne supposent rien. Les resserrer suppose de soutenir que les visites manquées ressemblent aux visites faites, et cet argument est testable.
observed = points.pivot_table(
index="water_point_id", columns="round", values="functional_status",
aggfunc="first",
)
followed_by_gap = []
for point, row in observed.iterrows():
for r in range(1, 12):
if pd.isna(row.get(r)):
continue
broken = row[r] not in WORKING
followed_by_gap.append((pd.isna(row.get(r + 1)), broken))
check = pd.DataFrame(followed_by_gap, columns=["gap_next", "broken"])
print(check.groupby("gap_next")["broken"].agg(["mean", "size"]).round(3))
# Same shape: was the point broken at the visit immediately before a missed round?
26,7 % des visites suivies d’une tournée manquée ont trouvé un point en panne, contre 24,7 % des visites suivies d’une tournée faite. L’écart va dans le sens que l’on redoutait — un point que l’on sait en panne est un peu plus souvent sauté — et il est assez faible pour ne ni prouver ni écarter le biais.
C’est un constat légitime et il doit être rapporté comme tel. Un test dont la réponse est ambiguë n’est pas un test raté ; il vous dit que les bornes ne peuvent pas honnêtement être resserrées, ce qui est ce qu’il fallait savoir.
Ce qu’il ne faut pas faire
Trois réparations qui paraissent toutes raisonnables.
Reporter la dernière observation. Remplir chaque tournée manquante par l’état précédent du point. Cela donne 74,2 % au total, presque identique au taux observé, et c’est l’hypothèse qu’un point en panne le reste et qu’un point en service continue — c’est-à-dire exactement ce qui est en question. Cela fabrique de la précision sans ajouter d’information.
Imputer par la moyenne du district. Suppose que les points manqués ressemblent aux points visités du même district, ce qui est l’hypothèse dont toute la leçon doute.
Retirer le district. Ôte du rapport le district le moins bien couvert, qui est presque toujours aussi le plus difficile d’accès et le plus mal servi.
print("Reported: 76.4% (observed), 63.2% to 76.4% (bounds), coverage 82.7%")
print("Not reported: a single imputed number that hides which of these it is")
# The band and the coverage are the finding. Neither is a caveat.
Rapportez la couverture à côté de chaque taux
Water point functionality by district, 2024
District Functionality Coverage Range if unvisited were broken
Sud-Est 71.4% 96.3% 68.8 - 71.4
Centre 75.7% 93.0% 70.5 - 75.7
Nord-Ouest 76.4% 82.7% 63.2 - 76.4
275 of 2,904 scheduled visits were not made, concentrated in Nord-Ouest
during June to September when roads are impassable. Districts are not
ranked here: Nord-Ouest's range overlaps both others.
Le taux, la couverture, la fourchette, et une phrase qui refuse de classer. Refuser de classer est un résultat, et c’est plus utile qu’un palmarès qui s’inverse dès que quelqu’un conduit les tournées manquantes.
La suite
Vous disposez maintenant de tous les nombres EAH que ce cours peut produire et d’un jeu de réserves attaché à chacun. La dernière leçon porte sur le rapport où ils vont — quels chiffres tiennent sur la même page, quels dénominateurs doivent être énoncés, et le tableau unique qui dit au lecteur ce qui peut être comparé avec quoi.