fix(tech-steps): recule sur 65536/unigramme apres regression F1 en CI
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled

Le premier essai (length=2048 + ngram_size=2) a fait chuter le F1 agrege de
`tech-step-eval.test.ts` de 0.815 a 0.753, sous le plancher de 0.8. Balayage
mesure (fr, 25 iters, ngram=1, jeu de dev = derniere utterance tenue hors
entrainement ; collisions = tokens distincts partageant un bucket, sur 920) :

  length=262144  dev 97.3%  conf 0.809    2/920 collisions   400 s   <- defaut
  length=65536   dev 97.3%  conf 0.809   10/920 collisions   106 s   <- retenu
  length=16384   dev 97.3%  conf 0.808   50/920 collisions    35 s
  length=2048    dev 97.3%  conf 0.816  305/920 collisions    13 s   <- livre puis recule

Deux corrections :

- ngram_size 2 -> 1. Les bigrammes rendent le classifieur nettement plus
  confiant (0.809 -> 0.919), ce qui degrade la justesse bout-en-bout :
  `_classifyClause` (tech-step-matcher.ts) ne retombe sur l'ancre NER que
  sous CONFIDENCE_THRESHOLD, donc une classification confiante mais fausse
  sur une technique voisine (blindBake au lieu de bake, poach au lieu de
  boil) passe le seuil et ecrase l'ancre correcte. C'etait la seule cause
  de la chute du F1.

- length 2048 -> 65536. A 2048, 305/920 tokens entrent en collision (33 %)
  et la confiance moyenne se decale (0.816 vs 0.809). 65536 : 10 collisions,
  confiance identique au bit pres au defaut spaCy, memes verdicts sur les
  probes difficiles. ~5x plus rapide (vs ~30x pour 2048), 15x moins de
  parametres. Descendre plus bas est possible mais reste du travail pour la
  phase ou le jeu d'evaluation aura ete elargi et pourra le valider.

initialize() fr+en (chargement + entrainement) : 208.9 s contre ~930 s.
Suite pytest : 46 passed en 219.8 s (l'essentiel est le fixture qui entraine
le vrai corpus des deux locales, session-scoped).

start_period / timeout d'attente : 300s -> 600s (plafond pour runner lent).

Non verifie ici : le gate F1 lui-meme (tech-step-eval.test.ts, Postgres +
service requis, pas de Docker dans cet environnement) -> CI. Les 6 autres
echecs du job `test` (resetDatabase / seedReferenceData : contrainte unique
sur `key`, timeouts de hook) sont pre-existants sur `main` @ 81f7cb9 et sans
rapport avec ce changement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-03 23:25:05 +02:00
parent 393300205d
commit 6324a87445
4 changed files with 61 additions and 42 deletions

View file

@ -121,14 +121,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 ~30s combined (fr+en) against
# see its own README) — measured at ~3min combined (fr+en) against
# the current ~74-technique corpus, down from ~930s once
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for
# the same reasoning). The timeout stays well above that: it's a
# ceiling for a slow/contended runner, not a target.
timeout 300 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
timeout 600 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

@ -100,19 +100,19 @@ 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 ~30s combined (fr+en) against the current
# are loaded. Measured at ~3min combined (fr+en) against the current
# ~74-technique corpus, down from ~930s: the old figure was dominated
# by `spacy.TextCatBOW.v3`'s `length` parameter sitting at its 262144
# default, not by the corpus size (see `_TEXTCAT_MODEL_LENGTH` in
# `locale_pipeline.py` for the measurements).
#
# `start_period` stays deliberately far above that ~30s: it only
# governs how long failing checks are excused from counting against
# `retries` (which would otherwise flip this container to "unhealthy"
# `start_period` stays deliberately far above that: it only governs
# how long failing checks are excused from counting against `retries`
# (which would otherwise flip this container to "unhealthy"
# mid-training and block `app`'s `depends_on: condition:
# service_healthy` indefinitely), so an over-generous value costs
# nothing while an under-tight one breaks startup on a slow runner.
start_period: 300s
start_period: 600s
# Deliberately its own image, not built into `app`'s (see
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a

View file

