Hai tutti i pezzi: il neurone, gli strati, il passaggio in avanti, la backpropagation, la discesa del gradiente. È come avere sul tavolo il motore, le ruote e il telaio: manca l'auto. Questa lezione la assembla. Costruirai un ciclo di addestramento completo, il programma che prende dati veri, li divide in modo onesto, addestra una rete a mini-batch, ne monitora i progressi, la ferma al momento giusto e la valuta una sola volta su dati mai visti. Prima lo farai interamente a mano con NumPy, con tre ottimizzatori scritti da zero, poi con una libreria (scikit-learn e PyTorch, entrambi eseguiti qui), riconoscendo in ogni riga qualcosa che hai già scritto. È la lezione in cui il lavoro quotidiano con le reti smette di essere teoria.
Il problema è il riconoscimento di cifre scritte a mano: il dataset Digits di scikit-learn contiene 1797 immagini in scala di grigi di 8×8 pixel, quindi 64 feature per esempio, con 10 classi (le cifre da 0 a 9). Non è il famoso MNIST (28×28 pixel, 70 000 immagini), ma ha la stessa struttura e gira in pochi secondi su un portatile, e questo ti permette di fare decine di esperimenti (il dataset è descritto nella documentazione di scikit-learn, nelle fonti).
Il vocabolario del ciclo di addestramento
- Iterazione (o passo): un singolo aggiornamento dei pesi, calcolato su un mini-batch.
- Epoca: un passaggio completo su tutti gli esempi di addestramento. Con N esempi e mini-batch di dimensione B, un'epoca ha N/B iterazioni.
- Mini-batch: il sottoinsieme di esempi usato per stimare il gradiente a ogni iterazione (lezione sulla discesa del gradiente).
- Training, validation, test: i tre insiemi della prima lezione del corso. Qui li useremo con precisione: il training serve ad aggiornare i pesi, la validazione a monitorare l'overfitting e decidere quando fermarsi, il test a dare il numero finale, una volta sola.
- Curva di apprendimento: il grafico (o la tabella) di perdita e accuratezza su training e validazione, epoca per epoca. È lo strumento diagnostico principale.
- Early stopping: fermare l'addestramento quando la perdita di validazione smette di migliorare, e ripristinare i pesi migliori.
Le sei voci sono i punti di appoggio del resto della lezione, e vanno tenute distinte: l'iterazione è l'unità di calcolo, l'epoca è l'unità di tempo con cui si legge la curva, e training, validazione e test sono tre ruoli diversi dei dati, non tre quantità. La relazione utile da ricordare è N/B: con i 1077 esempi di training e mini-batch da 32, un'epoca vale 34 iterazioni, e questo numero ricompare nell'esperimento sulla dimensione del batch.
Il disegno mostra il ciclo. Per ogni mini-batch si fanno i cinque passi; a fine epoca si valuta sulla validazione e si decide se continuare. Tutto ciò che segue è la realizzazione, riga per riga, di questo schema.
Preparare i dati: la divisione e la standardizzazione, nell'ordine giusto
La prima parte è la più importante, e quella in cui si fanno più errori silenziosi. Dividiamo in tre insiemi in modo stratificato (stessa proporzione di cifre in ciascuno) e prima di qualunque trasformazione. La standardizzazione impara media e deviazione standard dal solo training, e le applica a validazione e test.
Nota sul codice: i blocchi Python di questo articolo vanno eseguiti in ordine, in un unico script o notebook. Ciascuno riusa gli import e le variabili definiti nei blocchi precedenti.
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X, y = load_digits(return_X_y=True)
X_tmp, X_te, y_tmp, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0) # 20% test
X_tr, X_va, y_tr, y_va = train_test_split(X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=0) # 60% train, 20% val
scaler = StandardScaler().fit(X_tr) # parametri imparati SOLO dal training
X_tr, X_va, X_te = scaler.transform(X_tr), scaler.transform(X_va), scaler.transform(X_te)
print("forme train %s validazione %s test %s" % (X_tr.shape, X_va.shape, X_te.shape))
print("quota di ciascuna cifra nel training (stratificata):", np.round(np.bincount(y_tr) / len(y_tr), 3))
forme train (1077, 64) validazione (360, 64) test (360, 64)
quota di ciascuna cifra nel training (stratificata): [0.099 0.101 0.098 0.101 0.101 0.101 0.101 0.099 0.097 0.1 ]
Cosa fa il codice. Il primo train_test_split mette da parte il 20% per il test; il secondo divide il resto in training (il 75% del restante, cioè il 60% del totale) e validazione (il 25%, cioè il 20% del totale). stratify mantiene le proporzioni delle 10 cifre in ogni insieme, e random_state rende tutto riproducibile. Lo StandardScaler viene addestrato (fit) solo su X_tr; le altre due matrici vengono solo trasformate. Se avessi addestrato lo scaler sull'intero dataset, il test avrebbe influenzato il training: il data leakage visto nella prima lezione. Con 10 classi quasi bilanciate l'accuratezza è una metrica ragionevole; con classi sbilanciate dovresti guardare anche precision e recall.
La rete, l'ottimizzatore e il ciclo, interamente a mano
Riscriviamo la rete della lezione sulla backpropagation (strati densi, ReLU nei nascosti, softmax con cross-entropia in uscita) con due novità: l'aggiornamento dei pesi è affidato a un metodo passo che sa usare tre ottimizzatori diversi, e c'è un metodo per misurare perdita e accuratezza. I tre ottimizzatori:
- SGD semplice: θ ← θ − α·g.
- SGD con momento: l'idea è una pallina che rotola giù per la collina, accumulando velocità. Si mantiene una «velocità» v, media mobile dei gradienti passati, e si aggiorna v ← 0,9·v + g, θ ← θ − α·v. Attraversa le zone piatte più in fretta e smorza le oscillazioni nelle valli strette.
- Adam (Kingma e Ba, 2015): tiene due medie mobili per ogni parametro, una del gradiente (m, come il momento) e una del suo quadrato (s). L'aggiornamento è θ ← θ − α·m̂/(√ŝ + ε), dove m̂ e ŝ sono le medie corrette per il bias dell'inizio. In pratica ogni parametro ha un proprio learning rate adattivo: parametri con gradienti grandi e rumorosi fanno passi più piccoli, quelli con gradienti piccoli e costanti passi più grandi.
I tre ottimizzatori sono tre risposte alla stessa domanda, come usare il gradiente g. SGD lo usa così com'è, e quindi eredita il suo rumore; il momento ne fa una media mobile, che smorza le oscillazioni; Adam normalizza anche la scala, parametro per parametro. Nel codice che segue, la differenza è tutta nel metodo passo: sono poche righe di aritmetica su array, senza nulla di nascosto.
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True)
E = np.exp(Z)
return E / E.sum(axis=1, keepdims=True)
class Rete:
def __init__(self, dimensioni, seed=0, init="he"):
rng = np.random.default_rng(seed)
coppie = list(zip(dimensioni[:-1], dimensioni[1:]))
self.W = [rng.normal(size=(i, o)) * np.sqrt(2.0 / i) if init == "he" else np.zeros((i, o)) for i, o in coppie]
self.b = [np.zeros(o) for _, o in coppie]
self.stato = {} # memoria degli ottimizzatori (velocità, medie mobili)
def avanti(self, X):
self.Z, self.A = [], [X]
A = X
for l, (W, b) in enumerate(zip(self.W, self.b)):
Z = A @ W + b
A = softmax(Z) if l == len(self.W) - 1 else np.maximum(0, Z)
self.Z.append(Z); self.A.append(A)
return A
def indietro(self, y):
m = len(y)
delta = self.A[-1] - np.eye(self.A[-1].shape[1])[y]
gW, gb = [None] * len(self.W), [None] * len(self.W)
for l in range(len(self.W) - 1, -1, -1):
gW[l] = self.A[l].T @ delta / m
gb[l] = delta.sum(axis=0) / m
if l > 0:
delta = (delta @ self.W[l].T) * (self.Z[l - 1] > 0)
return gW, gb
def passo(self, gW, gb, ottimizzatore, lr, t):
for k, (p, g) in enumerate(zip(self.W + self.b, gW + gb)): # p è un array: l'aggiornamento è in-place
if ottimizzatore == "sgd":
p -= lr * g
elif ottimizzatore == "momentum":
v = self.stato.setdefault(("v", k), np.zeros_like(p))
v *= 0.9; v += g
p -= lr * v
else: # adam
m1 = self.stato.setdefault(("m", k), np.zeros_like(p))
m2 = self.stato.setdefault(("s", k), np.zeros_like(p))
m1 *= 0.9; m1 += 0.1 * g
m2 *= 0.999; m2 += 0.001 * g * g
p -= lr * (m1 / (1 - 0.9 ** t)) / (np.sqrt(m2 / (1 - 0.999 ** t)) + 1e-8)
def perdita(self, X, y):
P = self.avanti(X)
return -np.log(np.clip(P[np.arange(len(y)), y], 1e-12, 1.0)).mean()
def accuratezza(self, X, y):
return (self.avanti(X).argmax(axis=1) == y).mean()
def addestra(rete, epoche, batch, lr, ottimizzatore, seed=0, pazienza=None):
rng = np.random.default_rng(seed)
t, storia, migliore = 0, [], (np.inf, None, 0)
for epoca in range(epoche):
ordine = rng.permutation(len(y_tr)) # rimescola a ogni epoca
for inizio in range(0, len(ordine), batch):
idx = ordine[inizio:inizio + batch]
rete.avanti(X_tr[idx])
gW, gb = rete.indietro(y_tr[idx])
t += 1
rete.passo(gW, gb, ottimizzatore, lr, t)
storia.append((rete.perdita(X_tr, y_tr), rete.perdita(X_va, y_va), rete.accuratezza(X_tr, y_tr), rete.accuratezza(X_va, y_va)))
if pazienza: # early stopping
if storia[-1][1] < migliore[0] - 1e-4:
migliore = (storia[-1][1], [p.copy() for p in rete.W + rete.b], epoca)
elif epoca - migliore[2] >= pazienza:
break
return storia, migliore
print("ottimizzatore lr perdita val. dopo 1, 5, 10, 30 epoche accuratezza val. finale")
for nome, lr in (("sgd", 0.1), ("momentum", 0.05), ("adam", 0.002)):
rete = Rete([64, 32, 10])
storia, _ = addestra(rete, epoche=30, batch=32, lr=lr, ottimizzatore=nome)
print("%-13s %-6g %-45s %.3f" % (nome, lr, [round(float(storia[i][1]), 3) for i in (0, 4, 9, 29)], storia[-1][3]))
ottimizzatore lr perdita val. dopo 1, 5, 10, 30 epoche accuratezza val. finale
sgd 0.1 [0.744, 0.285, 0.236, 0.212] 0.958
momentum 0.05 [0.393, 0.277, 0.265, 0.268] 0.967
adam 0.002 [1.379, 0.372, 0.262, 0.238] 0.958
Cosa fa il codice. La classe contiene tutto ciò che hai già scritto (avanti, indietro con le quattro equazioni) più il metodo passo. Un dettaglio tecnico importante: p -= lr * g modifica l'array in-place; poiché p è lo stesso oggetto contenuto in self.W o self.b, l'aggiornamento arriva alla rete. Per momento e Adam la rete conserva in self.stato gli array di velocità e medie mobili. La funzione addestra è il ciclo del disegno: per ogni epoca rimescola gli indici, scorre il training a mini-batch, fa avanti, indietro e passo, poi registra perdita e accuratezza su training e validazione; se è attivata la pazienza applica l'early stopping.
Il confronto mostra comportamenti diversi. Dopo una sola epoca il momento è già a 0,39 di perdita di validazione, SGD semplice a 0,74 e Adam a 1,38 (Adam con learning rate piccolo parte più piano). Dopo 5 e 10 epoche tutti si avvicinano, e a 30 epoche sono intorno a 0,21-0,27 con accuratezza di validazione tra 0,958 e 0,967. Non c'è un vincitore assoluto su questo problema piccolo: Adam è molto usato perché è meno sensibile alla scelta del learning rate su problemi grandi e diversi, ma qui la semplice discesa con momento fa altrettanto bene. Se confronti ottimizzatori, ricorda che ognuno ha il suo learning rate adatto: usare lo stesso valore per tutti confronterebbe in realtà quanto sono sfortunati con quel valore.
Perché il momento e Adam aiutano: un'intuizione con l'aritmetica
Immagina di scendere lungo una valle stretta e lunga, ripida sui due lati e quasi piatta lungo il fondo. Il gradiente ha una componente grande che rimbalza da un lato all'altro (segno alternato: +1, −1, +1, ...) e una componente piccola ma costante lungo il fondo (per esempio +0,1 a ogni passo). Con SGD semplice la prima ti fa zigzagare e la seconda ti fa avanzare lentamente. Con il momento, la velocità è una somma pesata dei gradienti passati: v ← 0,9·v + g.
Le componenti che si alternano si cancellano (la somma di +1, −1, +1, ... con pesi decrescenti resta piccola), mentre quella costante si accumula come una serie geometrica: v = g·(1 + 0,9 + 0,9² + ...) = g/(1 − 0,9) = 10·g. Lungo il fondo della valle il passo effettivo è dieci volte più grande, mentre lo zigzag è smorzato. È la ragione per cui il momento accelera dove il gradiente è consistente.
Adam fa un passo ulteriore: divide l'aggiornamento per la radice della media mobile del gradiente al quadrato. Se un parametro ha gradiente sempre intorno a 0,001, quella radice è circa 0,001 e il rapporto gradiente/radice è circa 1: il parametro fa un passo di ampiezza circa pari al learning rate. Se un altro ha gradiente sempre intorno a 100, la radice è circa 100 e il rapporto è ancora circa 1: stesso passo.
In pratica Adam normalizza la scala dei gradienti, parametro per parametro, ed è per questo che riesce con learning rate molto simili su problemi molto diversi, e perché usa valori tipici di 0,001-0,003 invece dei 0,1 di SGD. Il prezzo è che l'adattamento a gradienti rumorosi può, in certi problemi, generalizzare un po' peggio di un SGD ben regolato: non esiste un ottimizzatore migliore in assoluto.
Il learning rate, misurato
Nella lezione sulla discesa del gradiente hai visto il learning rate sulla parabola. Vediamolo su una rete vera, con SGD semplice e 20 epoche:
print("learning rate perdita val. finale accuratezza val. finale")
for lr in (0.001, 0.01, 0.1, 0.5, 2.0):
rete = Rete([64, 32, 10])
with np.errstate(all="ignore"):
storia, _ = addestra(rete, epoche=20, batch=32, lr=lr, ottimizzatore="sgd")
print("%-13g %17.4f %22.3f" % (lr, storia[-1][1], storia[-1][3]))
learning rate perdita val. finale accuratezza val. finale
0.001 1.8588 0.428
0.01 0.4757 0.897
0.1 0.2189 0.961
0.5 0.2753 0.964
2 8.4428 0.694
Cosa fa il codice. Ripete lo stesso addestramento cambiando solo il learning rate. Con 0,001 il modello impara troppo piano: dopo 20 epoche la perdita è 1,86 e l'accuratezza di validazione 0,43, ancora lontano dal convergere. Con 0,01 migliora (0,476, 0,897), con 0,1 è vicino all'ottimo (0,219, 0,961); 0,5 è ancora buono (0,275, 0,964) ma più rumoroso; con 2,0 i passi sono troppo grandi e la perdita esplode a 8,44, con accuratezza 0,69. La lezione è la stessa della parabola, ma ora ne vedi l'effetto concreto: il learning rate è l'iperparametro da sistemare per primo, e si esplora su scala logaritmica.
Leggere le curve di apprendimento: l'overfitting e l'early stopping
Addestriamo con Adam per fino a 200 epoche, ma con una pazienza di 10: se la perdita di validazione non migliora per 10 epoche consecutive ci fermiamo e ripristiniamo i pesi dell'epoca migliore. Poi, e solo ora, apriamo il test.
rete = Rete([64, 32, 10])
storia, migliore = addestra(rete, epoche=200, batch=32, lr=0.002, ottimizzatore="adam", pazienza=10)
epoca_migliore = migliore[2]
print("epoche eseguite: %d epoca migliore: %d" % (len(storia), epoca_migliore))
print("epoca perdita train perdita val. acc. train acc. val.")
for e in (0, 5, 10, epoca_migliore, len(storia) - 1):
print("%5d %13.4f %12.4f %10.3f %9.3f" % ((e,) + storia[e]))
for p, q in zip(rete.W + rete.b, migliore[1]): # ripristina i pesi dell'epoca migliore
p[...] = q
print("\nACCURATEZZA SUL TEST (una sola volta): %.3f" % rete.accuratezza(X_te, y_te))
previsioni = rete.avanti(X_te).argmax(axis=1)
errori = [(int(a), int(b)) for a, b in zip(y_te, previsioni) if a != b]
print("errori sul test: %d su %d; coppie (vera, prevista):" % (len(errori), len(y_te)), sorted(errori))
epoche eseguite: 42 epoca migliore: 31
epoca perdita train perdita val. acc. train acc. val.
0 1.2591 1.3788 0.632 0.619
5 0.1777 0.3299 0.962 0.936
10 0.0782 0.2590 0.988 0.956
31 0.0118 0.2334 1.000 0.958
41 0.0067 0.2348 1.000 0.964
ACCURATEZZA SUL TEST (una sola volta): 0.958
errori sul test: 15 su 360; coppie (vera, prevista): [(1, 8), (2, 1), (3, 8), (3, 9), (4, 1), (4, 8), (6, 1), (8, 1), (8, 1), (8, 5), (8, 7), (8, 9), (8, 9), (9, 3), (9, 5)]
Cosa fa il codice. L'addestramento si ferma dopo 42 epoche (10 dopo l'epoca migliore, la 31). Nella tabella si vede il divario tipico dell'overfitting: la perdita di training continua a calare (0,012 all'epoca migliore, 0,007 alla fine, accuratezza 1.000) mentre quella di validazione smette di migliorare (0,233) e poi sale leggermente (0,235). Il training ha raggiunto il 100%: la rete ha imparato a memorizzare quei 1077 esempi; la validazione sta intorno al 96%.
Il numero che conta, quello sul test, è 0,958. Sono 15 errori su 360, e molti riguardano cifre morfologicamente simili (un 8 scambiato con un 1 o un 9, per esempio): è un tipo di errore ragionevole per immagini di 8×8 pixel. Il ripristino dei pesi migliori con p[...] = q è necessario perché, al momento dello stop, i pesi attuali sono quelli di 10 epoche dopo il minimo.
Perché il test va aperto una volta sola? Perché ogni volta che guardi il test e poi cambi qualcosa (il learning rate, la dimensione della rete) lo stai usando per scegliere, e il numero smette di essere una stima onesta. Le scelte si fanno sulla validazione.
Gli effetti della dimensione del batch
print("dimensione batch passi per epoca perdita val. dopo 20 epoche acc. val.")
for batch in (8, 32, 128, len(y_tr)):
rete = Rete([64, 32, 10])
storia, _ = addestra(rete, epoche=20, batch=batch, lr=0.1, ottimizzatore="sgd")
print("%16d %14d %27.4f %9.3f" % (batch, int(np.ceil(len(y_tr) / batch)), storia[-1][1], storia[-1][3]))
dimensione batch passi per epoca perdita val. dopo 20 epoche acc. val.
8 135 0.2538 0.964
32 34 0.2189 0.961
128 9 0.2778 0.947
1077 1 1.1155 0.703
Cosa fa il codice. A parità di epoche e di learning rate, cambia quanti aggiornamenti si fanno: 135 passi per epoca con batch 8, 34 con 32, 9 con 128 e uno solo con il batch completo. Con lo stesso numero di epoche, il batch completo ha fatto 20 aggiornamenti in tutto e non è ancora arrivato (perdita 1,11, accuratezza 0,70); batch 8 e 32 sono vicini all'ottimo (0,254 e 0,219), batch 128 è un po' indietro (0,278). In generale, batch piccoli fanno più passi e rumorosi (talvolta con un effetto regolarizzante), batch grandi fanno passi precisi ma meno numerosi e sfruttano meglio le GPU. Cambiare la dimensione del batch di solito richiede di ritoccare il learning rate.
La trappola dell'inizializzazione a zero
zeri = Rete([64, 32, 10], init="zero")
storia, _ = addestra(zeri, epoche=5, batch=32, lr=0.1, ottimizzatore="sgd")
righe_identiche = np.allclose(zeri.W[0], zeri.W[0][:, [0]]) # tutte le colonne di W1 sono uguali alla prima?
print("accuratezza di validazione dopo 5 epoche: %.3f (il caso con 10 classi darebbe 0.100)" % storia[-1][3])
print("tutti i 32 neuroni nascosti hanno gli stessi pesi in ingresso:", righe_identiche)
accuratezza di validazione dopo 5 epoche: 0.100 (il caso con 10 classi darebbe 0.100)
tutti i 32 neuroni nascosti hanno gli stessi pesi in ingresso: True
Cosa fa il codice. Inizializza tutti i pesi a zero e addestra. Il risultato è l'accuratezza del caso (0,1): la rete non impara nulla di utile. Il motivo è la simmetria: se tutti i neuroni di uno strato partono con gli stessi pesi, ricevono gli stessi gradienti (l'equazione (3) della backpropagation dà per tutti la stessa colonna) e restano identici per sempre. Una rete con 32 neuroni nascosti identici è una rete con un neurone solo. Per questo i pesi si inizializzano a caso, con la scala di He per ReLU (lezione sulle reti a strati); i bias possono partire da zero perché il loro gradiente dipende dai pesi.
La stessa cosa con una libreria
Una libreria automatizza i tre ingredienti che hai scritto a mano: il calcolo dei gradienti, gli ottimizzatori e la gestione dei mini-batch. Resta il ciclo, identico nella struttura. Qui usiamo MLPClassifier di scikit-learn, che sulla macchina di lavoro c'è, con gli stessi iperparametri del nostro esperimento con Adam: 32 neuroni nascosti, learning rate 0,002, mini-batch da 32, al massimo 200 epoche, arresto dopo 10 epoche senza miglioramento. Due differenze vanno dichiarate: l'early stopping di scikit-learn si basa su una validazione interna del 10% del training (non sul nostro insieme di validazione), e l'inizializzazione dei pesi è diversa dalla nostra.
from sklearn.neural_network import MLPClassifier
mlp = MLPClassifier(hidden_layer_sizes=(32,), solver="adam", learning_rate_init=0.002, batch_size=32,
max_iter=200, early_stopping=True, n_iter_no_change=10, random_state=0)
mlp.fit(X_tr, y_tr)
n_par = sum(c.size for c in mlp.coefs_) + sum(i.size for i in mlp.intercepts_)
print("scikit-learn: epoche eseguite %d punteggio sulla validazione interna (10%% del training) %.3f" % (mlp.n_iter_, mlp.best_validation_score_))
print("accuratezza di validazione %.3f ACCURATEZZA SUL TEST %.3f parametri: %d" % (mlp.score(X_va, y_va), mlp.score(X_te, y_te), n_par))
scikit-learn: epoche eseguite 26 punteggio sulla validazione interna (10% del training) 0.944
accuratezza di validazione 0.967 ACCURATEZZA SUL TEST 0.967 parametri: 2410
Cosa fa il codice. fit contiene l'intero ciclo che hai scritto: rimescolamento a ogni epoca, mini-batch, passaggio in avanti, indietro e aggiornamento con Adam. I parametri sono 2410, come la somma 32·65 + 10·33 = 2080 + 330 che si ricava dalla formula della lezione sulle reti a strati. L'accuratezza sul test è 0,967 contro il nostro 0,958: la differenza è di 3 esempi su 360 e non è significativa, e deriva dall'inizializzazione diversa, dalla diversa quota di validazione e dalla sequenza casuale dei batch. È un buon esempio di una regola generale: due implementazioni corrette dello stesso modello non danno lo stesso numero, ma numeri vicini, e le differenze piccole vanno confrontate con la variabilità dovuta al seme (l'esercizio 3).
La stessa cosa in PyTorch
PyTorch automatizza gli stessi tre ingredienti: il grafo e la backpropagation (backward), gli ottimizzatori (torch.optim) e la gestione dei mini-batch (DataLoader). Il ciclo ha la stessa struttura del tuo. Il listato riusa i dati X_tr, y_tr, X_va, y_va, X_te, y_te già preparati sopra (con np), ed è stato eseguito con PyTorch 2.12.0 su CPU (CUDA_VISIBLE_DEVICES="", 2 thread), due volte, con torch.manual_seed(0) e uscita identica; ogni esecuzione dura da qualche secondo a mezzo minuto, a seconda del carico della macchina.
import torch
def a_tensore(a, dtype=torch.float32):
return torch.tensor(a, dtype=dtype)
dataset = torch.utils.data.TensorDataset(a_tensore(X_tr), a_tensore(y_tr, torch.long)) # le etichette di classe sono interi "long"
torch.manual_seed(0)
loader = torch.utils.data.DataLoader(dataset, batch_size=32, shuffle=True) # rimescola a ogni epoca
modello = torch.nn.Sequential(torch.nn.Linear(64, 32), torch.nn.ReLU(), torch.nn.Linear(32, 10))
ottimizzatore = torch.optim.Adam(modello.parameters(), lr=0.002)
funzione_perdita = torch.nn.CrossEntropyLoss() # vuole logit, applica softmax da sola
def valuta(Xs, ys):
modello.eval() # modalità di valutazione
with torch.no_grad(): # niente grafo: più veloce, meno memoria
uscita = modello(a_tensore(Xs))
return funzione_perdita(uscita, a_tensore(ys, torch.long)).item(), float((uscita.argmax(dim=1).numpy() == ys).mean())
migliore = (np.inf, None, 0)
for epoca in range(200):
modello.train() # modalità di addestramento
for xb, yb in loader:
ottimizzatore.zero_grad() # azzera i gradienti accumulati
perdita = funzione_perdita(modello(xb), yb) # avanti + perdita
perdita.backward() # indietro
ottimizzatore.step() # aggiornamento
perdita_val, acc_val = valuta(X_va, y_va)
if perdita_val < migliore[0] - 1e-4:
migliore = (perdita_val, {k: v.clone() for k, v in modello.state_dict().items()}, epoca)
elif epoca - migliore[2] >= 10:
break
modello.load_state_dict(migliore[1])
print("epoche eseguite: %d epoca migliore: %d perdita di validazione: %.4f" % (epoca + 1, migliore[2], migliore[0]))
print("PyTorch: accuratezza di validazione %.3f ACCURATEZZA SUL TEST %.3f parametri: %d" % (
valuta(X_va, y_va)[1], valuta(X_te, y_te)[1], sum(p.numel() for p in modello.parameters())))
epoche eseguite: 33 epoca migliore: 22 perdita di validazione: 0.1431
PyTorch: accuratezza di validazione 0.958 ACCURATEZZA SUL TEST 0.967 parametri: 2410
Cosa fa il codice, riga per riga. TensorDataset accoppia le feature e le etichette, e DataLoader produce i mini-batch rimescolati a ogni epoca: sostituisce il tuo rng.permutation e i tuoi cicli sugli indici. nn.Sequential è la rete 64 → 32 → 10. CrossEntropyLoss riceve i logit e applica internamente softmax e cross-entropia, per questo il modello non ha una softmax finale e le etichette devono essere interi (torch.long). Le quattro righe nel ciclo interno sono quelle che conosci: zero_grad(), il calcolo della perdita, backward() (la tua indietro) e step() (il tuo passo).
modello.eval() e torch.no_grad() in valuta sono due cose distinte: la prima cambia il comportamento di certi strati (dropout, batch normalization) tra addestramento e valutazione, la seconda disattiva la costruzione del grafo dei gradienti. Per salvare i pesi migliori dell'early stopping serve una copia (clone()), altrimenti il dizionario punterebbe agli stessi tensori che continuano a cambiare.
L'addestramento si ferma dopo 33 epoche con la migliore all'indice 22 (le epoche si contano da 0), perdita di validazione 0,143, accuratezza di validazione 0,958 e accuratezza sul test 0,967, con 2410 parametri (32·65 + 10·33 = 2080 + 330). Il nostro ciclo a mano si era fermato dopo 42 epoche, con la migliore alla 31 e 0,958 sul test; scikit-learn dà 0,967. La differenza è di 3 esempi su 360 e non è significativa: deriva dall'inizializzazione (PyTorch estrae i pesi da una distribuzione uniforme di ampiezza 1/√ingressi, noi usiamo la scala di He con una normale) e dalla sequenza casuale dei batch. Tre implementazioni corrette dello stesso modello non danno lo stesso numero, ma numeri vicini.
Diagnosticare un addestramento che non funziona: tre domande in ordine
Prima o poi una rete non imparerà e dovrai capire perché. Il ciclo che hai costruito ti dà i segnali per farlo; conviene porsi tre domande, nell'ordine, perché ciascuna esclude un'intera famiglia di cause.
1. La perdita di training scende? Se no, il problema è nell'ottimizzazione o nel codice, non nei dati. Controlla in quest'ordine: la perdita iniziale (con 10 classi il riferimento è ln 10 ≈ 2,30; la nostra rete ne ha dato 2,79 con l'inizializzazione di He, che non è nulla; un valore dieci volte più grande o nan significa bug), il learning rate (il test sopra: troppo piccolo non si muove, troppo grande esplode), le forme dei tensori, il gradient check della lezione precedente e, in PyTorch, la presenza di zero_grad(). Un buon test di sanità è chiedere alla rete di fare overfitting su pochissimi esempi (per esempio 20): se non riesce a memorizzarli il codice ha un errore.
2. La perdita di training scende ma quella di validazione no (o risale)? È overfitting: la rete memorizza. Le leve sono quelle della lezione sulla regolarizzazione (penalità L2, early stopping), una rete più piccola, più dati o, per le immagini, tecniche di data augmentation e dropout, che vedrai tra poco. Se il divario è enorme fin dalle prime epoche, controlla anche il leakage: un'accuratezza di validazione irrealisticamente alta è più spesso un errore nella preparazione dei dati che un miracolo del modello.
3. Entrambe sono basse e ferme? È underfitting: il modello non ha capacità sufficiente, o non ha informazione sufficiente nelle feature, o l'ottimizzazione si è fermata su un altopiano. Prova una rete più larga o più profonda, un learning rate diverso, più epoche, ma prima chiediti se i dati contengono davvero il segnale che cerchi. Nessuna architettura estrae da una feature ciò che non c'è.
Un'abitudine che vale oro: tieni sempre due numeri a portata di mano, la baseline banale (qui, prevedere la classe più frequente: circa 0,10 di accuratezza) e la perdita iniziale attesa (qui, dell'ordine di ln 10 ≈ 2,3; misurata 2,79). Con questi due riferimenti riconosci a colpo d'occhio un addestramento che non sta imparando o un risultato troppo bello per essere vero.
Tre esercizi per fissare le idee
Esercizio 1. Dimezza il numero di neuroni nascosti (da 32 a 16) e raddoppialo (64). Misura, con la stessa procedura, perdita e accuratezza di validazione a parità di epoche. Cosa ti aspetti per la dimensione del divario tra training e validazione? (Più neuroni, più capacità: il divario tende a crescere, e l'early stopping interviene prima.)
Esercizio 2. Aggiungi alla funzione di perdita una penalità L2 sui pesi (lezione sulla regolarizzazione) modificando indietro: a ogni gradiente di W si somma (λ/m)·W (non ai bias). Prova λ in 0,001, 0,01, 0,1 e osserva l'effetto sul divario tra training e validazione.
Esercizio 3. Ripeti l'addestramento con cinque semi diversi (cambia sia seed della rete sia quello di addestra) e riporta media e deviazione standard dell'accuratezza di validazione. Quanto vale la variabilità dovuta al caso? Servirà come metro per decidere se una differenza tra due configurazioni è reale o rumore.
Trade-off e trappole reali
- Usare il test per scegliere: iperparametri, architettura, numero di epoche. Si scelgono sulla validazione; il test si apre una volta, alla fine.
- Standardizzare prima della divisione: leakage. Lo scaler impara dal solo training.
- Dimenticare
zero_grad()in PyTorch: i gradienti si accumulano e l'addestramento diventa instabile. - Dimenticare
model.eval()etorch.no_grad()in valutazione: con dropout o batch norm i risultati sono sbagliati, e la memoria cresce inutilmente. - Non rimescolare i dati: se il dataset è ordinato per classe, ogni mini-batch contiene una sola cifra e l'addestramento è un disastro.
- Etichette del tipo sbagliato:
CrossEntropyLossvuole interi (torch.long), non float; e i logit, non le probabilità. - Monitorare solo la perdita di training: scende sempre. L'overfitting si vede nel divario con la validazione.
- Interpretare una singola esecuzione: ripeti con semi diversi prima di dire che un'idea «funziona»; le differenze di un paio di esempi sul test sono rumore.
- Learning rate non adattato al batch o all'ottimizzatore: SGD e Adam vivono su scale di learning rate diverse (0,1 contro 0,002 qui).
- Ripristinare i pesi migliori: se si dimentica, il modello finale è quello dopo l'overfitting.
Le dieci voci si dividono per il danno che fanno. Le prime due (test usato per scegliere, standardizzazione prima della divisione) producono un numero ottimista senza alcun errore visibile. Le voci su zero_grad, eval, no_grad e sul tipo delle etichette riguardano PyTorch: descrivono il comportamento documentato della libreria; il ciclo PyTorch qui sopra è stato eseguito e funziona, ma non ho provocato questi errori uno per uno per mostrarne l'effetto. Le altre sono abitudini di metodo (rimescolare, guardare il divario, ripetere con più semi, ripristinare i pesi migliori) che costano pochi minuti e proteggono dalle conclusioni sbagliate.
Domande frequenti
Quante epoche servono? Non si decide in anticipo: si fissa un numero alto e si usa l'early stopping sulla validazione. In questo esempio bastavano una trentina.
Perché la validazione non coincide con il test? Perché la validazione ha già influenzato le tue scelte (quando fermarsi, quale learning rate). Il test deve restare incontaminato.
È un risultato buono? 0,96 su 10 classi con 1077 esempi di training e una rete minuscola è un buon risultato per una baseline, ma una rete convoluzionale, che vedrai tra poco, sfrutta la struttura delle immagini e tipicamente fa meglio. Non c'è un «buono» assoluto: si confronta con una baseline (qui, la classe più frequente darebbe circa 0,10) e con modelli alternativi.
Dovrei usare Adam sempre? È una buona scelta di partenza perché richiede meno messa a punto, ma SGD con momento e un buon schedule di learning rate è ancora molto usato e, in alcuni problemi di visione, generalizza meglio. Dipende dal problema.
Che cosa sai fare adesso
Sai preparare i dati senza leakage, scrivere un ciclo di addestramento a mini-batch con tre ottimizzatori, leggere le curve di apprendimento e fermare l'addestramento al momento giusto, aprendo il test una volta sola. Sai anche che cosa succede con un learning rate sbagliato, con batch troppo grandi o con pesi tutti a zero, e hai visto il tuo ciclo riprodotto da una libreria con risultati vicini. Questa lezione riprende la backpropagation e le reti a strati; il passo successivo è la convoluzione, che applica queste idee a un dato con struttura, le immagini.
Nella stessa serie rimandano a questa lezione anche: Entropia, entropia incrociata e divergenza KL: perché le funzioni di costo sono tutte la stessa cosa, Addestrare una CNN in stile LeNet-5 con PyTorch.
Fonti
- D. Kingma, J. Ba, Adam: A Method for Stochastic Optimization (arXiv:1412.6980; ICLR 2015) - l'algoritmo di Adam, scheda raggiunta il 2026-11-12; B. Polyak, Some methods of speeding up the convergence of iteration methods, 1964 - il momento (citazione bibliografica, non consultata online)
- scikit-learn: sklearn.datasets.load_digits - 1797 campioni, 8×8 pixel, 64 feature, 10 classi, consultata il 2026-11-12
- scikit-learn: MLPClassifier - significato di
early_stopping,n_iter_no_changeebatch_sizeusati nell'esempio, pagina raggiunta il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare