---
title: "Malnutrition aiguë globale par commune"
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

Les prévalences de MAG et de MAS par commune, avec intervalles de confiance à
95 %, à partir d'un registre de dépistage de masse communautaire. Les valeurs de
référence figurent dans les notes de qualité du jeu de données : une MAG globale
proche de 8,6 % et une MAS proche de 2,2 %, avec une amplitude d'environ 5 % à
15 % selon les communes. Si vos chiffres s'en écartent nettement, vous avez un
bogue et non un résultat.

Tous les jeux de données de cette plateforme sont synthétiques. Rien ici ne
décrit un enfant réel, et ces chiffres ne doivent jamais être cités comme des
prévalences réelles.

## Mise en place

Le téléchargement rend ce document exécutable dans Colab, où il n'existe aucun
fichier local. En local, sur votre propre copie, c'est le même code avec un
chemin différent.

```{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")

print(muac.shape)
muac.head()
```

Deux arguments font ici tout le travail. Le `na_values` restreint à `muac_mm`
empêche la valeur sentinelle `-99` d'entrer dans la moyenne : sans lui, le PB
moyen ressort inférieur de plusieurs millimètres, et rien dans le résultat n'a
l'air anormal. Le `dtype` conserve les identifiants sous forme de texte, ce qui
devient déterminant dès la première jointure avec un autre fichier.

## Corriger ce qui fausserait l'indicateur

Deux défauts de ce registre changent la réponse. Sept enregistrements sont
restés en centimètres sans jamais être convertis ; les plages plausibles en
millimètres et en centimètres ne se chevauchant pas, la correction est sans
ambiguïté. Deux communes ont consigné les œdèmes en `Y`/`N` au premier trimestre
plutôt qu'en `true`/`false`.

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

implausible = muac["muac_mm"].notna() & (
    (muac["muac_mm"] < 80) | (muac["muac_mm"] > 220)
)
muac.loc[implausible, "muac_mm"] = np.nan

table_oedeme = {
    "true": True, "TRUE": True, "Y": True, "y": True, "yes": True,
    "false": False, "FALSE": False, "N": False, "n": False, "no": False,
}
muac["oedema"] = muac["oedema"].astype("string").str.strip().map(table_oedeme)

print(f"erreurs d'unite corrigees : {int(erreur_unite.sum())}")
print(f"implausibles en manquant  : {int(implausible.sum())}")
print(f"oedemes encore manquants  : {int(muac['oedema'].isna().sum())}")
```

## Définir l'indicateur avant de le calculer

- **Numérateur** — enfants dont le PB est inférieur à 125 mm, ou porteurs
  d'œdèmes bilatéraux prenant le godet.
- **Dénominateur** — enfants disposant d'une mesure de PB valide **ou** d'une
  évaluation des œdèmes consignée.
- **Désagrégation** — commune.

Les œdèmes signent une malnutrition aiguë sévère quelle que soit la mesure :
filtrer sur le seul `muac_mm` sous-estime donc la charge de cas, et la
sous-estime précisément parmi les cas les plus graves.

```{python}
MAS_MM, MAG_MM = 115, 125

muac["evalue"] = muac["muac_mm"].notna() | muac["oedema"].notna()

muac["mas"] = np.where(
    ~muac["evalue"],
    np.nan,
    ((muac["muac_mm"] < MAS_MM) | (muac["oedema"] == True)).astype(float),
)
muac["mag"] = np.where(
    ~muac["evalue"],
    np.nan,
    ((muac["muac_mm"] < MAG_MM) | (muac["oedema"] == True)).astype(float),
)
```

## Le tableau d'indicateurs

Les colonnes `depistes` et `denominateur` sont distinctes à dessein : elles
diffèrent des lignes sans aucune évaluation, et un lecteur qui voit les deux peut
juger sur quelle part du registre repose le taux.

```{python}
tableau = (
    muac.groupby("commune", dropna=False)
    .agg(
        depistes=("child_id", "size"),
        denominateur=("evalue", "sum"),
        cas_mas=("mas", "sum"),
        cas_mag=("mag", "sum"),
    )
    .reset_index()
)

tableau["taux_mag"] = tableau["cas_mag"] / tableau["denominateur"]
tableau["taux_mas"] = tableau["cas_mas"] / tableau["denominateur"]

tableau = tableau.sort_values("taux_mag", ascending=False)
tableau.round(4)
```

## Énoncer son degré d'incertitude

Un taux calculé sur 340 enfants n'est pas la même affirmation qu'un taux calculé
sur 40. L'intervalle de Clopper-Pearson est exact pour une proportion et ne se
dérègle pas sur de petits effectifs, ce qui importe pour les communes les moins
peuplées.

```{python}
from scipy.stats import beta

def clopper_pearson(succes, n, confiance=0.95):
    if n == 0:
        return (np.nan, np.nan)
    alpha = 1 - confiance
    bas = beta.ppf(alpha / 2, succes, n - succes + 1) if succes > 0 else 0.0
    haut = beta.ppf(1 - alpha / 2, succes + 1, n - succes) if succes < n else 1.0
    return (bas, haut)

bornes = tableau.apply(
    lambda r: clopper_pearson(r["cas_mag"], r["denominateur"]), axis=1
)
tableau["mag_bas"] = [b[0] for b in bornes]
tableau["mag_haut"] = [b[1] for b in bornes]

tableau[["commune", "denominateur", "taux_mag", "mag_bas", "mag_haut"]].round(4)
```

## Représenter le classement avec son incertitude

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

SEUIL_URGENCE = 0.15

fig, ax = plt.subplots(figsize=(8, 5))
ordre = tableau.sort_values("taux_mag")

ax.errorbar(
    ordre["taux_mag"] * 100,
    range(len(ordre)),
    xerr=[
        (ordre["taux_mag"] - ordre["mag_bas"]) * 100,
        (ordre["mag_haut"] - ordre["taux_mag"]) * 100,
    ],
    fmt="o",
    color="#2F5D50",
    ecolor="#9AA8A3",
    capsize=3,
)

ax.axvline(SEUIL_URGENCE * 100, color="#B5533C", linestyle="--", linewidth=1)
ax.text(
    SEUIL_URGENCE * 100 + 0.3, 0.2,
    "seuil d'urgence (15%)", color="#B5533C", fontsize=9,
)

ax.set_yticks(range(len(ordre)))
ax.set_yticklabels(ordre["commune"])
ax.set_xlabel("Prevalence de la MAG par PB (%)")
ax.set_title("Malnutrition aigue globale par commune, avec IC 95%")
ax.spines[["top", "right"]].set_visible(False)

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

## Lire le résultat

Plusieurs intervalles se recouvrent. L'ordre entre ces communes n'est donc pas
soutenu par les données, et une décision qui attribuerait un site PCIMA
supplémentaire au seul rang lirait une précision que le dépistage ne possède
pas.

Notez aussi ce que l'intervalle ne couvre pas. Il s'agit d'un recensement des
enfants venus au dépistage et non d'un échantillon probabiliste : l'intervalle ne
décrit donc que la variation d'échantillonnage. Savoir si ces enfants
ressemblent à ceux qui ne sont pas venus constitue généralement la plus grande
source d'erreur, et cela relève de la section des limites de tout rapport
construit sur ces données.
