{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Liste de visites à domicile avant la fin du trimestre — notebook d'analyse\n",
    "\n",
    "*Assiduité scolaire et alerte précoce à l'abandon · 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/school-attendance-analytics/notebooks/dropout-early-warning.python.fr.ipynb)"
   ],
   "id": "cell-000"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La décision que ce document produit\n",
    "\n",
    "Quels élèves reçoivent une visite à domicile avant la fin du trimestre.\n",
    "\n",
    "Le problème que ce projet existe pour corriger : l'abandon scolaire était mesuré\n",
    "en fin d'année, moment où l'élève était déjà parti et où le programme pouvait\n",
    "rapporter le chiffre sans plus rien y changer. L'assiduité s'érode sur environ\n",
    "trois semaines avant qu'un élève cesse de venir, et c'est cette érosion qui rend\n",
    "l'alerte précoce possible.\n",
    "\n",
    "Destinataires : le chargé de programme éducation et les directeurs d'école.\n",
    "\n",
    "Tous les jeux de données de cette plateforme sont synthétiques. Aucun élève réel\n",
    "n'y est représenté.\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/files/\"\n",
    "\n",
    "DISENGAGED_DAYS = 15   # jours d'ecole, non jours calendaires\n",
    "\n",
    "attendance = pd.read_csv(BASE + \"school-attendance-2024.v1.csv\",\n",
    "                         dtype={\"student_id\": \"string\", \"present\": \"string\"})\n",
    "roster = pd.read_csv(BASE + \"school-roster-2024.v1.csv\",\n",
    "                     dtype={\"student_id\": \"string\", \"school_id\": \"string\"})\n",
    "attendance[\"attendance_date\"] = pd.to_datetime(attendance[\"attendance_date\"])\n",
    "\n",
    "print(f\"{len(attendance):,} lignes de presence, {len(roster)} lignes de liste\")"
   ],
   "id": "cell-002"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trois problèmes de données, dont chacun change la liste"
   ],
   "id": "cell-003"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "PRESENT = {\n",
    "    \"true\": True, \"TRUE\": True, \"Y\": True, \"y\": True, \"yes\": True,\n",
    "    \"false\": False, \"FALSE\": False, \"N\": False, \"n\": False, \"no\": False,\n",
    "}\n",
    "attendance[\"present_clean\"] = attendance[\"present\"].str.strip().map(PRESENT)\n",
    "\n",
    "naive = attendance[\"present\"] == \"true\"\n",
    "\n",
    "pd.Series({\n",
    "    \"lignes\": len(attendance),\n",
    "    \"jamais renseigne dans un sens ou l'autre\": int(attendance[\"present_clean\"].isna().sum()),\n",
    "    \"assiduite, conversion booleenne naive\": round(naive.mean(), 3),\n",
    "    \"assiduite, codage harmonise\": round(attendance[\"present_clean\"].mean(), 3),\n",
    "})"
   ],
   "id": "cell-004"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Une école a saisi `Y`/`N`. Une conversion booléenne transforme silencieusement ces\n",
    "valeurs en données manquantes, et cette école fournit alors l'essentiel du haut de\n",
    "n'importe quel classement de risque — pour un accident de codage, non pour un\n",
    "abandon."
   ],
   "id": "cell-005"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "duplicated = roster[roster.duplicated(\"student_id\", keep=False)]\n",
    "print(f\"eleves inscrits deux fois sur la liste : {duplicated['student_id'].nunique()}\")\n",
    "\n",
    "roster_resolved = (\n",
    "    roster.sort_values(\"grade\", na_position=\"last\")\n",
    "    .drop_duplicates(\"student_id\", keep=\"first\")\n",
    ")\n",
    "\n",
    "before = len(attendance)\n",
    "daily = attendance.merge(\n",
    "    roster_resolved, on=\"student_id\", how=\"left\", validate=\"many_to_one\"\n",
    ")\n",
    "assert len(daily) == before, \"la jointure a modifie le nombre de lignes\"\n",
    "daily = daily.dropna(subset=[\"present_clean\"]).sort_values(\n",
    "    [\"student_id\", \"attendance_date\"]\n",
    ")\n",
    "print(f\"jours-eleves analyses : {len(daily):,}\")"
   ],
   "id": "cell-006"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`validate=\"many_to_one\"` transforme une inflation silencieuse du nombre de lignes\n",
    "en erreur là où elle se produit. Deux élèves avaient été inscrits deux fois à la\n",
    "suite d'un transfert non enregistré.\n",
    "\n",
    "## Une ligne absente est une fermeture, non une absence"
   ],
   "id": "cell-007"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "calendar = sorted(daily[\"attendance_date\"].unique())\n",
    "school_days = daily.groupby(\"school_id\")[\"attendance_date\"].nunique()\n",
    "\n",
    "closures = {}\n",
    "for school in school_days[school_days < len(calendar)].index:\n",
    "    open_days = set(daily.loc[daily[\"school_id\"] == school, \"attendance_date\"])\n",
    "    closures[school] = sorted(set(calendar) - open_days)\n",
    "    print(f\"{school} : {len(closures[school])} jours de fermeture, \"\n",
    "          f\"du {pd.Timestamp(closures[school][0]).date()} au \"\n",
    "          f\"{pd.Timestamp(closures[school][-1]).date()}\")\n",
    "\n",
    "closed_schools = list(closures)"
   ],
   "id": "cell-008"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Deux écoles ont été fermées quinze jours en mars. Rien ne dérape jusqu'au moment\n",
    "où les variables sont construites sur une matrice élève × date — la forme\n",
    "naturelle pour une variable de série continue — parce que le réindexage sur le\n",
    "calendrier complet invente des lignes qui n'ont jamais été saisies, et le\n",
    "remplissage évident est « absent »."
   ],
   "id": "cell-009"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "matrix = (\n",
    "    daily.set_index([\"student_id\", \"attendance_date\"])[\"present_clean\"]\n",
    "    .unstack().reindex(columns=calendar)\n",
    ")\n",
    "filled = matrix.fillna(False).mean(axis=1)\n",
    "recorded = daily.groupby(\"student_id\")[\"present_clean\"].mean()\n",
    "\n",
    "in_closed = roster_resolved.set_index(\"student_id\")[\"school_id\"].isin(closed_schools)\n",
    "\n",
    "for label, rate in [(\"fermeture comptee absente\", filled), (\"jours saisis seulement\", recorded)]:\n",
    "    flagged = rate < 0.70\n",
    "    share = in_closed.reindex(rate.index).fillna(False)[flagged].mean()\n",
    "    print(f\"{label:26} {int(flagged.sum()):>4} signales, \"\n",
    "          f\"{share:.1%} issus des deux ecoles fermees\")\n",
    "\n",
    "print(f\"\\nces ecoles regroupent {in_closed.mean():.1%} de la liste\")"
   ],
   "id": "cell-010"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Le correctif n'est pas un ajustement. C'est de **ne pas réindexer** — chaque\n",
    "variable est calculée sur les jours que l'école de l'élève a effectivement\n",
    "saisis.\n",
    "\n",
    "## Les variables d'alerte précoce\n",
    "\n",
    "Le signal est une série récente d'absences, non un total faible. Un élève qui a\n",
    "manqué quinze jours en février puis est revenu n'est pas dans la même situation\n",
    "que celui qui a manqué les quinze derniers jours."
   ],
   "id": "cell-011"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "term_end = daily[\"attendance_date\"].max()\n",
    "\n",
    "def features(group):\n",
    "    present = group[\"present_clean\"].astype(bool).to_numpy()\n",
    "    dates = group[\"attendance_date\"].to_numpy()\n",
    "\n",
    "    run = 0\n",
    "    for value in present[::-1]:\n",
    "        if value:\n",
    "            break\n",
    "        run += 1\n",
    "\n",
    "    last_present = dates[present].max() if present.any() else pd.NaT\n",
    "    return pd.Series({\n",
    "        \"days_recorded\": len(group),\n",
    "        \"attendance_rate\": present.mean(),\n",
    "        \"trailing_absences\": run,\n",
    "        \"school_days_missed\": int((dates > last_present).sum()) if present.any() else len(group),\n",
    "        \"last_present\": last_present,\n",
    "    })\n",
    "\n",
    "students = daily.groupby(\"student_id\").apply(features, include_groups=False)\n",
    "students = students.join(\n",
    "    roster_resolved.set_index(\"student_id\")[[\"school_id\", \"grade\", \"feeding_programme\"]]\n",
    ")\n",
    "students[\"at_risk\"] = students[\"school_days_missed\"] > DISENGAGED_DAYS\n",
    "\n",
    "print(f\"signales : {int(students['at_risk'].sum())} sur {len(students)} \"\n",
    "      f\"({students['at_risk'].mean():.1%})\")"
   ],
   "id": "cell-012"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Les jours de fermeture sont absents de `school_days_missed` parce qu'ils n'ont\n",
    "jamais été saisis, ce qui est exactement le comportement recherché — mais notez\n",
    "que les deux écoles fermées restent un peu surreprésentées parmi les signalements,\n",
    "et cela est rapporté plutôt que corrigé. Une fermeture de trois semaines est un\n",
    "déclencheur plausible d'un désengagement réel."
   ],
   "id": "cell-013"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "share = students.loc[students[\"at_risk\"], \"school_id\"].isin(closed_schools).mean()\n",
    "print(f\"{share:.1%} des signalements viennent des ecoles fermees, \"\n",
    "      f\"qui regroupent {students['school_id'].isin(closed_schools).mean():.1%} des eleves\")"
   ],
   "id": "cell-014"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La liste de visites\n",
    "\n",
    "Trié par la durée d'absence de l'élève, puis par son assiduité antérieure — un\n",
    "élève qui était à 95 % et s'est arrêté il y a trois semaines n'est pas le même cas\n",
    "qu'un élève à 40 % tout le trimestre."
   ],
   "id": "cell-015"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "visits = (\n",
    "    students[students[\"at_risk\"]]\n",
    "    .sort_values([\"school_days_missed\", \"attendance_rate\"], ascending=[False, True])\n",
    "    [[\"school_id\", \"grade\", \"attendance_rate\", \"trailing_absences\", \"school_days_missed\"]]\n",
    "    .round(3)\n",
    ")\n",
    "visits.head(15)"
   ],
   "id": "cell-016"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "by_school = students.groupby(\"school_id\").agg(\n",
    "    eleves=(\"at_risk\", \"size\"),\n",
    "    signales=(\"at_risk\", \"sum\"),\n",
    ")\n",
    "by_school[\"taux\"] = (by_school[\"signales\"] / by_school[\"eleves\"]).round(3)\n",
    "by_school.sort_values(\"signales\", ascending=False).head(8)"
   ],
   "id": "cell-017"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Les directeurs reçoivent la liste de leur propre école ; le chargé de programme\n",
    "reçoit les effectifs par école. Une école dont le taux de signalement dépasse\n",
    "nettement les autres appelle une conversation sur l'école, non sur quinze élèves\n",
    "pris séparément.\n",
    "\n",
    "## Ce que cela classe, et ce que cela ne classe pas\n",
    "\n",
    "Ceci classe un **risque**, non un abandon. Un élève sur la liste peut avoir été\n",
    "transféré, être malade, ou aider à la maison pendant la récolte. Le produit est\n",
    "une conversation à avoir avant la fin du trimestre, non un statut à enregistrer —\n",
    "et une visite à domicile qui traite d'emblée l'élève comme un décrocheur sera la\n",
    "dernière que cette famille acceptera.\n",
    "\n",
    "Environ 3 % des lignes de la liste n'ont aucune classe renseignée. Ces élèves sont\n",
    "tout de même classés, car la classe n'entre pas dans le score ; elle figure dans\n",
    "la liste uniquement pour qu'un directeur sache de quelle classe s'enquérir."
   ],
   "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
}
