BERT Spiegato: Architettura, Pretraining e Fine-tuning
Il 2018 ha segnato un punto di svolta nella storia del Natural Language Processing. Con la pubblicazione di BERT (Bidirectional Encoder Representations from Transformers), il team di Google AI ha introdotto un modello che ha ridefinito lo stato dell'arte su 11 benchmark NLP contemporaneamente. Per la prima volta, un singolo modello pre-addestrato poteva essere adattato a task diversissimi tra loro (classificazione, question answering, NER) ottenendo risultati superiori a tutti i sistemi specializzati precedenti.
Ma cosa rende BERT cosi rivoluzionario? La risposta sta in tre innovazioni fondamentali: la bidirezionalita profonda, il pre-training su larga scala e la semplicità del fine-tuning per i task downstream. In questo articolo analizzeremo ogni aspetto dell'architettura BERT, dai meccanismi interni dell'attention fino all'implementazione pratica con HuggingFace, passando per le varianti che ne sono derivate.
Questo e il secondo articolo della serie NLP Moderno: da BERT ai LLM. Se non hai ancora letto il primo articolo sui fondamenti (tokenizzazione, embeddings e pipeline NLP), ti consiglio di farlo prima di procedere: molti dei concetti qui trattati si costruiscono su quelle basi.
Cosa Imparerai
- perchè BERT ha rappresentato una rivoluzione nell'NLP e i limiti dei modelli precedenti
- L'architettura encoder-only del Transformer alla base di BERT
- Come funziona la self-attention multi-head e le formule matematiche
- Input representation: token, segment e position embeddings
- Le due strategie di pre-training: Masked Language Model (MLM) e Next Sentence Prediction (NSP)
- La tokenizzazione WordPiece e i token speciali [CLS], [SEP], [MASK]
- Come fare fine-tuning per classificazione, NER, question answering
- Implementazione pratica completa con HuggingFace Transformers
- Varianti di BERT: RoBERTa, ALBERT, DistilBERT, DeBERTa, ELECTRA
- BERT per la lingua italiana: modelli disponibili e confronto
- Limiti di BERT e come i modelli successivi li hanno superati
Panoramica della Serie
| # | Articolo | Focus |
|---|---|---|
| 1 | Fondamenti NLP | Tokenizzazione, Embeddings, Pipeline |
| 2 | Sei qui - BERT e Transformer | Architettura Attention, Pre-training |
| 3 | Sentiment Analysis | Classificazione del testo con BERT |
| 4 | Named Entity Recognition | Estrazione di entità dal testo |
| 5 | HuggingFace Transformers | Libreria e modelli pre-addestrati |
| 6 | Fine-Tuning di Modelli | Adattare BERT al tuo dominio |
| 7 | NLP per l'Italiano | Modelli e risorse per la lingua italiana |
| 8 | Da BERT ai LLM | GPT, LLaMA e la generazione di testo |
1. perchè BERT ha Rivoluzionato l'NLP
Per comprendere l'impatto di BERT, dobbiamo tornare al panorama NLP pre-2018 e capire quali problemi fondamentali i modelli precedenti non riuscivano a risolvere.
1.1 I Limiti di Word2Vec e GloVe
Come abbiamo visto nel primo articolo della serie, Word2Vec (2013) e GloVe (2014) sono stati una svolta importante: per la prima volta, le parole venivano rappresentate come vettori densi in uno spazio continuo, dove relazioni semantiche come "re - uomo + donna = regina" emergevano naturalmente dalla geometria dello spazio.
Tuttavia, questi modelli soffrono di un limite fondamentale: producono rappresentazioni statiche. Ogni parola ha un unico vettore, indipendentemente dal contesto in cui appare. Consideriamo la parola "banco":
Il Problema delle Rappresentazioni Statiche
- "Ho depositato i soldi in banco" (istituto bancario)
- "Il banco di scuola era troppo piccolo" (mobile)
- "Un banco di nebbia copriva la valle" (massa di nebbia)
- "Il banco di pesci era enorme" (gruppo di pesci)
Con Word2Vec, la parola "banco" ha un unico vettore che e una media confusa di tutti questi significati. Il modello non ha modo di distinguere il contesto.
1.2 ELMo: Il Primo Passo Verso il Contesto
Nel 2018, prima di BERT, ELMo (Embeddings from Language Models) di AllenAI aveva già tentato di risolvere il problema dei contesti. ELMo utilizzava una rete biLSTM (bidirectional Long Short-Term Memory) pre-addestrata su un compito di language modeling per produrre embeddings contestuali.
Il problema di ELMo era duplice: primo, la bidirezionalita era superficiale, nel senso che due LSTM (una forward, una backward) venivano concatenate, ma non interagivano durante il processing. Secondo, le LSTM soffrono di un information bottleneck: le dipendenze a lungo raggio vengono progressivamente "dimenticate" man mano che la sequenza si allunga.
1.3 La Svolta: Bidirezionalita Profonda
BERT risolve entrambi i problemi grazie al meccanismo di self-attention del Transformer. Ogni token in una sequenza può "guardare" tutti gli altri token simultaneamente, sia quelli a sinistra che quelli a destra. Questa bidirezionalita non e la concatenazione di due modelli separati (come in ELMo), ma un'interazione profonda che avviene a ogni singolo layer dell'architettura.
Confronto: Approcci alla Bidirezionalita
| Modello | Tipo | Contesto | Limitazione |
|---|---|---|---|
| Word2Vec/GloVe | Statico | Nessun contesto | Un vettore per parola |
| GPT-1 | Unidirezionale (sx -> dx) | Solo contesto precedente | Non vede il futuro |
| ELMo | Bidirezionale superficiale | Contesto concatenato | Nessuna interazione tra direzioni |
| BERT | Bidirezionale profondo | Contesto completo a ogni layer | Solo encoder (no generazione) |
Questa bidirezionalita profonda e il motivo per cui BERT e stato in grado di stabilire nuovi record su 11 benchmark NLP al momento della sua pubblicazione, tra cui GLUE, SQuAD 1.1, SQuAD 2.0 e MultiNLI.
2. Architettura BERT: L'Encoder Transformer
BERT e un modello encoder-only, cioe utilizza solo la parte encoder dell'architettura Transformer originale descritta nel paper "Attention Is All You Need" (2017). Vediamo ogni componente nel dettaglio.
2.1 Visione d'Insieme dell'Architettura
L'architettura di BERT può essere visualizzata come una serie di blocchi Transformer impilati verticalmente. Ogni blocco contiene un layer di multi-head self-attention seguito da un feed-forward network, con layer normalization e connessioni residue.
Input: [CLS] Il gatto si siede sul tappeto [SEP]
| | | | | | |
+-----+-------+-----+----+-----+----+-------+-----+
| Token Embeddings |
| + Segment Embeddings |
| + Position Embeddings |
+-----+-------+-----+----+-----+----+-------+-----+
| | | | | | |
+---------------------------------------------------------+
| Transformer Encoder Block 1 |
| +---------------------------------------------------+ |
| | Multi-Head Self-Attention | |
| | Q = XWq K = XWk V = XWv | |
| | Attention(Q,K,V) = softmax(QK^T/sqrt(dk))V | |
| +---------------------------------------------------+ |
| | Add & Layer Norm | |
| +---------------------------------------------------+ |
| | Feed-Forward Network | |
| | FFN(x) = max(0, xW1 + b1)W2 + b2 | |
| +---------------------------------------------------+ |
| | Add & Layer Norm | |
+---------------------------------------------------------+
| | | | | | |
... ... ... ... ... ... ...
| | | | | | |
+---------------------------------------------------------+
| Transformer Encoder Block L (12 o 24) |
+---------------------------------------------------------+
| | | | | | |
[CLS]out T1 T2 T3 T4 T5 T6 [SEP]out
|
Pooling --> Classificazione / Output per task
2.2 BERT-base vs BERT-large
Il paper originale propone due configurazioni:
Configurazioni di BERT
| Parametro | BERT-base | BERT-large |
|---|---|---|
| Transformer Layers (L) | 12 | 24 |
| Hidden Size (H) | 768 | 1024 |
| Attention Heads (A) | 12 | 16 |
| Parametri Totali | 110M | 340M |
| Dim. per Head | 768/12 = 64 | 1024/16 = 64 |
| Feed-Forward Dim. | 3072 (4 x 768) | 4096 (4 x 1024) |
| Max Seq. Length | 512 | 512 |
| Vocabulary Size | 30,522 | 30,522 |
2.3 Input Representation: I Tre Embeddings
Una delle innovazioni di BERT e la sua rappresentazione dell'input, composta dalla somma di tre embeddings distinti:
Input: [CLS] Il gatto dorme [SEP] Il cane corre [SEP]
| | | | | | | | |
Token Emb: E[CLS] E_il E_gatto E_dorme E[SEP] E_il E_cane E_corre E[SEP]
+ + + + + + + + +
Segment Emb: EA EA EA EA EA EB EB EB EB
+ + + + + + + + +
Position Emb: E0 E1 E2 E3 E4 E5 E6 E7 E8
= = = = = = = = =
Input finale: I0 I1 I2 I3 I4 I5 I6 I7 I8
Token Embeddings: Ogni token della sequenza viene mappato a un vettore denso di dimensione H (768 per BERT-base) attraverso una matrice di embedding appresa durante il training. Il vocabolario WordPiece contiene 30,522 token.
Segment Embeddings: BERT può ricevere in input una o due frasi separate dal token [SEP]. I segment embeddings indicano a quale frase appartiene ogni token: tutti i token della prima frase ricevono l'embedding E_A, quelli della seconda E_B. Questo e fondamentale per task come la Natural Language Inference dove BERT deve ragionare sulla relazione tra due frasi.
Position Embeddings: A differenza del Transformer originale che usa funzioni sinusoidali, BERT utilizza position embeddings appresi. Ogni posizione (da 0 a 511) ha un vettore appreso durante il training, il che significa che BERT può gestire sequenze fino a un massimo di 512 token.
from transformers import BertTokenizer, BertModel
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
# Tokenizzare un input con due frasi
text_a = "Il gatto dorme"
text_b = "Il cane corre"
encoded = tokenizer(text_a, text_b, return_tensors='pt')
print("Token IDs:", encoded['input_ids'])
print("Token Type IDs (Segments):", encoded['token_type_ids'])
print("Attention Mask:", encoded['attention_mask'])
# Decodifica per vedere i token
tokens = tokenizer.convert_ids_to_tokens(encoded['input_ids'][0])
print("Tokens:", tokens)
# Output: ['[CLS]', 'il', 'gatto', 'dor', '##me', '[SEP]', 'il', 'cane', 'cor', '##re', '[SEP]']
# Accesso agli embedding layers
token_embeddings = model.embeddings.word_embeddings
position_embeddings = model.embeddings.position_embeddings
segment_embeddings = model.embeddings.token_type_embeddings
print(f"Token embedding matrix: {token_embeddings.weight.shape}")
# Output: Token embedding matrix: torch.Size([30522, 768])
print(f"Position embedding matrix: {position_embeddings.weight.shape}")
# Output: Position embedding matrix: torch.Size([512, 768])
print(f"Segment embedding matrix: {segment_embeddings.weight.shape}")
# Output: Segment embedding matrix: torch.Size([2, 768])
2.4 Multi-Head Self-Attention
Il cuore di BERT (e di ogni Transformer) e il meccanismo di self-attention. Intuitivamente, la self-attention permette a ogni token di "guardare" tutti gli altri token nella sequenza e decidere quanto ciascuno di essi sia rilevante per la propria rappresentazione.
Scaled Dot-Product Attention
Per ogni token, l'attention calcola tre vettori attraverso proiezioni lineari apprese:
- Query (Q): "Cosa sto cercando?"
- Key (K): "Cosa offro?"
- Value (V): "Quale informazione porto?"
La formula dell'attention e:
Attention(Q, K, V) = softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)VDove d_k e la dimensione delle chiavi (64 per BERT-base). Il fattore \frac{1}{\sqrt{d_k}} serve a stabilizzare i gradienti: senza questo scaling, il prodotto scalare produce valori molto grandi per dimensionalità elevate, e il softmax si "satura" producendo distribuzioni quasi one-hot.
Multi-Head Attention
Invece di calcolare una singola funzione di attention, BERT usa multi-head attention: il calcolo viene replicato h volte (12 per BERT-base, 16 per BERT-large), ciascuno con matrici di proiezione diverse. Questo permette al modello di catturare diversi tipi di relazioni simultaneamente: una head potrebbe specializzarsi nelle relazioni sintattiche, un'altra nelle coreferenze, un'altra nelle dipendenze semantiche.
MultiHead(Q, K, V) = Concat(head_1, ..., head_h)W^O head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)Dove W_i^Q \in \mathbb{R}^{d_{model} \times d_k}, W_i^K \in \mathbb{R}^{d_{model} \times d_k}, W_i^V \in \mathbb{R}^{d_{model} \times d_v} e W^O \in \mathbb{R}^{hd_v \times d_{model}}.
import torch
import torch.nn.functional as F
import math
def scaled_dot_product_attention(Q, K, V, mask=None):
"""Calcolo dell'attention scalata."""
d_k = Q.size(-1)
# Calcolo dei punteggi di attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
# Applicazione della maschera (opzionale)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
# Softmax per ottenere i pesi
attention_weights = F.softmax(scores, dim=-1)
# Output pesato
output = torch.matmul(attention_weights, V)
return output, attention_weights
class MultiHeadAttention(torch.nn.Module):
"""Multi-Head Attention come in BERT."""
def __init__(self, d_model=768, num_heads=12):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # 64 per BERT-base
self.W_q = torch.nn.Linear(d_model, d_model)
self.W_k = torch.nn.Linear(d_model, d_model)
self.W_v = torch.nn.Linear(d_model, d_model)
self.W_o = torch.nn.Linear(d_model, d_model)
def forward(self, x, mask=None):
batch_size, seq_len, _ = x.size()
# Proiezioni lineari
Q = self.W_q(x) # (batch, seq_len, d_model)
K = self.W_k(x)
V = self.W_v(x)
# Reshape per multi-head: (batch, heads, seq_len, d_k)
Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
# Attention per ogni head
attn_output, attn_weights = scaled_dot_product_attention(Q, K, V, mask)
# Concatenazione delle head
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.view(batch_size, seq_len, self.d_model)
# Proiezione finale
output = self.W_o(attn_output)
return output, attn_weights
# Test
mha = MultiHeadAttention(d_model=768, num_heads=12)
x = torch.randn(1, 10, 768) # batch=1, seq_len=10, dim=768
output, weights = mha(x)
print(f"Output shape: {output.shape}") # torch.Size([1, 10, 768])
print(f"Weights shape: {weights.shape}") # torch.Size([1, 12, 10, 10])
2.5 Feed-Forward Network
Dopo ogni layer di attention, BERT applica una rete feed-forward position-wise, cioe la stessa rete viene applicata indipendentemente a ogni posizione della sequenza:
FFN(x) = \text{GELU}(xW_1 + b_1)W_2 + b_2La dimensione intermedia e tipicamente 4 volte la dimensione nascosta (3072 per BERT-base, 4096 per BERT-large). BERT usa la funzione di attivazione GELU (Gaussian Error Linear Unit) invece della ReLU tradizionale, che fornisce una transizione più morbida e migliori performance in pratica.
2.6 Layer Normalization e Connessioni Residue
Ogni sublayer (attention e feed-forward) in BERT e avvolto da una connessione residua seguita da layer normalization:
\text{output} = \text{LayerNorm}(x + \text{Sublayer}(x))Le connessioni residue permettono ai gradienti di fluire direttamente attraverso la rete durante il backpropagation, prevenendo il problema del vanishing gradient in reti molto profonde. La layer normalization stabilizza l'allenamento normalizzando le attivazioni lungo la dimensione delle features.
import torch
import torch.nn as nn
class TransformerEncoderBlock(nn.Module):
"""Un singolo blocco encoder come in BERT."""
def __init__(self, d_model=768, num_heads=12, d_ff=3072, dropout=0.1):
super().__init__()
# Multi-Head Attention
self.attention = nn.MultiheadAttention(
embed_dim=d_model,
num_heads=num_heads,
dropout=dropout,
batch_first=True
)
# Feed-Forward Network
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout)
)
# Layer Normalization
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
# Sub-layer 1: Multi-Head Attention + Residual + LayerNorm
attn_output, _ = self.attention(x, x, x, key_padding_mask=mask)
x = self.norm1(x + self.dropout(attn_output))
# Sub-layer 2: FFN + Residual + LayerNorm
ffn_output = self.ffn(x)
x = self.norm2(x + ffn_output)
return x
class BERTEncoder(nn.Module):
"""Stack di encoder blocks come in BERT-base."""
def __init__(self, num_layers=12, d_model=768, num_heads=12, d_ff=3072):
super().__init__()
self.layers = nn.ModuleList([
TransformerEncoderBlock(d_model, num_heads, d_ff)
for _ in range(num_layers)
])
def forward(self, x, mask=None):
for layer in self.layers:
x = layer(x, mask)
return x
# Test: simula BERT-base
encoder = BERTEncoder(num_layers=12, d_model=768, num_heads=12, d_ff=3072)
x = torch.randn(2, 128, 768) # batch=2, seq_len=128
output = encoder(x)
print(f"Output: {output.shape}") # torch.Size([2, 128, 768])
# Conta parametri
total_params = sum(p.numel() for p in encoder.parameters())
print(f"Parametri encoder: {total_params:,}")
# ~85M (solo encoder, senza embeddings)
3. Pre-training: Come BERT Impara il Linguaggio
BERT viene pre-addestrato su una quantità enorme di testo non etichettato utilizzando due obiettivi di training complementari: il Masked Language Model (MLM) e il Next Sentence Prediction (NSP). L'idea fondamentale e che il modello impari rappresentazioni ricche del linguaggio da queste task auto-supervisionate, e che questa conoscenza possa poi essere trasferita a task specifici tramite fine-tuning.
Dati di Pre-training
- BooksCorpus: ~800M parole (11,000 libri non pubblicati)
- English Wikipedia: ~2,500M parole (solo testo, no tabelle/liste)
- Totale: ~3.3 miliardi di parole
- Training: 4 TPU Pod (64 chip TPU), 4 giorni per BERT-base
3.1 Masked Language Model (MLM)
Il Masked Language Model e l'innovazione principale di BERT. Il problema con il language modeling tradizionale (predire la parola successiva) e che e intrinsecamente unidirezionale: il modello può guardare solo a sinistra. Per rendere il modello bidirezionale, BERT introduce un trucco elegante: mascherare una porzione casuale dei token di input e chiedere al modello di predirli.
La Strategia 80/10/10
Per ogni sequenza di input, il 15% dei token viene selezionato per la predizione. Di questi token selezionati:
- 80% viene sostituito con il token speciale [MASK]
- 10% viene sostituito con un token casuale dal vocabolario
- 10% viene lasciato invariato
Questa strategia risolve un problema sottile: durante il fine-tuning, il token [MASK] non appare mai nell'input. Se il modello vedesse [MASK] solo durante il pre-training, ci sarebbe un mismatch tra pre-training e fine-tuning. Sostituendo il 10% con token casuali e lasciando il 10% invariato, il modello impara a produrre buone rappresentazioni per tutti i token, non solo per quelli mascherati.
import random
import torch
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
def apply_mlm_masking(tokens, tokenizer, mask_prob=0.15):
"""Applica la strategia di masking 80/10/10 di BERT."""
masked_tokens = list(tokens)
labels = [-100] * len(tokens) # -100 = ignora nella loss
for i in range(len(tokens)):
# Non mascherare token speciali
if tokens[i] in [tokenizer.cls_token_id, tokenizer.sep_token_id,
tokenizer.pad_token_id]:
continue
if random.random() < mask_prob:
labels[i] = tokens[i] # Salva il token originale come label
rand = random.random()
if rand < 0.8:
# 80%: sostituisci con [MASK]
masked_tokens[i] = tokenizer.mask_token_id
elif rand < 0.9:
# 10%: sostituisci con token casuale
masked_tokens[i] = random.randint(0, tokenizer.vocab_size - 1)
# else: 10%: lascia invariato
return masked_tokens, labels
# Esempio
text = "Il gatto si siede sul tappeto rosso"
encoded = tokenizer.encode(text)
print("Originale:", tokenizer.decode(encoded))
masked, labels = apply_mlm_masking(encoded, tokenizer)
print("Mascherato:", tokenizer.decode(masked))
print("Labels (posizioni da predire):",
[(i, tokenizer.decode([labels[i]])) for i in range(len(labels)) if labels[i] != -100])
3.2 Next Sentence Prediction (NSP)
Il secondo obiettivo di pre-training e il Next Sentence Prediction. Per ogni coppia di frasi (A, B) nell'input, il modello deve predire se B e la frase che segue effettivamente A nel testo originale (IsNext) oppure se e una frase casuale (NotNext). Il dataset e costruito in modo che il 50% delle coppie sia positivo e il 50% negativo.
Input = [CLS] Il gatto dorme [SEP] E' stanco dopo aver giocato [SEP]
Label = IsNext (frase B segue A nel testo originale)
Input = [CLS] Il gatto dorme [SEP] Roma e' la capitale d'Italia [SEP]
Label = NotNext (frase B e' casuale, non correlata ad A)
L'obiettivo NSP aiuta BERT a catturare relazioni tra frasi, utili per task come Question Answering e Natural Language Inference. Tuttavia, studi successivi (in particolare RoBERTa) hanno dimostrato che NSP potrebbe non essere necessario e potrebbe persino danneggiare le performance, come vedremo nella sezione sulle varianti.
3.3 La Loss Combinata
La loss totale di BERT durante il pre-training e la somma delle due loss:
\mathcal{L}_{total} = \mathcal{L}_{MLM} + \mathcal{L}_{NSP}Dove \mathcal{L}_{MLM} e la cross-entropy loss sui token mascherati e \mathcal{L}_{NSP} e la cross-entropy loss binaria sulla classificazione IsNext/NotNext.
4. Tokenizzazione WordPiece
BERT utilizza la tokenizzazione WordPiece, un algoritmo di tokenizzazione subword che bilancia efficienza del vocabolario e copertura linguistica. WordPiece e stato originariamente sviluppato per il sistema di traduzione automatica di Google.
4.1 Come Funziona WordPiece
L'idea alla base di WordPiece e semplice ma potente: partire da un vocabolario di singoli caratteri e iterativamente unire le coppie di token che massimizzano la verosimiglianza del corpus di training. Il processo continua finchè il vocabolario non raggiunge una dimensione target (30,522 per BERT).
Il risultato e un vocabolario che contiene:
- Parole comuni intere (es. "the", "of", "and")
- Prefissi e radici comuni (es. "un", "re", "pre")
- Suffissi e desinenze marcati con
##(es. "##ing", "##tion", "##ed") - Singoli caratteri per gestire qualsiasi parola sconosciuta
4.2 Token Speciali
Oltre ai token del vocabolario WordPiece, BERT utilizza diversi token speciali:
Token Speciali di BERT
| Token | ID | Scopo |
|---|---|---|
| [PAD] | 0 | Padding per uniformare la lunghezza delle sequenze in un batch |
| [UNK] | 100 | Token sconosciuto (non nel vocabolario) |
| [CLS] | 101 | Inizio sequenza, la sua rappresentazione finale e usata per la classificazione |
| [SEP] | 102 | Separatore tra le due frasi di input |
| [MASK] | 103 | Maschera per il Masked Language Model durante il pre-training |
from transformers import BertTokenizer
# Carica il tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# Tokenizzazione di parole comuni e rare
examples = [
"The cat sat on the mat",
"Electroencephalography is fascinating",
"L'intelligenza artificiale rivoluzionera il mondo",
"Tokenizzazione subword con WordPiece"
]
for text in examples:
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text)
print(f"Testo: {text}")
print(f" Tokens: {tokens}")
print(f" IDs: {ids}")
print(f" Num tokens: {len(tokens)}")
print()
# Output per "Electroencephalography is fascinating":
# Tokens: ['electro', '##ence', '##pha', '##log', '##raphy', 'is', 'fascinating']
# La parola lunga viene spezzata in subword, ma "is" e "fascinating" restano intere
# Decodifica
encoded = tokenizer("Hello world!", return_tensors="pt")
decoded = tokenizer.decode(encoded['input_ids'][0])
print(f"Encoded -> Decoded: {decoded}")
# Output: [CLS] hello world ! [SEP]
# Vocabolario
print(f"Dimensione vocabolario: {tokenizer.vocab_size}")
# Output: 30522
5. Fine-tuning BERT per Task Downstream
Il paradigma introdotto da BERT e "pre-train, then fine-tune". La fase di pre-training produce un modello con una comprensione profonda del linguaggio. La fase di fine-tuning adatta questo modello a un task specifico aggiungendo uno strato di output e allenando l'intero modello (o parte di esso) sui dati etichettati del task.
Il bello del fine-tuning e che richiede pochi dati etichettati e poco tempo di training rispetto ad allenare un modello da zero. Tipicamente, 2-4 epoche con un learning rate basso (2e-5 a 5e-5) sono sufficienti.
5.1 Text Classification
Per la classificazione del testo (sentiment analysis, topic classification, spam detection), si usa la rappresentazione del token [CLS] come input per un classificatore lineare:
Input: [CLS] Questo film e' fantastico [SEP]
|
BERT: 12 layers di Transformer Encoder
|
[CLS] output (768-dim) --> Linear(768, num_classes) --> Softmax --> Predizione
|
"Positivo" (p=0.95)
from transformers import BertForSequenceClassification, BertTokenizer
from transformers import Trainer, TrainingArguments
from datasets import load_dataset
import torch
# 1. Carica modello pre-addestrato con classification head
model = BertForSequenceClassification.from_pretrained(
'bert-base-uncased',
num_labels=2 # positivo/negativo
)
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# 2. Prepara il dataset (esempio: IMDB reviews)
dataset = load_dataset('imdb')
def tokenize_function(examples):
return tokenizer(
examples['text'],
padding='max_length',
truncation=True,
max_length=256
)
tokenized_datasets = dataset.map(tokenize_function, batched=True)
# 3. Configura il training
training_args = TrainingArguments(
output_dir='./results',
num_train_epochs=3,
per_device_train_batch_size=16,
per_device_eval_batch_size=64,
warmup_steps=500,
weight_decay=0.01,
learning_rate=2e-5,
logging_dir='./logs',
evaluation_strategy='epoch',
save_strategy='epoch',
load_best_model_at_end=True,
)
# 4. Crea il Trainer e avvia il fine-tuning
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets['train'],
eval_dataset=tokenized_datasets['test'],
)
trainer.train()
# 5. Valutazione
results = trainer.evaluate()
print(f"Accuracy: {results['eval_loss']:.4f}")
5.2 Named Entity Recognition (NER)
Per il NER, invece di usare solo il token [CLS], si usa l'output di ogni token per classificare ciascuno in una categoria di entità (persona, luogo, organizzazione, ecc.):
Input: [CLS] Mario Rossi vive a Roma [SEP]
| | | | | | |
BERT: 12 layers di Transformer Encoder
| | | | | | |
Output: O B-PER I-PER O O B-LOC O
Ogni token --> Linear(768, num_entity_types) --> Softmax --> Tipo entità
from transformers import BertForTokenClassification, BertTokenizer
from transformers import pipeline
# Usa un modello già fine-tuned per NER
ner_pipeline = pipeline(
"ner",
model="dbmdz/bert-large-cased-finetuned-conll03-english",
aggregation_strategy="simple"
)
text = "Mario Rossi works at Google in Mountain View, California"
entities = ner_pipeline(text)
for entity in entities:
print(f" {entity['word']}: {entity['entity_group']} "
f"(score: {entity['score']:.3f})")
# Output:
# Mario Rossi: PER (score: 0.998)
# Google: ORG (score: 0.997)
# Mountain View: LOC (score: 0.995)
# California: LOC (score: 0.999)
5.3 Question Answering
Per l'extractive question answering, BERT riceve in input una domanda e un contesto separati da [SEP]. Il modello deve predire le posizioni di inizio e fine della risposta all'interno del contesto:
Input: [CLS] Quando e' nato Einstein? [SEP] Albert Einstein e' nato il 14 marzo 1879 [SEP]
| | | | | | | | | | | | | |
BERT: 12 layers di Transformer Encoder
| | | | | | | | | | | | | |
Start: 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.02 0.8 0.02 0.01 0.01 0.01
End: 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.85 0.01
Risposta estratta: "14 marzo 1879"
from transformers import pipeline
# Pipeline di Question Answering
qa_pipeline = pipeline(
"question-answering",
model="deepset/bert-base-cased-squad2"
)
context = """
BERT (Bidirectional Encoder Representations from Transformers)
e' un modello di linguaggio sviluppato da Google AI nel 2018.
E' stato addestrato su Wikipedia e BookCorpus, per un totale
di circa 3.3 miliardi di parole. BERT-base ha 110 milioni di
parametri e 12 layer di Transformer encoder.
"""
questions = [
"Chi ha sviluppato BERT?",
"Quanti parametri ha BERT-base?",
"Su quali dati e' stato addestrato BERT?",
"In che anno e' stato pubblicato BERT?"
]
for question in questions:
result = qa_pipeline(question=question, context=context)
print(f"D: {question}")
print(f"R: {result['answer']} (score: {result['score']:.3f})")
print()
5.4 Sentence Pair Classification
Per task come il Natural Language Inference (NLI) o il paraphrase detection, BERT riceve due frasi e deve classificare la loro relazione (entailment, contradiction, neutral):
from transformers import pipeline
# Natural Language Inference
nli_pipeline = pipeline(
"text-classification",
model="cross-encoder/nli-deberta-v3-small"
)
pairs = [
("Il gatto dorme sul divano", "Un animale sta riposando"),
("Il gatto dorme sul divano", "Il cane corre nel parco"),
("Tutti gli studenti hanno superato l'esame", "Nessuno studente ha fallito"),
]
for premise, hypothesis in pairs:
result = nli_pipeline(f"{premise} [SEP] {hypothesis}")
print(f"Premessa: {premise}")
print(f"Ipotesi: {hypothesis}")
print(f"Risultato: {result[0]['label']} ({result[0]['score']:.3f})")
print()
6. Estrarre Embeddings da BERT
Oltre al fine-tuning per task specifici, BERT e estremamente utile come feature extractor. Le rappresentazioni prodotte dai layer interni catturano informazioni linguistiche a diversi livelli di astrazione.
from transformers import BertTokenizer, BertModel
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased', output_hidden_states=True)
model.eval()
text = "Il Natural Language Processing e' affascinante"
inputs = tokenizer(text, return_tensors='pt')
with torch.no_grad():
outputs = model(**inputs)
# outputs contiene:
# - last_hidden_state: output dell'ultimo layer (batch, seq_len, 768)
# - pooler_output: [CLS] passato attraverso un linear + tanh (batch, 768)
# - hidden_states: tuple di 13 tensori (embedding + 12 layers)
last_hidden = outputs.last_hidden_state
pooler = outputs.pooler_output
all_layers = outputs.hidden_states
print(f"Last hidden state: {last_hidden.shape}")
# torch.Size([1, N, 768])
print(f"Pooler output: {pooler.shape}")
# torch.Size([1, 768])
print(f"Numero di layer: {len(all_layers)}")
# 13 (embedding layer + 12 transformer layers)
# Strategie per ottenere sentence embeddings:
# 1. Usa il [CLS] token dell'ultimo layer
cls_embedding = last_hidden[:, 0, :]
print(f"[CLS] embedding: {cls_embedding.shape}")
# 2. Mean pooling sull'ultimo layer (spesso migliore)
attention_mask = inputs['attention_mask'].unsqueeze(-1)
mean_embedding = (last_hidden * attention_mask).sum(1) / attention_mask.sum(1)
print(f"Mean pooling: {mean_embedding.shape}")
# 3. Concatena gli ultimi 4 layer (cattura più informazione)
last_4_layers = torch.cat(
[all_layers[i] for i in [-1, -2, -3, -4]],
dim=-1
)
print(f"Last 4 layers concatenated: {last_4_layers.shape}")
# torch.Size([1, N, 3072])
Quale Layer Usare?
La ricerca ha mostrato che diversi layer catturano diversi tipi di informazione:
- Layer 1-4 (bassi): Informazioni morfologiche e sintattiche di base
- Layer 5-8 (medi): Informazioni sintattiche complesse, dipendenze
- Layer 9-12 (alti): Informazioni semantiche di alto livello
- Ultimo layer: Migliore per task semantici (NLI, similarità)
- Layer medi: Migliori per task sintattici (POS tagging, parsing)
7. Varianti di BERT: L'Evoluzione della Famiglia
Dopo la pubblicazione di BERT, numerosi gruppi di ricerca hanno proposto varianti che ne migliorano diversi aspetti. Ecco una panoramica delle più importanti.
Tabella Comparativa delle Varianti di BERT
| Modello | Anno | Innovazione Principale | Parametri |
|---|---|---|---|
| BERT-base | 2018 | MLM + NSP, bidirezionale profondo | 110M |
| RoBERTa | 2019 | No NSP, dynamic masking, più dati, training più lungo | 125M |
| ALBERT | 2019 | Parameter sharing, factorized embedding | 12M-235M |
| DistilBERT | 2019 | Knowledge distillation, 60% più veloce | 66M |
| ELECTRA | 2020 | Replaced token detection (più efficiente di MLM) | 110M |
| DeBERTa | 2020 | Disentangled attention (contenuto + posizione separati) | 134M-390M |
| XLNet | 2019 | Permutation language modeling | 340M |
| SpanBERT | 2019 | Masking di span contigui + SBO | 110M |
| ERNIE | 2019 | Knowledge-enhanced, entity masking | 110M |
7.1 RoBERTa: Ottimizzare il Training di BERT
RoBERTa (Robustly optimized BERT approach), proposto da Facebook AI (ora Meta), ha dimostrato che BERT era stato sotto-addestrato. Le modifiche principali sono:
- Rimozione di NSP: L'obiettivo NSP non aiuta e può persino peggiorare le performance
- Dynamic masking: Invece di mascherare una volta sola durante il preprocessing, i pattern di masking vengono generati dinamicamente ad ogni epoca
- Più dati: 160GB di testo (vs 16GB di BERT), inclusi CC-News, OpenWebText, Stories
- Training più lungo: 500K step (vs 100K di BERT), batch size maggiore
- Sequenze più lunghe: Sempre 512 token (BERT iniziava con 128)
7.2 ALBERT: Compressione Intelligente
ALBERT (A Lite BERT) di Google affronta il problema della crescita dei parametri con due tecniche eleganti:
- Factorized embedding parameterization: Separa la dimensione del vocabolario dalla dimensione nascosta. Invece di una matrice V x H, usa V x E e E x H (con E molto minore di H)
- Cross-layer parameter sharing: Tutti i layer del Transformer condividono gli stessi parametri. Questo riduce drasticamente il numero di parametri senza perdere molta qualità
ALBERT-xxlarge raggiunge performance superiori a BERT-large con solo 235M parametri (vs 340M), anche se il tempo di inferenza non diminuisce poichè il numero di layer resta invariato.
7.3 DistilBERT: Distillazione della Conoscenza
DistilBERT di Hugging Face usa la knowledge distillation per creare un modello più piccolo e veloce. Un modello "studente" (6 layer) viene addestrato a imitare le output di un modello "insegnante" (BERT-base, 12 layer).
- 40% più piccolo: 66M parametri vs 110M
- 60% più veloce: 6 layer vs 12
- 97% delle performance: Mantiene quasi tutta la qualità di BERT-base
7.4 ELECTRA: Un Approccio Completamente Diverso
ELECTRA sostituisce il Masked Language Model con un approccio ispirato alle GAN (Generative Adversarial Networks). Invece di mascherare token e predirli, ELECTRA:
- Un piccolo generatore (un BERT piccolo) produce token sostitutivi plausibili
- Il discriminatore (il modello principale) deve identificare quali token sono stati sostituiti (replaced token detection)
Il vantaggio chiave: il discriminatore impara da tutti i token della sequenza, non solo dal 15% mascherato come in BERT. Questo rende ELECTRA molto più efficiente in termini di dati e compute.
7.5 DeBERTa: Attenzione Disaccoppiata
DeBERTa (Decoding-enhanced BERT with disentangled Attention) di Microsoft introduce due innovazioni:
- Disentangled attention: Separa le informazioni di contenuto e posizione in due vettori distinti, permettendo al modello di ragionare su "cosa" e "dove" indipendentemente
- Enhanced mask decoder: Aggiunge informazioni di posizione assoluta nel decoder per migliorare le predizioni MLM
DeBERTa v3 e attualmente uno dei modelli encoder più performanti su molti benchmark, spesso superando modelli molto più grandi.
8. BERT per la Lingua Italiana
Il BERT originale e stato addestrato su testo inglese. Per l'italiano, abbiamo diverse opzioni, ciascuna con vantaggi e svantaggi.
Modelli BERT per l'Italiano
| Modello | Base | Dati di Training | Vocabolario |
|---|---|---|---|
| mBERT | BERT-base | Wikipedia in 104 lingue | 110K token multilingue |
| dbmdz/bert-base-italian-xxl-cased | BERT-base | OPUS, Wikipedia IT (~13GB) | 30K token italiani |
| AlBERTo | BERT-base | Tweet in italiano | 30K token italiani |
| UmBERTo | RoBERTa | OSCAR Italian corpus | 32K token SentencePiece |
| XLM-RoBERTa | RoBERTa | CC-100 in 100 lingue | 250K token multilingue |
from transformers import pipeline, AutoTokenizer, AutoModelForMaskedLM
# 1. BERT Italiano (dbmdz)
fill_mask_it = pipeline(
"fill-mask",
model="dbmdz/bert-base-italian-xxl-cased"
)
results = fill_mask_it("Roma e' la [MASK] d'Italia.")
for r in results[:3]:
print(f" {r['token_str']}: {r['score']:.4f}")
# Output: capitale: 0.8234, citta: 0.0521, ...
# 2. UmBERTo (basato su RoBERTa)
fill_mask_umberto = pipeline(
"fill-mask",
model="Musixmatch/umberto-commoncrawl-cased-v1"
)
results = fill_mask_umberto("L'intelligenza artificiale <mask> il futuro.")
for r in results[:3]:
print(f" {r['token_str']}: {r['score']:.4f}")
# 3. Multilingual BERT
fill_mask_mbert = pipeline(
"fill-mask",
model="bert-base-multilingual-cased"
)
results = fill_mask_mbert("Roma e' la [MASK] d'Italia.")
for r in results[:3]:
print(f" {r['token_str']}: {r['score']:.4f}")
# mBERT funziona, ma il modello italiano dedicato e' molto più' preciso
Quale Modello Scegliere per l'Italiano?
- Task generali (NER, classificazione, QA):
dbmdz/bert-base-italian-xxl-casedoppure UmBERTo - Analisi social media: AlBERTo (addestrato su tweet)
- Task multilingue: XLM-RoBERTa (ottimo per zero-shot cross-lingual)
- Massima qualità: Fine-tune di XLM-RoBERTa-large su dati italiani
9. Limiti di BERT
Nonostante il suo impatto rivoluzionario, BERT presenta diversi limiti strutturali che ne condizionano l'applicabilita:
9.1 Limite dei 512 Token
BERT può processare sequenze di massimo 512 token. Per documenti lunghi (articoli, contratti legali, paper scientifici), questo e un limite severo. Strategie di mitigazione includono il truncation, lo sliding window e la hierarchical pooling, ma nessuna e ottimale.
9.2 Complessità Quadratica dell'Attention
La self-attention ha complessità computazionale O(n^2) rispetto alla lunghezza della sequenza n. Questo significa che raddoppiare la lunghezza dell'input quadruplica il costo computazionale e il consumo di memoria. Modelli come Longformer, BigBird e Linformer propongono meccanismi di attention sparsa per aggirare questo problema.
9.3 Solo Encoder: Nessuna capacità Generativa
BERT e un modello encoder-only. Può produrre rappresentazioni ricche del testo, ma non può generare nuovo testo. Per la generazione serve un decoder (come in GPT) o un'architettura encoder-decoder (come in T5 o BART).
9.4 Mismatch Pre-training/Fine-tuning
Il token [MASK] appare durante il pre-training ma mai durante il fine-tuning o l'inferenza. Anche se la strategia 80/10/10 mitiga parzialmente il problema, questo mismatch rimane una debolezza teorica. ELECTRA risolve completamente il problema non usando mai [MASK].
9.5 Indipendenza dei Token Mascherati
Quando BERT maschera più token, li predice in modo indipendente. Non modella le dipendenze tra i token mascherati. Ad esempio, se la frase e "New [MASK] [MASK]" e la risposta e "New York City", BERT predice "York" e "City" indipendentemente, senza condizionare la predizione di uno sull'altro. XLNet affronta questo problema con il permutation language modeling.
Riepilogo Limiti e Soluzioni
| Limite | Impatto | Soluzione |
|---|---|---|
| Max 512 token | No documenti lunghi | Longformer, BigBird, sliding window |
| Attention O(n^2) | Costi computazionali elevati | Linformer, Performer, Flash Attention |
| Solo encoder | No generazione testo | T5, BART, GPT |
| [MASK] mismatch | Discrepanza train/inference | ELECTRA, XLNet |
| Predizioni indipendenti | No dipendenze tra token mascherati | XLNet, autoregressive models |
10. Da BERT ai Modelli Moderni
BERT ha inaugurato l'era del "pre-train then fine-tune" nell'NLP, influenzando profondamente tutte le architetture successive. Vediamo come si e evoluto il panorama.
10.1 L'Albero Genealogico dei Modelli
Transformer (2017)
|
+------ Encoder-Only ------+------ Decoder-Only ------+--- Encoder-Decoder ---+
| | | |
BERT (2018) GPT-1 (2018) T5 (2019) BART (2019)
| | |
+-- RoBERTa (2019) GPT-2 (2019) mT5 (2020)
+-- ALBERT (2019) |
+-- DistilBERT (2019) GPT-3 (2020)
+-- XLNet (2019) |
+-- ELECTRA (2020) GPT-4 (2023)
+-- DeBERTa (2020) |
+-- DeBERTa v3 (2021) LLaMA (2023)
|
LLaMA 2 (2023)
|
Mistral (2023)
|
LLaMA 3 (2024)
10.2 La Lezione Chiave di BERT
L'eredita più importante di BERT non e un'architettura specifica, ma un paradigma: addestra un modello su enormi quantità di testo non etichettato usando obiettivi auto-supervisionati, e poi adattalo a task specifici con pochi dati etichettati. Questo paradigma e alla base di tutti i modelli moderni, dai GPT ai LLaMA.
10.3 Dove BERT e Ancora Rilevante
Nonostante l'emergere di modelli generativi molto più grandi, BERT e i suoi discendenti encoder-only rimangono la scelta migliore per molti scenari:
- Classificazione del testo: Sentiment, topic, spam detection
- Named Entity Recognition: Estrazione di entità strutturate
- Semantic search: Ricerca basata su significato (con sentence-transformers)
- Information retrieval: Ranking di documenti rilevanti
- Embedding generation: Rappresentazioni dense per il testo
I modelli encoder come BERT sono più efficienti, più veloci e meno costosi rispetto ai grandi modelli generativi. Per molti task di comprensione del linguaggio, un DeBERTa-v3 fine-tuned supera modelli con 100 volte più parametri.
11. Conclusioni e Quando Usare BERT
In questo articolo abbiamo analizzato BERT in profondità: dall'architettura encoder-only del Transformer, al meccanismo di multi-head self-attention, alle strategie di pre-training (MLM e NSP), fino alle implementazioni pratiche con HuggingFace e alle varianti che ne sono derivate.
Decision Framework: Quando Usare BERT
| Scenario | Modello Consigliato | Motivazione |
|---|---|---|
| Classificazione testo (produzione) | DeBERTa-v3 o RoBERTa | Massima accuratezza, costo ragionevole |
| NER (italiano) | dbmdz BERT IT o UmBERTo | Vocabolario nativo italiano |
| Risorse limitate / edge | DistilBERT o TinyBERT | Veloce, compatto, 97% qualità |
| Efficienza di training | ELECTRA | Impara da tutti i token, non solo 15% |
| Generazione di testo | GPT / T5 / LLaMA | BERT non può generare |
| Documenti lunghi | Longformer / BigBird | BERT limitato a 512 token |
| Multilingue / zero-shot | XLM-RoBERTa | Eccellente transfer cross-linguale |
BERT resta un pilastro dell'NLP moderno. Anche se i Large Language Models generativi dominano i titoli dei giornali, i modelli encoder come BERT sono la spina dorsale di innumerevoli applicazioni in produzione: motori di ricerca, sistemi di raccomandazione, filtri anti-spam, assistenti legali, analisi del sentiment e molto altro.
Nel prossimo articolo della serie, metteremo in pratica queste conoscenze costruendo un sistema di Sentiment Analysis completo per il testo italiano, dalla preparazione del dataset al deployment del modello.
Risorse per Approfondire
- Paper originale: "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding" (Devlin et al., 2018)
- Paper RoBERTa: "A Robustly Optimized BERT Pretraining Approach" (Liu et al., 2019)
- Paper ELECTRA: "ELECTRA: Pre-training Text Encoders as Discriminators Rather Than Generators" (Clark et al., 2020)
- Paper DeBERTa: "DeBERTa: Decoding-enhanced BERT with Disentangled Attention" (He et al., 2020)
- HuggingFace BERT documentation: huggingface.co/docs/transformers/model_doc/bert
- The Illustrated BERT: jalammar.github.io/illustrated-bert/







