---
title: "Calculer la prévalence avec les normes de croissance de l'OMS"
subtitle: "Enquête nutritionnelle SMART, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## Ce que produit ce document

Les scores z poids-taille au regard des normes de croissance OMS 2006, calculés à
partir de la référence LMS plutôt que lus dans un paquet — car les trois
décisions qui déplacent la réponse se situent toutes en dehors de l'appel au
paquet.

Les scores z ne sont délibérément pas livrés dans ce jeu de données. Les calculer
est l'exercice.

Tous les jeux de données de cette plateforme sont synthétiques. Aucun enfant réel
n'est décrit, et ces résultats ne doivent pas être cités comme une situation
nutritionnelle réelle.

## La table de référence

R dispose du paquet officiel `anthro` de l'OMS. Les options sont plus minces côté
Python : cet exemple lit donc directement les tables LMS poids-longueur et
poids-taille de l'OMS 2006. Elles sont livrées avec la plateforme, et leurs
valeurs sont celles qu'emploie le paquet de l'OMS — la version R de cet exemple
produit une prévalence identique à la première décimale.

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

BASE = "https://data-analysis.cassion.dev/datasets/"

smart = pd.read_csv(BASE + "files/smart-nutrition-survey-2024.v1.csv",
                    dtype={"child_id": "string", "sex": "string"})
reference = pd.read_csv(BASE + "reference/who-2006-weight-for-lenhei.csv")

print(f"enquete : {len(smart)} enfants")
print(f"reference : {len(reference)} lignes")
reference.head()
```

`lorh` désigne la norme à laquelle la ligne appartient : `L` pour le
poids-longueur, employé avant deux ans, et `H` pour le poids-taille au-delà. `l`,
`m` et `s` sont les paramètres LMS à cette longueur ou cette taille.

## Contrôler les plages avant toute chose

Quatorze enregistrements portent des mesures impossibles — des poids erronés d'un
facteur dix dans les deux sens, et des tailles saisies en mètres. Ce sont des
erreurs de saisie et non des valeurs aberrantes statistiques : elles doivent
disparaître avant toute règle d'exclusion, car un seul enfant à 114 kg déplace la
moyenne de l'enquête au regard de laquelle se mesure le flag SMART.

```{python}
impossible = (
    (smart["weight_kg"] < 2) | (smart["weight_kg"] > 30)
    | (smart["height_cm"] < 45) | (smart["height_cm"] > 130)
)
smart.loc[impossible, ["child_id", "team", "weight_kg", "height_cm"]]
```

```{python}
plausible = smart[~impossible.fillna(False)].copy()
print(f"conserves : {len(plausible)} sur {len(smart)}")
print(f"age manquant : {int(plausible['age_months'].isna().sum())}, "
      f"poids manquant : {int(plausible['weight_kg'].isna().sum())}")
```

## La position de mesure, qui est une règle sur l'âge

C'est l'étape que l'on saute. La norme de l'OMS n'est pas « employer la position
utilisée » : c'est **la longueur avant 24 mois, la taille à partir de 24 mois**.
Lorsque la position consignée diffère de la règle, la mesure est convertie
d'environ 0,7 cm, soit l'écart systématique entre longueur en décubitus et taille
debout pour un même enfant.

```{python}
plausible["standard"] = np.where(plausible["age_months"] < 24, "L", "H")

should_be_length = (plausible["standard"] == "L") & ~plausible["measured_lying"]
should_be_height = (plausible["standard"] == "H") & plausible["measured_lying"]

print(f"mesures debout, devraient etre en longueur : {int(should_be_length.sum())}")
print(f"mesures couches, devraient etre en taille : {int(should_be_height.sum())}")

plausible["lenhei"] = plausible["height_cm"]
plausible.loc[should_be_length, "lenhei"] = plausible.loc[should_be_length, "height_cm"] + 0.7
plausible.loc[should_be_height, "lenhei"] = plausible.loc[should_be_height, "height_cm"] - 0.7
```

Sautez cette étape et vous biaisez tous les scores z de la moitié la plus jeune
de l'échantillon, dans une direction qui dépend de la façon dont les équipes ont
travaillé.

## Le calcul LMS

```{python}
plausible["lenhei_key"] = (plausible["lenhei"] * 10).round() / 10

lms = reference.set_index(["sex", "lorh", "lenhei"])[["l", "m", "s"]]
scored = plausible.join(lms, on=["sex", "standard", "lenhei_key"])

# The LMS transform: z = ((weight/M)^L - 1) / (L * S)
raw_z = ((scored["weight_kg"] / scored["m"]) ** scored["l"] - 1) / (
    scored["l"] * scored["s"]
)
```

Au-delà de ±3, la courbe LMS est extrapolée : l'OMS la remplace donc par une
extension linéaire ancrée sur la distance entre les 2e et 3e écarts types. Sans
cela, les enfants extrêmes reçoivent des scores z trop extrêmes, et une
prévalence de MAS bâtie sur eux est surestimée.

```{python}
def sd_at(row, n):
    return row["m"] * (1 + row["l"] * row["s"] * n) ** (1 / row["l"])

def who_adjust(row, z):
    if pd.isna(z):
        return z
    if z > 3:
        sd3, sd2 = sd_at(row, 3), sd_at(row, 2)
        return 3 + (row["weight_kg"] - sd3) / (sd3 - sd2)
    if z < -3:
        sd3, sd2 = sd_at(row, -3), sd_at(row, -2)
        return -3 + (row["weight_kg"] - sd3) / (sd2 - sd3)
    return z

