SLM · Ottimizzazione

AdamW e Ottimizzazione

Come il modello impara: gradienti, momenti, weight decay, learning rate schedule e gradient clipping — ogni pezzo spiegato dall'inizio.

1. Il gradiente

Il training è un ciclo ripetuto migliaia di volte. Ad ogni step il modello riceve un batch di testo, fa una previsione su quale token viene dopo, e calcola quanto ha sbagliato — la loss.

Backpropagation calcola poi, per ogni singolo parametro del modello, quanto la loss cambierebbe se quel parametro aumentasse di un piccolo epsilon. Quel valore è il gradiente.

g_t = ∂Loss / ∂w ← derivata della loss rispetto al peso w al passo t

g_t è un numero con segno. Se g_t = +0.3: aumentare w fa salire la loss → devi diminuire w. Se g_t = −0.8: il contrario. Il segno dice la direzione, il valore assoluto dice quanto è ripida la pendenza.

t è il numero dello step corrente: t=1 al primo step, t=2 al secondo, e così via. Il pedice ricorda che il gradiente cambia ad ogni step — dipende dal batch.

2. L'aggiornamento naïve e i suoi problemi

L'idea più semplice: sposta ogni peso nella direzione opposta al gradiente, scalato da un passo fisso.

w ← w − lr × g_t ← SGD base

Due problemi fondamentali:

Rumore. Il gradiente calcolato su un singolo batch è una stima rumorosa della direzione vera. Un batch fa sembrare che w vada aumentato, il successivo che vada diminuito — il peso zigzaga invece di convergere.

Passo unico per tutti. Il modello ha 46 milioni di parametri con dinamiche completamente diverse. Il peso della riga 423 della embedding table vede gradienti solo quando quel token appare nel batch — raramente. Un parametro del feed-forward centrale riceve gradienti ad ogni step. Lo stesso lr fisso per entrambi è inevitabilmente sbagliato per uno dei due.

3. β — la memoria del passato

β (beta) è un numero tra 0 e 1 che controlla quanto peso dare al passato in una media mobile esponenziale. La formula generale:

media_t = β × media_{t−1} + (1 − β) × valore_nuovo

Ad ogni step, il nuovo valore contribuisce per (1−β) e tutto il passato — compresso in media_{t−1} — contribuisce per β.

β = 0.0 Nessuna memoria. La media è il valore corrente. Nessuno smorzamento del rumore.
β = 0.9 Ogni nuovo valore pesa il 10%, il passato il 90%. La media segue la tendenza reale e ignora i picchi casuali.
β = 0.99 Memoria molto lunga. La media è stabile ma reattiva come un transatlantico — cambia direzione lentamente.

Intuitivamente: con β = 0.9 la "finestra effettiva" di memoria è circa 1/(1−0.9) = 10 step. Con β = 0.95 è circa 20 step.

4. I due momenti di Adam

Adam mantiene due medie mobili per ogni parametro, aggiornate ad ogni step.

Primo momento m_t — la direzione media

m_t = β₁ × m_{t−1} + (1 − β₁) × g_t ← con β₁ = 0.9

m_t è la media mobile dei gradienti delle ultime decine di step. Risponde alla domanda: in quale direzione si muove questo parametro in media?

Se due step consecutivi danno g=+0.5 e poi g=−0.3, l'aggiornamento naïve zigzaga. Con m_t, la direzione media è più stabile e il parametro avanza nella direzione dominante invece di rimbalzare. In fisica si chiama momento lineare — da qui il nome.

Secondo momento v_t — la varianza storica

v_t = β₂ × v_{t−1} + (1 − β₂) × g_t² ← con β₂ = 0.95

v_t è la media mobile dei quadrati dei gradienti. Il quadrato è sempre positivo — il segno non conta. Risponde alla domanda: quanto è stato grande il gradiente di questo parametro storicamente?

Un parametro che riceve gradienti grandi e frequenti ha v_t alto. Un parametro raro (come un token infrequente nella embedding table) ha v_t basso. Questa differenza viene usata per adattare il passo.

5. La correzione del bias iniziale

C'è un problema di avvio: all'inizio del training, m_0 = 0 e v_0 = 0. Nelle prime iterazioni le medie mobili sono ancora "fredde" — sottostimano i valori reali perché il passato era tutto zero.

Esempio: al passo 1 con β₁ = 0.9 e g₁ = 0.5:

m_1 = 0.9 × 0  +  0.1 × 0.5  =  0.05    ← dovrebbe essere ~0.5, non 0.05

La correzione divide per (1 − β^t), un termine che parte vicino a zero (amplifica il valore reale) e si avvicina a 1 dopo molti step (effetto trascurabile):

m̂_t = m_t / (1 − β₁ᵗ) v̂_t = v_t / (1 − β₂ᵗ)
# Al passo t=1, β₁=0.9:
m̂_1  =  0.05 / (1 − 0.9¹)  =  0.05 / 0.1  =  0.5   ✓

# Al passo t=100, β₁=0.9:
(1 − 0.9¹⁰⁰)  ≈  0.99999   → divisione quasi irrilevante

6. L'aggiornamento Adam completo

w ← w − lr × m̂_t / (√v̂_t + ε) ← ε = 1e-8

m̂_t al numeratore dà la direzione smorzata e corretta — il gradiente medio stabilizzato.

√v̂_t al denominatore scala il passo in base alla storia del parametro:

ParametroStorico gradientiv̂_tPasso effettivoEffetto
Embedding token frequentegrandi e continuialtopiccolorallenta chi è già attivo
Embedding token raropiccoli, episodicibassograndeaccelera chi è poco aggiornato
Peso FFN centralemedi, regolarimediomediopasso proporzionale

