---
title: "Insécurité alimentaire par statut de déplacement et moyen d'existence"
subtitle: "Enquête de sécurité alimentaire, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## Ce que produit ce document

Les groupes de consommation alimentaire ventilés par statut de déplacement,
moyen d'existence et sexe du chef de ménage — et, plus utile encore, un compte
rendu honnête des **ventilations assez robustes pour fonder une décision**. La
désagrégation est le moment où une enquête cesse d'être un chiffre de tête pour
devenir une décision de ciblage, et c'est aussi le moment où un petit échantillon
s'épuise discrètement.

Tous les jeux de données de cette plateforme sont synthétiques. Aucun ménage réel
n'est décrit.

## Mise en place et calcul du score

Le calcul reprend l'exemple précédent en condensé : contrôle de plage, exclusion
des incomplets, pondération.

```{python}
import pandas as pd
import numpy as np

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "food-security-survey-2024.v1.csv"
)

fs = pd.read_csv(URL, dtype={"household_id": "string"})

PONDERATIONS_SCA = {
    "fcs_cereals_tubers": 2, "fcs_pulses": 3, "fcs_vegetables": 1,
    "fcs_fruit": 1, "fcs_meat_fish_eggs": 4, "fcs_dairy": 4,
    "fcs_oils_fats": 0.5, "fcs_sugar": 0.5,
}
composantes = list(PONDERATIONS_SCA)

for colonne in composantes:
    fs[colonne] = fs[colonne].where(fs[colonne].between(0, 7))

fs["sca"] = sum(fs[c] * w for c, w in PONDERATIONS_SCA.items())
fs["groupe_consommation"] = pd.cut(
    fs["sca"], [-np.inf, 21, 35, np.inf],
    labels=["pauvre", "limite", "acceptable"],
)

valides = fs[fs["sca"].notna()].copy()
print(f"{len(valides)} menages au SCA complet, sur {len(fs)} enquetes")
```

## Nettoyer d'abord la variable de désagrégation

Une équipe du Sud a consigné le sexe du chef de ménage en `Female` et `Male`
plutôt qu'en `f` et `m`. Un tableau désagrégé par sexe construit sans
normalisation se fragmente en quatre catégories, deux d'entre elles assez petites
pour passer pour du bruit — et ces deux petites proviennent d'une seule équipe
dans une seule zone : ce n'est donc pas un sous-ensemble aléatoire de quoi que ce
soit.

```{python}
print(fs["sex_head_of_household"].value_counts())
```

```{python}
valides["sexe_chef"] = (
    valides["sex_head_of_household"]
    .str.strip()
    .str.lower()
    .str[0]
    .map({"f": "femme", "m": "homme"})
)
print(valides["sexe_chef"].value_counts(dropna=False))
```

Prendre la première lettre après passage en minuscules traite `f`, `F`, `female`
et `Female` en une seule opération. Le mappage explicite qui suit fait qu'une
valeur inattendue devient `NaN` et se retrouve comptée, au lieu de constituer
silencieusement une cinquième catégorie.

## La ventilation qui compte : le statut de déplacement

```{python}
def parts_groupes(df, par):
    effectifs = pd.crosstab(df[par], df["groupe_consommation"])
    parts = (effectifs.div(effectifs.sum(axis=1), axis=0) * 100).round(1)
    parts["menages"] = effectifs.sum(axis=1)
    return parts.sort_values("pauvre", ascending=False)

parts_groupes(valides, "displacement_status")
```

Le gradient est le constat : les ménages déplacés et retournés sont en moins bonne
situation que les résidents et les hôtes. Mais regardez les effectifs avant de
croire à l'ordre — le plus petit groupe compte ici moins d'une centaine de
ménages, et un pourcentage calculé sur une telle base bouge d'un point entier
lorsque trois ménages changent de catégorie.

## Quelle part de cet ordre est réelle ?

Attachez un intervalle avant de classer quoi que ce soit. Sans lui, un écart de
quatre points entre deux groupes se lit comme un constat alors qu'il peut relever
du tirage au sort.

