cassionAnalyse de données

Leçon 6 sur 8

Décisions de nettoyage et journal de nettoyage

Trancher le traitement de chaque défaut, chiffrer ce que la décision coûte au résultat final, et la consigner pour qu'un auditeur — ou vous-même dans six mois — puisse suivre le raisonnement.

PythonR75 min

Le nettoyage est un ensemble de décisions, pas un script

Le profilage de la leçon précédente a produit une liste de défauts. Aucun n’a de traitement objectivement correct. Chacun a un traitement défendable et une justification, et c’est la justification qui compte : un relecteur ne vous reprochera pas d’avoir supprimé sept lignes, il vous reprochera de ne pas pouvoir savoir pourquoi.

Donc, pour chaque défaut, trois éléments. Ce que vous avez fait, pourquoi, et combien de lignes cela a touché.

Les quatre options

Chaque défaut reçoit l’une d’entre elles.

Option Quand elle s’applique Ce qu’elle coûte
Corriger Vous savez quelle valeur était visée Rien, si vous avez raison
Passer en manquant La valeur est fausse et irrécupérable De la précision, et peut-être du biais
Supprimer la ligne La ligne n’est pas une observation réelle De la charge de cas, si vous vous trompez
Conserver et signaler La valeur peut être juste, sans moyen de trancher Rien, mais le lecteur doit la traiter

« Conserver et signaler » est sous-employée. C’est l’option honnête lorsqu’une valeur est suspecte sans être impossible, et elle renvoie le jugement à la personne qui lit l’analyse plutôt que de le dissimuler à l’intérieur.

Traitement de ce registre

Erreurs d’unité — sept enregistrements en centimètres. Récupérables. Un PB de 13,3 a été mesuré à 133 mm ; toute l’affaire tient à la virgule. Corrigez-les, et consignez-le.

import numpy as np

erreur_unite = muac["muac_mm"].notna() & (muac["muac_mm"] < 40)
n_erreur_unite = int(erreur_unite.sum())

muac.loc[erreur_unite, "muac_mm"] = muac.loc[erreur_unite, "muac_mm"] * 10
erreur_unite <- !is.na(muac$muac_mm) & muac$muac_mm < 40
n_erreur_unite <- sum(erreur_unite)

muac <- muac |>
  mutate(muac_mm = if_else(!is.na(muac_mm) & muac_mm < 40, muac_mm * 10L, muac_mm))

Multiplier par dix n’est sûr ici que parce que les plages plausibles ne se chevauchent pas : rien de réellement mesuré en millimètres n’est en dessous de 40, et rien de réellement mesuré en centimètres n’est au-dessus de 22. Vérifiez que les deux plages sont disjointes avant d’appliquer une règle de ce genre. Si elles se chevauchent, vous ne pouvez pas distinguer les cas et les valeurs ne sont pas récupérables.

Valeurs implausibles qui ne sont pas des erreurs d’unité. Irrécupérables. Passez-les en manquant, mais ne supprimez pas la ligne : l’enfant a bien été dépisté, et le reste de l’enregistrement reste une donnée probante.

implausible = muac["muac_mm"].notna() & (
    (muac["muac_mm"] < 80) | (muac["muac_mm"] > 220)
)
n_implausible = int(implausible.sum())

muac.loc[implausible, "muac_mm"] = np.nan
implausible <- !is.na(muac$muac_mm) & (muac$muac_mm < 80 | muac$muac_mm > 220)
n_implausible <- sum(implausible)

muac <- muac |>
  mutate(muac_mm = if_else(implausible, NA_integer_, muac_mm))

Doublons exacts — douze lignes. À supprimer. Un formulaire soumis deux fois sur une mauvaise connexion correspond à un dépistage, pas à deux.

avant = len(muac)
muac = muac.drop_duplicates()
n_doublons_exacts = avant - len(muac)
avant <- nrow(muac)
muac <- distinct(muac)
n_doublons_exacts <- avant - nrow(muac)

Réenregistrements sous un nouvel identifiant — six suspectés. À conserver et signaler. Vous ne pouvez pas les distinguer d’authentiques coïncidences sans retourner au registre : marquez-les et dites-le dans le rapport.

cle = ["commune", "screening_date", "age_months", "sex", "muac_mm"]
muac["doublon_suspecte"] = muac.duplicated(subset=cle, keep=False) & muac[
    "muac_mm"
].notna()

n_suspectes = int(muac["doublon_suspecte"].sum())
muac <- muac |>
  group_by(commune, screening_date, age_months, sex, muac_mm) |>
  mutate(doublon_suspecte = n() > 1 & !is.na(muac_mm)) |>
  ungroup()

n_suspectes <- sum(muac$doublon_suspecte)

Codage incohérent des œdèmes. Récupérable. Y et N signifient bien ce qu’ils paraissent signifier ; les cases vides, non, et elles deviennent manquantes.

table_oedeme = {
    "true": True, "TRUE": True, "Y": True, "y": True, "yes": True,
    "false": False, "FALSE": False, "N": False, "n": False, "no": False,
}
muac["oedema"] = muac["oedema"].astype("string").str.strip().map(table_oedeme)
n_oedeme_manquant = int(muac["oedema"].isna().sum())
muac <- muac |>
  mutate(
    oedema = case_when(
      tolower(trimws(oedema)) %in% c("true", "y", "yes") ~ TRUE,
      tolower(trimws(oedema)) %in% c("false", "n", "no") ~ FALSE,
      TRUE ~ NA
    )
  )

n_oedeme_manquant <- sum(is.na(muac$oedema))

Âge manquant concentré sur Gros-Morne. Voilà le cas qui exige une décision plutôt qu’une règle, et il fait l’objet de la section suivante.

Chiffrez ce que la décision coûte

La raison de prendre l’âge manquant au sérieux, c’est que le traitement évident — supprimer les lignes sans âge — change la réponse. Mesurez-le au lieu de le supposer.

seuil_mag = 125

complet = muac.dropna(subset=["age_months", "muac_mm"])
tous_mesures = muac.dropna(subset=["muac_mm"])

def taux_mag(df):
    return (df["muac_mm"] < seuil_mag).mean()

comparaison = pd.DataFrame(
    {
        "en_supprimant": complet.groupby("commune").apply(taux_mag),
        "en_conservant": tous_mesures.groupby("commune").apply(taux_mag),
    }
)
comparaison["ecart"] = (
    comparaison["en_supprimant"] - comparaison["en_conservant"]
)
print(comparaison.sort_values("ecart", ascending=False).round(4))
taux_mag <- function(df) mean(df$muac_mm < 125, na.rm = TRUE)

complet <- muac |> filter(!is.na(age_months), !is.na(muac_mm))
tous_mesures <- muac |> filter(!is.na(muac_mm))

comparaison <- full_join(
  complet |> group_by(commune) |> summarise(en_supprimant = taux_mag(pick(everything()))),
  tous_mesures |> group_by(commune) |> summarise(en_conservant = taux_mag(pick(everything()))),
  by = "commune"
) |>
  mutate(ecart = en_supprimant - en_conservant) |>
  arrange(desc(abs(ecart)))

comparaison

Gros-Morne bouge, et les autres communes à peine. Voilà le biais, rendu visible. Comme l’indicateur ne dépend pas ici de l’âge — les seuils de PB pour les 6 à 59 mois forment une bande unique, à la différence des scores z poids-taille — la bonne décision consiste à conserver les lignes, à les utiliser pour l’indicateur PB, et à ne les exclure que des analyses qui exigent réellement l’âge.

C’est une meilleure décision que la suppression, et elle n’est disponible que parce que vous avez regardé.

Principe général : supprimez des lignes pour une analyse donnée, jamais du jeu de données. Une ligne sans âge reste une donnée probante sur le PB. Filtrer au point d’utilisation maintient chaque analyse sur le plus grand échantillon qu’elle admette.

Le journal de nettoyage

Tout ce qui précède devient des lignes d’un tableau livré avec l’analyse.

