Salta al contenuto principale

60 - LSTM alla prova: il problema dell'addizione, strati multipli e padding

RNN contro LSTM sul problema dell'addizione con sequenze di 20, 50 e 100 passi: errori misurati, plateau iniziale, più strati, dropout e padding.

Cosa ne pensi di questo articolo?

60 - LSTM alla prova: il problema dell'addizione, strati multipli e padding
Machine Learning Engineerfedericocalo.dev

In Breve

RNN contro LSTM sul problema dell'addizione con sequenze di 20, 50 e 100 passi: errori misurati, plateau iniziale, più strati, dropout e padding.

Nell'articolo precedente abbiamo visto che la LSTM apre una strada al gradiente. Qui verifichiamo che l'ottimizzatore la percorra davvero, con un banco di prova classico: il problema dell'addizione, proposto da Hochreiter e Schmidhuber nel lavoro del 1997. Poi vediamo come si usa nn.LSTM nella pratica quotidiana: più strati, dropout, sequenze di lunghezza diversa. Tutti i numeri vengono da esecuzioni del codice mostrato (PyTorch 2.12.0 su CPU, due esecuzioni con uscita identica, un solo seme per configurazione).

Le parole di questo articolo

Un benchmark è un compito costruito apposta per misurare una sola capacità: il suo valore sta nel fatto che conosci la risposta giusta e puoi calcolare cosa ottiene chi non ha capito il problema. Una dipendenza a lungo termine è il caso in cui l'informazione utile per rispondere a un certo passo si trova molti passi prima. Un plateau è il tratto della curva di perdita in cui l'errore resta fermo, per poi (a volte) scendere di colpo: non è un crash, la rete sta cercando la strada.

Uno strato (layer) è un blocco di rete: una LSTM a due strati ha due LSTM in pila, e la seconda legge, come sequenza, gli stati prodotti dalla prima. Il dropout è una tecnica di regolarizzazione che, in addestramento, spegne a caso una frazione dei neuroni a ogni passo per impedire alla rete di appoggiarsi troppo a pochi di essi. Il padding sono i valori fittizi (qui zeri) aggiunti in coda alle sequenze corte per portarle alla lunghezza della più lunga e poterle impilare in un unico tensore.

Il compito: sommare due numeri tra tanti

La rete riceve una sequenza di T passi, con due canali per passo: un numero a caso in [0, 1] e un marcatore che vale 1 in esattamente due posizioni (scelte a caso) e 0 altrove. L'obiettivo è la somma dei due numeri marcati. Per riuscirci la rete deve ignorare quasi tutta la sequenza, accorgersi del primo marcatore, tenere in memoria quel numero finché non compare il secondo, e sommare. La distanza fra i due numeri può essere quasi pari a T: è un test diretto di memoria a lungo termine.

Esiste una baseline onesta: se la rete predice sempre la media della somma (1.0), l'errore quadratico medio vale la varianza della somma di due uniformi, cioè 2/12 ≈ 0.167. Un modello che non ha capito il compito sta a 0.167; uno che l'ha capito va vicino a zero.

Il problema dell'addizione: due valori della sequenza sono marcati con 1; l'obiettivo è la loro somma, quindi la rete deve ricordare il primo valore marcato per molti passi Adding problem: add the two numbers whose marker is 1 0.3 0 0.9 1 0.1 0 0.7 0 0.5 0 0.2 0 0.8 1 0.4 0 value marker target = 0.9 + 0.8 = 1.7 The network must keep the first marked value for many steps without being distracted by the rest.
Il problema dell'addizione: due valori della sequenza sono marcati con 1; l'obiettivo è la loro somma, quindi la rete deve ricordare il primo valore marcato per molti passi
import torch
import torch.nn as nn

