From 1bf97ce6de2671a6431e48d73221a6764ab0589f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 25 Aug 2026 21:00:48 +0200 Subject: [PATCH] fix(recipes): entraine le textcat plus longtemps pour une confiance reelle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/recipe-matching/tech-step-matcher.ts | 41 ++++++++++--------- apps/api/test-support/mocha-root-hooks.ts | 8 +++- services/tech-step-intent-service/README.md | 17 ++++++-- .../intent_service/locale_pipeline.py | 31 ++++++++++---- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index 0bc3267..e72bd0b 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -245,26 +245,27 @@ export function splitIntoClauses( * Recalibrated to `0.45` for the migration off `node-nlp` to * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes * over ~26 techniques) — its score distribution is meaningfully different - * from node-nlp's own classifier, and the old `0.75` (tuned against - * node-nlp) left genuine, correct verdicts on *anchor-less* clauses - * (nothing to fall back to — see this file's doc comment, point 3) below - * the bar: e.g. `melt` scored `0.68` on "jusqu'à ce que le beurre ait - * disparu dans la poêle" (the exact motivating no-keyword case this - * pipeline exists for), `preheat` scored `0.52` on "mettre la poêle sur - * feu vif" — both the classifier's own confident best guess, both correct, - * both wrongly discarded at `0.75`. `0.45` clears both with margin while - * staying far above the noise floor: English recipe text run through the - * French classifier (must find *nothing*, confirmed by - * `recipe-translation.test.ts`'s own locale-isolation test) scored `0.04` + * from node-nlp's own classifier. With that service's training tuned for + * real confidence rather than just correct argmax (see + * `_TRAINING_ITERATIONS`/`_TRAINING_DROPOUT` in + * `services/tech-step-intent-service/intent_service/locale_pipeline.py`), + * genuine matches score comfortably above `0.45` with real margin: `melt` + * scores `~0.95` on "jusqu'à ce que le beurre ait disparu dans la poêle" + * (the exact motivating no-keyword case this pipeline exists for, no NER + * anchor to fall back to), `preheat` `~0.90` on "mettre la poêle sur feu + * vif", down to `~0.51` for the weakest real case seen (`bake`, anchored). + * The noise floor stays far below all of them: English recipe text run + * 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 - * over this many exclusive classes, nowhere near `0.45`. Cross-checked - * against `apps/api/src/scripts/calibrate-tech-step-threshold.ts`'s sweep - * over `TECH_STEP_EVAL_DATASET`: aggregate F1 climbs to its plateau - * (`0.987`) starting exactly at `0.45` and stays flat through `0.95`, so - * this is the lowest threshold that already captures every gain available - * from trusting the classifier more — a higher value would only ever - * discard more anchor-less true positives like the two above, never buy - * back any precision. + * over this many exclusive classes. Cross-checked against + * `apps/api/src/scripts/calibrate-tech-step-threshold.ts`'s sweep over + * `TECH_STEP_EVAL_DATASET`: aggregate F1 climbs to its plateau (`0.987`) + * starting at `0.45` and stays flat through `0.95`, so this is the lowest + * threshold that already captures every gain available from trusting the + * classifier more — a higher value would only ever risk discarding a + * genuine anchor-less match like the two above, never buy back any + * precision. */ 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(); diff --git a/apps/api/test-support/mocha-root-hooks.ts b/apps/api/test-support/mocha-root-hooks.ts index 016869d..488439a 100644 --- a/apps/api/test-support/mocha-root-hooks.ts +++ b/apps/api/test-support/mocha-root-hooks.ts @@ -33,7 +33,13 @@ import { resetDatabase } from "./reset-db.js"; 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. async beforeAll(this: any): Promise { - 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 techStepClassifier.warmUp(); }, diff --git a/services/tech-step-intent-service/README.md b/services/tech-step-intent-service/README.md index 5db8f0a..fa4fb36 100644 --- a/services/tech-step-intent-service/README.md +++ b/services/tech-step-intent-service/README.md @@ -100,10 +100,19 @@ vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme ## Limitations connues (première version) -- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant/rapide pour - le corpus actuel, 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 +- **`/v1/train` prend de l'ordre de la minute par locale** (~110s mesuré en + CI avec `_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` actuels, voir + `locale_pipeline.py`) — `apps/api` l'appelle deux fois au warm-up + (`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 jour. - **Reconstruit tout le pipeline à chaque `/v1/train`** (pas de fusion diff --git a/services/tech-step-intent-service/intent_service/locale_pipeline.py b/services/tech-step-intent-service/intent_service/locale_pipeline.py index 5a0c790..a024839 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -49,14 +49,29 @@ _EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_r _TEXTCAT_PIPE_NAME = "textcat" -# Nombre d'itérations d'entraînement du textcat — calibré empiriquement pour -# converger sur un corpus de cette taille (quelques centaines d'utterances -# par locale) sans allonger inutilement le warm-up. À revoir si -# `calibrate-tech-step-threshold.ts` (côté apps/api) montre un F1 anormalement -# bas qui s'améliore avec plus d'itérations. -_TRAINING_ITERATIONS = 30 -_TRAINING_BATCH_SIZE = 8 -_TRAINING_DROPOUT = 0.2 +# Nombre d'itérations d'entraînement du textcat et taille de minibatch — +# calibrés empiriquement contre le corpus réel (`TECH_STEP_TRAINING_DATA`, +# ~26 techniques/locale), pas seulement contre les petits corpus jouets des +# tests de ce fichier. Une première valeur plus basse (30 itérations, lot de +# 8) convergeait mal sur le vrai corpus : des clauses correctement +# classifiées mais sans ancre NER (le cas motivant tout ce pipeline, voir +# `TechStepClassifierService`'s doc comment côté apps/api) scoraient à peine +# 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 # 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