Salta al contenuto principale

53 - Dropout e Batch Normalization: regolarizzare e stabilizzare una CNN

Dropout e batch normalization a mano e in PyTorch: il dropout qui non migliora nulla, la batch normalization rende la rete profonda addestrabile.

Cosa ne pensi di questo articolo?

53 - Dropout e Batch Normalization: regolarizzare e stabilizzare una CNN
Machine Learning Engineerfedericocalo.dev

In Breve

Dropout e batch normalization a mano e in PyTorch: il dropout qui non migliora nulla, la batch normalization rende la rete profonda addestrabile.

Due problemi accompagnano ogni addestramento di una rete profonda. Il primo lo conosci: l'overfitting, la rete che memorizza gli esempi di training invece di imparare ciò che generalizza. Il secondo è più sottile: l'addestramento può essere instabile, perché a ogni aggiornamento i pesi di uno strato cambiano e quindi cambia la distribuzione dei valori che quello strato passa al successivo, che si trova a imparare su un bersaglio in movimento; con learning rate non perfetti la discesa si blocca o diverge. Le due tecniche di questa lezione sono rimedi economici e diffusissimi: il dropout contro l'overfitting e la batch normalization contro l'instabilità.

Prerequisiti: sai addestrare una rete con PyTorch e leggere la norma di un gradiente, come nella lezione sulle reti residuali. Implementerai le due tecniche a mano, le verificherai contro PyTorch e le misurerai. Una delle due ha qui un effetto spettacolare, l'altra nessuno misurabile: capire perché serve a non adottare una tecnica «perché si fa così». I listati sono stati eseguiti con PyTorch 2.12.0 e scikit-learn 1.9.0 su CPU, due volte di seguito, con uscita identica (misura del 2026-11-12).

