Aller au contenu principal

Module 7 — Reconnaissance d'entités nommées

Classer un avis en cinq étoiles (module 6) répond à « comment le client se sent-il ? ». Ce module répond à « de quoi parle-t-il ? ». La reconnaissance d'entités nommées (Named Entity Recognition, NER) identifie les fragments qui désignent des choses concrètes — marques, produits, lieux — et leur type. Sur nos avis, on veut extraire les hôtels cités, les quartiers, les repas mentionnés.

Une tâche d'étiquetage jeton par jeton

Contrairement à la classification (une étiquette par document), le NER produit une étiquette par jeton. Pour la phrase « J'ai adoré l'hôtel Renaissance à Marseille », le modèle doit produire :

J'         → O
ai → O
adoré → O
l' → O
hôtel → O
Renaissance → B-HOTEL
à → O
Marseille → B-LIEU

L'étiquette O (outside) désigne les jetons hors entité. Les préfixes B- et I- viennent du schéma BIO :

  • B (begin) : premier jeton d'une entité
  • I (inside) : jetons suivants de la même entité
  • O : hors entité

Pour un hôtel dont le nom fait deux mots — « Hôtel du Louvre » — l'étiquetage est B-HOTEL I-HOTEL I-HOTEL. Le schéma BIO se lit sans ambiguïté : une nouvelle entité doit commencer par B-, un I- seul (sans B- avant) est une erreur d'inférence.

Variantes : BIOES ajoute E- (end) et S- (singleton) pour marquer explicitement la fin ; IOB2 est équivalent à BIO. En pratique, BIO suffit pour la majorité des tâches et les bibliothèques (seqeval, spaCy) le prennent en entrée par défaut.

L'alignement jetons contre étiquettes, le piège technique du module

Les étiquettes sont annotées mot par mot. Le tokeniseur d'un Transformer découpe en sous-mots. Un même mot annoté B-HOTEL peut devenir trois sous-jetons. Deux stratégies pour propager l'étiquette :

  1. Seul le premier sous-jeton porte l'étiquette, les autres reçoivent -100, ce qui indique à PyTorch d'ignorer la perte pour ces positions.
  2. Chaque sous-jeton porte la même étiquette, avec conversion des B- en I- à partir du deuxième pour ne pas créer plusieurs entités.

La première est le choix des exemples de la bibliothèque transformers et donne des résultats légèrement meilleurs. La seconde est plus intuitive.

def aligner(exemples, tokeniseur):
enc = tokeniseur(exemples["mots"], truncation=True,
is_split_into_words=True, max_length=256)
etiquettes_alignees = []
for i, etiq in enumerate(exemples["etiquettes"]):
ids_mots = enc.word_ids(batch_index=i)
precedent = None
lignes = []
for id_mot in ids_mots:
if id_mot is None:
lignes.append(-100) # <s>, </s>, [PAD]
elif id_mot != precedent:
lignes.append(etiq[id_mot]) # premier sous-jeton
else:
lignes.append(-100) # sous-jetons suivants
precedent = id_mot
etiquettes_alignees.append(lignes)
enc["labels"] = etiquettes_alignees
return enc

Sans cet alignement, la perte se calcule sur des étiquettes décalées et le modèle apprend n'importe quoi. C'est l'erreur la plus fréquente et la plus silencieuse de tout le cours : aucune exception, seulement une F1 lamentable qu'on met sur le compte du modèle.

L'exactitude par jeton ne mesure rien

La classe O représente 90 % ou plus des jetons. Un modèle qui répond O partout obtient donc plus de 90 % d'exactitude jeton par jeton — et zéro entité extraite. Toujours mesurer avec seqeval, qui calcule précision, rappel et F1 par entité complète, pas par jeton.

Entraîner un modèle NER en pratique

L'architecture utilisée est presque identique à celle du module 6 : on remplace la tête de classification de séquence par une tête de classification de jetons.

from transformers import AutoTokenizer, AutoModelForTokenClassification
from transformers import DataCollatorForTokenClassification, Trainer, TrainingArguments
import evaluate

etiquettes = ["O", "B-HOTEL", "I-HOTEL", "B-LIEU", "I-LIEU",
"B-REPAS", "I-REPAS"]
id2etiq = {i: e for i, e in enumerate(etiquettes)}
etiq2id = {e: i for i, e in enumerate(etiquettes)}

tok = AutoTokenizer.from_pretrained("camembert-base")
modele = AutoModelForTokenClassification.from_pretrained(
"camembert-base",
num_labels=len(etiquettes),
id2label=id2etiq, label2id=etiq2id,
)

seq = evaluate.load("seqeval")
def metriques(eval_pred):
logits, y = eval_pred
preds = logits.argmax(-1)
pred_ok, y_ok = [], []
for p_ligne, y_ligne in zip(preds, y):
pl = [id2etiq[p] for p, l in zip(p_ligne, y_ligne) if l != -100]
yl = [id2etiq[l] for p, l in zip(p_ligne, y_ligne) if l != -100]
pred_ok.append(pl); y_ok.append(yl)
return seq.compute(predictions=pred_ok, references=y_ok)

args = TrainingArguments(output_dir="./ner",
per_device_train_batch_size=16,
learning_rate=3e-5,
num_train_epochs=4,
eval_strategy="epoch")

collateur = DataCollatorForTokenClassification(tok)
trainer = Trainer(model=modele, args=args, tokenizer=tok,
data_collator=collateur, compute_metrics=metriques,
train_dataset=jeu_train, eval_dataset=jeu_test)
trainer.train()

Le score overall_f1 produit par seqeval est la métrique agrégée. Les scores HOTEL_f1, LIEU_f1, REPAS_f1 sont ce qu'on regarde en pratique, car une F1 globale à 85 % qui masque un REPAS_f1 à 40 % est un vrai problème métier.

Lire un rapport seqeval sans se tromper

Une sortie type sur nos avis :

              precision    recall  f1-score   support
HOTEL 0.83 0.79 0.81 412
LIEU 0.88 0.85 0.86 380
REPAS 0.62 0.55 0.58 210
overall_f1 0.78 1002

Le support est le nombre d'entités vraies de ce type dans le jeu de test. REPAS sous-performe : soit le corpus annoté en contient trop peu (support faible), soit sa définition est floue (« un plat », « un menu », « le petit-déjeuner » sont-ils du même type ?). La solution est presque toujours dans les données, rarement dans le modèle.

Post-traitement : recoller les sous-jetons

À l'inférence, le modèle prédit une étiquette par sous-jeton. Il faut ensuite regrouper les sous-jetons d'un même mot puis les mots d'une même entité pour reconstituer le texte à afficher.

from transformers import pipeline

nlp = pipeline("token-classification",
model=modele, tokenizer=tok,
aggregation_strategy="simple")

for ent in nlp("Nous avons dîné au Café de Flore, à Saint-Germain."):
print(f"{ent['entity_group']:>6} {ent['word']:<30} {ent['score']:.2f}")
# HOTEL Café de Flore 0.94
# LIEU Saint-Germain 0.98

aggregation_strategy="simple" recolle les sous-jetons et n'assemble deux jetons consécutifs que s'ils portent la même étiquette. Les autres stratégies (first, max, average) diffèrent sur la manière de fusionner les scores, jamais sur la portée des entités.

Le corpus d'annotation compte davantage que le modèle

Passer de CamemBERT à un modèle deux fois plus grand fait gagner un ou deux points de F1. Annoter cinq cents exemples de plus dans la classe faible en fait gagner cinq à dix. Sur nos avis, doubler le nombre d'exemples annotés REPAS a plus d'impact que n'importe quel changement d'architecture.

En résumé

  • Le schéma BIO étiquette chaque jeton avec un préfixe B- pour le début d'entité et I- pour la suite ; un I- sans B- avant est une erreur d'inférence.
  • L'alignement jetons contre étiquettes est indispensable dès qu'un tokeniseur de sous-mots découpe un mot annoté ; l'oublier détruit silencieusement l'entraînement.
  • L'exactitude par jeton est trompeuse à cause de la classe O majoritaire ; toujours mesurer par entité complète avec seqeval, et regarder la F1 par type.
  • Les gains viennent presque toujours du corpus annoté, pas de l'architecture ; doubler le support d'une classe faible bat presque tous les changements de modèle.

Module suivant : au lieu d'extraire des fragments, on va produire du texte — un résumé automatique des avis, et une réponse à des questions sur ces avis.