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.
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
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.