Il problema dell'apprendimento — cosa vuol dire “imparare”
Quando diciamo che una rete neurale “impara”, intendiamo qualcosa di molto preciso. Hai un dataset di coppie (input, output corretto): per esempio, 60.000 immagini di cifre scritte a mano (input) ciascuna etichettata con la cifra che rappresenta (output). Vuoi trovare i pesi della rete tali che, per ogni input del dataset, la sua predizione sia il più vicino possibile all'output corretto.
Formalizziamo. La rete è una funzione fθ(x) parametrizzata da θ (tutti i pesi e bias di tutti gli strati, magari decine di miliardi di numeri). Per ogni esempio (xi, yi) il modello produce una predizione ŷi = fθ(xi). Una loss function ℓ misura quanto la predizione è sbagliata rispetto al valore corretto. La loss totale sul dataset è la media:
Il problema dell'apprendimento è: trovare il θ che minimizza L. Questo è un problema di ottimizzazione in uno spazio ad altissima dimensionalità (in GPT-3, miliardi di dimensioni). Non si può risolvere analiticamente. Si risolve numericamente, prendendo piccoli passi nella direzione che riduce L. Quella direzione è data dal gradiente.
Analogia: immagina di trovarti in una catena montuosa, di notte, con la nebbia, e dover scendere a valle. Non vedi più di un metro intorno a te, ma puoi sentire la pendenza sotto i piedi. La strategia migliore è semplice: in ogni momento, fai un passo nella direzione di discesa più ripida. Ripeti. Non ti porterà necessariamente al punto più basso del pianeta (potresti finire in un valle locale), ma ti porterà sicuramente in basso. Questo è il gradient descent, e è il motore di praticamente tutto il deep learning moderno.
Loss functions — misurare quanto si sbaglia
La scelta della loss dipende dal task. Tre famiglie principali coprono il 90% dei casi.
Mean Squared Error (regressione)
Quando l'output è un numero reale (predire un prezzo, una temperatura, una posizione), si usa la MSE:
Penalizza quadraticamente: un errore di 10 è 100 volte peggio di un errore di 1. È comoda matematicamente (la sua derivata è lineare nell'errore), ma sensibile agli outlier. La MAE (Mean Absolute Error, |ŷ − y|) è un'alternativa robusta agli outlier ma matematicamente meno comoda. La Huber loss è un ibrido: quadratica per errori piccoli, lineare per errori grandi.
Binary Cross-Entropy (classificazione binaria)
Quando l'output è una probabilità p ∈ (0,1) di una classe binaria (spam vs ham, foto di gatto vs no), si usa la BCE:
La BCE viene dalla maximum likelihood estimation: stiamo massimizzando la probabilità che i dati osservati siano stati generati dal modello. Il logaritmo trasforma il prodotto delle probabilità in una somma (numericamente stabile) e penalizza infinitamente errori sicuri sbagliati — cosa molto sensata in classificazione, perché non vuoi che il modello dica con sicurezza al 99% qualcosa di sbagliato.
Cross-Entropy (classificazione multi-classe)
Estensione della BCE a K classi. L'output del modello è un vettore di probabilità p1, ..., pK (tipicamente prodotto da una softmax), l'etichetta è un indice di classe k*. La loss è:
Cioè: prendi la probabilità assegnata dalla rete alla classe vera, prendi il negativo del logaritmo. Se la rete è sicura della classe vera (pk* ≈ 1), la loss è 0. Se la rete è incerta (pk* ≈ 0.1), la loss è 2.3. Se la rete pensa che la classe vera sia molto improbabile (pk* ≈ 0.001), la loss esplode a 6.9.
In PyTorch nn.CrossEntropyLoss combina softmax e cross-entropy in un solo passo per stabilità numerica. Quando lo usi, l'output del tuo modello deve essere logit grezzi, non probabilità — uno degli errori più comuni dei principianti.
Gradient descent — l'intuizione geometrica
Il gradiente di L rispetto a θ è un vettore che indica la direzione di massima salita della loss nello spazio dei parametri. Si scrive ∇θL o, componente per componente, ∂L/∂θi. Per minimizzare L, ci muoviamo nella direzione opposta:
Questo è gradient descent puro. Tre parametri determinano se funziona: (1) il learning rate η; (2) quanti esempi del dataset usi per calcolare il gradiente a ogni passo; (3) da dove parti (inizializzazione, che abbiamo trattato in Reti Neurali).
La chain rule e il backward pass
Per applicare gradient descent dobbiamo saper calcolare ∂L/∂θi per ogni peso della rete. In una rete con un solo strato è calcolo diretto. In una rete profonda con miliardi di parametri sembra impossibile — eppure si fa, e si fa in modo efficiente, grazie a un trucco vecchio quanto Leibniz: la chain rule.
La chain rule del calcolo differenziale dice che la derivata di una composizione è il prodotto delle derivate:
Una rete neurale è letteralmente una composizione di funzioni: input → layer 1 → layer 2 → ... → output → loss. Per calcolare il gradiente di L rispetto a un peso del layer 1, applichi la chain rule moltiplicando le derivate locali di ogni strato attraversato.
L'algoritmo è backpropagation e funziona in due passate. Forward pass: calcoli e memorizzi le attivazioni a ogni strato. Backward pass: partendo dalla loss, propaghi il gradiente all'indietro applicando la chain rule, strato per strato. Alla fine hai il gradiente rispetto a ogni peso della rete.
Esempio numerico — backprop a mano
Vediamolo su una rete minima: 1 input, 1 hidden con 1 neurone, 1 output. ReLU sull'hidden, lineare sull'output, MSE come loss. Il numero di pesi totali è 4 (2 pesi + 2 bias) — abbastanza piccoli da calcolare tutto a mano.
# Rete: x → [W1, b1, ReLU] → h → [W2, b2] → ŷ → MSE(y, ŷ)
# Setup iniziale
W1, b1 = 0.5, 0.1
W2, b2 = -0.8, 0.2
x, y_true = 2.0, 1.0 # target è 1.0
lr = 0.1
### FORWARD PASS ###
z1 = W1 * x + b1 # z1 = 0.5*2 + 0.1 = 1.1
h = max(0, z1) # h = 1.1 (ReLU non blocca)
y_pred = W2 * h + b2 # y_pred = -0.8*1.1 + 0.2 = -0.68
loss = (y_pred - y_true)**2 # loss = (-0.68 - 1.0)² = 2.8224
### BACKWARD PASS ###
# ∂loss/∂y_pred = 2 * (y_pred - y_true)
dL_dy = 2 * (y_pred - y_true) # = -3.36
# ∂loss/∂W2 = (∂loss/∂y_pred) * (∂y_pred/∂W2) = dL_dy * h
dL_dW2 = dL_dy * h # = -3.36 * 1.1 = -3.696
dL_db2 = dL_dy * 1 # = -3.36
# ∂loss/∂h = dL_dy * (∂y_pred/∂h) = dL_dy * W2
dL_dh = dL_dy * W2 # = -3.36 * -0.8 = 2.688
# ∂loss/∂z1 = dL_dh * (∂h/∂z1) = dL_dh * 1{z1 > 0}
dL_dz1 = dL_dh * (1 if z1 > 0 else 0) # = 2.688
# ∂loss/∂W1 = dL_dz1 * x
dL_dW1 = dL_dz1 * x # = 2.688 * 2 = 5.376
dL_db1 = dL_dz1 * 1 # = 2.688
### UPDATE PESI ###
W1 -= lr * dL_dW1 # 0.5 - 0.1*5.376 = -0.0376
b1 -= lr * dL_db1 # 0.1 - 0.1*2.688 = -0.1688
W2 -= lr * dL_dW2 # -0.8 - 0.1*(-3.696) = -0.4304
b2 -= lr * dL_db2 # 0.2 - 0.1*(-3.36) = 0.536
Dopo questo singolo aggiornamento, ricalcoliamo il forward pass con i nuovi pesi e vediamo che la loss è scesa — il modello ha imparato un pelo. Ripeti per migliaia o milioni di esempi, e i pesi convergono a una configurazione che minimizza la loss media sul dataset.
PyTorch e TensorFlow fanno esattamente questi calcoli, in automatico, per qualsiasi rete tu costruisca, sfruttando l'autograd: ogni operazione tensoriale costruisce un nodo del grafo computazionale; loss.backward() percorre il grafo all'indietro applicando la chain rule. È il motivo per cui non devi mai calcolare gradienti a mano in produzione.
SGD, mini-batch, batch — quanti esempi per passo?
Una scelta fondamentale: calcoli il gradiente medio su quanti esempi prima di fare un update? Tre varianti.
Batch Gradient Descent
Calcoli il gradiente sull'intero dataset prima di un update. Pro: gradiente esatto, traiettoria stabile. Contro: ogni passo richiede una passata su tutto il dataset; impraticabile su dati grandi. Non usato nel deep learning moderno.
SGD (Stochastic GD)
Calcoli il gradiente su un singolo esempio alla volta. Pro: update frequentissimi, esce dai minimi locali grazie al rumore. Contro: traiettoria molto rumorosa, non sfrutta la GPU. Usato raramente in puro, ma il rumore stocastico è visto come una regolarizzazione utile.
Mini-batch GD (lo standard)
Calcoli il gradiente su un mini-batch di N esempi (tipico: 32–512). Pro: compromesso ottimale tra velocità di update e qualità del gradiente, sfrutta perfettamente la parallelizzazione GPU. Contro: la batch size è un iperparametro da tunare. Standard de facto in tutto il deep learning moderno.
In pratica “SGD” nel linguaggio comune significa mini-batch SGD: si chiama così per ragioni storiche anche quando il batch è 256. La batch size influenza il training in modi sottili — batch grandi convergono più rapidamente in termini di update ma spesso meno bene in termini di accuracy finale, perché perdono il rumore regolarizzante. Una regola empirica nota come linear scaling rule: se raddoppi la batch size, raddoppia il learning rate.
Vanishing e exploding gradients
Backpropagation propaga il gradiente all'indietro moltiplicando le derivate locali strato dopo strato. In reti profonde questo prodotto può degenerare in due modi opposti, entrambi catastrofici.
Vanishing gradient: se le derivate locali sono mediamente < 1, il loro prodotto attraverso 50 strati tende a zero. I primi strati ricevono un gradiente praticamente nullo e non imparano. Storicamente questo è il motivo per cui addestrare reti profonde con sigmoidi era impossibile (sigmoide ha derivata max 0.25). È il problema che ResNet (2015) ha risolto con le skip connection: l'output di un blocco è F(x) + x, dove l'addendo x garantisce che il gradiente passi sempre con un cammino diretto.
Exploding gradient: se le derivate locali sono mediamente > 1, il prodotto cresce esponenzialmente, e gli update di gradient descent diventano enormi, distruggendo i pesi. Mitigazione: gradient clipping (se la norma del gradiente supera una soglia, la riduci proporzionalmente). È un trucco semplicissimo ma quasi sempre attivo nel training di Transformer.
# Gradient clipping in PyTorch
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
Optimizer moderni — SGD, Momentum, RMSprop, Adam, AdamW
Gradient descent puro funziona, ma converge lentamente e fa fatica in valli strette e oblique. La famiglia degli optimizer adattivi è nata per fare passi più intelligenti.
Momentum
Mantieni una media mobile esponenziale dei gradienti passati e usala come direzione del passo, invece del solo gradiente corrente:
L'intuizione: una pallina che rotola in una valle accumula velocità nella direzione di discesa e attraversa più in fretta le aree pianeggianti. Aiuta tantissimo in funzioni di loss con regioni a bassa curvatura. β tipico = 0.9.
RMSprop
Mantieni una media mobile del quadrato dei gradienti per ogni parametro, e scala l'update inversamente alla sua radice. In pratica: parametri con gradienti storicamente piccoli vengono aggiornati con passi grandi (li “accelera”), parametri con gradienti grandi vengono aggiornati con passi piccoli (li “smorza”). Equilibra l'update tra parametri eterogenei.
Adam (Adaptive Moment Estimation)
Combina Momentum e RMSprop: media mobile del primo momento (gradiente) e del secondo momento (gradiente al quadrato). È l'optimizer di default per la maggior parte dei modelli moderni dal 2015 in poi.
Iperparametri tipici: β1=0.9, β2=0.999, ε=1e-8. È meno sensibile alla scelta del learning rate rispetto a SGD — un η di default funziona quasi sempre, motivo per cui è così popolare.
AdamW
Variante di Adam che applica il weight decay (regolarizzazione L2) in modo “disaccoppiato” dalla scala del gradiente. Risolve un bug sottile nel Adam originale e dà risultati migliori. È lo standard de facto per addestrare Transformer e LLM dal 2019 in poi. Quando vedi nel codice torch.optim.AdamW, sai che il team ha scelto bene.
- Computer Vision (CNN, ResNet, ViT): SGD con momentum + LR schedule cosine. Spesso supera Adam su questi task.
- NLP / Transformer / LLM: AdamW. Sempre.
- Prototipi rapidi: Adam con LR di default. Funziona quasi sempre.
- Fine-tuning LLM: AdamW con LR molto piccolo (1e-5 o 1e-6) per non distruggere i pesi pre-addestrati.
Learning rate scheduling
Tenere lo stesso learning rate per tutto il training è quasi sempre subottimo. All'inizio vuoi passi grandi per esplorare lo spazio. Alla fine vuoi passi piccoli per affinare. Lo scheduling regola η nel tempo. Tre pattern principali.
Step decay: ogni N epoche, dividi LR per un fattore (es. ogni 30 epoche: LR ×= 0.1). Semplice, robusto, ancora usato.
Cosine annealing: LR segue una curva coseno da ηmax a ηmin durante il training. Smooth, no salti discontinui. Default per molti modelli moderni.
Warmup + decay: nei primi N step LR cresce linearmente da 0 a ηmax, poi decresce. Il warmup è cruciale per addestrare Transformer grandi: senza, il training spesso esplode nelle prime iterazioni perché i pesi sono casuali e i gradienti enormi. Tutti i grandi LLM (GPT, BERT, Llama) usano warmup + decay coseno o lineare.
# Esempio: warmup 1000 step + cosine decay con PyTorch
from torch.optim.lr_scheduler import LambdaLR
import math
def lr_lambda(step):
warmup_steps = 1000
total_steps = 100_000
if step < warmup_steps:
return step / warmup_steps # lineare 0→1
progress = (step - warmup_steps) / (total_steps - warmup_steps)
return 0.5 * (1 + math.cos(math.pi * progress)) # cosine 1→0
scheduler = LambdaLR(optimizer, lr_lambda)
Regolarizzazione — impedire alla rete di memorizzare
Una rete neurale con abbastanza parametri può memorizzare perfettamente qualsiasi training set, ma generalizzerà male su dati nuovi (overfitting). Le tecniche di regolarizzazione introducono pressioni che spingono il modello a imparare pattern generali invece di memorizzare esempi.
L2 (weight decay)
Aggiungi alla loss un termine proporzionale al quadrato della norma dei pesi:
Il modello è penalizzato se i pesi diventano grandi. In pratica equivale a sottrarre 2λθ a ogni update di gradient descent — un decay dei pesi verso zero. λ tipico = 1e-4. L1 è una variante che usa il valore assoluto invece del quadrato, e tende a produrre soluzioni sparse (molti pesi esattamente a zero) — utile per feature selection ma raramente usata in deep learning.
Dropout (Hinton et al., 2014)
Durante il training, a ogni mini-batch, “spegni” casualmente una frazione dei neuroni di ogni layer (tipicamente 20–50%). Forzi la rete a non dipendere da nessun singolo neurone — diverse parti della rete devono essere autosufficienti.
import torch.nn as nn
net = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(0.3), # 30% dei neuroni spenti durante training
nn.Linear(256, 10),
)
net.train() # dropout attivo
net.eval() # dropout disattivato in inference
In inference il dropout è disattivato e gli output sono scalati per compensare. model.eval() è il flag che gestisce questo — dimenticarlo è un bug classico e silenzioso.
Analogia: dropout è come un'azienda che a turno manda in ferie il 30% del personale durante l'anno, per evitare di diventare dipendente da nessuno. Quando tutti tornano (inference), l'azienda è più resiliente: non c'è nessuna funzione critica che dipende da una singola persona insostituibile. Dropout fa la stessa cosa con i neuroni.
Batch Normalization (Ioffe & Szegedy, 2015)
Normalizza le pre-attivazioni di ogni layer per avere media 0 e varianza 1 sul mini-batch corrente, poi le riscala con parametri appresi γ e β. Ha tre effetti positivi: (1) stabilizza il training permettendo learning rate più alti; (2) riduce la sensitività all'inizializzazione; (3) agisce come regolarizzazione perché introduce rumore dal mini-batch (mini-batch diversi danno statistiche diverse).
In inference BatchNorm usa media e varianza accumulate durante il training (running statistics), non quelle del batch corrente. Per modelli molto piccoli o batch size piccole (es. =1 in object detection), BatchNorm può degradare i risultati — si usano allora LayerNorm (normalizza per ogni esempio attraverso le feature, non per feature attraverso il batch), o GroupNorm. I Transformer usano sempre LayerNorm, non BatchNorm.
Data augmentation
La regolarizzazione più efficace di tutte: aumenta artificialmente la dimensione del training set. Per immagini: flip, rotazioni, crop random, jitter di colore. Per testo: synonym replacement, back-translation, random swap. Per audio: pitch shift, time stretch. Una rete che vede 10x esempi più diversi generalizza 10x meglio — questo è di solito l'investimento più redditizio in regolarizzazione.
Overfitting, underfitting, generalizzazione
Il bias-variance tradeoff è il concetto più importante di tutto il machine learning. L'errore di generalizzazione si decompone in tre termini:
Bias alto (underfitting): il modello è troppo semplice per il problema. Sbaglia sia sul training set che sul test. Sintomo: training loss alta. Rimedi: modello più grande, training più lungo, meno regolarizzazione, feature engineering, learning rate diverso.
Varianza alta (overfitting): il modello è troppo complesso per la quantità di dati che ha. Memorizza il training, fallisce sul test. Sintomo classico: training loss bassa, test loss alta, gap grande. Rimedi: più dati, data augmentation, regolarizzazione (L2, dropout, BatchNorm), modello più piccolo, early stopping.
Sweet spot: modello abbastanza grande da catturare la struttura, abbastanza regolarizzato da non memorizzare. La curva “double descent” (Belkin et al. 2019) ha mostrato che in deep learning moderno la regola classica “modello troppo grande → overfitting” non vale: oltre una certa soglia di parametri, l'errore di test scende di nuovo. È una delle ragioni per cui i grandi LLM con miliardi di parametri funzionano così bene.
| Sintomo | Diagnosi | Cosa fare |
|---|---|---|
| Train loss alta + test loss alta | Underfitting | Modello più grande, più epoche, meno regolarizzazione |
| Train loss bassa + test loss alta | Overfitting | Più dati, data augmentation, dropout, L2, early stopping |
| Train loss oscilla / diverge | Learning rate alto / numerica | LR più basso, gradient clipping, controllo init |
| Train loss costante | Vanishing gradient / dead neurons | ReLU→GELU, BatchNorm/LayerNorm, init He, skip connection |
| Test loss prima scende poi sale | Overfitting in corso | Early stopping al minimo della validation loss |
Train, validation, test — la divisione cruciale
Mai usare il training set per misurare la qualità del modello. Dividi i dati in tre parti.
- Training set (60–80%) — usato per aggiornare i pesi durante il training.
- Validation set (10–20%) — usato durante il training per monitorare la qualità, scegliere iperparametri (LR, dropout rate, architettura), e decidere early stopping. Il modello vede queste predizioni e le si adatta indirettamente.
- Test set (10–20%) — toccato una sola volta, alla fine, per riportare la performance reale del modello. Mai usato per decisioni di tuning, altrimenti la stima diventa ottimisticamente sbagliata.
Per dataset piccoli si usa la k-fold cross-validation: dividi i dati in k pieghe, addestri k volte usando ogni piega come test a turno. Dà una stima più affidabile ma costa k volte di più.
Early stopping
Monitora la validation loss durante il training. Se non migliora per N epoche consecutive (es. N=10), ferma il training e ripristina i pesi del miglior checkpoint. È la regolarizzazione più semplice e una delle più efficaci.
# Early stopping minimo
best_val_loss = float('inf')
patience, wait = 10, 0
best_weights = None
for epoch in range(max_epochs):
train_one_epoch(model)
val_loss = evaluate(model, val_loader)
if val_loss < best_val_loss:
best_val_loss = val_loss
best_weights = model.state_dict()
wait = 0
else:
wait += 1
if wait >= patience:
print(f"Early stopping at epoch {epoch}")
break
model.load_state_dict(best_weights)
Il training loop canonico
Mettendo insieme tutto, ecco la struttura standard di un training loop in PyTorch. Cinque righe nel cuore del loop — forward, loss, zero_grad, backward, step — sono le stesse identiche in qualsiasi training di deep learning moderno, dal toy model alla pipeline di addestramento di GPT-4.
import torch
import torch.nn as nn
model = MyModel()
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
criterion = nn.CrossEntropyLoss()
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
for epoch in range(num_epochs):
model.train()
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(device), y_batch.to(device)
y_pred = model(X_batch) # forward
loss = criterion(y_pred, y_batch) # misura errore
optimizer.zero_grad() # azzera gradienti precedenti
loss.backward() # calcola nuovi gradienti
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # clip
optimizer.step() # aggiorna pesi
scheduler.step() # scheduler LR fine-epoch
# Validation
model.eval()
with torch.no_grad():
val_loss, val_acc = evaluate(model, val_loader)
print(f"Epoch {epoch}: train loss={loss.item():.4f}, val acc={val_acc:.4f}")
optimizer.zero_grad() all'inizio di ogni batch, i gradienti dei batch precedenti si sommano e il training va in tilt. È un bug subdolo che ha mandato in tilt decine di carriere di principianti. La ragione storica: l'accumulo permette gradient accumulation su batch grandi spalmati su più forward pass, comodissimo quando la batch non sta in memoria GPU.
Continua il percorso
Adesso sai come una rete impara. Resta da capire quali geometrie di rete funzionano meglio su quali tipi di dati. Le architetture moderne sono il pezzo successivo:
- Architetture neurali — CNN, RNN, Transformer: come la struttura della rete deve adattarsi alla struttura dei dati
- Storia dell'AI — per inquadrare come backprop è arrivato nel 1986 e cosa è cambiato nei trent'anni successivi
- Reti Neurali — il mattone elementare, se vuoi ripassare
- Deep Learning — i miei progetti didattici — tutto questo applicato in PyTorch: il training loop canonico, Adam, dropout, BatchNorm, early stopping, sui dataset MNIST, CIFAR, ResNet-50