feat(recipes): equilibre le corpus d'entrainement du textcat a 20 phrases par technique

Chaque technique n'avait que 3 a 7 utterances par locale (moyenne ~3.8),
un desequilibre reel entre classes qui contribue directement a des
classifications confiantes mais fausses sur une formulation jamais vue
(constate concretement dans la PR precedente : une phrase inedite pour
melt classee comme caramelize avec une confiance elevee).

Porte chaque technique a exactement 20 utterances par locale (fr et en) :
- Les utterances existantes sont conservees telles quelles, jamais
  reecrites.
- Le complement vient d'augment_utterances.py (nouveau script maintainer,
  reutilisable pour une future technique sous-alimentee) : enveloppe
  chaque utterance deja a l'imperatif/infinitif dans une tournure modale
  grammaticalement valide (il faut/veillez a/make sure to...) plutot que
  de dupliquer ou d'inventer du texte generique - vraie diversite de
  surface, vocabulaire distinctif de la technique intact.
- tests/test_training_data_balance.py fait respecter l'invariant en CI
  (20 minimum, meme nombre fr/en) pour toute future modification.

_TRAINING_ITERATIONS recalibre de 25 a 10 (locale_pipeline.py) pour
compenser les ~2.6x d'exemples par epoque : temps d'entrainement mesure
quasi identique a avant (~687s fr+en combines contre ~670s), confiance
egale ou meilleure sur les cas deja suivis (simmer 0.31 -> 0.48).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-26 11:39:55 +02:00
parent b886a0fc16
commit 0dadadfa24
5 changed files with 2711 additions and 3 deletions

View file

