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:
parent
c6b6b790c8
commit
19e0507852
1 changed files with 87 additions and 11 deletions
|
|
@ -19,6 +19,7 @@ vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
|
@ -27,10 +28,12 @@ from spacy.language import Language
|
|||
from spacy.matcher import PhraseMatcher
|
||||
from spacy.tokens import Doc, Span
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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
|
||||
# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition
|
||||
|
|
@ -77,6 +80,25 @@ _TEXTCAT_PIPE_NAME = "textcat"
|
|||
# calibration.
|
||||
_TRAINING_ITERATIONS = 40
|
||||
_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
|
||||
# encore contre la petite taille du corpus par technique tout en laissant
|
||||
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la
|
||||
|
|
@ -90,11 +112,6 @@ _TRAINING_DROPOUT = 0.1
|
|||
_TRAINING_SEED = 0
|
||||
|
||||
|
||||
@Language.factory("diacritics_normalizer")
|
||||
def _create_diacritics_normalizer(nlp: Language, name: str) -> "_DiacriticsNormalizer":
|
||||
return _DiacriticsNormalizer()
|
||||
|
||||
|
||||
class _DiacriticsNormalizer:
|
||||
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
|
||||
(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`.
|
||||
Indépendant des `entries` entraînées — ajouté une seule fois par
|
||||
`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:
|
||||
|
|
@ -116,6 +142,11 @@ class _DiacriticsNormalizer:
|
|||
return doc
|
||||
|
||||
|
||||
@Language.factory("diacritics_normalizer")
|
||||
def _create_diacritics_normalizer(nlp: Language, name: str) -> _DiacriticsNormalizer:
|
||||
return _DiacriticsNormalizer()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainEntry:
|
||||
"""Une technique à entraîner pour une locale — construit par
|
||||
|
|
@ -237,13 +268,21 @@ class LocalePipeline:
|
|||
|
||||
# `textcat` (exclusive_classes) exige au moins deux labels (voir
|
||||
# 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
|
||||
# seul : ne pas planter, juste ne pas construire de textcat du tout
|
||||
# (`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] = []
|
||||
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_PIPE_NAME,
|
||||
config={
|
||||
|
|
@ -265,13 +304,43 @@ class LocalePipeline:
|
|||
cats[entry.uid] = 1.0
|
||||
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)
|
||||
if 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)
|
||||
losses: dict[str, float] = {}
|
||||
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:
|
||||
# Des `entries` avec des `uid` mais aucune `utterance` nulle
|
||||
# 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
|
||||
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`.
|
||||
|
||||
`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():
|
||||
return ProcessResult(entities=[], intent=None, score=0.0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue