Aller au contenu
English

Les règles de validation

  • Leçon 16
  • 55 min
  • Module 6 : Contrats de données et passation

Ce que vous saurez faire

Décrivez ce sur quoi Priya peut compter dans le jeu de données avec des règles qu’un script vérifie chaque fois. Ainsi, un jeu de données incorrect déclenche un arrêt avec un message clair avant d’atteindre un modèle.

Carnet JupyterFaites cette leçon dans un carnet JupyterColab · Kaggle · GitHub · en anglais

Le carnet (notebook) Jupyter contient toutes les requêtes, tous les scripts, exercices et contrôles de cette leçon, avec les consignes. Il construit scripts/validate.py en quatre cellules, puis modifie volontairement les données pour tester les règles. Ses premières cellules installent pandas, téléchargent les données d’exercice et reconstruisent les fichiers des modules 3 à 5. Il fonctionne donc sans votre projet local. Lisez les explications ici et exécutez le code là-bas.

Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 55 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur (seulement pour le travail local; le carnet et les encadrés de cette page n’en ont pas besoin)

Testé avec Python 3.14.6 et pandas 3.0.6 sur macOS, et avec SQLite 3.49.1 dans le navigateur. Le carnet a été testé dans Jupyter.

Priya entraînera un modèle avec les fichiers de Mei. Elle demande : « Sur quoi puis-je compter? Est-ce que ticket_id est unique? Est-ce que channel peut manquer? Est-ce que la cible peut valoir 2? » Mei répond avec une liste de règles. Chaque règle indique ce qui doit être vrai dans chaque fichier livré.

D’abord, deux termes :

  • Le schéma d’une table est la liste de ses colonnes et le type de chaque colonne. Par exemple : ticket_id est du texte, priority est un entier.
  • Un contrat de données est une entente entre la personne qui crée un jeu de données et celles qui l’utilisent. Il contient le schéma et les règles sur les valeurs. Les règles indiquent les valeurs permises, les colonnes qui doivent avoir une valeur et la colonne qui sert de clé unique. Elles décrivent aussi les données dans leur ensemble.

Une règle de validation est un contrôle du contrat. Voici cinq billets de soutien et quatre règles. Lisez la table et trouvez les problèmes avant de lire la dernière colonne.

ticket_id channel priority escalated_72h Enfreint une règle?
T-1 email 2 0 non
T-2 Email 1 1 valeurs permises : Email ne figure pas dans la liste
T-3 chat 5 0 plage : priority doit être de 1 à 3; clé unique : T-3 apparaît deux fois
T-3 chat 5 0 les deux mêmes règles
T-4 NULL 3 0 valeur requise : channel manque

Les quatre règles :

  1. Valeurs requises. channel doit avoir une valeur dans chaque ligne.
  2. Valeurs et plages permises. channel est l’une des valeurs email, chat, phone, web_form. priority va de 1 à 3.
  3. Clé unique. Chaque ticket_id apparaît une seule fois.
  4. Distribution attendue. Entre 5 % et 30 % des billets sont transférés à un agent supérieur. Ici, 1 billet sur 5 est transféré : 20 %. Cette règle est respectée.

Les trois premières règles examinent les lignes individuellement. La quatrième examine toute la table. Elle détecte un autre type d’erreur : chaque ligne semble correcte, mais le total est faux. Par exemple, un bogue dans le code de la cible pourrait marquer presque aucun billet comme transféré. Chaque valeur reste alors 0 ou 1. Seul le taux révèle le bogue.

Aucun de ces problèmes ne déclenche d’erreur dans SQL ou pandas. Une règle rend visible une erreur silencieuse.

Des règles sous forme de requêtes qui ne doivent retourner aucune ligne

Section intitulée « Des règles sous forme de requêtes qui ne doivent retourner aucune ligne »

Une façon utile d’écrire une règle : écrire une requête qui retourne les lignes qui enfreignent la règle. La règle est respectée lorsque la requête retourne 0 ligne. Sinon, le résultat montre les lignes incorrectes, et pas seulement « faux ».

Les encadrés ci-dessous exécutent chaque règle sur l’exportation brute, la table tickets dans larkfield.db. L’exportation brute n’est pas propre, donc la plupart des règles y trouvent des problèmes. Après le pipeline, les mêmes règles ne doivent rien trouver.

