---
title: "Rapport de plausibilité SMART"
subtitle: "Enquête nutritionnelle SMART, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## À quoi sert un rapport de plausibilité

Avant d'utiliser le chiffre de prévalence d'une enquête SMART, on évalue
l'enquête elle-même : les mesures ont-elles été correctement prises, les enfants
correctement échantillonnés, et la distribution obtenue a-t-elle la forme d'une
population réelle ? Une enquête qui échoue à ces contrôles ne reçoit pas une
réserve : elle est rejetée, ou refaite.

Ce rapport reproduit les contrôles standards et se termine par un verdict.

Tous les jeux de données de cette plateforme sont synthétiques. Aucun enfant réel
n'est décrit.

## Mise en place

```{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"{len(smart)} enfants, {smart['cluster'].nunique()} grappes, "
      f"{smart['team'].nunique()} equipes")
```

## Contrôle 1 : complétude et valeurs impossibles

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

pd.DataFrame({
    "count": [
        len(smart),
        int(smart["age_months"].isna().sum()),
        int(smart["weight_kg"].isna().sum()),
        int(impossible.sum()),
    ]
}, index=["enfants", "age manquant", "poids manquant", "mesure impossible"])
```

Quatorze mesures impossibles sur 930, soit 1,5 % — acceptable pour une enquête de
terrain, et elles sont exclues plutôt que corrigées car on ne peut pas savoir ce
que l'enquêteur avait en tête.

## Contrôle 2 : les scores z, pour que les contrôles suivants aient prise

```{python}
plausible = smart[~impossible.fillna(False)].copy()
plausible["standard"] = np.where(plausible["age_months"] < 24, "L", "H")

lying_should_stand = (plausible["standard"] == "H") & plausible["measured_lying"]
stand_should_lie = (plausible["standard"] == "L") & ~plausible["measured_lying"]

plausible["lenhei"] = plausible["height_cm"]
plausible.loc[lying_should_stand, "lenhei"] -= 0.7
plausible.loc[stand_should_lie, "lenhei"] += 0.7
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"])

raw_z = ((scored["weight_kg"] / scored["m"]) ** scored["l"] - 1) / (scored["l"] * scored["s"])

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(r, z) for r, z in zip(scored.to_dict("records"), raw_z)]
```

## Contrôle 3 : enregistrements exclus

```{python}
mean_z, sd_z = scored["whz"].mean(), scored["whz"].std()

flags = pd.DataFrame({
    "exclus": [
        int((scored["whz"].abs() > 5).sum()),
        int(((scored["whz"] - mean_z).abs() > 3 * sd_z).sum()),
    ],
    "part": [
        (scored["whz"].abs() > 5).mean(),
        ((scored["whz"] - mean_z).abs() > 3 * sd_z).mean(),
    ],
}, index=["OMS (bornes fixes -5 a +5)", "SMART (3 ET de la moyenne)"]).round(4)
flags
```

SMART considère qu'au-delà de 2,5 % d'exclusions il y a problème, et au-delà de
5 % motif de rejet. Les deux règles passent ici largement.

## Contrôle 4 : l'écart type du score z

C'est le chiffre le plus informatif du rapport. SMART attend l'écart type du
score z poids-taille entre 0,8 et 1,2. Une population réelle présente une
dispersion proche de 1 ; l'erreur de mesure l'élargit.

```{python}
analysable = scored[(scored["whz"].abs() <= 5) & scored["whz"].notna()]

print(f"score z poids-taille moyen : {analysable['whz'].mean():.3f}")
print(f"ecart type                 : {analysable['whz'].std():.3f}")
```

Au sommet de la plage acceptable. C'est un avertissement et non un échec, et les
deux contrôles suivants en localisent la source.

## Contrôle 5 : préférence de chiffres

Un enquêteur qui lit une toise sous pression arrondit. Une équipe dont les mesures
s'accumulent sur `.0` et `.5` ne mesure pas au millimètre qu'elle consigne.

```{python}
scored["last_digit"] = ((scored["height_cm"] * 10).round() % 10).astype("Int64")

