Progetto SLM · Riferimento Teorico
Formule, intuizioni e implementazione dei meccanismi core del decoder-only Transformer — da attention al blocco completo.
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.
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]
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]
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.
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 meccanismo fondamentale. Ogni token interroga tutti gli altri tramite tre proiezioni — Query, Key e Value — e ottiene una rappresentazione arricchita dal contesto.
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.
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$.
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.
| Parametro | Ruolo | Shape |
|---|---|---|
| $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.
$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.
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.
$\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.
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.
| Componente | Parametri | Formula |
|---|---|---|
| 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 ×2 | 256 | $2 \cdot 2 \cdot d_\text{model}$ |
| Totale per blocco | 49,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.