cassionAnalyse de données

Leçon 6 sur 8

Unité · Travailler le tableau

Regrouper jusqu'à un numérateur et un dénominateur

groupby, agrégation nommée, et les deux réglages par défaut qui changent silencieusement un dénominateur — groupes manquants écartés et catégories non observées. Produire les deux moitiés d'un indicateur en une seule opération pour qu'elles ne puissent pas diverger.

Python90 min

Un indicateur, ce sont deux nombres

Presque tout chiffre rapporté dans ce secteur est un numérateur sur un dénominateur, et presque toute dispute sur un chiffre est une dispute sur le dénominateur. La conséquence pratique pour votre code : calculez les deux dans la même opération.

Deux calculs séparés divergent. L’un reçoit un filtre que l’autre n’a pas, quelqu’un modifie une ligne, et le rapport devient un chiffre que personne ne peut reconstituer.

import pandas as pd

muac = read_register(RAW / "muac-screening-artibonite-2024.v1.csv")

measured = muac["muac_mm"].notna()
gam = measured & (muac["muac_mm"] < 125)

by_commune = (
    muac.assign(measured=measured, gam=gam)
    .groupby("commune")
    .agg(
        screened=("child_id", "size"),
        measured=("measured", "sum"),
        gam_cases=("gam", "sum"),
    )
)
by_commune["gam_rate"] = (by_commune["gam_cases"] / by_commune["measured"]).round(3)
             screened  measured  gam_cases  gam_rate
commune
Gros-Morne        361       351         48     0.137
Anse-Rouge        229       223         30     0.135
L-Estere          189       187         18     0.096
Terre-Neuve       241       235         21     0.089

Trois colonnes, et chacune porte quelque chose. screened est le nombre d’enfants venus ; measured le nombre ayant produit un PB exploitable, et c’est le dénominateur ; gam_cases est le numérateur. Publier le taux sans le dénominateur à côté, c’est ainsi que la dispute commence.

Noter que screened et measured diffèrent — 4 218 enfants ont été dépistés et 4 146 ont un PB. Diviser par le mauvais décale le taux du district assez pour compter et trop peu pour se voir.

L’agrégation nommée

La forme employée ci-dessus est celle à standardiser :

.agg(
    nom_de_sortie=("colonne_d_entree", "fonction"),
    ...
)

Elle nomme la colonne de sortie à l’endroit où l’agrégation est définie : plus de seconde étape pour renommer muac_mm_mean en quelque chose de compréhensible, et pas d’index de colonnes à plusieurs niveaux à aplatir.

Les formes plus anciennes fonctionnent encore et méritent d’être reconnues dans le code d’autrui :

muac.groupby("commune")["muac_mm"].mean()               # une colonne, une fonction
muac.groupby("commune").agg({"muac_mm": ["mean", "std"]})  # colonnes MultiIndex

La seconde produit des colonnes comme ("muac_mm", "mean"), qu’il faut ensuite aplatir. L’agrégation nommée évite le problème plutôt que de le résoudre.

Les fonctions peuvent être les vôtres :

def share_below(series, threshold=125):
    valid = series.dropna()
    return (valid < threshold).mean() if len(valid) else float("nan")

muac.groupby("commune").agg(
    gam_rate=("muac_mm", share_below),
    n=("muac_mm", "count"),
)

count exclut les manquants ; size les inclut. Cette seule distinction fait la différence entre les deux dénominateurs ci-dessus, et mérite d’être énoncée à voix haute chaque fois que vous employez l’un des deux.

Deux réglages par défaut qui changent un dénominateur

Source d’eau améliorée contre service de base, par district. Compter les sources surestime la couverture partout ; l’écart correspond aux ménages dont la source est améliorée et située à plus de trente minutes.

Les deux barres de chaque paire proviennent du même groupby. La seule différence tient aux lignes que compte le numérateur — et elle déplace la réponse de vingt à trente points dans chaque district. Voilà ce que signifie en pratique « le dénominateur est tout l’enjeu ».

Les clés de groupe manquantes sont écartées

d = pd.DataFrame({"g": ["a", None, "a"], "v": [1, 2, 3]})

d.groupby("g")["v"].sum()                  # {'a': 4}
d.groupby("g", dropna=False)["v"].sum()    # {'a': 4, nan: 2}

Par défaut, groupby écarte les lignes dont la clé de groupe est manquante. Elles disparaissent du tableau et du total : les parties ne font plus le tout, et rien ne dit pourquoi.

Dans ce secteur, la clé manquante est généralement la plus intéressante : une formation sanitaire sans district saisi, un ménage sans code de site, un enfant sans commune. Utilisez dropna=False et décidez délibérément, ou vérifiez que la clé est complète :

assert muac["commune"].notna().all(), "des lignes sans commune seraient ecartees"

Les catégories non observées réapparaissent en lignes vides

Si la clé de groupe est catégorielle, le comportement par défaut est désormais de ne renvoyer que les catégories observées :

c = pd.DataFrame({"k": pd.Categorical(["x", "y"], categories=["x", "y", "z"]),
                  "v": [1, 2]})

len(c.groupby("k", observed=True)["v"].sum())    # 2
len(c.groupby("k", observed=False)["v"].sum())   # 3 — inclut un "z" vide