Il vocabolario

  • Co-adattamento: neuroni che imparano a funzionare solo in combinazione tra loro (uno corregge gli errori dell'altro), invece di essere ciascuno utile da solo. Rende il modello fragile e favorisce l'overfitting.
  • Maschera: un vettore di 0 e 1 che seleziona quali elementi tenere.
  • Normalizzare: togliere la media e dividere per la deviazione standard, per ottenere valori con media 0 e deviazione 1.
  • Statistiche del batch: la media e la varianza calcolate sui soli esempi del mini-batch corrente.
  • Statistiche correnti (running): una media mobile esponenziale delle statistiche viste negli ultimi batch, che si usa in inferenza, quando non c'è un batch da cui calcolarle.
  • Buffer (in PyTorch): un tensore che fa parte dello stato di un modulo ma non è un parametro addestrato dal gradiente (per esempio le statistiche correnti).

Le sei voci raccontano tre cose diverse. Co-adattamento e maschera appartengono al dropout: il primo è il difetto da curare, la seconda è lo strumento che lo cura. Normalizzare e statistiche del batch descrivono che cosa fa la batch normalization mentre si addestra. Statistiche correnti e buffer descrivono come si comporta dopo, in inferenza, ed è la coppia che fa inciampare più spesso: sono stato che il modello si porta dietro senza che il gradiente lo aggiorni.

Dropout: spegnere neuroni a caso

Immagina un gruppo di studio in cui, a ogni sessione, alcuni compagni scelti a caso restano a casa. Ognuno impara che non può fare affidamento sul fatto che «qualcun altro sa la parte difficile»: deve saperne abbastanza da solo. Il dropout (N. Srivastava, G. Hinton e coll., 2014) fa lo stesso con i neuroni: a ogni passo di addestramento, ciascun neurone di uno strato viene spento (la sua uscita è azzerata) con probabilità p, indipendentemente dagli altri. La rete che calcola il passo è quindi una versione «sfoltita» di quella intera, diversa a ogni mini-batch.

Dropout: in addestramento e in valutazioneA sinistra, durante l'addestramento, alcuni neuroni vengono spenti a caso a ogni passo e i superstiti sono amplificati; a destra, in valutazione, tutti i neuroni sono attivi e nessuna scalatura è necessariaDropout: in addestramento e in valutazioneaddestramento (p = 0,5)valutazione× 1/(1 − p)tutti attivi
A sinistra, durante l'addestramento, alcuni neuroni vengono spenti a caso a ogni passo e i superstiti sono amplificati; a destra, in valutazione, tutti i neuroni sono attivi e nessuna scalatura è necessaria

Il disegno mostra la differenza: in addestramento (a sinistra) alcuni neuroni sono spenti (crocette rosse) e le connessioni verso e da essi scompaiono; in valutazione (a destra) tutti i neuroni sono attivi. C'è un dettaglio da non perdere. Se in addestramento si spegne una frazione p dei neuroni, la somma pesata che arriva allo strato successivo è in media più piccola di un fattore (1 − p) rispetto a quella con tutti i neuroni accesi; se in valutazione si accendessero tutti, lo strato successivo vedrebbe valori più grandi di quelli per cui si è addestrato. Si risolve con il metodo detto dropout invertito: durante l'addestramento si moltiplicano i neuroni sopravvissuti per 1/(1 − p), in modo che il valore atteso dell'uscita resti uguale, e in valutazione non si fa niente.

Formalmente, per ogni elemento a dello strato si estrae m ∈ {0, 1} con P(m = 1) = 1 − p, e l'uscita è ã = a·m/(1 − p). Il valore atteso si calcola in una riga:

E[ã] = a · E[m] / (1 − p) = a · (1 − p) / (1 − p) = a                (il dropout non cambia il valore atteso)
Var[ã] = a² · Var[m] / (1 − p)² = a² · p(1 − p) / (1 − p)² = a² · p / (1 − p)    (ma aggiunge rumore)

Con p = 0,5 la varianza aggiunta è proprio a²: il rumore ha la stessa grandezza del segnale. Questo rumore è il meccanismo della regolarizzazione. Si può leggere anche come un insieme di reti: una rete con n neuroni «spegnibili» contiene 2ⁿ sottoreti, che condividono i pesi; il dropout addestra, uno dopo l'altro, un campione casuale di esse, e la rete intera in valutazione approssima la media delle loro previsioni. È lo stesso principio della foresta casuale della lezione sul machine learning classico, ottenuto con un solo modello.

Implementazione a mano e verifica statistica

import numpy as np
import torch
import torch.nn as nn

class DropoutManuale:
    def __init__(self, p, seed=0):
        self.p, self.rng, self.maschera = p, np.random.default_rng(seed), None

    def avanti(self, x, addestramento):
        if not addestramento or self.p == 0:                               # in valutazione: identità
            self.maschera = None
            return x
        self.maschera = (self.rng.random(x.shape) >= self.p) / (1.0 - self.p)    # 0 con prob. p, altrimenti 1/(1-p)
        return x * self.maschera

    def indietro(self, d_uscita):
        return d_uscita if self.maschera is None else d_uscita * self.maschera     # i neuroni spenti non ricevono gradiente

x = np.ones(100000)
for p in (0.2, 0.5, 0.8):
    drop = DropoutManuale(p)
    y = drop.avanti(x, addestramento=True)
    print("p=%.1f   media %.3f (attesa 1)   varianza %.3f (attesa p/(1-p) = %.3f)   frazione di zeri %.3f" % (p, y.mean(), y.var(), p / (1 - p), (y == 0).mean()))

print("valutazione: uscita identica all'ingresso:", np.array_equal(DropoutManuale(0.5).avanti(x, addestramento=False), x))
p=0.2   media 0.998 (attesa 1)   varianza 0.251 (attesa p/(1-p) = 0.250)   frazione di zeri 0.201
p=0.5   media 0.998 (attesa 1)   varianza 1.000 (attesa p/(1-p) = 1.000)   frazione di zeri 0.501
p=0.8   media 0.991 (attesa 1)   varianza 3.972 (attesa p/(1-p) = 4.000)   frazione di zeri 0.802
valutazione: uscita identica all'ingresso: True

Cosa fa il codice. avanti costruisce la maschera confrontando numeri casuali uniformi con p (vero con probabilità 1 − p) e dividendo per 1 − p: gli elementi sopravvissuti valgono 1/(1 − p), gli spenti 0. indietro applica la stessa maschera al gradiente in arrivo: un neurone spento non ha influito sull'uscita, quindi non riceve gradiente (come una ReLU con derivata zero), e i sopravvissuti portano il fattore di amplificazione. L'esperimento su 100 000 uni verifica le formule: la media resta intorno a 1 per ogni p, la varianza coincide con p/(1 − p) (0,25, 1 e 4) e la frazione di zeri con p. In valutazione la funzione è l'identità.

L'esperimento: il dropout migliora la generalizzazione?

Mettiamo il dropout alla prova nel regime in cui ce lo aspetteremmo più utile: una rete larga (512 neuroni per strato) e pochi dati (100 esempi di training di Digits), addestrata per 150 epoche, tre livelli di dropout, otto semi diversi. Misuriamo accuratezza di training, validazione e test, e la perdita di validazione.

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

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_pool, X_va, y_pool, y_va = train_test_split(X_tmp, y_tmp, test_size=0.5, stratify=y_tmp, random_state=0)

def esperimento(p, n_train=100, larghezza=512, semi=8, epoche=150):
    righe = []
    for seme in range(semi):
        X_tr, _, y_tr, _ = train_test_split(X_pool, y_pool, train_size=n_train, stratify=y_pool, random_state=seme)
        media, dev = X_tr.mean(), X_tr.std()
        a_t = lambda a: torch.tensor((a - media) / dev, dtype=torch.float32)
        T_tr, T_va, T_te = a_t(X_tr), a_t(X_va), a_t(X_te)
        L_tr, L_va, L_te = [torch.tensor(a) for a in (y_tr, y_va, y_te)]
        torch.manual_seed(seme)
        modello = nn.Sequential(nn.Linear(64, larghezza), nn.ReLU(), nn.Dropout(p),
                                nn.Linear(larghezza, larghezza), nn.ReLU(), nn.Dropout(p), nn.Linear(larghezza, 10))
        ott = torch.optim.Adam(modello.parameters(), lr=1e-3)
        perdita_fn = nn.CrossEntropyLoss()
        loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=16, shuffle=True)
        for _ in range(epoche):
            modello.train()
            for xb, yb in loader:
                ott.zero_grad(); perdita_fn(modello(xb), yb).backward(); ott.step()
        modello.eval()
        with torch.no_grad():
            righe.append([(modello(a).argmax(1) == b).float().mean().item() for a, b in ((T_tr, L_tr), (T_va, L_va), (T_te, L_te))]
                         + [perdita_fn(modello(T_va), L_va).item()])
    return np.array(righe)

