Hai un problema di classificazione di immagini e, invece di milioni di esempi etichettati, ne hai poche centinaia, forse poche decine. Addestrare una rete convolutiva da zero con così pochi dati dà risultati instabili: la rete impara a memoria i pochi esempi e sbaglia su quelli nuovi. Il transfer learning (apprendimento per trasferimento) è la risposta standard: si parte da una rete già addestrata su un compito grande, se ne tiene la parte che estrae le caratteristiche e la si adatta al tuo compito. Il tutorial ufficiale di PyTorch lo descrive proprio così: una rete addestrata su un dataset molto grande (l'esempio è ImageNet, 1,2 milioni di immagini in 1000 categorie) usata o come inizializzazione, o come estrattore di caratteristiche fisso.
Prima di fidarti della tecnica, però, conviene vedere quando funziona e quando no, perché il trasferimento non è garantito. Questa lezione chiude il modulo con tre esperimenti misurati, in cui capiterà di vedere il trasferimento aiutare molto, aiutare poco, e perfino peggiorare il risultato.
Prerequisiti: conosci l'addestramento di una piccola CNN con PyTorch (lezione su LeNet), la batch normalization e il suo comportamento in train() ed eval() (lezione su dropout e batch normalization) e l'idea che gli strati intermedi di una CNN producano mappe di caratteristiche da cui si può ripartire (lezione sulle feature map; per i blocchi residuali dei backbone moderni, la lezione sulle reti residuali).
Una scelta va dichiarata subito. Gli esperimenti di questa lezione non usano ImageNet né un ResNet: usano le immagini 8×8 del dataset Digits di scikit-learn, che sono già sulla tua macchina, e un backbone piccolo (13 152 parametri) che pre-addestriamo noi stessi. Il vantaggio è che puoi rieseguire tutto in pochi minuti su CPU, senza scaricare né dati né pesi. Il prezzo è che i numeri non si possono trasportare così come sono sul tuo problema reale: ciò che si trasporta è il metodo, cioè confrontare sempre con una baseline da zero e su più semi. Più avanti c'è uno schema, non eseguito qui, per fare la stessa cosa con un ResNet18 di torchvision. Tutti i listati sono stati eseguiti con Python 3.14, PyTorch 2.12.0 (CPU, 2 thread), scikit-learn 1.9.0 e NumPy 2.4.6, due volte di seguito con uscita identica.
Il vocabolario
- Modello preaddestrato: una rete già addestrata su un compito di partenza (il compito sorgente), con i suoi pesi disponibili per il riuso.
- Backbone (dorsale): la parte della rete che estrae le caratteristiche, tutto ciò che sta prima dello strato di classificazione finale.
- Testa (head): lo strato finale che trasforma le caratteristiche nelle previsioni del tuo compito. Si sostituisce con una nuova, perché le classi del tuo problema sono diverse.
- Congelare (freeze) uno strato: impedire che i suoi pesi cambino durante l'addestramento, con
requires_grad = False. - Estrazione di caratteristiche (feature extraction): usare il backbone congelato come un calcolatore fisso e addestrare solo la nuova testa.
- Fine-tuning (messa a punto): continuare ad addestrare anche i pesi del backbone, con un learning rate piccolo, partendo da quelli preaddestrati.
- Dominio: il tipo di dati (foto naturali, radiografie, cifre scritte a mano). Il divario di dominio tra sorgente e bersaglio è ciò che decide se il trasferimento aiuta.
Le ultime tre voci descrivono una scelta, non un oggetto: estrazione e fine-tuning sono le due strategie che confronteremo, e il dominio è la variabile che, come vedrai nell'esperimento 2, decide quale delle due conviene. Le prime quattro voci sono invece parti della macchina, e l'ordine non conta: puoi leggerle in qualsiasi sequenza.
Perché funziona: le caratteristiche sono (in parte) generali
Gli strati iniziali di una CNN imparano filtri semplici (bordi orientati, macchie di colore) e quelli successivi li combinano in strutture sempre più specifiche. I primi sono generali: servono per quasi qualsiasi immagine. Gli ultimi sono specifici del compito su cui la rete è stata addestrata. J. Yosinski e collaboratori hanno misurato proprio questo, strato per strato: nel loro abstract riportano tre risultati. La trasferibilità è ostacolata da due fattori distinti: la specializzazione dei neuroni più alti sul compito originale e le difficoltà di ottimizzazione che nascono quando si spezza una rete fra neuroni adattati insieme. Diminuisce inoltre al crescere della distanza fra compito di partenza e compito bersaglio. Infine, anche da un compito lontano si ottengono caratteristiche migliori di quelle casuali. Da qui le due strategie classiche, riassunte nel disegno.
Nell'estrazione di caratteristiche il backbone resta congelato (in grigio): le sue uscite sono caratteristiche fisse e si addestra soltanto la nuova testa, con pochissimi parametri e quindi con poco rischio di overfitting. Nel fine-tuning tutta la rete viene aggiornata, ma partendo da pesi già buoni e con un learning rate basso (tipicamente 10 o 100 volte più piccolo del solito, regola empirica e non teorema), per non distruggere ciò che la rete sa già. In mezzo c'è il fine-tuning parziale: si congelano gli strati iniziali e si addestrano solo gli ultimi blocchi e la testa.
Il banco di prova: cifre 8×8 in tre parti
Il dataset Digits di scikit-learn ha 1 797 immagini 8×8 di cifre scritte a mano, 64 valori interi da 0 a 16 per immagine (lo dichiara la docstring di load_digits). Per fare transfer learning servono un compito sorgente, un compito bersaglio e un test che non tocchi né l'uno né l'altro. Dividiamo quindi le immagini in tre parti disgiunte: 900 per addestrare il backbone (sorgente), 400 da cui estrarre i piccoli sottoinsiemi di addestramento del bersaglio (il pool), 497 per il test. La normalizzazione usa media e deviazione della sola sorgente, come si farebbe con un modello preaddestrato che porta con sé le proprie statistiche di ingresso.
La rete è una piccola CNN con due strati convolutivi, ciascuno seguito da BatchNorm2d: la batch normalization c'è apposta, perché è lei a rendere sottile il congelamento (la trappola è nella prossima sezione). La classe Rete separa esplicitamente caratteristiche (il backbone) e testa, così sostituire la testa o congelare il backbone sono operazioni su un attributo. La funzione addestra ha un parametro che conta: backbone_eval=True rimette il backbone in modo eval() dopo ogni train(); ne vedremo il motivo tra poco. Il parametro specchia di a_tensore serve solo all'esperimento 2.
import copy
import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
torch.set_num_threads(2)
X, y = load_digits(return_X_y=True) # 1797 immagini 8x8, cifre 0-9
X_sorg, X_resto, y_sorg, y_resto = train_test_split(X, y, train_size=900, stratify=y, random_state=0)
X_pool, X_te, y_pool, y_te = train_test_split(X_resto, y_resto, train_size=400, stratify=y_resto, random_state=0)
media, dev = X_sorg.mean(), X_sorg.std() # statistiche della SOLA sorgente
print("sorgente: %s pool bersaglio: %s test: %s" % (X_sorg.shape, X_pool.shape, X_te.shape))
def a_tensore(A, specchia=False):
img = A.reshape(-1, 8, 8)
if specchia:
img = img[:, :, ::-1] # riflessione sinistra-destra
return torch.tensor(((img - media) / dev).reshape(-1, 1, 8, 8), dtype=torch.float32)
class Rete(nn.Module):
def __init__(self, n_classi):
super().__init__()
self.caratteristiche = nn.Sequential( # il "backbone"
nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(128, 64), nn.ReLU())
self.testa = nn.Linear(64, n_classi) # la "testa"
def forward(self, x):
return self.testa(self.caratteristiche(x))
def addestra(rete, X, Y, parametri, epoche=60, lr=3e-3, seme=0, backbone_eval=False):
torch.manual_seed(seme)
ott = torch.optim.Adam(parametri, lr=lr)
loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(X, Y), batch_size=16, shuffle=True)
for _ in range(epoche):
rete.train()
if backbone_eval:
rete.caratteristiche.eval() # statistiche della batch norm ferme
for xb, yb in loader:
ott.zero_grad(); nn.CrossEntropyLoss()(rete(xb), yb).backward(); ott.step()
def accuratezza(rete, X, Y):
rete.eval()
with torch.no_grad():
return (rete(X).argmax(1) == Y).float().mean().item()
sorgente: (900, 64) pool bersaglio: (400, 64) test: (497, 64)
Le tre parti hanno le dimensioni dichiarate: 900, 400 e 497 immagini. Ora addestriamo il backbone sul compito sorgente, che è il più ricco che questo dataset permette: riconoscere tutte e dieci le cifre. Quaranta epoche bastano, perché le immagini sono minuscole; il seme è fissato, così la rete sorgente è la stessa a ogni esecuzione e i confronti dei prossimi esperimenti partono sempre dallo stesso backbone.
torch.manual_seed(0)
base = Rete(10) # compito sorgente: tutte e dieci le cifre
addestra(base, a_tensore(X_sorg), torch.tensor(y_sorg), base.parameters(), epoche=40)
print("accuratezza della rete sorgente sul test: %.3f" % accuratezza(base, a_tensore(X_te), torch.tensor(y_te)))
print("parametri del backbone: %d della testa a 10 classi: %d della testa a 3 classi: %d" % (
sum(p.numel() for p in base.caratteristiche.parameters()),
sum(p.numel() for p in base.testa.parameters()), 64 * 3 + 3))
accuratezza della rete sorgente sul test: 0.986
parametri del backbone: 13152 della testa a 10 classi: 650 della testa a 3 classi: 195
La rete sorgente fa 0,986 sul test. Il backbone ha 13 152 parametri e una testa a 10 classi ne aggiunge 650; la testa a tre classi che useremo nel compito bersaglio ne ha 195. Il dato rilevante per il seguito è il rapporto: nel caso dell'estrazione di caratteristiche si addestrano 195 parametri contro i 13 152 del backbone.
Esperimento 1: caratteristiche preaddestrate contro addestramento da zero
Il compito bersaglio dev'essere diverso da quello sorgente, altrimenti non è trasferimento. Usiamo un raggruppamento arbitrario delle stesse immagini: l'etichetta è il resto della cifra diviso 3 (0, 3, 6, 9 formano il gruppo 0; 1, 4, 7 il gruppo 1; 2, 5, 8 il gruppo 2). È una scelta artificiale, lo ammetto, ma ha una proprietà utile: i tre gruppi non corrispondono a una forma visiva semplice, quindi una rete che parte da zero con pochi esempi fatica, mentre un backbone che ha già imparato a distinguere le dieci cifre ha di fatto le informazioni giuste, ricombinate in un'altra maniera. Il confronto è fra due alternative sugli stessi esempi: una rete addestrata da zero e una regressione logistica sulle 64 caratteristiche prodotte dal backbone congelato (modo eval(), nessun gradiente). Il numero di esempi per gruppo varia da 2 a 50, con cinque semi.
def caratteristiche(rete, A):
rete.eval()
with torch.no_grad():
return rete.caratteristiche(a_tensore(A)).numpy() # 64 numeri per immagine
def campione(etichette, n, seme): # n esempi per classe, scelti a caso
r = np.random.default_rng(seme)
return np.concatenate([r.choice(np.where(etichette == k)[0], n, replace=False) for k in np.unique(etichette)])
g_pool, g_te = y_pool % 3, y_te % 3 # compito bersaglio: il resto della cifra diviso 3
F_pool, F_te = caratteristiche(base, X_pool), caratteristiche(base, X_te)
print("immagini per gruppo nel pool:", np.bincount(g_pool))
print("esempi per gruppo da zero (CNN) caratteristiche preaddestrate + regressione logistica")
for n in (2, 5, 10, 25, 50):
da_zero, preaddestrato = [], []
for seme in range(5):
idx = campione(g_pool, n, seme)
torch.manual_seed(seme)
z = Rete(3)
addestra(z, a_tensore(X_pool[idx]), torch.tensor(g_pool[idx]), z.parameters(), seme=seme)
da_zero.append(accuratezza(z, a_tensore(X_te), torch.tensor(g_te)))
preaddestrato.append(LogisticRegression(max_iter=3000).fit(F_pool[idx], g_pool[idx]).score(F_te, g_te))
print("%17d %.3f +- %.3f %.3f +- %.3f" % (n, np.mean(da_zero), np.std(da_zero), np.mean(preaddestrato), np.std(preaddestrato)))
immagini per gruppo nel pool: [160 120 120]
esempi per gruppo da zero (CNN) caratteristiche preaddestrate + regressione logistica
2 0.619 +- 0.079 0.659 +- 0.080
5 0.694 +- 0.079 0.850 +- 0.076
10 0.780 +- 0.057 0.933 +- 0.027
25 0.885 +- 0.017 0.963 +- 0.009
50 0.936 +- 0.015 0.971 +- 0.008
Leggi la tabella per colonne e poi per differenze. Con 2 esempi per gruppo (sei immagini in tutto) le due strategie sono indistinguibili: 0,619 contro 0,659, con una deviazione tra semi di 0,08. Il vantaggio compare con 5 esempi per gruppo (0,694 contro 0,850) e resta enorme con 10 (0,780 contro 0,933), poi si riduce a otto punti con 25 e a tre punti e mezzo con 50 (0,936 contro 0,971). Due lezioni. La prima: il guadagno si assottiglia al crescere dei dati, perché con abbastanza esempi una rete da zero impara da sola caratteristiche adatte. La seconda, che è un'informazione in più rispetto a ciò che ci si aspetterebbe: il guadagno non è massimo con il numero minimo di esempi. Con sei immagini in tutto nemmeno un classificatore lineare su caratteristiche ottime ha abbastanza informazione, e il rumore del campione domina. Il trasferimento non sostituisce i dati, li rende più efficaci.
La meccanica del congelamento, e una trappola sottile
Per i prossimi esperimenti serve una rete completa con la nuova testa. Congelare significa impostare requires_grad = False sui parametri: PyTorch non calcola più i loro gradienti e l'ottimizzatore non li aggiorna. C'è però una trappola specifica dei backbone con batch normalization: congelare i parametri non congela le statistiche correnti (running_mean e running_var), che sono buffer e non parametri. Se la rete è in modo train(), il modulo le aggiorna a ogni batch con un momento di 0,1 (il valore predefinito nel sorgente di torch/nn/modules/batchnorm.py), e dopo poche iterazioni riflettono i tuoi dati e non più quelli di partenza. Verifichiamolo.
rete = copy.deepcopy(base)
rete.testa = nn.Linear(64, 3) # NUOVA testa, addestrata da zero
for p in rete.parameters():
p.requires_grad = False # congela tutto...
for p in rete.testa.parameters():
p.requires_grad = True # ...tranne la nuova testa
addestrabili = sum(p.numel() for p in rete.parameters() if p.requires_grad)
totali = sum(p.numel() for p in rete.parameters())
print("parametri addestrabili: %d su %d (%.2f%%)" % (addestrabili, totali, 100 * addestrabili / totali))
batch = a_tensore(X_pool[:64])
bn = rete.caratteristiche[1] # il primo strato BatchNorm2d
prima = bn.running_mean.clone()
rete.train(); rete(batch) # pesi congelati, ma modo train
dopo_train = bn.running_mean.clone()
rete.eval(); rete(batch)
dopo_eval = bn.running_mean.clone()
print("le statistiche della batch norm cambiano in modo train con i pesi congelati:", not torch.equal(prima, dopo_train))
print("le statistiche cambiano in modo eval:", not torch.equal(dopo_train, dopo_eval))
parametri addestrabili: 195 su 13347 (1.46%)
le statistiche della batch norm cambiano in modo train con i pesi congelati: True
le statistiche cambiano in modo eval: False
Il primo valore, 195 su 13 347 parametri addestrabili, è l'1,46%: la parte della rete che impara davvero. Poi si vede la trappola: dopo un solo passaggio in modo train() la media corrente del primo strato di batch normalization è cambiata nonostante i pesi congelati, mentre in eval() resta ferma. La cura, quando fai estrazione di caratteristiche, è tenere il backbone in eval() per tutto l'addestramento della testa. È ciò che fa il parametro backbone_eval=True di addestra: ricorda che ogni chiamata a rete.train() riporta in train() anche i sottomoduli, per cui il backbone va rimesso in eval() dopo, a ogni epoca.
Esperimento 2: solo la testa o tutta la rete? Dipende dal dominio
Lo stesso backbone, due strategie: solo testa (backbone congelato e in eval(), learning rate 3·10⁻³ come per tutti gli addestramenti da zero) oppure tutta la rete con learning rate 10⁻³, partendo dai pesi preaddestrati. Aggiungiamo una variabile: la vicinanza del bersaglio alla sorgente. Nel caso vicino le immagini sono quelle di prima, stesse cifre nello stesso verso. Nel caso lontano le immagini del bersaglio (addestramento e test) sono riflesse sinistra-destra, e il backbone non ha mai visto una cifra rispecchiata. È una distanza artificiale ma controllata: stesso tipo di tratti, orientamento diverso, come succede in piccolo quando passi da fotografie a radiografie. Il compito è sempre il resto modulo 3, e c'è sempre la colonna «da zero» come termine di paragone.
print("dominio esempi per gruppo da zero solo testa tutta la rete (5 semi)")
for nome, specchia in (("vicino", False), ("lontano", True)):
X_te_d = a_tensore(X_te, specchia)
for n in (5, 25):
righe = []
for seme in range(5):
idx = campione(g_pool, n, seme)
X_n, g_n = a_tensore(X_pool[idx], specchia), torch.tensor(g_pool[idx])
torch.manual_seed(seme)
z = Rete(3)
addestra(z, X_n, g_n, z.parameters(), seme=seme)
torch.manual_seed(seme)
solo_testa = copy.deepcopy(base); solo_testa.testa = nn.Linear(64, 3)
for p in solo_testa.caratteristiche.parameters():
p.requires_grad = False
addestra(solo_testa, X_n, g_n, solo_testa.testa.parameters(), seme=seme, backbone_eval=True)
torch.manual_seed(seme)
tutta = copy.deepcopy(base); tutta.testa = nn.Linear(64, 3)
addestra(tutta, X_n, g_n, tutta.parameters(), lr=1e-3, seme=seme)
righe.append([accuratezza(m, X_te_d, torch.tensor(g_te)) for m in (z, solo_testa, tutta)])
r = np.array(righe)
print("%-8s %17d %.3f +- %.3f %.3f +- %.3f %.3f +- %.3f" % ((nome, n) + tuple(v for k in range(3) for v in (r[:, k].mean(), r[:, k].std()))))
dominio esempi per gruppo da zero solo testa tutta la rete (5 semi)
vicino 5 0.694 +- 0.079 0.823 +- 0.063 0.762 +- 0.080
vicino 25 0.885 +- 0.017 0.957 +- 0.014 0.948 +- 0.020
lontano 5 0.699 +- 0.086 0.662 +- 0.071 0.697 +- 0.062
lontano 25 0.889 +- 0.024 0.788 +- 0.015 0.884 +- 0.027
Nel dominio vicino la sola testa è la strategia migliore: 0,823 contro 0,762 per tutta la rete con 5 esempi per gruppo (con deviazioni tra semi di 0,06-0,08, il margine è modesto), 0,957 contro 0,948 con 25, dove la differenza è dentro il rumore. Nel dominio lontano il quadro si capovolge: la sola testa scende sotto la rete da zero (0,788 contro 0,889 con 25 esempi: dieci punti, con deviazioni di 0,015-0,024, quindi affidabile) e il fine-tuning, che aggiorna i filtri, recupera il livello della rete da zero (0,884 contro 0,889) ma non lo supera. I filtri congelati guardano le cifre «nel verso giusto», e nessuna testa lineare può correggere un backbone che estrae le caratteristiche sbagliate.
Questo risultato non conferma la regola che circola spesso («pochi dati, congela; più dati, sblocca»): qui nessuna delle due strategie vince per la sola quantità di dati. Quello che decide è la distanza di dominio. In pratica: prova per prima l'estrazione di caratteristiche (costa pochi secondi), e se resta sotto una baseline da zero il dominio è troppo lontano per congelare. Il fine-tuning, per parte sua, batte la baseline da zero nel dominio vicino (0,762 contro 0,694 e 0,948 contro 0,885) ma non supera la sola testa, ed equivale alla baseline nel dominio lontano. Perché aggiornare i filtri non aggiunga nulla alla sola testa nel caso vicino è una domanda che qui resta aperta: un'ipotesi, non verificata, è che un backbone già buono per le dieci cifre non abbia molto da imparare in più, e che i 13 mila parametri aggiornati su poche decine di immagini aggiungano soprattutto rumore.
Esperimento 3: quando il trasferimento non aiuta
Nell'esperimento 1 il backbone era stato addestrato su un compito più ricco del bersaglio (dieci cifre contro tre gruppi). Qui rovesciamo la situazione: il compito sorgente è più povero e il bersaglio è diverso. Il backbone viene addestrato sulle sole cifre 0-4 (cinque classi) e poi riusato per classificare le cifre 5-9, con pochissimi esempi. I due compiti sono vicini (sono sempre cifre scritte a mano), e ci aspetteremmo che il trasferimento aiuti.
sorgente = y_sorg < 5 # compito sorgente: cifre 0-4
torch.manual_seed(0)
base5 = Rete(5)
addestra(base5, a_tensore(X_sorg[sorgente]), torch.tensor(y_sorg[sorgente]), base5.parameters(), epoche=40)
bers_pool, bers_te = y_pool >= 5, y_te >= 5 # compito bersaglio: cifre 5-9
X_b, y_b = X_pool[bers_pool], y_pool[bers_pool] - 5
X_b_te, y_b_te = a_tensore(X_te[bers_te]), torch.tensor(y_te[bers_te] - 5)
print("rete sorgente (cifre 0-4) sul test delle cifre 0-4: %.3f" % accuratezza(base5, a_tensore(X_te[y_te < 5]), torch.tensor(y_te[y_te < 5])))
print("immagini per cifra nel pool bersaglio:", np.bincount(y_b))
print("esempi per classe da zero solo testa tutta la rete (5 semi)")
for n in (5, 25):
righe = []
for seme in range(5):
idx = campione(y_b, n, seme)
X_n, y_n = a_tensore(X_b[idx]), torch.tensor(y_b[idx])
torch.manual_seed(seme)
z = Rete(5)
addestra(z, X_n, y_n, z.parameters(), seme=seme)
torch.manual_seed(seme)
solo_testa = copy.deepcopy(base5); solo_testa.testa = nn.Linear(64, 5)
for p in solo_testa.caratteristiche.parameters():
p.requires_grad = False
addestra(solo_testa, X_n, y_n, solo_testa.testa.parameters(), seme=seme, backbone_eval=True)
torch.manual_seed(seme)
tutta = copy.deepcopy(base5); tutta.testa = nn.Linear(64, 5)
addestra(tutta, X_n, y_n, tutta.parameters(), lr=1e-3, seme=seme)
righe.append([accuratezza(m, X_b_te, y_b_te) for m in (z, solo_testa, tutta)])
r = np.array(righe)
print("%17d %.3f +- %.3f %.3f +- %.3f %.3f +- %.3f" % ((n,) + tuple(v for k in range(3) for v in (r[:, k].mean(), r[:, k].std()))))
rete sorgente (cifre 0-4) sul test delle cifre 0-4: 1.000
immagini per cifra nel pool bersaglio: [41 40 40 39 40]
esempi per classe da zero solo testa tutta la rete (5 semi)
5 0.924 +- 0.024 0.606 +- 0.032 0.906 +- 0.027
25 0.988 +- 0.005 0.833 +- 0.009 0.976 +- 0.004
L'aspettativa è smentita. La rete sorgente è perfetta sul suo compito (1,000 sul test delle cifre 0-4), ma sul bersaglio la rete da zero fa 0,924 con 5 esempi per classe e 0,988 con 25; il fine-tuning fa 0,906 e 0,976, un po' peggio; le caratteristiche congelate 0,606 e 0,833, nettamente peggio. Il preaddestramento non ha aiutato e, se congelato, ha danneggiato. I motivi sono due. Le caratteristiche imparate per separare cinque cifre sono molto specifiche di quelle cinque: la rete ha imparato a notare i tratti che le distinguono e a ignorare gli altri, che sono proprio quelli utili per le altre cinque. E il compito bersaglio è già facile: con 5 esempi per classe una rete da zero raggiunge il 92%, quindi c'è poco margine.
Leggere insieme i tre esperimenti
I tre risultati si completano. Nell'esperimento 1 il backbone era stato addestrato su un compito più ricco di quello bersaglio, e le sue caratteristiche hanno battuto l'addestramento da zero: di quindici punti con 5 e 10 esempi per gruppo, di tre e mezzo con 50. Nell'esperimento 2, con lo stesso backbone, la scelta fra congelare e sbloccare ha seguito la distanza di dominio: vicino, meglio la sola testa; lontano, la sola testa danneggia e il fine-tuning raggiunge appena la baseline. Nell'esperimento 3 il preaddestramento su un compito povero (cinque classi) ha prodotto caratteristiche troppo specifiche, dannose se congelate. Se dovessi ridurre tutto a una frase: il trasferimento porta con sé conoscenza, e la conoscenza serve solo se è abbastanza generale e vicina al tuo problema. Un'ultima precauzione sui numeri: con cinque semi e un test di 497 immagini, differenze inferiori a un paio di punti non vanno raccontate come vittorie. Il modo corretto di riportarle è «media ± deviazione su N semi, contro una baseline da zero».
Dalle cifre 8×8 a un ResNet18: lo schema, non eseguito
Su immagini reali il backbone preaddestrato non si costruisce: si scarica. Lo schema seguente segue il tutorial ufficiale di PyTorch sul transfer learning e non è stato eseguito qui, perché torchvision non è installato in questo ambiente e scaricare i pesi non era consentito: è uno schema da adattare, non un risultato. Mostra le tre operazioni che negli esperimenti hai già fatto a mano: sostituire la testa, congelare il backbone, passare alla messa a punto.
# SCHEMA DA ADATTARE, NON ESEGUITO in questa lezione (richiede torchvision e il download dei pesi)
import torch.nn as nn
from torchvision import models
n_classi = 10 # le classi del TUO problema
rete = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # pesi preaddestrati su ImageNet
rete.fc = nn.Linear(rete.fc.in_features, n_classi) # nuova testa, addestrata da zero
for p in rete.parameters():
p.requires_grad = False # congela tutto...
for p in rete.fc.parameters():
p.requires_grad = True # ...tranne la nuova testa
# durante l'addestramento della testa: rete.eval() per le statistiche della batch norm,
# poi rete.train() e requires_grad = True ovunque, con lr ~10 volte piu' basso, per il fine-tuning.
# Preprocessing: gli stessi ridimensionamento e normalizzazione dei pesi (vedi ResNet18_Weights.DEFAULT.transforms()).
Le due righe da non dimenticare sono quelle sul preprocessing. I pesi preaddestrati si aspettano immagini preparate come lo erano nel dataset d'origine (dimensione, ordine dei canali, media e deviazione di normalizzazione): usare un'altra scala degrada le caratteristiche senza alcun messaggio d'errore. Negli esperimenti di questa lezione abbiamo rispettato lo stesso principio usando la normalizzazione della sorgente anche sul bersaglio.
Una ricetta pratica
- Parti da un backbone preaddestrato su un compito più ricco e vario del tuo e controlla la licenza dei pesi e del dataset d'origine.
- Rispetta il preprocessing originale: dimensioni, ordine dei canali, media e deviazione standard.
- Sostituisci la testa con una adatta alle tue classi.
- Comincia con l'estrazione di caratteristiche (backbone congelato, in
eval()): è economica e fa da baseline. - Passa al fine-tuning se la sola testa resta sotto una baseline da zero (segno di dominio lontano), con learning rate più basso del solito, eventualmente sbloccando gli strati dall'alto verso il basso.
- Confronta sempre con una baseline da zero e con più semi: il trasferimento non è garantito.
- Usa l'augmentation e l'early stopping sulla validazione, e riporta il guadagno con la variabilità fra semi.
L'ordine qui conta: il passo 4 precede il 5 perché l'estrazione di caratteristiche costa poco ed è la baseline che dice se serve altro, e il passo 6 chiude il cerchio riportando ciò che hai trovato. Prima del rilascio in produzione ricorda anche che il modello eredita i limiti e le distorsioni dei dati d'origine: se il dominio di destinazione è molto diverso, valuta separatamente i sottogruppi difficili invece di fidarti di una sola metrica aggregata, e annota nella documentazione quale checkpoint hai usato.
| Dati bersaglio | Dominio simile alla sorgente | Dominio molto diverso |
|---|---|---|
| Pochi | Estrazione di caratteristiche (backbone congelato) | Baseline da zero a confronto; fine-tuning se la sola testa non la raggiunge |
| Molti | Estrazione o fine-tuning: confronta, il guadagno è piccolo | Fine-tuning o addestramento da zero, confrontando |
La tabella riassume ciò che hanno misurato gli esperimenti 1 e 2 e va letta come regola empirica: sono punti di partenza da validare sul tuo problema, non risultati garantiti.
Trappole reali
- Normalizzazione sbagliata: usare le statistiche del tuo dataset al posto di quelle del modello preaddestrato, o dimenticare di scalare i pixel.
- Batch normalization e congelamento: pesi congelati ma backbone in
train(), con le statistiche che cambiano (dimostrato sopra). - Learning rate troppo alto nel fine-tuning: può distruggere le caratteristiche preaddestrate nelle prime iterazioni (il cosiddetto catastrophic forgetting).
- Testa casuale e gradienti: all'inizio una testa non addestrata produce gradienti rumorosi che si propagano nel backbone sbloccato; per questo si addestra prima solo la testa.
- Dimenticare di sostituire la testa: il modello continua a produrre le classi del compito sorgente.
- Data leakage tra sorgente e bersaglio: se le immagini di test sono presenti nel dataset di preaddestramento, le prestazioni sono gonfiate. Negli esperimenti qui i tre insiemi sono disgiunti per costruzione.
- Considerare il transfer come garantito: negli esperimenti 2 (dominio lontano) e 3 non ha battuto la baseline.
- Pesi con licenza restrittiva e bias ereditati: il modello preaddestrato porta con sé i limiti dei suoi dati di origine.
Le prime due trappole sono silenziose: nessuna produce un errore, producono numeri leggermente peggiori e ti lasciano convinto che il modello sia fatto così. Le altre si individuano con la documentazione del modello e con il confronto contro una baseline. L'ordine non cambia il senso dell'elenco: è un promemoria, non una sequenza.
Domande frequenti
Si può fare transfer learning con dati non immagine? Sì: nel linguaggio naturale è lo standard (si preaddestrano grandi modelli su testo generico e si adattano a compiti specifici); per l'audio e i dati tabellari è meno diffuso. Il principio è identico.
Quanti strati sbloccare? Si parte dalla sola testa, poi si sbloccano progressivamente blocchi dall'alto verso il basso finché la validazione migliora. Gli strati iniziali raramente conviene modificarli.
Perché usare un classificatore lineare sulle caratteristiche? È rapido, ha pochi parametri e fa da baseline onesta. Se non funziona, il problema sta nelle caratteristiche (dominio troppo diverso), non nella testa: nell'esperimento 2, in dominio lontano, nemmeno una testa addestrata bene recupera.
Tre esercizi
Esercizio 1. Nell'esperimento 1 riduci il compito sorgente a 200 immagini (train_size=200 nella prima divisione, adattando le altre) e guarda quanto del vantaggio sopravvive: è la quantità di preaddestramento che conta, oltre alla sua qualità.
Esercizio 2. Sblocca solo il secondo strato convolutivo e la testa (rete.caratteristiche[4] e seguenti) e confrontalo con «solo testa» e «tutta» in dominio lontano a 25 esempi per gruppo.
Esercizio 3. Nell'esperimento 3 inverti i ruoli (sorgente: cifre 5-9, bersaglio: 0-4) e controlla se il risultato qualitativo si ripete.
Che cosa sai fare adesso
Sai costruire un backbone con una testa sostituibile, congelarlo senza cadere nella trappola della batch normalization (195 parametri addestrabili su 13 347, e backbone in eval()), e confrontare estrazione di caratteristiche e fine-tuning contro una baseline da zero, su più semi. Sai anche riconoscere i casi in cui il trasferimento non aiuta: sorgente più povera del bersaglio, dominio lontano. Con questa lezione si chiude il modulo: dalla regressione lineare ai neuroni, alla backpropagation, alle reti convolutive e al riuso di reti già addestrate hai costruito i pezzi a mano e li hai misurati. Il passo successivo del corso parte da qui.
Lezione successiva: Reti neurali ricorrenti (RNN): dare una memoria alla rete.
Fonti
- J. Yosinski, J. Clune, Y. Bengio, H. Lipson, How transferable are features in deep neural networks? (arXiv 1411.1792) - generalità e specificità delle caratteristiche, effetto della distanza fra i compiti; abstract letto, consultata il 2026-11-12
- PyTorch, Transfer Learning for Computer Vision Tutorial - le due strategie (messa a punto e estrattore fisso) e l'esempio con ResNet18 e ImageNet; pagina scaricata e letta, consultata il 2026-11-12
- PyTorch, sorgente di torch/nn/modules/batchnorm.py - il momento predefinito 0,1 e i buffer
running_mean/running_var; testo scaricato e letto, consultata il 2026-11-12 - scikit-learn, sorgente di sklearn/datasets/_base.py (load_digits) - 1 797 immagini 8×8, 64 caratteristiche intere da 0 a 16; testo scaricato e letto, consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare