Named Entity Recognition: Estrarre Informazioni dal Testo
Ogni giorno, sistemi NLP estraggono automaticamente informazioni strutturate da miliardi di documenti: notizie, contratti, email, cartelle cliniche, social media. Il motore di questa estrazione si chiama Named Entity Recognition (NER), un task che identifica e classifica le entità nominate nel testo — persone, organizzazioni, luoghi, date, quantità monetarie e molto altro.
NER e il primo passo in molte pipeline di information extraction: senza sapere chi fa cosa, dove e quando, non possiamo costruire grafi di conoscenza, alimentare sistemi RAG, automatizzare contratti o analizzare notizie finanziarie. In questo articolo costruiremo sistemi NER dalla baseline con spaCy al fine-tuning di BERT, con attenzione specifica all'italiano.
Cosa Imparerai
- Cos'è il NER e le principali categorie di entità (PER, ORG, LOC, DATE, MONEY...)
- Il formato BIO (Beginning-Inside-Outside) per l'annotazione dei token
- NER con spaCy: modelli pre-addestrati e personalizzazione
- Fine-tuning BERT per NER con HuggingFace Transformers
- Metriche: span-level F1, precision, recall con seqeval
- Gestione della tokenizzazione WordPiece per l'allineamento delle etichette
- NER per l'italiano con spaCy it_core_news e modelli BERT italiani
- NER su documenti lunghi: sliding window e post-processing
- Architetture avanzate: CRF layer, RoBERTa, DeBERTa per NER
- Pipeline di produzione, visualizzazione e caso di studio end-to-end
1. Cos'è il Named Entity Recognition
Il NER e un task di token classification: per ogni token nel testo, il modello deve predire se e parte di un'entità nominata e di quale tipo. A differenza della classificazione di frasi (che produce un output per frase), il NER produce un output per ogni token — questo lo rende più complesso sia dal punto di vista del modello che del post-processing.
Esempio di NER
Input: "Elon Musk ha fondato Tesla nel 2003 a San Carlos, California."
Output annotato:
- Elon Musk → PER (persona)
- Tesla → ORG (organizzazione)
- 2003 → DATE (data)
- San Carlos → LOC (luogo)
- California → LOC (luogo)
1.1 Il Formato BIO
L'annotazione NER usa il formato BIO (Beginning-Inside-Outside):
- B-TIPO: primo token di un'entità del tipo TIPO
- I-TIPO: token interno a un'entità del tipo TIPO
- O: token non appartiene ad alcuna 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 Benchmark e Dataset NER
Dataset NER Standard per il Benchmarking
| Dataset | Lingua | Entità | Train size | Best F1 |
|---|---|---|---|---|
| CoNLL-2003 | EN | PER, ORG, LOC, MISC | 14,041 sent | ~94% (DeBERTa) |
| OntoNotes 5.0 | EN | 18 tipi | ~75K sent | ~92% |
| Evalita 2009 NER | IT | PER, ORG, LOC, GPE | ~10K sent | ~88% |
| WikiNEuRal IT | IT | PER, ORG, LOC, MISC | ~40K sent | ~90% |
| I2B2 2014 | EN (medico) | PHI (anonimizzazione) | 27K sent | ~97% |
2. NER con spaCy
spaCy offre modelli NER pre-addestrati per molte lingue, incluso l'italiano. E il punto di partenza più veloce per un sistema NER in produzione.
2.1 NER Out-of-the-Box con 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 Categorie di Entità di spaCy per l'Italiano
| Label | Tipo | Esempio |
|---|---|---|
| PER | Persona | Mario Draghi, Sophia Loren |
| ORG | Organizzazione | ENI, Juventus, Banca d'Italia |
| LOC | Luogo generico | Alpi, Mar Mediterraneo |
| GPE | Entità geopolitica | Italia, Roma, Lombardia |
| DATE | Data/periodo | 3 marzo, estate 2024 |
| MONEY | Valuta | 5 miliardi di euro |
| MISC | Altro | Coppa del Mondo, COVID-19 |
2.3 Training di un Modello NER Personalizzato con 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 con BERT e HuggingFace Transformers
I modelli Transformer superano spaCy sulla maggior parte dei benchmark NER, soprattutto su testi complessi o quando le entità sono ambigue. Richiedono pero più dati e tempo di training.
3.1 Dataset 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 Il Problema dell'Allineamento Token-Label
BERT usa la tokenizzazione WordPiece: una parola può essere suddivisa in più subtoken. Dobbiamo allineare le etichette NER (a livello di parola) con i subtoken BERT. Questa e una delle sfide specifiche del NER con Transformers che non esiste con spaCy.
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 Fine-tuning Completo per 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. Architetture Avanzate per NER
Oltre al classico fine-tuning BERT, esistono varianti architetturali che migliorano le performance NER, in particolare per catturare le dipendenze tra le etichette BIO.
4.1 BERT + CRF Layer
Il CRF (Conditional Random Field) applicato sopra BERT impone
vincoli strutturali sulle sequenze di etichette: ad esempio, un token
I-ORG non può seguire B-PER. Questo riduce gli
errori di sequenza comune nelle architetture puramente neurali.
# 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 Modelli Più Recenti: RoBERTa e DeBERTa per 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. Inferenza e Post-processing NER
Dopo il training, l'inferenza richiede un post-processing per ricostruire le entità da span di token.
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 su Documenti Lunghi (oltre 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 per l'Italiano
L'italiano ha caratteristiche morfologiche che rendono il NER più sfidante: accordo di genere e numero, forme clitiche, nomi propri con articolo determinativo ("la Roma", "il Milan"). Vediamo le migliori opzioni disponibili.
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. Valutazione e Metriche 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. Caso di Studio: NER su Articoli di Notizie Finanziarie
Costruiamo una pipeline NER completa per estrarre entità da articoli finanziari: aziende, persone, valori monetari e date.
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. Ottimizzazione della Pipeline NER per la Produzione
Un sistema NER in produzione deve bilanciare precisione, velocità e costo computazionale. Di seguito una pipeline ottimizzata che combina pre-filtro lessicale, batch inference e caching dei risultati per scenari ad alto volume.
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 Comparazione Modelli NER: Benchmark Pratico
Benchmark NER: Velocita vs Accuratezza (2024-2025)
| Modello | CoNLL F1 | Velocita (CPU) | Parametri | Lingua | Caso d'Uso |
|---|---|---|---|---|---|
| spaCy it_core_news_sm | ~80% | Molto veloce (<5ms) | 12M | IT | Prototipazione rapida |
| spaCy it_core_news_lg | ~85% | Veloce (10-20ms) | 560M | IT | Produzione CPU |
| dslim/bert-base-NER | ~91% | Medio (50-100ms) | 110M | EN | Produzione GPU |
| dslim/bert-large-NER | ~92% | Lento (100-200ms) | 340M | EN | Alta precisione |
| Jean-Baptiste/roberta-large-ner-english | ~93.5% | Lento (150-250ms) | 355M | EN | Stato dell'arte EN |
| osiria/bert-base-italian-uncased-ner | ~88% | Medio (50-100ms) | 110M | IT | Miglior modello IT |
9.2 Anonimizzazione di Dati con NER
Un caso d'uso critico in ambito legale, medico e GDPR e l'anonimizzazione automatica di dati personali. Il NER può identificare automaticamente PER, ORG, LOC e DATE per la pseudonimizzazione o la redazione di documenti sensibili.
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. Best Practices per NER in Produzione
Anti-Pattern: Ignorare il Post-processing
I modelli NER producono predizioni a livello di token BIO. In produzione, devi sempre ricostruire gli span, gestire i subtoken WordPiece e filtrare le entità con confidenza bassa. Non esporre mai i token raw agli utenti finali.
Anti-Pattern: Valutare solo con Token Accuracy
La token accuracy su CoNLL-2003 e tipicamente 98-99% anche per modelli mediocri,
perchè la maggior parte dei token ha label O. Usa sempre seqeval
per la valutazione span-level F1 che e l'unica metrica rilevante per NER.
Checklist Produzione NER
- Valuta con seqeval (span F1), non solo accuracy a livello di token
- Imposta soglie di confidenza (tipicamente 0.7-0.85) per filtrare false positives
- Gestisci entità che si sovrappongono (rare ma possibili)
- Normalizza le entità estratte (deduplication, canonicalizzazione)
- Monitora la distribuzione delle entità per rilevare domain shift
- Usa visualizzazione (displacy) per il debugging delle predizioni
- Testa su testi di vari domini: news, contratti, social media si comportano diversamente
- Per l'italiano: usa it_core_news_lg (veloce) o BERT fine-tuned su WikiNEuRal IT (accurato)
Conclusioni e Prossimi Passi
Il NER e uno dei task NLP più utili in applicazioni reali: estrazione di informazioni, costruzione di knowledge graph, alimentazione di sistemi RAG, anonimizzazione di dati. Con spaCy per casi semplici e BERT fine-tuned per alta precisione, hai tutti gli strumenti per costruire pipeline NER robuste sia per l'italiano che per l'inglese.
La chiave per risultati eccellenti in dominio specifico e sempre il fine-tuning su dati annotati del tuo contesto: anche poche centinaia di esempi dominio-specifici possono migliorare notevolmente la performance rispetto al modello generico.
Continua la Serie
- Prossimo: Text Classification Multi-label — classificare testi con più etichette contemporaneamente
- Articolo 7: HuggingFace Transformers: Guida Completa — Trainer API, Model Hub, ottimizzazione
- Articolo 8: LoRA Fine-tuning — addestrare LLM localmente con GPU consumer
- Articolo 9: Semantic Similarity — NER come step di estrazione nel pipeline RAG
- Serie correlata: AI Engineering/RAG — NER come step di estrazione nel pipeline RAG







