Découper en train, validation et test
Trois jeux, trois rôles
| Jeu | Taille dans le lab | Qui le voit | Sert à |
|---|---|---|---|
| train (entraînement) | 70 | le job de réglage, pour ajuster les poids | apprendre |
| validation | 15 | le job de réglage, seulement pour mesurer à chaque étape | détecter le surapprentissage, choisir le nombre d'époques |
| test | 15 | personne pendant le réglage : seulement evaluate.py | la note finale, avant / après |
Le surapprentissage (overfitting) se produit quand le modèle apprend les exemples par cœur au lieu de la règle : sa perte (loss, l'erreur mesurée) baisse sur train mais remonte sur validation. Vertex AI affiche les deux courbes pendant le job.
flowchart LR A["100 exemples"] --> S["stratified_split, graine 42"] S --> T["train.jsonl : 70"] S --> V["validation.jsonl : 15"] S --> X["test.jsonl : 15"] T --> J["Job de réglage"] V --> J X --> E["evaluate.py, avant et après"]
Un découpage stratifié et reproductible
Stratifié veut dire que chaque jeu garde la même répartition des étiquettes que l'ensemble. Sans cela, le hasard peut mettre toutes les résiliations dans le train et aucune dans le test.
def stratified_split(rows: list[dict], ratios=(0.7, 0.15, 0.15), seed=SEED):
"""Découpe train/validation/test en gardant la même proportion de chaque étiquette."""
rng = random.Random(seed)
parts = ([], [], [])
for label in LABELS:
group = [r for r in rows if r["label"] == label]
rng.shuffle(group)
n_train = round(len(group) * ratios[0])
n_val = round(len(group) * ratios[1])
parts[0].extend(group[:n_train])
parts[1].extend(group[n_train:n_train + n_val])
parts[2].extend(group[n_train + n_val:])
for p in parts:
rng.shuffle(p)
return partsrandom.Random(42) rend le découpage reproductible : relancer le script donne les mêmes jeux, donc des évaluations comparables. Le test unitaire correspondant vérifie les tailles, la stratification et l'absence de recouvrement :
def test_split_is_stratified_and_disjoint():
rows = [{"ticket": f"{l} {i}", "label": l} for l in LABELS for i in range(20)]
train, val, test = stratified_split(rows)
assert (len(train), len(val), len(test)) == (70, 15, 15)
for part, n in ((train, 14), (val, 3), (test, 3)):
assert all(sum(r["label"] == l for r in part) == n for l in LABELS)Pendant le job, la perte sur train continue de baisser mais celle sur validation remonte depuis la 3e époque. Que se passe-t-il ?
C'est la signature du surapprentissage. On réduit le nombre d'époques (Vertex AI propose aussi des points de contrôle intermédiaires) ou on enrichit le jeu d'entraînement.
Les pièges classiques
| Piège | Symptôme | Parade |
|---|---|---|
| Quasi-doublons répartis entre train et test | note de test anormalement haute | dédoublonner avant de découper (norm()) |
| Découpage non stratifié | une classe absente du test | stratified_split |
| Données réelles découpées au hasard dans le temps | le modèle « connaît le futur » | pour des tickets réels, test = les plus récents |
| Même client dans train et test | le modèle apprend ses tournures | découper par client ou par conversation |
Pourquoi fixer la graine aléatoire du découpage ?
Si le test change à chaque exécution, une différence de note peut venir du jeu et non du modèle. La reproductibilité est la base d'une comparaison honnête.
Exercice : un découpage 80 / 20
Votre équipe ne veut pas de jeu de test séparé : « 80 % train, 20 % validation, et on évalue sur la validation ». Écrivez l'appel à stratified_split correspondant et expliquez en deux phrases le risque.
Voir la solution
train, val, test = stratified_split(rows, ratios=(0.8, 0.2, 0.0))
# test est vide : 16 + 4 par étiquetteRisque : la validation a été vue par le job (pour la perte de validation et, si l'on choisit un point de contrôle, pour sélectionner le meilleur). La note mesurée dessus est donc légèrement optimiste : on a « choisi » le modèle qui réussit le mieux ce jeu précis. Un test jamais touché donne une estimation honnête de ce qui se passera en production.
Résultat obtenu
Exécution réelle le 2026-10-15 : les trois jeux produits par distill.py, puis la suite de tests hors ligne (pytest, sans clé) :
train.jsonl: {"exemples": 70, "par_etiquette": {"demande_fonctionnalite": 14, "resiliation": 14, "facturation": 14, "acces_compte": 14, "bug_technique": 14}, ...}
validation.jsonl: {"exemples": 15, "par_etiquette": {"resiliation": 3, "facturation": 3, "bug_technique": 3, "demande_fonctionnalite": 3, "acces_compte": 3}, ...}
test.jsonl: {"exemples": 15, "par_etiquette": {"facturation": 3, "acces_compte": 3, "resiliation": 3, "demande_fonctionnalite": 3, "bug_technique": 3}, ...}
OK : format valide
$ pytest -q
.............. [100%]
14 passed in 0.31sJe sais faire
- Expliquer les rôles de train, validation et test
- Reconnaître le surapprentissage sur les courbes de perte
- Faire un découpage stratifié et reproductible
- Éviter les fuites : doublons, temps, même client