Salta al contenuto principale

10 - Reti Neurali Feedforward: da perceptron a deep learning

Perceptron, XOR risolto a mano, backpropagation verificata con autograd e differenze finite, vanishing gradient e overfitting misurati con PyTorch.

Cosa ne pensi di questo articolo?

10 - Reti Neurali Feedforward: da perceptron a deep learning
ML Fundamentalsfedericocalo.dev

In Breve

Perceptron, XOR risolto a mano, backpropagation verificata con autograd e differenze finite, vanishing gradient e overfitting misurati con PyTorch.

Un singolo neurone artificiale traccia una retta, e una retta non sa risolvere l'XOR, la funzione che vale 1 quando i due ingressi sono diversi: nessuna retta separa (0,1) e (1,0) da (0,0) e (1,1). Aggiungi uno strato intermedio e il problema si risolve con otto parametri.

Una rete neurale feedforward, o Multi-Layer Perceptron (MLP), è una pila di strati in cui ogni strato calcola una combinazione lineare dell'ingresso e poi applica una funzione di attivazione non lineare; «feedforward» significa che l'informazione va sempre in avanti, senza cicli. I pesi si trovano con la backpropagation, che calcola per ogni peso il gradiente (di quanto cambia l'errore se il peso cambia) applicando la regola della catena dall'uscita verso l'ingresso.

Questo articolo, il decimo della serie ML Fundamentals, mostra ciascuno di questi pezzi con un calcolo che puoi rifare: il perceptron che fallisce, l'XOR risolto a mano, un gradiente verificato in tre modi, e poi due esperimenti con PyTorch su perché le reti profonde sono difficili da addestrare e che cosa riduce davvero l'overfitting.

Le versioni più estese di questi temi stanno nella serie Machine Learning Engineer, che dedica un articolo al percettrone e alle funzioni di attivazione, uno alla backpropagation derivata per intero e uno a una rete da zero in NumPy e poi in PyTorch. Qui l'obiettivo è più stretto: capire dove sta il salto dal modello lineare a quello profondo, in un modo che puoi verificare in un minuto di calcolo. Ti serve aver visto la regressione logistica, che è un singolo neurone con attivazione sigmoide.

Il perceptron e il problema che non sa risolvere

Il perceptron di Rosenblatt calcola $\hat{y} = 1$ se $w \cdot x + b > 0$, altrimenti 0. La regola di apprendimento è un'unica riga: se la predizione è sbagliata, sposta il vettore dei pesi verso l'esempio, $w \leftarrow w + (y - \hat{y})\, x$, e fai lo stesso con $b$.

Per dati separabili da una retta la regola converge in un numero finito di passi (il teorema di convergenza del perceptron, che qui non dimostro). Il listato la applica a due funzioni logiche sui quattro punti del quadrato: AND, che è separabile, e XOR, che non lo è. Alla fine prova anche a forza bruta 15.625 rette diverse su una griglia di pesi e bias, per vedere qual è il massimo di punti XOR che una retta può classificare giusti.

# Il perceptron di Rosenblatt: impara AND, non puo' imparare XOR
import itertools
import numpy as np

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
funzioni = {"AND": np.array([0, 0, 0, 1]), "XOR": np.array([0, 1, 1, 0])}

def perceptron(X, y, max_epoche=100):
    """Regola: se sbagli, w <- w + (y - y_pred) * x. Fermati quando non sbagli piu'."""
    w, b = np.zeros(2), 0.0
    for epoca in range(1, max_epoche + 1):
        errori = 0
        for xi, yi in zip(X, y):
            pred = 1 if w @ xi + b > 0 else 0
            if pred != yi:
                w, b = w + (yi - pred) * xi, b + (yi - pred)
                errori += 1
        if errori == 0:
            return w, b, epoca
    return w, b, None

for nome, y in funzioni.items():
    w, b, ep = perceptron(X, y)
    pred = (X @ w + b > 0).astype(int)
    esito = f"converge alla epoca {ep}" if ep else "NON converge in 100 epoche"
    print(f"{nome}: {esito}; w = {w.tolist()}, b = {b}; punti giusti = {(pred == y).sum()}/4")

# Prova esaustiva: nessuna retta w1*x1 + w2*x2 + b > 0 classifica giusti tutti e 4 i punti di XOR
griglia = np.round(np.arange(-3, 3.01, 0.25), 2)
migliore = 0
for w1, w2, b in itertools.product(griglia, griglia, griglia):
    pred = (X @ np.array([w1, w2]) + b > 0).astype(int)
    migliore = max(migliore, int((pred == funzioni["XOR"]).sum()))
print(f"XOR: massimo di punti giusti su {len(griglia) ** 3} rette provate = {migliore}/4")

# Output:
# AND: converge alla epoca 6; w = [2.0, 1.0], b = -2.0; punti giusti = 4/4
# XOR: NON converge in 100 epoche; w = [-1.0, 0.0], b = 1.0; punti giusti = 2/4
# XOR: massimo di punti giusti su 15625 rette provate = 3/4

Su AND il perceptron converge alla sesta epoca, con $w = (2,1)$ e $b = -2$: la retta passa fra (1,1) e gli altri tre punti. Su XOR dopo 100 epoche non ha convergenza, e si ferma con 2 punti giusti su 4; la ricerca esaustiva conferma che non è un limite della regola, ma del modello: il massimo ottenibile con una retta su 15.625 tentativi è 3 punti su 4. È la ragione storica per cui servono più strati: una combinazione di rette può disegnare regioni che una retta sola non disegna.

Uno strato nascosto, il passo avanti e la backpropagation

Con uno strato nascosto di due unità ReLU ($\max(0, z)$) l'XOR si risolve con pesi scritti a mano, quelli che il libro di Goodfellow, Bengio e Courville usa nel capitolo 6: $h = \max(0, Wx + c)$ con $W = \begin{pmatrix}1&1\\1&1\end{pmatrix}$ e $c = (0,-1)$, poi $y = w \cdot h$ con $w = (1,-2)$.

Per l'ingresso (1,1): $Wx + c = (2, 1)$, quindi $h = (2,1)$ e $y = 2 - 2 = 0$; per (0,1): $h = (1, 0)$ e $y = 1$. Il primo neurone nascosto conta quanti ingressi sono accesi, il secondo si accende solo se sono entrambi accesi e gli viene sottratto il doppio: così il caso (1,1) rientra a zero.

Per trovare i pesi senza indovinarli serve il gradiente. Per una rete 3-4-1 con attivazione $\tanh$ e perdita $L = \tfrac{1}{2N}\sum (y - t)^2$, la regola della catena dà, dall'uscita all'ingresso: $\delta_y = (y - t)/N$, poi $\partial L/\partial W_2 = h^\top \delta_y$, poi si propaga $\delta_h = \delta_y W_2^\top$, si attraversa la $\tanh$ moltiplicando per la sua derivata $1 - h^2$, e infine $\partial L/\partial W_1 = x^\top \delta_a$.

La seconda metà del listato implementa queste righe e le confronta con due riferimenti indipendenti: l'autograd di PyTorch, in doppia precisione, e le differenze finite, cioè $(L(w+\epsilon) - L(w-\epsilon))/2\epsilon$ calcolate peso per peso.

# 1) La rete 2-2-1 con ReLU che risolve XOR a mano; 2) backpropagation scritta a mano contro autograd e differenze finite
import numpy as np
import torch

torch.set_num_threads(2)
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)

