fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Cause racine du dernier test Mocha en echec (getAuditBatch flaguait
"Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) :
avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon
intent (argmax correct) mais avec une confiance tres basse et compressee
(0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) —
un vrai probleme de qualite d'entrainement, pas seulement de seuil.

150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le
vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51
pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours
~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) —
compromis assume et documente (README du service, commentaires du code),
contrairement a l'entrainement quasi instantane de node-nlp.

Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout
de 600s pour couvrir cette duree avec marge.

Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a
confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-25 21:00:48 +02:00
parent 9590569f3f
commit 1bf97ce6de
4 changed files with 64 additions and 33 deletions

View file

@ -245,26 +245,27 @@ export function splitIntoClauses(
* Recalibrated to `0.45` for the migration off `node-nlp` to * Recalibrated to `0.45` for the migration off `node-nlp` to
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes
* over ~26 techniques) its score distribution is meaningfully different * over ~26 techniques) its score distribution is meaningfully different
* from node-nlp's own classifier, and the old `0.75` (tuned against * from node-nlp's own classifier. With that service's training tuned for
* node-nlp) left genuine, correct verdicts on *anchor-less* clauses * real confidence rather than just correct argmax (see
* (nothing to fall back to see this file's doc comment, point 3) below * `_TRAINING_ITERATIONS`/`_TRAINING_DROPOUT` in
* the bar: e.g. `melt` scored `0.68` on "jusqu'à ce que le beurre ait * `services/tech-step-intent-service/intent_service/locale_pipeline.py`),
* disparu dans la poêle" (the exact motivating no-keyword case this * genuine matches score comfortably above `0.45` with real margin: `melt`
* pipeline exists for), `preheat` scored `0.52` on "mettre la poêle sur * scores `~0.95` on "jusqu'à ce que le beurre ait disparu dans la poêle"
* feu vif" both the classifier's own confident best guess, both correct, * (the exact motivating no-keyword case this pipeline exists for, no NER
* both wrongly discarded at `0.75`. `0.45` clears both with margin while * anchor to fall back to), `preheat` `~0.90` on "mettre la poêle sur feu
* staying far above the noise floor: English recipe text run through the * vif", down to `~0.51` for the weakest real case seen (`bake`, anchored).
* French classifier (must find *nothing*, confirmed by * The noise floor stays far below all of them: English recipe text run
* `recipe-translation.test.ts`'s own locale-isolation test) scored `0.04` * through the French classifier (must find *nothing*, confirmed by
* `recipe-translation.test.ts`'s own locale-isolation test) scores `~0.05`
* for every technique indistinguishable from the ~1/26 uniform baseline * for every technique indistinguishable from the ~1/26 uniform baseline
* over this many exclusive classes, nowhere near `0.45`. Cross-checked * over this many exclusive classes. Cross-checked against
* against `apps/api/src/scripts/calibrate-tech-step-threshold.ts`'s sweep * `apps/api/src/scripts/calibrate-tech-step-threshold.ts`'s sweep over
* over `TECH_STEP_EVAL_DATASET`: aggregate F1 climbs to its plateau * `TECH_STEP_EVAL_DATASET`: aggregate F1 climbs to its plateau (`0.987`)
* (`0.987`) starting exactly at `0.45` and stays flat through `0.95`, so * starting at `0.45` and stays flat through `0.95`, so this is the lowest
* this is the lowest threshold that already captures every gain available * threshold that already captures every gain available from trusting the
* from trusting the classifier more a higher value would only ever * classifier more a higher value would only ever risk discarding a
* discard more anchor-less true positives like the two above, never buy * genuine anchor-less match like the two above, never buy back any
* back any precision. * precision.
*/ */
export const CONFIDENCE_THRESHOLD = 0.45; export const CONFIDENCE_THRESHOLD = 0.45;
@ -531,5 +532,5 @@ export class TechStepClassifierService {
} }
} }
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */ /** Single shared instance — training is expensive enough (a couple of minutes total, both locales combined — see `services/tech-step-intent-service`'s own `_TRAINING_ITERATIONS`) that every caller must reuse the one already-trained model, never spin up their own. */
export const techStepClassifier = new TechStepClassifierService(); export const techStepClassifier = new TechStepClassifierService();

View file

