{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Cibler la charge de cas de l'assistance monétaire — notebook d'analyse\n",
    "\n",
    "*Classification de la sécurité alimentaire et ciblage de l'assistance · 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/food-security-classification/notebooks/composite-indicators.python.fr.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La décision que ce document produit\n",
    "\n",
    "Quels ménages entrent dans la charge de cas de la prochaine distribution\n",
    "monétaire, et selon quels critères énoncés. La charge de cas est fixée à environ\n",
    "un cinquième des ménages enquêtés — c'est le budget qui l'a décidé, non les\n",
    "données — il s'agit donc d'un problème de classement à coupure ferme, et la\n",
    "sortie honnête est la règle assortie de ceux qu'elle exclut.\n",
    "\n",
    "Destinataires : le cluster sécurité alimentaire et le groupe de travail\n",
    "transferts monétaires.\n",
    "\n",
    "Tous les jeux de données de cette plateforme sont synthétiques. Aucun ménage 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",
    "URL = (\n",
    "    \"https://data-analysis.cassion.dev/datasets/files/\"\n",
    "    \"food-security-survey-2024.v1.csv\"\n",
    ")\n",
    "\n",
    "fs = pd.read_csv(URL, dtype={\"household_id\": \"string\"})\n",
    "CASELOAD_SHARE = 0.20\n",
    "caseload = int(round(CASELOAD_SHARE * len(fs)))\n",
    "\n",
    "print(f\"menages enquetes : {len(fs)}\")\n",
    "print(f\"charge de cas permise par le budget : {caseload}\")"
   ],
   "id": "cell-002"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Le calcul des scores, avec les exclusions qu'exigent les indicateurs\n",
    "\n",
    "Appliqué ici ; le raisonnement figure dans `docs/threshold-justification.md`. Deux règles\n",
    "comptent pour le ciblage : une valeur de consommation supérieure à sept jours est\n",
    "impossible et ne doit pas gonfler un score, et une échelle de la faim\n",
    "partiellement renseignée doit être exclue plutôt que remplie de zéros — la\n",
    "remplir de zéros scorerait un ménage affamé comme étant en sécurité alimentaire\n",
    "et le ferait sortir de la charge de cas."
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "FCS_W = {\n",
    "    \"fcs_cereals_tubers\": 2, \"fcs_pulses\": 3, \"fcs_vegetables\": 1, \"fcs_fruit\": 1,\n",
    "    \"fcs_meat_fish_eggs\": 4, \"fcs_dairy\": 4, \"fcs_oils_fats\": 0.5, \"fcs_sugar\": 0.5,\n",
    "}\n",
    "RCSI_W = {\n",
    "    \"rcsi_less_preferred_food\": 1, \"rcsi_borrowed_food\": 2,\n",
    "    \"rcsi_limit_portion_size\": 1, \"rcsi_restrict_adult_consumption\": 3,\n",
    "    \"rcsi_reduce_meal_numbers\": 1,\n",
    "}\n",
    "HHS = [\"hhs_no_food_in_house\", \"hhs_sleep_hungry\", \"hhs_day_and_night_without_eating\"]\n",
    "\n",
    "for column in FCS_W:\n",
    "    fs[column] = fs[column].where(fs[column].between(0, 7))\n",
    "\n",
    "fs[\"fcs\"] = sum(fs[c] * w for c, w in FCS_W.items())\n",
    "fs[\"rcsi\"] = sum(fs[c] * w for c, w in RCSI_W.items())\n",
    "fs[\"hhs\"] = fs[HHS].sum(axis=1).where(fs[HHS].notna().all(axis=1))\n",
    "\n",
    "fs[\"sex_head\"] = (\n",
    "    fs[\"sex_head_of_household\"].str.strip().str.lower().str[0]\n",
    "    .map({\"f\": \"female\", \"m\": \"male\"})\n",
    ")\n",
    "\n",
    "pd.Series({\n",
    "    \"SCA incomplet (exclu des regles SCA)\": int(fs[\"fcs\"].isna().sum()),\n",
    "    \"EFM partiel (exclu des regles EFM)\": int(fs[\"hhs\"].isna().sum()),\n",
    "    \"doubles enumerations\": int(fs.drop(columns=[\"household_id\"]).duplicated().sum()),\n",
    "})"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Pourquoi aucun indicateur seul ne donne la charge de cas\n",
    "\n",
    "L'approche évidente consiste à retenir un seuil. Elle ne fonctionne pas, et\n",
    "comprendre pourquoi constitue l'argument en faveur du composite."
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rules = {\n",
    "    \"SCA pauvre (<= 21)\": fs[\"fcs\"] <= 21,\n",
    "    \"SCA pauvre ou limite (<= 35)\": fs[\"fcs\"] <= 35,\n",
    "    \"EFM moderee ou pire (>= 2)\": fs[\"hhs\"] >= 2,\n",
    "    \"EFM severe (>= 4)\": fs[\"hhs\"] >= 4,\n",
    "    \"ICSR au-dessus de la mediane\": fs[\"rcsi\"] >= fs[\"rcsi\"].median(),\n",
    "}\n",
    "\n",
    "pd.DataFrame({\n",
    "    \"menages\": {k: int(v.sum()) for k, v in rules.items()},\n",
    "    \"part\": {k: round(v.mean(), 3) for k, v in rules.items()},\n",
    "    \"ecart charge de cas\": {k: int(v.sum()) - caseload for k, v in rules.items()},\n",
    "})"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`SCA <= 21` sélectionne bien trop peu de ménages pour dépenser le budget ;\n",
    "`ÉFM >= 2` en sélectionne le double de la charge de cas. `SCA <= 35` tombe juste\n",
    "par accident, et le retenir *parce qu'il* tombe juste revient à ajuster le\n",
    "critère au budget plutôt qu'au besoin — précisément ce qu'une revue de cluster\n",
    "viendra interroger.\n",
    "\n",
    "## Le composite, et ce qu'il suppose\n",
    "\n",
    "Trois indicateurs, standardisés pour qu'aucun ne domine par son échelle, puis\n",
    "moyennés. Le SCA entre négativement car un score **plus élevé** signifie une\n",
    "meilleure consommation alimentaire."
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def standardise(series):\n",
    "    return (series - series.mean()) / series.std()\n",
    "\n",
    "fs[\"need_score\"] = (\n",
    "    -standardise(fs[\"fcs\"].fillna(fs[\"fcs\"].median()))\n",
    "    + standardise(fs[\"rcsi\"])\n",
    "    + standardise(fs[\"hhs\"].fillna(0))\n",
    ") / 3\n",
    "\n",
    "fs[\"need_score\"].describe().round(3)"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Deux choix apparaissent dans cette cellule et tous deux doivent figurer au\n",
    "rapport. Des pondérations égales affirment que les trois indicateurs comptent\n",
    "autant, ce qui est une hypothèse et non un constat. Et les valeurs de\n",
    "remplacement — SCA médian pour un score incomplet, ÉFM nul pour une réponse\n",
    "partielle — sont prudentes dans des directions opposées : un ménage aux données\n",
    "lacunaires n'est donc ni poussé dans la charge de cas ni écarté par la lacune\n",
    "elle-même."
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "selected = fs.nlargest(caseload, \"need_score\").copy()\n",
    "selected[\"selected\"] = True\n",
    "fs[\"selected\"] = fs[\"household_id\"].isin(selected[\"household_id\"])\n",
    "\n",
    "pd.Series({\n",
    "    \"charge de cas\": int(fs[\"selected\"].sum()),\n",
    "    \"dont SCA pauvre\": int((selected[\"fcs\"] <= 21).sum()),\n",
    "    \"dont SCA pauvre ou limite\": int((selected[\"fcs\"] <= 35).sum()),\n",
    "    \"dont EFM severe\": int((selected[\"hhs\"] >= 4).sum()),\n",
    "})"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qui la règle inclut, et qui elle laisse de côté\n",
    "\n",
    "La partie qu'un groupe de travail transferts monétaires interrogera réellement."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def composition(column):\n",
    "    overall = fs[column].value_counts(normalize=True)\n",
    "    chosen = selected[column].value_counts(normalize=True)\n",
    "    out = pd.DataFrame({\"population\": overall, \"charge_de_cas\": chosen}).fillna(0)\n",
    "    out[\"ecart\"] = (out[\"charge_de_cas\"] - out[\"population\"]).round(3)\n",
    "    return out.round(3).sort_values(\"ecart\", ascending=False)\n",
    "\n",
    "composition(\"displacement_status\")"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Les ménages déplacés sont surreprésentés dans la charge de cas par rapport à la\n",
    "population enquêtée. C'est ce que disent les indicateurs, non un choix de\n",
    "ciblage — mais c'est la phrase qui doit figurer au rapport, car une autorité de\n",
    "district lira la charge de cas comme un énoncé sur qui mérite l'assistance."
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "composition(\"main_livelihood\")"
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "composition(\"sex_head\")"
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Les ménages juste au-delà de la ligne\n",
    "\n",
    "Une charge de cas fixe implique que certains ménages se situent à peine sous la\n",
    "coupure. Les nommer est plus utile que de faire comme si la frontière avait un\n",
    "sens."
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ordered = fs.sort_values(\"need_score\", ascending=False).reset_index(drop=True)\n",
    "boundary = ordered.iloc[caseload - 20 : caseload + 20]\n",
    "\n",
    "print(f\"score de besoin a la coupure : {ordered.loc[caseload - 1, 'need_score']:.3f}\")\n",
    "print(f\"amplitude des scores sur les 40 menages de part et d'autre : \"\n",
    "      f\"{boundary['need_score'].min():.3f} a {boundary['need_score'].max():.3f}\")\n",
    "\n",
    "boundary[[\"household_id\", \"district\", \"displacement_status\", \"fcs\", \"rcsi\", \"hhs\",\n",
    "          \"need_score\", \"selected\"]].head(12).round(3)"
   ],
   "id": "cell-017"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Les quarante ménages de part et d'autre de la coupure s'étendent sur moins d'un\n",
    "dixième de point, et les paires adjacentes diffèrent de quelques millièmes.\n",
    "**Rien dans les données ne distingue le dernier ménage inclus du premier\n",
    "exclu**, et le rapport doit le dire plutôt que de laisser croire que la coupure\n",
    "constitue un résultat. Si le programme peut étendre la charge de cas à la marge,\n",
    "c'est avec cette liste qu'il faut le faire.\n",
    "\n",
    "## La sortie que reçoit le groupe de travail"
   ],
   "id": "cell-018"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "caseload_list = (\n",
    "    selected.sort_values(\"need_score\", ascending=False)\n",
    "    [[\"household_id\", \"district\", \"household_size\", \"displacement_status\",\n",
    "      \"main_livelihood\", \"fcs\", \"rcsi\", \"hhs\", \"need_score\"]]\n",
    "    .round(3)\n",
    ")\n",
    "caseload_list.head(15)"
   ],
   "id": "cell-019"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "by_district = fs.groupby(\"district\").agg(\n",
    "    enquetes=(\"household_id\", \"size\"),\n",
    "    selectionnes=(\"selected\", \"sum\"),\n",
    ")\n",
    "by_district[\"part_selectionnee\"] = (\n",
    "    by_district[\"selectionnes\"] / by_district[\"enquetes\"]\n",
    ").round(3)\n",
    "by_district"
   ],
   "id": "cell-020"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ce que cela n'établit pas\n",
    "\n",
    "L'enquête porte sur les ménages atteints par l'énumération, non sur la population\n",
    "du district : la charge de cas est donc tirée de qui a été enquêté. Les ménages\n",
    "que l'enquête n'a pas atteints — personnes déplacées hors des sites, ménages\n",
    "absents le jour de l'enquête — ne figurent pas dans ce classement et ne peuvent\n",
    "pas en être ciblés.\n",
    "\n",
    "Par ailleurs, la taille de la charge de cas est une décision budgétaire. Ce\n",
    "notebook classe le besoin ; il n'affirme pas qu'un cinquième des ménages soit le\n",
    "bon nombre à assister, et le rapport ne doit pas laisser confondre les deux."
   ],
   "id": "cell-021"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
