Rozpoznávání pojmenovaných entit: Získávání informací z textu
Systémy NLP každý den automaticky extrahují strukturované informace z miliard dokumentů: novinky, smlouvy, e-maily, lékařské záznamy, sociální média. Motor této extrakce se nazývá Rozpoznávání pojmenované entity (NER), úkol který identifikuje a klasifikuje entity uvedené v textu — lidi, organizace, místa, data, peněžní částky a mnoho dalšího.
NER je prvním krokem v mnoha kanálech pro extrakci informací: aniž bychom to věděli kdo co, kde a kdy dělá, neumíme vytvářet znalostní grafy, napájejte RAG systémy, automatizujte smlouvy nebo analyzujte finanční zprávy. V tomto článku vybudujeme NER systémy od základní linie pomocí spaCy na doladění BERT, se zvláštní pozorností na italštinu.
Co se naučíte
- Co je NER a hlavní kategorie subjektů (PER, ORG, LOC, DATE, MONEY...)
- Formát BIO (Beginning-Inside-Outside) pro anotaci tokenu
- NER s spaCy: předem vyškolené modely a přizpůsobení
- Jemné doladění BERT pro NER pomocí HuggingFace Transformers
- Metriky: úroveň rozpětí F1, přesnost, vyvolání s postupností
- Zpracování tokenizace WordPiece pro zarovnání štítků
- NER pro italštinu se spaCy it_core_news a italskými modely BERT
- NER na dlouhých dokumentech: posuvné okno a následné zpracování
- Pokročilé architektury: CRF vrstva, RoBERTa, DeBERTa pro NER
- End-to-end výrobní potrubí, vizualizace a případová studie
1. Co je to Rozpoznávání pojmenovaných entit
NER je úkolem klasifikace tokenů: pro každý token v textu, model musí předvídat, zda je součástí pojmenované entity a jakého typu. Na rozdíl od klasifikace vět (která produkuje jeden výstup na větu), NER vytváří výstup pro každý token – díky tomu je složitější jak z hlediska modelu, tak z hlediska postprocesingu.
Příklad NER
Vstup: "Elon Musk založil Teslu v roce 2003 v San Carlos v Kalifornii."
Komentovaný výstup:
- Elon Musk → PER (osoba)
- Tesla → ORG (organizace)
- 2003 → DATUM (datum)
- San Carlos → LOC (místo)
- Kalifornie → LOC (místo)
1.1 ORGANICKÝ formát
Anotace NER používá formát BIO (začátek-uvnitř-venku):
- B-TYP: první token entity typu TYPE
- I-TYP: interní token entity typu TYPE
- O: token nepatří žádné entitě
# Esempio formato BIO
sentence = "Elon Musk fondò Tesla a San Carlos nel 2003"
bio_labels = [
("Elon", "B-PER"), # inizio persona
("Musk", "I-PER"), # interno persona
("fondò", "O"),
("Tesla", "B-ORG"), # inizio organizzazione
("a", "O"),
("San", "B-LOC"), # inizio luogo
("Carlos", "I-LOC"), # interno luogo
("nel", "O"),
("2003", "B-DATE"), # data
]
# Formato BIOES (esteso): aggiunge S-TIPO per entità di un solo token
# S-Tesla = singolo token ORG
# Il formato BIO è il più comune nei dataset NER moderni
# Label set per CoNLL-2003 (dataset NER più usato):
CONLL_LABELS = [
'O',
'B-PER', 'I-PER', # persone
'B-ORG', 'I-ORG', # organizzazioni
'B-LOC', 'I-LOC', # luoghi
'B-MISC', 'I-MISC', # miscellanea
]
1.2 Benchmarky a datové sady NER
Standardní datový soubor NER pro srovnávání
| Datové sady | Jazyk | Entita | Velikost vlaku | Nejlepší F1 |
|---|---|---|---|---|
| CoNLL-2003 | EN | PRO, ORG, LOC, RŮZNÉ | Odesláno 14 041 | ~94 % (DeBERTa) |
| OntoNotes 5.0 | EN | 18 druhů | Odesláno ~75 tis | ~92 % |
| Evalita 2009 NER | IT | PER, ORG, LOC, GPE | Odesláno ~10 tis | ~88 % |
| WikiNEuRal EN | IT | PRO, ORG, LOC, RŮZNÉ | Odesláno ~40 tis | ~90 % |
| I2B2 2014 | EN (lékař) | PHI (anonymizace) | Odesláno 27 tis | ~97 % |
2. NER s spaCy
prostorovost nabízí předem vyškolené modely NER pro mnoho jazyků, včetně italštiny. A nejrychlejší výchozí bod pro produkční systém NER.
2.1 NER Out-of-the-Box s spaCy
import spacy
from spacy import displacy
# Carica modello italiano con NER
# python -m spacy download it_core_news_lg
nlp_it = spacy.load("it_core_news_lg")
# Modello inglese per confronto
# python -m spacy download en_core_web_trf
nlp_en = spacy.load("en_core_web_trf") # Transformer-based, più preciso
# NER su testo italiano
text_it = """
Il presidente Sergio Mattarella ha incontrato ieri a Roma il CEO di Fiat Stellantis
Carlos Tavares per discutere del piano industriale 2025-2030.
L'incontro e avvenuto al Quirinale e ha riguardato investimenti per 5 miliardi di euro.
"""
doc_it = nlp_it(text_it)
print("=== Entità in italiano ===")
for ent in doc_it.ents:
print(f" '{ent.text}' -> {ent.label_} ({spacy.explain(ent.label_)})")
# NER su testo inglese
text_en = "Apple CEO Tim Cook announced a new $3 billion investment in Austin, Texas on Monday."
doc_en = nlp_en(text_en)
print("\n=== Entities in English ===")
for ent in doc_en.ents:
print(f" '{ent.text}' -> {ent.label_}")
# Visualizzazione HTML (utile in Jupyter)
html = displacy.render(doc_en, style="ent", page=False)
with open("ner_visualization.html", "w") as f:
f.write(html)
2.2 spaCy Kategorie entit pro italštinu
| Označení | Typ | Příklad |
|---|---|---|
| PRO | Osoba | Mario Draghi, Sophia Loren |
| ORG | Organizace | ENI, Juventus, Bank of Italy |
| LOC | Obecné místo | Alpy, Středozemní moře |
| GPE | Geopolitická entita | Itálie, Řím, Lombardie |
| DATA | Datum/období | 3. března v létě 2024 |
| PENÍZE | Měna | 5 miliard eur |
| RŮZNÉ | Ostatní | Mistrovství světa, COVID-19 |
2.3 Školení vlastního modelu NER pomocí spaCy
import spacy
from spacy.training import Example
import random
# Dati di training annotati (con offset carattere)
TRAIN_DATA = [
(
"La startup Satispay ha raccolto 320 milioni dalla BAFIN.",
{"entities": [(11, 19, "ORG"), (39, 53, "MONEY"), (58, 63, "ORG")]}
),
(
"Andrea Pirlo allena la Juve a Torino.",
{"entities": [(0, 12, "PER"), (23, 27, "ORG"), (30, 36, "LOC")]}
),
(
"Ferrari ha presentato la nuova SF-23 al Gran Premio di Monza.",
{"entities": [(0, 7, "ORG"), (29, 34, "MISC"), (38, 60, "MISC")]}
),
]
def train_custom_ner(train_data, n_iter=30):
"""Addestra un componente NER personalizzato su spaCy."""
nlp = spacy.blank("it")
ner = nlp.add_pipe("ner")
# Aggiungi etichette
for _, annotations in train_data:
for _, _, label in annotations.get("entities", []):
ner.add_label(label)
# Training
other_pipes = [pipe for pipe in nlp.pipe_names if pipe != "ner"]
with nlp.disable_pipes(*other_pipes):
optimizer = nlp.begin_training()
for i in range(n_iter):
random.shuffle(train_data)
losses = {}
for text, annotations in train_data:
doc = nlp.make_doc(text)
example = Example.from_dict(doc, annotations)
nlp.update([example], sgd=optimizer, losses=losses)
if (i + 1) % 10 == 0:
print(f"Iteration {i+1}: losses = {losses}")
return nlp
custom_nlp = train_custom_ner(TRAIN_DATA)
# Test
test_text = "Enel ha investito 2 miliardi a Milano."
doc = custom_nlp(test_text)
for ent in doc.ents:
print(f" '{ent.text}' -> {ent.label_}")
3. NER s BERT a HuggingFace Transformers
Modely transformátorů překonávají spaCy ve většině benchmarků NER, zejména u složitých textů nebo když jsou entity nejednoznačné. Vyžadují však více dat a času na školení.
3.1 Datový soubor CoNLL-2003
from datasets import load_dataset
# CoNLL-2003: dataset NER standard (inglese)
dataset = load_dataset("conll2003")
print(dataset)
# train: 14,041 | validation: 3,250 | test: 3,453
# Struttura del dataset
example = dataset['train'][0]
print("Tokens:", example['tokens'])
print("NER tags:", example['ner_tags'])
# Tokens: ['EU', 'rejects', 'German', 'call', 'to', 'boycott', 'British', 'lamb', '.']
# NER tags: [3, 0, 7, 0, 0, 0, 7, 0, 0]
# (3=B-ORG, 0=O, 7=B-MISC)
# Mappa da ID a label
label_names = dataset['train'].features['ner_tags'].feature.names
print("Labels:", label_names)
# ['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC', 'B-MISC', 'I-MISC']
3.2 Problém se zarovnáním tokenu a štítku
BERT používá tokenizaci WordPiece: slovo lze rozdělit do více dílčích tokenů. Potřebujeme zarovnat štítky NER (na úrovni slov) s dílčími tokeny BERT. Toto je jedna ze specifických výzev NER s Transformers, která u spaCy neexistuje.
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
# Esempio: parola "Johannesburg" e le sue label
words = ["Johannesburg", "is", "the", "largest", "city"]
word_labels = ["B-LOC", "O", "O", "O", "O"]
# Tokenizzazione WordPiece
tokenized = tokenizer(
words,
is_split_into_words=True, # input già tokenizzato a livello di parola
return_offsets_mapping=True
)
print("Subword tokens:", tokenizer.convert_ids_to_tokens(tokenized['input_ids']))
# ['[CLS]', 'Johann', '##es', '##burg', 'is', 'the', 'largest', 'city', '[SEP]']
# Allineamento etichette (strategia: -100 per subtoken non-first)
def align_labels(tokenized, word_labels, label2id):
word_ids = tokenized.word_ids()
label_ids = []
prev_word_id = None
for word_id in word_ids:
if word_id is None:
# Token speciale [CLS] o [SEP]
label_ids.append(-100)
elif word_id != prev_word_id:
# Primo subtoken della parola: usa la vera etichetta
label_ids.append(label2id[word_labels[word_id]])
else:
# Subtoken successivi: -100 (ignorati nella loss)
label_ids.append(-100)
prev_word_id = word_id
return label_ids
label2id = {"O": 0, "B-LOC": 1, "I-LOC": 2, "B-PER": 3, "I-PER": 4,
"B-ORG": 5, "I-ORG": 6, "B-MISC": 7, "I-MISC": 8}
aligned = align_labels(tokenized, word_labels, label2id)
tokens = tokenizer.convert_ids_to_tokens(tokenized['input_ids'])
for tok, lab in zip(tokens, aligned):
print(f" {tok:15s}: {lab}")
# [CLS] : -100
# Johann : 1 (B-LOC)
# ##es : -100 (ignorato)
# ##burg : -100 (ignorato)
# is : 0 (O)
# ...
3.3 Kompletní doladění pro NER
from transformers import (
AutoModelForTokenClassification,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForTokenClassification
)
from datasets import load_dataset
import evaluate
import numpy as np
# Configurazione
MODEL_NAME = "bert-base-cased"
DATASET_NAME = "conll2003"
MAX_LENGTH = 128
dataset = load_dataset(DATASET_NAME)
label_names = dataset['train'].features['ner_tags'].feature.names
num_labels = len(label_names)
id2label = {i: l for i, l in enumerate(label_names)}
label2id = {l: i for i, l in enumerate(label_names)}
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
def tokenize_and_align_labels(examples):
tokenized = tokenizer(
examples["tokens"],
truncation=True,
max_length=MAX_LENGTH,
is_split_into_words=True
)
all_labels = []
for i, labels in enumerate(examples["ner_tags"]):
word_ids = tokenized.word_ids(batch_index=i)
label_ids = []
prev_word_id = None
for word_id in word_ids:
if word_id is None:
label_ids.append(-100)
elif word_id != prev_word_id:
label_ids.append(labels[word_id])
else:
label_ids.append(-100)
prev_word_id = word_id
all_labels.append(label_ids)
tokenized["labels"] = all_labels
return tokenized
tokenized_datasets = dataset.map(
tokenize_and_align_labels,
batched=True,
remove_columns=dataset["train"].column_names
)
# Modello
model = AutoModelForTokenClassification.from_pretrained(
MODEL_NAME,
num_labels=num_labels,
id2label=id2label,
label2id=label2id
)
# Data collator con padding dinamico per NER
data_collator = DataCollatorForTokenClassification(tokenizer)
# Metriche: seqeval per NER span-level F1
seqeval = evaluate.load("seqeval")
def compute_metrics(p):
predictions, labels = p
predictions = np.argmax(predictions, axis=2)
true_predictions = [
[label_names[p] for (p, l) in zip(pred, label) if l != -100]
for pred, label in zip(predictions, labels)
]
true_labels = [
[label_names[l] for (p, l) in zip(pred, label) if l != -100]
for pred, label in zip(predictions, labels)
]
results = seqeval.compute(predictions=true_predictions, references=true_labels)
return {
"precision": results["overall_precision"],
"recall": results["overall_recall"],
"f1": results["overall_f1"],
"accuracy": results["overall_accuracy"],
}
# Training
args = TrainingArguments(
output_dir="./results/bert-ner-conll",
num_train_epochs=3,
per_device_train_batch_size=32,
per_device_eval_batch_size=64,
learning_rate=2e-5,
warmup_ratio=0.1,
weight_decay=0.01,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1",
fp16=True,
report_to="none"
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics
)
trainer.train()
# Expected F1 on CoNLL-2003 test: ~91-92% (BERT-base-cased)
# Con RoBERTa-large: ~93-94%
4. Pokročilé architektury pro NER
Kromě klasického doladění BERT existují architektonické varianty, které jej vylepšují Výkon NER, zejména pro zachycení závislostí mezi BIO štítky.
4.1 Vrstva BERT + CRF
Il CRF (Podmíněné náhodné pole) aplikované přes BERT vynucuje
strukturální omezení sekvencí značek: například token
I-ORG nemůže následovat B-PER. Tím se snižuje
běžné sekvenční chyby v čistě neuronových architekturách.
# BERT + CRF con torchcrf o pytorch-crf
# pip install pytorch-crf
import torch
import torch.nn as nn
from transformers import BertModel, BertPreTrainedModel
from torchcrf import CRF
class BertCRFForNER(BertPreTrainedModel):
"""BERT fine-tuned con CRF layer per NER."""
def __init__(self, config, num_labels):
super().__init__(config)
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, num_labels)
self.crf = CRF(num_labels, batch_first=True)
self.init_weights()
def forward(self, input_ids, attention_mask, token_type_ids=None, labels=None):
outputs = self.bert(
input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids
)
sequence_output = self.dropout(outputs[0])
emissions = self.classifier(sequence_output) # (batch, seq_len, num_labels)
if labels is not None:
# Training: calcola CRF loss (negativa log-likelihood)
loss = -self.crf(emissions, labels, mask=attention_mask.bool(), reduction='mean')
return {'loss': loss, 'logits': emissions}
else:
# Inference: decodifica Viterbi
predictions = self.crf.decode(emissions, mask=attention_mask.bool())
return {'predictions': predictions, 'logits': emissions}
# Vantaggi CRF:
# + Garantisce sequenze BIO valide (no I-X senza B-X prima)
# + Migliora F1 di ~0.5-1.5 punti su CoNLL
# Svantaggi:
# - Più lento in inferenza (Viterbi decoding O(n * L^2))
# - Più complesso da implementare
4.2 Novější modely: RoBERTa a DeBERTa pro NER
from transformers import AutoModelForTokenClassification, AutoTokenizer
from transformers import pipeline
# RoBERTa-large: ~1.5% F1 in più di BERT-base su CoNLL-2003
# Usa lo stesso codice ma cambia MODEL_NAME
# Per il massimo F1 su CoNLL inglese:
model_name = "Jean-Baptiste/roberta-large-ner-english"
ner_pipeline = pipeline(
"ner",
model=model_name,
aggregation_strategy="simple"
)
text = "Elon Musk's Tesla announced a new Gigafactory in Berlin, Germany, with €5B investment."
entities = ner_pipeline(text)
for ent in entities:
print(f" '{ent['word']}' -> {ent['entity_group']} (score={ent['score']:.3f})")
# DeBERTa-v3-large: stato dell'arte su molti benchmark NER
# (richiede più RAM - 900M parametri)
deberta_ner = pipeline(
"ner",
model="dslim/bert-large-NER",
aggregation_strategy="simple"
)
# Confronto benchmarks (CoNLL-2003 test F1):
# BERT-base-cased: ~92.0%
# RoBERTa-large: ~93.5%
# DeBERTa-v3-large: ~94.0%
# XLNet-large: ~93.0%
5. NER Inference a post-processing
Po trénování vyžaduje závěr k rekonstrukci následné zpracování entity z token span.
from transformers import pipeline
import torch
# Pipeline HuggingFace (gestisce automaticamente il post-processing)
ner_pipeline = pipeline(
"ner",
model="./results/bert-ner-conll",
tokenizer="./results/bert-ner-conll",
aggregation_strategy="simple" # raggruppa subtoken della stessa entità
)
texts = [
"Tim Cook presented Apple's new iPhone 16 in Cupertino last September.",
"The European Central Bank in Frankfurt raised rates by 25 basis points.",
"Enel Green Power signed a deal worth €2.5 billion with the Italian government.",
]
for text in texts:
entities = ner_pipeline(text)
print(f"\nText: {text}")
for ent in entities:
print(f" '{ent['word']}' -> {ent['entity_group']} "
f"(score={ent['score']:.3f}, start={ent['start']}, end={ent['end']})")
# Strategie di aggregazione disponibili:
# "none": restituisce tutti i token con la loro label
# "simple": raggruppa token consecutivi con lo stesso gruppo
# "first": usa il label del primo subtoken per ogni parola
# "average": media dei logit sui subtoken (più accurato)
# "max": usa il logit massimo sui subtoken
5.1 NER na dlouhých dokumentech (více než 512 tokenů)
def ner_long_document(text, ner_pipeline, max_length=400, stride=50):
"""
NER su documenti più lunghi di 512 token usando sliding window.
max_length: massimo token per finestra
stride: overlap tra finestre consecutive (evita boundary artifacts)
"""
words = text.split()
all_entities = []
processed_positions = set()
for start_idx in range(0, len(words), max_length - stride):
end_idx = min(start_idx + max_length, len(words))
chunk = ' '.join(words[start_idx:end_idx])
entities = ner_pipeline(chunk)
# Aggiusta offset per la posizione nel testo originale
chunk_offset = len(' '.join(words[:start_idx])) + (1 if start_idx > 0 else 0)
for ent in entities:
abs_start = ent['start'] + chunk_offset
abs_end = ent['end'] + chunk_offset
# Evita duplicati dall'overlap
if abs_start not in processed_positions:
all_entities.append({
'word': ent['word'],
'entity_group': ent['entity_group'],
'score': ent['score'],
'start': abs_start,
'end': abs_end
})
processed_positions.add(abs_start)
if end_idx == len(words):
break
return sorted(all_entities, key=lambda x: x['start'])
# Nota: alternativa moderna con Longformer (supporta fino a 4096 token nativamente)
# da allenallenai/longformer-base-4096
6. NER pro italštinu
Italština má morfologické vlastnosti, díky kterým je NER náročnější: rodová a číselná shoda, klitické tvary, vlastní jména s určitým členem ("Romové", "Milán"). Podívejme se na nejlepší dostupné možnosti.
import spacy
from transformers import pipeline
# spaCy NER per l'italiano
nlp_it = spacy.load("it_core_news_lg")
italian_texts = [
"Il primo ministro Giorgia Meloni ha incontrato il presidente francese Macron a Parigi.",
"Fiat Chrysler Automobiles ha annunciato fusione con PSA Group per 50 miliardi.",
"L'AS Roma ha battuto la Lazio per 2-1 allo Stadio Olimpico domenica sera.",
"Il Tribunale di Milano ha condannato Mediaset a pagare 300 milioni a Vivendi.",
]
print("=== NER Italiano con spaCy it_core_news_lg ===")
for text in italian_texts:
doc = nlp_it(text)
entities = [(ent.text, ent.label_) for ent in doc.ents]
print(f"\nTesto: {text[:70]}")
print(f"Entità: {entities}")
# BERT NER per l'italiano
# Opzione 1: fine-tuned su WikiNEuRal
try:
it_ner = pipeline(
"ner",
model="osiria/bert-base-italian-uncased-ner",
aggregation_strategy="simple"
)
text = "Matteo Renzi ha fondato Italia Viva a Firenze nel 2019."
entities = it_ner(text)
print("\n=== BERT NER Italiano (osiria) ===")
for ent in entities:
print(f" '{ent['word']}' -> {ent['entity_group']} ({ent['score']:.3f})")
except Exception as e:
print(f"Modello non disponibile: {e}")
# Opzione 2: dbmdz/bert-base-italian-cased fine-tuned su Evalita
# Opzione 3: fine-tuning su WikiNEuRal IT con dbmdz/bert-base-italian-cased
# WikiNEuRal IT: ~40K frasi annotate, molto più grande di Evalita
print("\nAlternative per NER italiano:")
print(" 1. spaCy it_core_news_lg (più veloce, F1 ~85%)")
print(" 2. osiria/bert-base-italian-uncased-ner (più accurato, F1 ~88%)")
print(" 3. Fine-tuning custom su dati del tuo dominio (massima qualità)")
7. Hodnocení a metriky NER
from seqeval.metrics import (
classification_report,
f1_score,
precision_score,
recall_score
)
# seqeval valuta a livello di span (entità completa)
# più appropriato dell'accuracy a livello di token
true_sequences = [
['O', 'B-PER', 'I-PER', 'O', 'B-ORG', 'O'],
['B-LOC', 'I-LOC', 'O', 'O', 'B-DATE', 'O'],
]
pred_sequences = [
['O', 'B-PER', 'I-PER', 'O', 'O', 'O'], # manca ORG
['B-LOC', 'I-LOC', 'O', 'O', 'B-DATE', 'O'], # perfetto
]
print("=== NER Evaluation (span-level) ===")
print(classification_report(true_sequences, pred_sequences))
print(f"Overall F1: {f1_score(true_sequences, pred_sequences):.4f}")
print(f"Overall Precision: {precision_score(true_sequences, pred_sequences):.4f}")
print(f"Overall Recall: {recall_score(true_sequences, pred_sequences):.4f}")
# Tipi di errori NER:
# 1. False Negative (Missed): entità non riconosciuta
# 2. False Positive (Spurious): entità inventata dove non c'e
# 3. Wrong Type: entità trovata ma tipo sbagliato (PER invece di ORG)
# 4. Wrong Boundary: entità trovata ma span parzialmente errato
# Differenza fondamentale:
# Token-level accuracy: conta token corretti / tot token
# Span-level F1 (seqeval): un'entità e corretta solo se
# TUTTI i suoi token hanno la label giusta
# -> molto più rigoroso e realistico
8. Případová studie: NER on Financial News Articles
Pojďme vybudovat kompletní NER potrubí pro extrakci subjektů z finančních článků: společnosti, lidé, peněžní hodnoty a data.
from transformers import pipeline
from collections import defaultdict
import json
class FinancialNERExtractor:
"""
Estrattore NER specializzato per notizie finanziarie.
Estrae: aziende, persone chiave, valori e date.
"""
def __init__(self, model_name="dslim/bert-large-NER"):
self.ner = pipeline(
"ner",
model=model_name,
aggregation_strategy="simple"
)
self.entity_types = {
'ORG': 'companies',
'PER': 'people',
'MONEY': 'values',
'DATE': 'dates',
'LOC': 'locations',
'GPE': 'locations'
}
def extract(self, text: str) -> dict:
"""Estrae e organizza le entità per tipo."""
entities = self.ner(text)
result = defaultdict(list)
for ent in entities:
group = ent['entity_group']
mapped = self.entity_types.get(group)
if mapped and ent['score'] > 0.8:
result[mapped].append({
'text': ent['word'],
'score': round(ent['score'], 3),
'position': (ent['start'], ent['end'])
})
return dict(result)
def analyze_article(self, title: str, body: str) -> dict:
"""Analisi completa di un articolo finanziario."""
full_text = f"{title}. {body}"
# NER su testo completo
raw_entities = self.extract(full_text)
# Deduplica (stesso testo, posizioni diverse)
for etype, ents in raw_entities.items():
seen = set()
deduped = []
for e in ents:
if e['text'] not in seen:
seen.add(e['text'])
deduped.append(e)
raw_entities[etype] = deduped
return {
'title': title,
'entities': raw_entities,
'entity_count': sum(len(v) for v in raw_entities.values())
}
# Test
extractor = FinancialNERExtractor()
articles = [
{
"title": "Amazon acquires Whole Foods for $13.7 billion",
"body": "Jeff Bezos announced the acquisition in Seattle on June 16, 2017. Whole Foods CEO John Mackey will remain in his role. The deal is expected to close in the second half of 2017."
},
{
"title": "Tesla opens new Gigafactory in Germany",
"body": "Elon Musk inaugurated the Berlin factory in March 2022. The facility in Gruenheide will employ 12,000 people and produce 500,000 vehicles per year. The German government provided €1.3 billion in subsidies."
},
]
print("=== Financial NER Analysis ===\n")
for article in articles:
result = extractor.analyze_article(article['title'], article['body'])
print(f"Title: {result['title']}")
print(f"Total entities: {result['entity_count']}")
for etype, ents in result['entities'].items():
if ents:
texts = [e['text'] for e in ents]
print(f" {etype:12s}: {', '.join(texts)}")
print()
9. Optimalizace NER Pipeline pro výrobu
Produkční NER systém musí vyvážit přesnost, rychlost a výpočetní náklady. Níže je uveden optimalizovaný kanál, který kombinuje lexikální předběžné filtrování a dávkové odvození a ukládání výsledků do mezipaměti pro scénáře s velkým objemem.
from transformers import pipeline
from functools import lru_cache
import hashlib
import json
import time
from typing import List, Dict, Optional
import numpy as np
class OptimizedNERPipeline:
"""
Pipeline NER ottimizzata per produzione:
- Caching dei risultati con LRU cache
- Batch processing adattivo
- Pre-filtro lessicale per ridurre il carico
- Monitoring della latenza e confidenza
"""
def __init__(
self,
model_name: str = "dslim/bert-large-NER",
batch_size: int = 8,
min_confidence: float = 0.75,
cache_size: int = 1024
):
self.ner = pipeline(
"ner",
model=model_name,
aggregation_strategy="simple",
batch_size=batch_size,
device=0 # -1 per CPU, 0 per prima GPU
)
self.min_confidence = min_confidence
self._cache: Dict[str, list] = {}
self._cache_size = cache_size
self._stats = {"hits": 0, "misses": 0, "total_time_ms": 0.0}
def _text_hash(self, text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
def extract(self, texts: List[str]) -> List[List[Dict]]:
"""Estrazione NER con caching e batch processing."""
results = [None] * len(texts)
uncached_indices = []
uncached_texts = []
# Controlla cache
for i, text in enumerate(texts):
key = self._text_hash(text)
if key in self._cache:
results[i] = self._cache[key]
self._stats["hits"] += 1
else:
uncached_indices.append(i)
uncached_texts.append(text)
self._stats["misses"] += 1
# Elabora i testi non in cache
if uncached_texts:
start = time.perf_counter()
raw_results = self.ner(uncached_texts)
elapsed_ms = (time.perf_counter() - start) * 1000
self._stats["total_time_ms"] += elapsed_ms
# Gestisci singolo vs batch
if len(uncached_texts) == 1:
raw_results = [raw_results]
for idx, raw in zip(uncached_indices, raw_results):
# Filtra per confidenza e pulisci
filtered = [
{
'word': e['word'].replace(' ##', '').strip(),
'entity_group': e['entity_group'],
'score': round(e['score'], 4),
'start': e['start'],
'end': e['end']
}
for e in raw
if e['score'] >= self.min_confidence
]
key = self._text_hash(texts[idx])
# Eviction semplice della cache (FIFO)
if len(self._cache) >= self._cache_size:
oldest_key = next(iter(self._cache))
del self._cache[oldest_key]
self._cache[key] = filtered
results[idx] = filtered
return results
def get_stats(self) -> Dict:
"""Restituisce statistiche sulla pipeline."""
total = self._stats["hits"] + self._stats["misses"]
return {
"cache_hit_rate": self._stats["hits"] / total if total > 0 else 0.0,
"avg_latency_ms": self._stats["total_time_ms"] / max(self._stats["misses"], 1),
"cache_size": len(self._cache),
**self._stats
}
# Utilizzo
ner_pipeline = OptimizedNERPipeline(min_confidence=0.80)
batch_texts = [
"Mario Draghi ha guidato la BCE dal 2011 al 2019.",
"Amazon ha acquisito MGM Studios per 8.45 miliardi di dollari.",
"Il MIT di Boston ha pubblicato una ricerca su GPT-4.",
"Sergio Mattarella e il Presidente della Repubblica Italiana.",
]
# Prima chiamata: elabora tutto
results1 = ner_pipeline.extract(batch_texts)
# Seconda chiamata: tutti in cache!
results2 = ner_pipeline.extract(batch_texts)
print("Statistiche pipeline NER:")
stats = ner_pipeline.get_stats()
for k, v in stats.items():
print(f" {k}: {v}")
print("\nRisultati estrazione:")
for text, entities in zip(batch_texts, results1):
print(f"\n Testo: {text[:60]}")
for ent in entities:
print(f" '{ent['word']}' -> {ent['entity_group']} ({ent['score']:.3f})")
9.1 Porovnání modelů NER: Praktický benchmark
Benchmark NER: Rychlost vs přesnost (2024–2025)
| Model | CoNLL F1 | Rychlost (CPU) | Parametry | Jazyk | Use Case |
|---|---|---|---|---|---|
| spaCy it_core_news_sm | ~80 % | Velmi rychlé (<5 ms) | 12 mil | IT | Rychlé prototypování |
| spaCy it_core_news_lg | ~85 % | Rychlé (10–20 ms) | 560 mil | IT | Výroba CPU |
| dslim/bert-base-NER | ~91 % | Střední (50–100 ms) | 110 mil | EN | Výroba GPU |
| dslim/bert-large-NER | ~92 % | Pomalé (100–200 ms) | 340 mil | EN | Vysoká přesnost |
| Jean-Baptiste/roberta-large-ner-anglicky | ~93,5 % | Pomalé (150–250 ms) | 355 mil | EN | Nejmodernější EN |
| osiria/bert-base-italian-uncased-ner | ~88 % | Střední (50–100 ms) | 110 mil | IT | Nejlepší IT model |
9.2 Anonymizace dat pomocí NER
Kritickým případem použití v právní, lékařské a GDPR je automatická anonymizace osobních údajů. NER dokáže automaticky identifikovat PER, ORG, LOC a DATE pro pseudonymizaci nebo sepisování citlivých dokumentů.
from transformers import pipeline
import re
class TextAnonymizer:
"""
Anonimizzatore di testo basato su NER.
Sostituisce entità sensibili con placeholder tipizzati.
GDPR-compliant: utile per dataset di training e log di sistema.
"""
REPLACEMENT_MAP = {
'PER': '<PERSONA>',
'ORG': '<ORGANIZZAZIONE>',
'LOC': '<LUOGO>',
'GPE': '<LUOGO>',
'DATE': '<DATA>',
'MONEY': '<IMPORTO>',
'MISC': '<ALTRO>',
}
def __init__(self, model_name="dslim/bert-large-NER"):
self.ner = pipeline(
"ner",
model=model_name,
aggregation_strategy="simple"
)
def anonymize(self, text: str, entity_types: list = None) -> dict:
"""
Anonimizza il testo sostituendo le entità.
entity_types: lista di tipi da anonimizzare (None = tutti)
"""
entities = self.ner(text)
# Filtra per tipo se specificato
if entity_types:
entities = [e for e in entities if e['entity_group'] in entity_types]
# Ordina per posizione decrescente per sostituire dal fondo
entities_sorted = sorted(entities, key=lambda e: e['start'], reverse=True)
anonymized = text
replacements = []
for ent in entities_sorted:
placeholder = self.REPLACEMENT_MAP.get(ent['entity_group'], '<ENTITA>')
original = text[ent['start']:ent['end']]
anonymized = anonymized[:ent['start']] + placeholder + anonymized[ent['end']:]
replacements.append({
'original': original,
'replacement': placeholder,
'type': ent['entity_group'],
'confidence': round(ent['score'], 3)
})
return {
'original': text,
'anonymized': anonymized,
'replacements': replacements,
'num_entities': len(replacements)
}
# Test
anonymizer = TextAnonymizer()
sensitive_texts = [
"Il paziente Mario Rossi, nato il 15 marzo 1978, e stato ricoverato all'Ospedale San Raffaele di Milano il 3 gennaio 2024 con diagnosi di polmonite.",
"La società Accenture Italia S.r.l., con sede in Via Paleocapa 7 a Milano, ha fatturato 500 milioni di euro nel 2023.",
"L'avvocato Giovanni Bianchi dello studio Chiomenti ha rappresentato Mediaset nel ricorso al TAR del Lazio.",
]
print("=== Anonimizzazione con NER ===\n")
for text in sensitive_texts:
result = anonymizer.anonymize(text, entity_types=['PER', 'ORG', 'LOC', 'GPE', 'DATE', 'MONEY'])
print(f"Originale: {result['original'][:100]}")
print(f"Anonimiz.: {result['anonymized'][:100]}")
print(f"Entità: {result['num_entities']} sostituite")
print()
10. Nejlepší postupy pro NER ve výrobě
Anti-Pattern: Ignorovat následné zpracování
Modely NER vytvářejí předpovědi na úrovni BIO tokenů. ve výrobě, vždy musíte znovu vytvořit rozpětí, spravovat dílčí tokeny WordPiece a filtrovat subjekty s nízkou důvěrou. Nikdy nevystavujte nezpracované tokeny koncovým uživatelům.
Anti-Pattern: Vyhodnoťte pouze s přesností tokenu
Přesnost tokenu na CoNLL-2003 je obvykle 98–99 % i u průměrných modelů,
protože většina tokenů má štítky O. Vždy používejte seqeval
pro hodnocení F1 na úrovni rozpětí, což je jediná relevantní metrika pro NER.
Kontrolní seznam výroby NER
- Vyhodnoťte sekveval (rozpětí F1), nejen přesnost na úrovni tokenů
- Chcete-li filtrovat falešně pozitivní výsledky, nastavte prahové hodnoty spolehlivosti (obvykle 0,7–0,85).
- Zpracování překrývajících se entit (vzácné, ale možné)
- Normalizovat extrahované entity (deduplikace, kanonikalizace)
- Sledujte distribuci entit a zjistěte posuny domén
- Použít zobrazení pro předpovědi ladění
- Testujte na textech z různých domén: zprávy, smlouvy, sociální média se chovají jinak
- Pro italštinu: použijte it_core_news_lg (rychlé) nebo BERT jemně vyladěné na WikiNEuRal IT (přesné)
Závěry a další kroky
NER je jednou z nejužitečnějších úloh NLP v reálných aplikacích: extrakce informací, konstrukce znalostních grafů, napájení RAG systémů, anonymizace dat. S spaCy pro jednoduché případy a vyladěným BERT pro vysokou přesnost máte všechny nástroje vybudovat robustní plynovody NER pro italštinu i angličtinu.
Klíčem k vynikajícím výsledkům v konkrétní doméně je vždy jemné vyladění dat anotujte svůj kontext: dokonce několik stovek příkladů specifických pro doménu může výrazně zlepšit výkon ve srovnání s generickým modelem.
Série pokračuje
- Další: Klasifikace textu s více štítky — klasifikovat texty s více štítky současně
- Článek 7: HuggingFace Transformers: Kompletní průvodce — API Trainer, Model Hub, optimalizace
- Článek 8: LoRA Jemné doladění — trénujte LLM lokálně se spotřebitelskými GPU
- Článek 9: Sémantická podobnost — NER jako těžební krok v potrubí RAG
- Související série: AI inženýrství/RAG — NER jako těžební krok v potrubí RAG







