Zaawansowana klasyfikacja tekstu: Multi-label, Zero-shot i Few-shot
La Klasyfikacja tekstu i jedno z najczęstszych zadań NLP, ale w praktyce wykracza daleko poza zwykłe „pozytywne lub negatywne”. Artykuł informacyjny może być taki sam czas polityczny, gospodarczy i międzynarodowy. Bilet pomocy technicznej może należeć do wielu zgłoszeń kategorie jednocześnie. Dokument można utajnić, nawet jeśli go nie widziałeś przykłady tej kategorii podczas szkoleń.
W tym artykule zajmiemy się klasyfikacją tekstu w całej jego złożoności: multiclass (wiele wzajemnie wykluczających się klas), multi-label (wiele równoczesnych etykiet), klasyfikacja hierarchiczna i klasyfikacja zero-shot (bez przykładów szkoleniowych dla klas docelowych). Uwzględniamy wdrożenia z włoskimi zbiorami danych, Focal Loss dla niezrównoważonych zbiorów danych i kompletnych rurociągów produkcyjnych.
To szósty artykuł z tej serii Nowoczesne NLP: od BERT do LLM. Zakłada znajomość BERT i ekosystemu HuggingFace.
Czego się nauczysz
- Różnica między klasyfikacją binarną, wieloklasową i wieloetykietową — kiedy czego używać
- Dostrajanie BERT pod kątem klasyfikacji wieloklasowej za pomocą wskaźników softmax i złożonych
- Klasyfikacja wieloetykietowa z sigmoidą, BCEWithLogitsLoss i Focal Loss
- Dostrajanie progu dla każdej etykiety w przypadku wielu etykiet z optymalizacją F1
- Klasyfikacja zero-shot z modelami NLI (BART, DeBERTa-v3) i szablonami niestandardowymi
- Klasyfikacja kilku strzałów za pomocą SetFit: 8–64 przykładów na klasę
- Klasyfikacja hierarchiczna z płaską i z góry na dół
- Zarządzanie niezrównoważonymi zbiorami danych: ważenie klas, utrata ogniskowej, nadpróbkowanie
- Metryki wieloetykietowe: strata Hamminga, mikro/makro F1, dokładność podzbioru
- Gotowy do produkcji potok klasyfikacji z buforowaniem i wnioskowaniem wsadowym
1. Taksonomia klasyfikacji tekstu
Wybór prawidłowego rodzaju klasyfikacji jest pierwszym, krytycznym krokiem. Wybór wpływa na funkcję straty, metryki oceny i architekturę modelu.
Rodzaje klasyfikacji tekstu: przewodnik po wyborze
| Typ | Opis | Praktyczny przykład | Warstwa wyjściowa | Funkcja straty | Główna metryka |
|---|---|---|---|---|---|
| Dwójkowy | 2 wzajemnie wykluczające się klasy | Spam kontra szynka, pozytywna kontra negatywna | Esicy(1) | BCEWithLogitsLoss | F1, AUC-ROC |
| Wieloklasowy | Zajęcia N, wybór 1 | Kategoria wiadomości, język tekstu | Softmax(N) | Strata CrossEntropy | Dokładność, makro F1 |
| Wiele etykiet | N klas, wiele aktywnych jednocześnie | Tag artykułu, wiele emocji | Sigmoida (N) | BCEWithLogitsLoss | Strata Hamminga, Micro F1 |
| Hierarchiczny | Zajęcia zorganizowane hierarchicznie | Kategoria produktu (Elektronika > Telewizor > OLED) | Zależy | Strata hierarchiczna | F1 według poziomu |
| Zerowy strzał | Zajęcia nigdy nie widziane podczas szkoleń | Routing na dowolne tematy | Wyniki NLI | Strata szkoleniowa NLI | F1, dokładność na klasę |
| Kilka strzałów (SetFit) | Kilka przykładów w każdej klasie (8-64) | Klasyfikacja specyficzna dla domeny | Szef logistyki | Kontrastowy + CE | Dokładność, F1 |
2. Klasyfikacja wieloklasowa z BERT
W przypadku wielu klas tylko jedna klasa jest poprawna dla każdego przykładu. Użyjmy softmax jako funkcja aktywacji w warstwie wyjściowej tj Strata CrossEntropy jako funkcja straty. BERT osiąga najnowocześniejsze wyniki w testach porównawczych, takich jak AG News (~95%), Yelp-full (~70%), Odpowiedzi Yahoo (~77%).
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
TrainingArguments,
Trainer,
EarlyStoppingCallback
)
from datasets import load_dataset
import evaluate
import numpy as np
import torch
# AG News: classifica notizie in 4 categorie (dataset bilanciato)
# World, Sports, Business, Sci/Tech
dataset = load_dataset("ag_news")
print("Dataset AG News:", dataset)
# train: 120,000 esempi (30,000 per classe)
# test: 7,600 esempi
LABELS = ["World", "Sports", "Business", "Sci/Tech"]
num_labels = len(LABELS)
MODEL = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(MODEL)
def tokenize(examples):
return tokenizer(
examples["text"],
truncation=True,
padding="max_length",
max_length=128
)
tokenized = dataset.map(tokenize, batched=True, remove_columns=["text"])
tokenized.set_format("torch")
# Modello multi-class: softmax su 4 classi
model = AutoModelForSequenceClassification.from_pretrained(
MODEL,
num_labels=num_labels,
id2label={i: l for i, l in enumerate(LABELS)},
label2id={l: i for i, l in enumerate(LABELS)}
)
# Metriche composite per classificazione multi-class
accuracy = evaluate.load("accuracy")
f1 = evaluate.load("f1")
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
# Softmax per probabilità
probs = torch.softmax(torch.tensor(logits, dtype=torch.float32), dim=-1).numpy()
max_prob = probs.max(axis=1).mean() # confidenza media
return {
"accuracy": accuracy.compute(predictions=preds, references=labels)["accuracy"],
"f1_macro": f1.compute(predictions=preds, references=labels, average="macro")["f1"],
"f1_weighted": f1.compute(predictions=preds, references=labels, average="weighted")["f1"],
"avg_confidence": float(max_prob)
}
args = TrainingArguments(
output_dir="./results/bert-agnews",
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,
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1_macro",
fp16=True,
report_to="none",
seed=42
)
trainer = Trainer(
model=model, args=args,
train_dataset=tokenized["train"],
eval_dataset=tokenized["test"],
compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)]
)
trainer.train()
# Risultati attesi su AG News test:
# Accuracy: ~94-95%, F1 macro: ~94-95%
# Inferenza production-ready
from transformers import pipeline
clf_pipeline = pipeline(
"text-classification",
model=model,
tokenizer=tokenizer,
device=0 if torch.cuda.is_available() else -1
)
texts = [
"European Central Bank raises interest rates by 0.5 percentage points",
"Juventus wins Champions League final against Real Madrid"
]
predictions = clf_pipeline(texts)
for text, pred in zip(texts, predictions):
print(f" '{text[:50]}...' -> {pred['label']} ({pred['score']:.3f})")
3. Klasyfikacja wieloetykietowa
W przypadku wielu etykiet każdy przykład może mieć zero, jedną lub więcej aktywnych etykiet. Zasadnicza zmiana w porównaniu z wieloklasowością dotyczy warstwy wyjściowej (esigmoid zamiast softmax) oraz w funkcji straty (BCEWithLogitsLoss zamiast CrossEntropyLoss). Każdą klasę traktuje się jako niezależny problem binarny.
3.1 Przygotowanie zbioru danych z wieloma etykietami
from datasets import Dataset
from transformers import AutoTokenizer
import torch
import numpy as np
# Dataset multi-label: articoli di news con tag multipli
data = {
"text": [
"La BCE alza i tassi di interesse per combattere l'inflazione europea",
"La Juventus batte il Milan 2-1 in una partita emozionante al Bernabeu",
"Apple presenta il nuovo iPhone con chip M4 e AI generativa avanzata",
"Il governo italiano approva la nuova legge fiscale tra polemiche politiche",
"La crisi climatica colpisce le economie dei paesi in via di sviluppo",
"Tesla annuncia nuovo stabilimento in Italia con 2000 posti di lavoro",
"La Commissione Europea propone nuove regole sull'intelligenza artificiale",
],
# Label: [economia, politica, sport, tecnologia, ambiente, italia]
"labels": [
[1, 0, 0, 0, 0, 0], # solo economia
[0, 0, 1, 0, 0, 0], # solo sport
[0, 0, 0, 1, 0, 0], # solo tecnologia
[1, 1, 0, 0, 0, 1], # economia + politica + italia
[1, 0, 0, 0, 1, 0], # economia + ambiente
[1, 0, 0, 1, 0, 1], # economia + tecnologia + italia
[1, 1, 0, 1, 0, 0], # economia + politica + tecnologia
]
}
LABELS = ["economia", "politica", "sport", "tecnologia", "ambiente", "italia"]
NUM_LABELS = len(LABELS)
tokenizer = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")
def tokenize_multilabel(examples):
encoding = tokenizer(
examples["text"],
truncation=True,
padding="max_length",
max_length=128
)
# Converti labels in float (richiesto da BCEWithLogitsLoss)
encoding["labels"] = [
[float(l) for l in label_list]
for label_list in examples["labels"]
]
return encoding
dataset = Dataset.from_dict(data)
tokenized = dataset.map(tokenize_multilabel, batched=True, remove_columns=["text"])
tokenized.set_format("torch", columns=["input_ids", "attention_mask", "token_type_ids"])
# Analisi della distribuzione delle label
import pandas as pd
labels_df = pd.DataFrame(data["labels"], columns=LABELS)
print("\nDistribuzione label:")
for col in LABELS:
count = labels_df[col].sum()
print(f" {col}: {count}/{len(labels_df)} esempi ({100*count/len(labels_df):.0f}%)")
3.2 Model z wieloma etykietami i stratą niestandardową
from transformers import AutoModelForSequenceClassification, Trainer, TrainingArguments
from torch import nn
import torch
import numpy as np
# =========================================
# Trainer con BCEWithLogitsLoss standard
# =========================================
class MultiLabelTrainer(Trainer):
"""Trainer personalizzato per multi-label con BCEWithLogitsLoss."""
def compute_loss(self, model, inputs, return_outputs=False):
labels = inputs.pop("labels")
outputs = model(**inputs)
logits = outputs.logits
# BCEWithLogitsLoss per multi-label
# Combina sigmoid + BCE in un'unica operazione numericamente stabile
loss_fct = nn.BCEWithLogitsLoss()
loss = loss_fct(logits.float(), labels.float().to(logits.device))
return (loss, outputs) if return_outputs else loss
# =========================================
# Focal Loss per dataset sbilanciati
# =========================================
class FocalLossMultiLabelTrainer(Trainer):
"""
Trainer con Focal Loss per gestire dataset multi-label sbilanciati.
Focal Loss riduce il peso degli esempi facili (ben classificati)
e aumenta l'attenzione sugli esempi difficili.
FL(p_t) = -alpha_t * (1 - p_t)^gamma * log(p_t)
gamma=2 e il valore standard (Lin et al. 2017)
"""
def __init__(self, *args, focal_gamma: float = 2.0, focal_alpha: float = 0.25, **kwargs):
super().__init__(*args, **kwargs)
self.focal_gamma = focal_gamma
self.focal_alpha = focal_alpha
def compute_loss(self, model, inputs, return_outputs=False):
labels = inputs.pop("labels")
outputs = model(**inputs)
logits = outputs.logits
# Focal Loss implementation
probs = torch.sigmoid(logits.float())
labels_float = labels.float().to(logits.device)
# Standard BCE term
bce_loss = nn.functional.binary_cross_entropy_with_logits(
logits.float(),
labels_float,
reduction='none'
)
# Focal modulation
pt = probs * labels_float + (1 - probs) * (1 - labels_float)
focal_weight = (1 - pt) ** self.focal_gamma
# Alpha balancing (peso diverso per classe positiva vs negativa)
alpha_t = self.focal_alpha * labels_float + (1 - self.focal_alpha) * (1 - labels_float)
focal_loss = (alpha_t * focal_weight * bce_loss).mean()
return (focal_loss, outputs) if return_outputs else focal_loss
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-multilingual-cased",
num_labels=NUM_LABELS,
problem_type="multi_label_classification"
)
# Metriche multi-label
def compute_multilabel_metrics(eval_pred):
logits, labels = eval_pred
probs = torch.sigmoid(torch.tensor(logits)).numpy()
predictions = (probs >= 0.5).astype(int)
# Hamming Loss: percentuale di label sbagliate (lower is better)
hamming = np.mean(predictions != labels)
# Subset accuracy: % di esempi con TUTTE le label corrette
exact_match = np.mean(np.all(predictions == labels, axis=1))
from sklearn.metrics import f1_score
micro_f1 = f1_score(labels, predictions, average='micro', zero_division=0)
macro_f1 = f1_score(labels, predictions, average='macro', zero_division=0)
return {
"hamming_loss": hamming,
"subset_accuracy": exact_match,
"micro_f1": micro_f1,
"macro_f1": macro_f1
}
args = TrainingArguments(
output_dir="./results/bert-multilabel",
num_train_epochs=5,
per_device_train_batch_size=16,
learning_rate=2e-5,
warmup_ratio=0.1,
fp16=True,
report_to="none"
)
# Usa FocalLoss per dataset sbilanciati, MultiLabelTrainer per bilanciati
trainer = FocalLossMultiLabelTrainer(
model=model, args=args,
train_dataset=tokenized,
compute_metrics=compute_multilabel_metrics,
focal_gamma=2.0,
focal_alpha=0.25
)
trainer.train()
3.3 Optymalizacja progów dla etykiety
Domyślny próg (0,5) nie zawsze jest optymalny dla wszystkich etykiet w przypadku wielu etykiet. Istnieje możliwość optymalizacji progu dla każdej etykiety osobno, aby zmaksymalizować wartość F1. Jest to szczególnie ważne w przypadku niezrównoważonych zbiorów danych.
from sklearn.metrics import f1_score
import numpy as np
import torch
def find_optimal_thresholds(logits: np.ndarray, true_labels: np.ndarray,
thresholds=None, label_names=None) -> np.ndarray:
"""
Trova il threshold ottimale per ogni label che massimizza l'F1.
Usa il validation set per la ricerca del threshold.
"""
if thresholds is None:
thresholds = np.arange(0.05, 0.95, 0.05)
probs = 1 / (1 + np.exp(-logits)) # sigmoid
n_labels = logits.shape[1]
optimal_thresholds = np.zeros(n_labels)
print("Ricerca threshold ottimale per label:")
for label_idx in range(n_labels):
best_f1 = 0
best_threshold = 0.5
for threshold in thresholds:
preds = (probs[:, label_idx] >= threshold).astype(int)
f1 = f1_score(true_labels[:, label_idx], preds, zero_division=0)
if f1 > best_f1:
best_f1 = f1
best_threshold = threshold
optimal_thresholds[label_idx] = best_threshold
label_name = label_names[label_idx] if label_names else f"label_{label_idx}"
support = true_labels[:, label_idx].sum()
print(f" {label_name:<15s}: threshold={best_threshold:.2f}, F1={best_f1:.4f} (n={support})")
return optimal_thresholds
# Funzione di predict con thresholds personalizzati
def predict_multilabel(
texts: list,
model,
tokenizer,
thresholds: np.ndarray,
label_names: list,
batch_size: int = 32
) -> list:
"""Predizione multi-label con thresholds per-label ottimizzati."""
model.eval()
all_results = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i+batch_size]
inputs = tokenizer(batch, return_tensors='pt', truncation=True,
padding=True, max_length=128)
with torch.no_grad():
logits = model(**inputs).logits
probs = torch.sigmoid(logits).numpy()
for sample_probs in probs:
sample_results = []
for j, (prob, threshold, label) in enumerate(
zip(sample_probs, thresholds, label_names)):
if prob >= threshold:
sample_results.append({"label": label, "probability": float(prob)})
all_results.append(sorted(sample_results, key=lambda x: x["probability"], reverse=True))
return all_results
# Esempio di utilizzo
# val_logits, val_labels = get_val_predictions(trainer, val_dataset)
# thresholds = find_optimal_thresholds(val_logits, val_labels, label_names=LABELS)
# predictions = predict_multilabel(texts, model, tokenizer, thresholds, LABELS)
4. Klasyfikacja zerowego strzału
La klasyfikacja zero-shot umożliwia klasyfikowanie tekstów w kategorie jakich modelka nigdy nie widziała na treningu. Modele dźwigni, na których się uczono Wnioskowanie w języku naturalnym (NLI): mając tekst i hipotezę, model przewiduje, czy hipoteza jest prawdziwa (wymaganie), fałszywa (sprzeczność) lub niepewny (neutralny).
Proces: tekst jest używany jako „przesłanka”, kategoria jako „hipoteza” (np. „Ten tekst dotyczy ekonomii”). Wynik wynikający wskazuje w jakim stopniu tekst należy do tej kategorii.
from transformers import pipeline
# Modelli NLI consigliati per zero-shot:
# - facebook/bart-large-mnli (inglese, ottimo per EN)
# - cross-encoder/nli-deberta-v3-large (più accurato, EN)
# - MoritzLaurer/mDeBERTa-v3-base-mnli-xnli (multilingua, include IT)
# - joeddav/xlm-roberta-large-xnli (multilingua alternativo)
# Zero-shot per italiano (multilingue)
classifier_it = pipeline(
"zero-shot-classification",
model="MoritzLaurer/mDeBERTa-v3-base-mnli-xnli",
device=0 # usa GPU se disponibile
)
# Classificazione di un articolo italiano
text_it = "La BCE ha alzato i tassi di interesse di 25 punti base nella riunione di ottobre."
categories_it = ["economia", "politica", "sport", "tecnologia", "ambiente"]
result = classifier_it(
text_it,
candidate_labels=categories_it,
multi_label=False # True per multi-label simultaneo
)
print("Classificazione testo IT:")
for label, score in zip(result['labels'][:3], result['scores'][:3]):
print(f" {label}: {score:.3f}")
# Zero-shot multi-label
text_multi = "Tesla investe 2 miliardi in pannelli solari riducendo le emissioni CO2."
result_multi = classifier_it(
text_multi,
candidate_labels=categories_it,
multi_label=True
)
print("\nClassificazione multi-label:")
for label, score in zip(result_multi['labels'], result_multi['scores']):
if score > 0.3:
print(f" {label}: {score:.3f}")
# =========================================
# Template personalizzati per migliori risultati
# =========================================
classifier_en = pipeline("zero-shot-classification", model="facebook/bart-large-mnli")
# Default: "This example is {label}."
# Personalizzato: più descrittivo e preciso
text = "The Federal Reserve raised interest rates by 50 basis points."
# Confronto template
templates = {
"default": "This example is {}.",
"topic_specific": "This news article is about {}.",
"domain_specific": "This text is related to {} matters.",
}
for template_name, template in templates.items():
result = classifier_en(
text,
candidate_labels=["economics", "politics", "sports"],
hypothesis_template=template
)
print(f"\nTemplate '{template_name}': top={result['labels'][0]} ({result['scores'][0]:.3f})")
# Template per dominio legale
legal_text = "The court ruled in favor of the plaintiff in the patent infringement case."
legal_result = classifier_en(
legal_text,
candidate_labels=["intellectual property", "criminal law", "employment law"],
hypothesis_template="This legal document concerns {} law."
)
print(f"\nDominio legale: {legal_result['labels'][0]} ({legal_result['scores'][0]:.3f})")
5. Klasyfikacja kilku strzałów za pomocą SetFit
SetFit (dostrajanie transformatora zdań) i technikę, która na to pozwala wytrenować dokładne klasyfikatory z bardzo małą liczbą przykładów na klasę (8-16 przykładów). Pomysł jest prosty: najpierw naucz transformator zdań rozpoznawać pary podobne/różne przy użyciu zbioru danych składającego się z kilku strzałów, a następnie wytrenuj prostą głowę klasyfikacji logistycznej powstałych osadzań.
SetFit przewyższa standardowe dostrajanie i kilka strzałów GPT-3 w wielu testach porównawczych z 8 przykładami na klasę, przy użyciu znacznie mniejszego modelu.
# pip install setfit
from setfit import SetFitModel, Trainer as SetFitTrainer, TrainingArguments as SetFitArgs
from datasets import Dataset, DatasetDict
import pandas as pd
# Dataset italiano few-shot: solo 8 esempi per classe
train_data = {
"text": [
# Economia (8 esempi)
"I tassi di interesse BCE aumentati dello 0.5%",
"Il PIL italiano cresce dell'1.2% nel terzo trimestre",
"La Borsa di Milano chiude in rialzo del 2.3%",
"L'inflazione scende al 2.8% grazie al calo dei prezzi",
"Fiat annuncia 3000 nuove assunzioni in Piemonte",
"Il deficit italiano supera il 3% del PIL europeo",
"Le esportazioni crescono verso i mercati emergenti",
"L'euro si rafforza rispetto al dollaro americano",
# Sport (8 esempi)
"La Juventus conquista la Coppa Italia ai rigori",
"Jannik Sinner vince il titolo ATP Finals a Torino",
"Ferrari ottiene la pole position a Silverstone",
"La nazionale azzurra batte la Germania 3-1",
"Il Milan acquista un attaccante per 80 milioni",
"La Roma pareggia 2-2 con l'Inter nel posticipo",
"Gianmarco Tamberi difende il titolo europeo nel salto in alto",
"La pallavolo italiana vince il Mondiale femminile",
# Tecnologia (8 esempi)
"OpenAI lancia il nuovo modello GPT con capacità multimodali",
"Apple presenta la nuova serie iPhone con chip 4nm",
"Google acquisisce una startup di intelligenza artificiale",
"Tesla aumenta la produzione di veicoli elettrici del 40%",
"Meta introduce nuovi filtri di privacy per gli utenti",
"Samsung annuncia chip con tecnologia 2nm nel 2025",
"Microsoft integra Copilot AI in Windows 12",
"Il 5G copre ora il 70% della popolazione italiana",
],
"label": [
0, 0, 0, 0, 0, 0, 0, 0, # economia = 0
1, 1, 1, 1, 1, 1, 1, 1, # sport = 1
2, 2, 2, 2, 2, 2, 2, 2 # tecnologia = 2
]
}
# Dataset di test più grande
test_data = {
"text": [
"La BCE mantiene invariati i tassi al 4.5%",
"L'Inter batte il Liverpool in Champions League",
"NVIDIA supera i 2 trilioni di capitalizzazione",
"Il governo italiano approva il Piano Mattei per l'Africa",
],
"label": [0, 1, 2, 0]
}
train_dataset = Dataset.from_dict(train_data)
test_dataset = Dataset.from_dict(test_data)
# Carica modello SetFit multilingue
model = SetFitModel.from_pretrained(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
labels=["economia", "sport", "tecnologia"]
)
# Training con SetFit
args = SetFitArgs(
batch_size=16,
num_epochs=1, # epoche per la testa di classificazione
num_iterations=20, # numero di coppie contrastive generate
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
report_to="none",
)
trainer = SetFitTrainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=test_dataset,
metric="accuracy"
)
trainer.train()
# Inferenza
texts = [
"Il governo ha approvato la manovra finanziaria 2025",
"L'Inter ha battuto il Barcellona 3-1 in Champions League",
"OpenAI presenta il nuovo modello reasoning o3-pro"
]
predictions = model.predict(texts)
scores = model.predict_proba(texts)
print("\nPredizioni SetFit:")
for text, pred, prob in zip(texts, predictions, scores):
label_names = ["economia", "sport", "tecnologia"]
print(f" '{text[:45]}...' -> {label_names[pred]} ({max(prob):.3f})")
6. Klasyfikacja hierarchiczna
W wielu rzeczywistych scenariuszach kategorie są zorganizowane w hierarchie. Artykuł można sklasyfikować jako „Technologia > AI > NLP”. Istnieją dwa główne podejścia: płaski (ignoruj hierarchię) e hierarchiczny (wykorzystaj konstrukcję).
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
from typing import Dict, List, Tuple
# Esempio di gerarchia di categorie
HIERARCHY = {
"Economia": ["Mercati Finanziari", "Macroeconomia", "Commercio", "Lavoro"],
"Politica": ["Politica Nazionale", "Politica Estera", "Elezioni", "Legislazione"],
"Sport": ["Calcio", "Tennis", "Formula 1", "Atletica"],
"Tecnologia": ["AI", "Smartphone", "Cloud", "Cybersecurity"]
}
class HierarchicalClassifier:
"""
Classificatore gerarchico top-down.
Step 1: classifica nella categoria di primo livello
Step 2: classifica nella sottocategoria (secondo livello)
"""
def __init__(self, coarse_model_path: str, fine_models: Dict[str, str]):
"""
coarse_model: modella categorie di alto livello (Economia, Politica, ...)
fine_models: dict {coarse_category -> fine_model_path}
"""
self.tokenizer = AutoTokenizer.from_pretrained(coarse_model_path)
self.coarse_model = AutoModelForSequenceClassification.from_pretrained(coarse_model_path)
self.coarse_model.eval()
self.fine_models = {}
for cat, path in fine_models.items():
m = AutoModelForSequenceClassification.from_pretrained(path)
m.eval()
self.fine_models[cat] = m
def predict(self, text: str, return_scores: bool = False) -> Tuple[str, str, float]:
"""
Classifica in modo gerarchico.
Returns: (coarse_label, fine_label, confidence)
"""
inputs = self.tokenizer(text, return_tensors='pt', truncation=True, max_length=256)
# Step 1: classificazione coarse
with torch.no_grad():
coarse_logits = self.coarse_model(**inputs).logits
coarse_probs = torch.softmax(coarse_logits, dim=-1)[0]
coarse_id = coarse_probs.argmax().item()
coarse_label = self.coarse_model.config.id2label[coarse_id]
coarse_score = coarse_probs[coarse_id].item()
# Step 2: classificazione fine (se disponibile per questa categoria)
fine_label = None
fine_score = None
if coarse_label in self.fine_models:
with torch.no_grad():
fine_logits = self.fine_models[coarse_label](**inputs).logits
fine_probs = torch.softmax(fine_logits, dim=-1)[0]
fine_id = fine_probs.argmax().item()
fine_label = self.fine_models[coarse_label].config.id2label[fine_id]
fine_score = fine_probs[fine_id].item()
return {
"coarse": coarse_label,
"fine": fine_label,
"coarse_confidence": coarse_score,
"fine_confidence": fine_score,
"full_path": f"{coarse_label} > {fine_label}" if fine_label else coarse_label
}
print("HierarchicalClassifier definito!")
7. Kompleksowe wskaźniki dla wielu etykiet
from sklearn.metrics import (
f1_score, precision_score, recall_score,
hamming_loss, accuracy_score, average_precision_score
)
import numpy as np
def multilabel_evaluation_report(y_true: np.ndarray, y_pred: np.ndarray,
y_proba: np.ndarray, label_names: list) -> dict:
"""Report completo per classificazione multi-label."""
print("=" * 65)
print("MULTI-LABEL CLASSIFICATION REPORT")
print("=" * 65)
# Metriche globali
hl = hamming_loss(y_true, y_pred)
sa = accuracy_score(y_true, y_pred) # subset accuracy
micro_f1 = f1_score(y_true, y_pred, average='micro', zero_division=0)
macro_f1 = f1_score(y_true, y_pred, average='macro', zero_division=0)
print(f"\n{'Hamming Loss':<25s}: {hl:.4f} (lower is better)")
print(f"{'Subset Accuracy':<25s}: {sa:.4f} (all labels must match)")
print(f"{'Micro F1':<25s}: {micro_f1:.4f} (label-weighted)")
print(f"{'Macro F1':<25s}: {macro_f1:.4f} (unweighted)")
print(f"{'Weighted F1':<25s}: {f1_score(y_true, y_pred, average='weighted', zero_division=0):.4f}")
# AUC per label (richiede probabilità, non predizioni binarie)
if y_proba is not None:
try:
macro_auc = average_precision_score(y_true, y_proba, average='macro')
print(f"{'Macro AP (AUC)':<25s}: {macro_auc:.4f}")
except Exception:
pass
# Per ogni label
print("\nPer-label metrics:")
header = f"{'Label':<18s} {'Precision':>10s} {'Recall':>10s} {'F1':>8s} {'Support':>10s}"
print(header)
print("-" * 65)
for i, label in enumerate(label_names):
prec = precision_score(y_true[:, i], y_pred[:, i], zero_division=0)
rec = recall_score(y_true[:, i], y_pred[:, i], zero_division=0)
f1 = f1_score(y_true[:, i], y_pred[:, i], zero_division=0)
support = int(y_true[:, i].sum())
print(f"{label:<18s} {prec:>10.4f} {rec:>10.4f} {f1:>8.4f} {support:>10d}")
return {
"hamming_loss": hl, "subset_accuracy": sa,
"micro_f1": micro_f1, "macro_f1": macro_f1
}
8. Rurociąg klasyfikacji gotowy do produkcji
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
import numpy as np
from functools import lru_cache
from typing import Union, List, Dict
import time
class ProductionClassifier:
"""
Classificatore production-ready con:
- Caching degli input tokenizzati
- Batch inference per efficienza
- Supporto multi-class e multi-label
- Monitoraggio latenza e confidenza
"""
def __init__(
self,
model_path: str,
task: str = "multi_class", # "multi_class" o "multi_label"
thresholds: np.ndarray = None,
max_length: int = 128,
batch_size: int = 32
):
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
self.model = AutoModelForSequenceClassification.from_pretrained(model_path)
self.model.to(self.device)
self.model.eval()
self.task = task
self.label_names = list(self.model.config.id2label.values())
self.thresholds = thresholds if thresholds is not None else np.full(len(self.label_names), 0.5)
self.max_length = max_length
self.batch_size = batch_size
self._latencies = []
@torch.no_grad()
def predict(self, texts: Union[str, List[str]]) -> List[Dict]:
"""Predizione con monitoraggio latenza."""
if isinstance(texts, str):
texts = [texts]
start = time.perf_counter()
all_results = []
for i in range(0, len(texts), self.batch_size):
batch = texts[i:i+self.batch_size]
inputs = self.tokenizer(
batch,
return_tensors='pt',
truncation=True,
padding=True,
max_length=self.max_length
).to(self.device)
outputs = self.model(**inputs)
logits = outputs.logits.cpu().numpy()
if self.task == "multi_class":
probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True)
for p in probs:
pred_id = p.argmax()
all_results.append({
"label": self.label_names[pred_id],
"score": float(p[pred_id]),
"all_scores": {name: float(score) for name, score in zip(self.label_names, p)}
})
else: # multi_label
probs = 1 / (1 + np.exp(-logits))
for p in probs:
labels = [
{"label": name, "score": float(score)}
for name, score, thr in zip(self.label_names, p, self.thresholds)
if score >= thr
]
all_results.append({"labels": sorted(labels, key=lambda x: -x["score"])})
latency_ms = (time.perf_counter() - start) * 1000
self._latencies.append(latency_ms)
return all_results
def get_stats(self) -> Dict:
"""Statistiche sulle latenze di inference."""
if not self._latencies:
return {}
return {
"avg_latency_ms": np.mean(self._latencies),
"p99_latency_ms": np.percentile(self._latencies, 99),
"total_predictions": len(self._latencies)
}
# Esempio di utilizzo
# clf = ProductionClassifier("./models/my-classifier", task="multi_label")
# results = clf.predict(["Il governo alza le tasse", "La Juve vince lo scudetto"])
# stats = clf.get_stats()
print("ProductionClassifier definito!")
9. Klasyfikacja za pomocą modeli generatywnych (podpowiadanie LLM)
Wraz z pojawieniem się LLM możliwe jest teraz przeprowadzanie klasyfikacji tekstu po prostu poprzez podpowiedzi, bez żadnego szkolenia. To podejście i szczególnie przydatne do szybkiego prototypowania i nowych lub rzadkich kategorii.
from transformers import pipeline
import json
# =========================================
# Classificazione con LLM tramite prompting
# =========================================
# Approccio 1: Con un modello instruction-following (es. Mistral-7B-Instruct)
def classify_with_llm(text: str, categories: list, model_pipeline) -> dict:
"""
Classificazione zero-shot con LLM instruction-following.
Il modello non richiede fine-tuning: usa la comprensione del linguaggio naturale.
"""
categories_str = ", ".join(categories)
prompt = f"""Classifica il seguente testo in UNA delle categorie: {categories_str}.
Testo: "{text}"
Rispondi SOLO con il nome della categoria, senza spiegazioni.
Categoria:"""
response = model_pipeline(
prompt,
max_new_tokens=20,
temperature=0.0, # deterministic
do_sample=False
)[0]['generated_text']
# Estrai la categoria dalla risposta
answer = response[len(prompt):].strip().split('\n')[0].strip()
# Valida che la risposta sia una categoria valida
for cat in categories:
if cat.lower() in answer.lower():
return {"label": cat, "method": "llm", "raw_answer": answer}
return {"label": "sconosciuto", "method": "llm", "raw_answer": answer}
# Approccio 2: Prompt con esempi (few-shot)
def classify_with_fewshot(text: str, categories: list, examples: list, model_pipeline) -> dict:
"""
Classificazione few-shot: fornisce esempi nel prompt per guidare il modello.
"""
examples_str = ""
for ex in examples[:3]: # massimo 3 esempi per non superare il context window
examples_str += f'Testo: "{ex["text"]}"\nCategoria: {ex["label"]}\n\n'
prompt = f"""Classifica testi nelle categorie: {", ".join(categories)}.
Esempi:
{examples_str}Testo: "{text}"
Categoria:"""
response = model_pipeline(
prompt, max_new_tokens=15, temperature=0.0, do_sample=False
)[0]['generated_text']
answer = response[len(prompt):].strip().split('\n')[0].strip()
return {"label": answer, "method": "few-shot-llm"}
# =========================================
# Confronto: zero-shot NLI vs LLM prompting vs BERT fine-tuned
# =========================================
comparison_table = [
{"method": "BERT fine-tuned", "F1": "0.95+", "speed": "veloce", "dati": "1000+ esempi", "costo": "basso"},
{"method": "SetFit (few-shot)", "F1": "0.85+", "speed": "veloce", "dati": "8-64 esempi", "costo": "basso"},
{"method": "Zero-shot NLI", "F1": "0.70+", "speed": "medio", "dati": "zero esempi", "costo": "basso"},
{"method": "LLM prompting (7B)", "F1": "0.75+", "speed": "lento", "dati": "zero esempi", "costo": "medio"},
{"method": "LLM few-shot (7B)", "F1": "0.82+", "speed": "lento", "dati": "3-10 esempi", "costo": "medio"},
{"method": "GPT-4 prompting (API)", "F1": "0.88+", "speed": "molto lento", "dati": "zero esempi", "costo": "alto"},
]
print("=== Confronto Metodi di Classificazione ===")
print(f"{'Metodo':<30s} {'F1':<10s} {'Velocita':<15s} {'Dati Richiesti':<18s} {'Costo'}")
print("-" * 85)
for row in comparison_table:
print(f"{row['method']:<30s} {row['F1']:<10s} {row['speed']:<15s} {row['dati']:<18s} {row['costo']}")
print("\nRaccomandazione: inizia con zero-shot NLI per validare il task,")
print("poi fine-tuna BERT se hai dati, oppure usa SetFit con pochi esempi annotati.")
Anty-wzorzec: używanie dokładności jako jedynej metryki
W przypadku niezrównoważonych zbiorów danych (np. 95% negatywnych, 5% pozytywnych) model, który przewiduje zawsze „negatywny” zapewnia 95% dokładności, ale jest bezużyteczny. Zawsze używaj klawisza F1, precyzji i przypominania w przypadku klasyfikacji binarnej i wieloklasowej. W przypadku wielu etykiet użyj straty Hamminga i mikro/makro F1. Nigdy nie ignoruj rozkładu klas w zbiorze danych.
Przewodnik po wyborze podejścia
| Scenariusz | Zalecane podejście | Czas konfiguracji |
|---|---|---|
| Stałe kategorie, dużo danych (>5 tys.) | Standardowe dostrajanie BERT | Godziny |
| Naprawiono kategorie, mało danych (<100) | SetFit (kilka strzałów) | Protokół |
| Zmienne lub nowe kategorie | Zero-shot NLI + niestandardowe szablony | Natychmiastowe |
| Wieloetykietowy, zrównoważony zbiór danych | BERT + BCEWithLogitsLoss + strojenie progu | Godziny |
| Wieloetykietowy, niezrównoważony zbiór danych | Utrata ogniskowej + dostrajanie progu dla każdej etykiety | Godziny |
| Hierarchia kategorii | Odgórny klasyfikator hierarchiczny | Dni |
| Szybkie prototypowanie | Rurociąg zerowy | Towary drugiej jakości |
10. Modelowe testy porównawcze i przewodnik wyboru
Wybór odpowiedniego modelu klasyfikacji tekstu zależy od rodzaju zadania, ilość danych, wymagania dotyczące opóźnień i dostępny sprzęt. Oto praktyczny punkt odniesienia dla głównych podejść do standardowych zbiorów danych.
Klasyfikacja tekstu wzorcowego (2024–2025)
| Zadania | Zbiory danych | Model | Dokładność / F1 | Notatki |
|---|---|---|---|---|
| Binarny sentyment | SST-2 (EN) | DistilBERT dostrojony | Dok. 92,7% | 6x szybszy niż baza BERT |
| Binarny sentyment | SST-2 (EN) | RoBERTa-duży, precyzyjnie dostrojony | Dok. 96,4% | Stan techniki PL |
| Wieloklasowy (6 klas) | Wiadomości AG | Dostrojona baza BERT | Dok. 94,8% | Standardowy test porównawczy wiadomości |
| Wieloetykietowy (90 kat.) | Reuters-21578 | RoBERTa + BCEStrata | Mikro-F1 89,2% | 90 kategorii Reutersa |
| Zerowy strzał | Odpowiedzi Yahoo | BART-duży-MNLI | Dok. 70,3% | Brak danych treningowych |
| Kilka strzałów (8 przykładów) | SST-2 (EN) | SetFit (MiniLM) | Dok. 88,1% | Odnotowano tylko 8 przykładów |
| Włoski sentyment | SENTIPOLC 2016 | dbmdz BERT dostrojony | F1 91,3% | Najlepszy włoski model |
Wnioski i dalsze kroki
Współczesna klasyfikacja tekstu wykracza daleko poza klasyfikację binarną. Zero-shot, kilku-shot i multi-label to realne scenariusze wymagające specyficznego podejścia. Dzięki narzędziom opisanym w tym artykule — od SetFit dla kilku zdjęć po Utratę ogniskową dla niezrównoważone zbiory danych, od zerowego NLI po klasyfikatory hierarchiczne — znasz podstawy aby uwzględnić dowolny scenariusz klasyfikacji tekstu w środowisku produkcyjnym.
Kontynuacja serii Modern NLP
- Poprzedni: Rozpoznawanie nazwanych podmiotów — ekstrakcja jednostek za pomocą BERT
- Następny: Transformatory HuggingFace: kompletny przewodnik — Trener ekosystemów i API
- Artykuł 8: Dostrajanie LLM lokalnie — LoRA i QLoRA na konsumenckich procesorach graficznych
- Artykuł 9: Podobieństwo semantyczne — osadzanie zdań i FAISS do wyszukiwania
- Artykuł 10: Monitorowanie NLP — wykrywanie dryfu i automatyczne przekwalifikowanie
- Powiązane serie: Inżynieria AI/RAG — klasyfikacja zero-shot jako routing w RAG
- Powiązane serie: Zaawansowane głębokie uczenie się — zaawansowane architektury klasyfikacyjne







