cassionAnalyse de données

Leçon 7 sur 8

Unité · La trace qui vous survit

Une validation qui tourne sans vous

Transformer les contrôles en une suite qui s'exécute à chaque lecture — un contrat de schéma, trois gravités, un artefact de rapport, et la décision de savoir quels échecs ont le droit d'arrêter la chaîne.

PythonR90 minNorme humanitaire fondamentale (CHS)

Les contrôles que vous avez exécutés ne sont pas ceux que vous avez

Six leçons de contrôles, et chacun d’eux a été exécuté par une personne qui a décidé de l’exécuter. Au trimestre suivant, il se produira l’une de trois choses — vous les relancez et cela prend une après-midi, quelqu’un d’autre en lance certains, ou l’export part directement dans le tableau de bord parce que l’échéance était mardi.

La troisième est l’issue normale. Les seuls contrôles qui survivent sont ceux qui s’exécutent que quelqu’un y pense ou non, et cette leçon montre comment y arriver.

Un contrat, pas un script

Commencez par écrire à quoi ressemble un export valide, séparément du code qui le lit. Cet énoncé est le contrat, et tout le reste en découle.

CONTRACT = {
    "name": "muac-screening",
    "key": ["child_id"],
    "columns": {
        "child_id":       {"type": "string",  "required": True},
        "commune":        {"type": "string",  "required": True, "allowed": COMMUNES},
        "screening_date": {"type": "date",    "required": True,
                           "min": "2024-01-01", "max": "2024-12-31"},
        "age_months":     {"type": "integer", "required": False, "min": 6, "max": 59},
        "sex":            {"type": "string",  "required": True, "allowed": ["f", "m"]},
        "muac_mm":        {"type": "integer", "required": False, "min": 80, "max": 220,
                           "sentinels": [-99]},
        "oedema":         {"type": "boolean", "required": False},
        "outcome":        {"type": "string",  "required": True, "allowed": OUTCOMES},
    },
    "expected_rows": (3500, 5000),
    "max_missing": {"age_months": 0.10, "muac_mm": 0.05},
}
CONTRACT <- list(
  name = "muac-screening",
  key  = "child_id",
  columns = list(
    child_id       = list(type = "character", required = TRUE),
    commune        = list(type = "character", required = TRUE, allowed = COMMUNES),
    screening_date = list(type = "Date", required = TRUE,
                          min = as.Date("2024-01-01"), max = as.Date("2024-12-31")),
    age_months     = list(type = "integer", required = FALSE, min = 6, max = 59),
    sex            = list(type = "character", required = TRUE, allowed = c("f", "m")),
    muac_mm        = list(type = "integer", required = FALSE, min = 80, max = 220,
                          sentinels = -99),
    oedema         = list(type = "logical", required = FALSE),
    outcome        = list(type = "character", required = TRUE, allowed = OUTCOMES)
  ),
  expected_rows = c(3500, 5000),
  max_missing = list(age_months = 0.10, muac_mm = 0.05)
)

Deux champs y sont faciles à négliger et ce sont eux qui attrapent les surprises.

expected_rows est un intervalle, pas un nombre. Un export de 400 lignes là où vous en attendez quatre mille est un téléchargement tronqué ou un filtre que quelqu’un a laissé actif, et il sera sinon découvert le jour où la charge de cas paraîtra encourageante.

max_missing transforme le constat de la leçon 2 en limite. Dix pour cent d’âges manquants est tolérable et documenté ; trente pour cent signifie qu’un formulaire est cassé, et la différence entre les deux n’est pas une chose que l’on souhaite remarquer à l’œil.

Trois gravités, et une seule arrête l’exécution

C’est la décision de conception qui détermine si la suite sera utilisée ou désactivée.

Gravité Signification Effet
error Le fichier n’est pas analysable en l’état Arrêt. Rien en aval ne s’exécute.
warning Quelque chose ne va pas sur certaines lignes Les signaler, continuer, rapporter le compte
note Bon à savoir, attendu Rapport seulement

Une colonne absente est une erreur — tout calcul postérieur est faux. Sept valeurs de PB implausibles sont un avertissement : 4 211 lignes restent analysables, et arrêter la chaîne pour 0,17 % d’entre elles signifie que la chaîne s’arrête tous les mois et que quelqu’un finit par la retirer.

