Salta al contenuto principale

68 - Highway Network: un cancello per decidere quanto trasformare e quanto lasciar passare

Lo strato highway mescola trasformazione e identità con un cancello appreso: lo scrivi in NumPy, misuri i gradienti a 50 strati e provi il bias iniziale.

Cosa ne pensi di questo articolo?

68 - Highway Network: un cancello per decidere quanto trasformare e quanto lasciar passare
Machine Learning Engineerfedericocalo.dev

In Breve

Lo strato highway mescola trasformazione e identità con un cancello appreso: lo scrivi in NumPy, misuri i gradienti a 50 strati e provi il bias iniziale.

Una rete più profonda può rappresentare funzioni più complesse, ma si addestra peggio: il segnale e il gradiente devono attraversare decine di strati, e nelle lezioni sulle reti ricorrenti abbiamo visto che cosa succede a un segnale moltiplicato molte volte per matrici e non linearità. Le Highway Network, proposte da Srivastava, Greff e Schmidhuber nel 2015 (il gruppo della LSTM), portano in profondità la stessa idea che la LSTM applica nel tempo: accanto alla trasformazione mettono un percorso diretto e un cancello che decide, per ogni ingresso, quanta trasformazione e quanto passaggio diretto mescolare. In questa lezione scrivi lo strato a mano in NumPy, ne scrivi anche la retropropagazione, misuri che cosa succede ai gradienti in reti da 10 e da 50 strati con e senza cancello, addestri reti da 5 a 50 strati e scopri che il numero decisivo, in questa architettura, è il bias iniziale del cancello.

Le parole di questa lezione

La profondità è il numero di strati impilati: non esiste una soglia fissa per dire «profonda», e qui chiamiamo così le reti da una ventina di strati in su. Uno strato semplice (plain) calcola y = ReLU(W x + b), dove la ReLU azzera i valori negativi. Una connessione di scorciatoia (skip connection) porta l'ingresso di uno strato più avanti, saltando la trasformazione; l'identità y = x è la più semplice. Il cancello di trasformazione T è un vettore con valori in (0, 1), calcolato dall'ingresso: come i cancelli della LSTM decide «quanto» lasciar passare. Il gradiente per strato è la norma del gradiente della perdita rispetto ai pesi di quello strato, e misura quanto segnale di apprendimento arriva fin lì. La degradazione, infine, è il fenomeno per cui una rete più profonda va peggio di una più superficiale anche sul training, perché fallisce l'ottimizzazione e non perché manchi capacità.

L'analogia: un'autostrada con caselli a pedaggio variabile

Un viaggio di 50 tappe. In una rete semplice ogni tappa è un paese in cui sei obbligato a fermarti e farti rimodellare: dopo cinquanta paesi non sei più riconoscibile, e se sbagli a ripartire non c'è modo di tornare indietro. In una highway ogni tappa ha un casello con una corsia di sorpasso: il casello T decide, in base a chi sei, quanta parte di te entra in paese per essere trasformata e quanta prosegue indisturbata sulla corsia di scorrimento. All'inizio il pedaggio per entrare in paese è alto (il cancello è quasi chiuso): quasi tutti proseguono sulla corsia diretta, il viaggio è rapido e senza perdite, e la rete impara gradualmente in quali paesi vale la pena fermarsi.

La matematica

Uno strato semplice calcola y = H(x), con H(x) = ReLU(W_H x + b_H). Lo strato highway aggiunge un secondo calcolo, il cancello T(x) = σ(W_T x + b_T) (sigmoide, quindi un valore in (0, 1) per unità), e mescola:

y = T(x) ⊙ H(x) + (1 − T(x)) ⊙ x

Nell'articolo originale compaiono due cancelli, uno di trasformazione T e uno di passaggio C; per semplicità gli autori stessi pongono C = 1 − T, ed è la forma usata qui. Se T = 1 lo strato è semplice (y = H(x)); se T = 0 è l'identità (y = x). Per mescolare ingresso e uscita servono dimensioni uguali: x, H(x) e T(x) hanno tutti la stessa larghezza, che quindi resta costante lungo la pila (al bisogno si inserisce uno strato lineare per cambiarla).

Il gradiente. Derivando y rispetto a x e trascurando, per chiarezza, la dipendenza di T da x:

∂y/∂x = diag(T) · ∂H/∂x + diag(1 − T)

Il secondo termine è la differenza con la rete semplice. Lì lo Jacobiano è solo ∂H/∂x: il prodotto di cinquanta Jacobiani, ciascuno con norma tipicamente minore di uno, svanisce. Nella highway compare diag(1 − T), un percorso diretto con fattore 1 − T per unità, vicino a 1 quando T è piccolo. Con 50 strati e T = 0.1 in ogni strato il prodotto lungo la scorciatoia è 0.9⁵⁰ ≈ 0.005; con T = 0.02 è 0.98⁵⁰ ≈ 0.36. Per confronto, in una rete semplice in cui ogni Jacobiano abbia norma 0.8, 0.8⁵⁰ ≈ 1.4·10⁻⁵. La highway non garantisce un gradiente costante (il fattore è 1 − T, non 1), ma sposta la scommessa: il segnale ha un canale diretto la cui larghezza è controllata da un parametro appreso, invece di dipendere dal fatto che ogni strato lo preservi per caso.

Uno strato highway: l'ingresso x segue tre percorsi, la trasformazione H(x), il cancello T(x) e il percorso diretto moltiplicato per 1 meno T; l'uscita è la somma pesata di trasformazione e passaggio diretto Highway layer: y = T × H(x) + (1 − T) × x — transform a little, carry the rest x carry: (1 − T) × x H(x) linear + ReLU × T(x) = σ(Wx + b) + y T near 0: the layer is the identity (the signal and the gradient pass untouched) T near 1: an ordinary layer. The network learns, per input, how much to transform.
Uno strato highway: l'ingresso x segue tre percorsi, la trasformazione H(x), il cancello T(x) e il percorso diretto moltiplicato per 1 meno T; l'uscita è la somma pesata di trasformazione e passaggio diretto

Il disegno mostra i tre percorsi dell'ingresso x. In basso il cancello T(x) è calcolato dall'ingresso (una piccola trasformazione lineare seguita dalla sigmoide). Al centro la trasformazione H(x) viene moltiplicata per T. In alto il percorso diretto porta x, moltiplicato per 1 − T, fino alla somma finale. Le due righe sotto il disegno danno la lettura intuitiva: cancello vicino a 0, strato quasi trasparente; vicino a 1, strato normale.

Codice a mano: il comportamento del cancello

Con NumPy e quattro unità vediamo l'effetto del bias del cancello. Il bias è il numero sommato al calcolo del cancello prima della sigmoide: se è molto negativo la sigmoide dà valori vicini a 0, e il cancello parte chiuso. Qui H usa la tangente iperbolica per tenere i numeri piccoli; negli esperimenti sotto torna la ReLU.

import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

rng = np.random.default_rng(0)
d = 4
W_H, b_H = rng.normal(0, 0.5, (d, d)), np.zeros(d)         # the "transform" path H(x)
W_T = rng.normal(0, 0.5, (d, d))                            # the gate T(x)

def highway(x, b_T):
    H = np.tanh(W_H @ x + b_H)                              # candidate new representation
    T = sigmoid(W_T @ x + b_T)                              # transform gate, one value in (0,1) per unit
    y = T * H + (1 - T) * x                                 # blend: transform a bit, carry the rest
    return y, T

x = rng.normal(size=d)
for bias in (0.0, -2.0, -5.0):
    y, T = highway(x, np.full(d, bias))
    print(f"b_T={bias:5.1f}  mean gate T={T.mean():.3f}   |y - x| = {np.linalg.norm(y - x):.3f}")
b_T=  0.0  mean gate T=0.513   |y - x| = 0.360
b_T= -2.0  mean gate T=0.128   |y - x| = 0.083
b_T= -5.0  mean gate T=0.007   |y - x| = 0.005

highway(x, b_T) calcola la trasformazione H, il cancello T e la miscela y = T·H + (1 − T)·x; il ciclo prova tre bias e misura il valore medio del cancello e la distanza ‖y − x‖ fra uscita e ingresso. Con bias 0 il cancello vale in media 0.51 e lo strato cambia molto l'ingresso (distanza 0.36). Con −2 il cancello vale 0.13 e la distanza scende a 0.083. Con −5 vale 0.007 e la distanza è 0.005: lo strato è praticamente l'identità. È l'idea dell'inizializzazione negativa: una rete di cinquanta strati highway con bias −2 o −5 parte come una rete quasi trasparente, il segnale arriva intatto fino alla fine e l'addestramento può cominciare ad aprire i cancelli dove serve. Gli autori propongono proprio questo: bias del cancello inizializzato a un valore negativo (negli esempi −1, −3), con un'ispirazione dichiarata alla pratica di Gers e colleghi per la LSTM.

Una rete profonda scritta in NumPy, con la sua retropropagazione

La rete è scritta in NumPy, retropropagazione compresa; più avanti la stessa rete è ricostruita in PyTorch e confrontata. È più lavoro, ma ha un vantaggio didattico: vedi esattamente dove la highway aggiunge un termine al gradiente. Il codice costruisce due famiglie di reti con la stessa larghezza (64 unità) e profondità variabile: strati semplici, oppure strati highway con bias del cancello −2. I pesi sono estratti da una distribuzione uniforme in ±1/√n_ingresso, la regola che PyTorch usa di default per nn.Linear (dato noto, non riverificato sulla documentazione per questo lavoro). Il passaggio più delicato è nel backward dello strato highway: il gradiente verso l'ingresso ha tre contributi, quello del percorso diretto dh·(1 − t), quello che passa dalla trasformazione e quello che passa dal cancello.

import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def init_linear(rng, n_in, n_out):
    # same rule as torch.nn.Linear: uniform in (-1/sqrt(n_in), 1/sqrt(n_in))
    lim = 1.0 / np.sqrt(n_in)
    return rng.uniform(-lim, lim, (n_in, n_out)), rng.uniform(-lim, lim, n_out)

def make_net(kind, depth, rng, width=64, in_dim=64, classes=10, gate_bias=-2.0):
    p = {"kind": kind, "depth": depth}
    p["Wi"], p["bi"] = init_linear(rng, in_dim, width)
    p["Wo"], p["bo"] = init_linear(rng, width, classes)
    p["layers"] = []
    for _ in range(depth):
        L = {}
        L["Wh"], L["bh"] = init_linear(rng, width, width)
        if kind == "highway":
            L["Wt"], _ = init_linear(rng, width, width)
            L["bt"] = np.full(width, gate_bias)             # gate starts close to "carry"
        p["layers"].append(L)
    return p

def forward(p, X):
    cache = []
    h = np.maximum(0, X @ p["Wi"] + p["bi"]); h0 = h
    for L in p["layers"]:
        H = np.maximum(0, h @ L["Wh"] + L["bh"])
        if p["kind"] == "highway":
            t = sigmoid(h @ L["Wt"] + L["bt"])
            out = t * H + (1 - t) * h
        else:
            t, out = None, H
        cache.append((h, H, t)); h = out
    logits = h @ p["Wo"] + p["bo"]
    return logits, (X, h0, cache, h)

def loss_and_grads(p, X, y):
    logits, (X, h0, cache, hL) = forward(p, X)
    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.copy(); dl[np.arange(n), y] -= 1; dl /= n
    g = {"Wo": hL.T @ dl, "bo": dl.sum(0), "layers": [None] * len(cache)}
    dh = dl @ p["Wo"].T
    for i in range(len(cache) - 1, -1, -1):
        h, H, t = cache[i]; L = p["layers"][i]
        if p["kind"] == "highway":
            dzh = dh * t * (H > 0)
            dzt = dh * (H - h) * t * (1 - t)
            g["layers"][i] = {"Wh": h.T @ dzh, "bh": dzh.sum(0), "Wt": h.T @ dzt, "bt": dzt.sum(0)}
            dh = dh * (1 - t) + dzh @ L["Wh"].T + dzt @ L["Wt"].T
        else:
            dzh = dh * (H > 0)
            g["layers"][i] = {"Wh": h.T @ dzh, "bh": dzh.sum(0)}
            dh = dzh @ L["Wh"].T
    dz0 = dh * (h0 > 0)
    g["Wi"], g["bi"] = X.T @ dz0, dz0.sum(0)
    return loss, g

Prima di fidarmi di una retropropagazione scritta a mano l'ho confrontata con le differenze finite (passo 10⁻⁶) su una rete piccola di 3 strati, larghezza 6: l'errore massimo sui pesi Wh, Wt e sul bias del cancello del secondo strato è dell'ordine di 10⁻¹⁰, sia per la rete semplice sia per la highway. Questo controllo non fa parte del listato, ma è la garanzia che le norme misurate sotto siano gradienti veri e non artefatti di un errore nel codice.

Ora la misura: una sola passata in avanti e all'indietro su dati casuali con etichette casuali, e per ogni strato la norma del gradiente dei pesi Wh. Non c'è addestramento: misuriamo quanto gradiente arriva a ogni strato all'inizializzazione.

def grad_norms_per_layer(kind, depth, seed=0):
    rng = np.random.default_rng(seed)
    p = make_net(kind, depth, rng)
    X = rng.normal(size=(256, 64)); y = rng.integers(0, 10, 256)
    _, g = loss_and_grads(p, X, y)
    return [np.linalg.norm(L["Wh"]) for L in g["layers"]]   # one number per layer

for depth in (10, 50):
    for kind in ("plain", "highway"):
        g = grad_norms_per_layer(kind, depth)
        print(f"{kind:8s} depth {depth:3d}: grad norm first layer {g[0]:.2e}, last layer {g[-1]:.2e}, ratio {g[0] / g[-1]:.2e}")
plain    depth  10: grad norm first layer 2.36e-05, last layer 1.65e-02, ratio 1.43e-03
highway  depth  10: grad norm first layer 3.36e-03, last layer 4.28e-03, ratio 7.84e-01
plain    depth  50: grad norm first layer 4.28e-21, last layer 8.99e-03, ratio 4.76e-19
highway  depth  50: grad norm first layer 2.37e-05, last layer 7.17e-04, ratio 3.31e-02

In una rete semplice di 10 strati il gradiente del primo strato è già circa 700 volte più piccolo di quello dell'ultimo (rapporto 1.4·10⁻³). A 50 strati è 4·10⁻²¹ nel primo strato, un rapporto di 10⁻¹⁹ con l'ultimo: il primo strato non riceve nessuna informazione utile. Nella highway, a 10 strati il rapporto è 0.78 (quasi uniforme) e a 50 strati è 0.033: il gradiente del primo strato è 2.4·10⁻⁵, circa sedici ordini di grandezza sopra quello della rete semplice. Non è perfettamente uniforme (0.033, non 1), coerente con il fattore 1 − T visto prima, ma è un segnale che l'ottimizzatore può usare. Un seme, dati casuali, rete non addestrata: misuriamo la struttura, non il risultato finale.

Addestramento su cifre scritte a mano, a profondità crescente

Ora la prova che conta: addestrare davvero. Il file MNIST richiede un download e qui non lo uso: prendo il piccolo insieme load_digits di scikit-learn (1797 immagini 8×8 di cifre, valori da 0 a 16, 10 classi), diviso 70/30 con stratificazione. È un problema più facile e più piccolo di MNIST, quindi le cifre assolute non sono confrontabili con quelle di MNIST; il confronto che interessa, semplice contro highway, resta valido. Alleno ogni rete per 30 epoche con Adam (learning rate 0.001, mini-batch da 64) scritto a mano e misuro l'accuratezza sul test di 540 immagini. La profondità 100 della versione precedente di questa lezione non è stata rieseguita: la versione PyTorch e i suoi numeri non sono stati riprodotti qui.

import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from b2 import make_net, forward, loss_and_grads

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 adam_step(p, g, m, v, t, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8):
    def upd(a, ga, ma, va):
        ma[:] = b1 * ma + (1 - b1) * ga; va[:] = b2 * va + (1 - b2) * ga * ga
        a -= lr * (ma / (1 - b1 ** t)) / (np.sqrt(va / (1 - b2 ** t)) + eps)
    for k in ("Wi", "bi", "Wo", "bo"):
        upd(p[k], g[k], m[k], v[k])
    for i, L in enumerate(p["layers"]):
        for k in L:
            upd(L[k], g["layers"][i][k], m["layers"][i][k], v["layers"][i][k])

def zeros_like(p):
    z = {k: np.zeros_like(p[k]) for k in ("Wi", "bi", "Wo", "bo")}
    z["layers"] = [{k: np.zeros_like(a) for k, a in L.items()} for L in p["layers"]]
    return z

Il primo blocco carica i dati e definisce Adam. adam_step aggiorna ogni parametro con le medie mobili del gradiente (m) e del suo quadrato (v), corrette per il bias delle prime iterazioni. Il secondo blocco contiene il ciclo di addestramento: per ogni epoca mescola gli indici, scorre i mini-batch, calcola perdita e gradienti con la retropropagazione di prima e fa un passo.