Colonnes requises. La première règle vérifie le schéma lui-même : les colonnes sont-elles présentes? SQLite énumère les colonnes d’une table avec pragma_table_info('tickets'). La requête énumère chaque colonne attendue qui manque dans la table.

Exécutez du SQL dans votre navigateur

Essayez : les colonnes requises sont-elles présentes?

Exécutez la requête. L’encadré affiche « The statement ran and returned no rows » (l’instruction s’est exécutée sans retourner de lignes) : chaque colonne attendue existe. Puis remplacez 'channel' par 'contact_channel' et exécutez-la de nouveau. Vous devriez voir une ligne, contact_channel. Dans la dernière leçon de ce module, une nouvelle exportation renomme channel ainsi.

Clé unique. Une clé primaire est une colonne dont la valeur diffère dans chaque ligne, ce qui permet d’identifier une ligne. L’exportation n’en garantit pas, donc vous devez la vérifier.

Faites d’abord une prédiction : combien d’identifiants de billets apparaissent plus d’une fois? Vous les avez trouvés au module 1.

Exécutez du SQL dans votre navigateur

Essayez : ticket_id est-il unique?

Exécutez la requête. Vous devriez voir 14 lignes, de T-10031 à T-11291, chacune avec 2 copies. Ces lignes sont des doublons exacts causés par le chevauchement des pages d’exportation. clean.py les supprime.

Valeurs permises. La requête suivante semble correcte, mais elle contient une erreur silencieuse. Faites une prédiction : combien de groupes retourne-t-elle, et le canal manquant en fait-il partie?

Exécutez du SQL dans votre navigateur

À vous : détectez chaque canal non permis

Exécutez la requête. Vous voyez 4 groupes : chat, EMAIL, Email et webform. Les 38 lignes sans canal n’y sont pas. Pour un canal NULL, channel NOT IN (...) n’est pas vrai, mais inconnu, donc WHERE exclut la ligne. Modifiez la requête pour qu’elle retourne aussi le groupe NULL. Le contrôle compare votre résultat au nôtre.

Afficher la réponse
SELECT channel, COUNT(*) AS n
FROM tickets
WHERE channel IS NULL
OR channel NOT IN ('email', 'chat', 'phone', 'web_form')
GROUP BY channel
ORDER BY n DESC;

Cinq groupes : NULL 38, Email 20, chat 8, EMAIL 7, webform 5. Cela donne 78 lignes. Une règle sur les valeurs permises doit préciser comment elle traite une valeur manquante. Ici, les valeurs manquantes ne sont pas permises.

Plages. Une règle de plage donne la plus petite et la plus grande valeur permise. Grace dit qu’une commande de plus de 2 000 $ est très rare chez Larkfield. Une commande négative est impossible. Faites une prédiction : quels deux problèmes du module 4 cette règle trouve-t-elle?

Exécutez du SQL dans votre navigateur

Essayez : valeurs de commande hors de la plage de 0 à 2 000

Exécutez la requête. Vous devriez voir 36 lignes. Les 11 premières ont -1, la valeur sentinelle qui signifie « inconnu ». Les 25 autres sont des billets de janvier soumis par formulaire Web, avec des montants en cents. Le dernier, T-10147, est aussi le billet dont l’année est 2036.

Exécutez du SQL dans votre navigateur

À vous : une règle de plage pour word_count

Le contrat dit : word_count va de 0 à 10 000. Zéro est permis, car un message peut contenir seulement une pièce jointe. Écrivez une requête qui retourne ticket_id et word_count pour chaque ligne qui enfreint cette règle. Le contrôle compare votre résultat au nôtre.

Afficher la réponse
SELECT ticket_id, word_count
FROM tickets
WHERE word_count < 0
OR word_count > 10000;

Une ligne : T-10361 avec 48 210 mots, le fichier de journaux (logs) collé dans le message. clean.py la met en quarantaine avec la raison word_count_above_10000.

Sur votre ordinateur : toutes les règles dans un rapport

Section intitulée « Sur votre ordinateur : toutes les règles dans un rapport »

Sur votre ordinateur, vous pouvez réunir les règles dans une requête qui compte les problèmes pour chacune. UNION ALL place les résultats de plusieurs instructions SELECT les uns sous les autres. Enregistrez ceci dans queries/60-contract-checks.sql :

queries/60-contract-checks.sql
-- Each rule counts the rows that break it. A rule passes with 0.
SELECT 'ticket_id is unique' AS rule,
(SELECT COUNT(*) FROM (SELECT ticket_id FROM tickets
GROUP BY ticket_id HAVING COUNT(*) > 1)) AS problems
UNION ALL
SELECT 'required values are present', COUNT(*)
FROM tickets
WHERE ticket_id IS NULL OR created_at IS NULL OR team IS NULL OR priority IS NULL
UNION ALL
SELECT 'channel has an allowed value', COUNT(*)
FROM tickets
WHERE channel IS NULL OR channel NOT IN ('email', 'chat', 'phone', 'web_form')
UNION ALL
SELECT 'order_value is 0 to 2000', COUNT(*)
FROM tickets
WHERE order_value < 0 OR order_value > 2000
UNION ALL
SELECT 'word_count is 0 to 10000', COUNT(*)
FROM tickets
WHERE word_count < 0 OR word_count > 10000
UNION ALL
SELECT 'created_at is in the first half of 2026', COUNT(*)
FROM tickets
WHERE created_at < '2026-01-01' OR created_at >= '2026-07-01';
python q.py queries/60-contract-checks.sql
rule problems
--------------------------------------- --------
ticket_id is unique 14
required values are present 0
channel has an allowed value 78
order_value is 0 to 2000 36
word_count is 0 to 10000 1
created_at is in the first half of 2026 1
6 rows

La première ligne compte les identifiants de billets, les autres comptent les lignes. Une règle est respectée dans l’exportation brute : aucun billet n’a d’identifiant, d’heure, d’équipe ou de priorité manquants. Les cinq autres trouvent les problèmes que les modules 3 à 5 traitent. C’est le but : les règles décrivent les données après le pipeline.

Les règles SQL vérifient la table brute dans la base de données. Priya reçoit des fichiers CSV. Mei réécrit donc le contrat en Python pour les fichiers dans data/model/. Le script est scripts/validate.py. Il utilise seulement pandas, sans bibliothèque supplémentaire. Il comporte quatre parties.

Les règles sont au début du fichier, sous forme de simples valeurs Python. N’importe qui peut les lire sans lire le code. Modifier le contrat revient à modifier ces lignes.

scripts/validate.py (part 1 of 4)
"""Check the delivered dataset against its contract: the rules that Priya can
rely on in data/model/. Prints PASS or FAIL for each rule, writes
reports/validation.json, and exits with code 1 if any rule fails."""
import json
import sys
from pathlib import Path
import pandas as pd
MODEL = Path("data/model")
REPORT = Path("reports/validation.json")
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
SPLITS = ["train", "valid", "test"]
# ---------------------------------------------------------------- the contract
# Every column, its type, and whether a value is required.
COLUMNS = {
"ticket_id": ("text", True),
"customer_id": ("text", False), # guests have no customer
"created_at": ("datetime", True),
"channel": ("text", True),
"team": ("text", True),
"priority": ("integer", True),
"segment": ("text", True),
"region": ("text", True),
"order_value": ("number", False), # no order (account) or unknown
"word_count": ("integer", True),
"customer_tenure_days": ("number", False), # guests and deleted customers
"prior_tickets_90d": ("integer", True),
"created_hour": ("integer", True),
"escalated_72h": ("integer", True), # the target
}
# Known only after the ticket is created: never in the dataset.
NEVER = ["priority_now", "first_reply_minutes", "closed_at"]
ALLOWED = {
"channel": {"email", "chat", "phone", "web_form", "unknown"},
"team": {"delivery", "returns", "payment", "warranty", "account"},
"segment": {"home", "trade", "business", "guest", "unknown"},
"region": {"north", "south", "east", "west", "unknown"},
}
RANGES = { # inclusive; None means no limit
"priority": (1, 3),
"order_value": (0, 2000),
"word_count": (0, 10_000),
"customer_tenure_days": (0, None),
"prior_tickets_90d": (0, None),
"created_hour": (0, 23),
# From the start of the selection to the snapshot minus 72 hours.
"created_at": (pd.Timestamp("2026-01-01"), pd.Timestamp("2026-06-28 06:00:00")),
}
ESCALATION_RATE = (0.05, 0.30)

