Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Tester données et modèle ML avec pytest avant promotion

Par mis à jour le

Améliorer cette page

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.

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

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.

Un jeu de données passe quatre contrôles successifs : le schéma est-il conforme, les valeurs sont-elles dans les bornes, la RMSE est-elle au plus rmse_max (28.0), le modèle est-il à moins de 1,0 ms au-dessus du champion. Si chaque réponse est oui, le modèle est promu ; si une réponse est non, il est refusé.ouiouiouiouinonnonnonnonjeu de donnéesschémaconforme ?valeurs dansles bornes ?RMSE ≤ rmse_max(28.0) ?au plus 1,0 msau-dessus du champion ?promouvoirrefuser
Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

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

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.

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

Le 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 joblib
from sklearn.metrics import mean_squared_error
from 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_MS

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

Fenêtre de terminal
uv run python train.py --arbres 100 --sortie modele/champion.joblib
uv run python train.py --arbres 100 --sortie modele/candidat.joblib
uv run python train.py --arbres 1 --sortie modele/trop-faible.joblib
uv run pytest -q
DONNEES=casse-schema.csv uv run pytest -q
MODELE=modele/trop-faible.joblib uv run pytest -q

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

SymptômeCause probableCorrection
pytest rend le code 5Aucun 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 absenteC'est un échec attendu ; test_schema donne le diagnostic lisible
Le candidat à 10 arbres passe le seuil mais la suite échoueIl dépasse le champion de plus de 1,0 msC'est le rôle de test_pas_pire_que_le_champion, pas un bug
La RMSE diffère de celle de train.pyDé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 testLire rmse_max dans seuils.yaml

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.

  • 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, MODELE et CHAMPION ; pytest rend 5 quand aucun test n'est collecté, ce qui n'est pas un échec de suite.

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

6 questions
6 min.
70% requis

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

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn