---
title: "Calculer le SCA, l'ÉFM et l'ICSR à partir de leurs composantes"
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 trois indicateurs composites de sécurité alimentaire, construits à partir de
leurs composantes plutôt que lus dans une colonne précalculée — parce que c'est
dans la construction que se logent toutes les erreurs.

Valeurs de référence issues des notes de qualité : avec les seuils standards
21/35 du SCA, environ 1 % des ménages sont en consommation pauvre et 23 % en
consommation limite ; avec le jeu 28/42, 7 % et 39 %. Aucun n'est faux. Ne pas
dire lequel a été utilisé l'est.

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

## Mise en place

```{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"})
print(fs.shape)
fs.head()
```

## Contrôler les plages avant de scorer

Vingt-trois enregistrements portent une valeur de consommation supérieure à sept
jours, ce qui est impossible pour un rappel de sept jours. Un score calculé sans
contrôle de plage hérite de la valeur impossible et gonfle ce ménage — en
silence, puisque le résultat reste un nombre plausible.

```{python}
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)
impossibles = (fs[composantes] > 7).sum().sum()
vides = fs[composantes].isna().sum().sum()

print(f"valeurs superieures a 7 jours : {int(impossibles)}")
print(f"cellules vides                : {int(vides)}")
fs[composantes].agg(["min", "max"]).T
```

```{python}
# Hors plage n'est pas une mesure. Passez en manquant plutot que de tronquer a 7 :
# tronquer invente une valeur que l'enqueteur n'a jamais relevee.
for colonne in composantes:
    fs[colonne] = fs[colonne].where(fs[colonne].between(0, 7))
```

## La case vide qui n'est pas un zéro

Environ 127 cellules réparties entre produits laitiers, fruits et viande sont
vides. **Traiter une case vide comme zéro jour score le ménage comme mangeant
moins qu'il n'a mangé**, et les composantes le plus souvent vides portent les
pondérations les plus lourdes — produits laitiers et viande valent 4 chacune.
C'est la défaillance silencieuse la plus lourde de conséquences de ce jeu de
données, et cela tient à une ligne de code dans un sens comme dans l'autre.

```{python}
fs["sca_complet"] = fs[composantes].notna().all(axis=1)

# NaN se propage : un menage incomplet ne recoit donc aucun score, ce qui est le
# comportement correct. La version remplie de zeros ne sert qu'a mesurer le degat.
fs["sca"] = sum(fs[c] * w for c, w in PONDERATIONS_SCA.items())
fs["sca_zeros"] = sum(fs[c].fillna(0) * w for c, w in PONDERATIONS_SCA.items())

incomplets = ~fs["sca_complet"]
print(f"menages dont le SCA est incomplet : {int(incomplets.sum())}")

pd.DataFrame({
    "menages complets (score reel)": fs.loc[~incomplets, "sca"].describe(),
    "menages incomplets, remplis de zeros": fs.loc[incomplets, "sca_zeros"].describe(),
}).round(1)
```

Les ménages remplis de zéros se situent en moyenne six points sous ceux qui ont
répondu intégralement. Cet écart n'est pas un constat sur leur alimentation :
c'est le décompte des cases vides comme journées sans manger.

```{python}
def part(scores, pauvre, limite):
    s = scores.dropna()
    return {
        "pauvre %": round((s <= pauvre).mean() * 100, 2),
        "limite %": round(((s > pauvre) & (s <= limite)).mean() * 100, 2),
        "menages": len(s),
    }

pd.DataFrame({
    "exclure les incomplets (correct)": part(fs["sca"], 21, 35),
    "remplir de zeros et conserver": part(fs["sca_zeros"], 21, 35),
}).T
```

Aux seuils 21/35 la distorsion est faible. Aux seuils 28/42, elle ne l'est plus :

```{python}
zeros_incomplets = fs.loc[incomplets, "sca_zeros"]
print(f"sur les {len(zeros_incomplets)} menages incomplets, le remplissage par zero classe")
print(f"  {int((zeros_incomplets <= 21).sum())} en consommation pauvre a 21/35")
print(f"  {int((zeros_incomplets <= 28).sum())} en consommation pauvre a 28/42")

pd.DataFrame({
    "exclure les incomplets (correct)": part(fs["sca"], 28, 42),
    "remplir de zeros et conserver": part(fs["sca_zeros"], 28, 42),
}).T
```

Trente-deux ménages classés en consommation alimentaire pauvre sur des scores
artificiellement bas, parce qu'une case vide a été lue comme un zéro. Chacun
d'eux serait comptabilisé dans une charge de cas.

## Les deux jeux de seuils, côte à côte