Chaque valeur vient d’une décision prise dans un module précédent :

Règle Pourquoi cette valeur
order_value et customer_tenure_days peuvent manquer Les billets liés aux comptes n’ont pas de commande, et le −1 du système de commandes est maintenant une valeur manquante (module 4). Les invités et les clients supprimés n’ont pas de date d’inscription.
channel permet unknown clean.py transforme un canal manquant en une catégorie distincte, unknown (inconnu).
NEVER Ces colonnes sont remplies après la création du billet. Elles révèlent la réponse (module 5).
created_at jusqu’au 28 juin 2026 à 6 h Un billet doit avoir ses 72 heures complètes avant l’instantané, sinon sa cible n’est pas définitive.
Taux de transfert de 5 % à 30 % L’exportation a un taux de 13,7 %. Grace s’attend à des variations selon les changements de politique, mais pas d’un facteur de 3. Un taux hors de cette plage indique un changement du code de la cible, pas du service de soutien.
scripts/validate.py (part 2 of 4)
# ---------------------------------------------------------------- helpers
results = []
def rule(name, problems):
"""Record one rule. An empty list of problems means the rule passes."""
results.append({"rule": name, "passed": not problems, "problems": problems})
print(f"{'PASS' if not problems else 'FAIL'} {name}")
for problem in problems:
print(f" {problem}")
def first(df, mask, column):
"""The first bad row, as an example: its ticket ID and its value."""
row = df[mask].iloc[0]
value = row[column]
shown = repr(value) if isinstance(value, str) else value
return f"first: {row['ticket_id']}, {shown}"
def n(count, word):
"""'1 row', '3 rows'."""
return f"{count} {word}" + ("" if count == 1 else "s")
def load(path):
"""Read every value as text. Types are checked by the rules, not assumed."""
return pd.read_csv(path, dtype=str, keep_default_na=False, na_values=[""])
def convert(text, kind):
"""Convert a text column to its type. A value that cannot be read becomes missing."""
if kind == "text":
return text
if kind == "datetime":
return pd.to_datetime(text, format=TIME_FORMAT, errors="coerce")
numbers = pd.to_numeric(text, errors="coerce")
if kind == "integer":
numbers = numbers.where(numbers % 1 == 0) # 2.5 is not an integer
return numbers

rule() est au cœur du script. Chaque contrôle crée une liste de problèmes, et rule() affiche PASS (réussite) ou FAIL (échec), puis les problèmes en dessous. Chaque message indique la colonne, le nombre de lignes et le premier billet incorrect pour vous aider à le trouver.

load() lit chaque valeur comme du texte. Pourquoi ne pas laisser pandas choisir les types? Parce que pandas choisit sans vous avertir. Si une valeur dans order_value est $10.85, pandas lit toute la colonne comme du texte, sans avertissement. convert() essaie ensuite chaque valeur. Une valeur présente mais impossible à convertir constitue un problème de type, que la partie suivante compte.

scripts/validate.py (part 3 of 4)
# ---------------------------------------------------------------- the rules
def check_table(raw):
"""Rules for the modelling table, one row per ticket. Returns the typed table."""
missing = [c for c in COLUMNS if c not in raw.columns]
leaky = [c for c in raw.columns if c in NEVER]
extra = [c for c in raw.columns if c not in COLUMNS and c not in NEVER]
rule("required columns are present",
[f"missing column: {c}" for c in missing])
rule("no column that is known only after creation",
[f"{c} must not be in the dataset" for c in leaky] + [f"unexpected column: {c}" for c in extra])
if missing:
return None
df = raw.copy()
problems = []
for column, (kind, _) in COLUMNS.items():
df[column] = convert(raw[column], kind)
bad = raw[column].notna() & df[column].isna()
if bad.any():
problems.append(f"{column}: not a valid {kind} in {n(bad.sum(), 'row')} ({first(raw, bad, column)})")
rule("every value has its column's type", problems)
problems = []
for column, (_, required) in COLUMNS.items():
if required and raw[column].isna().any():
problems.append(f"{column}: missing in {n(raw[column].isna().sum(), 'row')}")
rule("no missing values in required columns", problems)
repeated = df["ticket_id"].duplicated(keep=False)
rule("ticket_id is unique",
[f"{n(df.loc[repeated, 'ticket_id'].nunique(), 'ticket ID')} repeated "
f"(first: {df.loc[repeated, 'ticket_id'].iloc[0]})"] if repeated.any() else [])
problems = []
for column, allowed in ALLOWED.items():
bad = df[column].notna() & ~df[column].isin(allowed)
if bad.any():
values = sorted(df.loc[bad, column].unique())
problems.append(f"{column}: values not allowed in {n(bad.sum(), 'row')}: {values}")
rule("categories use allowed values only", problems)
problems = []
for column, (low, high) in RANGES.items():
values = df[column]
bad = pd.Series(False, index=df.index)
if low is not None:
bad |= values < low
if high is not None:
bad |= values > high
if bad.any():
problems.append(f"{column}: outside {low} to {high} in {n(bad.sum(), 'row')} ({first(df, bad, column)})")
rule("numbers and dates are in their allowed ranges", problems)
bad = ~df["escalated_72h"].isin([0, 1])
rule("the target escalated_72h is 0 or 1",
[f"not 0 or 1 in {n(bad.sum(), 'row')} ({first(df, bad, 'escalated_72h')})"] if bad.any() else [])
return df

Chaque bloc est une règle, et chaque règle trouve d’abord les lignes incorrectes, comme les règles SQL. Deux détails :

  • duplicated(keep=False) marque chaque copie d’un identifiant répété, pas seulement la deuxième.
  • La règle des valeurs permises ignore les valeurs manquantes (notna()). Une valeur manquante relève de la règle des valeurs requises. Ainsi, chaque problème est signalé une seule fois, par la règle qui l’explique le mieux.

Partie 4 : règles pour les séparations et résultat

Section intitulée « Partie 4 : règles pour les séparations et résultat »
scripts/validate.py (part 4 of 4)
def rate_problem(name, target):
"""The escalation rate must be in the expected range, or something upstream changed."""
low, high = ESCALATION_RATE
rate = pd.to_numeric(target).mean()
if not low <= rate <= high:
return [f"{name}: escalation rate {rate:.1%} is outside {low:.0%} to {high:.0%}"]
return []
def check_splits(table, parts):
"""Rules for train, valid and test."""
empty = [name for name, part in parts.items() if len(part) == 0]
rule("every split has rows", [f"{name} has no rows" for name in empty])
if empty:
return
problems = []
for name, part in parts.items():
if list(part.columns) != list(table.columns):
problems.append(f"{name}: the columns are not the same as in tickets_model.csv")
together = pd.concat(parts.values())
if len(together) != len(table):
problems.append(f"the splits have {len(together)} rows together; tickets_model.csv has {len(table)}")
if set(together["ticket_id"]) != set(table["ticket_id"]):
problems.append("the splits do not contain the same tickets as tickets_model.csv")
rule("together, the splits are exactly the modelling table", problems)
problems = []
for i, a in enumerate(SPLITS):
for b in SPLITS[i + 1:]:
shared = set(parts[a]["ticket_id"]) & set(parts[b]["ticket_id"])
if shared:
problems.append(f"{a} and {b} share {n(len(shared), 'ticket ID')} (first: {sorted(shared)[0]})")
rule("no ticket is in two splits", problems)
problems = []
for a, b in zip(SPLITS, SPLITS[1:]):
last = pd.to_datetime(parts[a]["created_at"], format=TIME_FORMAT).max()
start = pd.to_datetime(parts[b]["created_at"], format=TIME_FORMAT).min()
if not last < start:
problems.append(f"{a} ends at {last}, which is not before the start of {b} at {start}")
rule("the splits are in time order: train, then valid, then test", problems)
problems = rate_problem("all rows", table["escalated_72h"])
for name, part in parts.items():
problems += rate_problem(name, part["escalated_72h"])
rule(f"escalation rate between {ESCALATION_RATE[0]:.0%} and {ESCALATION_RATE[1]:.0%}", problems)
def main():
files = ["tickets_model.csv"] + [f"{name}.csv" for name in SPLITS]
missing = [f for f in files if not (MODEL / f).exists()]
if missing:
print(f"FAIL missing file(s) in {MODEL}: {', '.join(missing)}")
print("Run build_features.py and split.py first.")
sys.exit(1)
print(f"Checking {MODEL} against the contract")
table = check_table(load(MODEL / "tickets_model.csv"))
if table is not None:
check_splits(table, {name: load(MODEL / f"{name}.csv") for name in SPLITS})
failed = [r for r in results if not r["passed"]]
REPORT.parent.mkdir(exist_ok=True)
REPORT.write_text(json.dumps({"files": files, "passed": not failed, "rules": results}, indent=2) + "\n",
encoding="utf-8")
if failed:
print(f"{len(failed)} of {len(results)} rules failed. Fix the step that made the data, then run it again.")
sys.exit(1)
print(f"All {len(results)} rules passed.")
if __name__ == "__main__":
main()

