diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3c48e9..e49ed59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,11 +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 ~540s (fr) / ~390s (en), - # ~930s combined, against the current ~74-technique corpus (see - # docker-compose.yml's healthcheck for the same reasoning and why - # this grew slightly from the original ~670s). - timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done' + # see its own README) — measured at ~30s 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' - run: pnpm install --frozen-lockfile - run: pnpm --filter api exec prisma migrate deploy diff --git a/docker-compose.yml b/docker-compose.yml index 80a861d..a806c8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,17 +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 ~540s (fr) / ~390s (en), ~930s combined, - # against the current ~74-technique corpus — each technique now has - # the *same* number of `utterances` per locale as every other - # (equalized to the corpus's own pre-existing max, 7/5 — see - # `training_data.py`'s own doc comment for why a flat, larger target - # like 20 was tried and reverted) — `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: 1200s + # are loaded. Measured at ~30s 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" + # 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 # Deliberately its own image, not built into `app`'s (see # services/tech-step-llm-worker/Dockerfile's own doc comment) — a diff --git a/services/tech-step-intent-service/README.md b/services/tech-step-intent-service/README.md index 521e807..6b3b208 100644 --- a/services/tech-step-intent-service/README.md +++ b/services/tech-step-intent-service/README.md @@ -89,24 +89,24 @@ côté `apps/api`. ## Temps de démarrage -**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, chaque technique entraînée sur ses `synonyms` -en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre -de 540 secondes pour `fr` / 390 secondes pour `en` (mesuré localement, -sans GPU), donc environ 930 secondes (~15-16 minutes) pour `fr`+`en` -combinés à chaque démarrage du process — chaque technique a désormais le -même nombre d'`utterances` par locale (voir `training_data.py`'s own doc -comment), légèrement plus qu'avant ce rééquilibrage. `docker-compose.yml` -et `.github/workflows/ci.yml` ont un `start_period`/timeout d'attente -généreux pour ça (`1200s`) — voir leurs propres commentaires. C'est un -compromis -assumé, pas un défaut de configuration à corriger : moins d'itérations -entraîne plus vite mais laisse des verdicts corrects sous -`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante, -`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de -`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py` -pour le détail du compromis). +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. + +Ç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. + +`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. ## Logs @@ -177,20 +177,34 @@ vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme ## Limitations connues -- **Démarrage lent** (~15-16 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`). +- **`TECH_STEP_EVAL_DATASET` ne couvre que 26 des 74 techniques** (41 cas, + 33 `fr` / 8 `en`) — 48 techniques (65 %) n'ont *aucune* couverture + d'évaluation. Le F1 agrégé de `0.815` porte sur ~39 spans, ce qui ne + permet de mesurer ni le F1 par technique, ni les confusions entre + techniques voisines. **C'est la limitation la plus bloquante de la + liste** : tant qu'elle tient, aucun changement d'architecture ou de + corpus n'est mesurable autrement qu'à l'intuition. - **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis l'élargissement du corpus à ~74 techniques (calibré à la main, pas via une vraie repasse de `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante). -- **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. + Conséquence directe du point précédent. +- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — n'exploite pas les + vecteurs de mots des modèles `md` chargés. Une mesure ponctuelle donne + `spacy.TextCatReduce.v1` (tok2vec + vecteurs statiques) à 98,6 % + d'accuracy dev et 0,984 de confiance moyenne, contre 97,3 % / 0,919 pour + le BOW actuel. **Mais** les mêmes sondes montrent des erreurs *confiantes* + sur les classes voisines (`"enfourner la tarte"` → `blindBake` 0,711 avec + `bake` à 0,004 ; `"cuire les pâtes dans l'eau bouillante"` → `poach` + 0,801) : une confiance plus haute ne veut pas dire plus juste, et + neutralise `CONFIDENCE_THRESHOLD` comme filet de sécurité. À ne trancher + qu'avec un F1 par classe, donc après le premier point de cette liste. +- **Des techniques se recouvrent réellement** (`bake`/`blindBake`, + `boil`/`poach`/`blanch`/`cook`, `rest`/`proof`) — 74 classes mutuellement + exclusives à ~7 exemples chacune, aux frontières floues, est un cadrage + qui plafonne quelle que soit l'architecture. À instruire (fusion de + classes ? `textcat_multilabel` ?) seulement si une matrice de confusion le + confirme. - **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance, pas de fusion incrémentale) — un choix délibéré (voir `LocalePipeline.train`), pas une limitation à lever : `training_data.py` 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 04193c2..b695b34 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -54,6 +54,41 @@ _EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_r _TEXTCAT_PIPE_NAME = "textcat" +# Largeur du vecteur de features hachées de `spacy.TextCatBOW.v3`. +# +# **Ce paramètre était le goulot d'étranglement du démarrage de ce service.** +# spaCy le laisse à `262144` (2^18) par défaut, et il n'était pas passé +# 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. +# +# 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) : +# +# 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 +# +# `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 + # 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 # seulement contre les petits corpus jouets des tests de ce fichier. Trop @@ -61,33 +96,33 @@ _TEXTCAT_PIPE_NAME = "textcat" # mais avec une confiance dérisoire — bien en dessous de tout seuil # raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`). # -# Trois passes de calibration successives, toutes mesurées contre le -# corpus réel (74 techniques) : +# Historique de calibration (toutes les mesures contre le corpus réel) : # 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. +# 3. Les `synonyms` de chaque technique sont devenus eux 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. +# Gain confirmé depuis par ablation : retirer les synonymes des exemples +# fait retomber la confiance moyenne de `0.816` à `0.714` à configuration +# égale. `_TRAINING_ITERATIONS` a été descendu à `25` à cette occasion +# pour compenser le temps d'entraînement. +# 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 +# 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 +# `tech-step-evaluator.ts`) montre qu'il y a quelque chose à y gagner. +# +# Une vraie repasse de `calibrate-tech-step-threshold.ts` contre +# `TECH_STEP_EVAL_DATASET` reste nécessaire (voir `CONFIDENCE_THRESHOLD`'s +# propre commentaire, `tech-step-matcher.ts`) — les chiffres ci-dessus sont +# des mesures ponctuelles, 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 @@ -333,8 +368,12 @@ class LocalePipeline: "model": { "@architectures": "spacy.TextCatBOW.v3", "exclusive_classes": True, - "ngram_size": 1, + "ngram_size": _TEXTCAT_NGRAM_SIZE, "no_output_layer": False, + # Explicite, jamais laissé au défaut spaCy — voir + # `_TEXTCAT_MODEL_LENGTH` pour pourquoi ce seul + # paramètre valait 30x le temps d'entraînement. + "length": _TEXTCAT_MODEL_LENGTH, }, }, )