Personne n'a touché au code. Personne n'a changé le modèle. J'ai juste rangé ma boîte mail. Et mon classifieur a perdu une classe entière, sans un warning.
TL;DR : mon bot de tri d'emails classe avec un modèle TF-IDF entraîné sur mes propres dossiers. Un re-tri manuel a vidé la classe Personnel de 196 exemples à 9. Le réentraînement suivant l'a éjectée sous le seuil minimal et a déployé un modèle à 6 classes au lieu de 7. En silence. Voici l'incident, et les trois garde-fous que j'ai construits pour qu'il ne se reproduise jamais.
Cet article est pour tous ceux qui ont un modèle réentraîné en une commande, et aucune alarme autour.
Le contexte
Mon bot de tri d'emails tourne depuis des mois. J'ai raconté sa construction dans Put the LLM Last : des règles d'abord, un classifieur ensuite, le LLM en dernier recours.
Le classifieur est un TF-IDF avec régression logistique. L'entraînement se fait en Python, l'inférence en pur Go. La parité entre les deux a été vérifiée : 5 895 mails sur 5 895, même verdict.
Les labels viennent de l'usage réel : le dossier où chaque mail est rangé. 5 895 mails étiquetés, 7 classes retenues à l'entraînement.
Et un réentraînement tient en une commande : make retrain. C'est cette commodité qui m'a mordu.
L'incident : personne n'a touché au code
Un week-end, j'ai fait le ménage dans ma boîte. Les mails du dossier Personnel sont partis vers d'autres dossiers, mieux rangés.
Résultat côté données : la classe Personnel est passée de 196 exemples à 9.
Le pipeline d'entraînement a un seuil sain : une classe avec trop peu d'exemples est exclue du modèle. Sain, sauf que l'exclusion était silencieuse.
Le make retrain suivant a donc entraîné un modèle à 6 classes au lieu de 7. Il s'est déployé. Aucun message, aucun refus, aucune différence visible.
Le symptôme est arrivé plus tard : des mails personnels rangés n'importe où. Le pipeline avait fait exactement ce qu'on lui demandait. C'est bien ça, le problème.
Pourquoi c'est structurel : tes labels sont vivants
Ce bug n'est pas une maladresse locale. Il est structurel, et il t'attend aussi.
Mes labels sont générés par un système vivant : ma boîte mail et mes habitudes de rangement. Un re-tri, une archive, un changement d'habitude, et la distribution des classes bouge.
Ton dataset n'est pas un fichier. C'est une photo d'un truc qui bouge.
Or un pipeline de réentraînement ne voit que la photo du jour. Sans mémoire du modèle précédent, il ne peut pas savoir qu'une classe a disparu. Il faut lui donner cette mémoire.
Garde-fou n°1 : les classes attendues
Premier garde-fou, le plus simple. Le script de réentraînement lit d'abord les classes du modèle déployé. Puis il exige de les retrouver dans les données.
# retrain.sh lit les classes du modèle déployé,
# puis les impose au nouvel entraînement
train.py --expect-labels Cabinet,Maison,Newsletter,...
# une classe attendue sous le seuil minimal :
# ABORT. Rien n'est écrit, rien n'est déployé.
Une classe peut toujours disparaître. Mais c'est désormais une décision humaine et explicite, plus un effet de bord du rangement.
Garde-fou n°2 : le gate de qualité contre le modèle déployé
Le premier garde-fou attrape la disparition d'une classe. Il rate une classe qui survit mais s'effondre.
Donc chaque modèle embarque désormais ses métriques dans son propre fichier : accuracy, macro-F1, F1 par classe. Le macro-F1 est la moyenne des F1 de chaque classe : les petites classes y pèsent autant que les grosses.
Au réentraînement suivant, le script lit le macro-F1 du modèle déployé et le prend comme plancher. Le candidat régresse au-delà de la marge tolérée ? Abort, avant toute écriture.
Le gate est testé comme du code : un effondrement simulé doit déclencher l'abort. Un garde-fou non testé est une décoration.
Garde-fou n°3 : le moniteur d'escalade
Les deux premiers garde-fous protègent l'entraînement. Le troisième surveille la prod.
Quand le classifieur n'est pas sûr, il escalade : sous un seuil de confiance, la décision part au LLM de secours. Ce taux d'escalade est le rythme cardiaque du modèle.
Chaque décision écrit une ligne de journal : horodatage, confiance, escalade ou non, label. Jamais le sujet ni l'expéditeur, le journal est persistant.
Un petit outil agrège par semaine et alerte si le taux récent dépasse la base de plus de 10 points. Quand le monde change, le modèle doute plus. Le doute se mesure.
Ce que je n'ai pas fait
Je n'ai pas reconstruit la classe Personnel. Les données étaient éclatées et polluées, la reconstruction aurait coûté plus que la classe ne valait.
Personnel est reparti en règles déterministes, avec le LLM en filet. Le modèle à 6 classes est devenu la référence, actée et documentée.
Toutes les classes ne méritent pas du ML. Une classe pauvre en exemples vit mieux en règles.
La checklist avant ton prochain retrain
Trois garde-fous, une soirée de travail. L'incident, lui, coûte des semaines de confiance.
- Fais lister par le script les classes du modèle déployé avant d'entraîner
- Refuse le retrain si une classe attendue passe sous le seuil
- Embarque les métriques dans le fichier du modèle déployé
- Compare le candidat au déployé : régression au-delà de la marge, abort
- Teste tes garde-fous : un effondrement simulé doit déclencher l'abort
- Journalise chaque décision en prod : confiance, escalade, sans contenu
- Alerte sur le taux d'escalade, pas seulement sur les erreurs
- Accepte de sortir une classe du ML quand les règles font mieux
Ce qu'il faut retenir
Un pipeline de réentraînement sans mémoire détruit ton modèle en silence, parce que la donnée bouge sans prévenir.
La parade tient en trois pièces : des classes attendues, un plancher de qualité lu depuis le modèle déployé, et un taux d'escalade surveillé.
Rien de tout ça ne demande une plateforme MLOps. Un script honnête suffit.
Un pipeline ML à fiabiliser, même petit ? Parlons-en.