# Pesi scelti a mano: h = ReLU(W x + c), y = w . h + b
W = np.array([[1.0, 1.0], [1.0, 1.0]]); c = np.array([0.0, -1.0]); w = np.array([1.0, -2.0]); b = 0.0
H = np.maximum(0, X @ W.T + c)
print("attivazioni nascoste per i 4 ingressi:\n", H.tolist())
print("uscite:", (H @ w + b).tolist(), "  (XOR atteso: [0, 1, 1, 0])")

# --- Backpropagation a mano su una rete 3 -> 4 (tanh) -> 1 (lineare), perdita MSE, in float64 ---
rng = np.random.default_rng(0)
x = rng.normal(size=(5, 3)); t = rng.normal(size=(5, 1))
W1, b1 = rng.normal(size=(3, 4)) * 0.5, np.zeros(4)
W2, b2 = rng.normal(size=(4, 1)) * 0.5, np.zeros(1)

def avanti(W1, b1, W2, b2):
    a = x @ W1 + b1; h = np.tanh(a); y = h @ W2 + b2
    return a, h, y, 0.5 * ((y - t) ** 2).mean()

a, h, y, L = avanti(W1, b1, W2, b2)
dy = (y - t) / len(x)                       # dL/dy
gW2, gb2 = h.T @ dy, dy.sum(axis=0)         # strato di uscita
dh = dy @ W2.T                              # si propaga all'indietro
da = dh * (1 - h ** 2)                      # derivata di tanh: 1 - tanh^2
gW1, gb1 = x.T @ da, da.sum(axis=0)