def adding_batch(batch, T):
    """Hochreiter & Schmidhuber's adding problem.
    Input: 2 channels. Channel 0: random numbers in [0,1]. Channel 1: a marker with exactly two 1s.
    Target: the sum of the two marked numbers."""
    values = torch.rand(batch, T)
    markers = torch.zeros(batch, T)
    for b in range(batch):
        idx = torch.randperm(T)[:2]
        markers[b, idx] = 1.0
    x = torch.stack([values, markers], dim=-1)          # (batch, T, 2)
    y = (values * markers).sum(dim=1)                   # (batch,)
    return x, y

class Net(nn.Module):
    def __init__(self, kind, hidden=64):
        super().__init__()
        cls = {"rnn": nn.RNN, "lstm": nn.LSTM}[kind]
        self.rec = cls(2, hidden, batch_first=True)
        self.head = nn.Linear(hidden, 1)
        if kind == "lstm":                              # forget-gate bias = 1
            with torch.no_grad():
                self.rec.bias_ih_l0[hidden:2 * hidden] = 1.0
    def forward(self, x):
        out, _ = self.rec(x)
        return self.head(out[:, -1]).squeeze(-1)

def run(kind, T, steps=4000, seed=0):
    torch.manual_seed(seed)
    net = Net(kind)
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    for _ in range(steps):
        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()
    with torch.no_grad():
        x, y = adding_batch(1000, T)
        return nn.functional.mse_loss(net(x), y).item()

# Reference: always predicting the mean (1.0) gives MSE = Var(sum of two U(0,1)) = 2/12 = 0.167
for T in (20, 50, 100):
    print(f"T={T:3d}  RNN {run('rnn', T):.4f}   LSTM {run('lstm', T):.4f}")

Lettura del codice. adding_batch costruisce un batch di esempi: values sono i numeri casuali, markers è un tensore di zeri in cui, per ogni sequenza, due posizioni scelte a caso (torch.randperm(T)[:2]) valgono 1. L'ingresso è l'impilamento dei due canali (torch.stack(..., dim=-1), forma (batch, T, 2)) e il bersaglio è (values * markers).sum(dim=1): azzera i valori non marcati e somma il resto. La classe Net è una RNN o una LSTM più una testa lineare sull'ultimo stato; per la LSTM, il bias del forget gate è posto a 1 con la stessa fetta [hidden : 2*hidden] vista nella lezione precedente. run addestra con Adam e clipping e restituisce l'MSE su 1000 sequenze fresche: poiché ogni batch di addestramento è generato al volo, non c'è alcuna possibilità di memorizzazione e il numero finale misura la vera capacità di generalizzare.

Risultati

Errore quadratico medio su 1000 sequenze nuove, dopo 4000 passi di addestramento con Adam, clipping a 1.0 e (per la LSTM) bias del forget gate a 1:

         RNN       LSTM
T=20   0.0199    0.0001
T=50   0.1491    0.0005
T=100  0.1785    0.0002

Il risultato si legge in tre punti. La RNN semplice impara a distanza 20 e crolla oltre, restando al livello di «predico la media» (0.167), in coerenza con l'articolo sul gradiente ma con un solo seme. La LSTM lo risolve a tutte le lunghezze provate, con errori mille volte inferiori alla baseline. Il plateau iniziale è normale: con pochi passi la LSTM a T=100 resta ferma sulla baseline e solo con più addestramento arriva a valori minuscoli, quindi se ti fermi troppo presto concludi che il modello non funziona quando ha solo bisogno di tempo.

La lezione generale: la perdita che non scende non significa necessariamente che il modello sia sbagliato. Prima di cambiare architettura controlla la baseline del compito e allunga l'addestramento.

Guardare il plateau da vicino

La frase «la LSTM a T=100 dopo 4000 passi è a 0.0002» nasconde la parte più istruttiva: come ci è arrivata. Registriamo la perdita media di addestramento ogni 250 passi, con lo stesso setup (T = 100, un seed).

