cassionAnalyse de données

Leçon 1 sur 8

Unité · Avant de modifier quoi que ce soit

Le profil que l'on exécute avant d'y toucher

Un export neuf est une pièce à conviction tant que vous n'y touchez pas. Le lire d'abord comme du texte brut, inventorier chaque colonne, prouver ou réfuter la clé, et enregistrer le résultat comme trace de ce qui est arrivé.

PythonR90 minNorme humanitaire fondamentale (CHS)

Le fichier est une pièce à conviction tant que vous ne l’éditez pas

Dans six mois, quelqu’un vous demandera si le chiffre d’août a toujours été ainsi. Cette conversation a deux issues possibles. Soit vous ouvrez un fichier enregistré le jour de l’arrivée de l’export et vous y lisez la réponse, soit vous la reconstituez de mémoire, à partir d’un script modifié onze fois depuis.

La première chose à faire d’un export neuf n’est donc pas de le nettoyer. C’est de le profiler et d’écrire ce profil, avant qu’une seule valeur ne change. Le profil coûte vingt lignes, et c’est le seul artefact capable de prouver un jour l’état du fichier à son arrivée.

Cette leçon construit ce profil. Le cours Fondamentaux enseignait les cinq contrôles qui repèrent les défauts ; celui-ci en fait quelque chose que l’on conserve.

Lisez-le d’abord comme du texte

Tout lecteur de fichier devine. read_csv regarde les premiers milliers de lignes et décide que chaque colonne est un nombre, une date ou une chaîne, et chacune de ces suppositions peut détruire une information irrécupérable — un zéro initial sur un code de formation sanitaire, une sentinelle -99 moyennée, une date lue à l’américaine.

Lisez une première fois avec tout en chaîne de caractères. Cette lecture sert à regarder, pas à calculer.

import pandas as pd

PATH = "muac-screening-artibonite-2024.v1.csv"
raw = pd.read_csv(PATH, dtype="string", keep_default_na=False)

print(raw.shape)
print(raw.dtypes)
library(readr)
library(dplyr)

PATH <- "muac-screening-artibonite-2024.v1.csv"
raw <- read_csv(PATH, col_types = cols(.default = col_character()))

dim(raw)

Deux arguments travaillent réellement dans l’appel Python. dtype="string" désactive l’inférence de type ; keep_default_na=False empêche pandas de transformer les chaînes littérales NA, N/A, null et nan en valeurs manquantes avant que vous ne les ayez vues. Dans ce secteur, cela compte plus qu’il n’y paraît — une colonne où un enquêteur a tapé NA pour « sans objet » n’est pas la même colonne que celle où le champ a été sauté, et pandas confond les deux par défaut.

La lecture R impose délibérément col_character() partout, ce dont problems() a besoin pour être utile — en lisant strictement plus tard, toute valeur non convertible est signalée au lieu d’être silencieusement transformée en NA.

Ce à quoi un profil doit répondre

Six questions, et un profil qui ne répond pas aux six n’en est pas un.

  • Combien est arrivé ? Lignes et colonnes, face à ce que vous attendiez.
  • Qu’est réellement chaque colonne ? Ses valeurs brutes, pas le type deviné.
  • Où sont les trous ? Par colonne et par site — jamais un chiffre global.
  • La clé en est-elle une ? Non pas « la colonne existe-t-elle » mais « est-elle unique ».
  • Qu’est-ce qui sort des bornes ? Face aux limites du secteur, pas à celles des données.
  • Quels codes circulent ? Chaque valeur distincte de chaque colonne catégorielle.

La forme, face à une attente que vous écrivez

EXPECTED_COLUMNS = [
    "child_id", "commune", "screening_date", "age_months",
    "sex", "muac_mm", "oedema", "outcome",
]

print(f"{len(raw)} rows, {raw.shape[1]} columns")
print("missing columns:", set(EXPECTED_COLUMNS) - set(raw.columns))
print("unexpected columns:", set(raw.columns) - set(EXPECTED_COLUMNS))
EXPECTED_COLUMNS <- c(
  "child_id", "commune", "screening_date", "age_months",
  "sex", "muac_mm", "oedema", "outcome"
)

cat(nrow(raw), "rows,", ncol(raw), "columns\n")
setdiff(EXPECTED_COLUMNS, names(raw))
setdiff(names(raw), EXPECTED_COLUMNS)

Ce registre compte 4 218 lignes et huit colonnes. L’intérêt n’est pas le décompte, c’est que vous ayez écrit ce que vous attendiez. Une colonne qui disparaît discrètement d’un export à l’autre est la rupture la plus fréquente que livrent les plateformes de collecte, et elle reste invisible tant que rien ne compare à une liste.