Les erreurs portent sur le fichier. Les avertissements portent sur des lignes. Si vous avez envie de faire d’un contrôle de ligne une erreur, ce que vous voulez en réalité est un seuil sur le nombre de lignes autorisées à échouer — c’est exactement ce qu’est max_missing.

Le validateur

from dataclasses import dataclass


@dataclass
class Finding:
    check: str
    severity: str
    count: int
    detail: str


def validate(df, contract):
    findings = []

    missing = set(contract["columns"]) - set(df.columns)
    if missing:
        findings.append(Finding("columns-present", "error", len(missing),
                                f"missing columns: {sorted(missing)}"))
        return findings                      # nothing else is meaningful

    low, high = contract["expected_rows"]
    if not low <= len(df) <= high:
        findings.append(Finding("row-count", "error", len(df),
                                f"{len(df)} rows, expected {low}-{high}"))

    duplicated = df.duplicated(subset=contract["key"], keep=False)
    if duplicated.any():
        findings.append(Finding("key-unique", "error", int(duplicated.sum()),
                                f"rows sharing a {contract['key']}"))

    for column, spec in contract["columns"].items():
        values = df[column]
        if spec.get("required") and values.isna().any():
            findings.append(Finding(f"{column}-required", "error",
                                    int(values.isna().sum()), "required column has blanks"))
        if "allowed" in spec:
            unexpected = set(values.dropna().unique()) - set(spec["allowed"])
            if unexpected:
                findings.append(Finding(f"{column}-allowed", "error", len(unexpected),
                                        f"unexpected values: {sorted(unexpected)}"))
        if "min" in spec:
            out = (values < spec["min"]) | (values > spec["max"])
            if out.any():
                findings.append(Finding(f"{column}-range", "warning", int(out.sum()),
                                        f"outside {spec['min']}-{spec['max']}"))

    for column, limit in contract["max_missing"].items():
        share = df[column].isna().mean()
        if share > limit:
            findings.append(Finding(f"{column}-missing", "error", int(df[column].isna().sum()),
                                    f"{share:.1%} missing, limit {limit:.0%}"))

    return findings
validate <- function(df, contract) {
  findings <- list()
  add <- function(check, severity, count, detail) {
    findings[[length(findings) + 1]] <<-
      tibble::tibble(check = check, severity = severity, count = count, detail = detail)
  }

  missing <- setdiff(names(contract$columns), names(df))
  if (length(missing)) {
    add("columns-present", "error", length(missing),
        paste("missing columns:", paste(missing, collapse = ", ")))
    return(dplyr::bind_rows(findings))
  }

  if (!dplyr::between(nrow(df), contract$expected_rows[1], contract$expected_rows[2])) {
    add("row-count", "error", nrow(df), sprintf("%d rows, expected %d-%d", nrow(df),
        contract$expected_rows[1], contract$expected_rows[2]))
  }

  dup <- duplicated(df[contract$key]) | duplicated(df[contract$key], fromLast = TRUE)
  if (any(dup)) add("key-unique", "error", sum(dup), "rows sharing a key")

  for (column in names(contract$columns)) {
    spec <- contract$columns[[column]]
    values <- df[[column]]
    if (isTRUE(spec$required) && any(is.na(values))) {
      add(paste0(column, "-required"), "error", sum(is.na(values)), "required column has blanks")
    }
    if (!is.null(spec$allowed)) {
      unexpected <- setdiff(unique(stats::na.omit(values)), spec$allowed)
      if (length(unexpected)) {
        add(paste0(column, "-allowed"), "error", length(unexpected),
            paste("unexpected values:", paste(unexpected, collapse = ", ")))
      }
    }
    if (!is.null(spec$min)) {
      out <- values < spec$min | values > spec$max
      if (any(out, na.rm = TRUE)) {
        add(paste0(column, "-range"), "warning", sum(out, na.rm = TRUE),
            sprintf("outside %s-%s", spec$min, spec$max))
      }
    }
  }

  dplyr::bind_rows(findings)
}

Remarquez le retour anticipé après une colonne absente. Une fois la forme fausse, tout constat ultérieur est du bruit, et un validateur qui affiche quatre- vingt-dix constats alors que le vrai problème est une colonne renommée a échoué dans sa tâche même s’il a fonctionné.

Branchez-le sur la lecture, pas sur une cellule de notebook