```{python}
def groupe_consommation(score, pauvre, limite):
    if pd.isna(score):
        return None
    if score <= pauvre:
        return "pauvre"
    if score <= limite:
        return "limite"
    return "acceptable"

valides = fs[fs["sca_complet"]].copy()

for etiquette, (pauvre, limite) in {
    "21/35": (21, 35),
    "28/42": (28, 42),
}.items():
    valides[f"groupe {etiquette}"] = valides["sca"].apply(
        lambda s: groupe_consommation(s, pauvre, limite)
    )

pd.DataFrame({
    etiquette: valides[f"groupe {etiquette}"].value_counts(normalize=True)
    for etiquette in ["21/35", "28/42"]
}).round(3)
```

Le jeu 28/42 s'emploie là où l'huile et le sucre sont consommés de manière quasi
universelle, ce qui gonfle tous les scores et rend les seuils standards trop
généreux. Le retenir relève d'un jugement sur le système alimentaire, non sur les
données — et **ce choix fait passer le chiffre de tête de 1 % à 7 % de
consommation pauvre**. Énoncez le jeu de seuils employé, dans la même phrase que
le chiffre.

## L'échelle de la faim dans le ménage

L'ÉFM n'est valide que si les trois questions ont reçu une réponse. Une réponse
partielle doit être **exclue, et non remplie de zéros** — la remplir de zéros
score un ménage affamé comme étant en sécurité alimentaire.

```{python}
ITEMS_EFM = [
    "hhs_no_food_in_house",
    "hhs_sleep_hungry",
    "hhs_day_and_night_without_eating",
]

efm_complet = fs[ITEMS_EFM].notna().all(axis=1)
efm_partiel = fs[ITEMS_EFM].isna().any(axis=1) & fs[ITEMS_EFM].notna().any(axis=1)

print(f"complets : {int(efm_complet.sum())}   partiels : {int(efm_partiel.sum())}")

fs["efm"] = fs[ITEMS_EFM].sum(axis=1).where(efm_complet)
fs["categorie_efm"] = pd.cut(
    fs["efm"], [-1, 1, 3, 6],
    labels=["faible ou nulle", "moderee", "severe"],
)

(fs["categorie_efm"].value_counts(normalize=True).sort_index() * 100).round(1)
```

Notez que `.sum(axis=1)` renverrait volontiers un score pour une réponse
partielle : pandas traite les valeurs manquantes comme des zéros dans une somme
par ligne. C'est le `.where(efm_complet)` qui rend l'exclusion effective, et son
omission est exactement la défaillance que la note met en garde.

## L'indice réduit des stratégies de survie

```{python}
PONDERATIONS_ICSR = {
    "rcsi_less_preferred_food": 1,
    "rcsi_borrowed_food": 2,
    "rcsi_limit_portion_size": 1,
    "rcsi_restrict_adult_consumption": 3,
    "rcsi_reduce_meal_numbers": 1,
}

fs["icsr"] = sum(fs[c] * w for c, w in PONDERATIONS_ICSR.items())
fs["icsr"].describe().round(1)
```

Les pondérations traduisent la gravité, non la fréquence : restreindre la
consommation des adultes pour que les enfants mangent vaut 3, acheter des
aliments moins appréciés vaut 1. Elles sont fixées par le standard — ne les
ajustez pas au contexte, car un ICSR repondéré localement n'est comparable à rien.

## Ne pas utiliser l'un comme approximation de l'autre

```{python}
deux = fs[fs["sca_complet"]]
correlation = deux[["sca", "icsr"]].corr().iloc[0, 1]
print(f"correlation entre SCA et ICSR : {correlation:.3f}")
```

Environ -0,45. Les deux mesurent des choses liées mais distinctes : un ménage peut
manger de façon monotone sans avoir encore recours à des stratégies d'adaptation,
et un autre peut s'adapter fortement tout en conservant un régime varié grâce à
des aliments empruntés. Rapporter l'un à la place de l'autre perd une information
réelle, et c'est le genre de raccourci qui tient jusqu'à ce que quelqu'un demande
pourquoi les deux tableaux se contredisent.

```{python}
pd.crosstab(
    deux["sca"].apply(lambda s: groupe_consommation(s, 21, 35)),
    pd.cut(deux["icsr"], [-1, 3, 18, 100], labels=["faible", "moyen", "eleve"]),
    normalize="index",
).round(3)
```

## Ce que cela produit, et ce que cela ne produit pas

Ce sont les données probantes de consommation alimentaire utilisées **dans** une
analyse IPC. Ce n'est pas une phase IPC. Une phase est attribuée par un groupe de
travail technique qui fait converger plusieurs indicateurs de résultat avec les
facteurs contributifs, et un tableau qui affiche « phase 3 » à partir d'une
distribution de SCA a escamoté tout le processus analytique que la classification
existe pour représenter.