journal_nettoyage = pd.DataFrame(
    [
        {
            "regle": "Erreur d'unité PB corrigée (cm vers mm)",
            "colonne": "muac_mm",
            "action": "corrige",
            "lignes": n_erreur_unite,
            "justification": "Les valeurs sous 40 sont des centimètres non convertis. "
            "Les plages plausibles en mm et en cm sont disjointes, la correction est donc sans ambiguïté.",
        },
        {
            "regle": "PB implausible passé en manquant",
            "colonne": "muac_mm",
            "action": "passe-manquant",
            "lignes": n_implausible,
            "justification": "Hors de 80-220 mm, ce n'est pas une mesure pour 6-59 mois. "
            "Ligne conservée : le dépistage a bien eu lieu.",
        },
        {
            "regle": "Doubles soumissions exactes supprimées",
            "colonne": "toutes",
            "action": "supprime",
            "lignes": n_doublons_exacts,
            "justification": "Des lignes identiques correspondent à un formulaire soumis deux fois sur une mauvaise connexion.",
        },
        {
            "regle": "Réenregistrement suspecté signalé",
            "colonne": "doublon_suspecte",
            "action": "signale",
            "lignes": n_suspectes,
            "justification": "Mêmes commune, date, âge, sexe et PB sous des identifiants différents. "
            "Indiscernable d'une coïncidence sans le registre papier.",
        },
        {
            "regle": "Codage des œdèmes harmonisé",
            "colonne": "oedema",
            "action": "corrige",
            "lignes": n_oedeme_manquant,
            "justification": "Ennery et Desdunes ont utilisé Y/N au T1. Les cases vides restent manquantes.",
        },
        {
            "regle": "Âge manquant conservé",
            "colonne": "age_months",
            "action": "conserve",
            "lignes": int(muac["age_months"].isna().sum()),
            "justification": "60 % de manquants sur la semaine du 10 au 14 juin à Gros-Morne. "
            "Supprimer modifie le classement des communes ; les seuils de PB n'exigent pas l'âge.",
        },
    ]
)

journal_nettoyage.to_csv("output/tables/journal-nettoyage.csv", index=False)
print(journal_nettoyage[["regle", "action", "lignes"]])
journal_nettoyage <- tibble::tribble(
  ~regle,                                      ~colonne,             ~action,          ~lignes,
  "Erreur d'unité PB corrigée (cm vers mm)",   "muac_mm",            "corrige",        n_erreur_unite,
  "PB implausible passé en manquant",          "muac_mm",            "passe-manquant", n_implausible,
  "Doubles soumissions exactes supprimées",    "toutes",             "supprime",       n_doublons_exacts,
  "Réenregistrement suspecté signalé",         "doublon_suspecte",   "signale",        n_suspectes,
  "Codage des œdèmes harmonisé",               "oedema",             "corrige",        n_oedeme_manquant,
  "Âge manquant conservé",                     "age_months",         "conserve",       sum(is.na(muac$age_months))
)

readr::write_csv(journal_nettoyage, "output/tables/journal-nettoyage.csv")
journal_nettoyage

Quatre propriétés rendent ce journal utile plutôt que cérémoniel :

  • Les effectifs viennent du code, pas de la mémoire. Si la règle change, l’effectif change avec elle.
  • Il est livré avec l’analyse — en annexe du rapport, et non comme un fichier sur le portable de quelqu’un.
  • La justification est une phrase, pas une catégorie. « Qualité des données » n’est pas une justification.
  • Il s’écrit pendant le nettoyage, et non reconstitué après coup. Les journaux reconstitués sont faux précisément là où cela compte.

La seule chose à ne jamais faire

Ne modifiez pas data/raw/. Pas une cellule, pas pour corriger une coquille manifeste, pas « juste cette fois » la veille d’une échéance.

Chacune des corrections ci-dessus est du code. Elle est donc visible, réversible, appliquée à l’identique à l’export du trimestre suivant, et réexécutable par quelqu’un qui doute de vous. Une cellule modifiée à la main dans un tableur n’est rien de tout cela, et elle est invisible six mois plus tard lorsque le chiffre est contesté.

La suite

Les données sont propres et les décisions sont consignées. La leçon suivante les transforme en indicateur — ce qui suppose d’affronter le fait qu’un taux est un numérateur rapporté à un dénominateur, et que presque personne ne s’accorde sur le dénominateur.

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.

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.