# autograd di PyTorch
P = [torch.tensor(v, dtype=torch.float64, requires_grad=True) for v in (W1, b1, W2, b2)]
yy = torch.tanh(torch.tensor(x) @ P[0] + P[1]) @ P[2] + P[3]
(0.5 * ((yy - torch.tensor(t)) ** 2).mean()).backward()
print("max |gradiente a mano - autograd|:", max(float(np.abs(g - p.grad.numpy()).max()) for g, p in zip((gW1, gb1, gW2, gb2), P)))

# differenze finite sui pesi W1: (L(w+e) - L(w-e)) / 2e
e = 1e-6; num = np.zeros_like(W1)
for i in range(W1.shape[0]):
    for j in range(W1.shape[1]):
        Wp, Wm = W1.copy(), W1.copy(); Wp[i, j] += e; Wm[i, j] -= e
        num[i, j] = (avanti(Wp, b1, W2, b2)[3] - avanti(Wm, b1, W2, b2)[3]) / (2 * e)
print("max |gradiente a mano - differenze finite| su W1:", f"{np.abs(gW1 - num).max():.1e}")

# Output:
# attivazioni nascoste per i 4 ingressi:
#  [[0.0, 0.0], [1.0, 0.0], [1.0, 0.0], [2.0, 1.0]]
# uscite: [0.0, 1.0, 1.0, 0.0]   (XOR atteso: [0, 1, 1, 0])
# max |gradiente a mano - autograd|: 5.551115123125783e-17
# max |gradiente a mano - differenze finite| su W1: 4.4e-11

Le quattro uscite sono 0, 1, 1, 0 come XOR richiede, e le attivazioni nascoste coincidono con il conto a mano (per (1,1) sono (2,1)). Il gradiente a mano differisce da quello di PyTorch per 5,6e-17, cioè per l'arrotondamento, e dalle differenze finite per 4,4e-11, un errore dell'ordine di 1e-10, che è il limite imposto dall'arrotondamento con $\epsilon = 10^{-6}$.

Le differenze finite sono troppo lente per addestrare una rete vera (un calcolo della perdita per ogni peso) ma sono lo strumento giusto per controllare una backpropagation scritta a mano: se il confronto non torna, il bug è nel tuo codice e non nel metodo.

Perché una rete profonda non parte: i gradienti

Nel gradiente appena visto compaiono prodotti di pesi e di derivate dell'attivazione, uno per ogni strato attraversato. Con molti strati, se quei fattori sono mediamente minori di 1 il gradiente dei primi strati si spegne (vanishing gradient), se sono maggiori di 1 esplode. La sigmoide ha derivata al più 0,25, quindi in una pila profonda ogni strato taglia il gradiente almeno di quattro volte.

Glorot e Bengio (2010) trovano che la sigmoide logistica non è adatta a reti profonde con inizializzazione casuale per via del suo valore medio, che può portare in saturazione lo strato più alto, e propongono un'inizializzazione che mantiene la varianza dei segnali. He e colleghi (2015) la correggono per le ReLU: poiché la ReLU azzera metà dei valori, la condizione diventa $\tfrac{1}{2} n\, \mathrm{Var}[w] = 1$, cioè deviazione standard $\sqrt{2/n}$.