Les règles de séparation protègent les décisions du module 5. Un billet dans deux ensembles crée une fuite : le modèle voit un billet de test pendant l’entraînement. Des ensembles hors de l’ordre temporel mêlent le futur aux données d’entraînement. La règle du taux s’applique à chaque ensemble, car un ensemble incorrect peut se cacher dans un total correct.

sys.exit(1) termine le script avec le code de sortie 1. Chaque programme retourne un code de sortie au terminal quand il se termine : 0 signifie réussite, tout autre nombre signifie échec. Les autres programmes lisent le code, pas le texte. Dans la dernière leçon de ce module, run_pipeline.py l’utilise pour s’arrêter.

Mei l’exécute sur les fichiers du module 5 :

python scripts/validate.py
Checking data/model against the contract
PASS required columns are present
PASS no column that is known only after creation
PASS every value has its column's type
PASS no missing values in required columns
PASS ticket_id is unique
PASS categories use allowed values only
PASS numbers and dates are in their allowed ranges
PASS the target escalated_72h is 0 or 1
PASS every split has rows
PASS together, the splits are exactly the modelling table
PASS no ticket is in two splits
PASS the splits are in time order: train, then valid, then test
PASS escalation rate between 5% and 30%
All 13 rules passed.

Le script enregistre aussi les mêmes résultats dans reports/validation.json. Ce fichier est le rapport de qualité que Priya reçoit avec les données.

Les 13 règles sont respectées, mais fonctionnent-elles vraiment? Une règle qui n’échoue jamais ne prouve rien. Mei teste la règle de distribution avec un vrai bogue. Dans build_features.py, elle tape minutes au lieu de hours :

scripts/build_features.py (a bug, for the test only)
WINDOW = pd.Timedelta(minutes=72)

build_features.py s’exécute sans erreur. Puis elle exécute split.py et validate.py. Deux règles échouent :

python scripts/validate.py (with the bug)
FAIL numbers and dates are in their allowed ranges
created_at: outside 2026-01-01 00:00:00 to 2026-06-28 06:00:00 in 29 rows (first: T-11374, 2026-06-28 07:14:18)
...
FAIL escalation rate between 5% and 30%
all rows: escalation rate 0.8% is outside 5% to 30%
train: escalation rate 0.5% is outside 5% to 30%
valid: escalation rate 1.8% is outside 5% to 30%
test: escalation rate 0.8% is outside 5% to 30%
2 of 13 rules failed. Fix the step that made the data, then run it again.

Chaque ligne reste valide : chaque cible vaut 0 ou 1. Mais seulement 11 billets sur 1 398 sont maintenant transférés. Peu de billets atteignent un agent supérieur en 72 minutes. Le bogue a aussi déplacé la limite pour une cible définitive à 72 minutes avant l’instantané. Les 29 billets trop récents sont donc revenus. Sans les règles, Priya entraîne un modèle sur la mauvaise cible, et ses scores semblent normaux. Mei remet hours=72 dans la ligne.

Exercice guidé : exécuter le contrat, puis l’enfreindre