L’inventaire des colonnes

Pour chaque colonne — combien de lignes vides, combien de valeurs distinctes, et les quelques plus fréquentes. Ce seul tableau remplace l’essentiel de ce que l’on fait en faisant défiler le fichier.

def inventory(df):
    rows = []
    for column in df.columns:
        values = df[column]
        counts = values.value_counts(dropna=False)
        rows.append({
            "column": column,
            "blank": int((values == "").sum()),
            "distinct": int(values.nunique(dropna=False)),
            "top": counts.index[0] if len(counts) else None,
            "top_n": int(counts.iloc[0]) if len(counts) else 0,
        })
    return pd.DataFrame(rows)


print(inventory(raw).to_string(index=False))
inventory <- function(df) {
  purrr::map_dfr(names(df), function(column) {
    values <- df[[column]]
    counts <- sort(table(values, useNA = "ifany"), decreasing = TRUE)
    tibble::tibble(
      column   = column,
      blank    = sum(values == "" | is.na(values)),
      distinct = dplyr::n_distinct(values),
      top      = names(counts)[1],
      top_n    = as.integer(counts[1])
    )
  })
}

print(inventory(raw), n = Inf)

Exécutez-le sur ce registre et deux lignes méritent qu’on s’y arrête.

Colonne Vides Distinctes Remarque
age_months 226 55 5,4 % de vides — la leçon 2 demande où
oedema 44 4 quatre valeurs distinctes dans une colonne booléenne

Quatre valeurs distinctes dans une colonne booléenne, voilà le constat. Le registre contient true, false, une chaîne vide et N — vingt-cinq lignes où un enquêteur a utilisé les conventions Y/N dans une colonne que le formulaire attendait en true/false. Convertissez cette colonne en booléen et les lignes N deviennent manquantes, silencieusement, et vous avez perdu vingt-cinq négatifs enregistrés en les traitant comme non renseignés.

C’est tout l’argument en faveur d’une première lecture en texte. Après la conversion, il n’y a plus rien à trouver.

Les sentinelles ne sont pas encore des valeurs manquantes

muac_mm a l’air complète — aucune case vide. Elle ne l’est pas.

print(raw["muac_mm"].value_counts().head())
print("rows coded -99:", int((raw["muac_mm"] == "-99").sum()))
raw |> count(muac_mm, sort = TRUE) |> head()
sum(raw$muac_mm == "-99")

Soixante-douze lignes portent -99, le code du registre pour « non mesuré ». Lisez la colonne comme un nombre sans déclarer ce code et la moyenne perd environ deux millimètres, la charge de cas est sous-estimée, parce que vous avez moyenné soixante-douze enfants à moins quatre-vingt-dix-neuf.

Les codes sentinelles sont documentés dans le dictionnaire des données et nulle part ailleurs. Consultez le dictionnaire avant la première lecture numérique, à chaque fois — -99, -1, 999, 9999 et 88 sont tous en usage dans ce secteur, et aucun n’a l’air faux dans un résumé.

La clé en est-elle une ?

La colonne nommée child_id est un identifiant. Savoir si elle est unique est une autre question, et la réponse est ici non.

duplicated_ids = raw["child_id"].duplicated(keep=False)
print("rows sharing a child_id:", int(duplicated_ids.sum()))
print("distinct ids involved:", raw.loc[duplicated_ids, "child_id"].nunique())
raw |>
  group_by(child_id) |>
  filter(n() > 1) |>
  ungroup() |>
  summarise(rows = n(), ids = n_distinct(child_id))

Vingt-quatre lignes réparties sur douze identifiants. Toute jointure que vous écrirez désormais fait une hypothèse sur cette colonne, et cette hypothèse est actuellement fausse. L’unité 2 traite de cela ; le rôle du profil se borne à le faire apparaître dès le premier jour plutôt qu’au milieu d’une jointure qui double discrètement une charge de cas.

Les bornes, avant de croire un résumé

Un résumé en cinq nombres masque précisément les valeurs que vous cherchez, car une ligne aberrante déplace à peine un quartile. Regardez les extrêmes directement.

muac = pd.to_numeric(raw["muac_mm"], errors="coerce")
muac = muac.where(muac != -99)

print(muac.describe())
print(muac.nsmallest(10).tolist())
print(muac.nlargest(10).tolist())
muac <- suppressWarnings(as.numeric(raw$muac_mm))
muac[muac == -99] <- NA

