---
title: "Évolution du dépistage sur l'année de campagne"
subtitle: "Dépistage du périmètre brachial — Artibonite, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## Ce que produit ce document

Le nombre de dépistages par mois et par commune sur la campagne 2024, et le
problème de qualité des données que dissimule le volume. C'est le pendant de
l'exemple sur la prévalence : celui-là demande à quel point les enfants étaient
malnutris, celui-ci demande si vous en avez dépisté assez, aux bons endroits, et
avec assez de régularité pour croire la réponse.

Tous les jeux de données de cette plateforme sont synthétiques. Rien ici ne
décrit un enfant réel.

## Une mise en garde sur le mot « couverture »

Ce qui suit n'est **pas** la couverture du programme. La couverture est le
rapport des cas atteints aux cas existants, et ce registre ne dispose d'aucun
dénominateur d'enfants dans la population — seulement des enfants qui se sont
présentés. Appeler « couverture » un volume de dépistage est l'une des manières
les plus courantes pour un rapport nutritionnel de surestimer ce qu'il sait.

Ce que le volume peut vous dire, en revanche, c'est où la campagne a été
interrompue : une question réelle et utile, à laquelle il existe une réponse
honnête.

## Mise en place

```{python}
import pandas as pd
import numpy as np

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "muac-screening-artibonite-2024.v1.csv"
)

muac = pd.read_csv(
    URL,
    dtype={"child_id": "string", "commune": "string", "sex": "string"},
    na_values={"muac_mm": ["-99"]},
)

muac["screening_date"] = pd.to_datetime(muac["screening_date"], format="%Y-%m-%d")
muac["mois"] = muac["screening_date"].dt.to_period("M")

print(f"{len(muac):,} depistages, {muac['commune'].nunique()} communes")
print(f"{muac['mois'].min()} a {muac['mois'].max()}")
```

## Le volume sur l'année

```{python}
mensuel = muac.groupby("mois").size()
mensuel
```

Janvier et décembre sont visiblement plus maigres que les mois intermédiaires.
Avant d'y lire une campagne au démarrage lent et à l'extinction progressive,
vérifiez l'explication évidente : un registre qui ne couvre qu'une partie du mois
à chaque extrémité. Ici le premier dépistage a lieu à la mi-janvier et le dernier
à la mi-décembre : les deux mois faibles sont donc un artefact de la fenêtre de
rapportage, et non une baisse d'activité.

C'est la vérification à mener chaque fois qu'une première ou une dernière période
paraît faible.

```{python}
print("premier depistage :", muac["screening_date"].min().date())
print("dernier depistage :", muac["screening_date"].max().date())
```

## Le volume par commune

```{python}
par_commune = (
    muac.groupby("commune")
    .size()
    .sort_values(ascending=False)
    .rename("depistages")
    .to_frame()
)
par_commune["part"] = (par_commune["depistages"] / len(muac)).round(3)
par_commune
```

Gonaïves et Saint-Marc représentent une large part du registre. Ce n'est pas en
soi un résultat — ce sont les communes les plus peuplées — mais cela signifie
qu'un taux départemental non pondéré est dominé par elles, et qu'une commune à
189 dépistages porte un intervalle assez large pour rendre son rang à peu près
dépourvu de sens.

## Où la campagne a été interrompue

Le motif utile n'est pas le total : c'est le mois où l'activité d'une commune
s'écarte de sa propre norme.

```{python}
grille = (
    muac.pivot_table(
        index="commune", columns="mois", values="child_id", aggfunc="count"
    )
    .fillna(0)
    .astype(int)
)

# Chaque commune rapportée à sa propre médiane mensuelle, pour qu'une grande et
# une petite commune restent comparables.
mediane = grille.median(axis=1)
relatif = grille.div(mediane, axis=0).round(2)
relatif
```

```{python}
# Empiler d'abord, filtrer ensuite. Filtrer le tableau puis empiler laisse les
# cellules NaN produites par le masque, que pandas conserve désormais : vous
# obtenez un résultat contenant tous les couples commune-mois, sans erreur pour
# vous prevenir.
plat = relatif.stack()
creux = plat[plat < 0.5].sort_values()

print(f"{len(creux)} couples commune-mois sous la moitie de la mediane")
creux
```

La quasi-totalité correspond à décembre et à janvier, soit de nouveau l'artefact
de fenêtre de rapportage et non une interruption. Le seul qui n'en relève pas —
Gros-Morne en janvier — correspond à une campagne démarrée tardivement dans cette
commune, et c'est la seule entrée sur laquelle il vaut la peine de poser une
question.

## Le problème qui ne relève pas du volume

Le volume de dépistage s'est maintenu en juin. Ce qui ne s'est pas maintenu,
c'est la complétude — et un décompte de lignes ne vous le montrera jamais,
puisque les lignes sont bien là.

```{python}
juin = muac[muac["mois"] == pd.Period("2024-06")]

completude = (
    juin.assign(age_manquant=juin["age_months"].isna())
    .groupby("commune")["age_manquant"]
    .agg(lignes="size", taux_age_manquant="mean")
    .sort_values("taux_age_manquant", ascending=False)
    .round(3)
)
completude
```

Le mois de juin d'une commune est bien plus dégradé que celui des autres.
Resserrez sur la semaine et la cause devient évidente :

```{python}
pire = completude.index[0]

hebdo = (
    muac[muac["commune"] == pire]
    .assign(semaine=lambda d: d["screening_date"].dt.to_period("W"))
    .groupby("semaine")["age_months"]
    .agg(lignes="size", taux_age_manquant=lambda s: s.isna().mean())
    .sort_values("taux_age_manquant", ascending=False)
    .round(3)
)
hebdo.head()
```

Une équipe, une semaine, un formulaire de tablette dont le champ âge était mal
configuré. Les dépistages ont bien eu lieu et les enfants ont bien été mesurés ;
seul l'âge manque.

## Pourquoi cela compte pour le tableau de prévalence

Si vous supprimez les lignes incomplètes avant de calculer la prévalence, vous
retirez cette commune bien plus que toute autre — puis vous classez les communes
en partie selon le formulaire de quelle équipe était défectueux.

```{python}
MAG_MM = 125

erreur_unite = muac["muac_mm"].notna() & (muac["muac_mm"] < 40)
muac.loc[erreur_unite, "muac_mm"] = muac.loc[erreur_unite, "muac_mm"] * 10

mesures = muac[muac["muac_mm"].notna()]
complet = mesures[mesures["age_months"].notna()]

comparaison = pd.DataFrame({
    "en_conservant": mesures.groupby("commune")["muac_mm"].apply(
        lambda s: (s < MAG_MM).mean()
    ),
    "en_supprimant": complet.groupby("commune")["muac_mm"].apply(
        lambda s: (s < MAG_MM).mean()
    ),
})
comparaison["ecart"] = (
    comparaison["en_supprimant"] - comparaison["en_conservant"]
)
comparaison.sort_values("ecart", key=abs, ascending=False).round(4)
```

Les seuils de PB pour les 6 à 59 mois forment une bande unique et n'exigent pas
l'âge : la bonne décision est donc de conserver ces lignes pour l'indicateur PB
et de ne les exclure que des analyses qui réclament réellement l'âge. Cette
décision n'est disponible que parce que vous avez regardé.

## La représentation

```{python}
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 5))

for commune in grille.index:
    ax.plot(
        range(len(grille.columns)),
        grille.loc[commune],
        marker="o",
        markersize=3,
        linewidth=1,
        color="#9AA8A3",
        alpha=0.7,
    )

ax.plot(
    range(len(grille.columns)),
    grille.loc[pire],
    marker="o",
    markersize=4,
    linewidth=2,
    color="#2F5D50",
    label=pire,
)

ax.set_xticks(range(len(grille.columns)))
ax.set_xticklabels([str(m) for m in grille.columns], rotation=45, ha="right")
ax.set_ylabel("Depistages")
ax.set_title("Depistages par commune et par mois, 2024")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)

plt.tight_layout()
plt.show()
```

## Ce qu'il faut rapporter

Énoncez le volume, énoncez la complétude séparément, et ne laissez jamais l'un
tenir lieu de l'autre. Une campagne ayant dépisté le nombre d'enfants visé avec
un champ âge défectueux présente un problème de volume nul et un problème de
données qui modifie le classement — et un seul des deux est visible dans un
décompte de lignes.