def loss_curve(kind, T, steps=4000, every=250, seed=0):
    """Same training as run(), but returns the training loss every `every` steps."""
    torch.manual_seed(seed)
    net = Net(kind)
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    curve, acc = [], 0.0
    for step in range(1, 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()
        acc += loss.item()
        if step % every == 0:
            curve.append(acc / every); acc = 0.0           # average loss of the last `every` steps
    return curve

curve = loss_curve("lstm", T=100)
for i, v in enumerate(curve, start=1):
    print(f"steps {(i-1)*250+1:4d}-{i*250:4d}  mean train loss {v:.4f}")
steps    1- 250  mean train loss 0.1817
steps  251- 500  mean train loss 0.1669
steps  501- 750  mean train loss 0.1675
steps  751-1000  mean train loss 0.1668
steps 1001-1250  mean train loss 0.1654
steps 1251-1500  mean train loss 0.1703
steps 1501-1750  mean train loss 0.1455
steps 1751-2000  mean train loss 0.0087
steps 2001-2250  mean train loss 0.0023
...
steps 3751-4000  mean train loss 0.0002

Come leggere la tabella: loss_curve è l'addestramento di sempre, ma accumula la perdita di ogni passo e ne scrive la media ogni 250. Dai passi 251 a 1500 la perdita sta inchiodata a circa 0.167: esattamente la baseline «predico sempre la media». La rete ha imparato la cosa più facile, l'uscita media, e non ha ancora trovato la struttura del problema. Poi, fra i passi 1500 e 2000, la perdita crolla di due ordini di grandezza (da 0.1455 a 0.0087), e continua a scendere.

Questo andamento ha una spiegazione intuitiva: per migliorare sulla media bisogna prima imparare a fare due cose insieme (guardare il canale dei marcatori e conservare il valore marcato); finché non le fa entrambe, nessuna delle due riduce l'errore, quindi il gradiente verso ciascuna è debole. Quando una delle due compare, l'altra diventa utile e il progresso precipita. Per te, che addestri un modello vero, la lezione pratica è: non fermarti per una perdita piatta se la baseline del compito è proprio quel valore, ma non aspettare all'infinito: confronta con la baseline, e se dopo un budget generoso non ti stacchi, il problema è altrove (learning rate, inizializzazione, architettura).

Usare nn.LSTM nella pratica

I tre parametri che cambiano davvero il comportamento sono num_layers, dropout e bidirectional (quest'ultimo ha una lezione dedicata). Con num_layers=2 la seconda LSTM legge l'intera sequenza di stati prodotta dalla prima: più capacità di astrazione, ma anche più costo e più difficoltà di addestramento. Il dropout di PyTorch viene applicato fra gli strati e non dentro il ciclo temporale, quindi con un solo strato non fa nulla (PyTorch avvisa).

L'altro tema pratico è la lunghezza variabile. Le frasi di un batch hanno lunghezze diverse: si aggiunge padding di zeri fino alla più lunga. Ma se poi leggi l'ultimo stato di una sequenza che era corta, stai leggendo lo stato dopo una sfilza di zeri, non dopo l'ultimo elemento vero. pack_padded_sequence dice alla rete di fermarsi alla vera lunghezza di ciascuna sequenza.

import torch
import torch.nn as nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_sequence

torch.manual_seed(0)
seqs = [torch.randn(5, 2), torch.randn(3, 2), torch.randn(1, 2)]      # three sequences, different lengths
lengths = torch.tensor([len(s) for s in seqs])
padded = pad_sequence(seqs, batch_first=True)                          # (3, 5, 2), zeros at the end

lstm = nn.LSTM(2, 8, num_layers=2, dropout=0.2, batch_first=True)    # dropout acts BETWEEN layers only
packed = pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=False)
_, (h_n, c_n) = lstm(packed)           # h_n: (num_layers, batch, hidden) = last REAL step of each sequence

print(h_n.shape)                       # torch.Size([2, 3, 8])
last_layer = h_n[-1]                   # (3, 8): one summary vector per sequence, padding ignored

# Compare with the naive way: last position of the padded tensor (reads zeros for the short sequences)
lstm.eval()
naive, _ = lstm(padded)
_, (h_ok, _) = lstm(pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=False))
print((naive[:, -1] - h_ok[-1]).abs().max(dim=1).values)    # ~0 for the longest sequence, large for the others

