cassionAnalyse de données

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.

Python85 min

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.

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.