Le service mettait ~930 s (15-16 min) a devenir `healthy`, ce que le README
presentait comme "un compromis assume, pas un defaut de configuration a
corriger". Mauvais diagnostic : le cout ne venait ni des iterations ni de la
taille du corpus, mais du parametre `length` de `spacy.TextCatBOW.v3`, laisse
a son defaut spaCy de 262144 faute d'etre passe explicitement.
Avec `no_output_layer: False` et 74 techniques, la couche de sortie faisait
19 398 730 parametres mis a jour par Adam a chaque pas de gradient, pour une
table de hachage vide a 99 % (le corpus tient dans quelques milliers de
tokens distincts).
Mesure contre le vrai corpus (fr, 25 iterations, jeu de dev = derniere
utterance de chaque technique tenue hors entrainement) :
length=262144 ngram=1 dev 97.3% conf 0.809 394.7 s <- avant
length=2048 ngram=2 dev 97.3% conf 0.919 12.3 s <- apres
Meme accuracy, meilleure confiance, 32x plus rapide. Les bigrammes profitent
des locutions du corpus ("faire fondre", "laisser mijoter") qu'un sac
d'unigrammes ne distingue pas de leurs constituants isoles.
PipelineRegistry.initialize() (fr+en, chargement + entrainement) mesure a
28,5 s contre ~930 s. Suite pytest complete : 46 passed en 40,6 s.
- locale_pipeline.py : `_TEXTCAT_MODEL_LENGTH` / `_TEXTCAT_NGRAM_SIZE`
explicites et commentes avec les mesures ; historique de calibration de
`_TRAINING_ITERATIONS` corrige (il attribuait le cout aux iterations)
- README : sections "Temps de demarrage" et "Limitations connues" reecrites
(la couverture d'evaluation, 26/74 techniques, y devient la limitation
bloquante ; ajout des collisions de classes observees)
- docker-compose.yml / ci.yml : commentaires citant ~930 s corriges,
start_period et timeout 1200s -> 300s (plafond pour runner lent, pas cible)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
535 lines
27 KiB
Python
535 lines
27 KiB
Python
"""Pipeline spaCy pour UNE locale — l'équivalent Python de ce que
|
|
`node-nlp`'s `NlpManager` faisait pour cette locale dans
|
|
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
|
|
NER par entités enum (ici un `PhraseMatcher`) + classification d'intention
|
|
(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce
|
|
service lui-même (`training_data.TECH_STEP_TRAINING_DATA` — plus poussé par
|
|
`apps/api` via HTTP, voir `pipeline_registry.py`).
|
|
|
|
Le modèle de base spaCy (tokenizer + vecteurs + le composant
|
|
`diacritics_normalizer` défini plus bas) est chargé une seule fois
|
|
(`preload()`, appelé au démarrage du process — voir `main.py` — pas
|
|
paresseusement au premier `train()`, pour que `GET /health` ne devienne
|
|
`200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le
|
|
`textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont
|
|
reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est
|
|
jamais persisté sur disque — `training_data.py` reste l'unique source de
|
|
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
|
|
|
|
import spacy
|
|
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, fix_random_seed, minibatch
|
|
|
|
from . import utensil_vocabulary
|
|
from .temperature_extraction import Temperature, extract_temperatures
|
|
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
|
|
# future de similarité sémantique (voir le README de ce service).
|
|
SUPPORTED_LOCALES = {
|
|
"fr": "fr_core_news_md",
|
|
"en": "en_core_web_md",
|
|
}
|
|
|
|
# Composants du modèle de base non utilisés par ce pipeline (on ne s'appuie
|
|
# ni sur le NER générique de spaCy, ni sur l'analyse syntaxique/morphologique
|
|
# — seuls le tokenizer et les vecteurs de mots restent nécessaires) : les
|
|
# exclure au chargement évite le coût mémoire/CPU de composants qui ne
|
|
# tourneraient jamais.
|
|
_EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_ruler", "lemmatizer"]
|
|
|
|
_TEXTCAT_PIPE_NAME = "textcat"
|
|
|
|
# Largeur du vecteur de features hachées de `spacy.TextCatBOW.v3`.
|
|
#
|
|
# **Ce paramètre était le goulot d'étranglement du démarrage de ce service.**
|
|
# spaCy le laisse à `262144` (2^18) par défaut, et il n'était pas passé
|
|
# explicitement ici. Avec `no_output_layer: False` et 74 techniques, la
|
|
# couche de sortie fait alors `262144 x 74` = **19 398 730 paramètres**, que
|
|
# l'optimiseur Adam met à jour à *chaque* pas de gradient. Or le corpus
|
|
# (`training_data.py`) tient dans quelques milliers de tokens distincts :
|
|
# l'écrasante majorité de cette table de hachage n'est jamais peuplée. On
|
|
# payait donc un temps de calcul proportionnel à une table vide.
|
|
#
|
|
# Mesuré contre le corpus réel (74 techniques, locale `fr`, 25 itérations,
|
|
# jeu de dev = la dernière `utterance` de chaque technique tenue hors
|
|
# entraînement) :
|
|
#
|
|
# length | params | accuracy dev | confiance moy. | entraînement
|
|
# ---------|-----------|--------------|----------------|-------------
|
|
# 262144 | 19398730 | 97.3% | 0.809 | 406 s
|
|
# 2048 | 151626 | 97.3% | 0.816 | 13 s
|
|
# 8192 | 606218 | 97.3% | 0.910 | 24 s
|
|
#
|
|
# `2048` est donc **30x plus rapide à qualité strictement identique** — ce
|
|
# n'est pas un compromis vitesse/qualité, juste du calcul rendu. Si le
|
|
# corpus grossissait au point de saturer 2048 buckets (collisions de hachage
|
|
# visibles sous forme de F1 par classe qui chute sans autre cause), la
|
|
# valeur se remonte ; `8192` reste bien en deçà du coût d'origine.
|
|
_TEXTCAT_MODEL_LENGTH = 2048
|
|
# Bigrammes plutôt qu'unigrammes : le corpus est plein de locutions verbales
|
|
# ("faire fondre", "laisser mijoter", "faire revenir", "porter à ébullition")
|
|
# qu'un sac d'unigrammes ne peut pas distinguer de leurs constituants isolés.
|
|
# Mesuré dans les mêmes conditions que le tableau ci-dessus : accuracy dev
|
|
# inchangée (97.3%) mais confiance moyenne sur la classe correcte
|
|
# **0.816 -> 0.919**, pour +0.5 s d'entraînement.
|
|
_TEXTCAT_NGRAM_SIZE = 2
|
|
|
|
# Nombre d'itérations d'entraînement du textcat et taille de minibatch —
|
|
# 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 — bien en dessous de tout seuil
|
|
# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
|
|
#
|
|
# Historique de calibration (toutes les mesures contre le corpus réel) :
|
|
# 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. Les `synonyms` de chaque technique sont devenus eux 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.
|
|
# Gain confirmé depuis par ablation : retirer les synonymes des exemples
|
|
# fait retomber la confiance moyenne de `0.816` à `0.714` à configuration
|
|
# égale. `_TRAINING_ITERATIONS` a été descendu à `25` à cette occasion
|
|
# pour compenser le temps d'entraînement.
|
|
# 4. **Correction du diagnostic** : les étapes 1-3 attribuaient le coût aux
|
|
# itérations et au nombre d'exemples. C'était faux — il venait presque
|
|
# entièrement de `_TEXTCAT_MODEL_LENGTH` (voir son propre commentaire).
|
|
# Une fois ce paramètre corrigé, 25 itérations coûtent ~13 s par locale
|
|
# au lieu de ~406 s. `25` est conservé tel quel : la valeur donne déjà
|
|
# 97.3% d'accuracy dev, et la marge de temps désormais disponible n'a
|
|
# d'intérêt que si une vraie mesure (F1 par classe, voir
|
|
# `tech-step-evaluator.ts`) montre qu'il y a quelque chose à y gagner.
|
|
#
|
|
# Une vraie repasse de `calibrate-tech-step-threshold.ts` contre
|
|
# `TECH_STEP_EVAL_DATASET` reste nécessaire (voir `CONFIDENCE_THRESHOLD`'s
|
|
# propre commentaire, `tech-step-matcher.ts`) — les chiffres ci-dessus sont
|
|
# des mesures ponctuelles, 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
|
|
# 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, 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
|
|
# 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
|
|
# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire
|
|
# reste aussi bas qu'avant, voir ci-dessus).
|
|
_TRAINING_DROPOUT = 0.1
|
|
# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même
|
|
# corpus en entrée) est préférable à un score qui varie légèrement à chaque
|
|
# déploiement pour la même donnée, en particulier pendant la calibration du
|
|
# seuil de confiance côté apps/api.
|
|
_TRAINING_SEED = 0
|
|
|
|
|
|
class _DiacriticsNormalizer:
|
|
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
|
|
(le port Python de `normalizeText()` côté `apps/api`) pour chaque token.
|
|
|
|
Point clé : ce composant tourne aussi bien sur les `Doc` construits pour
|
|
les *patterns* du `PhraseMatcher` (voir `LocalePipeline.train`) que sur
|
|
le *texte cible* passé à `process()` — les deux passent donc par
|
|
exactement la même normalisation, ce qui garantit qu'un synonyme comme
|
|
"mijoter" matche indifféremment "MIJOTER"/"mijoté"/"Mijotée" dans le
|
|
texte, reproduisant le comportement `ner.threshold: 1` (exact après
|
|
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:
|
|
for token in doc:
|
|
token.norm_ = normalize_text(token.text)
|
|
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
|
|
`PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`."""
|
|
|
|
uid: str
|
|
synonyms: list[str] = field(default_factory=list)
|
|
utterances: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Entity:
|
|
"""Une mention candidate trouvée par un `PhraseMatcher` — offsets
|
|
caractère `[start, end)`, miroir de `EntityPayload` (`schemas.py`).
|
|
`kind` distingue de quel `PhraseMatcher` la mention vient (`"technique"`
|
|
— `self._matcher`, entraîné depuis `training_data.py` — ou `"utensil"`
|
|
— `self._utensil_matcher`, statique, voir `utensil_vocabulary.py`) :
|
|
`apps/api`'s `tech-step-matcher.ts` a besoin de savoir laquelle des deux
|
|
résoudre (`TechStep.key` vs `Utensil.key`)."""
|
|
|
|
uid: str
|
|
start: int
|
|
end: int
|
|
kind: str = "technique"
|
|
|
|
|
|
@dataclass
|
|
class ProcessResult:
|
|
"""Résultat complet d'un `process()` — miroir de `ProcessResponse`
|
|
(`schemas.py`)."""
|
|
|
|
entities: list[Entity]
|
|
intent: str | None
|
|
score: float
|
|
# Températures détectées dans `text` (regex, indépendantes du modèle et
|
|
# de l'entraînement) — voir `temperature_extraction.py`.
|
|
temperatures: list[Temperature] = field(default_factory=list)
|
|
|
|
|
|
class UnsupportedLocaleError(ValueError):
|
|
"""`locale` ne correspond à aucun modèle spaCy connu (voir
|
|
`SUPPORTED_LOCALES`) — distinct d'une locale simplement "pas encore
|
|
entraînée" (`LocalePipeline.is_trained is False`), qui n'est pas une
|
|
erreur (voir `process()`)."""
|
|
|
|
|
|
class LocalePipeline:
|
|
"""Pipeline spaCy (NER par phrases + textcat) pour une locale donnée.
|
|
Un `PipelineRegistry` (voir `pipeline_registry.py`) en détient une
|
|
instance par locale supportée.
|
|
"""
|
|
|
|
def __init__(self, locale: str) -> None:
|
|
if locale not in SUPPORTED_LOCALES:
|
|
raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}")
|
|
self._locale = locale
|
|
self._model_name = SUPPORTED_LOCALES[locale]
|
|
# `None` tant que `preload()` n'a pas tourné.
|
|
self._base_nlp: Language | None = None
|
|
# `None` tant qu'aucun `train()` n'a réussi — `process()` traite ça
|
|
# comme "rien à trouver" plutôt qu'une erreur, exactement le
|
|
# comportement testé côté `apps/api` pour "une locale jamais
|
|
# entraînée".
|
|
self._matcher: PhraseMatcher | None = None
|
|
# Construit une seule fois par `preload()`, jamais par `train()` —
|
|
# contrairement à `self._matcher`, ce vocabulaire est statique
|
|
# (`utensil_vocabulary.py`), il n'a pas de contrepartie "corpus
|
|
# poussé par un appelant" à reconstruire.
|
|
self._utensil_matcher: PhraseMatcher | None = None
|
|
self._trained = False
|
|
|
|
@property
|
|
def is_trained(self) -> bool:
|
|
return self._trained
|
|
|
|
def preload(self) -> None:
|
|
"""Charge le modèle spaCy de base (tokenizer + vecteurs), le
|
|
composant `diacritics_normalizer`, et construit le `PhraseMatcher`
|
|
d'ustensiles — idempotent, sans effet si déjà chargé. Appelé au
|
|
démarrage du process pour les deux locales connues (voir
|
|
`main.py`), pas paresseusement au premier `train()`.
|
|
|
|
Le matcher d'ustensiles est construit ici, pas dans `train()` :
|
|
contrairement au `PhraseMatcher` de techniques (reconstruit à
|
|
chaque `train()` depuis les `entries` reçues), le vocabulaire
|
|
d'ustensiles est statique (`utensil_vocabulary.py`) — rien ne le
|
|
fait jamais varier d'un appel à l'autre, donc rien ne justifie de
|
|
payer son coût de construction plus d'une fois par démarrage.
|
|
"""
|
|
if self._base_nlp is not None:
|
|
return
|
|
nlp = spacy.load(self._model_name, exclude=_EXCLUDED_COMPONENTS)
|
|
nlp.add_pipe("diacritics_normalizer", first=True)
|
|
self._base_nlp = nlp
|
|
|
|
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
|
|
utensil_matcher = PhraseMatcher(nlp.vocab, attr="NORM")
|
|
for uid, synonyms in utensil_vocabulary.synonyms_for_locale(self._locale).items():
|
|
if not synonyms:
|
|
continue
|
|
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in synonyms]
|
|
utensil_matcher.add(uid, patterns)
|
|
self._utensil_matcher = utensil_matcher
|
|
|
|
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, 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
|
|
même comportement que "jamais entraîné", pas une erreur 500.
|
|
"""
|
|
self.preload()
|
|
assert self._base_nlp is not None # garanti par preload() ci-dessus
|
|
|
|
if _TEXTCAT_PIPE_NAME in self._base_nlp.pipe_names:
|
|
self._base_nlp.remove_pipe(_TEXTCAT_PIPE_NAME)
|
|
|
|
if not entries:
|
|
self._matcher = None
|
|
self._trained = False
|
|
return (0, 0, 0)
|
|
|
|
nlp = self._base_nlp
|
|
# `nlp.make_doc()` ne fait tourner *que* le tokenizer, pas les
|
|
# composants du pipeline — le `diacritics_normalizer` ajouté par
|
|
# `preload()` ne tournerait donc jamais sur les `Doc` de patterns
|
|
# s'ils n'étaient construits qu'avec `make_doc()`, alors que
|
|
# `process()` appelle `nlp(text)` (le pipeline complet) sur le texte
|
|
# cible. Sans ce correctif, un synonyme accentué comme "préchauffer"
|
|
# n'aurait jamais matché "PRÉCHAUFFER"/"Préchauffer" : trouvé en
|
|
# calibrant contre les cas exacts de `tech-step-matcher.test.ts`
|
|
# (fr, la locale la plus concernée par les accents) — un synonyme
|
|
# sans diacritique comme "faire fondre" masquait le bug en semblant
|
|
# fonctionner par coïncidence. Appliquer explicitement le même
|
|
# composant aux deux côtés garantit qu'ils passent par la même
|
|
# normalisation.
|
|
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
|
|
|
|
matcher = PhraseMatcher(nlp.vocab, attr="NORM")
|
|
synonym_count = 0
|
|
for entry in entries:
|
|
if not entry.synonyms:
|
|
continue
|
|
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in entry.synonyms]
|
|
matcher.add(entry.uid, patterns)
|
|
synonym_count += len(entry.synonyms)
|
|
|
|
# `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 ~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"). 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:
|
|
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={
|
|
"model": {
|
|
"@architectures": "spacy.TextCatBOW.v3",
|
|
"exclusive_classes": True,
|
|
"ngram_size": _TEXTCAT_NGRAM_SIZE,
|
|
"no_output_layer": False,
|
|
# Explicite, jamais laissé au défaut spaCy — voir
|
|
# `_TEXTCAT_MODEL_LENGTH` pour pourquoi ce seul
|
|
# paramètre valait 30x le temps d'entraînement.
|
|
"length": _TEXTCAT_MODEL_LENGTH,
|
|
},
|
|
},
|
|
)
|
|
for entry in entries:
|
|
textcat.add_label(entry.uid)
|
|
|
|
for entry in entries:
|
|
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 à
|
|
# `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)
|
|
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, 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
|
|
# pour apprendre à les distinguer — toujours initialisé pour
|
|
# rester un pipeline valide ; `process()` renverra alors un
|
|
# score ~uniforme entre labels. Ce n'est pas ce module qui doit
|
|
# juger la qualité du corpus reçu (voir `tech-step-eval-runner.ts`
|
|
# côté apps/api pour ce rôle).
|
|
nlp.initialize()
|
|
|
|
self._matcher = matcher
|
|
self._trained = True
|
|
return (len(entries), len(examples), synonym_count)
|
|
|
|
def process(self, text: str) -> ProcessResult:
|
|
"""Reproduit la forme de `NlpManager.process(locale, text)` : les
|
|
entités candidates (NER) et le verdict du classifieur d'intention
|
|
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).
|
|
"""
|
|
# Indépendant du modèle et de l'entraînement — une regex sur le
|
|
# texte brut, donc extraite même pour une locale pas encore
|
|
# entraînée (elle ne sera juste jamais accompagnée d'entités).
|
|
temperatures = extract_temperatures(text, self._locale)
|
|
|
|
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, temperatures=temperatures
|
|
)
|
|
|
|
doc = self._base_nlp(text)
|
|
|
|
# A technique's own synonym list can legitimately contain one phrase
|
|
# nested inside another (`melt`'s "fondre" is a literal substring of
|
|
# its own "faire fondre") — the `PhraseMatcher` reports *both* as
|
|
# separate matches at overlapping positions, which without
|
|
# resolution would hand `splitIntoClauses` (apps/api) two candidates
|
|
# for what a human reads as one mention, producing the same
|
|
# techStepId twice in the final result. `filter_spans` keeps only
|
|
# the longest match at each position (so "faire fondre" wins over
|
|
# the "fondre" it contains) — found by a real regression in
|
|
# `tech-step-matcher.test.ts`'s "detects several distinct
|
|
# techniques..." case once this service replaced node-nlp (which
|
|
# apparently resolved this internally; nothing here recreates that
|
|
# by choice, `filter_spans` is spaCy's own documented tool for
|
|
# exactly this "one span per position" problem, e.g. as used for
|
|
# NER-style outputs).
|
|
matched_spans = [
|
|
Span(doc, start, end, label=match_id) for match_id, start, end in self._matcher(doc)
|
|
]
|
|
technique_entities = [
|
|
Entity(
|
|
uid=self._base_nlp.vocab.strings[span.label],
|
|
start=span.start_char,
|
|
end=span.end_char,
|
|
kind="technique",
|
|
)
|
|
for span in filter_spans(matched_spans)
|
|
]
|
|
|
|
# Second, independent `PhraseMatcher` pass for ustensiles — run and
|
|
# `filter_spans`-resolved *separately* from the technique pass
|
|
# above: the two matchers' candidates never compete for the same
|
|
# position (a longer utensil match must never swallow/be swallowed
|
|
# by a technique match the way two overlapping technique synonyms
|
|
# do), only overlaps *within* the same matcher are the known
|
|
# problem `filter_spans` exists for (see the technique pass's own
|
|
# comment above).
|
|
utensil_entities: list[Entity] = []
|
|
if self._utensil_matcher is not None:
|
|
utensil_spans = [
|
|
Span(doc, start, end, label=match_id)
|
|
for match_id, start, end in self._utensil_matcher(doc)
|
|
]
|
|
utensil_entities = [
|
|
Entity(
|
|
uid=self._base_nlp.vocab.strings[span.label],
|
|
start=span.start_char,
|
|
end=span.end_char,
|
|
kind="utensil",
|
|
)
|
|
for span in filter_spans(utensil_spans)
|
|
]
|
|
|
|
entities = sorted(technique_entities + utensil_entities, key=lambda entity: entity.start)
|
|
|
|
cats = doc.cats
|
|
if not cats:
|
|
return ProcessResult(
|
|
entities=entities, intent=None, score=0.0, temperatures=temperatures
|
|
)
|
|
intent = max(cats, key=cats.get)
|
|
return ProcessResult(
|
|
entities=entities, intent=intent, score=cats[intent], temperatures=temperatures
|
|
)
|