{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Tableau de bord du programme PCIMA — notebook d'analyse\n",
    "\n",
    "*Tableau de bord du programme nutritionnel · 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/nutrition-programme-dashboard/notebooks/nutrition-dashboard.python.fr.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ce qu'est ce notebook\n",
    "\n",
    "L'analyse qui sous-tend le tableau de bord : elle part du registre de dépistage,\n",
    "produit les trois chiffres affichés, et énonce ce que chacun compte. C'est un\n",
    "livrable de projet et non une leçon — il suppose acquise la formation\n",
    "Fondamentaux et ne réexplique pas le nettoyage.\n",
    "\n",
    "**La décision qu'il éclaire.** Vers lesquels des douze sites réaffecter le stock\n",
    "d'ATPE avant l'ouverture du trimestre suivant. C'est un problème de classement\n",
    "avec une échéance, et la contrainte que ce tableau de bord existe pour respecter\n",
    "est que les chiffres d'admission parvenaient au responsable de programme six\n",
    "semaines après le mois de rapportage.\n",
    "\n",
    "Tous les jeux de données de cette plateforme sont synthétiques. Aucun enfant réel\n",
    "n'est décrit, et ces chiffres 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",
    "URL = (\n",
    "    \"https://data-analysis.cassion.dev/datasets/files/\"\n",
    "    \"muac-screening-artibonite-2024.v1.csv\"\n",
    ")\n",
    "\n",
    "SAM_MM, GAM_MM = 115, 125\n",
    "PLAUSIBLE_MM = (80, 220)\n",
    "\n",
    "muac = pd.read_csv(\n",
    "    URL,\n",
    "    dtype={\"child_id\": \"string\", \"commune\": \"string\", \"sex\": \"string\"},\n",
    "    na_values={\"muac_mm\": [\"-99\"]},\n",
    ")\n",
    "muac[\"screening_date\"] = pd.to_datetime(muac[\"screening_date\"], format=\"%Y-%m-%d\")\n",
    "muac[\"month\"] = muac[\"screening_date\"].dt.to_period(\"M\")\n",
    "\n",
    "len(muac)"
   ],
   "id": "cell-002"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Le nettoyage, appliqué et non expliqué\n",
    "\n",
    "Les décisions et leurs justifications figurent dans `docs/technical-notes.md` ;\n",
    "le code ci-dessous les applique afin que le tableau de bord soit reproductible à\n",
    "partir de l'export brut."
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "unit_error = muac[\"muac_mm\"].notna() & (muac[\"muac_mm\"] < 40)\n",
    "muac.loc[unit_error, \"muac_mm\"] *= 10\n",
    "\n",
    "implausible = muac[\"muac_mm\"].notna() & ~muac[\"muac_mm\"].between(*PLAUSIBLE_MM)\n",
    "muac.loc[implausible, \"muac_mm\"] = np.nan\n",
    "\n",
    "OEDEMA = {\n",
    "    \"true\": True, \"TRUE\": True, \"Y\": True, \"y\": True, \"yes\": True,\n",
    "    \"false\": False, \"FALSE\": False, \"N\": False, \"n\": False, \"no\": False,\n",
    "}\n",
    "muac[\"oedema\"] = muac[\"oedema\"].astype(\"string\").str.strip().map(OEDEMA)\n",
    "\n",
    "before = len(muac)\n",
    "muac = muac.drop_duplicates()\n",
    "\n",
    "cleaning = pd.Series({\n",
    "    \"erreurs d'unite corrigees\": int(unit_error.sum()),\n",
    "    \"implausibles en manquant\": int(implausible.sum()),\n",
    "    \"doublons exacts retires\": before - len(muac),\n",
    "    \"oedemes non consignes\": int(muac[\"oedema\"].isna().sum()),\n",
    "    \"lignes analysees\": len(muac),\n",
    "})\n",
    "cleaning"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Volet 1 — la charge de cas, et sa fraîcheur\n",
    "\n",
    "Le premier chiffre du tableau de bord n'est pas un taux. C'est le nombre\n",
    "d'enfants dépistés et la date à laquelle ils l'ont été, car une prévalence\n",
    "calculée sur un mois à moitié rapporté est précisément la défaillance que ce\n",
    "projet existe pour éviter."
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "muac[\"assessed\"] = muac[\"muac_mm\"].notna() | muac[\"oedema\"].notna()\n",
    "muac[\"gam\"] = np.where(\n",
    "    ~muac[\"assessed\"], np.nan,\n",
    "    ((muac[\"muac_mm\"] < GAM_MM) | (muac[\"oedema\"] == True)).astype(float),\n",
    ")\n",
    "muac[\"sam\"] = np.where(\n",
    "    ~muac[\"assessed\"], np.nan,\n",
    "    ((muac[\"muac_mm\"] < SAM_MM) | (muac[\"oedema\"] == True)).astype(float),\n",
    ")\n",
    "\n",
    "monthly = muac.groupby(\"month\").agg(\n",
    "    screened=(\"child_id\", \"size\"),\n",
    "    assessed=(\"assessed\", \"sum\"),\n",
    "    sam_cases=(\"sam\", \"sum\"),\n",
    ")\n",
    "monthly[\"assessment_rate\"] = (monthly[\"assessed\"] / monthly[\"screened\"]).round(3)\n",
    "monthly.tail(6)"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Le premier et le dernier mois sont tronqués — le dépistage court de la mi-janvier\n",
    "à la mi-décembre — si bien que le tableau de bord les signale, plutôt que de\n",
    "laisser un lecteur prendre un mois partiel pour un fléchissement."
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"premier depistage :\", muac[\"screening_date\"].min().date())\n",
    "print(\"dernier depistage :\", muac[\"screening_date\"].max().date())"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Volet 2 — le classement des sites, avec son incertitude"
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from scipy.stats import beta\n",
    "\n",
    "def interval(cases, n, confidence=0.95):\n",
    "    if n == 0:\n",
    "        return (np.nan, np.nan)\n",
    "    a = 1 - confidence\n",
    "    low = beta.ppf(a / 2, cases, n - cases + 1) if cases > 0 else 0.0\n",
    "    high = beta.ppf(1 - a / 2, cases + 1, n - cases) if cases < n else 1.0\n",
    "    return (low, high)\n",
    "\n",
    "sites = muac.groupby(\"commune\").agg(\n",
    "    screened=(\"child_id\", \"size\"),\n",
    "    assessed=(\"assessed\", \"sum\"),\n",
    "    gam_cases=(\"gam\", \"sum\"),\n",
    "    sam_cases=(\"sam\", \"sum\"),\n",
    ")\n",
    "sites[\"gam_rate\"] = sites[\"gam_cases\"] / sites[\"assessed\"]\n",
    "sites[\"sam_rate\"] = sites[\"sam_cases\"] / sites[\"assessed\"]\n",
    "\n",
    "bounds = sites.apply(lambda r: interval(r[\"gam_cases\"], r[\"assessed\"]), axis=1)\n",
    "sites[\"gam_low\"] = [b[0] for b in bounds]\n",
    "sites[\"gam_high\"] = [b[1] for b in bounds]\n",
    "\n",
    "sites = sites.sort_values(\"gam_rate\", ascending=False)\n",
    "sites.round(3)"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Le classement n'est pas une file d'attente.** Plusieurs intervalles se\n",
    "recouvrent : l'ordre entre ces sites n'est donc pas soutenu par le dépistage. Le\n",
    "tableau de bord affiche l'intervalle et non le seul point, précisément pour qu'un\n",
    "responsable qui répartit du stock voie quels écarts sont réels."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "EMERGENCY = 0.15\n",
    "\n",
    "decisive = sites[sites[\"gam_low\"] > sites[\"gam_rate\"].median()]\n",
    "print(f\"sites nettement au-dessus de la mediane du district : {list(decisive.index)}\")\n",
    "print(f\"sites dont l'intervalle franchit le seuil d'urgence de {EMERGENCY:.0%} : \"\n",
    "      f\"{list(sites[(sites['gam_high'] > EMERGENCY)].index)}\")"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Volet 3 — là où les données sont trop minces pour agir\n",
    "\n",
    "Le volet qui rend le tableau de bord digne de confiance plutôt\n",
    "qu'impressionnant. Un site à faible couverture d'évaluation produit un taux sur\n",
    "lequel le responsable ne devrait pas agir, et le dire est plus utile qu'un\n",
    "chiffre assuré."
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "coverage = muac.groupby(\"commune\").agg(\n",
    "    screened=(\"child_id\", \"size\"),\n",
    "    assessment_rate=(\"assessed\", \"mean\"),\n",
    "    missing_age=(\"age_months\", lambda s: s.isna().mean()),\n",
    ").round(3)\n",
    "\n",
    "coverage[\"interval_width\"] = (sites[\"gam_high\"] - sites[\"gam_low\"]).round(3)\n",
    "coverage.sort_values(\"interval_width\", ascending=False)"
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Le taux d'âges manquants d'un site se détache nettement des autres — une seule\n",
    "semaine de campagne avec un formulaire mal configuré. Cela n'affecte pas\n",
    "l'indicateur PB, qui n'a pas besoin de l'âge, et le tableau de bord le précise\n",
    "plutôt que d'écarter le site.\n",
    "\n",
    "## Les trois chiffres qu'affiche le tableau de bord"
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "district_assessed = int(sites[\"assessed\"].sum())\n",
    "district_gam = sites[\"gam_cases\"].sum() / district_assessed\n",
    "district_sam = sites[\"sam_cases\"].sum() / district_assessed\n",
    "low, high = interval(sites[\"gam_cases\"].sum(), district_assessed)\n",
    "\n",
    "summary = pd.Series({\n",
    "    \"enfants evalues\": district_assessed,\n",
    "    \"GAM\": f\"{district_gam:.1%} ({low:.1%} - {high:.1%})\",\n",
    "    \"SAM\": f\"{district_sam:.1%}\",\n",
    "    \"sites au-dessus de la mediane, de maniere decisive\": len(decisive),\n",
    "})\n",
    "summary"
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Les définitions d'indicateurs, imprimées avec le résultat\n",
    "\n",
    "Le tableau de bord porte également ce volet. Un chiffre et sa définition\n",
    "circulent ensemble, faute de quoi le débat mensuel sur le dénominateur\n",
    "recommence."
   ],
   "id": "cell-017"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "definitions = pd.DataFrame([\n",
    "    (\"MAG\", \"PB < 125 mm ou oedemes bilateraux prenant le godet\",\n",
    "     \"enfants avec une mesure de PB ou une evaluation des oedemes\"),\n",
    "    (\"MAS\", \"PB < 115 mm ou oedemes bilateraux prenant le godet\",\n",
    "     \"enfants avec une mesure de PB ou une evaluation des oedemes\"),\n",
    "    (\"Taux d'evaluation\", \"enfants avec PB ou oedeme consigne\",\n",
    "     \"enfants depistes\"),\n",
    "], columns=[\"indicateur\", \"numerateur\", \"denominateur\"])\n",
    "definitions"
   ],
   "id": "cell-018"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Ce qu'aucun de ces chiffres n'est.** La couverture du programme. Ce registre\n",
    "contient les enfants venus se faire dépister, non les enfants qui existent :\n",
    "rien ici n'estime donc la part des cas que le programme atteint. Cela exige un\n",
    "chiffre de population et une enquête de couverture, et le rapport l'énonce dans\n",
    "ses limites."
   ],
   "id": "cell-019"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