def train_eval(kind, depth, gate_bias=-2.0, epochs=30, seed=0, batch=64):
    rng = np.random.default_rng(seed)
    p = make_net(kind, depth, rng, gate_bias=gate_bias)
    m, v, t = zeros_like(p), zeros_like(p), 0
    for _ in range(epochs):
        perm = rng.permutation(len(Xtr))
        for i in range(0, len(Xtr), batch):
            idx = perm[i:i + batch]
            loss, g = loss_and_grads(p, Xtr[idx], ytr[idx])
            t += 1; adam_step(p, g, m, v, t)
    acc = (forward(p, Xte)[0].argmax(1) == yte).mean()
    return p, acc, loss

Il ciclo finale prova le tre profondità, una rete semplice e una highway ciascuna, con lo stesso seme per i pesi e per l'ordine dei mini-batch. Stampa l'accuratezza sul test e la perdita dell'ultimo mini-batch di addestramento. Trenta epoche sono una scelta mia, non un valore ottimizzato: bastano perché le reti che si addestrano arrivino a una perdita bassa sul training, come mostra l'ultima colonna, ma non ho provato durate diverse.

for depth in (5, 20, 50):
    for kind in ("plain", "highway"):
        _, acc, last = train_eval(kind, depth)
        print(f"depth {depth:3d} {kind:8s} test accuracy {acc:.4f}   (last batch loss {last:.3f})")
depth   5 plain    test accuracy 0.9481   (last batch loss 0.012)
depth   5 highway  test accuracy 0.9630   (last batch loss 0.024)
depth  20 plain    test accuracy 0.3593   (last batch loss 1.555)
depth  20 highway  test accuracy 0.9500   (last batch loss 0.090)
depth  50 plain    test accuracy 0.1019   (last batch loss 2.303)
depth  50 highway  test accuracy 0.9426   (last batch loss 0.023)

L'ultima colonna è la perdita dell'ultimo mini-batch, quindi è rumorosa: serve soltanto a riconoscere il livello del caso (ln 10 ≈ 2.30 per dieci classi). Che cosa si vede, in quattro punti:

  1. A 5 strati entrambe funzionano (94.8% la semplice, 96.3% la highway).
  2. A 50 strati la rete semplice non impara nulla: 10.2%, che coincide con la quota della classe più frequente nel test (misurata: 0.1019), e perdita 2.303, cioè ln 10. A 20 strati arriva solo al 35.9%: l'ottimizzazione è partita male e non si è ripresa in 30 epoche.
  3. La highway funziona a 20 e a 50 strati (95.0% e 94.3%).
  4. La profondità non migliora l'accuratezza: la highway a 5 strati fa 96.3%, quella a 20 e 50 un punto o due meno. Il risultato dice che la highway permette di addestrare reti profonde, non che averle serva a questo problema.

Con 540 esempi di test un solo esempio pesa 0.19 punti, e un seme solo non dice quanto varia il risultato da un'inizializzazione all'altra: «35.9% contro 95.0%» è una differenza enorme, «96.3% contro 95.0%» è dentro quello che un cambio di seme potrebbe spostare. Ho riportato i numeri come sono, senza costruirci sopra una classifica.

Il numero decisivo: il bias del cancello

Abbiamo detto che il bias iniziale negativo rende lo strato quasi trasparente. Quanto conta davvero? Prova con una rete di 50 strati e tre bias iniziali, poi guarda i cancelli di una rete di 20 strati dopo l'addestramento. mean_gates rifà il percorso in avanti a mano, sulle immagini di test, e registra il valore medio del cancello di ogni strato.

def mean_gates(p, X):
    """Average transform gate T of every layer, on real digits."""
    h = np.maximum(0, X @ p["Wi"] + p["bi"]); gates = []
    for L in p["layers"]:
        t = 1.0 / (1.0 + np.exp(-(h @ L["Wt"] + L["bt"])))
        gates.append(t.mean())
        H = np.maximum(0, h @ L["Wh"] + L["bh"])
        h = t * H + (1 - t) * h
    return gates

print("share of the most frequent class in the test set:", round(np.bincount(yte).max() / len(yte), 4))
for gate_bias in (0.0, -2.0, -5.0):
    _, acc, _ = train_eval("highway", 50, gate_bias=gate_bias)
    print(f"depth 50, gate bias {gate_bias:5.1f}: test accuracy {acc:.4f}")

p, acc, _ = train_eval("highway", 20, gate_bias=-2.0)
g = mean_gates(p, Xte)
print("mean transform gate per layer (depth 20, after training):", [round(float(v), 2) for v in g])
share of the most frequent class in the test set: 0.1019
depth 50, gate bias   0.0: test accuracy 0.1000
depth 50, gate bias  -2.0: test accuracy 0.9426
depth 50, gate bias  -5.0: test accuracy 0.9611
mean transform gate per layer (depth 20, after training): [0.19, 0.16, 0.17, 0.21, 0.11, 0.15, 0.12, 0.14, 0.12, 0.07, 0.1, 0.16, 0.11, 0.08, 0.14, 0.13, 0.11, 0.03, 0.06, 0.11]

Il risultato è netto sul punto che conta. Con bias 0 (cancello a metà strada all'inizio) la rete da 50 strati non si addestra: 10.0%, livello del caso, come la rete semplice. A ogni strato il percorso di trasformazione pesa circa il 50% e il fattore lungo il percorso diretto è 0.5⁵⁰ ≈ 10⁻¹⁵. Con −2 e −5 la rete si addestra (94.3% e 96.1%); la differenza fra queste due è di dieci esempi su 540 e con un solo seme non la considero un risultato. Dopo l'addestramento di una highway da 20 strati con bias −2, i cancelli medi stanno fra 0.03 e 0.21, contro il 0.12 di partenza (σ(−2)): alcuni si sono un po' aperti, altri chiusi, nessuno si è spalancato. La rete usa i suoi strati poco e funziona di fatto come una rete molto più superficiale con piccole correzioni distribuite lungo la pila, il che spiega perché la profondità non aiuta su un problema così semplice.

Gli autori dell'articolo originale, su altri problemi, riportano un andamento simile: bias iniziali molto negativi che in buona parte diminuiscono ancora durante l'addestramento.

Highway, rete residua e dove si usano oggi

Pochi mesi dopo le Highway Network, He e colleghi (2015) proposero le reti residue (ResNet), in cui la scorciatoia è un'identità fissa e senza cancello: y = x + F(x). È più semplice (nessun parametro in più per il cancello) e il termine diretto del gradiente vale esattamente 1: ∂y/∂x = I + ∂F/∂x. Le ResNet sono diventate lo standard nella visione. Le highway hanno trovato impieghi specifici: Kim e colleghi (2016) ne mettono una sopra le convoluzioni a livello di carattere in un modello di linguaggio. La scelta fra le due è pratica: se non ti serve che la rete decida, in funzione dell'ingresso, quanto trasformare, la residua è più semplice e di solito basta.

Chi lavora in PyTorch può tradurre lo strato in poche righe. Il blocco seguente è stato eseguito (PyTorch 2.12, CPU, 2 thread, due volte con la stessa uscita) e fa un controllo vero: costruisce con make_net la rete NumPy di 5 strati highway, ne copia i pesi in una pila di HighwayLayer e confronta i logit su 8 esempi casuali, in 64 bit perché i pesi NumPy lo sono.

import numpy as np
import torch
import torch.nn as nn
from b2 import make_net, forward      # la rete NumPy di questa lezione

torch.set_num_threads(2)

class HighwayLayer(nn.Module):
    def __init__(self, dim, gate_bias=-2.0):
        super().__init__()
        self.H = nn.Linear(dim, dim)
        self.T = nn.Linear(dim, dim)
        nn.init.constant_(self.T.bias, gate_bias)   # il cancello parte vicino a «lascia passare»
    def forward(self, x):
        t = torch.sigmoid(self.T(x))
        return t * torch.relu(self.H(x)) + (1 - t) * x

# Rete NumPy: 5 strati highway, larghezza 64. Copiamo i SUOI pesi in PyTorch.
p = make_net("highway", 5, np.random.default_rng(0))
X = np.random.default_rng(1).normal(size=(8, 64))
logits_np, _ = forward(p, X)

def linear(W, b):
    # NumPy tiene W come (n_in, n_out), nn.Linear come (n_out, n_in): trasponiamo.
    m = nn.Linear(W.shape[0], W.shape[1]).double()
    with torch.no_grad():
        m.weight.copy_(torch.tensor(W.T)); m.bias.copy_(torch.tensor(b))
    return m

layers = []
for L in p["layers"]:
    layer = HighwayLayer(64).double()
    layer.H, layer.T = linear(L["Wh"], L["bh"]), linear(L["Wt"], L["bt"])
    layers.append(layer)
net = nn.Sequential(linear(p["Wi"], p["bi"]), nn.ReLU(), *layers, linear(p["Wo"], p["bo"]))
with torch.no_grad():
    logits_pt = net(torch.tensor(X)).numpy()

print("forma dell'uscita:", logits_pt.shape)
print("differenza massima sui logit, PyTorch contro NumPy:", np.abs(logits_pt - logits_np).max())
print("prima riga, PyTorch:", logits_pt[0, :4].round(4))
print("prima riga, NumPy:  ", logits_np[0, :4].round(4))

fresh = HighwayLayer(64)
print("bias del cancello in uno strato nuovo:", fresh.T.bias[:3].tolist())
print("parametri di uno strato:", sum(q.numel() for q in fresh.parameters()))

# Output:
# forma dell'uscita: (8, 10)
# differenza massima sui logit, PyTorch contro NumPy: 2.220446049250313e-16
# prima riga, PyTorch: [-0.0732 -0.1108  0.114  -0.1583]
# prima riga, NumPy:   [-0.0732 -0.1108  0.114  -0.1583]
# bias del cancello in uno strato nuovo: [-2.0, -2.0, -2.0]
# parametri di uno strato: 8320

Che cosa mostra l'uscita. La differenza massima sui logit è 2.22e-16, il rumore della doppia precisione: lo strato PyTorch e la nostra forward calcolano la stessa funzione, e le prime quattro uscite coincidono alla quarta cifra. La parte utile è la convenzione sui pesi: NumPy li tiene come (n_ingressi, n_uscite) e calcola h @ W, mentre nn.Linear li tiene come (n_uscite, n_ingressi) e calcola x @ W.T, quindi nel copiarli serve la trasposta. Senza .T, con strati 64×64, le forme coincidono e PyTorch non dà errore: il risultato però è sbagliato (verificato su una matrice casuale, la differenza con x @ W era 2.5). Un HighwayLayer(64) nuovo parte con i bias del cancello a −2.0 e ha 8320 parametri, gli stessi 2·(64·64 + 64) contati nei trade-off.

Trade-off

  • Parametri. Ogni strato highway ha circa il doppio dei parametri di uno semplice: la matrice del cancello si aggiunge a quella della trasformazione. Con larghezza 64 sono 2·(64·64 + 64) = 8320 contro 4160.
  • Larghezza costante. Ingresso e uscita devono avere la stessa dimensione per poter essere mescolati.
  • Un iperparametro che pesa molto. Il bias iniziale del cancello decide se la rete si addestra (misurato: 10.0% con 0, oltre il 94% con −2 e −5).
  • Profondità non è accuratezza. Poter addestrare 50 strati non li rende utili.

Letti insieme, i quattro costi dicono che la highway è una scommessa pagata in parametri e in attenzione all'inizializzazione per ottenere reti addestrabili a profondità alta. Se il tuo problema si risolve con cinque strati, il cancello è un costo senza ritorno, e la misura sopra (96.3% a 5 strati) lo conferma.

Trappole reali

  1. Lasciare il bias del cancello a zero. Con 50 strati la rete non parte (10.0% misurato). Il bias di default di un livello lineare è piccolo e centrato in zero, quindi il cancello parte a circa 0.5: imposta il bias esplicitamente (−1, −2 o inferiore).
  2. Aumentare la profondità senza una ragione. Se il problema non ha bisogno di 50 strati i cancelli restano quasi chiusi e paghi il doppio dei parametri per niente: confronta sempre con una rete superficiale.
  3. Dimenticare che il cancello dipende dall'ingresso. Un cancello fisso, uguale per tutti gli ingressi, è una connessione residua pesata, non una highway.
  4. Misurare solo la perdita. Con 10 classi una perdita di 2.30 è il livello del caso; se la vedi, la rete non ha imparato nulla, e l'accuratezza vicina alla quota della classe più frequente lo conferma.
  5. Un solo seme. Qui ogni risultato viene da un seme: la differenza fra 10% e 94% è netta, quella fra 94.3% e 96.1% no.
  6. Learning rate non adattato. Con reti molto profonde un learning rate troppo alto può destabilizzare comunque; qui Adam a 0.001 senza clipping ha funzionato fino a 50 strati, ma non ho provato altri valori.

Sono sei modi diversi di arrivare allo stesso errore di fondo: scambiare un risultato di addestramento per una proprietà dell'architettura. Il bias, il seme, il learning rate e la dimensione del problema decidono quanto di ciò che vedi sia dovuto al cancello.

Un esempio svolto a mano: uno strato highway e il gradiente su molti strati

Un passo in avanti. Larghezza 2, ingresso x = (1.0, −0.5). Supponi che la trasformazione dia H(x) = (2.0, 0.0) (dopo la ReLU) e il cancello T(x) = (0.1, 0.8). L'uscita è y = T·H + (1 − T)·x componente per componente: y₁ = 0.1·2.0 + 0.9·1.0 = 1.10 e y₂ = 0.8·0.0 + 0.2·(−0.5) = −0.10. La prima componente è quasi l'ingresso (cancello chiuso: 10% di trasformazione), la seconda è soprattutto trasformazione (cancello aperto all'80%): per ogni componente il cancello sceglie quanto trasformare.

Il gradiente su molti strati. Il fattore lungo il percorso diretto di una pila di L strati è (1 − T)^L. Lo ricalcolo con due cicli per tre valori del cancello (poco aperto, aperto al 10%, a metà) e tre profondità, così la tabella non dipende da un conto a mano che potrebbe contenere un errore di aritmetica. Il risultato dipende solo da T e L, non da dati né da semi.

for T in (0.02, 0.10, 0.50):
    print(f"T = {T:.2f}  ", "   ".join(f"L={L}: {(1 - T) ** L:.3g}" for L in (10, 30, 50)))
T = 0.02   L=10: 0.817   L=30: 0.545   L=50: 0.364
T = 0.10   L=10: 0.349   L=30: 0.0424   L=50: 0.00515
T = 0.50   L=10: 0.000977   L=30: 9.31e-10   L=50: 8.88e-16

Con T = 0.5 il percorso diretto è inservibile già a 10 strati (0.001): è la ragione per cui con bias 0 la rete da 50 strati non si addestra. Con T = 0.1 a 10 strati si conserva il 35% del segnale, ma a 50 meno dell'1%. Con T = 0.02 a 50 strati se ne conserva ancora il 36%. Dopo l'addestramento, nella rete da 20 strati, abbiamo misurato cancelli medi fra 0.03 e 0.21: la scorciatoia resta il canale principale.

Domande frequenti

Perché non usare sempre una rete residua? La residua y = x + F(x) non ha parametri extra e ha un termine diretto del gradiente esattamente 1; spesso basta. La highway ha senso se vuoi che la rete decida, in funzione dell'ingresso, quanto trasformare.

Che bias iniziale scelgo? Un valore negativo, tipicamente −1, −2 o −3; gli autori citano −1 e −3 come esempi. Nei nostri esperimenti −2 e −5 funzionano e 0 no. Più negativo significa rete più «trasparente» all'inizio.

La larghezza deve essere costante? Sì, per poter mescolare ingresso e uscita. Per cambiarla inserisci uno strato lineare di proiezione fra due blocchi highway.

Si usano ancora le highway? In nicchie, ad esempio sopra le convoluzioni a livello di carattere nei modelli di linguaggio di Kim e colleghi; per le reti molto profonde sulle immagini si usano soprattutto le connessioni residue.

L'ottimizzatore conta? Probabilmente molto, ma qui ho provato soltanto Adam, quindi non misuro il confronto con SGD. Normalizzazioni e inizializzazioni accurate (He) aiutano anche le reti semplici e possono ridimensionare il vantaggio della highway: non l'ho misurato.

Posso usare le highway per sequenze? Sì, ed è un'idea parente delle porte della LSTM applicate in profondità invece che nel tempo: gli autori stessi dichiarano l'ispirazione.

Che cosa sai fare adesso

Sai scrivere uno strato highway y = T·H(x) + (1 − T)·x e la sua retropropagazione, e sai perché il gradiente ha un percorso diretto con fattore 1 − T. Hai misurato che a 50 strati il gradiente del primo strato è dell'ordine di 10⁻²¹ nella rete semplice e di 10⁻⁵ nella highway, e che su un problema di cifre piccolo la semplice a 50 strati resta al livello del caso mentre la highway supera il 94%. Sai che il dettaglio decisivo è il bias iniziale del cancello: con 0 la rete da 50 strati non parte, con −2 o −5 sì. E dopo l'addestramento i cancelli restano bassi, promemoria che profondità e accuratezza sono cose diverse. Nella lezione successiva un'altra strada per addestrare reti profonde, senza scorciatoie esplicite: le reti frattali.

Nella serie, la lezione precedente è ConvLSTM in pratica: prevedere il frame successivo di cifre in movimento.

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