Seguiamo il codice. pad_sequence porta tre sequenze di lunghezze 5, 3 e 1 a una matrice (3, 5, 2) riempiendo di zeri. pack_padded_sequence impacchetta il tensore insieme alle lunghezze vere, in modo che la LSTM salti i passi di padding (enforce_sorted=False evita di dover ordinare le sequenze). Dopo la LSTM, h_n[-1] è lo stato dell'ultimo strato all'ultimo passo vero di ogni sequenza: tre vettori da 8. Il confronto finale legge naive[:, -1], cioè l'ultima posizione del tensore con padding, e lo paragona con h_ok[-1]: dove c'era padding (seconda e terza sequenza), le due letture differiscono.

L'ultima riga misura la differenza fra i due modi di leggere l'ultimo stato: zero (2.98·10⁻⁸, rumore numerico) per la sequenza più lunga, che non ha padding, e fino a 0.047 e 0.143 per le due sequenze più corte. Non è una differenza enorme su dati casuali non addestrati, ma è una distorsione sistematica che il modello deve imparare a compensare, e che in produzione sporca ogni previsione sulle frasi brevi.

La forma di h_n è (num_layers, batch, hidden), con il batch in seconda posizione anche se hai impostato batch_first=True (quel flag riguarda solo ingresso e uscita out). L'ultimo strato è h_n[-1]. Questo è un errore frequente di forma, e non genera errori finché le dimensioni coincidono per caso.

Più strati e dropout: servono davvero?

Un riflesso comune è «più profondo = meglio». Proviamo sul compito dell'addizione a T = 50, con una LSTM a uno strato, a due strati, e a due strati con dropout 0.2, tutte con bias del forget gate a 1 in ogni strato.

class DeepNet(nn.Module):
    def __init__(self, layers, dropout, hidden=64):
        super().__init__()
        self.rec = nn.LSTM(2, hidden, num_layers=layers, dropout=dropout if layers > 1 else 0.0, batch_first=True)
        self.head = nn.Linear(hidden, 1)
        with torch.no_grad():
            for l in range(layers):
                getattr(self.rec, f"bias_ih_l{l}")[hidden:2 * hidden] = 1.0
    def forward(self, x):
        out, _ = self.rec(x)
        return self.head(out[:, -1]).squeeze(-1)

def run_deep(layers, dropout, T=50, steps=3000, seed=0):
    torch.manual_seed(seed)
    net = DeepNet(layers, dropout)
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    for _ in range(steps):
        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()
    net.eval()
    with torch.no_grad():
        x, y = adding_batch(1000, T)
        return nn.functional.mse_loss(net(x), y).item(), sum(p.numel() for p in net.parameters())

for layers, dropout in ((1, 0.0), (2, 0.0), (2, 0.2)):
    mse, n_par = run_deep(layers, dropout)
    print(f"layers={layers} dropout={dropout}: MSE {mse:.4f}  ({n_par} parameters)")
layers=1 dropout=0.0: MSE 0.0001  (17473 parameters)
layers=2 dropout=0.0: MSE 0.0003  (50753 parameters)
layers=2 dropout=0.2: MSE 0.0003  (50753 parameters)

Spiegazione del codice: DeepNet crea una nn.LSTM con il numero di strati voluto; dropout si passa solo se layers > 1 (altrimenti PyTorch avvisa che sarebbe ignorato); il ciclo imposta a 1 il bias del forget gate di ogni strato (bias_ih_l0, bias_ih_l1…). Dopo l'addestramento passiamo in eval(), che spegne il dropout, e contiamo i parametri con numel. Risultato: lo strato singolo è già perfetto (0.0001) con 17.473 parametri; due strati ne usano 50.753 (circa tre volte) senza migliorare (0.0003), e il dropout non cambia nulla qui perché il compito è generato al volo e non c'è overfitting da regolarizzare. Regola: aggiungi complessità solo quando una misura la giustifica. Ogni strato in più triplica circa i parametri, rallenta l'addestramento e introduce un'altra fonte di instabilità.