summary(muac)
head(sort(muac), 10)
head(sort(muac, decreasing = TRUE), 10)

Les dix plus petites valeurs donnent 13, 14, 14, 15, 16, 17, 18 puis sautent à 92. Les sept premières sont des centimètres jamais convertis — un PB de 13,4 cm saisi 13. La moyenne ne les remarque pas. La queue de distribution les nomme immédiatement.

Regardez toujours les dix plus petites et les dix plus grandes valeurs de toute colonne de mesure. Cela coûte une ligne, et c’est le contrôle le plus rentable de cette leçon.

Enregistrez le profil à côté des données

Le profil ne vaut d’être écrit que s’il survit à la session.

from pathlib import Path
import json

profile = {
    "file": PATH,
    "rows": len(raw),
    "columns": list(raw.columns),
    "blank_by_column": {c: int((raw[c] == "").sum()) for c in raw.columns},
    "distinct_by_column": {c: int(raw[c].nunique()) for c in raw.columns},
    "duplicate_key_rows": int(raw["child_id"].duplicated(keep=False).sum()),
    "categorical_values": {
        c: sorted(raw[c].unique().tolist())
        for c in ["commune", "sex", "oedema", "outcome"]
    },
}

Path("outputs/profiles").mkdir(parents=True, exist_ok=True)
Path("outputs/profiles/muac-2024-q4.json").write_text(json.dumps(profile, indent=2))
profile <- list(
  file    = PATH,
  rows    = nrow(raw),
  columns = names(raw),
  blank_by_column    = sapply(raw, function(x) sum(x == "" | is.na(x))),
  distinct_by_column = sapply(raw, dplyr::n_distinct),
  duplicate_key_rows = sum(duplicated(raw$child_id) | duplicated(raw$child_id, fromLast = TRUE)),
  categorical_values = lapply(
    raw[c("commune", "sex", "oedema", "outcome")],
    function(x) sort(unique(x))
  )
)

dir.create(here::here("outputs", "profiles"), recursive = TRUE, showWarnings = FALSE)
jsonlite::write_json(
  profile,
  here::here("outputs", "profiles", "muac-2024-q4.json"),
  pretty = TRUE, auto_unbox = TRUE
)

Du JSON plutôt qu’un tableau imprimé, parce que toute la section suivante consiste à en comparer deux.

Comparer cet export au précédent

La plupart des exports sont le même export, un trimestre plus tard. La question intéressante n’est donc jamais « qu’y a-t-il dans ce fichier » mais « qu’est-ce qui a changé ».

previous = json.loads(Path("outputs/profiles/muac-2024-q3.json").read_text())

for column, values in profile["categorical_values"].items():
    was, now = set(previous["categorical_values"][column]), set(values)
    if was != now:
        print(f"{column}: new {sorted(now - was)}, gone {sorted(was - now)}")

growth = (profile["rows"] - previous["rows"]) / previous["rows"]
print(f"row count moved {growth:.1%}")
previous <- jsonlite::read_json(
  here::here("outputs", "profiles", "muac-2024-q3.json"),
  simplifyVector = TRUE
)

for (column in names(profile$categorical_values)) {
  was <- previous$categorical_values[[column]]
  now <- profile$categorical_values[[column]]
  if (!setequal(was, now)) {
    cat(column, ": new", setdiff(now, was), "| gone", setdiff(was, now), "\n")
  }
}

sprintf("row count moved %.1f%%", 100 * (profile$rows - previous$rows) / previous$rows)

Une valeur nouvelle dans une colonne catégorielle est le moyen le plus courant pour qu’une analyse se mette à produire des réponses fausses sans jamais échouer. Un formulaire gagne une modalité de réponse, une treizième commune est ajoutée, un antigène est renommé — et tout case_when écrit avant ce jour envoie désormais la valeur nouvelle dans sa branche par défaut. Comparer les profils l’attrape à l’arrivée, le seul moment où c’est bon marché.

Un profil que vous n’avez pas enregistré est un profil que vous n’avez pas exécuté. Toute sa valeur tient à la possibilité de le rouvrir plus tard.

La suite

Vous savez désormais que ce registre a perdu 5,4 % de ses âges. Ce chiffre seul ne veut presque rien dire — il importe énormément de savoir si ces 226 lignes sont dispersées sur douze communes ou concentrées sur une seule. La leçon suivante décompose la non-réponse jusqu’à ce qu’elle cesse d’avoir l’air d’un accident ou qu’elle prouve qu’elle en est un, et chiffre ce que la suppression de ces lignes ferait au classement que vous publiez.

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.