```{python}
from scipy.stats import beta

def intervalle(succes, n, confiance=0.95):
    if n == 0:
        return (np.nan, np.nan)
    alpha = 1 - confiance
    bas = beta.ppf(alpha / 2, succes, n - succes + 1) if succes > 0 else 0.0
    haut = beta.ppf(1 - alpha / 2, succes + 1, n - succes) if succes < n else 1.0
    return (bas, haut)

def taux_inadequat(df, par):
    inadequat = df["groupe_consommation"].isin(["pauvre", "limite"])
    out = df.assign(inadequat=inadequat).groupby(par).agg(
        menages=("inadequat", "size"),
        cas=("inadequat", "sum"),
    )
    out["taux"] = out["cas"] / out["menages"]
    bornes = out.apply(lambda r: intervalle(r["cas"], r["menages"]), axis=1)
    out["bas"] = [b[0] for b in bornes]
    out["haut"] = [b[1] for b in bornes]
    return out.sort_values("taux", ascending=False).round(3)

taux_inadequat(valides, "displacement_status")
```

Les consommations pauvre et limite sont regroupées ici parce que c'est la
population dans laquelle se constitue une charge de cas d'assistance alimentaire,
et parce qu'éclater un petit groupe en trois catégories ne laisse plus rien à
estimer.

Lisez les intervalles les uns contre les autres. Là où ils se recouvrent, l'ordre
entre ces deux groupes n'est pas soutenu : vous pouvez dire que les ménages
déplacés sont en moins bonne situation que les résidents, sans nécessairement
pouvoir dire que les retournés le sont davantage que les déplacés.

## Le moyen d'existence : là où l'échantillon s'épuise

```{python}
taux_inadequat(valides, "main_livelihood")
```

Le bas de ce tableau est l'endroit où l'enquête cesse de pouvoir répondre à la
question. Un groupe de moyens d'existence comptant une vingtaine de ménages
produit un taux dont l'intervalle s'étend sur vingt points ou davantage — ce
n'est pas un constat, et le placer dans un tableau classé invite quelqu'un à agir
dessus.

```{python}
par_moyen = taux_inadequat(valides, "main_livelihood")
par_moyen["largeur intervalle"] = (
    par_moyen["haut"] - par_moyen["bas"]
).round(3)
par_moyen[["menages", "taux", "largeur intervalle"]]
```

**Une règle pratique :** fixez un effectif minimal de cellule avant de regarder
les résultats, inscrivez-le dans le plan d'analyse, et rapportez les groupes en
deçà en une seule ligne « autres » avec leur taux combiné. Le fixer après coup est
la manière dont un seuil finit par être choisi pour faire mal paraître un groupe
en particulier.

## Les ventilations croisées s'épuisent plus vite

```{python}
croise = pd.crosstab(
    valides["displacement_status"], valides["sexe_chef"],
    values=valides["groupe_consommation"].isin(["pauvre", "limite"]),
    aggfunc="mean",
).round(3)

effectifs = pd.crosstab(valides["displacement_status"], valides["sexe_chef"])

pd.concat({"taux": croise, "menages": effectifs}, axis=1)
```

Chaque dimension supplémentaire divise à nouveau l'échantillon. Une ventilation à
trois entrées de cette enquête — déplacement par sexe par moyen d'existence —
placerait des effectifs à un chiffre dans la plupart des cellules, et un tableau
de pourcentages calculé sur quatre ménages n'est pas une donnée probante, aussi
proprement s'affiche-t-il.

## Ce qu'il faut rapporter

Les ventilations que l'échantillon soutient, avec leurs intervalles, et un énoncé
explicite de celles qu'il ne soutient pas. « Nous n'avons pas pu estimer
séparément l'insécurité alimentaire des ménages de pêcheurs, à peine une centaine
ayant été enquêtés » est une phrase utile. Son absence est ce qui conduit un
lecteur à supposer que toutes les lignes de votre tableau se valent.