Quali manopole contano? Misurare «passi per risolvere»

Quando due configurazioni finiscono entrambe a 0.0002, il valore finale non le distingue. Una metrica più informativa è quanti passi servono per risolvere il compito: il primo passo in cui la media mobile della perdita (ultimi 100 passi) scende sotto 0.05, cioè sotto un terzo della baseline. La funzione steps_to_solve la calcola, e restituisce None se non ci si arriva entro il budget. Variamo una manopola alla volta, con T = 50 e un seed.

def steps_to_solve(lr=3e-3, forget_bias=1.0, hidden=64, T=50, max_steps=4000, threshold=0.05, seed=0):
    """First training step at which the running mean of the loss (last 100 steps) drops below `threshold`.
    Returns None if it never does within max_steps."""
    torch.manual_seed(seed)
    net = DeepNet(1, 0.0, hidden)
    with torch.no_grad():
        net.rec.bias_ih_l0[hidden:2 * hidden] = forget_bias
    opt = torch.optim.Adam(net.parameters(), lr=lr)
    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

print("learning rate :", {lr: steps_to_solve(lr=lr) for lr in (1e-3, 3e-3, 1e-2)})
print("forget bias   :", {fb: steps_to_solve(forget_bias=fb) for fb in (0.0, 1.0, 2.0)})
print("hidden size   :", {h: steps_to_solve(hidden=h) for h in (8, 32, 128)})
learning rate : {0.001: 1645, 0.003: 1027, 0.01: 604}
forget bias   : {0.0: 887, 1.0: 1027, 2.0: 788}
hidden size   : {8: 1497, 32: 851, 128: 928}

Come leggerlo, con la dovuta cautela (un seed, differenze del 10-20% sono rumore). Learning rate: è la manopola più chiara, e in modo monotono: 0.001 impiega 1645 passi, 0.003 ne impiega 1027, 0.01 ne impiega 604. Un learning rate più alto accelera il plateau; il prezzo è l'instabilità, che qui il clipping ha tenuto sotto controllo. Bias del forget gate: a T = 50 e con questo compito, 0, 1 e 2 danno risultati dello stesso ordine (887, 1027, 788), senza un vincitore chiaro. Non contraddice le lezioni precedenti: il bias aveva effetto netto sul flusso del gradiente non addestrato e sul compito di richiamo a T = 20 con cella standard; qui l'ottimizzatore, con 4000 passi a disposizione, ce la fa comunque. Segno che la stessa manopola pesa di più in condizioni più difficili (sequenze più lunghe, meno passi). Dimensione dello stato: 8 unità sono troppo poche (1497 passi), 32 e 128 sono equivalenti (851 e 928). Non serve uno stato enorme per un compito che richiede di ricordare un solo numero.

La metodologia è la parte da portarsi via: misurare il tempo di apprendimento oltre al risultato finale, perché due modelli che arrivano allo stesso punto possono differire di un fattore due o tre nel costo per arrivarci, ed è un costo che paghi a ogni addestramento.

Trade-off pratici

  • Un solo strato spesso basta. Aggiungi strati solo se la validazione lo giustifica; ogni strato è un'altra sorgente di instabilità.
  • Hidden size: 32 bastano per il compito dell'addizione. Per compiti reali si parte da 128-256 e si verifica con la validazione.
  • LSTM contro GRU: la GRU è più leggera; a parità di dimensione la LSTM ha più parametri.
  • Lunghezze molto grandi (centinaia o migliaia di passi): anche la LSTM fatica e costa memoria in BPTT, e si ricorre a BPTT troncata o a modelli ad attenzione.

