diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57666e5..5009fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index d4fdc2d..54864e9 100644 --- a/README.md +++ b/README.md @@ -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 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 af62d54..cfc51d3 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 797ad14..4af78a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/services/tech-step-intent-service/README.md b/services/tech-step-intent-service/README.md index 9dfed69..25695f2 100644 --- a/services/tech-step-intent-service/README.md +++ b/services/tech-step-intent-service/README.md @@ -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`). 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 7d910f9..de604df 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -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 à diff --git a/services/tech-step-intent-service/intent_service/pipeline_registry.py b/services/tech-step-intent-service/intent_service/pipeline_registry.py index de7d435..396da22 100644 --- a/services/tech-step-intent-service/intent_service/pipeline_registry.py +++ b/services/tech-step-intent-service/intent_service/pipeline_registry.py @@ -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, }, ) diff --git a/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py b/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py index 256ed0b..b9efa03 100644 --- a/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py +++ b/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py @@ -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