Leçon 2 sur 8
Unité · Avant de modifier quoi que ce soit
Une non-réponse qui n'est pas un accident
Un taux de complétude global masque la seule chose qui compte. Décomposer la non-réponse par site et par semaine, nommer le mécanisme, et chiffrer ce que la suppression des lignes incomplètes fait à votre classement.
5,4 %, ce n’est pas un constat
Le registre n’a pas d’age_months sur 226 de ses 4 218 lignes. Présenté comme
« le jeu de données est complet à 94,6 % », ce chiffre est pire qu’inutile — il
invite le lecteur à conclure que le problème est petit, et il ne l’est pas, parce
qu’il n’est pas réparti.
Un taux de complétude ne signifie quelque chose qu’une fois qu’on sait où sont les trous. Une non-réponse dispersée coûte de la précision. Une non-réponse concentrée coûte la réponse. Cette leçon montre comment savoir dans quel cas vous êtes, et quoi en dire.
Décomposez par groupe avant toute chose
by_commune = (
muac.assign(missing_age=muac["age_months"].isna())
.groupby("commune")
.agg(missing=("missing_age", "mean"), n=("missing_age", "size"))
.sort_values("missing", ascending=False)
)
print((by_commune["missing"] * 100).round(1))
muac |>
group_by(commune) |>
summarise(missing = mean(is.na(age_months)), n = n()) |>
arrange(desc(missing)) |>
mutate(missing = round(100 * missing, 1))
| Commune | Âge manquant | Lignes |
|---|---|---|
| Gros-Morne | 17,7 % | 361 |
| Anse-Rouge | 5,7 % | 229 |
| Saint-Michel | 5,1 % | 335 |
| Dessalines | 4,9 % | 450 |
| Ennery | 3,0 % | 263 |
Onze communes se situent entre 3 % et 6 %. Une seule est à 17,7 %. Ce n’est pas une distribution à longue traîne, ce sont onze communes avec une non-réponse ordinaire et une commune avec un problème.
Puis par période, à l’intérieur du groupe qui ressort
gros_morne = muac[muac["commune"] == "Gros-Morne"].copy()
gros_morne["week"] = gros_morne["screening_date"].dt.to_period("W").dt.start_time
print(
gros_morne.groupby("week")["age_months"]
.apply(lambda s: s.isna().mean())
.sort_values(ascending=False)
.head()
)
muac |>
filter(commune == "Gros-Morne") |>
mutate(week = lubridate::floor_date(screening_date, "week", week_start = 1)) |>
group_by(week) |>
summarise(missing = mean(is.na(age_months)), n = n()) |>
arrange(desc(missing)) |>
head()
La semaine du 10 juin — 54 dépistages sur 85, soit 63,5 %. Toutes les autres semaines de cette commune sont sous les 10 %.
Ce n’est plus une statistique de qualité des données. C’est une équipe, une semaine, un formulaire de tablette dont le champ âge était mal configuré, et cela porte un nom, une date et probablement une personne qui s’en souvient. Une non-réponse qui se ramène à un événement est une non-réponse sur laquelle on peut poser une question, et souvent la corriger à la source.
Nommez le mécanisme, dans des mots que le rapport peut porter
La littérature statistique distingue trois catégories. Vous n’avez pas besoin de la notation, mais bien de la distinction, car elle décide de ce que vous avez le droit de faire.
- Manquant complètement au hasard. Les trous ne dépendent de rien, pas même de la valeur absente. Supprimer ces lignes coûte de la précision et rien d’autre.
- Manquant au hasard. Les trous dépendent de quelque chose d’observé — une commune, une semaine, un enquêteur. Les supprimer biaise le résultat, mais on peut ajuster sur ce dont ils dépendent, puisqu’on le possède.
- Manquant non au hasard. Les trous dépendent de la valeur absente elle-même. Les enfants les plus malades sont ceux que la file n’a jamais atteints ; les ménages ayant refusé l’analyse d’eau sont ceux dont l’eau est la plus sale. Rien dans les données ne corrige cela, et la seule réponse honnête est de le dire.
La non-réponse sur l’âge est ici manquante au hasard — elle dépend de la
commune et de la semaine, toutes deux enregistrées. Les codes -99 du PB sont
peut-être pires — si la mesure a été sautée lorsque l’enfant était agité, et que
l’agitation est corrélée à la maladie, alors c’est du manquant non au hasard, et
aucun code ne vous le dira.
La catégorie n’est pas une subtilité statistique. C’est la différence entre « nous avons supprimé 226 lignes » et « nous avons supprimé 226 lignes, dont 54 d’une seule commune en une seule semaine, ce qui déplace le taux de cette commune de 1,1 point ».
Chiffrez ce que leur suppression coûte
C’est l’étape que presque personne ne fait, et elle prend six lignes. Calculez l’indicateur deux fois — une fois sur tout, une fois sur les seuls cas complets.
def gam_rate(df):
assessed = df["muac_mm"].notna() | df["oedema"].notna()
cases = assessed & ((df["muac_mm"] < 125) | (df["oedema"] == True))
return pd.Series({
"assessed": int(assessed.sum()),
"cases": int(cases.sum()),
"rate": round(cases.sum() / assessed.sum(), 3),
})
all_rows = muac.groupby("commune").apply(gam_rate)
complete = muac[muac["age_months"].notna()].groupby("commune").apply(gam_rate)
comparison = all_rows.join(complete, rsuffix="_complete")
comparison["shift"] = comparison["rate_complete"] - comparison["rate"]
print(comparison.sort_values("shift"))
gam_rate <- function(df) {
df |>
mutate(
assessed = !is.na(muac_mm) | !is.na(oedema),
case = assessed & (muac_mm < 125 | oedema)
) |>
summarise(
assessed = sum(assessed, na.rm = TRUE),
cases = sum(case, na.rm = TRUE),
rate = round(cases / assessed, 3),
.by = commune
)
}
comparison <- gam_rate(muac) |>
left_join(gam_rate(filter(muac, !is.na(age_months))),
by = "commune", suffix = c("", "_complete")) |>
mutate(shift = rate_complete - rate) |>
arrange(shift)
| Commune | Toutes lignes | Cas complets | Écart |
|---|---|---|---|
| Gros-Morne | 14,5 % | 13,4 % | -1,1 pt |
| Anse-Rouge | 15,6 % | 16,5 % | +0,9 pt |
| Dessalines | 5,8 % | 5,4 % | -0,4 pt |
| Saint-Michel | 7,6 % | 7,6 % | 0,0 pt |
Lisez les deux premières lignes ensemble. Sur toutes les lignes, Gros-Morne est deuxième plus touchée à 14,5 %, avec 1,7 point d’avance sur la commune suivante. Sur les cas complets, elle est à 13,4 % et la commune suivante à 13,3 %. L’écart entre la deuxième et la troisième place passe de 1,7 point à 0,1 — autrement dit, le classement que vous poseriez sur la table d’une réunion du cluster nutrition dépend d’un formulaire mal configuré dans une commune au mois de juin.
Remarquez aussi que rien ici n’a franchi de seuil au point de changer une décision. Dites-le également. Un test de sensibilité qui ne trouve aucun effet est un résultat, et c’est celui qui permet d’arrêter de s’inquiéter.
Les trois réponses, et le moment où chacune est honnête
Les supprimer, et déclarer la suppression. Légitime quand la non-réponse est dispersée et que vous l’avez montré. La déclaration n’est pas facultative — « n = 3 992 sur 4 218 ; 226 lignes exclues pour âge manquant » a sa place dans la note de bas de tableau, pas dans votre tête.
Les garder, dans une catégorie à part. Souvent la bonne réponse pour une variable catégorielle, car « non renseigné » est un vrai constat sur le programme. Une colonne de complétude à côté de la colonne d’indicateur en dit plus que chacune séparément.
Imputer — prudemment, et rarement. Pour une variable servant à désagréger plutôt qu’à calculer, remplir l’âge manquant par l’âge médian de la même commune et du même mois est défendable si vous marquez chaque ligne imputée et rapportez l’indicateur des deux façons. Pour une variable du numérateur, imputer revient à inventer le constat.
muac["age_imputed"] = muac["age_months"].isna()
muac["age_months_filled"] = muac.groupby("commune")["age_months"].transform(
lambda s: s.fillna(s.median())
)
muac <- muac |>
mutate(age_imputed = is.na(age_months)) |>
group_by(commune) |>
mutate(age_months_filled = ifelse(is.na(age_months),
median(age_months, na.rm = TRUE),
age_months)) |>
ungroup()
La colonne de marquage est l’essentiel. Une valeur imputée qu’on ne distingue plus d’une valeur mesurée a cessé d’être une estimation pour devenir une affirmation.
Des lignes manquantes, pas seulement des valeurs
La non-réponse la plus difficile est celle sans case vide, parce que la ligne n’est pas là du tout.
Dans l’extraction vaccinale de routine, une formation sanitaire qui n’a pas
rapporté n’est pas absente — elle est présente avec doses_administered à zéro
et report_submitted à false. Six cent quarante-deux lignes sont dans cet
état. Calculez la couverture sans les séparer et toute formation sanitaire
silencieuse devient une formation sanitaire n’ayant vacciné personne.
vax["reported"] = vax["report_submitted"] == True
reporting_rate = vax.groupby("period")["reported"].mean()
coverage = (
vax[vax["reported"]]
.groupby("period")
.apply(lambda g: g["doses_administered"].sum() / g["target_population"].sum())
)
print(pd.DataFrame({"reporting_rate": reporting_rate, "coverage": coverage}))
vax |>
summarise(
reporting_rate = mean(report_submitted),
coverage = sum(doses_administered[report_submitted]) /
sum(target_population[report_submitted]),
.by = period
)
Deux chiffres, toujours publiés ensemble. La couverture calculée sur les formations sanitaires ayant rapporté, et le taux de complétude qui dit quelle part du district cela représente. Un taux de couverture unique qui inclut discrètement les structures silencieuses à son dénominateur est le défaut le plus répandu des données sanitaires de routine, et il tire toujours dans le même sens — vers le bas.
La suite
La non-réponse est un trou là où quelque chose devrait être. L’unité suivante traite du problème inverse — quelque chose qui est là deux fois. La leçon 3 demande si la colonne que vous traitez comme une clé en est réellement une, et montre ce que fait une jointure quand elle n’en est pas une.