Évaluer un modèle avant de le livrer : mon harnais en quatre couches
Un bon score sur le jeu de test ne garantit rien sur la production. Comment je monte un pipeline d'évaluation qui attrape les régressions avant les utilisateurs : tests de comportement, jeu de régression, verrou en CI, et le calcul qui dit si un écart de score veut dire quelque chose.
Un modèle peut faire 94 % de F1 sur le jeu de test et décevoir dès la semaine qui suit sa mise en ligne. Il n'y a rien de mystérieux là-dedans : le chiffre qu'on a optimisé et le comportement qu'on a livré n'ont jamais été la même chose.
La couche qui comble cet écart, c'est le pipeline d'évaluation. Je décris ici l'ordre dans lequel je la construis, et je reste volontairement terre à terre sur la façon de faire.
Ce qu'un score sur jeu de test ne dit pas
Un jeu de test (hold-out) est tiré du même réservoir que les données d'entraînement. Tout ce qui s'est passé après la collecte de ce réservoir en est absent, par construction.
Trois problèmes se cachent derrière un bon score offline.
La dérive de distribution. Les entrées changent : nouvelles catégories de produits, nouveau segment de clients, une évolution de l'interface qui modifie la façon dont les gens formulent leurs demandes. Le modèle a raison sur l'ancienne question, mais la question a changé.
La fuite d'étiquette. Une feature contient la réponse sous une forme qui n'existera pas au moment de l'inférence. Une colonne updated_at écrite seulement une fois l'issue connue donne un score de test spectaculaire et un modèle inutile.
L'écart entre la métrique et l'objectif. Le F1 compte toutes les erreurs de la même façon. En production, c'est rarement le cas. Sur un contrôle anti-fraude, un faux négatif et un faux positif n'ont ni le même coût, ni le même circuit d'escalade, ni les mêmes personnes qui se plaignent.
Les deux premiers se détectent offline, à condition de les chercher. Le troisième est un choix de conception : il faut le régler avant de choisir la métrique, aucune évaluation ne le corrigera après coup.
Les quatre couches du harnais
Je pense l'évaluation en quatre couches. Chacune attrape ce que la précédente ne voit pas.
Couche 1, des tests unitaires sur le modèle. Ils portent sur le comportement du modèle, et le code d'entraînement a ses propres tests à côté. Une entrée connue, une sortie vérifiée. Ils ne coûtent rien, tournent à chaque commit et attrapent les régressions bêtes : un prétraitement qui met en minuscules ce qu'il ne fallait pas, un changement de tokeniseur qui tronque sans prévenir.
Couche 2, les suites comportementales. Des groupes d'entrées qui testent une capacité. « Le classifieur gère-t-il encore la négation ? » se teste avec vingt phrases, une seule assertion ne suffit pas. L'intérêt : quand elles échouent, on sait quelle capacité a cassé, en plus de savoir que le score a bougé.
Couche 3, le jeu de régression. Chaque bug trouvé en production devient une ligne permanente. C'est la couche que les équipes sautent, et c'est celle qui prend de la valeur avec le temps : au bout d'un an, elle contient tout ce que le système a raté.
Couche 4, les métriques online. La seule couche qui mesure ce qui se passe vraiment. Tout ce qui précède reste un indicateur indirect.
Mon jeu de régression tient dans un CSV
Le format doit être assez simple pour qu'ajouter une ligne en plein incident ne soit pas une corvée.
class=class="tok-str">"tok-com"># regressions.csv — id,input,expected,note,added_at,ticket
class=class="tok-str">"tok-com">#
class=class="tok-str">"tok-com"># Une ligne par échec en production. On ne la supprime jamais, même quand le
class=class="tok-str">"tok-com"># bug est corrigé depuis longtemps : cclass="tok-num">39;est elle qui lclass="tok-num">39;empêche de revenir.
import csv
from pathlib import Path
import pytest
ROWS = list(csv.DictReader(Path("tests/regressions.csv").open()))
@pytest.mark.parametrize("row", ROWS, ids=lambda r: r["id"])
def test_regression(row, model):
got = model.predict(row["input"])
assert got == row["expected"], (
f"regression {row[class="tok-num">39;idclass="tok-num">39;]} ({row[class="tok-num">39;ticketclass="tok-num">39;]}): {row[class="tok-num">39;noteclass="tok-num">39;]}\n"
f" expected {row[class="tok-num">39;expectedclass="tok-num">39;]!r}, got {got!r}"
)Les colonnes note et ticket sont plus importantes qu'elles n'en ont l'air. Dix-huit mois plus tard, une ligne en échec sans contexte ressemble à une attente erronée, et quelqu'un finira par la « corriger » en changeant la valeur attendue.
Déterministe d'abord, exhaustif ensuite
Une suite d'évaluation qui donne deux résultats différents sur deux exécutions de suite fait plus de mal que de bien : l'équipe apprend à ignorer les builds rouges.
- Figer tout ce qui a une version. Les poids du modèle par empreinte (une étiquette peut bouger), le tokeniseur, le gabarit de prompt et la bibliothèque qui le rend. Un gabarit de prompt, c'est du code : il va dans Git.
- Fixer les paramètres d'échantillonnage.
temperature=0est nécessaire, et ça ne suffit pas toujours : sur certains noyaux, l'ordre des réductions en virgule flottante dépend de la composition du batch. Pour une sortie identique au bit près, il faut aussi figer la taille de batch. - Geler les données. J'adresse le jeu d'évaluation par son contenu (une empreinte). Si le jeu de régression a grossi entre deux exécutions, la comparaison ne veut plus rien dire.
- Séparer l'instabilité de l'échec. Avant de croire un rouge, je relance la suite une deuxième fois sur le même commit. Si les deux exécutions divergent, c'est un bug de déterminisme, et il passe avant tout le reste.
Juger une sortie ouverte
La correspondance exacte marche pour la classification et ne tient plus dès que la sortie est une phrase. La progression que je suis, du moins cher au plus cher :
Les assertions structurelles. Avant de juger la qualité, je vérifie ce qui ne coûte rien : JSON valide, champs obligatoires présents, aucun bout de prompt qui fuit, longueur dans les bornes, citations qui renvoient à des documents existants. Une bonne partie des vrais échecs se fait attraper ici, de façon déterministe et gratuite.
La notation par grille. J'écris la grille avant de regarder les sorties. Une grille écrite après coup décrit ce que le modèle fait déjà.
Le modèle-juge, avec ses biais connus. Utile, mais ses défauts sont documentés : biais de position quand on compare deux réponses, préférence pour les réponses longues, préférence pour sa propre famille de modèles. Les parades : mélanger l'ordre et faire les deux sens, retirer la mise en forme avant de juger, prendre un juge d'une autre famille que le système évalué.
La comparaison par paires. « A est-il meilleur que B ? » donne des réponses beaucoup plus stables que « note A sur 10 », chez les humains comme chez les modèles.
Recalibrer contre des humains, régulièrement. Je prends 50 éléments, je les fais noter à l'aveugle par une personne, et je mesure l'accord avec le juge automatique. Si l'accord dérive, c'est le juge qui a changé, et le modèle évalué n'y est pour rien.
Le brancher dans la CI
Le verrou compte autant que les tests. Une suite qui ne bloque aucune fusion, c'est de la documentation.
class=class="tok-str">"tok-com"># tests/test_quality_gate.py
class=class="tok-str">"tok-com">#
class=class="tok-str">"tok-com"># Deux seuils de nature différente :
class=class="tok-str">"tok-com"># - lignes de régression : tolérance ZÉRO. Un bug connu qui revient bloque.
class=class="tok-str">"tok-com"># - score agrégé : comparé au modèle en production, avec une petite marge
class=class="tok-str">"tok-com"># pour le bruit(un seuil absolu se périme vite).
MAX_ABSOLUTE_DROP = class="tok-num">0.02
def test_no_regression_rows_fail(results):
failed = [r for r in results.regressions if not r.passed]
assert not failed, f"{len(failed)} known bug(s) came back: {[r.id for r in failed]}"
def test_no_aggregate_drop(results, production_baseline):
delta = results.score - production_baseline.score
assert delta > -MAX_ABSOLUTE_DROP, (
f"score dropped {abs(delta):.3f} vs production "
f"({production_baseline.version}); threshold {MAX_ABSOLUTE_DROP}"
)Deux détails décident si ça tient dans la durée.
Comparer à la production. Un seuil absolu est juste pendant une semaine. Comparer au modèle actuellement déployé garde le verrou pertinent à mesure que le système progresse, et fait apparaître des cas comme « on a progressé partout sauf sur cette capacité ».
Publier la différence. Un commentaire de CI qui liste les suites qui ont bougé, et dans quel sens, se lit et s'exploite. Un score unique seul ne dit presque rien.
L'intuition mathématique : le bruit d'un score
Un score mesuré sur un jeu d'évaluation est une mesure faite sur un échantillon. Tirez 100 autres exemples du même réservoir, et le même modèle obtiendra 87 %, puis 92 %, puis 89 %. Le seuil de 2 points du verrou plus haut n'a de sens que si ce bruit est plus petit que lui.
La taille de ce bruit se calcule. Pour un score p (la proportion de réponses justes) mesuré sur n exemples, l'écart type vaut :
σ = √( p(1 − p) / n )
et 95 % des mesures tombent à ± 1,96 σ du vrai score. Le point qui compte, c'est le n sous la racine : pour diviser le bruit par deux, il faut quatre fois plus d'exemples.
Pour fixer les idées : à 90 % sur 500 exemples, σ ≈ 1,3 point, soit un intervalle de ± 2,6 points. Un verrou à 2 points sur un tel jeu sonnera parfois pour rien. Trois parades, de la moins chère à la plus chère :
- comparer les deux modèles sur les mêmes exemples : le bruit lié au choix des exemples devient commun aux deux et s'annule en grande partie ;
- regarder les exemples qui ont changé de verdict, en plus de la différence de scores ;
- agrandir le jeu, en gardant en tête que la précision coûte le carré de ce qu'elle rapporte.
Boucler la boucle
La boucle qui compte est courte : la production révèle un échec, l'échec devient une ligne, la ligne tourne à chaque commit. Le reste de l'article, c'est l'outillage autour de cette boucle.
Un test simple pour savoir où on en est : peut-on reproduire en local, en moins de cinq minutes, la régression vue hier en production ? Si la réponse est non, on a une suite de tests, et il manque encore le pipeline d'évaluation.
Par quoi commencer
Pour une équipe qui livre son premier modèle, dans cet ordre :
- Des assertions structurelles sur la sortie. Un après-midi de travail, et les échecs embarrassants sont attrapés.
- Un CSV de régression et le test de dix lignes qui le lit. À démarrer dès le premier incident.
- Une comparaison avec le modèle déployé, en CI. Le seuil se calcule par rapport à lui.
- Des suites comportementales, une fois qu'on sait quelles capacités comptent vraiment. Personne ne les devine juste le premier jour, et ce n'est pas grave.
Les métriques online arrivent en dernier dans l'ordre de construction et en premier dans l'ordre d'importance. C'est inconfortable, mais on ne peut pas instrumenter ce qu'on n'a pas encore livré.