Il listato costruisce una rete di 10 strati da 64 unità e misura, all'inizializzazione e con un solo batch casuale, il modulo medio del gradiente dei pesi nel primo e nel decimo strato, per cinque combinazioni di attivazione e inizializzazione; poi misura la deviazione standard dell'attivazione al decimo strato per tre inizializzazioni della ReLU. Le inizializzazioni sono quelle di torch.nn.init: xavier_uniform_ (Glorot), kaiming_normal_ con nonlinearity="relu" (He) e una gaussiana con deviazione 0,01.

# Vanishing gradient: rete di 10 strati da 64 unita', gradiente medio per strato, all'inizializzazione
import numpy as np
import torch
from torch import nn

torch.set_num_threads(2)

def rete(attivazione, init, profondita=10, larghezza=64):
    strati = []
    for i in range(profondita):
        lin = nn.Linear(larghezza if i else 20, larghezza)
        init(lin.weight); nn.init.zeros_(lin.bias)
        strati += [lin, attivazione()]
    return nn.Sequential(*strati, nn.Linear(larghezza, 2))

def misura(nome, attivazione, init):
    torch.manual_seed(0)
    net = rete(attivazione, init)
    x = torch.randn(256, 20); y = torch.randint(0, 2, (256,))
    nn.functional.cross_entropy(net(x), y).backward()
    lin = [m for m in net if isinstance(m, nn.Linear)]
    g = [float(m.weight.grad.abs().mean()) for m in lin]       # modulo medio del gradiente dei pesi
    print(f"{nome:34s} strato 1: {g[0]:.2e}  strato 10: {g[-2]:.2e}  rapporto 1/10: {g[0] / g[-2]:.2e}")

xavier = nn.init.xavier_uniform_
he = lambda w: nn.init.kaiming_normal_(w, nonlinearity="relu")
piccolo = lambda w: nn.init.normal_(w, std=0.01)
misura("sigmoide + Xavier", nn.Sigmoid, xavier)
misura("tanh + Xavier", nn.Tanh, xavier)
misura("ReLU + He (std = sqrt(2/n))", nn.ReLU, he)
misura("ReLU + pesi N(0, 0.01^2)", nn.ReLU, piccolo)
misura("ReLU + Xavier", nn.ReLU, xavier)

# Varianza propagata in avanti: la deviazione standard dell'ultima attivazione, ReLU, 10 strati
for nome, init in [("He", he), ("Xavier", xavier), ("N(0, 0.01^2)", piccolo)]:
    torch.manual_seed(0)
    net = rete(nn.ReLU, init)
    h = torch.randn(256, 20)
    for m in list(net)[:-1]:
        h = m(h)
    print(f"deviazione standard dell'attivazione al decimo strato, {nome:12s}: {float(h.detach().std()):.3e}")

# Output:
# sigmoide + Xavier                  strato 1: 1.41e-09  strato 10: 3.95e-04  rapporto 1/10: 3.56e-06
# tanh + Xavier                      strato 1: 9.23e-04  strato 10: 5.19e-04  rapporto 1/10: 1.78e+00
# ReLU + He (std = sqrt(2/n))        strato 1: 1.27e-03  strato 10: 1.83e-03  rapporto 1/10: 6.91e-01
# ReLU + pesi N(0, 0.01^2)           strato 1: 6.79e-15  strato 10: 1.45e-15  rapporto 1/10: 4.69e+00
# ReLU + Xavier                      strato 1: 7.65e-05  strato 10: 1.22e-05  rapporto 1/10: 6.29e+00
# deviazione standard dell'attivazione al decimo strato, He          : 5.978e-01
# deviazione standard dell'attivazione al decimo strato, Xavier      : 1.406e-02
# deviazione standard dell'attivazione al decimo strato, N(0, 0.01^2): 1.121e-13