Le quattro voci hanno una logica comune: ogni capacità in più costa parametri, tempo o stabilità, e va giustificata da una misura. Il punto di partenza ragionevole è lo strato singolo con uno stato medio; da lì si sale solo se la validazione lo chiede, e per sequenze molto lunghe la scelta si sposta su altre architetture.

Trappole reali

La prima è concludere troppo presto: il plateau può durare migliaia di passi, quindi fissa un budget di addestramento sensato e confronta con la baseline del compito. La seconda è usare un benchmark che non richiede memoria: se il compito si risolve guardando gli ultimi elementi, una RNN semplice «vince» e non hai misurato nulla; l'addizione è utile proprio perché la posizione dei due numeri è casuale. La terza è leggere l'ultimo stato di sequenze con padding: usa pack_padded_sequence o seleziona a mano l'indice lengths − 1. La quarta è confondere batch_first con la forma di h_n, che ha sempre il batch in posizione 1. L'ultima è il dropout con un solo strato, che non fa nulla; per regolarizzare dentro il ciclo servono tecniche come il dropout variazionale, non incluse in nn.LSTM.

Verificati con quattro conti

La baseline dell'addizione vale 2/12 perché la somma di due uniformi in [0, 1] ha media 1 e varianza 1/12 + 1/12 = 1/6 ≈ 0.167, e predire sempre la media dà un errore quadratico medio uguale alla varianza. Se la perdita resta a 0.167 per 1000 passi il modello non è necessariamente rotto: può essere un plateau pre-apprendimento, e prima di cambiare architettura conviene allungare l'addestramento e verificare learning rate, clipping e bias del forget gate. Con sequenze di lunghezze 3, 7 e 10 eviti di leggere lo stato dopo il padding con pack_padded_sequence e le lunghezze vere, oppure selezionando a mano l'indice lunghezza − 1 degli stati out. La forma di h_n per una LSTM a 3 strati, batch 16, stato 64 è (3, 16, 64): strati, batch, stato.

Un esempio svolto a mano: il problema dell'addizione su otto passi

Prendiamo una sequenza di T = 8 passi (come nel disegno): valori 0.3, 0.9, 0.1, 0.7, 0.5, 0.2, 0.8, 0.4 e marcatori 0, 1, 0, 0, 0, 0, 1, 0. Il bersaglio è la somma dei due valori marcati: 0.9 + 0.8 = 1.7. Cosa deve fare la rete, passo per passo? Ai passi 1, 3, 4, 5, 6 e 8 il marcatore è 0: la cella deve lasciare invariata la memoria (porta di input vicina a 0, forget vicino a 1). Al passo 2 il marcatore è 1: deve scrivere 0.9 nella memoria (input aperto). Nei passi 3-6 deve conservarlo senza lasciarsi disturbare dai valori 0.1, 0.7, 0.5, 0.2. Al passo 7 deve scrivere anche 0.8 sommandolo a quello già presente (memoria additiva: c = f·c + i·g è proprio una somma). Al passo 8 deve non rovinare nulla. All'uscita, la testa lineare legge 1.7.

È una descrizione quasi alla lettera del comportamento delle porte della LSTM, ed è il motivo per cui questo compito è un buon test: richiede, in un'unica sequenza, di scrivere, conservare, sommare e ignorare. La baseline vale 1/6 ≈ 0.167 perché ciascun valore uniforme in [0, 1] ha varianza 1/12 e le varianze di due variabili indipendenti si sommano; un errore quadratico medio di 0.0002 corrisponde a uno scarto tipico di √0.0002 ≈ 0.014 sulla somma, contro √0.167 ≈ 0.41 di chi predice la media: circa 29 volte più piccolo.

Anche il padding si può contare a mano. Con tre sequenze di lunghezze 5, 3 e 1, il tensore con padding ha 3 × 5 = 15 posizioni, ma solo 5 + 3 + 1 = 9 sono vere: il 40% del calcolo, senza il packing, lavora su zeri (e, peggio, produce uno stato finale che ha letto quegli zeri).

Domande frequenti