scored["whz"] = [
    who_adjust(row, z) for row, z in zip(scored.to_dict("records"), raw_z)
]
scored["whz"].describe().round(3)
```

## Deux règles d'exclusion

Les flags OMS sont des bornes fixes : un score z poids-taille hors de -5 à +5 est
biologiquement implausible. Les flags SMART sont relatifs — au-delà de 3 écarts
types de la moyenne de *l'enquête*. Ils excluent des enfants différents, et un
rapport de plausibilité énonce lequel a été employé.

```{python}
scored["who_flag"] = scored["whz"].abs() > 5

mean_z, sd_z = scored["whz"].mean(), scored["whz"].std()
scored["smart_flag"] = (scored["whz"] - mean_z).abs() > 3 * sd_z

print(f"exclus OMS   : {int(scored['who_flag'].sum())}")
print(f"exclus SMART : {int(scored['smart_flag'].sum())}")
```

La règle SMART se rapporte à une moyenne que les observations exclues influencent
elles-mêmes, ce qui explique que le contrôle de plage doive venir en premier.

## La prévalence

**Les œdèmes priment sur l'anthropométrie.** Un enfant porteur d'œdèmes
bilatéraux prenant le godet est en malnutrition aiguë sévère quel que soit son
rapport poids-taille : le numérateur de la MAS n'est donc pas le simple décompte
des scores inférieurs à -3.

```{python}
analysable = scored[~scored["who_flag"] & scored["whz"].notna()]

gam = ((analysable["whz"] < -2) | analysable["oedema"]).mean()
sam = ((analysable["whz"] < -3) | analysable["oedema"]).mean()

print(f"enfants analysables : {len(analysable)}")
print(f"GAM: {gam:.1%}")
print(f"SAM: {sam:.1%}")
print(f"score z moyen : {analysable['whz'].mean():.2f}   ET : {analysable['whz'].std():.2f}")
```

Une malnutrition aiguë globale proche de 14,9 %, une forme sévère proche de
3,9 %. On se situe juste sous le seuil d'urgence de 15 % de l'OMS — précisément
là où les décisions ci-dessus cessent d'être théoriques, car un ajustement de
position omis ou une autre règle d'exclusion font franchir la ligne au chiffre.

L'écart type du score z est un signal de qualité à part entière. SMART l'attend
entre 0,8 et 1,2 environ ; au-delà, cela suggère une erreur de mesure qui gonfle
la dispersion. Cette enquête se situe au sommet de cette plage, pour une raison
que la section suivante identifie.

## L'effet équipe, qui n'est pas un constat nutritionnel

```{python}
by_team = analysable.groupby("team").agg(
    children=("whz", "size"),
    mean_z=("whz", "mean"),
)
by_team["gam"] = analysable.groupby("team").apply(
    lambda g: ((g["whz"] < -2) | g["oedema"]).mean(), include_groups=False
)
by_team.round(3)
```

L'équipe 3 rapporte une MAG proche de 22 % contre 10 à 16 % pour les autres, avec
un score z moyen de -1,10 contre -0,43 à -0,69. Une différence réelle de cette
ampleur entre des grappes attribuées aléatoirement serait extraordinaire. Il
s'agit d'un artefact de mesure, et le rapporter comme un constat géographique
orienterait des ressources vers les mauvaises grappes.

## L'effet de plan

C'est un échantillon en grappes : les enfants d'une même grappe se ressemblent, et
la taille d'échantillon effective est donc inférieure au nombre d'enfants.

```{python}
analysable = analysable.assign(case=(analysable["whz"] < -2) | analysable["oedema"])

clusters = analysable.groupby("cluster").agg(m=("case", "size"), y=("case", "sum"))
k, M = len(clusters), clusters["m"].sum()
p_bar = clusters["y"].sum() / M

# Ultimate-cluster variance of a ratio estimator: deviation of each cluster's
# case count from what the overall rate predicts for its size.
var_cluster = (k / ((k - 1) * M**2)) * ((clusters["y"] - p_bar * clusters["m"]) ** 2).sum()
var_srs = p_bar * (1 - p_bar) / M

deff = var_cluster / var_srs
icc = (deff - 1) / (clusters["m"].mean() - 1)

print(f"grappes : {k}   enfants : {M}   taille moyenne : {clusters['m'].mean():.1f}")
print(f"effet de plan : {deff:.2f}   ICC : {icc:.3f}")
print(f"taille effective : {M / deff:.0f} sur {M}")
```

Un effet de plan proche de 2,3 et un ICC autour de 0,05 sont ordinaires pour une
enquête nutritionnelle en grappes. Un effet de plan inférieur à 1 ou supérieur à
4 environ signale généralement un calcul erroné plutôt qu'une enquête
inhabituelle — vérifiez votre propre arithmétique avant de la rapporter.

```{python}
se_cluster, se_srs = np.sqrt(var_cluster), np.sqrt(var_srs)

print(f"GAM {p_bar:.1%}  (95% CI {p_bar - 1.96*se_cluster:.1%} - {p_bar + 1.96*se_cluster:.1%})  avec les grappes")
print(f"          (95% CI {p_bar - 1.96*se_srs:.1%} - {p_bar + 1.96*se_srs:.1%})  en ignorant les grappes")
```

L'intervalle correct est plus large, et sa borne supérieure franchit 15 %.
L'estimation ponctuelle se situe sous le seuil d'urgence de l'OMS ; l'intervalle
n'exclut pas d'être au-dessus. C'est cette phrase-là que le rapport doit porter,
et non un « 14,9 %, sous le seuil » sec.

## Ce qu'il faut rapporter

La prévalence avec son intervalle et l'effet de plan employé, la règle
d'exclusion nommée, les exclusions comptées, et la comparaison entre équipes —
car une enquête où une équipe s'écarte des autres d'un demi-score z présente un
problème de mesure qui prime sur tout chiffre de prévalence qu'elle contient.