La sigmoide con Xavier mostra il vanishing gradient in forma pura: il gradiente del primo strato è 1,4e-09, contro 4,0e-04 del decimo, un rapporto di 3,6e-06. Con la tanh e Xavier, per cui Glorot e Bengio avevano pensato l'inizializzazione, i due strati hanno gradienti simili (9,2e-04 e 5,2e-04). La ReLU con He conserva sia il gradiente (1,3e-03 contro 1,8e-03) sia l'attivazione: la deviazione standard al decimo strato è 0,598.

Con la ReLU e pesi troppo piccoli (0,01) tutto è nell'ordine di 1e-15 e di 1e-13: il rapporto fra i due strati è 4,7, dello stesso ordine, ma la rete non può imparare perché il segnale è sparito fin dall'inizio. La ReLU con Xavier sta nel mezzo, con attivazione 0,014 al decimo strato: la scelta dell'inizializzazione deve accompagnare l'attivazione, non essere una costante. Questa è una misura di una rete e di un batch, che illustra il meccanismo senza pretendere di quantificarlo in generale.

Addestrare una rete: tre ottimizzatori sulle stesse cifre

L'ottimizzatore decide come usare il gradiente. La discesa stocastica del gradiente (SGD) sposta i pesi di $-\eta\, g$, dove $\eta$ è il tasso di apprendimento. Con il momentum mantiene una media mobile dei gradienti passati, e smorza le oscillazioni. Adam (Kingma e Ba, 2014) adatta il passo per ogni peso usando medie mobili del gradiente e del suo quadrato.

Il listato addestra la stessa rete 64-64-10 con ReLU sulle 1.797 immagini 8x8 di load_digits (1.257 per il training, 540 per il test, pixel riportati in 0..1) con la stessa inizializzazione e la stessa sequenza di mini-batch da 64, per 40 epoche, e cambia solo l'ottimizzatore. Come riferimento stampa l'accuratezza di una regressione logistica.

# Una MLP 64-64-10 su digits con tre ottimizzatori, stessa inizializzazione, stesse 40 epoche
import numpy as np
import torch
from torch import 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)
Xtr, Xte, ytr, yte = train_test_split(X / 16.0, y, test_size=0.3, random_state=0, stratify=y)
Xtr_t, ytr_t = torch.tensor(Xtr, dtype=torch.float32), torch.tensor(ytr)
Xte_t, yte_t = torch.tensor(Xte, dtype=torch.float32), torch.tensor(yte)

def addestra(nome, opt_fn, epoche=40, batch=64):
    torch.manual_seed(0)
    net = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10))
    opt = opt_fn(net.parameters())
    g = torch.Generator().manual_seed(0)
    storia = []
    for ep in range(epoche):
        perm = torch.randperm(len(Xtr_t), generator=g)
        for i in range(0, len(perm), batch):
            idx = perm[i:i + batch]
            opt.zero_grad()
            nn.functional.cross_entropy(net(Xtr_t[idx]), ytr_t[idx]).backward()
            opt.step()
        if ep + 1 in (1, 5, 10, 20, 40):
            with torch.no_grad():
                storia.append(float(nn.functional.cross_entropy(net(Xtr_t), ytr_t)))
    with torch.no_grad():
        acc = (net(Xte_t).argmax(1) == yte_t).float().mean().item()
    print(f"{nome:28s} perdita sul training alle epoche 1/5/10/20/40: " + " ".join(f"{v:.3f}" for v in storia) + f"   accuratezza test {acc:.3f}")

addestra("SGD lr=0.05", lambda p: torch.optim.SGD(p, lr=0.05))
addestra("SGD lr=0.05 momentum=0.9", lambda p: torch.optim.SGD(p, lr=0.05, momentum=0.9))
addestra("Adam lr=0.003", lambda p: torch.optim.Adam(p, lr=0.003))

lr = LogisticRegression(max_iter=1000).fit(Xtr, ytr)
print(f"riferimento, regressione logistica: accuratezza test {lr.score(Xte, yte):.3f} (540 immagini di test)")

# Output:
# SGD lr=0.05                  perdita sul training alle epoche 1/5/10/20/40: 2.250 1.901 1.138 0.458 0.217   accuratezza test 0.950
# SGD lr=0.05 momentum=0.9     perdita sul training alle epoche 1/5/10/20/40: 1.914 0.195 0.105 0.077 0.024   accuratezza test 0.978
# Adam lr=0.003                perdita sul training alle epoche 1/5/10/20/40: 1.807 0.334 0.159 0.086 0.034   accuratezza test 0.978
# riferimento, regressione logistica: accuratezza test 0.970 (540 immagini di test)

La discesa semplice con $\eta = 0{,}05$ è ancora a perdita 0,217 dopo 40 epoche, con accuratezza di test 0,950: non ha ancora finito di scendere. Il momentum a parità di tasso di apprendimento porta la perdita a 0,024 e l'accuratezza a 0,978; Adam con $\eta = 0{,}003$ arriva a 0,034 e allo stesso 0,978.

La regressione logistica, un solo strato, ottiene 0,970 sullo stesso test: la rete la supera di 0,8 punti, cioè quattro immagini su 540. Quindi per queste cifre una rete a strati non è un salto di qualità, e la differenza fra ottimizzatori è molto più grande di quella fra rete e modello lineare. Con un solo seme non puoi dire che SGD con momentum e Adam siano diversi: distano meno dell'incertezza che un solo addestramento ti lascia.

Overfitting: che cosa lo riduce davvero

Una rete con migliaia di pesi può memorizzare il training set: l'accuratezza sul training arriva a 1,000 e quella sul test resta più bassa, e la differenza è l'overfitting. Le tecniche di regolarizzazione più usate sono due: il dropout, che a ogni passo spegne a caso una frazione dei neuroni, e il weight decay, che penalizza i pesi grandi (in AdamW è un termine separato dal gradiente).

Il listato ha due parti. Nella prima addestra una rete 64-256-256-10 su 200 immagini, con e senza queste tecniche e con due semi, e confronta l'accuratezza su 500 immagini di test mai toccate. Nella seconda, senza regolarizzazione, aumenta la quantità di dati di training da 50 a 1.297 immagini.

# Overfitting: la rete 64-256-256-10 impara a memoria 200 immagini. Che cosa lo riduce davvero?
import numpy as np
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

torch.set_num_threads(2)
X, y = load_digits(return_X_y=True)
X = torch.tensor(X / 16.0, dtype=torch.float32); y = torch.tensor(y)
idx_tr_tutti, idx_te = train_test_split(np.arange(len(X)), test_size=500, random_state=0, stratify=y.numpy())
Xte, yte = X[idx_te], y[idx_te]                       # 500 immagini di test, mai toccate

def prova(n, dropout=0.0, wd=0.0, seed=0, epoche=100):
    idx = idx_tr_tutti[:n]                            # i primi n (gia' mescolati dallo split)
    Xtr, ytr = X[idx], y[idx]
    torch.manual_seed(seed)
    net = nn.Sequential(nn.Linear(64, 256), nn.ReLU(), nn.Dropout(dropout),
                        nn.Linear(256, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, 10))
    opt = torch.optim.AdamW(net.parameters(), lr=0.003, weight_decay=wd)
    g = torch.Generator().manual_seed(seed)
    for ep in range(epoche):
        net.train()
        perm = torch.randperm(n, generator=g)
        for i in range(0, n, 50):
            b = perm[i:i + 50]
            opt.zero_grad()
            nn.functional.cross_entropy(net(Xtr[b]), ytr[b]).backward()
            opt.step()
    net.eval()
    with torch.no_grad():
        return (net(Xtr).argmax(1) == ytr).float().mean().item(), (net(Xte).argmax(1) == yte).float().mean().item()

