Progetto SLM · Riferimento Teorico

Fondamenti del Transformer

Formule, intuizioni e implementazione dei meccanismi core del decoder-only Transformer — da attention al blocco completo.

PyTorch Decoder-only Pre-Norm attention.py transformer.py

Prima di entrare nell'architettura, c'è una cosa da tenere a mente: il modello non vede mai il testo. Da quando il corpus viene processato, il testo scompare. Quello che rimane sono numeri, e da numeri si parte fino alla fine.

Step 1 — Tokenizzazione

Il tokenizer (BPE-SPM) converte il testo in una sequenza di interi — gli ID dei token nel vocabolario (16.000 entry). Il risultato viene salvato in un file .pt: un tensor monodimensionale di interi, lungo milioni di elementi. Il testo originale non serve più.

"il gatto dorme sul tetto"
        ↓
[312, 8741, 2093, 451, 9834]

Step 2 — Embedding lookup

Ogni intero viene trasformato in un vettore denso di 512 numeri float tramite la embedding matrix (16.000 × 512). Questa tabella è parte del modello — inizializzata a caso e appresa durante il training come tutti gli altri pesi. È l'unico punto in cui un intero diventa un vettore. Da qui in poi, il modello non sa più che c'era del testo.

312  →  [0.12, −0.45, 0.87, …, 0.33]   ← 512 dimensioni
8741 →  [−0.91, 0.22, 0.04, …, 0.67]

Step 3 — Transformer

I layer del transformer prendono la sequenza di vettori e la passano attraverso meccanismi di attenzione e feed-forward network. Nessun riferimento al testo originale: solo operazioni su tensori float.

Step 4 — Prediction e loss

L'output finale è una distribuzione di probabilità su tutti i 16.000 token del vocabolario: "dato questo contesto, qual è il token successivo più probabile?" Il target è il token successivo reale, preso direttamente dal .pt — un intero. La loss (cross-entropy) misura l'errore; la backpropagation aggiusta i pesi.

Il punto chiave: il testo scompare dopo il tokenizer. Il modello impara relazioni tra ID interi — più precisamente, tra i vettori che quegli ID proiettano nello spazio delle embedding. La comprensione della lingua emerge come side effect dell'ottimizzare la previsione del prossimo intero.
Glossario → Token Glossario → Embedding Glossario → Pipeline testo→modello

Il meccanismo fondamentale. Ogni token interroga tutti gli altri tramite tre proiezioni — Query, Key e Value — e ottiene una rappresentazione arricchita dal contesto.

Formula

Scaled Dot-Product Attention
$$\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V$$

Il denominatore $\sqrt{d_k}$ scala il prodotto scalare: senza di esso, con $d_k$ grande, i valori diventano enormi e il softmax satura verso 0 e 1, rendendo i gradienti quasi nulli.

Implementazione

def scaled_dot_product_attention(Q, K, V, mask=None):
    d_k = Q.size(-1)
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float('-inf'))
    weights = torch.softmax(scores, dim=-1)
    return torch.matmul(weights, V), weights

Durante il training, il modello conosce l'intera sequenza. La causal mask gli impedisce di "barare" guardando i token futuri: il token in posizione $i$ può vedere solo le posizioni $0 \ldots i$.

Matrice triangolare inferiore
$$M_{ij} = \begin{cases} 1 & \text{se } j \leq i \\ 0 & \text{se } j > i \end{cases}$$
Perché −∞? Sostituiamo 0 con $-\infty$ negli score prima del softmax. Il softmax calcola $e^{-\infty} = 0$, quindi quelle posizioni ricevono peso nullo senza destabilizzare i gradienti.
seq_len = 4

M =
  [1, 0, 0, 0]  ← token 0: vede solo sé
  [1, 1, 0, 0]  ← token 1: vede 0,1
  [1, 1, 1, 0]  ← token 2: vede 0,1,2
  [1, 1, 1, 1]  ← token 3: vede tutto

Invece di una singola attention, si eseguono $h$ attention parallele su sottospazi di dimensione $d_k = d_\text{model}/h$. Ogni testa può specializzarsi su un tipo diverso di relazione.

Multi-Head Attention
$$\text{MHA}(Q, K, V) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h)\, W^O$$ $$\text{head}_i = \text{Attention}(Q W_i^Q,\; K W_i^K,\; V W_i^V)$$
ParametroRuoloShape
$W_i^Q, W_i^K, W_i^V$Proiezioni per testa $i$$(d_\text{model},\, d_k)$
$W^O$Proiezione output$(d_\text{model},\, d_\text{model})$
$d_k$Dimensione per testa$d_\text{model} / h$

