fix(recipes): entraine le textcat sur les synonymes en plus des utterances

Suite a une suggestion de revue de code : le textcat n'apprenait
jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja
utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple
positif de sa propre technique ameliore radicalement la confiance sur
les cas ancres sans paraphrase entrainee.

Mesures sur le vrai corpus (74 techniques) :
- 40 iterations + synonymes (749 exemples vs 286 avant) : gain de
  confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86)
  mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min
  combine pour fr+en — inacceptable).
- 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais
  qualite pire qu'avant (simmer/cook repassent sous le seuil de
  confiance) — les exemples supplementaires ne compensent pas la perte
  d'epoques a ce point.
- 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine),
  meilleur compromis — tous les cas mesures s'ameliorent par rapport a
  la config precedente (simmer 0.25->0.31, cook 0.33->0.38,
  bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote
  0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02).

CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure
est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml
(start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau
temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant).

Deux autres pistes de la meme revue examinees et non retenues avec
justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure
est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost
de score post-traitement si le NER confirme l'intention predite (casserait
la garantie "score brut, jamais corrige par l'ancre" que
services/tech-step-llm-worker's audit de faible confiance depend
explicitement d'avoir, voir le commentaire de TechStepClauseClassification
dans tech-step-matcher.ts).

Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la
suite complete), lint + build du monorepo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-26 08:37:09 +02:00
parent 19e0507852
commit f2fd3b961a
8 changed files with 101 additions and 71 deletions

View file

@ -96,13 +96,14 @@ jobs:
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~200s per locale (~400s for
# fr+en combined) against the current ~74-technique corpus, so
# this wait is generous rather than the fast "base models only"
# check it used to be before that service trained itself at
# startup (see docker-compose.yml's healthcheck for the same
# reasoning).
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
# see its own README) — measured at ~335s per locale (~670s for
# fr+en combined) against the current ~74-technique corpus,
# trained on each technique's own synonyms in addition to its
# example phrases, so this wait is generous rather than the fast
# "base models only" check it used to be before that service
# trained itself at startup (see docker-compose.yml's healthcheck
# for the same reasoning).
timeout 900 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy

View file

@ -105,7 +105,7 @@ pnpm --filter api prisma:seed
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~7 minutes pour le corpus actuel, voir son propre README) avant de
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync

View file

