{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Accepter, exclure ou recommencer — notebook d'analyse\n",
    "\n",
    "*Analyse d'enquête SMART et rapport de plausibilité · livrable de projet*\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/projects/smart-survey-analysis/notebooks/whz-prevalence.python.fr.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La décision que ce document produit\n",
    "\n",
    "Faut-il accepter l'enquête, exclure les données d'une équipe de mesure, ou\n",
    "recommencer la collecte — avant que l'estimation n'alimente une décision de\n",
    "montée en charge.\n",
    "\n",
    "L'estimation est revenue proche du seuil qui déclencherait une telle montée en\n",
    "charge, et les mesures d'une équipe se distinguaient des autres. C'est toute la\n",
    "question : si l'équipe a raison, la montée en charge est justifiée ; si l'équipe\n",
    "mesure mal, elle ne l'est pas.\n",
    "\n",
    "Destinataires : le cluster nutrition et l'organe national de coordination\n",
    "nutritionnelle.\n",
    "\n",
    "Tous les jeux de données de cette plateforme sont synthétiques. Aucun enfant réel\n",
    "n'y est décrit, et ces résultats ne doivent pas être cités comme une situation\n",
    "nutritionnelle réelle.\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",
    "EMERGENCY = 0.15\n",
    "PLAUSIBLE_WEIGHT = (2, 30)\n",
    "PLAUSIBLE_HEIGHT = (45, 130)\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": [
    "## Des scores Z calculés, non repris"
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# `between()` renvoie False pour NaN : un simple `~between(...)` compte donc une\n",
    "# mesure manquante comme une mesure impossible. Ce sont deux defauts differents et\n",
    "# un seul des deux est une erreur de saisie.\n",
    "impossible = (\n",
    "    (smart[\"weight_kg\"].notna() & ~smart[\"weight_kg\"].between(*PLAUSIBLE_WEIGHT))\n",
    "    | (smart[\"height_cm\"].notna() & ~smart[\"height_cm\"].between(*PLAUSIBLE_HEIGHT))\n",
    ")\n",
    "plausible = smart[~impossible].copy()\n",
    "\n",
    "print(f\"mesures impossibles exclues : {int(impossible.sum())}\")\n",
    "print(f\"poids manquant (conserve, score NA) : {int(smart['weight_kg'].isna().sum())}\")\n",
    "print(f\"age manquant (conserve, score NA)   : {int(smart['age_months'].isna().sum())}\")\n",
    "\n",
    "# L'OMS utilise la longueur en dessous de 24 mois et la taille a partir de 24 mois ;\n",
    "# lorsque la position saisie differe, la mesure est convertie de 0,7 cm.\n",
    "plausible[\"standard\"] = np.where(plausible[\"age_months\"] < 24, \"L\", \"H\")\n",
    "to_length = (plausible[\"standard\"] == \"L\") & ~plausible[\"measured_lying\"]\n",
    "to_height = (plausible[\"standard\"] == \"H\") & plausible[\"measured_lying\"]\n",
    "\n",
    "plausible[\"lenhei\"] = plausible[\"height_cm\"]\n",
    "plausible.loc[to_length, \"lenhei\"] += 0.7\n",
    "plausible.loc[to_height, \"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",
    "raw = ((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)]\n",
    "analysable = scored[(scored[\"whz\"].abs() <= 5) & scored[\"whz\"].notna()]\n",
    "len(analysable)"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## L'estimation, et sa position par rapport au seuil"
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def prevalence(df):\n",
    "    return pd.Series({\n",
    "        \"enfants\": len(df),\n",
    "        \"MAG\": ((df[\"whz\"] < -2) | df[\"oedema\"]).mean(),\n",
    "        \"MAS\": ((df[\"whz\"] < -3) | df[\"oedema\"]).mean(),\n",
    "        \"z moyen\": df[\"whz\"].mean(),\n",
    "        \"ecart-type z\": df[\"whz\"].std(),\n",
    "    })\n",
    "\n",
    "all_teams = prevalence(analysable)\n",
    "all_teams.round(3)"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clusters = analysable.assign(\n",
    "    case=(analysable[\"whz\"] < -2) | analysable[\"oedema\"]\n",
    ").groupby(\"cluster\").agg(m=(\"case\", \"size\"), y=(\"case\", \"sum\"))\n",
    "\n",
    "def with_interval(cl):\n",
    "    k, M = len(cl), cl[\"m\"].sum()\n",
    "    p = cl[\"y\"].sum() / M\n",
    "    var_cluster = (k / ((k - 1) * M**2)) * ((cl[\"y\"] - p * cl[\"m\"]) ** 2).sum()\n",
    "    deff = var_cluster / (p * (1 - p) / M)\n",
    "    se = np.sqrt(var_cluster)\n",
    "    return p, p - 1.96 * se, p + 1.96 * se, deff\n",
    "\n",
    "p, low, high, deff = with_interval(clusters)\n",
    "print(f\"MAG {p:.1%}  (IC 95 % {low:.1%} - {high:.1%})   effet de grappe {deff:.2f}\")\n",
    "print(f\"seuil d'urgence {EMERGENCY:.0%} : estimation ponctuelle \"\n",
    "      f\"{'au-dessus' if p > EMERGENCY else 'en dessous'}, intervalle \"\n",
    "      f\"{'le franchit' if high > EMERGENCY > low else 'ne le franchit pas'}\")"
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "L'estimation ponctuelle se situe juste en dessous du seuil et l'intervalle le\n",
    "franchit. Sur cette seule base, l'enquête ne peut pas dire si le critère de montée\n",
    "en charge est rempli — d'où la nécessité de trancher la question des équipes avant\n",
    "même d'utiliser le chiffre.\n",
    "\n",
    "## La question des équipes"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "by_team = analysable.groupby(\"team\").apply(prevalence, include_groups=False).round(3)\n",
    "by_team"
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "spread = by_team[\"z moyen\"].max() - by_team[\"z moyen\"].min()\n",
    "suspect = by_team[\"z moyen\"].idxmin()\n",
    "\n",
    "print(f\"ecart du z moyen entre equipes : {spread:.2f}\")\n",
    "print(f\"equipe mesurant le plus bas : {suspect}\")\n",
    "print(\"SMART considere un ecart entre equipes superieur a ~0,3 z comme un probleme de supervision.\")"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Les grappes ont été attribuées aux équipes indépendamment de l'état nutritionnel :\n",
    "un écart réel d'un demi-score Z entre équipes serait donc extraordinaire. Deux\n",
    "vérifications supplémentaires indiquent quelle explication tient."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "scored[\"last_digit\"] = ((scored[\"height_cm\"] * 10).round() % 10).astype(\"Int64\")\n",
    "digits = pd.crosstab(scored[\"team\"], scored[\"last_digit\"], normalize=\"index\") * 100\n",
    "rounded = digits[[0, 5]].sum(axis=1).round(1)\n",
    "rounded.to_frame(\"% des tailles en ,0 ou ,5\").assign(attendu=20.0)"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "sizes = analysable.groupby(\"team\")[\"cluster\"].nunique()\n",
    "sizes.to_frame(\"grappes mesurees\")"
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La préférence de chiffre désigne une équipe *différente*. L'enquête présente donc\n",
    "deux défauts de mesure indépendants, dans deux équipes, et un seul des deux\n",
    "déplace l'estimation de prévalence.\n",
    "\n",
    "## Les trois options, chiffrées"
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "options = {}\n",
    "\n",
    "options[\"accepter tel que mesure\"] = with_interval(clusters)\n",
    "\n",
    "without = analysable[analysable[\"team\"] != suspect]\n",
    "cl_without = without.assign(\n",
    "    case=(without[\"whz\"] < -2) | without[\"oedema\"]\n",
    ").groupby(\"cluster\").agg(m=(\"case\", \"size\"), y=(\"case\", \"sum\"))\n",
    "options[f\"exclure l'equipe {suspect}\"] = with_interval(cl_without)\n",
    "\n",
    "summary = pd.DataFrame(\n",
    "    {k: {\"MAG\": v[0], \"IC bas\": v[1], \"IC haut\": v[2], \"effet de grappe\": v[3]}\n",
    "     for k, v in options.items()}\n",
    ").T.round(3)\n",
    "summary[\"enfants\"] = [len(analysable), len(without)]\n",
    "summary[\"franchit le seuil\"] = summary[\"IC haut\"] > EMERGENCY\n",
    "summary"
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La recommandation"
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(f\"tel que mesure        : MAG {options['accepter tel que mesure'][0]:.1%}, \"\n",
    "      f\"n={len(analysable)}\")\n",
    "print(f\"sans l'equipe {suspect}      : MAG {options[f'exclure l\\'equipe {suspect}'][0]:.1%}, \"\n",
    "      f\"n={len(without)}\")\n",
    "print(f\"\\ndifference : \"\n",
    "      f\"{options[f'exclure l\\'equipe {suspect}'][0] - options['accepter tel que mesure'][0]:+.1%}\")"
   ],
   "id": "cell-017"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Ne pas accepter tel que mesuré.** L'estimation est gonflée par une équipe dont\n",
    "le score Z moyen se situe un demi écart-type en dessous des autres, sur des\n",
    "grappes attribuées indépendamment, et la décision de montée en charge se joue\n",
    "précisément sur cet écart.\n",
    "\n",
    "**Ne pas exclure silencieusement cette équipe non plus.** Exclure un quart des\n",
    "grappes modifie la base de sondage, et une prévalence calculée sur le reste n'est\n",
    "plus l'enquête qui avait été conçue. Elle est rapportée ici comme analyse de\n",
    "sensibilité, non comme la réponse.\n",
    "\n",
    "**Recommander de remesurer les grappes de cette équipe** si les équipes sont\n",
    "encore sur le terrain, et, si elles ne le sont plus, publier en plaçant la\n",
    "comparaison entre équipes dans le corps du rapport plutôt qu'en annexe. Un lecteur\n",
    "à qui l'on ne montre que le chiffre du district ne peut pas savoir qu'un quart de\n",
    "l'échantillon a été mesuré par quelqu'un dont les résultats ne concordent avec\n",
    "ceux de personne.\n",
    "\n",
    "## Ce que cela n'établit pas\n",
    "\n",
    "Quelle équipe a raison. La comparaison montre qu'une équipe diffère ; elle ne\n",
    "montre pas que les autres sont justes. Si un exercice de standardisation a été\n",
    "mené avant la collecte, ses résultats tranchent cette question, et ce notebook ne\n",
    "les contient pas.\n",
    "\n",
    "L'attraction des âges vers les années entières influence le standard de croissance\n",
    "appliqué, car la règle longueur/taille bascule exactement à 24 mois. C'est une\n",
    "seconde source d'erreur, plus faible, qui traverse toutes les équipes, et elle est\n",
    "traitée dans le rapport de plausibilité plutôt qu'ici."
   ],
   "id": "cell-018"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
