«La LSTM è meglio della RNN semplice» è una frase che si legge ovunque. In questa lezione la mettiamo alla prova con un esperimento controllato su un compito che richiede memoria vera, usando tre seed per ogni configurazione, e il risultato non è quello che ci si aspetterebbe da un manuale: in alcune condizioni la LSTM, con l'inizializzazione standard, fa peggio della RNN semplice. Il motivo si capisce, ed è istruttivo, perché riporta alle lezioni su gradiente e porte. Il secondo obiettivo è metodologico: imparare a confrontare modelli senza ingannare sé stessi.
Le parole di questa lezione
Il seed è il numero che inizializza il generatore di numeri casuali: cambiarlo cambia i pesi iniziali e l'ordine dei dati, e due addestramenti identici salvo il seed possono dare risultati molto diversi. La varianza fra seed misura quanto: se è grande, un singolo numero non dice nulla di affidabile sul modello. Un iperparametro è una scelta che non viene appresa ma decisa da te prima (dimensione dello stato, learning rate, numero di passi). Il livello del caso (chance level) è l'accuratezza di chi tira a indovinare: con 8 classi equiprobabili vale 1/8 = 0.125, e un risultato vicino a quel valore significa «la rete non ha imparato niente». Infine un confronto equo usa gli stessi dati, lo stesso budget di addestramento, le stesse metriche e la stessa quantità di tuning per ogni modello.
L'analogia: tre studenti, stessa prova, giorni diversi
Se vuoi sapere quale di tre studenti sia più bravo a ricordare, non basta una prova a testa: uno potrebbe aver dormito male. Ripeti la prova in giorni diversi e guarda la distribuzione dei punteggi. I seed sono i «giorni diversi» del tuo modello. Un modello che vince una volta su tre e prende zero le altre due non è «il migliore»: è instabile.
Il compito: ricordare il primo simbolo
La rete legge una sequenza di T simboli (interi da 0 a 9). Il primo simbolo è sempre una «classe» da 0 a 7; i successivi T − 1 sono rumore. Alla fine la rete deve dire qual era il primo. Non esiste scorciatoia: l'informazione è solo in prima posizione e deve sopravvivere a tutto il rumore. Per ogni simbolo la rete ne ricava prima un vettore (strato nn.Embedding, che associa a ogni intero un vettore di 16 numeri appresi) e poi lo passa alla parte ricorrente.
import torch
import torch.nn as nn
VOCAB, CLASSES = 10, 8
def recall_batch(batch, T):
"""Sequence of random tokens. The label is the FIRST token (always in 0..7).
The network must carry it through T-1 distracting steps."""
x = torch.randint(0, VOCAB, (batch, T))
x[:, 0] = torch.randint(0, CLASSES, (batch,))
return x, x[:, 0].clone()
class Classifier(nn.Module):
def __init__(self, kind, hidden=64):
super().__init__()
self.emb = nn.Embedding(VOCAB, 16)
self.rec = {"RNN": nn.RNN, "LSTM": nn.LSTM, "GRU": nn.GRU}[kind](16, hidden, batch_first=True)
self.head = nn.Linear(hidden, CLASSES)
def forward(self, x):
out, _ = self.rec(self.emb(x))
return self.head(out[:, -1])
def train_and_eval(kind, T, seed, steps=3000):
torch.manual_seed(seed)
net = Classifier(kind)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for _ in range(steps):
x, y = recall_batch(64, T)
loss = nn.functional.cross_entropy(net(x), y)
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
with torch.no_grad():
x, y = recall_batch(2000, T)
return (net(x).argmax(-1) == y).float().mean().item()
for T in (10, 20, 40):
for kind in ("RNN", "LSTM", "GRU"):
accs = [train_and_eval(kind, T, seed) for seed in range(3)]
print(f"T={T:2d} {kind:5s} accuracy per seed: {[round(a, 2) for a in accs]}")
print("chance level:", 1 / CLASSES)
Spiegazione del codice. recall_batch genera i dati al volo: simboli casuali, con la prima posizione sovrascritta da una classe 0-7; l'etichetta è proprio quella. Classifier accetta il tipo di cella come stringa ("RNN", "LSTM", "GRU") e tiene tutto il resto identico: stessa dimensione di embedding, stessa dimensione dello stato (64), stessa testa lineare. Questo è il cuore del confronto equo: cambia una cosa sola. train_and_eval addestra 3000 passi con Adam e clipping, uguale per tutti, e misura l'accuratezza su 2000 sequenze nuove. Il doppio ciclo in fondo prova tre lunghezze (T = 10, 20, 40) e tre seed per cella. Nessuna cella riceve un trattamento di favore: la LSTM qui usa l'inizializzazione di default di PyTorch (bias del forget gate a 0), che è anche ciò che si trova in tanto codice reale.
Risultati misurati
Accuratezza su 2000 sequenze nuove, un valore per seed (0, 1, 2). Livello del caso: 0.125.
seed 0 seed 1 seed 2
T=10 RNN 0.62 0.87 1.00
T=10 LSTM 1.00 1.00 1.00
T=10 GRU 1.00 1.00 1.00
T=20 RNN 1.00 0.51 0.23
T=20 LSTM 0.14 0.12 0.13
T=20 GRU 1.00 1.00 1.00
T=40 RNN 0.13 0.12 0.90
T=40 LSTM 0.12 0.12 0.14
T=40 GRU 0.11 0.11 0.13
Cosa si vede, senza abbellire. A T=10 LSTM e GRU risolvono sempre (3 seed su 3), mentre la RNN semplice funziona in modo irregolare (0.62, 0.87, 1.00). A T=20 la GRU risolve ancora 3 volte su 3; la RNN semplice vince in un seed (1.00), poi arriva a metà (0.51) e a un quarto (0.23), quindi una volta su tre è perfetta, ma non ci si può contare; la LSTM con l'inizializzazione standard resta al livello del caso in tutti e tre i seed. A T=40 nessuna cella risolve in modo affidabile in 3000 passi: un seed della RNN semplice arriva a 0.90, gli altri due no, e GRU e LSTM restano al caso.
Il dato che sorprende è la LSTM a T=20: la cella considerata «la soluzione» al problema della memoria è quella che fallisce più nettamente. Non è un difetto dell'architettura, e il paragrafo seguente mostra perché.
Perché la LSTM è rimasta al caso a T=20?
La spiegazione plausibile passa dalla porta di dimenticanza (forget gate). In PyTorch tutti i pesi e i bias di una LSTM partono da una distribuzione uniforme fra −1/√H e +1/√H (con H = 64: ±0.125), quindi all'inizio il bias del forget gate è vicino a zero e la porta vale circa f = σ(0) = 0.5. Dopo 20 passi il fattore che moltiplica la memoria è circa 0.5²⁰ ≈ 10⁻⁶: il segnale del primo simbolo è sparito prima che l'addestramento abbia modo di insegnare alla porta a «tenere». È un'ipotesi, non una dimostrazione: la GRU parte da una condizione più favorevole in questo compito, ma la sua struttura è diversa e qui la causa non è stata isolata. L'ipotesi sulla LSTM però si può mettere alla prova: stesso identico esperimento, con il bias del forget gate portato a 1, il rimedio che propongono Jozefowicz e colleghi (vedi le fonti).
def lstm_forget_bias(net, value=1.0):
h = net.rec.hidden_size
with torch.no_grad():
net.rec.bias_ih_l0[h:2 * h] = value # bias layout [i | f | g | o]
def train_and_eval_fb(T, seed, steps=3000, forget_bias=1.0):
torch.manual_seed(seed)
net = Classifier("LSTM")
lstm_forget_bias(net, forget_bias)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for _ in range(steps):
x, y = recall_batch(64, T)
loss = nn.functional.cross_entropy(net(x), y)
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
with torch.no_grad():
x, y = recall_batch(2000, T)
return (net(x).argmax(-1) == y).float().mean().item()
for T in (20, 40):
accs = [train_and_eval_fb(T, seed) for seed in range(3)]
print(f"T={T:2d} LSTM (forget bias 1) accuracy per seed: {[round(a, 2) for a in accs]}")
T=20 LSTM (forget bias 1) accuracy per seed: [1.0, 0.25, 1.0]
T=40 LSTM (forget bias 1) accuracy per seed: [0.12, 0.12, 0.13]
A T=20 con bias 1 la LSTM passa da «caso in tutti e tre i seed» a «perfetta in due seed su tre» (il terzo arriva a 0.25: ha iniziato a imparare, non ha finito). Un singolo iperparametro, una singola riga di codice, ha fatto la differenza. A T=40 niente cambia entro 3000 passi: per lunghezze maggiori servono più passi o altri accorgimenti. Il codice di lstm_forget_bias usa la stessa fetta [hidden : 2*hidden] della lezione sulla LSTM, perché nel layout di PyTorch il vettore dei bias è [i | f | g | o].
Cosa si può e non si può concludere
Si può concludere che, su questo compito e con questo budget, l'inizializzazione conta quanto l'architettura: a parità di tutto il resto, «LSTM o GRU» ha dato risultati diversi, e un'unica riga di codice ha cambiato l'esito della LSTM. Non si può concludere che la GRU sia «meglio» della LSTM, perché con il bias a 1 la LSTM ha recuperato in due seed su tre, e con più passi altre configurazioni cambierebbero. Tre seed bastano a vedere che la varianza è grande, non a stimare una media stabile. Non si può nemmeno dire che la RNN semplice sia buona: ha raggiunto l'accuratezza piena in due dei sei addestramenti a T=10 e T=20, e in quattro su sei no, quindi è instabile.
Il messaggio utile è questo: ogni confronto fra architetture è anche un confronto fra le loro inizializzazioni, i loro learning rate e il loro budget. Se non li controlli, stai misurando altro.
Riassumere con media, deviazione standard e «risolti su tre»
Una tabella di nove righe da tre numeri è leggibile ma non si confronta a colpo d'occhio. Per riassumere in modo onesto servono due decisioni prese prima di guardare i risultati: quale soglia significa «risolto» (qui accuratezza ≥ 95%) e quali statistiche riportare. Il codice sotto prende i numeri della tabella e calcola media, deviazione standard (con ddof=1, la formula corretta per un campione piccolo), minimo e quanti seed su tre superano la soglia.
import numpy as np
# Results of the recall experiment, copied from the table above (accuracy per seed).
results = {
("RNN", 10): [0.62, 0.87, 1.00], ("LSTM", 10): [1.00, 1.00, 1.00], ("GRU", 10): [1.00, 1.00, 1.00],
("RNN", 20): [1.00, 0.51, 0.23], ("LSTM", 20): [0.14, 0.12, 0.13], ("GRU", 20): [1.00, 1.00, 1.00],
("RNN", 40): [0.13, 0.12, 0.90], ("LSTM", 40): [0.12, 0.12, 0.14], ("GRU", 40): [0.11, 0.11, 0.13],
}
SOLVED = 0.95 # decide BEFORE looking: "solved" means accuracy >= 95%
print(f"{'cell':5s} {'T':>3s} {'mean':>5s} {'std':>5s} {'min':>5s} solved/seeds")
for (cell, T), accs in results.items():
a = np.array(accs)
print(f"{cell:5s} {T:3d} {a.mean():5.2f} {a.std(ddof=1):5.2f} {a.min():5.2f} {(a >= SOLVED).sum()}/{len(a)}")
cell T mean std min solved/seeds
RNN 10 0.83 0.19 0.62 1/3
LSTM 10 1.00 0.00 1.00 3/3
GRU 10 1.00 0.00 1.00 3/3
RNN 20 0.58 0.39 0.23 1/3
LSTM 20 0.13 0.01 0.12 0/3
GRU 20 1.00 0.00 1.00 3/3
RNN 40 0.38 0.45 0.12 0/3
LSTM 40 0.13 0.01 0.12 0/3
GRU 40 0.12 0.01 0.11 0/3
La colonna più rivelatrice è la deviazione standard. La RNN semplice a T = 20 ha media 0.58 ma deviazione 0.39: una media che non descrive nessun seed reale (i tre valori erano 1.00, 0.51, 0.23). Quando la deviazione è comparabile alla media, la media è quasi inutile e la colonna «risolti su tre» dice di più: 1 su 3. Il ddof=1 serve perché con tre seed la deviazione calcolata dividendo per n sottostimerebbe quella vera (si divide per n − 1, correzione di Bessel).
Un secondo banco di prova: il problema dell'addizione, con i passi per risolvere
Un risultato su un solo compito può essere un caso. Rifacciamo il confronto sul problema dell'addizione (la definizione di adding_batch è ripetuta nel blocco, così il listato è autonomo), che ha una struttura diversa (valori continui, due marcatori, regressione). La metrica è «passi per risolvere» (soglia 0.05, un terzo della baseline), con tre seed e un budget massimo di 5000 passi; None significa «non risolto entro il budget». La LSTM ha il bias del forget gate a 1: la trattiamo nel modo più favorevole possibile.
def adding_batch(batch, T):
"""Adding problem: channel 0 = random values in [0,1], channel 1 = two markers; target = sum of the two marked values."""
values = torch.rand(batch, T)
markers = torch.zeros(batch, T)
for b in range(batch):
markers[b, torch.randperm(T)[:2]] = 1.0
return torch.stack([values, markers], dim=-1), (values * markers).sum(dim=1)
class AnyNet3(nn.Module):
def __init__(self, kind, hidden=64):
super().__init__()
self.rec = {"RNN": nn.RNN, "GRU": nn.GRU, "LSTM": nn.LSTM}[kind](2, hidden, batch_first=True)
self.head = nn.Linear(hidden, 1)
if kind == "LSTM":
with torch.no_grad():
self.rec.bias_ih_l0[hidden:2 * hidden] = 1.0 # fair to the LSTM: forget bias 1
def forward(self, x):
out, _ = self.rec(x)
return self.head(out[:, -1]).squeeze(-1)
def adding_steps(kind, T, seed, max_steps=5000, threshold=0.05):
torch.manual_seed(seed)
net = AnyNet3(kind)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
recent = []
for step in range(1, max_steps + 1):
x, y = adding_batch(64, T)
loss = nn.functional.mse_loss(net(x), y)
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
recent.append(loss.item()); recent = recent[-100:]
if step >= 100 and sum(recent) / 100 < threshold:
return step
return None # not solved within the budget
for T in (30, 100):
for kind in ("RNN", "GRU", "LSTM"):
print(f"T={T:3d} {kind:4s} steps to solve (3 seeds): {[adding_steps(kind, T, s) for s in range(3)]}")
T= 30 RNN steps to solve (3 seeds): [2082, 1245, None]
T= 30 GRU steps to solve (3 seeds): [335, 315, 341]
T= 30 LSTM steps to solve (3 seeds): [776, 651, 580]
T=100 RNN steps to solve (3 seeds): [None, None, None]
T=100 GRU steps to solve (3 seeds): [556, 556, 575]
T=100 LSTM steps to solve (3 seeds): [1800, 1524, 1423]
Lettura. AnyNet3 costruisce la rete (RNN, GRU o LSTM) con la stessa dimensione dello stato e la stessa testa; adding_steps addestra finché la media mobile della perdita scende sotto la soglia e restituisce il passo, o None. La RNN semplice ce la fa a T = 30 solo in due seed su tre, e lentamente (1245 e 2082 passi), e a T = 100 non ce la fa mai entro 5000 passi. La GRU risolve sempre e con una variabilità minima: 315-341 passi a T = 30 e 556-575 a T = 100. La LSTM risolve sempre ma con circa il doppio-triplo dei passi della GRU (580-776 a T = 30, 1423-1800 a T = 100). Questo quadro differisce da quello del richiamo, dove la LSTM con bias 1 aveva recuperato: lì la LSTM era ancora competitiva su T = 20. Due compiti, due quadri non identici: è esattamente la ragione per cui un solo benchmark non basta.
Cosa dicono insieme i due esperimenti
La GRU è stata la cella più affidabile nel complesso: ha vinto a T=20 nel richiamo e in tutte le prove di addizione, con bassa variabilità fra seed. Non è però sempre la migliore: a T=40 nel richiamo è rimasta al caso, mentre un seed della RNN semplice è arrivato a 0.90. È un fatto sui nostri esperimenti, non una legge. La LSTM ha bisogno di cure (bias del forget gate, più passi) per dare il meglio: con la cura risolve sempre, senza a volte non risolve. La RNN semplice è inaffidabile oltre le dipendenze corte, tanto che a T = 100 non risolve mai l'addizione. Resta un limite che va detto: tutti i compiti qui sono sintetici e di sola memoria, quindi non abbiamo misurato la generalizzazione su dati reali. Su compiti linguistici o su serie con struttura complessa l'ordine può cambiare, e per questo il consiglio resta «parti dalla GRU per rapidità, confronta con la LSTM se serve, misura su più seed».
Come scegliere: un criterio operativo
Il diagramma riassume tre lezioni. La prima domanda riguarda la legittimità: se al momento della previsione non hai il futuro, la bidirezionalità è vietata. La seconda riguarda la portata della memoria. Nel mezzo (decine di passi) la scelta fra GRU e LSTM non si fa con una regola: si prova entrambe con lo stesso budget, su più seed, e si guarda anche la stabilità, non solo la media.
Una lista di controllo per confronti onesti
Un confronto onesto parte da uno stesso budget (passi, epoche, learning rate) per tutti, oppure da un tuning di pari impegno per ciascun modello. Usa più seed, almeno 3 e meglio 5 o più, e riporta tutti i valori o media e deviazione standard, non solo il migliore. Scrivi una baseline esplicita (caso, persistenza, modello banale) prima di guardare i risultati e valuta su dati nuovi: qui sono generati al volo, quindi il leakage non è possibile. Cambia una cosa alla volta, altrimenti non sai a cosa attribuire la differenza. Infine guarda il costo: nello strato ricorrente, con stato 64 e ingresso 16, i parametri sono 5.248 per la RNN, 15.744 per la GRU e 20.992 per la LSTM (valori che si ricavano da sum(p.numel() for p in rec.parameters()), verificati nell'esecuzione), mentre il tempo per passo dipende dall'implementazione e dal tuo hardware e qui non è stato confrontato.
Trappole reali
La più comune è pubblicare il seed fortunato: provi dieci seed e riporti il migliore. Si decide prima quanti seed usare. La seconda è concludere dal «non impara» che il modello è inadatto, mentre come visto può essere un problema di inizializzazione o di passi. La terza è confrontare modelli con parametri molto diversi senza dirlo: la LSTM ha quattro volte i parametri della RNN, quindi a parità di hidden_size non è lo stesso budget di capacità. L'ultima è usare un compito che non richiede memoria: se bastano gli ultimi passi, tutti i modelli «vincono» e il confronto non misura ciò che vuoi sapere.
Un esempio svolto a mano: perché tre seed non bastano a «provare» nulla
Riprendiamo i tre valori della RNN a T = 20: 1.00, 0.51, 0.23. La media è (1.00 + 0.51 + 0.23)/3 = 0.58. Gli scarti dalla media sono +0.42, −0.07, −0.35; i loro quadrati 0.176, 0.005, 0.123, la cui somma è 0.304. Dividendo per n − 1 = 2 si ottiene la varianza campionaria 0.152 e la deviazione standard 0.39 (come nella tabella). L'errore standard della media, cioè l'incertezza sulla media stimata, è 0.39 / √3 = 0.225: un intervallo approssimato per la «vera» accuratezza media è 0.58 ± 0.45 (due errori standard), cioè da 0.13 a 1.03: praticamente tutto lo spazio possibile. Con soli tre campioni il moltiplicatore corretto non è 2 ma 4.30 (il valore della distribuzione t di Student con 2 gradi di libertà, calcolato con scipy.stats.t.ppf(0.975, 2)), e l'intervallo diventa ancora più largo. Con tre seed non si può dire quasi nulla sulla media della RNN a T = 20, e questa è l'informazione importante: la deviazione è alta, quindi servono più seed per stringere la stima.
Quanti? L'errore standard scala come 1/√n: per dimezzarlo servono quattro volte i seed. Da 3 a 12 seed l'errore standard passa da 0.225 a circa 0.11 (a parità di deviazione). Per una RNN così instabile conviene riportare anche il numero di seed riusciti («1 su 3»), che non dipende da stime di media.
Domande frequenti
Perché non si usano sempre 100 seed? Perché ogni addestramento costa; si sceglie il numero di seed in base al costo e alla varianza osservata. Per esperimenti di diagnosi come questi 3-5 bastano a vedere differenze grandi; per confronti fini ne servono molti di più.
Posso confrontare i modelli sullo stesso insieme di seed? Sì, e conviene: a parità di seed i dati e le inizializzazioni casuali sono (in parte) comuni e i confronti sono più «appaiati». Attenzione però che lo stesso seed non produce gli stessi numeri casuali in architetture diverse.
Il test statistico serve? Con pochi seed e distribuzioni non normali, un test classico è debole. Meglio mostrare tutti i valori (come nella tabella) e usare criteri come «risolti su n».
La varianza fra seed è un difetto del modello? È un'informazione sul modello: un modello instabile (RNN: 1.00, 0.51, 0.23) è meno affidabile in produzione di uno stabile (GRU: 1.00, 1.00, 1.00), a parità di media.
Come scelgo la soglia «risolto»? Prima di guardare i risultati, in base all'uso. Qui 95% di accuratezza, ma per un sistema di sicurezza potrebbe essere 99.9%. Cambiare la soglia dopo aver visto i dati è una forma di auto-inganno.
Un confronto equo richiede lo stesso numero di parametri? Dipende dalla domanda: «a parità di hidden_size» o «a parità di parametri». Sono due domande diverse; qui abbiamo risposto alla prima (stesso hidden_size, quindi la LSTM ha più parametri) e non alla seconda.
Come ricontrollare i conti
Per fissare i concetti, ricalcola a mano la deviazione standard campionaria (ddof = 1) di 0.9, 1.0, 0.2: la media è 0.70, gli scarti sono 0.2, 0.3, −0.5, i quadrati 0.04, 0.09, 0.25, la somma 0.38, divisa per 2 fa 0.19 e la radice è 0.436 (il valore coincide con np.std(..., ddof=1)). Se con 3 seed l'errore standard della media è 0.25, per portarlo a 0.125 servono 4 volte i seed, cioè 12. E se una cella risolve 2 seed su 3, l'intervallo esatto al 95% (Clopper-Pearson, scipy.stats.binomtest(2, 3).proportion_ci()) va da circa 0.09 a 0.99: con tre seed l'affidabilità di una cella è quasi indeterminata, e servono più seed prima di fidarsi.
Che cosa sai fare adesso
Sai montare un confronto equo fra RNN, LSTM e GRU (stessi dati, stesso stato, stesso budget, più seed), riportare i risultati con media, deviazione e «risolti su tre», e riconoscere quando un modello «non impara» solo perché parte male. Su un compito di memoria pura, a lunghezza 20, la GRU ha risolto in tutti e tre i seed, la LSTM con bias a 1 in due su tre, la RNN semplice in uno su tre e la LSTM con inizializzazione standard in nessuno; a lunghezza 40 nessuna cella ha risolto entro 3000 passi. Il risultato che porti via non è quale cella vince, ma che inizializzazione, budget e seed spostano i risultati quanto l'architettura. Nella lezione successiva applichiamo queste celle a un compito generativo: produrre testo carattere per carattere.
Nella serie, la lezione precedente è RNN bidirezionali: leggere la sequenza in entrambe le direzioni; la successiva è Generare testo carattere per carattere con una LSTM.
Fonti
- Jozefowicz, Zaremba, Sutskever, «An Empirical Exploration of Recurrent Network Architectures», ICML 2015 - l'osservazione che un bias di 1 sul forget gate della LSTM riduce il divario con la GRU; letto l'abstract, consultata il 2026-11-12.
- PyTorch, documentazione di torch.nn.LSTM - layout dei bias
(b_ii|b_if|b_ig|b_io)usato dalla fetta[h:2h]; consultata il 2026-11-12. - SciPy, documentazione di scipy.stats.binomtest - intervallo di confidenza esatto per una proporzione; consultata il 2026-11-12.
- NumPy, documentazione di numpy.std - parametro
ddofper la deviazione standard campionaria; consultata il 2026-11-12.
Commenti
Caricamento commenti...
Accedi per commentare