fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Suite a une revue de code sur locale_pipeline.py, trois ameliorations
implementees et verifiees contre le vrai corpus (74 techniques) :

- spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() —
  random.Random() ne graine que l'ordre de melange des exemples, pas
  l'init des poids/dropout internes de thinc.
- _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory
  — plus d'annotation de type en chaine.
- Log explicite (logger.warning) quand train() recoit moins de 2 labels
  et saute la creation du textcat, plus une clarification de la docstring
  de process() sur les deux cas menant a intent=None.
- Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS
  restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais
  dans le budget actuel de 40 iterations (la perte continue de baisser
  significativement jusqu'au bout) — documente honnetement comme filet
  de securite pour un futur relevement du plafond, pas un gain de temps
  aujourd'hui.

Deux suggestions de la revue examinees et non retenues, avec
justification en commentaire : le risque de desalignement pattern/texte
via normalize_text (normalize_text opere par token deja tokenise, jamais
sur la chaine brute — pas de risque de segmentation differente) ; passer
a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents
que attr="NORM" fournit deliberement.

Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture
partagee), lint du monorepo. Temps d'entrainement mesure stable
(~200-230s/locale, dans la marge de bruit deja documentee).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-26 07:49:01 +02:00
parent c6b6b790c8
commit 19e0507852

View file

