{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Rapport de plausibilité SMART\n",
    "\n",
    "*Enquête nutritionnelle SMART, 2024*\n",
    "\n",
    "Cassion · data-analysis.cassion.dev\n",
    "\n",
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/thecassion/cassion-learning-platform/blob/main/apps/data-analysis/public/datasets/examples/smart-nutrition-survey-2024/plausibility.python.fr.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## À quoi sert un rapport de plausibilité\n",
    "\n",
    "Avant d'utiliser le chiffre de prévalence d'une enquête SMART, on évalue\n",
    "l'enquête elle-même : les mesures ont-elles été correctement prises, les enfants\n",
    "correctement échantillonnés, et la distribution obtenue a-t-elle la forme d'une\n",
    "population réelle ? Une enquête qui échoue à ces contrôles ne reçoit pas une\n",
    "réserve : elle est rejetée, ou refaite.\n",
    "\n",
    "Ce rapport reproduit les contrôles standards et se termine par un verdict.\n",
    "\n",
    "Tous les jeux de données de cette plateforme sont synthétiques. Aucun enfant réel\n",
    "n'est décrit.\n",
    "\n",
    "## Mise en place"
   ],
   "id": "cell-001"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "BASE = \"https://data-analysis.cassion.dev/datasets/\"\n",
    "\n",
    "smart = pd.read_csv(BASE + \"files/smart-nutrition-survey-2024.v1.csv\",\n",
    "                    dtype={\"child_id\": \"string\", \"sex\": \"string\"})\n",
    "reference = pd.read_csv(BASE + \"reference/who-2006-weight-for-lenhei.csv\")\n",
    "\n",
    "print(f\"{len(smart)} enfants, {smart['cluster'].nunique()} grappes, \"\n",
    "      f\"{smart['team'].nunique()} equipes\")"
   ],
   "id": "cell-002"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Contrôle 1 : complétude et valeurs impossibles"
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "impossible = (\n",
    "    (smart[\"weight_kg\"] < 2) | (smart[\"weight_kg\"] > 30)\n",
    "    | (smart[\"height_cm\"] < 45) | (smart[\"height_cm\"] > 130)\n",
    ")\n",
    "\n",
    "pd.DataFrame({\n",
    "    \"count\": [\n",
    "        len(smart),\n",
    "        int(smart[\"age_months\"].isna().sum()),\n",
    "        int(smart[\"weight_kg\"].isna().sum()),\n",
    "        int(impossible.sum()),\n",
    "    ]\n",
    "}, index=[\"enfants\", \"age manquant\", \"poids manquant\", \"mesure impossible\"])"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Quatorze mesures impossibles sur 930, soit 1,5 % — acceptable pour une enquête de\n",
    "terrain, et elles sont exclues plutôt que corrigées car on ne peut pas savoir ce\n",
    "que l'enquêteur avait en tête.\n",
    "\n",
    "## Contrôle 2 : les scores z, pour que les contrôles suivants aient prise"
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "plausible = smart[~impossible.fillna(False)].copy()\n",
    "plausible[\"standard\"] = np.where(plausible[\"age_months\"] < 24, \"L\", \"H\")\n",
    "\n",
    "lying_should_stand = (plausible[\"standard\"] == \"H\") & plausible[\"measured_lying\"]\n",
    "stand_should_lie = (plausible[\"standard\"] == \"L\") & ~plausible[\"measured_lying\"]\n",
    "\n",
    "plausible[\"lenhei\"] = plausible[\"height_cm\"]\n",
    "plausible.loc[lying_should_stand, \"lenhei\"] -= 0.7\n",
    "plausible.loc[stand_should_lie, \"lenhei\"] += 0.7\n",
    "plausible[\"lenhei_key\"] = (plausible[\"lenhei\"] * 10).round() / 10\n",
    "\n",
    "lms = reference.set_index([\"sex\", \"lorh\", \"lenhei\"])[[\"l\", \"m\", \"s\"]]\n",
    "scored = plausible.join(lms, on=[\"sex\", \"standard\", \"lenhei_key\"])\n",
    "\n",
    "raw_z = ((scored[\"weight_kg\"] / scored[\"m\"]) ** scored[\"l\"] - 1) / (scored[\"l\"] * scored[\"s\"])\n",
    "\n",
    "def sd_at(row, n):\n",
    "    return row[\"m\"] * (1 + row[\"l\"] * row[\"s\"] * n) ** (1 / row[\"l\"])\n",
    "\n",
    "def who_adjust(row, z):\n",
    "    if pd.isna(z):\n",
    "        return z\n",
    "    if z > 3:\n",
    "        sd3, sd2 = sd_at(row, 3), sd_at(row, 2)\n",
    "        return 3 + (row[\"weight_kg\"] - sd3) / (sd3 - sd2)\n",
    "    if z < -3:\n",
    "        sd3, sd2 = sd_at(row, -3), sd_at(row, -2)\n",
    "        return -3 + (row[\"weight_kg\"] - sd3) / (sd2 - sd3)\n",
    "    return z\n",
    "\n",
    "scored[\"whz\"] = [who_adjust(r, z) for r, z in zip(scored.to_dict(\"records\"), raw_z)]"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Contrôle 3 : enregistrements exclus"
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mean_z, sd_z = scored[\"whz\"].mean(), scored[\"whz\"].std()\n",
    "\n",
    "flags = pd.DataFrame({\n",
    "    \"exclus\": [\n",
    "        int((scored[\"whz\"].abs() > 5).sum()),\n",
    "        int(((scored[\"whz\"] - mean_z).abs() > 3 * sd_z).sum()),\n",
    "    ],\n",
    "    \"part\": [\n",
    "        (scored[\"whz\"].abs() > 5).mean(),\n",
    "        ((scored[\"whz\"] - mean_z).abs() > 3 * sd_z).mean(),\n",
    "    ],\n",
    "}, index=[\"OMS (bornes fixes -5 a +5)\", \"SMART (3 ET de la moyenne)\"]).round(4)\n",
    "flags"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "SMART considère qu'au-delà de 2,5 % d'exclusions il y a problème, et au-delà de\n",
    "5 % motif de rejet. Les deux règles passent ici largement.\n",
    "\n",
    "## Contrôle 4 : l'écart type du score z\n",
    "\n",
    "C'est le chiffre le plus informatif du rapport. SMART attend l'écart type du\n",
    "score z poids-taille entre 0,8 et 1,2. Une population réelle présente une\n",
    "dispersion proche de 1 ; l'erreur de mesure l'élargit."
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "analysable = scored[(scored[\"whz\"].abs() <= 5) & scored[\"whz\"].notna()]\n",
    "\n",
    "print(f\"score z poids-taille moyen : {analysable['whz'].mean():.3f}\")\n",
    "print(f\"ecart type                 : {analysable['whz'].std():.3f}\")"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Au sommet de la plage acceptable. C'est un avertissement et non un échec, et les\n",
    "deux contrôles suivants en localisent la source.\n",
    "\n",
    "## Contrôle 5 : préférence de chiffres\n",
    "\n",
    "Un enquêteur qui lit une toise sous pression arrondit. Une équipe dont les mesures\n",
    "s'accumulent sur `.0` et `.5` ne mesure pas au millimètre qu'elle consigne."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "scored[\"last_digit\"] = ((scored[\"height_cm\"] * 10).round() % 10).astype(\"Int64\")\n",
    "\n",
    "digits = pd.crosstab(scored[\"team\"], scored[\"last_digit\"], normalize=\"index\") * 100\n",
    "digits.round(1)"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rounded = digits[[0, 5]].sum(axis=1).round(1)\n",
    "rounded.name = \"% finissant par .0 ou .5\"\n",
    "rounded.to_frame().assign(attendu=20.0)"
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "L'équipe 2 consigne environ 69 % de ses tailles sur un centimètre entier ou un\n",
    "demi-centimètre, contre 17 à 23 % pour les autres équipes. Avec dix derniers\n",
    "chiffres possibles, 20 % est ce que produit une équipe sans biais. Voilà la source\n",
    "de l'écart type élevé, et c'est un problème de formation avec un nom dessus.\n",
    "\n",
    "## Contrôle 6 : entassement des âges\n",
    "\n",
    "Les âges déclarés par les accompagnants plutôt que par des documents s'entassent\n",
    "sur les années entières. Cela compte, car l'âge détermine la norme de croissance\n",
    "applicable — la règle longueur/taille bascule à exactement 24 mois."
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ages = scored[\"age_months\"].dropna()\n",
    "whole_years = ages.isin([12, 24, 36, 48, 60])\n",
    "\n",
    "print(f\"enfants a une annee entiere exacte : {int(whole_years.sum())} ({whole_years.mean():.1%})\")\n",
    "\n",
    "ages.value_counts().reindex([23, 24, 25, 35, 36, 37, 47, 48, 49]).to_frame(\"enfants\")"
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Soixante-six enfants consignés à exactement 24 mois contre 15 et 19 de part et\n",
    "d'autre ; quatre-vingts à 36 mois contre 28 et 17. Ce n'est pas un profil de\n",
    "naissances, c'est un arrondi. Comme 24 mois constitue la frontière entre les\n",
    "normes longueur et taille, une partie de ces enfants est évaluée au regard de la\n",
    "mauvaise référence.\n",
    "\n",
    "## Contrôle 7 : rapport de masculinité"
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "counts = scored[\"sex\"].value_counts()\n",
    "ratio = counts.get(\"m\", 0) / counts.get(\"f\", 1)\n",
    "\n",
    "chi_square = (counts.get(\"m\", 0) - len(scored) / 2) ** 2 / (len(scored) / 4)\n",
    "\n",
    "print(f\"garcons : {counts.get('m', 0)}   filles : {counts.get('f', 0)}\")\n",
    "print(f\"rapport : {ratio:.3f}   khi-deux contre 1:1 : {chi_square:.2f}\")"
   ],
   "id": "cell-017"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un rapport proche de 1,0 et un khi-deux nettement sous 3,84 — aucun indice qu'un\n",
    "sexe ait été échantillonné ou omis préférentiellement.\n",
    "\n",
    "## Contrôle 8 : biais entre équipes"
   ],
   "id": "cell-018"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "by_team = analysable.groupby(\"team\").agg(\n",
    "    children=(\"whz\", \"size\"),\n",
    "    mean_z=(\"whz\", \"mean\"),\n",
    "    sd_z=(\"whz\", \"std\"),\n",
    "    mean_height=(\"height_cm\", \"mean\"),\n",
    "    mean_weight=(\"weight_kg\", \"mean\"),\n",
    ")\n",
    "by_team[\"gam\"] = analysable.groupby(\"team\").apply(\n",
    "    lambda g: ((g[\"whz\"] < -2) | g[\"oedema\"]).mean(), include_groups=False\n",
    ")\n",
    "by_team.round(3)"
   ],
   "id": "cell-019"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Le score z moyen de l'équipe 3 est de -1,10 contre -0,43 à -0,69 pour les autres,\n",
    "et sa MAG ressort proche de 22 % contre 10 à 16 %. Les grappes ont été attribuées\n",
    "aux équipes indépendamment de l'état nutritionnel : une différence réelle d'un\n",
    "demi-score z entre équipes n'est donc pas une lecture plausible. L'équipe 3\n",
    "mesure long, léger, ou les deux."
   ],
   "id": "cell-020"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "spread = by_team[\"mean_z\"].max() - by_team[\"mean_z\"].min()\n",
    "print(f\"amplitude du score z moyen entre equipes : {spread:.2f}\")\n",
    "print(\"SMART considere une amplitude au-dela de ~0,3 z comme un probleme de supervision.\")"
   ],
   "id": "cell-021"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Le verdict"
   ],
   "id": "cell-022"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "verdict = pd.DataFrame([\n",
    "    (\"Mesures impossibles\", \"1,5% exclues\", \"conforme\"),\n",
    "    (\"Enregistrements exclus\", f\"{flags.loc['SMART (3 ET de la moyenne)', 'part']:.1%} SMART\", \"conforme\"),\n",
    "    (\"Ecart type du score z\", f\"{analysable['whz'].std():.2f}\", \"avertissement\"),\n",
    "    (\"Preference de chiffres\", \"equipe 2 a 69% sur .0/.5\", \"echec\"),\n",
    "    (\"Entassement des ages\", f\"{whole_years.mean():.0%} sur annees entieres\", \"avertissement\"),\n",
    "    (\"Rapport de masculinite\", f\"{ratio:.2f}\", \"conforme\"),\n",
    "    (\"Biais entre equipes\", f\"amplitude {spread:.2f} z\", \"echec\"),\n",
    "], columns=[\"controle\", \"valeur\", \"resultat\"])\n",
    "verdict"
   ],
   "id": "cell-023"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Cette enquête serait-elle acceptée ?** Pas en l'état. Le chiffre de prévalence\n",
    "est calculable et l'échantillonnage paraît solide, mais deux contrôles échouent\n",
    "pour une même cause racine : une équipe a mesuré différemment des autres, et une\n",
    "seconde a arrondi ses tailles. Ce sont des problèmes de supervision et de\n",
    "formation, et tous deux gonflent la dispersion sur laquelle repose l'estimation\n",
    "de prévalence.\n",
    "\n",
    "L'action défendable n'est pas de publier 14,9 % avec une note de bas de page.\n",
    "C'est de refaire les mesures des grappes de l'équipe 3 si l'enquête est encore\n",
    "sur le terrain, ou de publier en plaçant la comparaison entre équipes dans le\n",
    "corps du rapport plutôt qu'en annexe — afin que le lecteur voie qu'un sixième de\n",
    "l'échantillon a été mesuré par quelqu'un dont les résultats ne concordent avec\n",
    "ceux de personne.\n",
    "\n",
    "## Ce qu'il faut rapporter\n",
    "\n",
    "Chaque contrôle avec sa valeur et son seuil, le verdict, et l'action. Une annexe\n",
    "de plausibilité qui ne rapporte que les contrôles réussis n'est pas un rapport de\n",
    "plausibilité."
   ],
   "id": "cell-024"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