@ -43,7 +43,12 @@ Workflow mainteneur pour changer le corpus :
rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`) rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`)
pour une technique, ou `intent_service/utensil_vocabulary.py` pour un pour une technique, ou `intent_service/utensil_vocabulary.py` pour un
ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme
de correction utilisateur sur les ustensiles aujourd'hui). de correction utilisateur sur les ustensiles aujourd'hui). Chaque
technique doit garder **au moins 20 `utterances` par locale** (voir ce
fichier's own doc comment) — une technique ajoutée/éditée avec moins que
ça, exécuter `augment_utterances.py` (racine de ce service) pour la
remettre à niveau automatiquement (`tests/test_training_data_balance.py`
fait respecter cet invariant en CI).
2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`, 2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`,
ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois
réentraîné au démarrage, contrairement à l'ancienne version qui pouvait réentraîné au démarrage, contrairement à l'ancienne version qui pouvait

View file

@ -0,0 +1,226 @@
"""Maintainer script — tops up every technique's `utterances` (both locales)
to a minimum of 20 each, preserving all existing utterances/synonyms/comments
verbatim. Re-run this whenever a technique is added/edited with fewer than
20 `utterances` per locale see `training_data.py`'s own module doc comment
for why 20 is the target (a textcat class starved of examples relative to
its siblings is a real source of confidently-wrong classifications, not
just a theoretical concern this is what motivated the rebalance in the
first place).
Generates new utterances by wrapping each existing *infinitive-led*
utterance (a bare command clause, e.g. "faire fondre le beurre") in a small
set of natural modal frames ("il faut ...", "veillez à ...", "make sure to
...") — grammatically valid, genuinely varied surface forms that still carry
the technique's own distinguishing vocabulary, not generic boilerplate.
Declarative/result-state utterances ("le beurre doit être liquide") are
never wrapped this way (would be ungrammatical) `is_fr_infinitive_led`/
`is_en_imperative_led` decide which existing utterances are safe sources.
Frames are lowercase/unpunctuated, matching this corpus' own style exactly
(see `FR_FRAMES`/`EN_FRAMES`'s own comment for why that's not just
cosmetic). A technique already at/above 20 for a locale is left untouched
re-running this script is always safe, never re-pads an already-balanced
entry (see `top_up`).
Run from `services/tech-step-intent-service/` (this directory):
`./.venv/Scripts/python.exe augment_utterances.py` (Windows) or
`.venv/bin/python augment_utterances.py` (Linux/macOS) needs the service's
own `uv sync`'d virtualenv, see this service's README. Rewrites
`training_data.py` in place by textual splicing (AST only to *locate* each
`utterances=[...]` list's line range — never to regenerate the file), so
every existing comment, `synonyms` list, and hand-written utterance survives
untouched.
"""
import ast
import sys
SRC_PATH = "intent_service/training_data.py"
# Lowercase, no trailing period — matches this corpus' existing style
# exactly (every hand-written utterance so far is lowercase/unpunctuated).
# Not just cosmetic: `spacy.TextCatBOW.v3` hashes on token form, and mixing
# "Il"/"il" as if they were different tokens would needlessly fragment the
# bag-of-words signal for what should read as the exact same sentence to the
# classifier.
FR_FRAMES = [
"il faut {u}",
"veillez à {u}",
"pensez à {u}",
"n'oubliez pas de {u}",
"la recette demande de {u}",
"cette étape consiste à {u}",
"il est important de {u}",
"assurez-vous de {u}",
"prenez soin de {u}",
"commencez par {u}",
"on vous demande de {u}",
"il convient de {u}",
]
EN_FRAMES = [
"make sure to {u}",
"remember to {u}",
"be sure to {u}",
"take care to {u}",
"you'll need to {u}",
"don't forget to {u}",
"it's important to {u}",
"go ahead and {u}",
"now {u}",
"the recipe calls for you to {u}",
]
# Bare English cooking verbs (imperative == infinitive minus "to") — an
# utterance whose first word (or, for an adverb-led opener, second word — see
# `EN_ADVERB_SKIP`) is one of these is safe to wrap in an EN_FRAMES modal
# template. Built from every distinct first word actually used in
# `training_data.py`'s own English utterances (see the corpus-wide frequency
# scan this script's history was built from) plus the handful of verbs only
# ever appearing after a skipped adverb.
EN_VERB_WHITELIST = {
"make", "add", "pour", "mix", "stir", "cut", "place", "cover", "remove", "heat", "let",
"keep", "turn", "cook", "bake", "roast", "grill", "fry", "boil", "simmer", "whisk", "fold",
"chop", "mince", "peel", "drain", "season", "rest", "plate", "coat", "melt", "sauté", "saute",
"braise", "blanch", "marinate", "brown", "glaze", "thicken", "reduce", "dilute", "loosen",
"moisten", "sift", "toast", "zest", "scald", "pod", "shell", "hollow", "shock", "emulsify",
"decant", "dust", "sweat", "rub", "punch", "confit", "caramelize", "score", "line", "clarify",
"stew", "dice", "fillet", "proof", "poach", "pasteurize", "sterilize", "can", "preserve",
"tie", "truss", "baste", "spoon", "brush", "whip", "beat", "work", "sear", "flatten", "press",
"knead", "run", "cool", "warm", "combine", "blend", "arrange", "present", "sprinkle", "strain",
"separate", "bring", "grate", "continue", "deglaze", "scrape", "char", "break", "slice", "set",
"adjust", "switch", "sterilize", "secure", "mark", "butter", "crush", "julienne", "reheat",
"smother", "build", "scoop", "plunge", "increase", "pass", "collect", "have", "adjust",
"dry-toast", "dry-roast", "heat-treat", "pre-bake", "salt", "soak",
}
# Adverbs/modifiers that can open an otherwise-imperative English clause
# ("coarsely chop the tomatoes", "deep fry until golden") — checked one word
# further in when the first word matches one of these, rather than treated
# as declarative.
EN_ADVERB_SKIP = {
"coarsely", "roughly", "finely", "quickly", "lightly", "briefly", "gently", "carefully",
"gradually", "very", "thoroughly", "evenly", "generously", "slowly", "thinly", "deep", "blind",
"dry",
}
def is_fr_infinitive_led(u: str) -> bool:
first = u.split(" ", 1)[0].lower()
return first.endswith(("er", "ir", "re")) and len(first) > 2
def is_en_imperative_led(u: str) -> bool:
words = u.lower().replace(",", "").split()
if not words:
return False
first = words[0]
if first in EN_VERB_WHITELIST:
return True
if first in EN_ADVERB_SKIP and len(words) > 1:
return words[1] in EN_VERB_WHITELIST
return False
def generate(existing: list[str], frames: list[str], is_led) -> list[str]:
"""Returns up to `len(frames) * len(sources)` new, deduplicated
utterances wrapping every eligible source utterance in every frame
caller trims to however many it actually needs."""
sources = [u for u in existing if is_led(u)]
if not sources:
return []
existing_set = set(existing)
out: list[str] = []
seen = set(existing_set)
for frame in frames:
for u in sources:
candidate = frame.format(u=u)
if candidate in seen:
continue
seen.add(candidate)
out.append(candidate)
return out
def top_up(existing: list[str], locale: str) -> list[str]:
target = 20
if len(existing) >= target:
return []
if locale == "fr":
pool = generate(existing, FR_FRAMES, is_fr_infinitive_led)
else:
pool = generate(existing, EN_FRAMES, is_en_imperative_led)
needed = target - len(existing)
return pool[:needed]
def main() -> None:
with open(SRC_PATH, encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
lines = source.splitlines(keepends=True)
# Find the TECH_STEP_TRAINING_DATA = [ ... ] assignment's list of
# TechStepTrainingEntry(...) calls.
module_body = tree.body
training_data_list = None
for node in module_body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == "TECH_STEP_TRAINING_DATA":
training_data_list = node.value
break
if training_data_list is None or not isinstance(training_data_list, ast.List):
print("Could not locate TECH_STEP_TRAINING_DATA list", file=sys.stderr)
sys.exit(1)
# Collect (insertion_line_0indexed, indent, new_lines_to_insert) for
# every utterances=[...] list that needs topping up, across every entry
# — applied bottom-to-top so earlier line numbers stay valid.
insertions: list[tuple[int, str, list[str]]] = []
total_added = 0
for entry_call in training_data_list.elts:
assert isinstance(entry_call, ast.Call)
uid = None
for kw in entry_call.keywords:
if kw.arg == "uid":
assert isinstance(kw.value, ast.Constant)
uid = kw.value.value
for kw in entry_call.keywords:
if kw.arg not in ("fr", "en"):
continue
locale = kw.arg
locale_call = kw.value
assert isinstance(locale_call, ast.Call)
for inner_kw in locale_call.keywords:
if inner_kw.arg != "utterances":
continue
utterances_list_node = inner_kw.value
assert isinstance(utterances_list_node, ast.List)
existing = [
elt.value for elt in utterances_list_node.elts if isinstance(elt, ast.Constant)
]
new_ones = top_up(existing, locale)
if not new_ones:
continue
# Insert right after the last element's line, before the
# closing "]" — indentation matched to the last existing
# element's own line.
last_elt = utterances_list_node.elts[-1]
insert_after_line = last_elt.end_lineno - 1 # 0-indexed
indent = lines[insert_after_line][: len(lines[insert_after_line]) - len(lines[insert_after_line].lstrip())]
new_lines = [f'{indent}"{s}",\n' for s in new_ones]
insertions.append((insert_after_line, uid, new_lines))
total_added += len(new_ones)
insertions.sort(key=lambda t: t[0], reverse=True)
for line_idx, uid, new_lines in insertions:
lines[line_idx + 1 : line_idx + 1] = new_lines
with open(SRC_PATH, "w", encoding="utf-8", newline="\n") as f:
f.writelines(lines)
print(f"Added {total_added} new utterances across {len(insertions)} (technique, locale) pairs.")
if __name__ == "__main__":
main()

View file

@ -60,7 +60,7 @@ _TEXTCAT_PIPE_NAME = "textcat"
# mais avec une confiance dérisoire — bien en dessous de tout seuil # mais avec une confiance dérisoire — bien en dessous de tout seuil
# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`). # raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
# #
# Trois passes de calibration successives, toutes mesurées contre le # Quatre passes de calibration successives, toutes mesurées contre le
# corpus réel (74 techniques) : # corpus réel (74 techniques) :
# 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne # 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne
# passe plus à l'échelle une fois élargi : `150` sur 74 classes # passe plus à l'échelle une fois élargi : `150` sur 74 classes
@ -87,7 +87,30 @@ _TEXTCAT_PIPE_NAME = "textcat"
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`) # `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`)
# — ce qui précède est une mesure manuelle ponctuelle, pas un # — ce qui précède est une mesure manuelle ponctuelle, pas un
# remplacement de cette calibration. # remplacement de cette calibration.
_TRAINING_ITERATIONS = 25 # 4. Le corpus a ensuite été rééquilibré à 20 `utterances` minimum par
# technique et par locale (contre 3-7 avant — chaque technique en a
# désormais *le même nombre*, demande explicite pour que le textcat ne
# voie pas certaines classes avec 3x moins de signal que d'autres). Les
# exemples par époque grimpent d'environ 749 à ~2180/locale (+191%) — à
# `_TRAINING_ITERATIONS` inchangé (25), ça aurait fait grimper le temps
# d'entraînement dans les mêmes proportions (~336s -> ~980s/locale,
# ~33 minutes pour fr+en). Réduit à `10` pour retrouver un temps par
# époque comparable à l'étape 3 malgré ~3x plus d'exemples par époque —
# un corpus plus large et mieux équilibré par classe a aussi besoin de
# structurellement moins d'époques pour bien converger (chaque époque
# voit déjà beaucoup plus de signal par classe), donc ce n'est pas un
# simple compromis qualité/temps à somme nulle comme les étapes
# précédentes. Mesuré : ~355s (fr, 1943 exemples) / ~332s (en, 1835
# exemples), ~687s pour fr+en combinés — quasi identique à l'étape 3
# malgré ~2.6x plus d'exemples par époque, et confiance égale ou
# meilleure sur les cas déjà suivis : simmer ~0.48 (était ~0.31, le plus
# faible d'alors), melt ~0.75, preheat ~0.75, compote ~0.85, julienne
# ~0.75, bake ~0.91, cook ~0.65 (fr) — chop (en) reste sous
# `CONFIDENCE_THRESHOLD` à ~0.22, mais retombe sur son ancre NER
# (littéralement le mot "chop"), donc sans régression fonctionnelle.
# À confirmer/affiner par une vraie repasse de
# `calibrate-tech-step-threshold.ts` comme aux étapes précédentes.
_TRAINING_ITERATIONS = 10
_TRAINING_BATCH_SIZE = 16 _TRAINING_BATCH_SIZE = 16
# Arrêt anticipé : `_TRAINING_ITERATIONS` reste le plafond (le pire cas ne # 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 # change pas), un corpus/locale qui converge plus vite n'a pas à payer les

View file

@ -0,0 +1,38 @@
"""Garde-fou de non-régression pour l'équilibrage du corpus (voir
`training_data.py`'s propre commentaire de tête) : un textcat entraîné sur
des classes très inégales en nombre d'exemples est une source réelle de
classifications confiantes mais fausses sur une phrase jamais vue (constaté
en pratique voir l'historique Git de ce fichier). Chaque technique doit
avoir *au moins* 20 `utterances` par locale, et pour rester vraiment
équilibré plutôt que juste "assez" le même nombre pour les deux locales
d'une même technique."""
from intent_service.training_data import TECH_STEP_TRAINING_DATA
_MIN_UTTERANCES_PER_LOCALE = 20
def test_every_technique_has_at_least_the_minimum_utterances_per_locale():
short = [
(entry.uid, locale, len(getattr(entry, locale).utterances))
for entry in TECH_STEP_TRAINING_DATA
for locale in ("fr", "en")
if len(getattr(entry, locale).utterances) < _MIN_UTTERANCES_PER_LOCALE
]
assert short == [], (
f"{len(short)} (uid, locale) pair(s) below the {_MIN_UTTERANCES_PER_LOCALE}-utterance "
f"floor — run augment_utterances.py: {short}"
)
def test_every_technique_has_the_same_utterance_count_in_both_locales():
# Not just "both above the floor" — a technique whose fr/en counts drift
# apart re-introduces the same per-class imbalance this test file exists
# to catch, just between locales of the same technique instead of across
# techniques.
mismatched = [
(entry.uid, len(entry.fr.utterances), len(entry.en.utterances))
for entry in TECH_STEP_TRAINING_DATA
if len(entry.fr.utterances) != len(entry.en.utterances)
]
assert mismatched == [], f"fr/en utterance count mismatch: {mismatched}"