@ -19,6 +19,7 @@ vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process.
from __future__ import annotations from __future__ import annotations
import logging
import random import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -27,10 +28,12 @@ from spacy.language import Language
from spacy.matcher import PhraseMatcher from spacy.matcher import PhraseMatcher
from spacy.tokens import Doc, Span from spacy.tokens import Doc, Span
from spacy.training import Example from spacy.training import Example
from spacy.util import filter_spans, minibatch from spacy.util import filter_spans, fix_random_seed, minibatch
from .text_normalization import normalize_text from .text_normalization import normalize_text
logger = logging.getLogger(__name__)
# Modèle spaCy de base par locale — voir pyproject.toml pour la version # Modèle spaCy de base par locale — voir pyproject.toml pour la version
# pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés # pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés
# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition # par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition
@ -77,6 +80,25 @@ _TEXTCAT_PIPE_NAME = "textcat"
# calibration. # calibration.
_TRAINING_ITERATIONS = 40 _TRAINING_ITERATIONS = 40
_TRAINING_BATCH_SIZE = 16 _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
# itérations restantes pour rien. Une époque compte comme "sans progrès"
# quand sa perte totale ne descend pas d'au moins `_EARLY_STOPPING_MIN_DELTA`
# 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
# `_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
# plafond à la main à chaque changement du corpus.
_EARLY_STOPPING_PATIENCE = 3
_EARLY_STOPPING_MIN_DELTA = 0.001
# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise # Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise
# encore contre la petite taille du corpus par technique tout en laissant # encore contre la petite taille du corpus par technique tout en laissant
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la # plus de signal passer à chaque pas, ce qui a mesurablement aidé la
@ -90,11 +112,6 @@ _TRAINING_DROPOUT = 0.1
_TRAINING_SEED = 0 _TRAINING_SEED = 0
@Language.factory("diacritics_normalizer")
def _create_diacritics_normalizer(nlp: Language, name: str) -> "_DiacriticsNormalizer":
return _DiacriticsNormalizer()
class _DiacriticsNormalizer: class _DiacriticsNormalizer:
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()` """Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
(le port Python de `normalizeText()` côté `apps/api`) pour chaque token. (le port Python de `normalizeText()` côté `apps/api`) pour chaque token.
@ -108,6 +125,15 @@ class _DiacriticsNormalizer:
normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`. normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`.
Indépendant des `entries` entraînées ajouté une seule fois par Indépendant des `entries` entraînées ajouté une seule fois par
`preload()`, jamais retiré/rajouté par `train()`. `preload()`, jamais retiré/rajouté par `train()`.
Opère token par token, sur du texte déjà tokenisé `normalize_text()`
ne fait que réécrire la forme d'un token existant (minuscule, sans
diacritique), jamais fusionner/scinder des tokens : les patterns
(`nlp.make_doc(synonym)` + ce composant appliqué à la main, voir
`LocalePipeline.train`) et le texte cible (`nlp(text)`, pipeline
complet) passent donc toujours par le *même* découpage en tokens que
le tokenizer du modèle de base leur donne, avant que ce composant n'y
touche pas de risque de désalignement entre les deux.
""" """
def __call__(self, doc: Doc) -> Doc: def __call__(self, doc: Doc) -> Doc:
@ -116,6 +142,11 @@ class _DiacriticsNormalizer:
return doc return doc
@Language.factory("diacritics_normalizer")
def _create_diacritics_normalizer(nlp: Language, name: str) -> _DiacriticsNormalizer:
return _DiacriticsNormalizer()
@dataclass @dataclass
class TrainEntry: class TrainEntry:
"""Une technique à entraîner pour une locale — construit par """Une technique à entraîner pour une locale — construit par
@ -237,13 +268,21 @@ class LocalePipeline:
# `textcat` (exclusive_classes) exige au moins deux labels (voir # `textcat` (exclusive_classes) exige au moins deux labels (voir
# spaCy's error E867) — jamais un problème avec le vrai corpus # spaCy's error E867) — jamais un problème avec le vrai corpus
# (`TECH_STEP_TRAINING_DATA` a ~27 techniques), mais un `entries` à # (`TECH_STEP_TRAINING_DATA` a ~74 techniques), mais un `entries` à
# un seul élément resterait structurellement valide pour le NER # un seul élément resterait structurellement valide pour le NER
# seul : ne pas planter, juste ne pas construire de textcat du tout # seul : ne pas planter, juste ne pas construire de textcat du tout
# (`process()` retombe alors sur `intent: null` via son garde # (`process()` retombe alors sur `intent: null` via son garde
# `if not cats`, exactement comme "rien à classifier"). # `if not cats`, exactement comme "rien à classifier"). Journalisé
# explicitement — sans ça, "pourquoi cette locale ne classifie
# jamais rien" ne serait visible qu'en déduisant `labelCount < 2`
# de la ligne "tech-step NLP pipeline trained" (`pipeline_registry.py`).
examples: list[Example] = [] examples: list[Example] = []
if len(entries) >= 2: if len(entries) < 2:
logger.warning(
"tech-step NLP textcat skipped: fewer than 2 labels, intent classification disabled for this locale",
extra={"locale": self._locale, "labelCount": len(entries)},
)
else:
textcat = nlp.add_pipe( textcat = nlp.add_pipe(
_TEXTCAT_PIPE_NAME, _TEXTCAT_PIPE_NAME,
config={ config={
@ -265,13 +304,43 @@ class LocalePipeline:
cats[entry.uid] = 1.0 cats[entry.uid] = 1.0
examples.append(Example.from_dict(doc, {"cats": cats})) examples.append(Example.from_dict(doc, {"cats": cats}))
# Graine le RNG Python *et* celui de numpy/thinc sous-jacent à
# `nlp.update()` (initialisation des poids, masque de dropout) —
# `random.Random(_TRAINING_SEED)` ci-dessous ne couvre que l'ordre
# de mélange des exemples choisi par ce module, pas ce que spaCy
# fait en interne à chaque pas de gradient.
fix_random_seed(_TRAINING_SEED)
rng = random.Random(_TRAINING_SEED) rng = random.Random(_TRAINING_SEED)
if examples: if examples:
optimizer = nlp.initialize(lambda: examples) optimizer = nlp.initialize(lambda: examples)
for _ in range(_TRAINING_ITERATIONS): best_loss = float("inf")
epochs_without_improvement = 0
for iteration in range(_TRAINING_ITERATIONS):
rng.shuffle(examples) rng.shuffle(examples)
losses: dict[str, float] = {}
for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE): for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE):
nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT) nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT, losses=losses)
epoch_loss = losses.get(_TEXTCAT_PIPE_NAME, 0.0)
# Arrêt anticipé — voir `_EARLY_STOPPING_PATIENCE`'s propre
# commentaire. `_TRAINING_ITERATIONS` reste le plafond
# (pire cas inchangé), ceci ne fait que raccourcir les
# cas qui convergent plus vite.
if epoch_loss < best_loss - _EARLY_STOPPING_MIN_DELTA:
best_loss = epoch_loss
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= _EARLY_STOPPING_PATIENCE:
logger.info(
"tech-step NLP textcat training stopped early",
extra={
"locale": self._locale,
"iteration": iteration + 1,
"maxIterations": _TRAINING_ITERATIONS,
"finalLoss": epoch_loss,
},
)
break
else: else:
# Des `entries` avec des `uid` mais aucune `utterance` nulle # Des `entries` avec des `uid` mais aucune `utterance` nulle
# part (corpus incomplet) : le textcat a des labels mais rien # part (corpus incomplet) : le textcat a des labels mais rien
@ -292,6 +361,13 @@ class LocalePipeline:
sur `text` tel quel que ce soit la description complète ou une sur `text` tel quel que ce soit la description complète ou une
clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne
s'en soucie pas, exactement comme l'ancien `NlpManager`. s'en soucie pas, exactement comme l'ancien `NlpManager`.
`intent` vaut `None` dans deux cas distincts, tous deux silencieux
côté retour (voir le log d'avertissement de `train()` pour repérer
le second en amont) : `text` vide/blanc, ou `doc.cats` vide parce
que `train()` a reçu moins de deux labels pour cette locale (le
textcat n'a alors jamais été construit — voir son propre
commentaire).
""" """
if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip(): if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip():
return ProcessResult(entities=[], intent=None, score=0.0) return ProcessResult(entities=[], intent=None, score=0.0)