digits = pd.crosstab(scored["team"], scored["last_digit"], normalize="index") * 100
digits.round(1)
```

```{python}
rounded = digits[[0, 5]].sum(axis=1).round(1)
rounded.name = "% finissant par .0 ou .5"
rounded.to_frame().assign(attendu=20.0)
```

L'équipe 2 consigne environ 69 % de ses tailles sur un centimètre entier ou un
demi-centimètre, contre 17 à 23 % pour les autres équipes. Avec dix derniers
chiffres possibles, 20 % est ce que produit une équipe sans biais. Voilà la source
de l'écart type élevé, et c'est un problème de formation avec un nom dessus.

## Contrôle 6 : entassement des âges

Les âges déclarés par les accompagnants plutôt que par des documents s'entassent
sur les années entières. Cela compte, car l'âge détermine la norme de croissance
applicable — la règle longueur/taille bascule à exactement 24 mois.

```{python}
ages = scored["age_months"].dropna()
whole_years = ages.isin([12, 24, 36, 48, 60])

print(f"enfants a une annee entiere exacte : {int(whole_years.sum())} ({whole_years.mean():.1%})")

ages.value_counts().reindex([23, 24, 25, 35, 36, 37, 47, 48, 49]).to_frame("enfants")
```

Soixante-six enfants consignés à exactement 24 mois contre 15 et 19 de part et
d'autre ; quatre-vingts à 36 mois contre 28 et 17. Ce n'est pas un profil de
naissances, c'est un arrondi. Comme 24 mois constitue la frontière entre les
normes longueur et taille, une partie de ces enfants est évaluée au regard de la
mauvaise référence.

## Contrôle 7 : rapport de masculinité

```{python}
counts = scored["sex"].value_counts()
ratio = counts.get("m", 0) / counts.get("f", 1)

chi_square = (counts.get("m", 0) - len(scored) / 2) ** 2 / (len(scored) / 4)

print(f"garcons : {counts.get('m', 0)}   filles : {counts.get('f', 0)}")
print(f"rapport : {ratio:.3f}   khi-deux contre 1:1 : {chi_square:.2f}")
```

Un rapport proche de 1,0 et un khi-deux nettement sous 3,84 — aucun indice qu'un
sexe ait été échantillonné ou omis préférentiellement.

## Contrôle 8 : biais entre équipes

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

Le score z moyen de l'équipe 3 est de -1,10 contre -0,43 à -0,69 pour les autres,
et sa MAG ressort proche de 22 % contre 10 à 16 %. Les grappes ont été attribuées
aux équipes indépendamment de l'état nutritionnel : une différence réelle d'un
demi-score z entre équipes n'est donc pas une lecture plausible. L'équipe 3
mesure long, léger, ou les deux.

```{python}
spread = by_team["mean_z"].max() - by_team["mean_z"].min()
print(f"amplitude du score z moyen entre equipes : {spread:.2f}")
print("SMART considere une amplitude au-dela de ~0,3 z comme un probleme de supervision.")
```

## Le verdict

```{python}
verdict = pd.DataFrame([
    ("Mesures impossibles", "1,5% exclues", "conforme"),
    ("Enregistrements exclus", f"{flags.loc['SMART (3 ET de la moyenne)', 'part']:.1%} SMART", "conforme"),
    ("Ecart type du score z", f"{analysable['whz'].std():.2f}", "avertissement"),
    ("Preference de chiffres", "equipe 2 a 69% sur .0/.5", "echec"),
    ("Entassement des ages", f"{whole_years.mean():.0%} sur annees entieres", "avertissement"),
    ("Rapport de masculinite", f"{ratio:.2f}", "conforme"),
    ("Biais entre equipes", f"amplitude {spread:.2f} z", "echec"),
], columns=["controle", "valeur", "resultat"])
verdict
```

**Cette enquête serait-elle acceptée ?** Pas en l'état. Le chiffre de prévalence
est calculable et l'échantillonnage paraît solide, mais deux contrôles échouent
pour une même cause racine : une équipe a mesuré différemment des autres, et une
seconde a arrondi ses tailles. Ce sont des problèmes de supervision et de
formation, et tous deux gonflent la dispersion sur laquelle repose l'estimation
de prévalence.

L'action défendable n'est pas de publier 14,9 % avec une note de bas de page.
C'est de refaire les mesures des grappes de l'équipe 3 si l'enquête est encore
sur le terrain, ou de publier en plaçant la comparaison entre équipes dans le
corps du rapport plutôt qu'en annexe — afin que le lecteur voie qu'un sixième de
l'échantillon a été mesuré par quelqu'un dont les résultats ne concordent avec
ceux de personne.

## Ce qu'il faut rapporter

Chaque contrôle avec sa valeur et son seuil, le verdict, et l'action. Une annexe
de plausibilité qui ne rapporte que les contrôles réussis n'est pas un rapport de
plausibilité.