@ -249,22 +249,24 @@ export function splitIntoClauses(
* changes (more exclusive classes generally means a *lower* natural
* confidence ceiling, softmax mass spread thinner).
*
* Currently `0.2`, set against the corpus as expanded to ~74 techniques
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
* (`services/tech-step-intent-service/intent_service/training_data.py`,
* `_TRAINING_ITERATIONS = 40`) from manual spot-checks, not yet a real
* `calibrate-tech-step-threshold.ts` sweep against
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
* `synonyms` in addition to its `utterances` see that constant's own
* comment for the calibration history) from manual spot-checks, not yet a
* real `calibrate-tech-step-threshold.ts` sweep against
* `TECH_STEP_EVAL_DATASET` (needs Postgres see that script's own doc
* comment): observed real-case scores ranged `0.25`-`0.89` (`simmer`
* lowest, still correct and anchored anyway; `melt` highest, the
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
* motivating anchor-less case), against a noise floor around `0.02`
* (English text through the French classifier). `0.2` sits with margin
* above the noise floor and below every real case seen so far, but **this
* is a placeholder pending the real eval-dataset sweep** do not treat it
* as load-bearing precision the way the previous `0.45` (calibrated
* against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET` F1 plateauing
* exactly there) was.
* (English text through the French classifier). `0.25` sits with real
* margin above the noise floor and below every real case seen so far, but
* **this is a placeholder pending the real eval-dataset sweep** do not
* treat it as load-bearing precision the way the original `0.45`
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
* F1 plateauing exactly there) was.
*/
export const CONFIDENCE_THRESHOLD = 0.2;
export const CONFIDENCE_THRESHOLD = 0.25;
/**
* One clause's full classification detail the finer-grained sibling of

View file

@ -94,14 +94,15 @@ services:
# This service trains itself from scratch on every start (no model
# ever persisted to disk, see its own README) — `/health` only
# returns 200 once that's done, not just once the base spaCy models
# are loaded. Measured at ~200s per locale (~400s for fr+en combined)
# against the current ~74-technique corpus
# (`intent_service/training_data.py`) — `start_period` generous
# enough that failing checks during that whole window never count
# against `retries` (which would otherwise flip this container to
# "unhealthy" mid-training, blocking `app`'s own
# `depends_on: condition: service_healthy` indefinitely).
start_period: 600s
# are loaded. Measured at ~335s per locale (~670s for fr+en combined)
# against the current ~74-technique corpus, trained on each
# technique's own synonyms in addition to its example phrases
# (`intent_service/locale_pipeline.py`'s `_TRAINING_ITERATIONS`) —
# `start_period` generous enough that failing checks during that
# whole window never count against `retries` (which would otherwise
# flip this container to "unhealthy" mid-training, blocking `app`'s
# own `depends_on: condition: service_healthy` indefinitely).
start_period: 900s
# Deliberately its own image, not built into `app`'s (see
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a

View file

@ -71,9 +71,11 @@ côté `apps/api`.
**Ce service met plusieurs minutes à devenir `healthy`** — contrairement à
node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le
corpus réel (~74 techniques) prend de l'ordre de 200 secondes par locale
(mesuré localement, sans GPU), donc environ 400 secondes (~7 minutes) pour
`fr`+`en` combinés à chaque démarrage du process. `docker-compose.yml` et
corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms`
en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre
de 335 secondes par locale (mesuré localement, sans GPU), donc environ 670
secondes (~11 minutes) pour `fr`+`en` combinés à chaque démarrage du
process. `docker-compose.yml` et
`.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
généreux pour ça — voir leurs propres commentaires. C'est un compromis
assumé, pas un défaut de configuration à corriger : moins d'itérations
@ -149,7 +151,7 @@ vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
## Limitations connues
- **Démarrage lent** (~7 minutes) — voir "Temps de démarrage" ci-dessus.
- **Démarrage lent** (~11 minutes) — voir "Temps de démarrage" ci-dessus.
Une optimisation possible non explorée : parallélisation de
l'entraînement `fr`/`en` (actuellement séquentiel,
`PipelineRegistry.initialize`).

View file

@ -56,29 +56,37 @@ _TEXTCAT_PIPE_NAME = "textcat"
# calibrés empiriquement contre le corpus réel (`training_data.py`), pas
# seulement contre les petits corpus jouets des tests de ce fichier. Trop
# peu d'itérations laisse des clauses correctement classifiées (bon argmax)
# mais avec une confiance dérisoire (`0.02`-`0.08` observé à 5-15
# itérations) — bien en dessous de tout seuil raisonnable pour
# `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
# mais avec une confiance dérisoire — bien en dessous de tout seuil
# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
#
# `150` convenait au corpus original (~26 techniques) mais ne passe plus à
# l'échelle une fois le corpus élargi à ~74 : le temps d'entraînement croît
# avec le nombre de classes autant qu'avec les itérations (mesuré :
# ~150s pour seulement 30 itérations sur 74 classes, contre ~110s pour 150
# itérations sur 26 classes) — `150` sur 74 classes dépassait 17 minutes
# rien que pour une locale, constaté en CI. `40` est le meilleur compromis
# trouvé empiriquement sur ce corpus élargi : ~200s par locale (~400s pour
# fr+en combinés au démarrage), avec des scores exploitables sur tous les
# cas testés à la main (melt ~0.89, preheat ~0.76, compote ~0.76, zest
# ~0.64, julienne ~0.56, cook/bake ~0.33, simmer ~0.25 — le plus faible
# observé, toujours correct en argmax et de toute façon ancré par NER) et
# un bruit hors-vocabulaire qui reste négligeable (anglais via le
# classifieur français : `~0.02`). Une vraie repasse de
# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET` reste
# nécessaire pour confirmer/affiner ces deux valeurs (voir
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`) — ce
# qui suit est une mesure manuelle ponctuelle, pas un remplacement de cette
# calibration.
_TRAINING_ITERATIONS = 40
# Trois passes de calibration successives, toutes mesurées contre le
# corpus réel (74 techniques) :
# 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne
# passe plus à l'échelle une fois élargi : `150` sur 74 classes
# dépassait 17 minutes pour une seule locale, constaté en CI.
# 2. `40` itérations, `examples` limité aux `utterances` (pas les
# `synonyms`) : ~200s/locale, mais confiance faible sur les clauses
# ancrées sans paraphrase entraînée (`simmer`/`cook`/`bake` ~0.25-0.34).
# 3. **Configuration actuelle** : les `synonyms` de chaque technique sont
# désormais aussi des exemples d'entraînement du textcat (voir plus bas
# dans `train()`) — un signal "mot-clé isolé -> sa propre technique"
# qui manquait complètement avant. À `_TRAINING_ITERATIONS` inchangé
# (40), le nombre d'exemples par époque grimpe de ~286 à ~749 et le
# temps d'entraînement suit (~535s/locale) ; réduire à `25` retrouve un
# temps proche de l'étape 2 (~336s/locale, ~670s pour fr+en combinés)
# tout en gardant l'essentiel du gain de confiance apporté par les
# synonymes : melt ~0.89, preheat ~0.77, compote ~0.78, julienne ~0.76,
# zest ~0.66, bake ~0.62, cook ~0.38, simmer ~0.31 — le plus faible
# observé, mais désormais nettement au-dessus du seuil de confiance
# (contre ~0.25, sous le seuil d'alors, à l'étape 2). Bruit
# hors-vocabulaire toujours négligeable (anglais via le classifieur
# français : `~0.02`). Une vraie repasse de
# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET`
# reste nécessaire pour confirmer/affiner ces valeurs (voir
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`)
# — ce qui précède est une mesure manuelle ponctuelle, pas un
# remplacement de cette calibration.
_TRAINING_ITERATIONS = 25
_TRAINING_BATCH_SIZE = 16
# Arrêt anticipé : `_TRAINING_ITERATIONS` reste le plafond (le pire cas ne
# change pas), un corpus/locale qui converge plus vite n'a pas à payer les
@ -87,12 +95,12 @@ _TRAINING_BATCH_SIZE = 16
# sous la meilleure perte vue jusqu'ici ; `_EARLY_STOPPING_PATIENCE` époques
# consécutives sans progrès arrêtent l'entraînement.
#
# Mesuré contre le corpus réel (74 techniques) : ne se déclenche jamais dans
# le budget actuel de 40 itérations — la perte continue de baisser
# significativement sur toute la plage (cohérent avec la confiance qui
# grimpe encore nettement entre 15 et 40 itérations, voir le commentaire de
# `_TRAINING_ITERATIONS`). Ce n'est donc pas un gain de temps aujourd'hui,
# mais un filet de sécurité peu coûteux pour la suite : si
# Mesuré contre le corpus réel (74 techniques, budget de 40 itérations,
# avant le passage à 25) : ne s'est jamais déclenché — la perte continuait
# de baisser significativement sur toute la plage (cohérent avec la
# confiance qui grimpait encore nettement entre 15 et 40 itérations, voir
# le commentaire de `_TRAINING_ITERATIONS`). Ce n'est donc pas un gain de
# temps aujourd'hui, mais un filet de sécurité peu coûteux pour la suite : si
# `_TRAINING_ITERATIONS` est un jour augmenté pour une meilleure confiance,
# ceci évite de payer des itérations supplémentaires une fois la
# convergence réellement atteinte, sans qu'il faille retrouver le bon
@ -223,8 +231,11 @@ class LocalePipeline:
def train(self, entries: list[TrainEntry]) -> tuple[int, int, int]:
"""Reconstruit le `textcat` et le `PhraseMatcher` de ce pipeline à
partir de `entries` (le tokenizer/les vecteurs restent ceux chargés
par `preload()`). Retourne `(label_count, utterance_count,
synonym_count)` pour la réponse `/v1/train`.
par `preload()`). Retourne `(label_count, example_count,
synonym_count)` pour la journalisation (`pipeline_registry.py`)
`example_count` est le nombre réel d'exemples donnés au `textcat`
(`utterances` *et* `synonyms` combinés, voir plus bas), pas
seulement `entry.utterances`.
`entries` vide retombe à `is_trained == False` plutôt que de lever
un appelant qui n'a rien à entraîner pour cette locale obtient le
@ -298,10 +309,16 @@ class LocalePipeline:
textcat.add_label(entry.uid)
for entry in entries:
for utterance in entry.utterances:
doc = nlp.make_doc(utterance)
cats = {other.uid: 0.0 for other in entries}
cats[entry.uid] = 1.0
cats = {other.uid: 0.0 for other in entries}
cats[entry.uid] = 1.0
# `synonyms` (déjà utilisés pour le `PhraseMatcher` ci-dessus)
# sont aussi de bonnes phrases d'entraînement pour le
# `textcat` — un texte réduit au mot-clé lui-même ("fondre",
# "faire fondre") est le cas le plus net qui soit pour sa
# propre technique, et n'était auparavant vu par le textcat
# que noyé dans le contexte plus riche des `utterances`.
for text in (*entry.synonyms, *entry.utterances):
doc = nlp.make_doc(text)
examples.append(Example.from_dict(doc, {"cats": cats}))
# Graine le RNG Python *et* celui de numpy/thinc sous-jacent à

View file

@ -45,13 +45,16 @@ class PipelineRegistry:
for locale, pipeline in self._pipelines.items():
pipeline.preload()
entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)]
label_count, utterance_count, synonym_count = pipeline.train(entries)
label_count, example_count, synonym_count = pipeline.train(entries)
logger.info(
"tech-step NLP pipeline trained",
extra={
"locale": locale,
"labelCount": label_count,
"utteranceCount": utterance_count,
# Nombre réel d'exemples donnés au textcat (utterances
# *et* synonyms combinés — voir `LocalePipeline.train`),
# pas seulement le compte d'`utterances` du corpus.
"exampleCount": example_count,
"synonymCount": synonym_count,
},
)

View file

@ -29,11 +29,15 @@ _ENTRIES = [
]
def test_train_returns_label_utterance_and_synonym_counts():
def test_train_returns_label_example_and_synonym_counts():
pipeline = LocalePipeline("fr")
label_count, utterance_count, synonym_count = pipeline.train(_ENTRIES)
label_count, example_count, synonym_count = pipeline.train(_ENTRIES)
assert label_count == 2
assert utterance_count == sum(len(entry.utterances) for entry in _ENTRIES)
# `example_count` couvre les utterances *et* les synonyms (voir
# LocalePipeline.train — les synonymes sont aussi des exemples
# d'entraînement pour le textcat, pas seulement pour le PhraseMatcher).
expected_examples = sum(len(entry.utterances) + len(entry.synonyms) for entry in _ENTRIES)
assert example_count == expected_examples
assert synonym_count == sum(len(entry.synonyms) for entry in _ENTRIES)
assert pipeline.is_trained is True