Nelle lezioni scorse hai costruito, pezzo per pezzo, tutto ciò che serve: la convoluzione, il padding, lo stride, il pooling, il conteggio dei parametri, il ciclo di addestramento. Ora li metti insieme e ti poni la domanda che conta davvero: una rete convolutiva è meglio di una densa? La risposta che aspetti è sì, ed è quella che troveresti ripetuta ovunque. La risposta che troverai misurando, con più semi e un confronto onesto, è più sfumata, e proprio per questo istruttiva: ti mostra dove sta davvero il vantaggio della struttura convolutiva e quanto conta il resto della pipeline (l'augmentation, i dati, la valutazione).
Useremo di nuovo il dataset Digits di scikit-learn (1797 cifre 8×8), per tre motivi: è piccolo e gira in secondi su qualsiasi macchina, lo conosci già dalla lezione sull'addestramento di una rete densa in NumPy e PyTorch, e i risultati sono confrontabili con quelli. Scriverai una CNN come classe nn.Module di PyTorch, un ciclo di addestramento riusabile, e confronterai quattro configurazioni su cinque semi diversi. Imparerai anche a salvare un modello, a valutarlo in condizioni diverse da quelle di addestramento, e a riconoscere le trappole tipiche di una pipeline per immagini. Servono le forme e i parametri di una CNN della lezione precedente.
Come sono stati ottenuti i numeri: tutti i listati sono stati eseguiti due volte, con uscita identica, su CPU (nessuna GPU), con PyTorch 2.12.0, Python 3.14, scikit-learn 1.8.0 e torch.set_num_threads(2); l'intero script richiede circa 5,5 minuti sulla macchina usata (una sola misura). Con un'altra versione di PyTorch o un altro numero di thread le cifre possono cambiare nell'ultima cifra decimale: per questo il confronto si fa sulle differenze grandi rispetto alla variabilità tra semi, non sui millesimi.
Il vocabolario di PyTorch per le reti
- Tensore: un array multidimensionale (come in NumPy) che può vivere su CPU o GPU e tiene traccia delle operazioni per calcolare i gradienti.
nn.Module: la classe base di ogni modello. Una sottoclasse dichiara gli strati nel costruttore (__init__) e descrive il passaggio in avanti nel metodoforward. PyTorch scopre da solo i parametri contenuti negli strati assegnati come attributi, e li restituisce conmodel.parameters().state_dict: il dizionario nome → tensore di tutti i parametri di un modello; è il formato con cui si salva e si ricarica un modello addestrato.model.train()/model.eval(): interruttori che cambiano il comportamento di alcuni strati (dropout, batch normalization) tra addestramento e valutazione.torch.no_grad(): un contesto che disattiva il calcolo dei gradienti, per valutare più velocemente e con meno memoria.- Data augmentation: generare nuovi esempi di addestramento applicando trasformazioni che non cambiano l'etichetta (spostare, ruotare, ritagliare un'immagine). La rete vede sempre varianti diverse e impara a non dipendere da quelle.
I sei termini si dividono in due gruppi. nn.Module, state_dict e tensore descrivono come il modello è fatto e come si conserva; train(), eval() e no_grad() descrivono in che modalità lo si usa, e sono quelli che si dimenticano. In questa rete non ci sono dropout né batch normalization, quindi eval() non ne cambierebbe il risultato; lo si scrive comunque come abitudine, perché basta aggiungere uno strato per renderlo decisivo (vedi la lezione su dropout e batch normalization). L'augmentation, invece, è puro preprocessing e non dipende dal framework.
Il disegno mostra l'idea con una vera cifra 3 del dataset: l'originale, la stessa spostata di un pixel a destra, la stessa spostata a destra e in basso. Per una persona è sempre un 3; per un modello denso sono tre vettori di 64 numeri molto diversi, perché ciascun pixel ha cambiato valore.
I dati come tensori per una CNN
Una rete convolutiva vuole tensori (N, C, H, W): serve una dimensione dei canali anche per un'immagine in scala di grigi (C = 1). Il dataset fornisce le cifre già appiattite in 64 valori da 0 a 16: le riportiamo a (1, 8, 8) e le normalizziamo con la media e la deviazione standard del solo training (un unico valore scalare per l'intero training, perché i pixel hanno tutti lo stesso significato fisico).
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
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.set_num_threads(2) # i numeri di questo articolo sono ottenuti con 2 thread
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)
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)
media, dev = X_tr.mean(), X_tr.std() # un solo scalare, dal solo training
def a_tensore(a):
return torch.tensor(((a - media) / dev).reshape(-1, 1, 8, 8), dtype=torch.float32) # (N, 1, 8, 8)
T_tr, T_va, T_te = a_tensore(X_tr), a_tensore(X_va), a_tensore(X_te)
L_tr, L_va, L_te = [torch.tensor(a, dtype=torch.long) for a in (y_tr, y_va, y_te)] # etichette: interi 'long'
print("forma del training:", tuple(T_tr.shape), " validazione:", tuple(T_va.shape), " test:", tuple(T_te.shape))
print("media %.3f e deviazione %.3f dal training; tipo etichette: %s" % (media, dev, L_tr.dtype))
forma del training: (1077, 1, 8, 8) validazione: (360, 1, 8, 8) test: (360, 1, 8, 8)
media 4.885 e deviazione 6.016 dal training; tipo etichette: torch.int64
Cosa fa il codice. La divisione stratificata 60/20/20 è la stessa della lezione sulla rete densa, così i risultati sono confrontabili. a_tensore normalizza con le statistiche del training (leakage evitato anche qui) e usa reshape(-1, 1, 8, 8) per ottenere (N, 1, 8, 8): il -1 lascia a NumPy il compito di calcolare N. Le etichette sono interi di tipo long (64 bit), richiesti da CrossEntropyLoss.
La rete come nn.Module
Costruiamo una versione in scala ridotta di LeNet adatta a immagini 8×8: due blocchi convoluzione-ReLU-pooling con padding «same», poi due strati densi. Con 8×8 in ingresso, il primo pooling porta a 4×4 e il secondo a 2×2: il flatten produce 16·2·2 = 64 valori.
class CNN(nn.Module):
def __init__(self):
super().__init__() # inizializza la classe base: obbligatorio
self.conv = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # (1,8,8) -> (8,8,8) -> (8,4,4)
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2)) # (8,4,4) -> (16,4,4) -> (16,2,2)
self.fc = nn.Sequential(nn.Flatten(), nn.Linear(16 * 2 * 2, 32), nn.ReLU(), nn.Linear(32, 10))
def forward(self, x):
return self.fc(self.conv(x)) # logit: nessuna softmax finale
def MLP(): # il modello denso della lezione precedente, per il confronto
return nn.Sequential(nn.Flatten(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 10))
modello = CNN()
print("parametri CNN: %d parametri MLP: %d" % (sum(p.numel() for p in modello.parameters()), sum(p.numel() for p in MLP().parameters())))
z = torch.zeros(1, 1, 8, 8)
print("forma dopo la parte convolutiva:", tuple(modello.conv(z).shape), " forma dell'uscita:", tuple(modello(z).shape))
print("parametri registrati:", [n for n, _ in modello.named_parameters()])
parametri CNN: 3658 parametri MLP: 2410
forma dopo la parte convolutiva: (1, 16, 2, 2) forma dell'uscita: (1, 10)
parametri registrati: ['conv.0.weight', 'conv.0.bias', 'conv.3.weight', 'conv.3.bias', 'fc.1.weight', 'fc.1.bias', 'fc.3.weight', 'fc.3.bias']
Cosa fa il codice. Una sottoclasse di nn.Module dichiara gli strati nel costruttore e li compone in forward; la chiamata modello(x) invoca forward. Aver messo gli strati come attributi (self.conv, self.fc) li «registra»: named_parameters() li elenca con nomi del tipo conv.0.weight, conv.0.bias, ..., ed è ciò che l'ottimizzatore e state_dict useranno. Il conto dei parametri dà 3 658 per la CNN e 2 410 per il modello denso: la CNN ha circa il 50% di parametri in più, perché abbiamo scelto 8 e 16 filtri. Non sono modelli identici in capacità, e per questo il confronto sarà solo indicativo; la lezione è nel confrontare le differenze qualitative, non nel nome del vincitore su un decimale.
Un ciclo di addestramento riusabile
Scriviamo una funzione generale: riceve il modello, il seme e un flag per l'augmentation, addestra con Adam e early stopping sulla validazione, e ripristina i pesi migliori. L'augmentation è una traslazione casuale di −1, 0 o +1 pixel in ciascuna direzione, applicata solo al training e a ogni mini-batch (la validazione e il test non vanno mai modificati).
def trasla_batch(xb):
out = torch.zeros_like(xb)
dx, dy = torch.randint(-1, 2, (len(xb),)), torch.randint(-1, 2, (len(xb),)) # spostamento per ogni esempio
for a in (-1, 0, 1):
for b in (-1, 0, 1):
maschera = (dx == a) & (dy == b)
if maschera.any():
out[maschera] = torch.roll(xb[maschera], shifts=(a, b), dims=(2, 3))
return out
def addestra(modello, seme, augmentation=False, epoche=200, pazienza=10, lr=0.003):
torch.manual_seed(seme)
ott = torch.optim.Adam(modello.parameters(), lr=lr)
perdita_fn = nn.CrossEntropyLoss()
loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=32, shuffle=True)
migliore = (np.inf, None, 0)
for epoca in range(epoche):
modello.train()
for xb, yb in loader:
if augmentation:
xb = trasla_batch(xb)
ott.zero_grad()
perdita_fn(modello(xb), yb).backward()
ott.step()
modello.eval()
with torch.no_grad():
perdita_val = perdita_fn(modello(T_va), L_va).item()
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] >= pazienza:
break
modello.load_state_dict(migliore[1])
return epoca + 1
def accuratezza(modello, T, L):
modello.eval()
with torch.no_grad():
return (modello(T).argmax(dim=1) == L).float().mean().item()
def trasla(T, a, b): # sposta un intero insieme (per il test di robustezza)
return torch.roll(T, shifts=(a, b), dims=(2, 3))
torch.manual_seed(0)
cnn = CNN()
epoche_usate = addestra(cnn, seme=0)
print("CNN (seme 0): epoche eseguite %d accuratezza training %.3f validazione %.3f test %.3f" % (
epoche_usate, accuratezza(cnn, T_tr, L_tr), accuratezza(cnn, T_va, L_va), accuratezza(cnn, T_te, L_te)))
CNN (seme 0): epoche eseguite 36 accuratezza training 1.000 validazione 0.969 test 0.969
Cosa fa il codice. trasla_batch sceglie per ciascun esempio uno spostamento orizzontale e uno verticale in {−1, 0, 1} e li applica con torch.roll (che sposta i pixel e li fa «rientrare» dal lato opposto: per le cifre, che hanno margini quasi vuoti, è un'approssimazione ragionevole di una traslazione). La funzione raggruppa gli esempi per spostamento per evitare un ciclo per singola immagine. addestra è il ciclo della lezione precedente con tre differenze: il modello è un nn.Module; l'augmentation viene applicata solo se richiesta e solo ai batch di training; la dimensione del batch è 32. Notare che la validazione usata per l'early stopping non è aumentata: misura l'errore sulla distribuzione reale. Con il seme 0 la CNN si ferma dopo 36 epoche con training a 1,000, validazione a 0,969 e test a 0,969. Le funzioni accuratezza e trasla servono per la valutazione: la seconda sposta un intero insieme di immagini, e la useremo per misurare la robustezza.
Il confronto onesto: quattro configurazioni, cinque semi
Una sola esecuzione non dice nulla su una differenza di mezzo punto: i pesi iniziali e l'ordine dei batch cambiano il risultato di qualche esempio. Ripetiamo quindi ogni configurazione con cinque semi e riportiamo media e deviazione standard. Valutiamo il test in tre condizioni: immagini originali, spostate di un pixel a destra, spostate di un pixel a destra e in basso. Le ultime due simulano un piccolo cambiamento nella distribuzione dei dati (una cifra non perfettamente centrata, un nuovo scanner) che è frequente nella realtà.
configurazioni = (("MLP", MLP, False), ("CNN", CNN, False), ("MLP + augmentation", MLP, True), ("CNN + augmentation", CNN, True))
print("%-20s %-16s %-16s %-16s %s" % ("modello", "test originale", "test spostato (0,+1)", "spostato (+1,+1)", "epoche"))
for nome, costruttore, augmentation in configurazioni:
righe = []
for seme in range(5):
torch.manual_seed(seme)
m = costruttore()
epoche_eseguite = addestra(m, seme, augmentation)
righe.append((accuratezza(m, T_te, L_te), accuratezza(m, trasla(T_te, 0, 1), L_te), accuratezza(m, trasla(T_te, 1, 1), L_te), epoche_eseguite))
r = np.array(righe)
print("%-20s %.3f +- %.3f %.3f +- %.3f %.3f +- %.3f %.0f" % (
(nome,) + tuple(v for k in range(3) for v in (r[:, k].mean(), r[:, k].std())) + (r[:, 3].mean(),)))
modello test originale test spostato (0,+1) spostato (+1,+1) epoche
MLP 0.969 +- 0.004 0.411 +- 0.015 0.133 +- 0.008 29
CNN 0.966 +- 0.004 0.582 +- 0.045 0.289 +- 0.013 34
MLP + augmentation 0.930 +- 0.012 0.916 +- 0.019 0.868 +- 0.024 71
CNN + augmentation 0.967 +- 0.006 0.949 +- 0.014 0.930 +- 0.014 54
Cosa fa il codice. Per ciascuna delle quattro configurazioni addestra cinque modelli con semi diversi, valuta ognuno sul test originale e sul test spostato, e stampa media e deviazione standard. Leggi la tabella con attenzione, perché contiene tre messaggi distinti.
1. Sul test originale la CNN non è meglio del modello denso. L'accuratezza è 0,969 per l'MLP e 0,966 per la CNN, con deviazioni di 0,004: la differenza di tre millesimi è dentro il rumore tra semi. Non è un errore del codice: con immagini minuscole (8×8, un pixel è già il 12% del lato) e dati centrati con cura, il modello denso ha tutto ciò che gli serve. Il vantaggio delle convoluzioni si vede con immagini più grandi, in cui il numero di parametri di uno strato denso esplode, e quando le immagini non sono allineate. Chi presenta una CNN come «sempre migliore» sta omettendo il confronto con una baseline densa.
2. Basta spostare di un pixel per mettere in crisi il modello denso. Con uno spostamento (0, +1) l'MLP scende da 0,969 a 0,411; con (+1, +1) a 0,133, praticamente il caso. La CNN senza augmentation fa meglio ma non bene: 0,582 e 0,289, con una deviazione di 0,045 sul primo caso, cioè una forte dipendenza dal seme. Il pooling offre un'invarianza parziale (come hai misurato nella lezione precedente), non sufficiente per spostamenti sistematici con una rete addestrata solo su immagini centrate. Questo è il tipico overfitting alla distribuzione di addestramento: l'accuratezza di validazione resta alta perché la validazione ha le stesse caratteristiche del training.
3. L'augmentation cambia il quadro. La CNN addestrata con spostamenti casuali di ±1 pixel mantiene 0,967 sul test originale e ottiene 0,949 e 0,930 sui test spostati: perde pochissimo e guadagna moltissimo in robustezza. Sul caso più difficile, (+1, +1), supera l'MLP con augmentation (0,868) di sei punti, con deviazioni di 0,014 e 0,024: una differenza netta. Per l'MLP, invece, l'augmentation aiuta la robustezza (0,916 e 0,868) ma costa sul test originale (scende a 0,930) e richiede più epoche: un modello denso deve imparare ogni traslazione separatamente, mentre la convoluzione condivide i pesi tra le posizioni, e l'augmentation fa leva su questa struttura. È la combinazione, struttura giusta più dati variati, a funzionare. Una cautela di onestà: i test spostati usano la stessa famiglia di trasformazioni (traslazioni di un pixel) con cui l'augmentation ha addestrato, quindi misurano quanto bene i modelli hanno imparato quella variazione, non una robustezza generale. Il numero di epoche cresce (da circa 34 a 54 per la CNN) perché il compito diventa più difficile e l'early stopping interviene più tardi.
Come leggere una tabella di risultati senza farsi ingannare
Una regola pratica utile: con cinque semi, la media ha un'incertezza (errore standard) di circa la deviazione standard divisa per √5, cioè deviazione/2,2. Per l'MLP sul test originale la deviazione è 0,004, quindi la media 0,969 è incerta di circa ±0,002; per la CNN la deviazione è 0,004 e l'incertezza ±0,002. La differenza di 0,003 tra le medie è dell'ordine di un errore standard: non decisiva. Sugli spostamenti, invece, le differenze (0,41 contro 0,95 fra l'MLP senza augmentation e la CNN con augmentation) sono decine di volte più grandi delle deviazioni: nessun dubbio. Il criterio da tenere a mente: una differenza ha senso solo se supera nettamente la variabilità tra semi. Se le tue configurazioni differiscono meno, o ripeti con più semi, o smetti di dire che una vince.
C'è una seconda lettura. Il test di robustezza che abbiamo costruito (spostare di un pixel) è una forma di cambio di distribuzione tra le molte possibili: rotazioni, cambi di luminosità, rumore, sfocature, sfondi diversi. Un modello robusto a una non lo è automaticamente alle altre. L'augmentation rende robusti a ciò che hai deciso di simulare; per scoprire a cosa il modello non è robusto bisogna provare, ed è un'attività che nei progetti seri occupa tempo come l'addestramento.
Come si decide quale augmentation usare
Le trasformazioni vanno scelte con conoscenza del dominio, non a caso. Tre domande aiutano. Preserva l'etichetta? Una traslazione di un pixel non cambia una cifra; un ribaltamento orizzontale cambia un 2 in qualcosa che non è più un 2, e un 3 in un ε. Per una foto di un gatto il ribaltamento è lecito; per un testo no. Rispecchia le variazioni reali? Se in produzione le cifre arrivano da uno scanner con piccole rotazioni, aggiungere rotazioni di pochi gradi serve; aggiungere rotazioni di 90° no. Quanto? Troppa augmentation rende il compito impossibile e abbassa l'accuratezza sul test originale: lo abbiamo visto per l'MLP, che con ±1 pixel perde quasi quattro punti sul test originale perché la sua capacità è poca e deve spartirla tra tutte le varianti. La CNN, grazie ai pesi condivisi, ha un vantaggio strutturale. La procedura corretta è sempre la stessa: si prova una trasformazione per volta, si misura sulla validazione e sul test di robustezza, e si tiene ciò che aiuta.
Salvare, ricaricare e usare il modello in inferenza
Un modello addestrato serve se lo puoi riusare. In PyTorch si salva il state_dict (i soli parametri), non l'intero oggetto: per ricaricarlo si ricostruisce la classe e si carica il dizionario. Qui usiamo un buffer in memoria al posto di un file, per non dipendere dal filesystem, ma il procedimento con torch.save(modello.state_dict(), "modello.pt") e torch.load("modello.pt") è identico.
import io
torch.manual_seed(0)
finale = CNN()
addestra(finale, seme=0, augmentation=True)
buffer = io.BytesIO()
torch.save(finale.state_dict(), buffer) # salva SOLO i parametri
buffer.seek(0)
ricaricato = CNN() # ricostruisce la stessa architettura
ricaricato.load_state_dict(torch.load(buffer))
ricaricato.eval() # modalità di inferenza
with torch.no_grad():
logit = ricaricato(T_te[:5]) # un batch di 5 immagini
probabilita = torch.softmax(logit, dim=1) # la softmax si applica fuori dal modello, in inferenza
print("previsioni:", logit.argmax(dim=1).tolist(), " etichette vere:", L_te[:5].tolist())
print("confidenza massima per ciascuna: ", np.round(probabilita.max(dim=1).values.numpy(), 3))
print("uscite identiche prima e dopo il salvataggio:", torch.allclose(finale.eval()(T_te[:5]), logit))
previsioni: [7, 6, 3, 7, 7] etichette vere: [7, 6, 3, 7, 7]
confidenza massima per ciascuna: [0.997 0.955 0.998 0.99 0.913]
uscite identiche prima e dopo il salvataggio: True
Cosa fa il codice. Addestra un modello finale (con augmentation), ne salva i parametri in un buffer, ricostruisce un nuovo oggetto CNN() con la stessa architettura e ci carica i pesi. eval() disattiva i comportamenti tipici dell'addestramento. In inferenza il modello produce logit; per ottenere probabilità si applica softmax fuori dal modello (la CrossEntropyLoss la applicava internamente durante l'addestramento). La verifica finale conferma che le uscite sono identiche prima e dopo il salvataggio: è un controllo che vale la pena fare ogni volta che si implementa il caricamento di un modello, perché un'architettura ricostruita in modo leggermente diverso darebbe un errore di forma, oppure (peggio) pesi caricati nell'ordine sbagliato.
Le trappole di una pipeline per immagini
try:
CNN()(torch.zeros(5, 8, 8)) # manca la dimensione dei canali
except RuntimeError as e:
print("immagini senza canale:", str(e)[:85])
try:
nn.CrossEntropyLoss()(CNN()(T_tr[:4]), L_tr[:4].float()) # etichette float invece di long
except (RuntimeError, TypeError) as e:
print("etichette float: ", str(e)[:85])
immagini senza canale: Given groups=1, weight of size [8, 1, 3, 3], expected input[1, 5, 8, 8] to have 1 cha
etichette float: expected target dtype to be Long or Byte, but got Float
Cosa fa il codice. Riproduce due errori che incontrerai davvero. Il primo: passare una pila di immagini (N, H, W) invece di (N, C, H, W). PyTorch interpreta il tensore (5, 8, 8) come una singola immagine con 5 canali, e il messaggio («expected input[1, 5, 8, 8] to have 1 channels») ti dice proprio questo: la rete si aspettava un solo canale. Il secondo: passare etichette di tipo float alla CrossEntropyLoss di classificazione, che per gli indici di classe vuole interi: il messaggio dice «expected target dtype to be Long or Byte, but got Float».
Dal laboratorio alla produzione: una checklist
- Versiona insieme modello e preprocessing: la media 4,885 e la deviazione 6,016 sono parte del modello quanto i pesi. Se cambiano, le previsioni cambiano senza che nessun errore venga segnalato.
- Valida l'ingresso: forma, tipo, intervallo dei valori. Un'immagine in scala 0-255 invece che 0-16 non produce un errore: produce probabilità senza senso con alta confidenza.
- Non fidarti della confidenza: la massima probabilità della softmax è spesso alta anche per input che il modello non ha mai visto e non capisce (nell'esempio, confidenze fra 0,913 e 0,998 su cinque cifre tutte giuste). Non è una misura calibrata dell'incertezza.
- Monitora i dati in produzione: se la distribuzione cambia (come il nostro spostamento di un pixel), l'accuratezza cala senza che tu lo sappia. Servono controlli su statistiche semplici (media e deviazione dei pixel, distribuzione delle classi previste) e un campione di esempi etichettati periodicamente.
- Registra le condizioni: seme, versione delle librerie, iperparametri, divisione dei dati. Senza, il risultato non è riproducibile.
Le cinque voci hanno una radice comune: il modello che porti in produzione non è solo l'insieme dei pesi, ma pesi, preprocessing, forma e tipo dell'ingresso e condizioni di addestramento. Se una di queste cose cambia in silenzio, la rete continua a rispondere con sicurezza, e per questo i controlli vanno messi dove l'errore si manifesta come un numero plausibile.
Tre esercizi
Esercizio 1. Aggiungi alla funzione di augmentation piccole rotazioni (±10°) usando torchvision.transforms.functional.rotate oppure, se non vuoi dipendenze, spostamenti più ampi (±2 pixel). Misura accuratezza sul test originale e sui test spostati. Cambia qualcosa sul test originale?
Esercizio 2. Costruisci un test di robustezza diverso, per esempio aggiungendo rumore gaussiano ai pixel (con deviazione 0,3 nelle unità normalizzate). L'augmentation per traslazione aiuta anche qui? (Probabilmente poco: ciascuna augmentation rende robusti solo alla variazione che simula.)
Esercizio 3. Ripeti il confronto tra MLP e CNN con 10 semi invece di 5, e calcola l'errore standard di ciascuna media. La differenza sul test originale risulta ora significativa?
Trade-off e trappole reali
- Presentare un solo seme: una differenza di 0,003 (come quella tra MLP e CNN sul test originale) non permette di dire che un modello è migliore. Riporta media e deviazione su più semi.
- Augmentation applicata a validazione o test: sono distorsioni pensate per l'addestramento; applicarle alla valutazione misura un'altra cosa, e se hai il test aumentato casualmente, il risultato non è riproducibile.
- Augmentation che cambia l'etichetta: una rotazione di 180° trasforma un 6 in un 9. Le trasformazioni devono preservare il significato: per le cifre, piccole traslazioni e rotazioni, non ribaltamenti.
- Valutare solo sulla distribuzione di addestramento: l'accuratezza sul test originale era identica per due modelli che, spostando di un pixel, hanno prestazioni molto diverse (0,41 contro 0,95). Prepara sempre dei test di robustezza che rispecchiano come cambiano i dati in produzione.
- Normalizzazione inconsistente: la stessa media e deviazione del training vanno usate in produzione. Salva questi numeri insieme al modello.
- Dimenticare
model.eval()prima dell'inferenza (con dropout o batch norm cambia il risultato) etorch.no_grad()(spreca memoria). - Salvare l'intero oggetto con pickle invece del
state_dict: dipende dalla struttura esatta del codice e si rompe al primo refactoring. - Riproducibilità: fissare
torch.manual_seedaiuta (qui, su CPU, due esecuzioni hanno dato uscite identiche), ma su GPU alcune operazioni sono non deterministiche; per confronti seri, ripeti con più semi anziché inseguire un singolo risultato esatto. - Un modello più grande non è la soluzione: qui il fattore decisivo era l'augmentation, non la capacità.
Le nove voci si raggruppano in tre famiglie: statistica (un solo seme, test che non rispecchiano la produzione), disciplina dei dati (augmentation sbagliata, normalizzazione incoerente) e disciplina del codice (eval(), pickle, riproducibilità). L'ultima voce è la lezione sintetica del confronto: davanti a un risultato debole, prima di ingrandire il modello conviene chiedersi se i dati e la valutazione sono quelli giusti.
Domande frequenti
Quando una CNN batte davvero una rete densa? Quando le immagini sono abbastanza grandi che uno strato denso avrebbe milioni di parametri, quando gli oggetti compaiono in posizioni diverse e quando serve riusare caratteristiche locali. Su dati piccoli, centrati e a bassa risoluzione, come questi, la differenza si riduce o sparisce.
L'augmentation non è «barare»? No: incorpora nella procedura di addestramento una conoscenza vera del problema (una cifra spostata di un pixel è la stessa cifra). È lecito finché le trasformazioni sono coerenti con i dati reali e finché la valutazione resta su dati non aumentati.
Perché i dati di validazione non sono aumentati? Perché la validazione deve misurare la qualità sul tipo di dati che incontrerai. Se vuoi misurare la robustezza, si fa con test dedicati, come quelli spostati sopra.
Posso ripetere l'esperimento con MNIST o con immagini più grandi? Sì, e ti consiglio di farlo: con immagini di 28×28 o più, il numero di parametri di una rete densa cresce rapidamente e il divario con una CNN a parità di parametri tende ad aprirsi, a favore della CNN. Il codice di questa lezione richiede pochi cambi (le dimensioni 8×8, il numero di canali in ingresso, il valore di nn.Linear dopo il flatten, da ricalcolare con un passaggio di prova); le librerie come torchvision scaricano i dataset e offrono trasformazioni di augmentation già pronte. La cautela resta la stessa: più semi, un confronto con una baseline densa, un test di robustezza.
Quante epoche con l'augmentation? Più che senza: il compito è più difficile. L'early stopping lo gestisce da solo.
Che cosa sai fare adesso
Sai scrivere una CNN come nn.Module, addestrarla con early stopping e confrontarla con un modello denso su cinque semi. Sai leggere il risultato: sul test originale la CNN non batte il modello denso (0,966 contro 0,969, dentro il rumore), ma sotto spostamenti di un pixel il modello denso scende a 0,41 e 0,13 e la CNN con augmentation resta a 0,95 e 0,93. Sai salvare e ricaricare i soli parametri con state_dict, verificare che le uscite non cambino e riconoscere le trappole tipiche (canali mancanti, etichette del tipo sbagliato, augmentation sulla valutazione, un solo seme). Nella lezione successiva affronti l'architettura che ha reso addestrabili reti con decine e centinaia di strati: le connessioni residuali, la convoluzione 1×1 e i blocchi Inception.
Checklist di revisione prima di fidarsi di una pipeline per immagini
Gli errori più costosi in una pipeline per immagini non fanno crashare niente: producono numeri plausibili e sbagliati. Prima di credere a un risultato, verifica questi punti, che nascono tutti dalle trappole viste in questa lezione. (1) Modalità: model.train() durante l'addestramento e model.eval() con torch.no_grad() nella valutazione e nell'inferenza; dimenticare l'eval() lascia attivi dropout e statistiche di batch e rende le previsioni variabili da una chiamata all'altra. (2) Stessa preparazione dei dati in addestramento e in produzione: normalizzazione, dimensione, ordine dei canali e tipo numerico devono coincidere, e i valori di normalizzazione vanno calcolati sul solo training set e salvati insieme al modello.
(3) Separazione vera fra training e test: se la stessa immagine, o varianti quasi identiche di essa, compaiono da entrambe le parti, la metrica misura la memoria e non la generalizzazione. (4) Semi e ripetizioni: un confronto fra due configurazioni basato su una sola esecuzione non è un confronto, perché il rumore dell'inizializzazione può avere la stessa grandezza della differenza; riporta media e deviazione su più semi. (5) Forma dei tensori: stampa le forme a ogni passo la prima volta che costruisci il modello, perché un errore di una dimensione si propaga in silenzio nelle operazioni che accettano il broadcasting. (6) Dispositivo: modello e dati devono stare sullo stesso dispositivo, e ricaricare un modello salvato su GPU in un ambiente senza GPU richiede di indicare dove mappare i pesi.
Nella stessa serie rimandano a questa lezione anche: Leggere una CNN: feature map, global pooling e U-Net, Transfer learning: riusare una CNN già addestrata.
Fonti
- PyTorch Tutorials: Saving and Loading Models - salvare e ricaricare i soli parametri con
state_dict, pagina raggiunta (HTTP 200), consultata il 2026-11-12 - PyTorch: torch.nn.CrossEntropyLoss - perdita che prende logit e indici di classe (da cui l'obbligo delle etichette intere), pagina raggiunta, consultata il 2026-11-12
- PyTorch: torch.nn.Module - classe base dei modelli,
parameters(),train(),eval(), pagina raggiunta, consultata il 2026-11-12 - scikit-learn: sklearn.datasets.load_digits - 1797 campioni, immagini 8×8, interi da 0 a 16, letti nella pagina, consultata il 2026-11-12
- Y. LeCun, L. Bottou, Y. Bengio, P. Haffner, Gradient-based learning applied to document recognition, Proceedings of the IEEE, 86(11), 1998 - l'architettura LeNet-5 di cui la rete è una versione ridotta (non riletta online per questo lavoro)
Commenti
Caricamento commenti...
Accedi per commentare