print("p     acc. training    acc. validazione   acc. test         perdita di validazione")
for p in (0.0, 0.2, 0.5):
    r = esperimento(p)
    print("%.1f   %.3f +- %.3f    %.3f +- %.3f      %.3f +- %.3f    %.3f +- %.3f" % ((p,) + tuple(v for k in range(4) for v in (r[:, k].mean(), r[:, k].std()))))
p     acc. training    acc. validazione   acc. test         perdita di validazione
0.0   1.000 +- 0.000    0.915 +- 0.011      0.906 +- 0.015    0.368 +- 0.064
0.2   1.000 +- 0.000    0.917 +- 0.010      0.913 +- 0.009    0.367 +- 0.063
0.5   1.000 +- 0.000    0.918 +- 0.012      0.909 +- 0.006    0.378 +- 0.059

Cosa fa il codice. La funzione esperimento ripete l'addestramento con otto semi diversi, che cambiano sia i 100 esempi scelti sia l'inizializzazione; nn.Dropout(p) è lo strato di PyTorch che implementa il dropout invertito (attivo solo in train()). La tabella dice una cosa sgradita ma istruttiva: in questo esperimento il dropout non ha alcun effetto misurabile. Con p = 0, 0,2 e 0,5 l'accuratezza sul test è 0,906, 0,913 e 0,909, con deviazioni tra semi di 0,006-0,015; la perdita di validazione è 0,37-0,38 in tutti e tre i casi. Le differenze stanno dentro il rumore. Tutte le reti arrivano al 100% sul training indipendentemente dal dropout.

Perché? Digits è un problema facile e a bassa dimensione, e un addestramento di 150 epoche con Adam porta tutte le versioni a memorizzare il training; il rumore del dropout non basta a impedirlo con 512 neuroni per strato. L'articolo originale lo valuta su reti e dataset più grandi (tra cui reti convolutive su CIFAR e ImageNet), ed è in quel regime che conviene misurarlo; qui il dato è che sulle cifre 8×8 non serve. La regola che ne ricavi è più utile del risultato: una tecnica di regolarizzazione è un'ipotesi da misurare sul tuo problema, non un obbligo.

La trappola più comune: dimenticare eval()

torch.manual_seed(0)
X_tr, _, y_tr, _ = train_test_split(X_pool, y_pool, train_size=250, stratify=y_pool, random_state=0)
media, dev = X_tr.mean(), X_tr.std()
T_tr, T_te = [torch.tensor((a - media) / dev, dtype=torch.float32) for a in (X_tr, X_te)]
L_tr, L_te = torch.tensor(y_tr), torch.tensor(y_te)

modello = nn.Sequential(nn.Linear(64, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 10))
ott = torch.optim.Adam(modello.parameters(), lr=1e-3)
loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=32, shuffle=True)
for _ in range(100):
    modello.train()
    for xb, yb in loader:
        ott.zero_grad(); nn.CrossEntropyLoss()(modello(xb), yb).backward(); ott.step()

def accuratezza_test():
    with torch.no_grad():
        return (modello(T_te).argmax(1) == L_te).float().mean().item()

modello.train()                                                    # DIMENTICATO eval(): il dropout è ancora attivo
print("modo train (sbagliato), 5 misure sullo stesso test:", np.round([accuratezza_test() for _ in range(5)], 3))
modello.eval()
print("modo eval  (corretto),  5 misure sullo stesso test:", np.round([accuratezza_test() for _ in range(5)], 3))
modo train (sbagliato), 5 misure sullo stesso test: [0.911 0.9   0.908 0.908 0.903]
modo eval  (corretto),  5 misure sullo stesso test: [0.922 0.922 0.922 0.922 0.922]

Cosa fa il codice. Addestra una rete con dropout 0,5 e poi misura cinque volte l'accuratezza sullo stesso test set, prima lasciando il modello in modo train() e poi in modo eval(). In modo train il dropout spegne neuroni a caso anche in valutazione: le cinque misure sono diverse tra loro (da 0,900 a 0,911) e mediamente più basse; in modo eval sono identiche (0,922). Due sintomi inequivocabili: un risultato che cambia da una valutazione all'altra sugli stessi dati e un'accuratezza peggiore di quella di training più validazione. È l'errore più frequente con il dropout, e la cura è l'abitudine: model.eval() prima di ogni valutazione, model.train() prima di ogni addestramento.

Batch Normalization: normalizzare dentro la rete

Nella lezione sulla regressione hai visto che standardizzare le feature rende molto più facile la discesa del gradiente. La batch normalization (S. Ioffe e C. Szegedy, 2015) applica la stessa idea non solo all'ingresso ma in mezzo alla rete: prima di una non linearità, normalizza ciascuna attivazione usando la media e la deviazione standard del mini-batch, e poi permette alla rete di imparare la scala e lo spostamento che preferisce. Per ogni feature (o canale) j e un batch di N esempi:

μ_j = (1/N) Σ_i x_ij                          media del batch
σ²_j = (1/N) Σ_i (x_ij − μ_j)²                varianza del batch
x̂_ij = (x_ij − μ_j) / √(σ²_j + ε)              normalizzazione (ε ≈ 10⁻⁵ evita la divisione per zero)
y_ij = γ_j · x̂_ij + β_j                        scala e spostamento APPRESI