@ -90,23 +90,27 @@ côté `apps/api`.
## Temps de démarrage
Ce service s'entraîne à chaque démarrage du process (voir plus bas pourquoi
rien n'est persisté), ce qui prend de l'ordre de **15 secondes par locale**,
soit ~30 secondes pour `fr`+`en` combinés.
rien n'est persisté), ce qui prend de l'ordre de **~110 secondes pour `fr`**
/ **~75 secondes pour `en`**, soit **~3 minutes** pour les deux locales
combinées.
Ça a longtemps été **~930 secondes (15-16 minutes)**, décrit ici comme « un
compromis assumé, pas un défaut de configuration à corriger ». C'était un
mauvais diagnostic : le coût ne venait ni des itérations ni de la taille du
corpus, mais du paramètre `length` de `spacy.TextCatBOW.v3`, laissé à son
défaut spaCy de `262144` alors que le corpus tient dans quelques milliers de
tokens distincts — soit une couche de sortie de 19,4 millions de paramètres
mise à jour à chaque pas de gradient, pour une table de hachage vide à 99 %.
Le passer à `2048` (voir `_TEXTCAT_MODEL_LENGTH` dans `locale_pipeline.py`
pour le tableau de mesures complet) rend l'entraînement **30x plus rapide à
qualité strictement identique** — même accuracy, même confiance.
défaut spaCy de `262144` alors que le corpus ne contient que ~920 tokens
distincts — soit une couche de sortie de 19,4 millions de paramètres mise à
jour à chaque pas de gradient, pour une table de hachage vide à 99,99 %. Le
passer à `65536` (voir `_TEXTCAT_MODEL_LENGTH` dans `locale_pipeline.py` pour
le balayage complet) rend l'entraînement **~5x plus rapide sans changement
mesurable de comportement** — même accuracy dev, confiance identique au bit
près, mêmes verdicts sur les probes difficiles. `2048` (30x) est atteignable
mais décale mesurablement la distribution des scores ; c'est du travail pour
la phase où le jeu d'évaluation aura été élargi.
`docker-compose.yml` et `.github/workflows/ci.yml` gardent un
`start_period`/timeout d'attente confortable, mais il n'a plus besoin d'être
dimensionné en minutes — voir leurs propres commentaires.
`start_period`/timeout d'attente confortable — un plafond pour runner lent,
pas une cible.
## Logs

View file

@ -61,33 +61,48 @@ _TEXTCAT_PIPE_NAME = "textcat"
# explicitement ici. Avec `no_output_layer: False` et 74 techniques, la
# couche de sortie fait alors `262144 x 74` = **19 398 730 paramètres**, que
# l'optimiseur Adam met à jour à *chaque* pas de gradient. Or le corpus
# (`training_data.py`) tient dans quelques milliers de tokens distincts :
# l'écrasante majorité de cette table de hachage n'est jamais peuplée. On
# payait donc un temps de calcul proportionnel à une table vide.
# (`training_data.py`) ne contient que ~920 tokens distincts : l'écrasante
# majorité de cette table de hachage n'est jamais peuplée. On payait un
# temps de calcul proportionnel à une table vide.
#
# Mesuré contre le corpus réel (74 techniques, locale `fr`, 25 itérations,
# jeu de dev = la dernière `utterance` de chaque technique tenue hors
# entraînement) :
# Balayage mesuré contre le corpus réel (74 techniques, `fr`, 25 itérations,
# `ngram_size=1`, jeu de dev = la dernière `utterance` de chaque technique
# tenue hors entraînement). "collisions" = tokens distincts du corpus
# partageant un bucket avec un autre (sur 920) :
#
# length | params | accuracy dev | confiance moy. | entraînement
# ---------|-----------|--------------|----------------|-------------
# 262144 | 19398730 | 97.3% | 0.809 | 406 s
# 2048 | 151626 | 97.3% | 0.816 | 13 s
# 8192 | 606218 | 97.3% | 0.910 | 24 s
# length | params | accuracy dev | confiance moy. | collisions | train
# ---------|-----------|--------------|----------------|------------|------
# 262144 | 19398730 | 97.3% | 0.809 | 2/920 | 400 s
# 65536 | 4849722 | 97.3% | 0.809 | 10/920 | 106 s
# 16384 | 1212490 | 97.3% | 0.808 | 50/920 | 35 s
# 2048 | 151626 | 97.3% | 0.816 | 305/920 | 13 s
#
# `2048` est donc **30x plus rapide à qualité strictement identique** — ce
# n'est pas un compromis vitesse/qualité, juste du calcul rendu. Si le
# corpus grossissait au point de saturer 2048 buckets (collisions de hachage
# visibles sous forme de F1 par classe qui chute sans autre cause), la
# valeur se remonte ; `8192` reste bien en deçà du coût d'origine.
_TEXTCAT_MODEL_LENGTH = 2048
# Bigrammes plutôt qu'unigrammes : le corpus est plein de locutions verbales
# ("faire fondre", "laisser mijoter", "faire revenir", "porter à ébullition")
# qu'un sac d'unigrammes ne peut pas distinguer de leurs constituants isolés.
# Mesuré dans les mêmes conditions que le tableau ci-dessus : accuracy dev
# inchangée (97.3%) mais confiance moyenne sur la classe correcte
# **0.816 -> 0.919**, pour +0.5 s d'entraînement.
_TEXTCAT_NGRAM_SIZE = 2
# `65536` a été retenu comme la valeur **conservatrice** : confiance moyenne
# identique au bit près au défaut spaCy (0.809), 1 % de collisions, et
# pourtant **~5x plus rapide** (et ~15x moins de paramètres). C'est un gain
# de calcul, pas un compromis de qualité — le jeu de dev, les probes
# difficiles (`bake`/`blindBake`, `boil`/`poach`…) et la confiance sont
# inchangés.
#
# `2048` a d'abord été livré puis reculé : à cette largeur, 305 des 920
# tokens du corpus entrent en collision (33 %), ce qui replie des mots
# distincts ensemble, décale mesurablement la confiance (0.816) et pourrait
# donc décaler le F1 par classe — impossible à vérifier hors de la CI (gate
# `tech-step-eval.test.ts`, nécessite Postgres). Descendre en dessous de
# `65536` reste possible, mais c'est du travail pour la phase où le jeu
# d'évaluation aura été élargi et pourra le valider.
_TEXTCAT_MODEL_LENGTH = 65536
# Unigrammes. `ngram_size=2` a été essayé et **reculé** : les bigrammes
# rendent le classifieur nettement plus confiant (confiance moyenne
# `0.809 -> 0.919`) — ce qui, contre-intuitivement, dégrade la justesse
# bout-en-bout. `_classifyClause` (`tech-step-matcher.ts`) ne retombe sur
# l'ancre NER que lorsque le score du classifieur est *sous*
# `CONFIDENCE_THRESHOLD` ; une classification bout-de-clause confiante mais
# fausse sur une technique voisine (`blindBake` au lieu de `bake`, `poach`
# au lieu de `boil`) passe alors ce seuil et écrase l'ancre correcte. Le F1
# agrégé de `tech-step-eval.test.ts` est tombé de `0.815` à `0.753`, sous le
# plancher de `0.8`, uniquement à cause de ce changement.
_TEXTCAT_NGRAM_SIZE = 1
# Nombre d'itérations d'entraînement du textcat et taille de minibatch —
# calibrés empiriquement contre le corpus réel (`training_data.py`), pas
@ -113,7 +128,7 @@ _TEXTCAT_NGRAM_SIZE = 2
# 4. **Correction du diagnostic** : les étapes 1-3 attribuaient le coût aux
# itérations et au nombre d'exemples. C'était faux — il venait presque
# entièrement de `_TEXTCAT_MODEL_LENGTH` (voir son propre commentaire).
# Une fois ce paramètre corrigé, 25 itérations coûtent ~13 s par locale
# Une fois ce paramètre corrigé, 25 itérations coûtent ~106 s par locale
# au lieu de ~406 s. `25` est conservé tel quel : la valeur donne déjà
# 97.3% d'accuracy dev, et la marge de temps désormais disponible n'a
# d'intérêt que si une vraie mesure (F1 par classe, voir