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.
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.