I due parametri γ (scala) e β (spostamento) sono addestrati dalla discesa del gradiente come qualunque peso. Servono perché forzare ogni attivazione a media 0 e deviazione 1 toglierebbe espressività (per esempio, una sigmoide in zona lineare non avrebbe più saturazione): con γ e β la rete può, se conviene, annullare la normalizzazione (γ = √(σ² + ε) e β = μ restituiscono l'identità).

Batch normalization: centrare, scalare, riscalareLa distribuzione di un'attivazione prima della normalizzazione (centrata lontano da zero e larga), dopo la normalizzazione (media 0, deviazione 1) e dopo la scala e lo spostamento appresi γ e βBatch normalization: centrare, scalare, riscalareprima: media 5, dev. 3dopo la normalizzazione: media 0, dev. 1dopo γ e β (appresi)(x − μ)/√(σ² + ε)γ · x̂ + β
La distribuzione di un'attivazione prima della normalizzazione (centrata lontano da zero e larga), dopo la normalizzazione (media 0, deviazione 1) e dopo la scala e lo spostamento appresi γ e β

Il disegno mostra le tre fasi: una attivazione con media 5 e deviazione 3 (a sinistra), la stessa dopo la normalizzazione, media 0 e deviazione 1 (al centro), e dopo la scala e lo spostamento appresi (a destra).

Forward e backward a mano, verificati contro PyTorch

Il passaggio in avanti segue le formule. Il passaggio all'indietro richiede un po' di pazienza, perché μ e σ² dipendono da tutti gli esempi del batch: ogni x_ij influenza tutte le uscite della sua colonna. Applicando la regola della catena su questo grafo si ottiene, con dx̂ = dy·γ:

dγ_j = Σ_i dy_ij · x̂_ij              dβ_j = Σ_i dy_ij
dx_ij = (1/N) · (1/√(σ²_j + ε)) · ( N·dx̂_ij − Σ_k dx̂_kj − x̂_ij · Σ_k dx̂_kj · x̂_kj )

I tre termini nella parentesi hanno un significato: il primo è il gradiente «diretto»; il secondo toglie la componente comune a tutto il batch (perché spostare tutti gli x_ij della stessa quantità non cambia nulla dopo la sottrazione della media); il terzo toglie la componente lungo x̂ (perché scalare tutto il batch non cambia l'uscita normalizzata). Verifichiamo la formula confrontandola con la differenziazione automatica:

rng = np.random.default_rng(0)
N, C, eps = 32, 4, 1e-5
X = rng.normal(5, 3, size=(N, C))                                    # attivazioni con media 5 e deviazione 3
gamma = np.array([1.0, 2.0, 0.5, 1.5]); beta = np.array([0.0, 1.0, -1.0, 0.5])

def bn_avanti(X, gamma, beta):
    mu, var = X.mean(axis=0), X.var(axis=0)                          # statistiche del batch, per colonna
    xh = (X - mu) / np.sqrt(var + eps)
    return gamma * xh + beta, (xh, var)

def bn_indietro(dY, gamma, cache):
    xh, var = cache
    dxh = dY * gamma
    dX = (1.0 / N) / np.sqrt(var + eps) * (N * dxh - dxh.sum(axis=0) - xh * (dxh * xh).sum(axis=0))
    return dX, (dY * xh).sum(axis=0), dY.sum(axis=0)

Y, cache = bn_avanti(X, gamma, beta)
print("dopo la BN: media per colonna", np.round(Y.mean(axis=0), 3), "  deviazione per colonna", np.round(Y.std(axis=0), 3), "(= beta e gamma)")

dY = rng.normal(size=X.shape)
dX, dgamma, dbeta = bn_indietro(dY, gamma, cache)

Xt = torch.tensor(X, requires_grad=True)
bn_torch = nn.BatchNorm1d(C, eps=eps).double()
bn_torch.weight.data, bn_torch.bias.data = torch.tensor(gamma), torch.tensor(beta)
uscita = bn_torch(Xt)
uscita.backward(torch.tensor(dY))
print("avanti coincide con PyTorch:", np.allclose(Y, uscita.detach().numpy()))
print("massima differenza dei gradienti (dX, dgamma, dbeta): %.1e  %.1e  %.1e" % (
    np.abs(dX - Xt.grad.numpy()).max(), np.abs(dgamma - bn_torch.weight.grad.numpy()).max(), np.abs(dbeta - bn_torch.bias.grad.numpy()).max()))
dopo la BN: media per colonna [ 0.   1.  -1.   0.5]   deviazione per colonna [1.  2.  0.5 1.5] (= beta e gamma)
avanti coincide con PyTorch: True
massima differenza dei gradienti (dX, dgamma, dbeta): 2.2e-16  6.2e-15  8.9e-16

Cosa fa il codice. bn_avanti calcola media e varianza lungo l'asse dei campioni (axis=0), normalizza e applica γ e β; salva nella cache x̂ e la varianza per il ritorno. bn_indietro trascrive la formula del gradiente. Dopo la normalizzazione ciascuna colonna ha esattamente media β_j e deviazione γ_j (il primo risultato), e l'avanti e i gradienti coincidono con quelli calcolati da PyTorch a meno di errori di arrotondamento (10⁻¹⁵). Aver derivato e verificato la formula ti dà l'assoluta tranquillità che la backpropagation attraverso un'operazione che coinvolge l'intero batch funzioni come ti aspetti.

Allenamento contro inferenza: le statistiche correnti

Un dettaglio che fa inciampare molti. In addestramento la BN usa media e varianza del batch corrente. In inferenza si può dover prevedere un solo esempio, e un batch di uno non ha statistiche sensate. Perciò durante l'addestramento la BN mantiene una media mobile esponenziale di μ e σ² (con momento 0,1: nuovo = 0,9·vecchio + 0,1·batch), e in modalità eval() usa quelle. Il comportamento è diverso nei due modi, e la differenza è una trappola:

bn = nn.BatchNorm1d(4)
for _ in range(200):                                                         # 200 batch di addestramento, dati con media 5 e dev. 3
    bn(torch.tensor(rng.normal(5, 3, size=(32, 4)), dtype=torch.float32))
print("statistiche correnti apprese: media", bn.running_mean.numpy().round(2), " varianza", bn.running_var.numpy().round(2), "(vere: 5 e 9)")

x = torch.tensor(rng.normal(5, 3, size=(2, 4)), dtype=torch.float32)         # un mini-batch di 2 esempi
bn.eval()
print("\nmodo eval (usa le statistiche correnti):\n", bn(x).detach().numpy().round(2))
bn.train()
print("modo train con un batch di 2 (usa le statistiche di quei 2 esempi):\n", bn(x).detach().numpy().round(2))

conv_bn = nn.BatchNorm2d(3)                                                  # per le immagini: una media per canale su (N, H, W)
imm = torch.randn(8, 3, 5, 5) * torch.tensor([1., 5., 10.])[None, :, None, None] + torch.tensor([0., 3., -2.])[None, :, None, None]
y = conv_bn(imm)
print("\nBatchNorm2d: medie per canale dopo la normalizzazione", y.mean(dim=(0, 2, 3)).detach().numpy().round(3),
      " parametri addestrabili:", sum(p.numel() for p in conv_bn.parameters()), "(gamma e beta per 3 canali)",
      " buffer:", [n for n, _ in conv_bn.named_buffers()])
statistiche correnti apprese: media [5.14 4.94 4.91 4.97]  varianza [8.98 8.43 9.07 8.58] (vere: 5 e 9)

modo eval (usa le statistiche correnti):
 [[-1.47 -1.1  -0.79 -1.43]
 [ 0.21 -1.18  0.32 -0.64]]
modo train con un batch di 2 (usa le statistiche di quei 2 esempi):
 [[-1.  1. -1. -1.]
 [ 1. -1.  1.  1.]]

BatchNorm2d: medie per canale dopo la normalizzazione [-0.  0.  0.]  parametri addestrabili: 6 (gamma e beta per 3 canali)  buffer: ['running_mean', 'running_var', 'num_batches_tracked']

Cosa fa il codice. Dopo 200 batch, le statistiche correnti convergono ai valori veri dei dati (media 5, varianza 9). Poi il confronto: in modo eval un esempio viene normalizzato con le statistiche accumulate, e produce valori ragionevoli (tra −1,5 e 0,3); in modo train con un batch di due soli esempi, ogni colonna ha due valori, che la normalizzazione porta a −1 e +1 (circa) qualunque sia il loro contenuto: l'informazione sull'ampiezza delle differenze è cancellata. È il motivo per cui la BN soffre con batch molto piccoli (le statistiche sono rumorose) e per cui, in inferenza, non si possono usare statistiche calcolate sul batch. Per le immagini, BatchNorm2d calcola una media e una varianza per canale, mediando su tutti gli esempi e su tutte le posizioni (N, H, W): le tre medie per canale tornano a zero pur partendo da scale diverse. I parametri addestrabili sono 2C (γ e β per ciascun canale); le statistiche correnti sono buffer, non parametri: fanno parte dello state_dict ma non ricevono gradiente.

L'effetto: la sensibilità al learning rate

Mettiamo la BN alla prova dove ci aspettiamo che serva: una rete profonda (7 strati densi da 64) addestrata con SGD, con diversi learning rate, con e senza BN davanti a ogni ReLU. Si misura la perdita finale di training e l'accuratezza di test.

X_all, X_te2 = X_tmp, X_te
media, dev = X_all.mean(), X_all.std()
T_all, T_test = [torch.tensor((a - media) / dev, dtype=torch.float32) for a in (X_all, X_te2)]
L_all, L_test = torch.tensor(y_tmp), torch.tensor(y_te)

def rete_profonda(con_bn, profondita=6, larghezza=64):
    strati = [nn.Linear(64, larghezza)]
    for _ in range(profondita):
        if con_bn:
            strati.append(nn.BatchNorm1d(larghezza))
        strati += [nn.ReLU(), nn.Linear(larghezza, larghezza)]
    if con_bn:
        strati.append(nn.BatchNorm1d(larghezza))
    strati += [nn.ReLU(), nn.Linear(larghezza, 10)]
    return nn.Sequential(*strati)

def allena(modello, lr, epoche=20):
    ott = torch.optim.SGD(modello.parameters(), lr=lr)
    perdita_fn = nn.CrossEntropyLoss()
    loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_all, L_all), batch_size=32, shuffle=True)
    for _ in range(epoche):
        modello.train()
        for xb, yb in loader:
            ott.zero_grad(); perdita_fn(modello(xb), yb).backward(); ott.step()
    modello.eval()
    with torch.no_grad():
        return perdita_fn(modello(T_all), L_all).item(), (modello(T_test).argmax(1) == L_test).float().mean().item()

print("learning rate   senza BN: perdita  acc. test      con BN: perdita  acc. test")
for lr in (0.01, 0.05, 0.2, 0.5):
    torch.manual_seed(0); senza = allena(rete_profonda(False), lr)
    torch.manual_seed(0); con = allena(rete_profonda(True), lr)
    print("%13g   %18.4f  %8.3f   %19.4f  %8.3f" % ((lr,) + senza + con))
learning rate   senza BN: perdita  acc. test      con BN: perdita  acc. test
         0.01               2.3022     0.100                0.0348     0.978
         0.05               2.2955     0.236                0.0314     0.961
          0.2                  nan     0.100                0.0650     0.956
          0.5               1.6249     0.283                0.1007     0.922

Cosa fa il codice. rete_profonda costruisce 7 strati densi, con o senza BatchNorm1d davanti a ogni ReLU. allena usa SGD semplice (senza momento né Adam, per isolare l'effetto) per 20 epoche e restituisce la perdita sul training e l'accuratezza sul test. L'effetto è spettacolare. Senza BN, con learning rate 0,01 e 0,05 la rete non parte: la perdita resta a 2,30 (cioè ln 10, il caso) e l'accuratezza di test è 0,10 e 0,24; con 0,2 la discesa diverge (la perdita diventa nan, test 0,10) e con 0,5 è instabile (perdita 1,62, test 0,28).

Con BN la stessa rete impara a tutti i learning rate: perdita di training tra 0,031 e 0,101 e accuratezza di test tra 0,922 e 0,978; la qualità cala un poco al crescere del learning rate (0,978 con 0,01, 0,922 con 0,5), ma nessuna cella è fallita. In questo esperimento la BN rende l'addestramento insensibile alla scelta del learning rate su un intervallo di 50 volte (da 0,01 a 0,5). Ogni cella è una sola esecuzione con seme 0, quindi leggi la tendenza e non i decimali; Ioffe e Szegedy riportano lo stesso vantaggio: learning rate molto più alti.

Perché funziona?

La spiegazione dell'articolo originale era che la BN riduce l'«internal covariate shift», cioè il cambiamento della distribuzione degli ingressi di ogni strato durante l'addestramento. Lavori successivi, in particolare S. Santurkar e coll. (2018), hanno mostrato che l'effetto non dipende da quel meccanismo: la BN rende il paesaggio della funzione di perdita più liscio (i gradienti variano meno bruscamente da un passo all'altro), e questo permette passi più grandi senza instabilità. Un secondo effetto è regolarizzante: poiché le statistiche dipendono dal batch, ogni esempio viene normalizzato in modo leggermente diverso a ogni passo, un rumore simile a quello del dropout. In pratica l'onestà richiede di dire «funziona, e il perché è ancora discusso»: la tecnica è in uso in quasi tutte le CNN, ma la spiegazione teorica resta oggetto di ricerca.

Dove si mettono, e come si combinano

  • BN: dopo la convoluzione (o lo strato denso) e prima della ReLU nello schema originale; molti usano anche la disposizione dopo la ReLU, con risultati simili. Con la BN il bias dello strato precedente è ridondante (la sottrazione della media lo cancella), per cui si può usare bias=False.
  • Dropout: tipicamente dopo gli strati densi finali, con p tra 0,2 e 0,5 come nei listati. Srivastava e coll. lo applicano anche agli strati convolutivi, con probabilità di spegnimento più basse negli strati iniziali; attenzione al verso: nel loro testo p è la probabilità di tenere il neurone, in PyTorch nn.Dropout(p) è la probabilità di spegnerlo.
  • Insieme: nelle CNN con BN il dropout è spesso ridotto o omesso, perché la BN ha già un effetto regolarizzante. Si decide misurando.
  • Alternative: la layer normalization normalizza per esempio, su tutte le feature, e non dipende dal batch: è la scelta standard nei transformer e quando i batch sono piccoli o variabili.

Le quattro voci rispondono a una sola domanda: dove sta ciascuna tecnica, e quando una può sostituire l'altra. La batch normalization va dentro la rete, davanti alla non linearità; il dropout sta in fondo, dove gli strati densi sono grandi. Quando usi la prima, spesso togli o riduci il secondo; la sostituzione per i batch piccoli è la layer normalization. Nessuna delle quattro è una regola: sono punti di partenza da confermare sulla validazione, come ha mostrato l'esperimento del dropout.

Trade-off e trappole reali

  • Dimenticare model.eval(): con dropout produce risultati rumorosi; con la BN usa le statistiche del batch invece di quelle correnti. È l'errore numero uno per entrambe.
  • Batch troppo piccoli con la BN: statistiche rumorose, comportamento diverso tra training e inferenza. Con batch molto piccoli conviene una normalizzazione che non dipende dal batch (layer, group).
  • Batch non rappresentativi: un batch di sole cifre «7» ha statistiche diverse da uno misto. Rimescola.
  • Statistiche correnti stantie: se cambi i dati (fine-tuning su un dominio diverso) le statistiche correnti vanno ricalcolate o la BN va congelata consapevolmente.
  • Dropout sull'ultimo strato o p troppo alto: toglie informazione necessaria alla previsione.
  • Adottare il dropout «perché si fa»: nell'esperimento sopra non ha dato nessun vantaggio misurabile. Se non migliora la validazione, toglilo: ogni componente in più è una cosa che può rompersi.
  • Bias ridondante prima della BN: non sbagliato, solo inutile (parametri sprecati).
  • Salvare solo i parametri e dimenticare i buffer: state_dict li include, ma se salvi a mano parameters() perdi le statistiche correnti e il modello caricato si comporta diversamente.
  • Valutare con il batch intero invece di batch piccoli in modo train: le statistiche di un test set sbilanciato contaminano la valutazione. In inferenza usa sempre eval().

Le nove trappole nascono quasi tutte dalla stessa radice: dropout e batch normalization si comportano in modo diverso in addestramento e in inferenza, e il codice non te lo ricorda. Le prime quattro riguardano quel doppio comportamento (eval(), batch piccoli, batch non rappresentativi, statistiche stantie); le altre sono scelte da misurare o spreco di parametri. Un sintomo da riconoscere subito è il risultato diverso a ogni valutazione sugli stessi dati: nel listato della sezione sul dropout andava da 0,900 a 0,911, contro un 0,922 stabile in modo eval(). Se salvi e ricarichi il modello e i numeri cambiano, controlla che i buffer siano nello state_dict.

Domande frequenti

Il dropout rallenta l'addestramento? Sì, di solito servono più epoche, perché il rumore rende il segnale di gradiente meno pulito. Il costo per passo è trascurabile.

La BN è una forma di regolarizzazione? Ha un effetto regolarizzante (il rumore dovuto alle statistiche del batch), ma il suo scopo principale è stabilizzare e accelerare l'ottimizzazione.

Perché γ e β sono per canale e non per posizione? Perché la convoluzione tratta tutte le posizioni allo stesso modo (condivisione dei pesi); la normalizzazione per canale rispetta questa simmetria.

Posso usare la BN con batch size 1 in inferenza? Sì, in modo eval(): usa le statistiche correnti. È in addestramento che un batch di 1 non funziona.

Tre esercizi

Esercizio 1. Ripeti l'esperimento del dropout con una rete più piccola (32 neuroni per strato) e con più dati (400 esempi): l'effetto cambia? Prova anche p = 0,8. Cosa succede all'accuratezza sul training?

Esercizio 2. Nella rete profonda con BN, riprova con batch di 2 esempi invece di 32 e confronta l'accuratezza di test in eval() con quella del batch 32. Quanto soffre la BN?

Esercizio 3. Aggiungi BatchNorm2d alla CNN della lezione precedente e misura se migliora la robustezza agli spostamenti. (Probabilmente poco: la BN agisce sull'ottimizzazione, non sull'invarianza.)

Che cosa sai fare adesso

Sai scrivere il dropout invertito a mano e verificare che la media resti 1 e la varianza valga p/(1 − p). Sai riconoscere il suo errore classico, eval() dimenticato, dal risultato che cambia a ogni misura. Hai visto una tecnica che qui non serve (dropout su Digits: test 0,906, 0,913 e 0,909 con p = 0, 0,2 e 0,5, dentro il rumore tra semi) e una che cambia l'esito (BN: la rete profonda senza BN non parte con learning rate 0,01 e 0,05, con BN impara a tutti e quattro i valori provati). Sai derivare il gradiente della batch normalization e controllarlo contro PyTorch (differenza massima dell'ordine di 10⁻¹⁵), e sai che in inferenza usa le statistiche correnti, che sono buffer e non parametri. Nella lezione successiva, feature map, global pooling e U-Net, guardi dentro una CNN; per riusarne una già addestrata, vai al transfer learning, dove le statistiche correnti della BN tornano a contare.

Nella stessa serie rimandano a questa lezione anche: Addestrare una CNN in stile LeNet-5 con PyTorch.

Fonti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti