Nella lezione precedente abbiamo visto una prima soluzione al problema delle reti molto profonde: affiancare a ogni strato un cancello e un percorso diretto. Le reti frattali (FractalNet), proposte da Larsson, Maire e Shakhnarovich nel 2016, scelgono un'altra strada: nessuna scorciatoia di identità e nessun cancello, ma una regola di costruzione ricorsiva che produce, dentro un unico blocco, tanti percorsi paralleli di profondità diversa, dal più corto (una sola trasformazione) al più lungo (decine). Il segnale ha sempre almeno un percorso breve per arrivare dove serve, e un metodo di regolarizzazione chiamato drop-path costringe ogni percorso a funzionare anche da solo. In questa lezione costruisci il blocco ricorsivo in NumPy, con la retropropagazione scritta a mano, conti i percorsi e misuri una cosa che sorprende: le singole «colonne» di una rete frattale, estratte dopo l'addestramento, possono essere reti complete e accurate, ma solo se la rete è stata addestrata con il drop-path.
Le parole di questa lezione
Una colonna è un percorso dall'ingresso all'uscita del blocco frattale: in un blocco con C colonne, la colonna j contiene 2^(j−1) unità (1, 2, 4, 8…). Il join è l'operazione che combina i percorsi che si ricongiungono, e qui è la media elemento per elemento dei tensori in arrivo. Il drop-path spegne a caso interi percorsi durante l'addestramento, come il dropout fa con i singoli neuroni: nella versione locale ogni join scarta a caso alcuni dei suoi ingressi lasciandone almeno uno, in quella globale si sceglie una sola colonna per tutto il batch. La Batch Normalization normalizza le attivazioni con media e deviazione standard del batch corrente durante l'addestramento, e in valutazione usa una media mobile (running_mean, running_var) accumulata in addestramento. Una previsione «anytime» è una risposta approssimata subito, con un percorso corto, e migliore dopo, con uno lungo. Nel codice l'unità di base è «lineare senza bias, BatchNorm, ReLU»; nell'articolo originale è una convoluzione al posto del livello lineare, e la struttura ricorsiva è identica.
L'analogia: una rete stradale con tanti itinerari
Immagina una città in cui per andare da un punto A a un punto B esistono molti itinerari: una strada diretta di un solo isolato, un percorso che attraversa due quartieri, uno che ne attraversa quattro, e così via, costruiti con una regola che si ripete a ogni scala. Il traffico (il segnale) si divide fra gli itinerari e si ricompone all'arrivo. Per evitare che il traffico dipenda sempre dalla stessa tangenziale, ogni tanto si chiude a caso una strada: i guidatori devono imparare a funzionare con qualsiasi sottoinsieme. Alla fine ogni itinerario, anche da solo, porta a destinazione.
La regola di espansione
Il blocco frattale f_C si definisce per ricorsione. Il caso base è una sola unità. Il passo ricorsivo prende due copie del blocco precedente in serie e le affianca a una unità singola, ricongiungendo i due percorsi con una media:
f_1(x) = unit(x)
f_{C}(x) = join( f_{C−1}( f_{C−1}(x) ), unit(x) )
Per C = 2 ci sono due percorsi: uno di una sola unità e uno di due in serie, ricongiunti dalla media. Per C = 3 si ricomincia: un percorso con una sola unità e un percorso formato da due copie in serie del blocco f(2), ciascuna con i propri due percorsi e la propria media. Nel disegno le unità sono etichettate «conv», come nell'articolo originale. Il numero di unità e la lunghezza del percorso più lungo seguono due formule che ricaviamo a mano e poi verifichiamo con il codice: le unità N(C) = 2·N(C−1) + 1 (due copie più una), quindi N(C) = 2^C − 1; il percorso più lungo L(C) = 2·L(C−1), quindi L(C) = 2^(C−1).
def conv_count(C):
"""Units in f_C: f_1 = 1 unit; f_{C+1} = f_C(f_C(x)) joined with 1 unit."""
return 1 if C == 1 else 2 * conv_count(C - 1) + 1
def longest_path(C):
return 1 if C == 1 else 2 * longest_path(C - 1)
for C in range(1, 6):
print(f"C={C}: units {conv_count(C):3d} (= 2^C - 1 = {2**C - 1:3d}), longest path {longest_path(C):2d} (= 2^(C-1) = {2**(C-1):2d}), columns {C}")
C=1: units 1 (= 2^C - 1 = 1), longest path 1 (= 2^(C-1) = 1), columns 1
C=2: units 3 (= 2^C - 1 = 3), longest path 2 (= 2^(C-1) = 2), columns 2
C=3: units 7 (= 2^C - 1 = 7), longest path 4 (= 2^(C-1) = 4), columns 3
C=4: units 15 (= 2^C - 1 = 15), longest path 8 (= 2^(C-1) = 8), columns 4
C=5: units 31 (= 2^C - 1 = 31), longest path 16 (= 2^(C-1) = 16), columns 5
Il codice implementa le due ricorrenze con due funzioni ricorsive e le confronta con le formule chiuse. Con C = 5 il blocco ha 31 unità, ma il percorso più lungo ne attraversa solo 16 e il più corto 1. Il parametro che pesa non è «quanti strati ho», è «quanto è lungo il cammino più lungo».
Il blocco frattale in NumPy, con la retropropagazione
PyTorch non è disponibile sulla macchina su cui ho eseguito i listati, e il download di MNIST richiede la rete: la rete qui sotto è quindi scritta in NumPy, con le unità dense al posto delle convoluzioni, e addestrata sulle 1797 cifre 8×8 di load_digits di scikit-learn. È un banco di prova più piccolo e più facile di quello della versione precedente di questa lezione, e i numeri assoluti non vanno confrontati con quelli di MNIST. La struttura che interessa, ricorsione, join a media e drop-path, è la stessa. L'unità contiene il pezzo più delicato, la retropropagazione attraverso la BatchNorm: la formula per dz è quella standard, in cui il gradiente di ogni campione dipende anche dalle statistiche dell'intero batch.
import numpy as np
class Unit:
"""Linear (no bias) + BatchNorm + ReLU, with its own backward pass."""
def __init__(self, rng, n_in, n_out, eps=1e-5, momentum=0.1):
lim = 1.0 / np.sqrt(n_in)
self.W = rng.uniform(-lim, lim, (n_in, n_out))
self.g, self.b = np.ones(n_out), np.zeros(n_out)
self.rm, self.rv = np.zeros(n_out), np.ones(n_out) # running mean / variance, used in eval
self.eps, self.momentum = eps, momentum
self.n_seen = 0
def forward(self, x, train):
z = x @ self.W
if train:
mu, var = z.mean(0), z.var(0)
if self.momentum is None: # plain average of all the batches seen
self.n_seen += 1; k = 1.0 / self.n_seen
else:
k = self.momentum
self.rm = (1 - k) * self.rm + k * mu
self.rv = (1 - k) * self.rv + k * var
else:
mu, var = self.rm, self.rv
self.inv = 1.0 / np.sqrt(var + self.eps)
self.xhat = (z - mu) * self.inv
y = self.g * self.xhat + self.b
self.x, self.mask = x, y > 0
return np.maximum(y, 0)
def backward(self, dout):
dy = dout * self.mask
self.dg, self.db = (dy * self.xhat).sum(0), dy.sum(0)
dxh = dy * self.g
n = len(dy)
dz = self.inv / n * (n * dxh - dxh.sum(0) - self.xhat * (dxh * self.xhat).sum(0))
self.dW = self.x.T @ dz
return dz @ self.W.T
def params(self):
return [(self, "W", "dW"), (self, "g", "dg"), (self, "b", "db")]
def reset_stats(self):
self.rm, self.rv, self.n_seen, self.momentum = np.zeros_like(self.rm), np.ones_like(self.rv), 0, None
forward calcola z = x·W, poi in addestramento media e varianza del batch, che aggiorna anche le medie mobili con peso momentum (0.1); in valutazione usa le medie mobili. reset_stats serve più avanti: azzera le medie mobili e passa a momentum = None, cioè alla semplice media aritmetica di tutti i batch visti, la convenzione che PyTorch chiama con lo stesso nome. Ho verificato backward con le differenze finite su una rete piccola (3 colonne, larghezza 5, tutti i parametri): la differenza massima fra gradiente analitico e numerico è 1.3·10⁻⁷, e il controllo non è nel listato.
La classe Fractal traduce la ricorsione quasi parola per parola. Contiene sempre l'unità «corta» e, se C > 1, due sottoblocchi di livello C − 1. Il forward ha tre modi: con col numerico esegue una sola colonna (la colonna 1 è l'unità corta, la colonna j > 1 è la colonna j − 1 di due copie in serie, ed è questo che dà 2^(j−1) unità); con col=None calcola il percorso profondo e quello corto e li ricongiunge con la media; in addestramento, con local_drop > 0, ogni percorso viene scartato con quella probabilità, con la regola che almeno uno sopravviva. Anche i percorsi scartati vengono calcolati, come nel codice originale, quindi aggiornano le statistiche della BatchNorm anche se non contribuiscono all'uscita. Il backward divide il gradiente per il numero di percorsi tenuti e lo manda solo a quelli.
class Fractal:
"""f_1 = unit ; f_C = join( f_{C-1}( f_{C-1}(x) ), unit(x) ). Column j has 2^(j-1) units."""
def __init__(self, rng, C, n_in, n_out):
self.C = C
self.shallow = Unit(rng, n_in, n_out) # the 1-unit path (column 1)
if C > 1:
self.a = Fractal(rng, C - 1, n_in, n_out)
self.b = Fractal(rng, C - 1, n_out, n_out)
def forward(self, x, train, rng, col=None, local_drop=0.0):
if col is not None: # run ONE column only
if col == 1:
self.mode = "s"; return self.shallow.forward(x, train)
self.mode = "c"
return self.b.forward(self.a.forward(x, train, rng, col - 1), train, rng, col - 1)
if self.C == 1:
self.mode = "s"; return self.shallow.forward(x, train)
self.mode = "full"
deep = self.b.forward(self.a.forward(x, train, rng, None, local_drop), train, rng, None, local_drop)
paths = [deep, self.shallow.forward(x, train)]
self.keep = [True, True]
if train and local_drop > 0: # LOCAL drop-path: drop each input of the join
self.keep = [bool(rng.random() > local_drop) for _ in paths]
if not any(self.keep): # never drop everything
self.keep[int(rng.integers(len(paths)))] = True
return np.mean([p for p, k in zip(paths, self.keep) if k], axis=0) # the join: mean of the survivors
def backward(self, dout):
if self.mode == "s":
return self.shallow.backward(dout)
if self.mode == "c":
return self.a.backward(self.b.backward(dout))
d = dout / sum(self.keep)
dx = 0
if self.keep[0]:
dx = dx + self.a.backward(self.b.backward(d))
if self.keep[1]:
dx = dx + self.shallow.backward(d)
return dx
def units(self):
return [self.shallow] + (self.a.units() + self.b.units() if self.C > 1 else [])
FractalNet impila due blocchi con un classificatore lineare in cima. Il drop-path globale sceglie, con probabilità global_drop, una colonna sola per l'intero batch, e la rete lavora come se fosse quella colonna. Una rete come questa ha 18.634 parametri (contati dal programma).
class FractalNet:
def __init__(self, rng, C=3, width=32, in_dim=64, classes=10):
self.C = C
self.blocks = [Fractal(rng, C, in_dim, width), Fractal(rng, C, width, width)]
lim = 1.0 / np.sqrt(width)
self.Wo, self.bo = rng.uniform(-lim, lim, (width, classes)), rng.uniform(-lim, lim, classes)
def forward(self, x, train=False, rng=None, col=None, local_drop=0.0, global_drop=0.0):
if train and col is None and rng.random() < global_drop:
col = int(rng.integers(1, self.C + 1)) # GLOBAL drop-path: one single column for this batch
for blk in self.blocks:
x = blk.forward(x, train, rng, col, local_drop)
self.h = x
return x @ self.Wo + self.bo
def loss_and_backward(self, logits, y):
z = logits - logits.max(1, keepdims=True)
P = np.exp(z); P /= P.sum(1, keepdims=True)
n = len(y); loss = -np.log(P[np.arange(n), y]).mean()
dl = P; dl[np.arange(n), y] -= 1; dl /= n
self.dWo, self.dbo = self.h.T @ dl, dl.sum(0)
d = dl @ self.Wo.T
for blk in reversed(self.blocks):
d = blk.backward(d)
return loss
def params(self):
ps = [(self, "Wo", "dWo"), (self, "bo", "dbo")]
for blk in self.blocks:
for u in blk.units():
ps += u.params()
return ps
def units(self):
return [u for blk in self.blocks for u in blk.units()]
def n_params(self):
return sum(getattr(o, n).size for o, n, _ in self.params())
print(FractalNet(np.random.default_rng(0)).n_params(), "parameters")
18634 parameters
L'esperimento: cosa vale una colonna da sola?
Alleniamo la stessa rete in due modi: senza drop-path e con drop-path (locale 0.15 e globale 0.5, cioè metà dei batch su una sola colonna, come nella miscela 50% e 50% dell'articolo originale). Per 40 epoche, Adam con learning rate 0.002 e mini-batch da 64, scritto a mano. Poi misuriamo l'accuratezza della rete completa e di ciascuna colonna presa da sola: 1 = una unità per blocco, 3 = quattro unità per blocco. Un parametro che non sta sul percorso attivo non riceve gradiente, e come in PyTorch non viene aggiornato.
import copy
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
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)
def accuracy(net, col=None):
return (net.forward(Xte, train=False, col=col).argmax(1) == yte).mean()
def train(local_drop, global_drop, epochs=40, seed=0, only_col=None, lr=2e-3, batch=64):
rng = np.random.default_rng(seed)
net = FractalNet(rng)
ps = net.params()
m = [np.zeros_like(getattr(o, n)) for o, n, _ in ps]
v = [np.zeros_like(getattr(o, n)) for o, n, _ in ps]
t = [0] * len(ps)
for _ in range(epochs):
perm = rng.permutation(len(Xtr))
for i in range(0, len(Xtr), batch):
idx = perm[i:i + batch]
for o, _, gn in ps:
setattr(o, gn, None) # a parameter off the active path gets no gradient
logits = net.forward(Xtr[idx], train=True, rng=rng, col=only_col, local_drop=local_drop, global_drop=global_drop)
net.loss_and_backward(logits, ytr[idx])
for k, (o, n, gn) in enumerate(ps): # Adam, one parameter array at a time
g = getattr(o, gn)
if g is None:
continue # same as PyTorch: no gradient, no update
t[k] += 1
m[k] = 0.9 * m[k] + 0.1 * g; v[k] = 0.999 * v[k] + 0.001 * g * g
getattr(o, n)[...] -= lr * (m[k] / (1 - 0.9 ** t[k])) / (np.sqrt(v[k] / (1 - 0.999 ** t[k])) + 1e-8)
return net
Il primo blocco carica i dati, li divide 70/30 con stratificazione, definisce la misura di accuratezza e il ciclo di addestramento con Adam. Il ciclo azzera i gradienti a ogni passo, perché un parametro fuori dal percorso attivo deve restare senza gradiente, e salta quei parametri nell'aggiornamento. Il blocco seguente lo usa su due configurazioni, senza e con drop-path, e stampa rete completa e colonne singole.
for name, ld, gd in [("no drop-path", 0.0, 0.0), ("drop-path (local 0.15 + global 0.5)", 0.15, 0.5)]:
net = train(ld, gd)
cols = {j: round(float(accuracy(net, col=j)), 4) for j in (1, 2, 3)}
print(f"{name:38s} full net {accuracy(net):.4f} single columns {cols}")
no drop-path full net 0.9889 single columns {1: 0.8611, 2: 0.4722, 3: 0.4741}
drop-path (local 0.15 + global 0.5) full net 0.9833 single columns {1: 0.9722, 2: 0.9704, 3: 0.9481}
accuracy(net, col) valuta in modalità di valutazione sul test (540 immagini), scegliendo una colonna con col=j o l'intera rete con col=None. Senza drop-path la rete completa fa il 98.9%, ma le colonne 2 e 3 prese da sole valgono 47.2% e 47.4%, mentre la colonna 1 arriva all'86.1%: le colonne profonde sono «co-adattate» e non funzionano senza le altre. Con il drop-path la rete completa fa il 98.3%, mezzo punto in meno, e le colonne diventano utilizzabili: 97.2%, 97.0% e 94.8%. Il drop-path non rende la rete completa migliore su questo problema; rende ogni percorso autonomo, ed è quello che voleva ottenere. Un seme e 540 esempi di test: un esempio vale 0.19 punti, quindi differenze di un punto o meno fra la rete completa con e senza drop-path non sono un risultato.
Le statistiche della BatchNorm: quanto pesano davvero
In valutazione la BatchNorm usa la media mobile delle statistiche calcolate in addestramento. Con il drop-path il flusso attraversa un mix variabile di percorsi, e le statistiche accumulate sono quelle di quel mix; valutando una colonna da sola i dati seguono un percorso diverso, con attivazioni di scala diversa. La cura proposta è ricalibrare le statistiche per quella colonna: azzerarle e farle ricalcolare come media su alcuni batch di addestramento fatti passare solo sulla colonna scelta, senza aggiornare i pesi. La ricalibrazione è una tecnica diffusa, ma non è nell'articolo originale delle reti frattali (cercata nel testo del PDF: nessuna occorrenza di «recalib» o simili): la tratto qui come una mia verifica, non come un risultato degli autori.
import copy
import numpy as np
def accuracy_after_bn_recalibration(net, col, n_batches=15, batch=64):
"""Evaluate ONE column after re-estimating the BatchNorm statistics for that column alone."""
net = copy.deepcopy(net)
for u in net.units():
u.reset_stats() # momentum None: plain average of the batches seen
rng = np.random.default_rng(0)
perm = rng.permutation(len(Xtr))[:n_batches * batch]
for i in range(0, len(perm), batch):
net.forward(Xtr[perm[i:i + batch]], train=True, rng=rng, col=col) # forward only: no weight changes
return accuracy(net, col=col)
La funzione appena definita viene applicata a entrambe le reti, quella senza e quella con drop-path, ripetendo l'addestramento con lo stesso seme. Per ognuna stampa tre righe: le colonne con le statistiche originali, le colonne ricalibrate una per una, e la rete completa prima e dopo. L'ultima riga addestra ciascuna colonna da sola, per il confronto della sezione successiva.
for name, ld, gd in [("no drop-path", 0.0, 0.0), ("drop-path", 0.15, 0.5)]:
net = train(ld, gd)
print(f"{name:13s} columns as is :", {j: round(float(accuracy(net, col=j)), 4) for j in (1, 2, 3)})
print(f"{name:13s} BN recalibrated/col. :", {j: round(float(accuracy_after_bn_recalibration(net, j)), 4) for j in (1, 2, 3)})
print(f"{name:13s} full net as is / recalibrated:", round(float(accuracy(net)), 4), round(float(accuracy_after_bn_recalibration(net, None)), 4))
print("trained ALONE:", {j: round(float(accuracy(train(0.0, 0.0, only_col=j), col=j)), 4) for j in (1, 2, 3)})
no drop-path columns as is : {1: 0.8611, 2: 0.4722, 3: 0.4741}
no drop-path BN recalibrated/col. : {1: 0.8852, 2: 0.4907, 3: 0.5407}
no drop-path full net as is / recalibrated: 0.9889 0.9926
drop-path columns as is : {1: 0.9722, 2: 0.9704, 3: 0.9481}
drop-path BN recalibrated/col. : {1: 0.9778, 2: 0.9722, 3: 0.9519}
drop-path full net as is / recalibrated: 0.9833 0.9815
La funzione copia la rete, azzera le statistiche, esegue 15 batch da 64 in avanti sulla colonna scelta (quasi tutto il training set da 1257 immagini) e misura l'accuratezza. Nessun peso viene ritoccato. Il risultato è meno spettacolare di quanto mi aspettassi: con il drop-path la ricalibrazione porta le colonne da 97.2/97.0/94.8% a 97.8/97.2/95.2%, un guadagno da 0.2 a 0.6 punti, cioè da uno a tre esempi di test su 540. Senza drop-path le colonne 2 e 3 salgono di 2 e 7 punti ma restano sotto il 55%: il difetto non stava nelle statistiche, stava nei pesi. L'effetto principale di questa lezione è dunque del drop-path, non della ricalibrazione. La ricalibrazione pesa di più quando la rete è addestrata poco, come mostra l'ultimo blocco di questo articolo.
Confronto con le colonne addestrate da sole
Per sapere se il guadagno è reale confrontiamo con ciascuna colonna addestrata da sola, come rete indipendente con la stessa ricetta (40 epoche, stesso seme). L'ultima riga del blocco precedente riporta già il risultato, ottenuto con train(0.0, 0.0, only_col=j): a ogni batch la rete esegue solo la colonna j. Riassumendo:
col 1 col 2 col 3 rete completa
senza drop-path 0.861 0.472 0.474 0.989
con drop-path, statistiche così come 0.972 0.970 0.948 0.983
con drop-path, BN ricalibrata 0.978 0.972 0.952 0.982
addestrate da sole (riferimento) 0.982 0.980 0.980 --
Cosa dice la tabella, tenendo presente che un solo seme e 540 esempi non distinguono differenze di mezzo punto:
- Le colonne estratte da una rete con drop-path stanno a 0.4-3 punti dalle stesse colonne addestrate da sole (97.2 contro 98.2 per la colonna 1, 97.0 contro 98.0 per la 2, 94.8 contro 98.0 per la 3). Un solo addestramento ha prodotto tre reti utilizzabili, a un costo di pochi punti sulla più profonda.
- Senza drop-path la colonna 1 funziona perché è l'unità che riceve segnale diretto, le altre due no: l'estrazione senza drop-path non è praticabile.
- La rete completa non batte la colonna 3 da sola in modo significativo (98.2 contro 98.0): su questo problema piccolo la media dei percorsi non aggiunge accuratezza.
Questo ridimensiona il racconto della versione precedente, in cui una colonna ricalibrata pareggiava quella addestrata da sola dopo un recupero enorme, da 29% a 88%. Su queste cifre e con questa rete il recupero grazie alla ricalibrazione è piccolo e le colonne profonde estratte restano un po' sotto quelle addestrate da sole. Non so dire se la differenza dipenda dalle convoluzioni, dal dataset o dalla durata dell'addestramento, perché la versione PyTorch non è stata rieseguita.
Quando la ricalibrazione pesa: addestramento breve
Ripeto l'esperimento con il drop-path, ma con 3 e 10 epoche invece di 40, per vedere se la ricalibrazione conta di più quando la rete è ancora lontana dalla convergenza.
for epochs in (3, 10):
net = train(0.15, 0.5, epochs=epochs)
print(epochs, "epochs, drop-path: as is :", {j: round(float(accuracy(net, col=j)), 4) for j in (1, 2, 3)})
print(epochs, "epochs, drop-path: recalib.:", {j: round(float(accuracy_after_bn_recalibration(net, j)), 4) for j in (1, 2, 3)})
3 epochs, drop-path: as is : {1: 0.8481, 2: 0.65, 3: 0.5796}
3 epochs, drop-path: recalib.: {1: 0.85, 2: 0.6944, 3: 0.6167}
10 epochs, drop-path: as is : {1: 0.95, 2: 0.9333, 3: 0.8944}
10 epochs, drop-path: recalib.: {1: 0.9556, 2: 0.9426, 3: 0.9111}
Con 3 epoche le colonne 2 e 3 passano da 65.0% e 58.0% a 69.4% e 61.7% (più 4.4 e più 3.7 punti), con 10 epoche i guadagni sono 0.6, 0.9 e 1.7 punti, con 40 epoche 0.6, 0.2 e 0.4. La tendenza è coerente con l'intuizione, cioè più l'addestramento è breve e più le statistiche memorizzate sono lontane da quelle giuste, ma resta una tendenza osservata su un seme. In tutti i casi la ricalibrazione costa 15 passate in avanti per colonna: la applicherei sempre, anche se qui non cambia la conclusione.
Cosa ci guadagna la previsione «anytime»
Con 2 blocchi, la colonna 1 ha 2 unità in tutto (una per blocco), la colonna 2 ne ha 4, la colonna 3 ne ha 8. Con la rete addestrata 40 epoche e il drop-path, l'accuratezza della colonna ricalibrata è 97.8% con 2 unità, 97.2% con 4 e 95.2% con 8: nel nostro banco di prova la colonna più profonda non è la migliore, perché il problema è semplice e il percorso corto basta. Il compromesso «anytime» resta valido come funzionamento (un modello, tre punti di costo diversi), ma qui non c'è nessun guadagno di accuratezza che lo giustifichi; l'ho visto funzionare solo come meccanismo. Gli autori applicano il drop-path a reti molto più profonde su CIFAR e ImageNet, che non ho riprodotto.
Frattali contro highway contro residue
La highway mescola trasformazione e identità con un cancello appreso; il gradiente ha un percorso diretto con fattore 1 − T. La residua usa un'identità fissa, y = x + F(x), ed è la più semplice. Il frattale non usa identità: il percorso corto è una trasformazione vera e il drop-path impone che ogni percorso funzioni da solo. L'idea degli autori è che la rete non abbia bisogno di residui per essere profonda, basta che esistano percorsi brevi che portano il segnale e il gradiente; l'abstract dichiara risultati alla pari con le reti residue su CIFAR e ImageNet, benchmark che non riproduco e che non sono confrontabili con il nostro mini-esperimento. Un'osservazione che l'esperimento supporta: se esiste un percorso corto indipendente, la rete può diventare profonda senza che il gradiente debba attraversare tutte le unità.
Trade-off
Il costo cresce in fretta: un blocco con C colonne ha 2^C − 1 unità, 31 con C = 5. Il drop-path richiede in genere più epoche per convergere (nelle nostre prove le colonne a 3 epoche stanno fra il 58% e l'85%, a 40 fra il 95% e il 97%; qui una durata sola per volta). L'estrazione di sottoreti è gratuita in teoria, ma richiede la ricalibrazione della BatchNorm o una normalizzazione che non dipenda dal batch. E il percorso breve non è un'identità: non preserva il segnale, lo trasforma, e la sua utilità sta nell'essere corto.
Trappole reali
- Valutare le colonne di una rete addestrata senza drop-path. Qui le colonne 2 e 3 valgono 47% contro 98.9% della rete completa. Si conclude, giustamente, che il percorso lungo non è autonomo, ma non è un difetto di valutazione.
- Non ricalibrare la BatchNorm dopo l'addestramento breve. A 3 epoche la differenza arriva a 4 punti sulle colonne profonde.
- Usare il drop-path in valutazione. Va usato solo in addestramento; in valutazione si usa l'intera rete o una colonna scelta.
- Scartare tutti i percorsi in un join. Il codice impone che almeno uno sopravviva: senza questa regola la media sarebbe su zero elementi.
- Fissare un solo generatore. Il drop-path usa un generatore casuale oltre a quello dell'inizializzazione; per riprodurre un risultato vanno controllati entrambi (qui un solo
default_rngcon seme). - Confrontare reti con budget diversi. Una rete frattale ha più parametri di una colonna da sola; il confronto equo dipende da che cosa vuoi confrontare, costo di inferenza o costo di addestramento.
Le sei trappole condividono la stessa radice: un numero di valutazione dipende da come hai addestrato e da come hai valutato, non solo dal modello. Prima di dire che il drop-path «non funziona» o «funziona» controlla durata dell'addestramento, modalità di valutazione e statistiche di normalizzazione.
Un esempio svolto a mano: la probabilità del drop-path locale
In un join con due ingressi e probabilità di scarto p = 0.15 per ciascuno, calcoliamo le probabilità dei quattro esiti (scarto indipendente):
entrambi tenuti: 0.85 × 0.85 = 0.7225
solo il primo tenuto: 0.85 × 0.15 = 0.1275
solo il secondo tenuto: 0.15 × 0.85 = 0.1275
nessuno tenuto: 0.15 × 0.15 = 0.0225 → non ammesso
L'ultimo caso viene corretto scegliendo a caso uno dei due ingressi da tenere (come nel codice, con rng.integers): ciascuno riceve la metà di 0.0225, cioè 0.01125. Le probabilità finali sono quindi 72.25% per due percorsi, 13.875% per solo il primo e 13.875% per solo il secondo. In circa un join su quattro (27.75%) la rete lavora con un solo percorso, ed è lì che ciascun percorso è costretto a funzionare da solo. Con più join in serie e annidati, la probabilità che tutta la rete funzioni su un percorso specifico si moltiplica: da qui l'effetto di «addestrare molte sotto-reti insieme». Il drop-path globale è più brutale: con probabilità 0.5 si sceglie una sola colonna per tutto il batch, quindi un batch su due viene addestrato su una colonna, circa 17% ciascuna delle tre. Con p = 0.3 le stesse formule danno 0.49 per entrambi tenuti e 0.21 + 0.045 = 0.255 per il solo primo.
Domande frequenti
Perché la media come join e non la somma? La media mantiene costante la scala delle attivazioni qualunque sia il numero di percorsi sopravvissuti: con la somma, spegnere un percorso ne cambierebbe l'ordine di grandezza. L'articolo originale usa la media per il join.
Il drop-path è come il dropout? Stessa idea, scala diversa: il dropout spegne singoli neuroni, il drop-path interi percorsi. Il dropout riduce la co-adattazione fra neuroni, il drop-path quella fra percorsi, ed è quello che serve per estrarre le colonne.
È utile su un problema facile come queste cifre? Come banco di prova sì, perché le colonne si vedono bene; ma la rete completa non è migliore della colonna profonda, e il guadagno delle FractalNet si vede, secondo gli autori, su dataset e reti molto più grandi.
Posso usare LayerNorm o GroupNorm al posto della BatchNorm? Una normalizzazione che non dipende dalle statistiche del batch non ha medie mobili da ricalibrare. Non l'ho provato: è una conseguenza di come funziona, non una misura.
Che cosa sai fare adesso
Sai costruire una rete frattale con la regola f_C = join(f_{C−1} ∘ f_{C−1}, unit), sai che ha 2^C − 1 unità distribuite su colonne di profondità 1, 2, 4…, e sai scrivere a mano il join a media, il drop-path locale e globale e la retropropagazione attraverso la BatchNorm. Hai misurato che senza drop-path le colonne profonde estratte valgono 47%, con drop-path 95-97%, a pochi punti da quelle addestrate da sole (98%), e che la ricalibrazione della BatchNorm aggiunge da 0.2 a 4 punti a seconda di quanto è breve l'addestramento. Nella lezione successiva cambiamo registro: dalle reti che classificano alle reti che comprimono e ricostruiscono, con gli autoencoder.
Nella serie, la lezione precedente è Highway Network: un cancello per decidere quanto trasformare e quanto lasciar passare.
Fonti
- Larsson, Maire, Shakhnarovich, «FractalNet: Ultra-Deep Neural Networks without Residuals» (ICLR 2017, arXiv 1605.07648) - la regola di espansione, il join a media, il drop-path locale (15%) e globale su una colonna, la miscela 50% locale e 50% globale, la proprietà «anytime»; PDF letto, consultata il 2026-11-12
- Ioffe e Szegedy, «Batch Normalization» (arXiv 1502.03167) - la normalizzazione con statistiche del batch in addestramento e medie mobili in valutazione; scheda citata, non riletta per questo lavoro, consultata il 2026-11-12
- scikit-learn,
sklearn.datasets.load_digits- il dataset di cifre 8×8 usato negli esperimenti, consultata il 2026-11-12 - Misure: i listati di questa lezione, eseguiti due volte di seguito come un unico script con Python 3.14.4, NumPy 2.3.5, scikit-learn 1.8.0, seme fisso, uscite identiche
Commenti
Caricamento commenti...
Accedi per commentare