Une suite pytest peut refuser des données cassées et un modèle moins bon avant toute promotion : vous l'écrivez, puis elle contrôle le schéma et les bornes du jeu, compare la RMSE à un seuil versionné, puis au modèle champion. Sans elle, un modèle est promu parce que sa métrique « a l'air correcte », sur un CSV que personne n'inspecte.
Cette leçon est la sixième du module Reproduire de la formation MLOps. Public : intermédiaire, qui sait lancer pytest et écrire une fonction Python simple. Le scénario vient de deux incidents typiques du fil rouge : une colonne renommée en amont, et des valeurs impossibles (un CPU à 140 %). Vous écrirez la suite qui les attrape, et vous verrez pourquoi un seuil absolu ne suffit pas.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Contrôler le schéma d'un jeu de données et les bornes de ses valeurs.
- Fixer un seuil de métrique dans un fichier versionné.
- Comparer un modèle candidat au champion avec une tolérance.
- Rendre une suite rejouable sur d'autres jeux et d'autres modèles par variables d'environnement.
- Choisir entre assertions pandas et une bibliothèque de validation.
Quels sont les prérequis ?
Section intitulée « Quels sont les prérequis ? »La leçon suppose celles du module : un projet uv avec params.yaml
(seed, part_test), le générateur du jeu de métriques de services (graine 42,
5000 lignes) et le script train.py que le lab fournit : celui du fil rouge,
mais qui prend --arbres N et --sortie chemin en ligne de commande au lieu de
lire n_arbres dans params.yaml. Ni DVC ni MLflow ne servent ici ; aucun
serveur n'est nécessaire. Versions mesurées : scikit-learn 1.9.1, pandas 3.0.6.
pytest 9.1.1 est celui du projet : le pyproject.toml du lab déclare la
dépendance pytest sans version, uv sync la résout dans uv.lock et l'installe
dans .venv, d'où uv run pytest --version rend pytest 9.1.1. Ce n'est pas le
pytest embarqué par dsoxlab, qui sert au contrôle du lab. La suite n'emploie que
des fonctionnalités anciennes (fonctions test_*, assert, fixtures).
Pourquoi tester les données et le modèle avant de promouvoir ?
Section intitulée « Pourquoi tester les données et le modèle avant de promouvoir ? »Une métrique correcte ne prouve ni que les données sont valides ni que le modèle est meilleur que celui qui sert. Elle se calcule sur ce qu'on lui donne. Une colonne renommée fait planter l'entraînement, au mieux ; des valeurs impossibles (CPU à 140 %, taux d'erreur négatif) passent, et le modèle apprend sur du bruit en affichant une RMSE honorable.
Une suite de tests transforme ces deux risques en portes. Lisez le schéma de gauche à droite : le jeu passe d'abord le schéma, puis les bornes ; le modèle passe ensuite le seuil absolu, puis la comparaison au champion. Un seul « non » suffit à refuser. Les deux dernières portes ne sont pas redondantes : la section sur le champion montre un modèle que l'une laisse passer et que l'autre arrête.
Comment contrôler le schéma et les bornes des données ?
Section intitulée « Comment contrôler le schéma et les bornes des données ? »Le schéma se contrôle par la présence des colonnes attendues, les bornes par
des assertions sur chaque colonne. Les extraits de la leçon s'assemblent, dans
l'ordre, en un seul fichier tests/test_suite.py. Le jeu du fil rouge compte huit colonnes
(horodatage_min, service, cpu_pct, memoire_pct, requetes_s,
taux_erreur, latence_p95_ms, incident), mais le modèle n'en lit que cinq :
les quatre entrées cpu_pct, memoire_pct, requetes_s, taux_erreur et la
cible latence_p95_ms. La suite exige ces cinq-là et tolère les autres :
une colonne en plus ne casse pas le modèle, une colonne en moins oui. Les bornes viennent de la signification des mesures : un
pourcentage reste dans [0, 100], un taux d'erreur dans [0, 1], un débit est
positif ou nul, une latence est strictement positive.
import os
import pandas as pdimport pytest
DONNEES = os.environ.get("DONNEES", "data/metriques.csv")COLONNES = ["cpu_pct", "memoire_pct", "requetes_s", "taux_erreur", "latence_p95_ms"]
@pytest.fixture(scope="module")def jeu(): return pd.read_csv(DONNEES)
def test_schema(jeu): manquantes = set(COLONNES) - set(jeu.columns) assert not manquantes, f"colonnes manquantes : {sorted(manquantes)}"
def test_bornes(jeu): assert jeu["cpu_pct"].between(0, 100).all() assert jeu["memoire_pct"].between(0, 100).all() assert jeu["taux_erreur"].between(0, 1).all() assert (jeu["requetes_s"] >= 0).all() assert (jeu["latence_p95_ms"] > 0).all()Une fixture pytest est une fonction que les tests reçoivent en argument ;
avec scope="module", le CSV est lu une seule fois pour tous les tests du
fichier au lieu d'être relu à chaque test. Le chemin du jeu vient de la variable d'environnement DONNEES, avec le CSV du
fil rouge par défaut : la raison est donnée plus bas. Le lab fournit deux jeux
cassés de 60 lignes pour vous exercer. Dans casse-schema.csv, la colonne
memoire_pct est renommée mem_pct : test_schema doit échouer.
Dans casse-bornes.csv, le schéma est intact mais cpu_pct vaut 140,
taux_erreur -0.5, requetes_s -12 et latence_p95_ms 0 : test_bornes
doit échouer. Une suite qui ne rougit pas sur ces jeux ne protège de rien.
Pandas ou pandera pour valider les données ?
Section intitulée « Pandas ou pandera pour valider les données ? »Pour un schéma de cinq colonnes exigées, des assertions pandas suffisent : zéro dépendance, et le test se lit sans connaître d'API. La bibliothèque de validation pandera a été essayée en lab (pandera 0.34.1, pandas 3.0.6, pytest) avant de choisir.
Son coût est mesuré : +11 Mo dans le venv (255 à 266 Mo), quatre dépendances en
plus (pydantic, pydantic-core, typeguard, typing-inspect) et 0,10 s d'import
en plus des 0,18 s de pandas. Son bénéfice réel : avec lazy=True, elle rend
toutes les erreurs d'un coup. Sur le jeu au schéma cassé, une erreur
(column_in_dataframe) ; sur le jeu aux bornes cassées, quatre
(in_range(0, 1), in_range(0, 100), greater_than_or_equal_to(0),
greater_than(0)). Un assert pandas, lui, s'arrête à la première.
Le choix de la leçon est donc les assertions pandas, avec pandera comme étape suivante quand le schéma grossit (dizaines de colonnes, types, règles croisées). Le contrôle du lab ne dépend pas de l'outil : il note le comportement de la suite, pas la bibliothèque utilisée.
Comment fixer un seuil de métrique versionné ?
Section intitulée « Comment fixer un seuil de métrique versionné ? »Le seuil de RMSE se lit dans un fichier versionné, seuils.yaml, plutôt que
dans le code du test. Changer le seuil devient alors une décision visible dans
l'historique Git, relue comme n'importe quelle modification, et non une valeur
perdue au fond d'une assertion.
rmse_max: 28.0Le lab attend un rmse_max supérieur à 20,0 (garde-fou contre un seuil
absurde, qu'aucun modèle ne pourrait tenir) et au plus 28.0. Ce nombre se justifie par des mesures
sur le découpage 0,2 et la graine 42 des 5000 lignes : le modèle à 100 arbres
atteint une RMSE de 22,41 ms, celui à 50 arbres 23,31 ms, celui à 10 arbres
24,30 ms, celui à 3 arbres 27,41 ms. Un modèle à 50 arbres limité à une
profondeur de 4 donne 27,48 ms, et à une profondeur de 2 il donne 30,41 ms ; une
forêt d'un seul arbre, que train.py sait produire avec --arbres 1, donne
31,05 ms. Le seuil de 28.0 accepte les premiers et refuse les derniers. Ces chiffres valent
pour scikit-learn 1.9.1 et ce jeu : ne les prenez pas pour des constantes.
import yaml
SEUILS = yaml.safe_load(open("seuils.yaml", encoding="utf-8"))Comment refuser un modèle moins bon que le champion ?
Section intitulée « Comment refuser un modèle moins bon que le champion ? »Un seuil absolu laisse passer un modèle qui dégrade le champion : on ajoute une comparaison avec une tolérance, ici 1,0 ms. Le candidat est refusé si sa RMSE dépasse celle du champion de plus d'une milliseconde. Le champion est le modèle actuellement désigné comme celui qui sert ; le candidat est celui qu'on voudrait promouvoir, ce que la leçon sur le registre appelle le challenger. Ici le champion est le modèle à 100 arbres : c'est le challenger de cette leçon-là, une fois promu.
Prenez le modèle à 10 arbres : sa RMSE de 24,30 ms est sous le seuil de 28.0, il passe donc le premier garde-fou. Face à un champion à 100 arbres (22,41 ms), il est refusé : 24,30 dépasse 22,41 + 1,0. À l'inverse, le modèle à un seul arbre (31,05 ms) est arrêté par le seuil. Les deux garde-fous détectent des erreurs différentes et sont testés séparément par le contrôle du lab.
La RMSE se calcule sur la part de test, avec le même découpage que l'entraînement, sans quoi la comparaison ne compare pas la même chose :
import joblibfrom sklearn.metrics import mean_squared_errorfrom sklearn.model_selection import train_test_split
ENTREES = ["cpu_pct", "memoire_pct", "requetes_s", "taux_erreur"]MODELE = os.environ.get("MODELE", "modele/candidat.joblib")CHAMPION = os.environ.get("CHAMPION", "modele/champion.joblib")TOLERANCE_MS = 1.0
def rmse(chemin, jeu): params = yaml.safe_load(open("params.yaml", encoding="utf-8"))["entrainement"] _, X_test, _, y_test = train_test_split( jeu[ENTREES], jeu["latence_p95_ms"], test_size=params["part_test"], random_state=params["seed"], ) pred = joblib.load(chemin).predict(X_test) return mean_squared_error(y_test, pred) ** 0.5
def test_seuil_absolu(jeu): assert rmse(MODELE, jeu) <= SEUILS["rmse_max"]
def test_pas_pire_que_le_champion(jeu): assert rmse(MODELE, jeu) <= rmse(CHAMPION, jeu) + TOLERANCE_MSLe modèle reçoit un tableau pandas (DataFrame), comme à l'entraînement de
train.py : le passer en tableau NumPy déclenche l'avertissement « X does not
have valid feature names ». La tolérance de 1,0 ms
laisse passer la variation d'un réentraînement sans écarter un vrai progrès : un
nouveau modèle ne doit pas être strictement meilleur, il ne doit pas être
nettement pire.
Comment rendre la suite rejouable sur d'autres jeux ?
Section intitulée « Comment rendre la suite rejouable sur d'autres jeux ? »Trois variables d'environnement, DONNEES, MODELE et CHAMPION, désignent
le jeu, le candidat et le champion ; la suite les lit avec les valeurs par défaut
du projet. Rien n'est écrit en dur dans les tests : la même suite contrôle le
CSV du jour, un jeu cassé pour la démonstration, ou un modèle fabriqué pour
l'occasion.
uv run python train.py --arbres 100 --sortie modele/champion.joblibuv run python train.py --arbres 100 --sortie modele/candidat.joblibuv run python train.py --arbres 1 --sortie modele/trop-faible.joblibuv run pytest -qDONNEES=casse-schema.csv uv run pytest -qMODELE=modele/trop-faible.joblib uv run pytest -qLes deux premières commandes produisent le champion et le candidat par
défaut (modele/champion.joblib, modele/candidat.joblib), ici identiques :
100 arbres chacun. La troisième fabrique un modèle volontairement trop faible.
La quatrième doit finir sur 4 passed. La cinquième finit sur
4 failed, avec test_schema en rouge et les trois autres en échec par
KeyError, faute de colonne ; la sixième finit sur 2 failed, 2 passed : le
seuil et la comparaison au champion refusent le modèle à un arbre (31,05 ms). C'est
ainsi que le contrôle du lab note votre suite : il la rejoue sur ses propres
jeux (casse-schema.csv, casse-bornes.csv et un jeu valide) et ses propres
modèles (un bon, un trop faible, un moins bon que le champion). Elle doit
passer sur les valides et échouer, avec un code de sortie 1, sur les autres.
Un piège de harnais vaut d'être connu : sans aucun test collecté, pytest
rend le code 5, ni 0 ni 1. Un contrôle qui accepterait « tout code non nul »
comme un échec de la suite serait vert avant tout travail. Le contrôle du lab
exige donc 1 ou 2 et le mot failed ou error dans la sortie. Faites de même
dans votre propre CI : un échec attendu se vérifie sur le code et sur le texte.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Correction |
|---|---|---|
pytest rend le code 5 | Aucun test collecté (mauvais dossier, fichier non nommé test_*.py) | Nommer les fichiers test_*.py dans tests/ et vérifier collected N items |
KeyError: 'memoire_pct' sur un jeu cassé | Le test de bornes lit une colonne absente | C'est un échec attendu ; test_schema donne le diagnostic lisible |
| Le candidat à 10 arbres passe le seuil mais la suite échoue | Il dépasse le champion de plus de 1,0 ms | C'est le rôle de test_pas_pire_que_le_champion, pas un bug |
La RMSE diffère de celle de train.py | Découpage différent (graine ou part_test non lus dans params.yaml) | Reprendre part_test et seed de params.yaml |
| Le seuil semble ignoré | Valeur écrite en dur dans le test | Lire rmse_max dans seuils.yaml |
Mettre en pratique
Section intitulée « Mettre en pratique »Le lab vous demande de produire un champion et un candidat avec train.py (par
exemple 100 arbres), d'écrire seuils.yaml avec rmse_max entre 20,0 (exclu) et 28.0, puis
la suite pytest dans tests/ qui lit DONNEES, MODELE et CHAMPION. Elle
contrôle le schéma, les bornes, le seuil et la comparaison au champion avec
1,0 ms de tolérance. Vous commitez le tout. La validation ne lit pas vos
commandes : elle rejoue votre suite sur ses propres jeux et ses propres modèles,
et exige qu'elle passe sur les valides et échoue sur les autres. Aucun serveur
n'est à démarrer.
À retenir
Section intitulée « À retenir »- Une métrique correcte ne prouve ni la qualité des données ni la supériorité du modèle : une suite de tests en fait des portes explicites.
- Le schéma se contrôle par les colonnes attendues, les bornes par une assertion par colonne ; une suite qui ne rougit pas sur un jeu cassé ne protège de rien.
- Le jeu compte huit colonnes mais la suite n'exige que les cinq dont dépend le modèle ; des assertions pandas suffisent à les contrôler ; pandera apporte
lazy=True(toutes les erreurs d'un coup) au prix de quatre dépendances. - Le seuil de RMSE vit dans
seuils.yaml, versionné ; la comparaison au champion ajoute une tolérance (1,0 ms) que le seuil absolu ne couvre pas. - Les deux garde-fous détectent des erreurs différentes et se testent séparément.
- Une suite rejouable lit
DONNEES,MODELEetCHAMPION;pytestrend 5 quand aucun test n'est collecté, ce qui n'est pas un échec de suite.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Score par domaine
Un domaine regroupe plusieurs guides et reçoit plusieurs questions : son score est plus fiable qu'un score par thème, qui porte souvent sur une seule question.
Bilan
Points forts
Aucun sujet réussi à 80 % sur au moins deux questions pour l’instant.
Points faibles
Aucun sujet sous le seuil de réussite.
À faire ensuite
Des indices pour retenter votre chance ?
Continuer le cours : Épreuve de niveau : rendre un modèle reproductible et traçable
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications