Leçon 4 sur 8
Unité · Faire entrer l'export
Codes, sentinelles et le 99 qui entre dans une moyenne
Valeurs sentinelles, vocabulaires catégoriels, booléens saisis de cinq façons et étiquettes de valeurs Stata — transformer ce que le formulaire a enregistré en ce que pandas peut calculer.
Un nombre qui signifie « pas de réponse »
Les formulaires papier et les systèmes bâtis à leur image n’ont pas de blanc. Ils
ont un code. 99 signifie « non répondu », 88 « sans objet », -99 « non
mesuré », et 999 existe parce que quelqu’un en a eu besoin d’un troisième.
Aucun n’est une valeur. Tous sont des nombres du point de vue de pandas.
import pandas as pd
muac = pd.read_csv(RAW / "muac-screening-artibonite-2024.v1.csv")
print(muac["muac_mm"].mean()) # plusieurs millimetres sous la verite
Le résultat est faux et, pire, plausible. Rien n’y ressemble à une erreur, et
une moyenne de PB n’est pas un chiffre que la plupart des lecteurs peuvent
vérifier à l’œil. Un 99 dans une colonne d’âge est encore plus discret — c’est
un âge possible.
Déclarer les sentinelles à la lecture
muac = pd.read_csv(
RAW / "muac-screening-artibonite-2024.v1.csv",
dtype={"child_id": "string", "commune": "string"},
na_values={"muac_mm": ["-99"]},
)
print(muac["muac_mm"].mean())
print(muac["muac_mm"].isna().sum())
Restreignez la sentinelle à sa colonne. na_values=["-99"] sans
dictionnaire l’applique à toutes les colonnes du fichier, ce qui reste juste
jusqu’à ce qu’une colonne porte légitimement -99.
La liste des sentinelles est une documentation, non une tradition orale
Les sentinelles viennent du dictionnaire de codes du formulaire. Écrivez-les là où le code peut les voir :
SENTINELS = {
"muac_mm": ["-99"], # non mesure
"age_months": ["99", "-1"], # non repondu / sans objet
}
muac = pd.read_csv(path, na_values=SENTINELS)
Une sentinelle que vous ignorez est invisible. Cela vaut un contrôle direct par colonne numérique avant de lui faire confiance :
for column in ["muac_mm", "age_months"]:
print(column, sorted(muac[column].dropna().unique())[:5],
sorted(muac[column].dropna().unique())[-5:])
Des valeurs agglutinées aux extrêmes — 98, 99 en haut d’une colonne d’âge, -1
en bas — sont des sentinelles, non des observations. Un histogramme avec un pic
exactement sur 99 est le même signal.
Déclarer une sentinelle n’est pas décider quoi faire de la valeur manquante. Cette décision — supprimer, imputer, rapporter séparément — relève de l’analyse, et doit être consignée. Ici vous empêchez seulement un code de se faire passer pour une mesure.
Vocabulaires catégoriels
outcome dans le registre PB prend quatre valeurs et sex en prend deux.
Déclarer le vocabulaire vous vaut une alerte dès qu’autre chose apparaît.
OUTCOMES = pd.CategoricalDtype(
["no-action", "referred-tsfp", "referred-otp", "referred-sc"], ordered=False
)
muac["outcome"] = muac["outcome"].astype(OUTCOMES)
print(muac["outcome"].isna().sum())
Comptez les manquants immédiatement après la conversion. C’est là le piège :
une valeur hors des catégories déclarées devient NaN au lieu de lever une
erreur. Un passage de zéro à neuf signifie que neuf lignes portaient une valeur
que vous ignoriez, et vous voulez le voir maintenant plutôt que le découvrir
comme un trou dans un tableau trois étapes plus loin.
Pour savoir lesquelles, comparez avant et après :
raw_values = set(muac_raw["outcome"].dropna().unique())
declared = set(OUTCOMES.categories)
print("inattendues :", raw_values - declared)
Catégories ordonnées
Certains vocabulaires ont un ordre, et le déclarer fait fonctionner la comparaison :
LADDER = pd.CategoricalDtype(
["surface-water", "unimproved", "limited", "basic", "safely-managed"],
ordered=True,
)
wash["service"] = wash["service"].astype(LADDER)
at_least_basic = wash["service"] >= "basic"
Sans ordered=True cette comparaison lève une erreur. Avec, l’échelle du JMP se
trie et se trace dans le bon ordre plutôt qu’alphabétiquement — c’est la
différence entre un graphique lisible et un graphique qui place « basic » entre
« unimproved » et « limited ».
Des booléens saisis de cinq façons
La colonne oedema en est l’exemple type. Deux communes ont saisi Y et N au
premier trimestre ; les autres true et false.
print(muac["oedema"].value_counts(dropna=False))
Ne convertissez jamais une telle colonne directement :
# Faux d'une maniere difficile a voir : toute chaine non vide est vraie,
# donc "false" devient True.
muac["oedema"] = muac["oedema"].astype(bool)
Faites une correspondance explicite, avec une liste d’autorisation :
BOOLEANS = {
"true": True, "TRUE": True, "Y": True, "y": True, "yes": True, "1": True,
"false": False, "FALSE": False, "N": False, "n": False, "no": False, "0": False,
}
muac["oedema"] = (
muac["oedema"].astype("string").str.strip().str.lower()
.map({k.lower(): v for k, v in BOOLEANS.items()})
)
print(muac["oedema"].isna().sum()) # ce que la correspondance n'a pas couvert
Une liste d’autorisation plutôt qu’une heuristique, pour la raison qui justifie une séance entière sur cette leçon : tout ce qui en sort reste manquant et est compté, au lieu d’être deviné. Une valeur que la table ne couvre pas est une question à poser à qui l’a saisie.
Utilisez le booléen nullable de pandas quand un véritable « non renseigné » existe :
muac["oedema"] = muac["oedema"].astype("boolean") # True / False / <NA>
bool ne peut pas porter de manquant ; boolean le peut. Dans ce registre,
44 enregistrements n’ont aucune évaluation d’œdèmes, et les ramener à False
affirmerait silencieusement que 44 enfants ont été examinés et déclarés indemnes.
Du texte qui devrait être une seule valeur
Les colonnes proches du texte libre arrivent avec des variations de casse, d’espacement et d’orthographe. L’enquête EAH porte un district écrit de quatre façons.
print(wash["district"].value_counts())
# Nord-Ouest 727
# NORD-OUEST 33
# Nord Ouest 22
# nord-ouest 20
Sans regroupement, cela éclate le district en quatre fragments dont aucun ne paraît alarmant.
wash["district"] = (
wash["district"].astype("string").str.strip().str.lower()
.str.replace(r"\s+", "-", regex=True)
)
print(wash["district"].nunique()) # 3
Normalisez avant le premier groupby, non après avoir remarqué que les
totaux ne tombent pas juste. Et normalisez vers une forme canonique que vous
choisissez, plutôt que de retenir l’orthographe la plus fréquente — la plus
fréquente peut changer au prochain export.
Là où la variation n’est pas mécanique — Gonaives contre Gonaïves, St-Marc
contre Saint-Marc — une table de correspondance est la réponse honnête :
CANONICAL = {
"gonaives": "Gonaïves",
"st-marc": "Saint-Marc",
"saint-marc": "Saint-Marc",
}
wash["district"] = wash["district"].map(CANONICAL).fillna(wash["district"])
Gardez cette table dans le code, non dans votre tête. C’est une décision documentée que quelqu’un devra vérifier.
Stata et SPSS transportent leurs étiquettes
Un .dta issu d’un institut de sondage porte à la fois le code et son sens :
1 = Oui, 2 = Non, 9 = Ne sait pas.
survey = pd.read_stata(path) # valeurs converties en etiquettes
survey = pd.read_stata(path, convert_categoricals=False) # codes bruts
pandas vous donne l’un ou l’autre. Pour garder les deux — ce que vous voulez, car le code est ce que documente le dictionnaire et l’étiquette est ce dont un lecteur a besoin — lisez les métadonnées séparément :
with pd.io.stata.StataReader(path) as reader:
labels = reader.value_labels()
variables = reader.variable_labels()
print(variables["hh_size"])
print(labels.get("consent", {}))
Le paquet haven de R préserve les étiquettes plus complètement que pandas. Si
on vous remet un .dta d’un institut de sondage et que les étiquettes comptent,
le lire sous R et écrire un CSV accompagné d’un dictionnaire est une étape
légitime — et c’est le seul endroit de ce cours où la réponse est « utilisez
l’autre langage ».
La fonction de lecture, assemblée
Tout ce qui précède appartient à une fonction unique que le notebook et les scripts partagent :
from pathlib import Path
import pandas as pd
SENTINELS = {"muac_mm": ["-99"]}
OUTCOMES = pd.CategoricalDtype(
["no-action", "referred-tsfp", "referred-otp", "referred-sc"]
)
BOOLEANS = {"true": True, "y": True, "yes": True, "1": True,
"false": False, "n": False, "no": False, "0": False}
def read_register(path: Path) -> pd.DataFrame:
muac = pd.read_csv(
path,
dtype={"child_id": "string", "commune": "string", "sex": "string"},
na_values=SENTINELS,
)
muac["screening_date"] = pd.to_datetime(
muac["screening_date"], format="%Y-%m-%d", errors="raise"
)
muac["outcome"] = muac["outcome"].astype(OUTCOMES)
muac["oedema"] = (
muac["oedema"].astype("string").str.strip().str.lower()
.map(BOOLEANS).astype("boolean")
)
assert muac["child_id"].notna().all(), "chaque ligne exige un identifiant"
return muac
Une fonction, un seul endroit à corriger quand le prochain export introduira une cinquième façon d’écrire « non ».
Ce qui vient ensuite
Le tableau est en mémoire et chaque colonne dit ce qu’elle signifie. L’unité suivante le travaille : sélectionner et filtrer sans l’avertissement que personne ne lit, puis regrouper jusqu’au numérateur et au dénominateur qu’exige réellement un indicateur.