A propos Compétences Expérience Services Blog Contact

Mon réentraînement a supprimé une classe en silence My retraining silently deleted a class

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.

Nobody touched the code. Nobody changed the model. I just tidied my mailbox. And my classifier lost an entire class, without a single warning.

TL;DR: my email triage bot classifies with a TF-IDF model trained on my own folders. A manual re-sort drained the Personnel class from 196 examples to 9. The next retraining pushed it under the minimum-class threshold and deployed a 6-class model instead of 7. Silently. Here is the incident, and the three guardrails I built so it can never happen again.

This article is for everyone who can retrain a model with one command, and has no alarm around it.

The setup

My email triage bot has been running for months. I told its story in Put the LLM Last: rules first, a classifier next, the LLM as a last resort.

The classifier is TF-IDF with logistic regression. Training happens in Python, inference in pure Go. Parity between both was verified: 5,895 emails out of 5,895, same verdict.

Labels come from real usage: the folder each email ends up in. 5,895 labeled emails, 7 classes kept for training.

And retraining takes one command: make retrain. That convenience is what bit me.

The incident: nobody touched the code

One weekend, I cleaned up my mailbox. The emails in the Personnel folder moved to other, better-organized folders.

On the data side: the Personnel class went from 196 examples to 9.

The training pipeline has a healthy threshold: a class with too few examples is excluded from the model. Healthy, except the exclusion was silent.

So the next make retrain trained a 6-class model instead of 7. It got deployed. No message, no refusal, no visible difference.

The symptom came later: personal emails filed anywhere. The pipeline did exactly what it was told. That is precisely the problem.

Why it is structural: your labels are alive

This bug is not a local blunder. It is structural, and it is waiting for you too.

My labels are generated by a living system: my mailbox and my sorting habits. One re-sort, one archive, one new habit, and the class distribution shifts.

Your dataset is not a file. It is a snapshot of something that moves.

A retraining pipeline only sees today's snapshot. Without a memory of the previous model, it cannot know a class disappeared. You have to give it that memory.

Guardrail 1: expected classes

First guardrail, the simplest. The retraining script first reads the deployed model's classes. Then it requires finding them in the data.

# retrain.sh reads the deployed model's classes,
# then enforces them on the new training run
train.py --expect-labels Cabinet,Maison,Newsletter,...

# an expected class under the minimum threshold:
# ABORT. Nothing written, nothing deployed.

A class can still disappear. But it is now an explicit human decision, not a side effect of tidying up.

Guardrail 2: a quality gate against the deployed model

The first guardrail catches a vanishing class. It misses a class that survives but collapses.

So every model now embeds its metrics in its own file: accuracy, macro-F1, per-class F1. Macro-F1 averages each class's F1: small classes weigh as much as big ones.

On the next retraining, the script reads the deployed model's macro-F1 and takes it as the floor. The candidate regresses beyond the tolerated margin? Abort, before anything is written.

The gate is tested like code: a simulated collapse must trigger the abort. An untested guardrail is a decoration.

Guardrail 3: the escalation monitor

The first two guardrails protect training. The third one watches production.

When the classifier is unsure, it escalates: below a confidence threshold, the decision goes to the fallback LLM. That escalation rate is the model's heartbeat.

Every decision writes one journal line: timestamp, confidence, escalated or not, label. Never the subject or the sender, the journal is persistent.

A small tool aggregates by week and alerts when the recent rate exceeds the baseline by more than 10 points. When the world changes, the model doubts more. Doubt is measurable.

What I did not do

I did not rebuild the Personnel class. The data was scattered and polluted, and rebuilding would have cost more than the class was worth.

Personnel went back to deterministic rules, with the LLM as a net. The 6-class model became the documented, accepted reference.

Not every class deserves ML. A class poor in examples lives better as rules.

The checklist before your next retrain

Three guardrails, one evening of work. The incident costs weeks of trust.

  • Make the script list the deployed model's classes before training
  • Refuse the retrain if an expected class falls under the threshold
  • Embed the metrics inside the deployed model's file
  • Compare candidate to deployed: regression beyond the margin, abort
  • Test your guardrails: a simulated collapse must trigger the abort
  • Journal every production decision: confidence, escalation, no content
  • Alert on the escalation rate, not only on errors
  • Accept moving a class out of ML when rules do better

What to remember

A retraining pipeline without memory destroys your model silently, because data moves without notice.

The defense has three parts: expected classes, a quality floor read from the deployed model, and a watched escalation rate.

None of this requires an MLOps platform. An honest script is enough.

An ML pipeline to make trustworthy, even a small one? Let's talk.