Il Transformer elabora tutti i token in parallelo: senza un segnale di posizione esplicito, "gatto mangia topo" e "topo mangia gatto" sarebbero indistinguibili. La codifica sinusoidale inietta questo segnale sommando un vettore all'embedding di ogni token.

Sinusoidal Positional Encoding
$$PE_{(pos,\, 2i)} = \sin\!\left(\frac{pos}{10000^{\,2i/d_\text{model}}}\right)$$ $$PE_{(pos,\, 2i+1)} = \cos\!\left(\frac{pos}{10000^{\,2i/d_\text{model}}}\right)$$

$pos$ è la posizione nella sequenza, $i$ è l'indice della dimensione. Le frequenze decrescenti creano un "codice binario continuo": le dimensioni basse variano lentamente (distinguono posizioni lontane), quelle alte variano rapidamente (distinguono posizioni vicine).

Dopo l'attention, ogni token passa attraverso una rete fully-connected applicata indipendentemente e identicamente a ogni posizione. L'attention aggrega informazioni tra token; il FFN "ragiona" su ogni token individualmente — è qui che risiede gran parte della capacità memorizzativa.

Position-wise Feed-Forward
$$\text{FFN}(x) = \text{GELU}(x W_1 + b_1)\, W_2 + b_2$$

Per convenzione $d_{ff} = 4 \times d_\text{model}$: il FFN proietta in uno spazio più ampio, applica la non-linearità, poi comprime. GELU è preferito a ReLU nei transformer moderni per la sua smoothness.

Normalizza le attivazioni lungo la dimensione delle feature (non del batch), stabilizzando la scala dei valori durante il training.

Layer Normalization
$$\text{LN}(x) = \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}} \cdot \gamma + \beta$$

$\mu$ e $\sigma^2$ sono media e varianza calcolate sull'ultimo asse (le feature). $\gamma$ e $\beta$ sono parametri learnable che permettono al modello di riscalare e shiftare le attivazioni normalizzate. $\varepsilon \approx 10^{-5}$ evita la divisione per zero.

Le residual connection creano un canale diretto attraverso i layer, risolvendo il problema del vanishing gradient nelle reti profonde: il gradiente può fluire direttamente senza attraversare le trasformazioni.

Pre-Norm (architettura GPT-2 / LLaMA)
$$x \leftarrow x + \text{Attention}\!\left(\text{LN}(x)\right)$$ $$x \leftarrow x + \text{FFN}\!\left(\text{LN}(x)\right)$$
Pre-Norm vs Post-Norm. L'articolo originale ("Attention is All You Need", 2017) usava Post-Norm: $x \leftarrow \text{LN}(x + \text{Sublayer}(x))$. I modelli moderni (GPT-2 in poi) usano Pre-Norm: più stabile durante il training, non richiede warm-up del learning rate prolungato.

Tutti i componenti assemblati. Il GPT è uno stack di $N$ di questi blocchi preceduto da un embedding layer e seguito da una proiezione sul vocabolario.

Input $x$
    ↓
Embedding + Positional Encoding
    ↓
┌───────────────────────────────┐
│  LayerNorm
│     ↓
│  Multi-Head Self-Attention + mask
│     ↓
│  x = x + dropout(attn_out)
│  LayerNorm
│     ↓
│  Feed-Forward Network
│     ↓
│  x = x + dropout(ff_out)
└───────────────────────────────┘
    ↓ × N blocchi
LayerNorm finale
    ↓
Linear → Vocabolario (logits)
    ↓
Softmax → Distribuzione sul prossimo token

Parametri — Esempio con d_model=64

ComponenteParametriFormula
MHA (W_q, W_k, W_v, W_o)16,384$4 \cdot d_\text{model}^2$
FFN (W_1, W_2)32,768$2 \cdot d_\text{model} \cdot d_{ff}$
LayerNorm ×2256$2 \cdot 2 \cdot d_\text{model}$
Totale per blocco49,408—

Il file model.py assemblerà l'intero GPT: embedding del vocabolario, $N$ TransformerBlock impilati, proiezione finale sul vocabolario e la funzione di loss (cross-entropy sul prossimo token).

Dopodichè: tokenizer su corpus italiano, dataset loader, training loop con ottimizzatore AdamW, e prima generazione di testo.