ε = 1e-8 è un numero infinitesimale aggiunto per evitare la divisione per zero quando v̂_t ≈ 0.

Il risultato: ogni parametro ha un learning rate effettivo diverso, che si adatta automaticamente alla sua storia. Non serve tarare manualmente un lr per ogni layer.

7. Weight decay — e perché AdamW non è Adam

I pesi tendono a crescere durante il training se non vengono frenati — il modello impara a fare affidamento su pochi parametri molto forti invece di distribuire l'informazione. Il weight decay contrasta questo con una piccola penalità proporzionale alla dimensione del peso.

Il problema di Adam classico

In Adam classico, il weight decay veniva implementato aggiungendo una penalità al gradiente prima dell'update:

g_t_modificato  =  g_t  +  λ × w      ← λ = 0.1
w  ←  w  −  lr × m̂(g_t_modificato) / √v̂(g_t_modificato)

Il problema: la penalità λ × w finiva dentro la divisione per √v̂_t. Parametri con alta varianza storica (gradienti grandi) ricevevano meno regolarizzazione di parametri con bassa varianza. La penalità non era uniforme — non era quello che si voleva.

AdamW: i due passi separati

AdamW (Loshchilov & Hutter, 2019) risolve separando i due aggiornamenti:

# Passo 1 — Adam puro: trova la direzione ottimale
w  ←  w  −  lr × m̂_t / (√v̂_t + ε)

# Passo 2 — weight decay diretto: regolarizza uniformemente
w  ←  w  −  lr × λ × w
#  equivalente a:  w ← w × (1 − lr × λ)
#  con lr=3e-4 e λ=0.1 → ogni step moltiplica w per 0.99997

Intuitivamente: il weight decay applica un piccolo "restringimento" costante verso zero ad ogni step. Con λ=0.1 e lr=3×10⁻⁴, ogni peso viene moltiplicato per 0.99997 — quasi nulla per step, ma dopo 30.000 step il contributo cumulativo è significativo.

Nota: il weight decay si applica solo ai pesi matriciali (nn.Linear, embedding) — non ai bias né ai parametri LayerNorm. Questi ultimi sono vettori unidimensionali che non partecipano alla regolarizzazione. In train.py, decay_params e no_decay_params separano esattamente questi due gruppi.

8. Learning rate schedule

AdamW usa un lr passato al momento dell'aggiornamento — ma in train.py quel valore non è costante. Segue una curva in due fasi.

warmup 200 step cosine decay → 30 000 step 3e-4 3e-5 0 30k

Fase 1 — Warmup lineare (step 0 → 200)

Il learning rate cresce linearmente da 0 a lr_max = 3×10⁻⁴ nei primi 200 step.

Perché? All'inizio del training, i momenti m_t e v_t sono ancora "freddi" — la correzione del bias li aggiusta ma non li rende perfetti. Partire con un lr grande farebbe aggiornamenti grandi e inaffidabili, destabilizzando il modello prima che abbia imparato qualcosa. Il warmup lascia tempo alle statistiche di stabilizzarsi.

Fase 2 — Cosine decay (step 200 → 30.000)

lr_t = lr_min + ½ × (lr_max − lr_min) × (1 + cos(π × progresso))

dove progresso va da 0 a 1 durante il decay.

Il coseno scende dolcemente — inizia quasi piatto vicino al massimo (passi ancora grandi quando il modello ha molto da imparare), poi accelera verso il minimo, poi si appiattisce di nuovo vicino al minimo 3×10⁻⁵. Questo profilo funziona meglio di un taglio brusco perché riduce il passo progressivamente mentre il modello si avvicina al minimo della loss — passi grandi vicino al minimo farebbero rimbalzare il modello senza convergere.

9. Gradient clipping

Occasionalmente, per un batch sfortunato o durante le prime fasi del training, il gradiente può diventare molto grande — un cosiddetto gradient spike. Un singolo aggiornamento enorme può distruggere i progressi fatti nei mille step precedenti.

Il gradient clipping risolve in modo semplice: calcola la norma L2 dell'intero vettore di gradienti (come se tutti i 46 milioni di gradienti fossero un singolo vettore), e se supera una soglia lo scala proporzionalmente.

se ‖g‖ > soglia: g ← g × (soglia / ‖g‖) ← soglia = 1.0

La direzione non cambia — solo il modulo viene limitato. È l'equivalente di dire: "non fare mai un passo più lungo di 1.0, qualunque cosa dica il gradiente."

# In train.py, applicato prima di optimizer.step()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

Il gradient clipping non è sempre necessario, ma per i transformer è prassi standard. Senza di esso, un singolo batch anomalo può portare la loss a esplodere a NaN e rendere il checkpoint inutilizzabile.

10. Come si leggono gli iperparametri in train.py

AdamW(
    params,
    lr          = 3e-4,        # learning rate massimo (raggiunto dopo il warmup)
    betas       = (0.9, 0.95), # β₁ per m_t, β₂ per v_t
    weight_decay= 0.1,        # λ — forza della regolarizzazione
    eps         = 1e-8,        # ε — stabilità numerica
)
ParametroValoreSignificato
lr3 × 10⁻⁴Passo massimo — raggiunto al termine del warmup
min_lr3 × 10⁻⁵Passo minimo al termine del cosine decay (= lr/10)
warmup_steps200Step di salita lineare prima del decay
β₁0.9Memoria gradienti — finestra effettiva ~10 step
β₂0.95Memoria varianza — finestra effettiva ~20 step (più alto del default 0.999 usato per i transformer)
weight_decay0.1Restringimento per pesi matriciali (non su bias/LayerNorm)
grad_clip1.0Soglia norma L2 del gradiente globale