Les deux sont justes, pour des questions différentes. Rapporter sur les formations ayant transmis des données appelle observed=True ; rapporter la couverture face à une liste de formations qui auraient dû transmettre appelle observed=False, car une formation à zéro ligne est justement le constat.

Passez-le explicitement. Le comportement par défaut a changé d’une version de pandas à l’autre, et un script qui s’y fie produit un autre tableau sur la machine d’un collègue.

Regrouper sur plusieurs clés

by_commune_month = (
    muac.assign(month=muac["screening_date"].dt.to_period("M"), gam=gam)
    .groupby(["commune", "month"], observed=True)
    .agg(measured=("muac_mm", "count"), gam_cases=("gam", "sum"))
)

Le résultat porte un MultiIndex. Deux façons de travailler avec :

by_commune_month.loc["Gonaives"]                      # une commune, tous les mois
by_commune_month.reset_index()                        # retour a des colonnes plates

reset_index() avant toute écriture en CSV, systématiquement — sinon les colonnes d’index disparaissent ou arrivent sans nom.

Pour transformer le résultat long en tableau large attendu par un rapport :

wide = by_commune_month["gam_cases"].unstack("month", fill_value=0)

fill_value=0 est sans risque ici, car une commune-mois sans cas en avait réellement zéro. Ce serait faux pour un taux, où l’absence signifie « non calculé » et non « zéro pour cent » — distinction à vérifier chaque fois que vous y recourez.

transform : une statistique de groupe sur chaque ligne

agg réduit ; transform renvoie une valeur par ligne d’origine. C’est ainsi qu’on compare une ligne à son propre groupe sans jointure :

muac["commune_mean"] = muac.groupby("commune")["muac_mm"].transform("mean")
muac["vs_commune"] = muac["muac_mm"] - muac["commune_mean"]

Utile pour signaler un site dont les mesures s’écartent systématiquement du reste — c’est exactement le contrôle qu’emploie le projet Analyse d’enquête SMART pour repérer une équipe mesurant 0,5 kg trop léger.

apply, et pourquoi l’éviter

apply exécute une fonction Python par groupe et constitue de loin l’opération la plus lente de pandas. C’est aussi la plus souple, donc celle vers laquelle on se tourne d’abord.

# Fonctionne, mais lent, et renvoie un objet dont la forme depend de la fonction.
muac.groupby("commune").apply(lambda g: g["muac_mm"].mean(), include_groups=False)

Préférez une agrégation nommée. Réservez apply aux opérations exigeant réellement le tableau du groupe entier — une régression par groupe, un classement par groupe avec égalités départagées sur une seconde colonne — et passez include_groups=False, désormais nécessaire pour éviter que les colonnes de regroupement ne soient transmises à votre fonction.

Les tableaux croisés se lisent mieux dans un rapport

pivot_table est un groupby suivi d’un unstack, avec une signature plus agréable :

pd.crosstab(muac["commune"], muac["outcome"])

pd.crosstab(
    muac["commune"], muac["outcome"],
    values=muac["muac_mm"], aggfunc="mean",
).round(1)

crosstab avec normalize="index" donne les proportions par ligne, ce qu’un tableau de référencement doit généralement montrer. Gardez les effectifs à côté : un taux de référencement de 50 % sur quatre enfants n’est pas le même fait que 50 % sur quatre cents, et seul l’effectif les distingue.

Écrire le résultat

OUTPUTS = PROJECT / "outputs" / "tables"
OUTPUTS.mkdir(parents=True, exist_ok=True)

by_commune.reset_index().to_csv(OUTPUTS / "gam_by_commune.csv", index=False)

index=False après reset_index() — sinon vous obtenez une colonne d’entiers sans nom que quelqu’un ouvrira plus tard dans Excel et utilisera pour trier.

Pour un tableau destiné à être lu plutôt que calculé, écrivez la définition avec lui :

definitions = pd.DataFrame([
    ("MAG", "PB < 125 mm ou oedemes bilateraux prenant le godet",
     "enfants avec une mesure de PB ou une evaluation d'oedemes saisie"),
], columns=["indicateur", "numerateur", "denominateur"])

definitions.to_csv(OUTPUTS / "definitions.csv", index=False)

Un nombre et sa définition voyagent ensemble, sinon la dispute mensuelle sur le dénominateur recommence.

Ce qui vient ensuite

Le tableau agrège correctement et les deux moitiés de chaque taux proviennent d’une seule opération. L’unité suivante règle la dernière pièce — dates, âges et périodes de rapportage, où un âge en mois décide du standard de croissance applicable — puis enveloppe l’ensemble dans un script qui s’exécute sur la machine de quelqu’un d’autre.

Animer cette leçon

La leçon en diaporama, la prose étant reléguée dans les notes du présentateur plutôt que projetée. Produit à partir de cette page, dont il ne peut donc pas s'écarter.

Lancer le diaporamaLire les diapositives

Le PDF ne requiert aucun logiciel et se projette depuis n'importe quel poste. Le fichier PowerPoint est fait pour être modifié : appliquez la charte de votre organisation, retirez une section pour une séance plus courte, ou fusionnez deux leçons en atelier.