def load_screening(path, contract=CONTRACT):
    df = read_typed(path, contract)
    findings = validate(df, contract)

    errors = [f for f in findings if f.severity == "error"]
    for finding in findings:
        print(f"[{finding.severity}] {finding.check}: {finding.count} - {finding.detail}")

    if errors:
        raise ValueError(f"{len(errors)} validation errors in {path}")
    return df, findings
load_screening <- function(path, contract = CONTRACT) {
  df <- read_typed(path, contract)
  findings <- validate(df, contract)

  if (nrow(findings)) print(findings, n = Inf)
  if (any(findings$severity == "error")) {
    stop(sprintf("%d validation errors in %s", sum(findings$severity == "error"), path))
  }
  list(data = df, findings = findings)
}

Il n’existe désormais aucun moyen de lire ce fichier sans le valider. C’est tout le mécanisme. Une fonction de validation que personne n’appelle est de la documentation ; une fonction de validation placée dans le chargeur est une garantie.

Si vous utilisez une bibliothèque — pandera en Python, pointblank ou validate en R — elle fait la même chose avec moins de code et de meilleurs rapports. Servez-vous-en si vous le pouvez. Si cette leçon l’écrit à la main, c’est que la forme importe plus que l’outil, et la forme est celle-ci — contrat, gravités, constats, chargeur.

Les constats sont un artefact, comme le profil

import json
from pathlib import Path

Path("outputs/validation").mkdir(parents=True, exist_ok=True)
Path("outputs/validation/muac-2024-q4.json").write_text(
    json.dumps([f.__dict__ for f in findings], indent=2)
)
jsonlite::write_json(findings,
  here::here("outputs", "validation", "muac-2024-q4.json"),
  pretty = TRUE
)

Enregistrés à côté du profil d’arrivée de la leçon 1, ils vous donnent quelque chose de plus précieux que chacun séparément — une série. Trimestre après trimestre, les mêmes contrôles sur le même fichier, et le mouvement des comptes est une tendance de qualité des données que vous pouvez rapporter sans collecter quoi que ce soit de nouveau.

history = pd.concat([
    pd.read_json(p).assign(quarter=p.stem)
    for p in sorted(Path("outputs/validation").glob("*.json"))
])
print(history.pivot_table(index="check", columns="quarter", values="count", fill_value=0))
history <- purrr::map_dfr(
  list.files(here::here("outputs", "validation"), full.names = TRUE),
  ~ jsonlite::read_json(.x, simplifyVector = TRUE) |>
      dplyr::mutate(quarter = tools::file_path_sans_ext(basename(.x)))
)

tidyr::pivot_wider(history, id_cols = check, names_from = quarter, values_from = count)

Un nombre d’avertissements qui baisse après une visite de formation est la preuve que la formation a servi. C’est un bien meilleur usage de ce travail qu’un tableau propre.

Ce que coûte l’échec bruyant, et pourquoi il reste juste

Une chaîne qui s’arrête a un coût réel — quelqu’un attend le chiffre, et le voilà qui vous attend. Il faut reconnaître honnêtement qu’il s’agit d’un arbitrage et non d’un gain gratuit.

L’arbitrage est favorable pour une raison. Un chiffre faux qui est parti coûte plus cher qu’un rapport en retard, parce que le chiffre faux se fait citer, figure dans une proposition, et se compare au trimestre suivant — et la correction, si elle a lieu un jour, doit le poursuivre à travers tous les documents qu’il a atteints.

La règle est donc : arrêter sur tout ce qui rend le résultat faux, signaler tout ce qui rend certaines lignes fausses, et rendre le message d’échec assez bon pour que la personne qui le rencontre à 7 heures du matin puisse agir sans vous.

[error] commune-allowed: 1 - unexpected values: ['Petite-Riviere']
[error] age_months-missing: 226 - 5.4% missing, limit 5%
ValueError: 2 validation errors in muac-screening-2024-q4.csv

Ces deux lignes disent au lecteur quoi faire ensuite. Une AssertionError à la ligne 41 ne le dit pas.

La suite

La suite trouve désormais tout et ne change rien — à dessein, puisque chaque modification jusqu’ici a été un signalement. La dernière leçon porte sur ce qu’il advient de ces signalements : les décisions, qui les a prises, l’effet de chacune sur le chiffre, et le journal qui accompagne le rapport pour que personne n’ait à demander.

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.