Perché la perdita resta ferma a 0.167 e poi crolla? Prima di scoprire che il valore marcato serve, la miglior mossa è predire la media; scoprirlo richiede insieme di guardare il canale dei marcatori e di conservare il valore, e finché non succede il gradiente verso ciascuna delle due abilità è debole. Il crollo arriva quando una delle due è «agganciata» e rende l'altra utile.

Il clipping non basterebbe a evitare il plateau? No: il clipping limita i gradienti troppo grandi, mentre il plateau ha gradienti piccoli. Lo si accorcia con un learning rate più alto (604 passi a 0.01 contro 1645 a 0.001), un'inizializzazione migliore o più dati per passo.

Quanti strati servono? Qui uno basta; due non hanno aiutato (0.0003 contro 0.0001) e hanno triplicato i parametri. Aggiungi strati quando la validazione lo giustifica, non per default.

Come scelgo il batch size? Qui 64. Batch più grandi danno gradienti meno rumorosi ma costano di più per passo; batch piccoli sono rumorosi e, nel plateau, possono aiutare a uscirne. Non l'abbiamo variato: non possiamo dire quale sia meglio per questo compito.

Il risultato vale per testo e audio? È un risultato su un compito sintetico di memoria: indica che la LSTM può conservare un valore per decine di passi. Per il linguaggio reale contano molte altre cose (vocabolario, dati, tokenizzazione) che qui non abbiamo misurato.

Esercizi con soluzione

Prima di andare avanti, prova a risolvere questi esercizi con carta e matita; le soluzioni (verificate con il codice) sono subito sotto ciascuno.

Esercizio 1. Quanti parametri ha nn.LSTM(2, 64) con i due vettori di bias di PyTorch? Soluzione: 4·64·(64 + 2 + 2) = 17.408 (quattro trasformazioni, ognuna con una matrice 64×(64+2) e due bias da 64). La rete dell'addizione aggiunge una testa lineare da 65 parametri: 17.473, il numero stampato nell'esperimento.

Esercizio 2. Quattro sequenze hanno lunghezze 10, 6, 4 e 2: quante posizioni sono vere e quante di padding? Soluzione: il tensore ha 4·10 = 40 posizioni, di cui 10 + 6 + 4 + 2 = 22 vere e 18 di padding, il 45% del calcolo senza packing.

Esercizio 3. Un modello ha MSE 0.04 sul problema dell'addizione (baseline 1/6): che frazione della varianza spiega? Soluzione: 1 − 0.04/0.1667 = 0.76, il 76% della varianza del bersaglio; un MSE di 0.0002 ne spiega il 99.9%. Una domanda in più, senza calcoli: perché lo stesso problema può dare 0.167 a 1500 passi e 0.0002 a 4000? La risposta è nella sezione sul plateau: la perdita ha un tratto piatto, quindi il numero di passi è parte dell'esperimento.

Che cosa sai fare adesso

Sai usare il problema dell'addizione come banco di prova della memoria, confrontando RNN e LSTM con la baseline 2/12 e riconoscendo il plateau iniziale per quello che è. Sai usare nn.LSTM con più strati e dropout, gestire sequenze di lunghezza diversa con pack_padded_sequence, leggere la forma di h_n e misurare le manopole non solo sul risultato finale ma sui passi necessari a risolvere il compito. Il passo successivo è la cella più leggera, la GRU.

Fonti

  • PyTorch: sorgente di torch/nn/modules/rnn.py - forma di h_n (num_layers, batch, hidden), layout dei bias [i | f | g | o] e comportamento del dropout fra gli strati, letto il 2026-11-12
  • PyTorch: sorgente di torch/nn/utils/rnn.py - pad_sequence e pack_padded_sequence con enforce_sorted=False, consultata il 2026-11-12
  • S. Hochreiter, J. Schmidhuber, Long Short-Term Memory, Neural Computation 9(8), 1997 - il problema dell'addizione come banco di prova della memoria (citazione bibliografica, non consultata online)

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