print("A) con 200 immagini di training, 2 semi (accuratezza di test, media e intervallo)")
for nome, kw in [("nessuna regolarizzazione", {}), ("dropout 0.5", {"dropout": 0.5}),
                 ("weight decay 1.0 (AdamW)", {"wd": 1.0}), ("dropout 0.5 + weight decay 1.0", {"dropout": 0.5, "wd": 1.0})]:
    r = np.array([prova(200, seed=s, **kw) for s in range(2)])
    print(f"   {nome:32s} train {r[:, 0].mean():.3f}  test {r[:, 1].mean():.3f} ({r[:, 1].min():.3f}-{r[:, 1].max():.3f})")

print("B) senza regolarizzazione, al crescere dei dati di training (1 seme)")
for n in (50, 100, 200, 400, 800, 1297):
    tr, te = prova(n)
    print(f"   n = {n:5d}  train {tr:.3f}  test {te:.3f}  divario {tr - te:+.3f}")

# Output:
# A) con 200 immagini di training, 2 semi (accuratezza di test, media e intervallo)
#    nessuna regolarizzazione         train 1.000  test 0.935 (0.932-0.938)
#    dropout 0.5                      train 1.000  test 0.938 (0.936-0.940)
#    weight decay 1.0 (AdamW)         train 1.000  test 0.937 (0.936-0.938)
#    dropout 0.5 + weight decay 1.0   train 1.000  test 0.932 (0.926-0.938)
# B) senza regolarizzazione, al crescere dei dati di training (1 seme)
#    n =    50  train 1.000  test 0.684  divario +0.316
#    n =   100  train 1.000  test 0.882  divario +0.118
#    n =   200  train 1.000  test 0.932  divario +0.068
#    n =   400  train 1.000  test 0.958  divario +0.042
#    n =   800  train 1.000  test 0.966  divario +0.034
#    n =  1297  train 1.000  test 0.980  divario +0.020

Nella parte A il training è sempre a 1,000 e le differenze di test sono da 0,932 a 0,938 (intervalli di due semi, che si sovrappongono): su questi dati e con questa rete, né il dropout 0,5 né il weight decay 1,0 muovono l'accuratezza oltre l'oscillazione fra semi. Nella parte B l'effetto dei dati è invece enorme: 0,684 con 50 immagini, 0,882 con 100, 0,932 con 200, 0,980 con 1.297, e il divario fra training e test scende da 0,316 a 0,020.

Non puoi concludere che il dropout «non serve»: l'esperimento riguarda una rete, un dataset e un'impostazione, e le tecniche dovrebbero contare di più con reti più grandi o dati rumorosi. Il risultato che regge è un altro: prima di aggiungere una regolarizzazione conviene sapere quanto guadagneresti con più dati, e di solito si misura con una curva di apprendimento come la parte B. L'articolo su overfitting e regolarizzazione e quello su dropout e batch normalization della serie Machine Learning Engineer approfondiscono entrambe le tecniche.

Esercizio svolto

Una rete 2-2-1 ha $W = \begin{pmatrix}1&-1\\2&0\end{pmatrix}$, $c = (0,-1)$, $w = (1,3)$, $b = 0{,}5$, attivazione ReLU, ingresso $x = (1,2)$. Calcola a mano $z$, $h$, $y$; poi i gradienti di $y$ rispetto a $w$, $W$ e $c$. C'è un neurone che non impara: quale, e perché? Poi controlla con il listato.

# Esercizio: passo avanti e gradienti di una rete 2-2-1 con ReLU
import torch

W = torch.tensor([[1.0, -1.0], [2.0, 0.0]], requires_grad=True)
c = torch.tensor([0.0, -1.0], requires_grad=True)
w = torch.tensor([1.0, 3.0], requires_grad=True)
b = torch.tensor(0.5, requires_grad=True)
x = torch.tensor([1.0, 2.0])

z = W @ x + c
h = torch.relu(z)
y = w @ h + b
y.backward()
print("z =", z.tolist(), " h =", h.tolist(), " y =", y.item())
print("dy/dw =", w.grad.tolist(), " dy/db =", b.grad.item())
print("dy/dW =", W.grad.tolist())
print("dy/dc =", c.grad.tolist())