@ -33,7 +33,13 @@ import { resetDatabase } from "./reset-db.js";
export const mochaHooks = { export const mochaHooks = {
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts. // biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
async beforeAll(this: any): Promise<void> { async beforeAll(this: any): Promise<void> {
this.timeout(60000); // Generous on purpose: training both locales' `textcat` on the full
// corpus takes on the order of a couple of minutes combined (see
// `_TRAINING_ITERATIONS` in `services/tech-step-intent-service`'s
// `locale_pipeline.py`) — comfortably under 10 minutes even on a
// slower/contended CI runner, but nowhere near Mocha's normal 10s
// per-test default (`.mocharc.json`).
this.timeout(600000);
await resetDatabase(); await resetDatabase();
await techStepClassifier.warmUp(); await techStepClassifier.warmUp();
}, },

View file

@ -100,10 +100,19 @@ vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
## Limitations connues (première version) ## Limitations connues (première version)
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant/rapide pour - **`/v1/train` prend de l'ordre de la minute par locale** (~110s mesuré en
le corpus actuel, mais n'exploite pas les vecteurs de mots des modèles CI avec `_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` actuels, voir
`md` chargés. Migrable vers une architecture tok2vec/similarité sans `locale_pipeline.py`) — `apps/api` l'appelle deux fois au warm-up
changer le contrat HTTP, si le F1 mesuré par (`fr`/`en`), donc un redémarrage prend quelques minutes avant qu'une
recette puisse voir ses techniques détectées. Contrairement à node-nlp
(entraînement quasi instantané), c'est un vrai compromis assumé : moins
d'itérations entraînait plus vite mais laissait des verdicts corrects
sous `CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante,
`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`).
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le
corpus actuel une fois correctement entraîné, mais n'exploite pas les
vecteurs de mots des modèles `md` chargés. Migrable vers une architecture
tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par
`apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un `apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un
jour. jour.
- **Reconstruit tout le pipeline à chaque `/v1/train`** (pas de fusion - **Reconstruit tout le pipeline à chaque `/v1/train`** (pas de fusion

View file

@ -49,14 +49,29 @@ _EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_r
_TEXTCAT_PIPE_NAME = "textcat" _TEXTCAT_PIPE_NAME = "textcat"
# Nombre d'itérations d'entraînement du textcat — calibré empiriquement pour # Nombre d'itérations d'entraînement du textcat et taille de minibatch —
# converger sur un corpus de cette taille (quelques centaines d'utterances # calibrés empiriquement contre le corpus réel (`TECH_STEP_TRAINING_DATA`,
# par locale) sans allonger inutilement le warm-up. À revoir si # ~26 techniques/locale), pas seulement contre les petits corpus jouets des
# `calibrate-tech-step-threshold.ts` (côté apps/api) montre un F1 anormalement # tests de ce fichier. Une première valeur plus basse (30 itérations, lot de
# bas qui s'améliore avec plus d'itérations. # 8) convergeait mal sur le vrai corpus : des clauses correctement
_TRAINING_ITERATIONS = 30 # classifiées mais sans ancre NER (le cas motivant tout ce pipeline, voir
_TRAINING_BATCH_SIZE = 8 # `TechStepClassifierService`'s doc comment côté apps/api) scoraient à peine
_TRAINING_DROPOUT = 0.2 # 0.5-0.7, et des clauses *avec* ancre à peine 0.2-0.3 — bien en dessous de
# `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`), un CI réel l'a confirmé
# avant que ces valeurs ne soient relevées. `150`/`16` entraîne en ~110s par
# locale sur un runner GitHub Actions standard (donc ~220s pour fr+en
# combinés au warm-up — voir `server.ts`'s propre commentaire sur le retry
# côté apps/api) et pousse les mêmes scores nettement au-dessus du seuil
# (melt ~0.95, preheat ~0.90, bake ~0.51) sans dégrader le rejet du bruit
# (texte anglais via le classifieur français reste ~0.05, inchangé).
_TRAINING_ITERATIONS = 150
_TRAINING_BATCH_SIZE = 16
# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise
# encore contre la petite taille du corpus par technique tout en laissant
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la
# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire
# reste aussi bas qu'avant, voir ci-dessus).
_TRAINING_DROPOUT = 0.1
# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même # Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même
# corpus en entrée) est préférable à un score qui varie légèrement à chaque # corpus en entrée) est préférable à un score qui varie légèrement à chaque
# déploiement pour la même donnée, en particulier pendant la calibration du # déploiement pour la même donnée, en particulier pendant la calibration du