Section intitulée « Exercice guidé : exécuter le contrat, puis l’enfreindre »
  1. Créez scripts/validate.py avec les quatre parties ci-dessus, dans l’ordre, dans un seul fichier.

  2. Dans le terminal, allez dans le dossier de votre projet et vérifiez que l’environnement est actif.

  3. Exécutez le script :

    Fenêtre de terminal
    python scripts/validate.py

    Vous devriez voir 13 lignes PASS et All 13 rules passed. (les 13 règles sont respectées).

  4. Lisez le code de sortie. Il vaut 0.

    Terminal
    echo $?
  5. Introduisez volontairement une erreur. Cette commande ajoute la dernière ligne d’entraînement à la fin de test.csv :

    Fenêtre de terminal
    python -c "import pandas as pd; train = pd.read_csv('data/model/train.csv'); train.tail(1).to_csv('data/model/test.csv', mode='a', header=False, index=False)"
  6. Prédisez quelles règles échoueront. Puis exécutez de nouveau python scripts/validate.py. Vous devriez voir :

    FAIL together, the splits are exactly the modelling table
    the splits have 1370 rows together; tickets_model.csv has 1369
    FAIL no ticket is in two splits
    train and test share 1 ticket ID (first: T-10929)
    FAIL the splits are in time order: train, then valid, then test
    valid ends at 2026-05-31 20:57:12, which is not before the start of test at 2026-04-30 23:54:47
    PASS escalation rate between 5% and 30%
    3 of 13 rules failed. Fix the step that made the data, then run it again.
  7. Relisez le code de sortie, comme à l’étape 4. Il vaut 1.

  8. Réparez les fichiers. Exécutez l’étape qui les a créés : python scripts/split.py.

  9. Exécutez de nouveau python scripts/validate.py. Les 13 règles sont respectées.

Une seule ligne incorrecte a enfreint trois règles, et chaque message la signale sous un angle différent. La règle du taux reste respectée : une ligne change peu un taux. Voilà pourquoi un contrat a besoin de règles sur les lignes et de règles de distribution.

À vous de jouer : une règle sur la taille des données d’évaluation

Section intitulée « À vous de jouer : une règle sur la taille des données d’évaluation »

Priya ajoute une demande : « Avec moins de 30 billets de soutien escaladés, un seul billet peut beaucoup changer le score de validation ou de test. Je veux au moins 30 billets escaladés dans chacun de ces ensembles. »

  1. Ajoutez une constante MIN_ESCALATED = 30 dans la partie du contrat.
  2. Ajoutez une règle à la fin de check_splits() qui compte les billets escaladés dans valid et dans test.
  3. Prédisez : la règle réussit-elle? Puis exécutez le script.
  4. Changez la valeur pour 40 et prédisez de nouveau.
Afficher la réponse
scripts/validate.py (in the contract part)
MIN_ESCALATED = 30
scripts/validate.py (at the end of check_splits)
problems = []
for name in ["valid", "test"]:
escalated = int(pd.to_numeric(parts[name]["escalated_72h"]).sum())
if escalated < MIN_ESCALATED:
problems.append(f"{name}: {escalated} escalated tickets; at least {MIN_ESCALATED} are needed")
rule(f"valid and test have at least {MIN_ESCALATED} escalated tickets each", problems)

Avec 30, la règle réussit : valid contient 45 billets escaladés, et test en contient 35. La dernière ligne est All 14 rules passed. (les 14 règles ont réussi). Avec 40, elle échoue :

FAIL valid and test have at least 40 escalated tickets each
test: 35 escalated tickets; at least 40 are needed
1 of 14 rules failed. Fix the step that made the data, then run it again.

Les données ne sont pas incorrectes ici : les données de test sont peu nombreuses. Priya et Mei doivent décider de la solution, par exemple une période de test plus longue. Une règle peut échouer parce que les données ne répondent pas à un besoin. C’est utile aussi : elle révèle le problème avant que quelqu’un se fie à un score.

Erreur courante

Une règle qui ne peut pas échouer

Symptôme : la règle sur les canaux autorisés réussit sur l’exportation brute, où vous savez que Email (courriel) et webform (formulaire Web) existent.

Cause : les valeurs autorisées viennent des données que la règle vérifie. Par exemple :

the mistake
import pandas as pd
df = pd.read_csv("data/extract/tickets.csv", dtype=str, keep_default_na=False, na_values=[""])
allowed = set(df["channel"].dropna()) # the mistake: the rule comes from the data
print(sorted(allowed))
print("rows that break the rule:", (~df["channel"].dropna().isin(allowed)).sum())
Sortie
[' chat', 'EMAIL', 'Email', 'chat', 'email', 'phone', 'web_form', 'webform']
rows that break the rule: 0

