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 b55a74c..7d910f9 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -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)