cassionAnalyse de données

Retour à la leçonLeçon 7 sur 8La trace qui vous survit

Une validation qui tourne sans vous

Le même diaporama que les téléchargements, rendu sous forme de page. Lancez le diaporama pour le présenter en plein écran — les flèches ou un clic avancent d'une diapositive, Échap quitte.

Diapositives · PDFDiapositives · PowerPoint

  1. Diapositive 1 / 26

    Ce que couvre cette leçon

    • Les contrôles que vous avez exécutés ne sont pas ceux que vous avez
    • Un contrat, pas un script
    • Trois gravités, et une seule arrête l'exécution
    • Le validateur
    • Branchez-le sur la lecture, pas sur une cellule de notebook
    • Les constats sont un artefact, comme le profil
    • Ce que coûte l'échec bruyant, et pourquoi il reste juste
    • La suite
    Notes du présentateur
    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.
  2. Diapositive 2 / 26

    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.
    Notes du présentateur
    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.
  3. Diapositive 3 / 26

    Un contrat, pas un script — En Python (suite)

    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),
    Notes du présentateur
    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.
  4. Diapositive 4 / 26

    Un contrat, pas un script — En Python (suite)

        "max_missing": {"age_months": 0.10, "muac_mm": 0.05},
    }
  5. Diapositive 5 / 26

    Un contrat, pas un script — En R (suite)

    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),
  6. Diapositive 6 / 26

    Un contrat, pas un script — En R (suite)

      max_missing = list(age_months = 0.10, muac_mm = 0.05)
    )
  7. Diapositive 7 / 26

    Un contrat, pas un script

    • expected_rows — est un intervalle, pas un nombre
    • max_missing — transforme le constat de la leçon 2 en limite
    Notes du présentateur
    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.
  8. Diapositive 8 / 26

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

    GravitéSignificationEffet
    errorLe fichier n'est pas analysable en l'étatArrêt. Rien en aval ne s'exécute.
    warningQuelque chose ne va pas sur certaines lignesLes signaler, continuer, rapporter le compte
    noteBon à savoir, attenduRapport seulement
    Notes du présentateur
    C'est la décision de conception qui détermine si la suite sera utilisée ou désactivée.
  9. Diapositive 9 / 26

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

    • Les erreurs portent sur le fichier. Les avertissements portent sur des lignes — Si vous avez envie de faire d'un…
    Notes du présentateur
    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.
  10. Diapositive 10 / 26

    Le validateur — En Python (suite)

    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:
  11. Diapositive 11 / 26

    Le validateur — En Python (suite)

            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]
  12. Diapositive 12 / 26

    Le validateur — En Python (suite)

            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()
  13. Diapositive 13 / 26

    Le validateur — En Python (suite)

            if share > limit:
                findings.append(Finding(f"{column}-missing", "error", int(df[column].isna().sum()),
                                        f"{share:.1%} missing, limit {limit:.0%}"))
    
        return findings
  14. Diapositive 14 / 26

    Le validateur — En R (suite)

    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),
  15. Diapositive 15 / 26

    Le validateur — En R (suite)

            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),
  16. Diapositive 16 / 26

    Le validateur — En R (suite)

                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)
    }
    Notes du présentateur
    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é.
  17. Diapositive 17 / 26

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

    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
  18. Diapositive 18 / 26

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

    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)
    }
  19. Diapositive 19 / 26

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

    • Il n'existe désormais aucun moyen de lire ce fichier sans le valider — C'est tout le mécanisme
    Notes du présentateur
    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.
  20. Diapositive 20 / 26

    Les constats sont un artefact, comme le profil — En Python

    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)
    )
  21. Diapositive 21 / 26

    Les constats sont un artefact, comme le profil — En R

    jsonlite::write_json(findings,
      here::here("outputs", "validation", "muac-2024-q4.json"),
      pretty = TRUE
    )
  22. Diapositive 22 / 26

    Les constats sont un artefact, comme le profil — En Python

    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))
    Notes du présentateur
    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.
  23. Diapositive 23 / 26

    Les constats sont un artefact, comme le profil — En R

    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)
    Notes du présentateur
    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.
  24. Diapositive 24 / 26

    Ce que coûte l'échec bruyant, et pourquoi il reste juste — Exemple

    [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
    Notes du présentateur
    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. Ces deux lignes disent au lecteur quoi faire ensuite. Une AssertionError à la ligne 41 ne le dit pas.
  25. Diapositive 25 / 26

    La suite

    • La suite trouve désormais tout et ne change rien — à dessein, puisque chaque modification jusqu'ici a été un signalement.
    Notes du présentateur
    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.
  26. Diapositive 26 / 26

    La suite

    Lire la leçon complète, avec le code exécutable Retour à la leçon