Rozpoznawanie nazwanych podmiotów: wydobywanie informacji z tekstu
Każdego dnia systemy NLP automatycznie wyodrębniają ustrukturyzowane informacje od miliardów osób dokumentów: aktualności, umowy, e-maile, dokumentacja medyczna, media społecznościowe. Silnik tej ekstrakcji nazywa się Rozpoznawanie nazwanych podmiotów (NER), zadanie który identyfikuje i klasyfikuje wymienione w tekście podmioty — osoby, organizacje, lokalizacje, daty, kwoty pieniężne i wiele więcej.
NER to pierwszy krok w wielu procesach ekstrakcji informacji: bez wiedzy kto, co, gdzie i kiedy robi, nie możemy budować wykresów wiedzy, zasilaj systemy RAG, automatyzuj kontrakty czy analizuj wiadomości finansowe. W tym artykule zbudujemy systemy NER od podstaw za pomocą spaCy do dostrojenia BERT, ze szczególnym uwzględnieniem języka włoskiego.
Czego się nauczysz
- Co to jest NER i główne kategorie podmiotów (PER, ORG, LOC, DATE, MONEY...)
- Format BIO (Beginning-Inside-Outside) dla adnotacji tokena
- NER ze spaCy: wstępnie przeszkolone modele i dostosowywanie
- Dostrajanie BERT dla NER z transformatorami HuggingFace
- Metryki: poziom zakresu F1, precyzja, przywoływanie z sekwencją
- Obsługa tokenizacji WordPiece w celu wyrównania etykiet
- NER dla języka włoskiego ze spaCy it_core_news i włoskimi modelami BERT
- NER na długich dokumentach: przesuwane okno i przetwarzanie końcowe
- Zaawansowane architektury: warstwa CRF, RoBERTa, DeBERTa dla NER
- Kompleksowy rurociąg produkcyjny, wizualizacja i studium przypadku
1. Co to jest rozpoznawanie nazwanych jednostek
Zadaniem NER jest klasyfikacja tokenów: dla każdego tokenu w tekście, model musi przewidzieć, czy jest częścią nazwanej jednostki i jakiego typu. W przeciwieństwie do klasyfikacji zdań (która generuje jeden wynik na zdanie), NER generuje dane wyjściowe dla każdego tokena — to sprawia, że jest to bardziej złożone zarówno z punktu widzenia modelu, jak i postprocessingu.
Przykład NER
Wpis: „Elon Musk założył Teslę w 2003 roku w San Carlos w Kalifornii”.
Dane wyjściowe z adnotacjami:
- Elona Muska → ZA (osobę)
- Tesli → ORG (organizacja)
- 2003 → DATA (data)
- San Carlos → LOC (miejsce)
- Kalifornia → LOC (miejsce)
1.1 Format ORGANICZNY
Adnotacja NER używa formatu BIO (początek-wewnątrz-na zewnątrz):
- TYP B: pierwszy token jednostki typu TYPE
- TYP I: token wewnętrzny dla jednostki typu TYPE
- O: token nie należy do żadnego podmiotu
# 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 Testy porównawcze i zbiory danych NER
Standardowy zbiór danych NER do testów porównawczych
| Zbiory danych | Język | Podmiot | Rozmiar pociągu | Najlepsza F1 |
|---|---|---|---|---|
| CONLL-2003 | EN | DLA, ORG, LOC, RÓŻNE | Wysłano 14041 | ~94% (DeBERTa) |
| W Notatkach 5.0 | EN | 18 typów | Wysłano ~75 tys | ~92% |
| Ocena 2009 NER | IT | NA, ORG, LOC, GPE | Wysłano ~10 tys | ~88% |
| WikiNEuRal PL | IT | DLA, ORG, LOC, RÓŻNE | Wysłano ~40 tys | ~90% |
| I2B2 2014 | PL (lekarz) | PHI (anonimizacja) | Wysłano 27 tys | ~97% |
2. NER ze spaCy
spaCy oferuje wstępnie przeszkolone modele NER dla wielu języków, w tym włoskiego. I najszybszy punkt wyjścia dla produkcyjnego systemu NER.
Gotowy do użycia system 2.1 NER ze 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 Kategorie encji spaCy dla języka włoskiego
| Etykieta | Typ | Przykład |
|---|---|---|
| DLA | Osoba | Mario Draghiego i Sophię Loren |
| ORGANIZACJA | Organizacja | ENI, Juventus, Bank Włoch |
| LOK | Ogólne miejsce | Alpy, Morze Śródziemne |
| GPE | Podmiot geopolityczny | Włochy, Rzym, Lombardia |
| DATY | Data/okres | 3 marca, lato 2024 r |
| PIENIĄDZE | Waluta | 5 miliardów euro |
| RÓŻNE | Inny | Puchar Świata, Covid-19 |
2.3 Trenowanie niestandardowego modelu NER za 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 z BERT i HuggingFace Transformers
Modele transformatorów przewyższają spaCy w większości testów porównawczych NER, szczególnie w przypadku złożonych tekstów lub gdy podmioty są niejednoznaczne. Wymagają jednak większej ilości danych i czasu szkolenia.
3.1 Zbiór danych 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 Problem wyrównania etykiety tokenu
BERT wykorzystuje tokenizację WordPiece: słowo można podzielić na wiele subtokenów. Musimy dopasować etykiety NER (na poziomie słów) do podtokenów BERT. Jest to jedno ze specyficznych wyzwań stojących przed NER w przypadku transformatorów, które nie występują w przypadku 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 Całkowite dostrojenie dla 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. Zaawansowane architektury dla NER
Oprócz klasycznego dostrojenia BERT istnieją warianty architektoniczne, które je ulepszają Wydajność NER, w szczególności w celu wychwytywania zależności pomiędzy etykietami BIO.
4.1 Warstwa BERT + CRF
Il CRF (Warunkowe pole losowe) stosowane w stosunku do egzekwowanych przez BERT
ograniczenia strukturalne dotyczące sekwencji etykiet: na przykład token
I-ORG nie mogę nadążać B-PER. Zmniejsza to
typowe błędy sekwencji w architekturach czysto neuronowych.
# 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 Nowsze modele: RoBERTa i DeBERTa dla 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. Wnioskowanie i przetwarzanie końcowe NER
Po treningu wnioskowanie wymaga przetwarzania końcowego w celu rekonstrukcji podmioty z zakresu tokenów.
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 długich dokumentach (ponad 512 tokenów)
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 dla języka włoskiego
Włoski ma cechy morfologiczne, które sprawiają, że NER jest trudniejszy: zgodność płci i liczby, formy łechtaczki, nazwy własne z rodzajnikiem określonym („Roma”, „Mediolan”). Przyjrzyjmy się najlepszym dostępnym opcjom.
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. Ocena i wskaźniki 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. Studium przypadku: NER w artykułach z wiadomości finansowych
Zbudujmy kompletny rurociąg NER, aby wyodrębnić podmioty z artykułów finansowych: firmy, ludzie, wartości pieniężne i daty.
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. Optymalizacja gazociągu NER do celów produkcyjnych
Produkcyjny system NER musi równoważyć dokładność, szybkość i koszty obliczeniowe. Poniżej znajduje się zoptymalizowany potok, który łączy leksykalne filtrowanie wstępne i wnioskowanie wsadowe i buforowanie wyników w przypadku scenariuszy o dużej objętości.
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 Porównanie modeli NER: praktyczny punkt odniesienia
Benchmark NER: Szybkość kontra dokładność (2024–2025)
| Model | KONLL F1 | Szybkość (procesor) | Parametry | Język | Przypadek użycia |
|---|---|---|---|---|---|
| spaCy it_core_news_sm | ~80% | Bardzo szybki (<5ms) | 12M | IT | Szybkie prototypowanie |
| spaCy it_core_news_lg | ~85% | Szybki (10-20 ms) | 560M | IT | Produkcja procesorów |
| dslim/bert-base-NER | ~91% | Średni (50-100 ms) | 110M | EN | Produkcja GPU |
| dslim/bert-large-NER | ~92% | Wolny (100-200 ms) | 340M | EN | Wysoka precyzja |
| Jean-Baptiste/roberta-large-ner-angielski | ~93,5% | Wolny (150-250 ms) | 355M | EN | Stan techniki PL |
| osiria/bert-base-italian-uncased-ner | ~88% | Średni (50-100 ms) | 110M | IT | Najlepszy model IT |
9.2 Anonimizacja danych w NER
Krytycznym przypadkiem użycia w prawie, medycynie i RODO jest automatyczna anonimizacja danych osobowych. NER może automatycznie identyfikować PER, ORG, LOC i DATE do pseudonimizacji lub sporządzania dokumentów wrażliwych.
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. Najlepsze praktyki dla NER w produkcji
Anty-wzorzec: Ignoruj przetwarzanie końcowe
Modele NER generują prognozy na poziomie tokena BIO. W produkcji zawsze musisz odbudować zakresy, zarządzać podtokenami WordPiece i filtrować podmioty o niskim poziomie zaufania. Nigdy nie udostępniaj surowych tokenów użytkownikom końcowym.
Anty-wzorzec: oceniaj tylko z dokładnością tokenu
Dokładność tokena na CoNLL-2003 wynosi zazwyczaj 98-99% nawet w przypadku przeciętnych modeli,
ponieważ większość tokenów ma etykiety O. Zawsze używaj sekwencji
dla oceny na poziomie zakresu F1, która jest jedyną istotną metryką dla NER.
Lista kontrolna produkcji NER
- Oceniaj z dokładnością seqeval (zakres F1), a nie tylko z dokładnością na poziomie tokena
- Ustaw progi ufności (zwykle 0,7–0,85), aby filtrować fałszywe alarmy
- Obsługa nakładających się elementów (rzadkie, ale możliwe)
- Normalizacja wyodrębnionych jednostek (deduplikacja, kanonizacja)
- Monitoruj dystrybucję jednostek, aby wykryć zmiany domeny
- Użyj wyświetlacza do debugowania prognoz
- Testuj na tekstach z różnych domen: aktualności, umowy, media społecznościowe zachowują się inaczej
- W języku włoskim: użyj it_core_news_lg (szybko) lub dostrojonego BERT na WikiNEuRal IT (dokładnie)
Wnioski i dalsze kroki
NER to jedno z najbardziej przydatnych zadań NLP w rzeczywistych zastosowaniach: ekstrakcja informacji, budowa wykresów wiedzy, zasilanie systemów RAG, anonimizacja danych. Dzięki spaCy do prostych przypadków i dopracowanemu BERTowi zapewniającemu wysoką precyzję masz wszystkie narzędzia budowę solidnych rurociągów NER zarówno dla języka włoskiego, jak i angielskiego.
Kluczem do doskonałych wyników w określonej dziedzinie jest zawsze dostrojenie danych opisz swój kontekst: nawet kilkaset przykładów specyficznych dla domeny może znacznie poprawić wydajność w porównaniu do modelu ogólnego.
Seria trwa
- Następny: Klasyfikacja tekstu z wieloma etykietami — klasyfikuj teksty z wieloma etykietami jednocześnie
- Artykuł 7: Transformatory HuggingFace: kompletny przewodnik — Trener API, Model Hub, optymalizacja
- Artykuł 8: Dostrajanie LoRA — trenuj LLM lokalnie przy użyciu konsumenckich procesorów graficznych
- Artykuł 9: Podobieństwo semantyczne — NER jako etap ekstrakcji w rurociągu RAG
- Powiązane serie: Inżynieria AI/RAG — NER jako etap ekstrakcji w rurociągu RAG