# Output:
# z = [-1.0, 1.0]  h = [0.0, 1.0]  y = 3.5
# dy/dw = [0.0, 1.0]  dy/db = 1.0
# dy/dW = [[0.0, 0.0], [3.0, 6.0]]
# dy/dc = [0.0, 3.0]
Soluzione dell'esercizio

Il passo avanti è $z = Wx + c = (1 - 2 + 0,\ 2 + 0 - 1) = (-1, 1)$, quindi $h = (0, 1)$ e $y = 1 \cdot 0 + 3 \cdot 1 + 0{,}5 = 3{,}5$. Per la regola della catena $\partial y/\partial w = h = (0, 1)$, $\partial y/\partial b = 1$ e $\partial y/\partial c_j = w_j \cdot \mathbb{1}[z_j > 0]$, cioè $(0, 3)$; per $W$, la riga $j$ vale $w_j \cdot \mathbb{1}[z_j > 0] \cdot x$: la prima riga è nulla, la seconda è $3 \cdot (1,2) = (3, 6)$.

Il primo neurone nascosto ha $z_1 = -1 < 0$: la ReLU lo spegne e il suo gradiente è zero per questo ingresso, per cui nessun peso del neurone viene aggiornato. Se accade per tutti gli ingressi il neurone non impara più (un dead neuron).

Che cosa sai fare adesso

Dopo questo articolo sai fare cinque cose, tutte con una verifica a portata di mano:

  • spiegare perché un neurone solo non risolve XOR e costruire a mano la rete che lo risolve;
  • scrivere il passo avanti e la backpropagation di una rete piccola e controllarli con l'autograd e con le differenze finite;
  • riconoscere il vanishing gradient e scegliere inizializzazione (Xavier o He) e attivazione in modo coerente;
  • addestrare una rete con SGD, momentum o Adam sapendo che cosa cambia;
  • distinguere l'effetto della regolarizzazione da quello dei dati, con una curva di apprendimento.

Questi cinque punti si collegano in una sola frase: una rete è una funzione composta, il gradiente è la regola della catena applicata a quella composizione, e quasi tutti i problemi di addestramento sono problemi di scala dei numeri che attraversano la composizione. Nel prossimo articolo si passa dall'addestrare un modello al giudicarlo: le metriche di valutazione. Per portarlo in esercizio c'è poi il deployment; prima, nella serie, c'erano la PCA e il K-Means.

Domande frequenti

Perché una rete senza attivazione non serve a niente?

Perché la composizione di trasformazioni lineari è ancora una trasformazione lineare: due strati senza attivazione equivalgono a uno solo, con matrice $W_2 W_1$, e non risolvono XOR. La non linearità fra gli strati è ciò che permette alla rete di disegnare regioni che una retta non disegna.

Quale inizializzazione devo usare con la ReLU?

L'inizializzazione di He (kaiming_normal_ con nonlinearity="relu"), che usa deviazione standard $\sqrt{2/n}$. Nell'esperimento a 10 strati ha mantenuto sia il gradiente sia l'attivazione (deviazione standard 0,598), mentre Xavier l'ha ridotta a 0,014 e pesi troppo piccoli a 1e-13.

Il dropout evita sempre l'overfitting?

No. Nell'esperimento con 200 immagini il dropout 0,5 non ha spostato l'accuratezza di test oltre l'oscillazione fra semi, mentre passare da 100 a 200 immagini l'ha alzata da 0,882 a 0,932. Prima di regolarizzare misura una curva di apprendimento: se più dati aiutano molto, è la leva più efficace.

Nella stessa serie trovi anche: 01 - Introduzione al Machine Learning: tassonomia e workflow e 02 - Regressione Lineare from scratch in Python.

Fonti

I listati usano Python 3.12, PyTorch 2.12.0 su CPU con 2 thread, scikit-learn 1.9.0 e numpy 2.4.6; ciascuno è stato eseguito due volte con uscita identica. I dati sono sintetici o load_digits di scikit-learn. Il teorema di convergenza del perceptron e l'articolo di Kingma e Ba sull'ottimizzatore Adam sono citati a memoria, non aperti in questa sessione.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

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