Chaque valeur dans les données est « autorisée », parce que la liste est créée à partir des données. La règle ne peut jamais échouer.

Solution :

  1. Écrivez les valeurs autorisées à la main, selon l’entente avec les personnes qui connaissent les données. Grace connaît les quatre canaux.
  2. Testez chaque nouvelle règle une fois sur des données qui doivent la faire échouer, comme dans l’exercice guidé. Si elle n’échoue pas, elle ne fonctionne pas.
the fix
contract = {"email", "chat", "phone", "web_form"} # the fix: the rule comes from the agreement
print("rows that break the rule:", (~df["channel"].dropna().isin(contract)).sum())
Sortie
rows that break the rule: 40

Dans le travail en IA : une vérification qui réussit toujours est pire qu’aucune vérification, parce que les gens s’y fient. Une nouvelle catégorie en production, par exemple un canal social (réseaux sociaux), atteint alors le modèle sans que personne le remarque.

Vérifiez votre compréhension

Pourquoi une règle retourne-t-elle les lignes qui l'enfreignent, et pas seulement vrai ou faux?

Afficher la réponse

Parce que les lignes vous indiquent quoi corriger. « Faux » indique qu’il y a un problème. « 36 lignes, la première est T-10046 avec −1 » indique le problème et où chercher. Le nombre montre aussi l’ampleur du problème. Une seule ligne est souvent une erreur de frappe. Avec 25 lignes, c’est souvent un problème du système, comme les cents de l’ancien formulaire Web.

Vérifiez votre compréhension

Prédisez : Tomás remet priority_now dans tickets_model.csv, parce que « ça fonctionne tellement bien ». Quelle règle échoue, et que dit le message?

Afficher la réponse

La règle « aucune colonne connue seulement après la création » échoue avec priority_now must not be in the dataset (priority_now ne doit pas être dans le jeu de données). La colonne figure dans NEVER. Si les ensembles séparés n’ont pas cette colonne non plus, une deuxième règle échoue : « ensemble, les ensembles séparés correspondent exactement à la table de modélisation ». Les colonnes ne sont pas les mêmes. Le contrat transforme la décision du module 5 en vérification. Personne n’a besoin de s’en souvenir.

Vérifiez votre compréhension

Le taux d'escalade est de 11,7 % dans train et de 19,8 % dans valid. Omar propose une règle : chaque ensemble doit rester à moins de 3 points de pourcentage du taux global de 13,7 %. Que se passe-t-il, et est-ce une bonne règle?

Afficher la réponse

Valid échoue : 19,8 % dépasse 13,7 % de 6,1 points. Mais les données sont correctes. Le taux a augmenté après le 1er mai 2026 à cause de la nouvelle politique de garantie. Une séparation temporelle montre volontairement ce changement. Une règle de distribution doit décrire ce qui est normal, selon les personnes qui connaissent les données, comme Grace. Une règle qui échoue sur des données correctes apprend aux gens à ignorer les échecs. La plage de 5 % à 30 % détecte les bogues et permet les changements réels.

Récapitulatif

  • Un schéma énumère les colonnes et leurs types. Un contrat de données ajoute des règles sur les valeurs. C’est une entente entre la personne qui crée le jeu de données et celles qui l’utilisent.
  • Quatre types de règles : valeurs obligatoires, valeurs et plages autorisées, clés uniques et distributions attendues.
  • Écrivez chaque règle comme une vérification qui retourne les lignes qui l’enfreignent. Elle réussit avec 0 ligne.
  • En SQL, NOT IN exclut les lignes NULL. Précisez comment une règle traite les valeurs manquantes.
  • validate.py lit chaque valeur comme du texte, vérifie 13 règles, écrit reports/validation.json et termine avec le code 1 en cas d’échec.
  • Testez chaque règle sur des données qui doivent la faire échouer. Une règle qui ne peut pas échouer est pire qu’aucune règle.

Ensuite, vous noterez la provenance des données et les transformations subies en chemin. Priya pourra ainsi s’y fier et reproduire le processus.

Leçon suivante : Suivre la provenance des données