Salta al contenuto principale

59 - LSTM: la cella con porte, equazione per equazione

Come funziona la cella LSTM: tre porte, uno stato di cella additivo e il bias del forget gate, con le equazioni, un passo calcolato a mano e il codice.

Cosa ne pensi di questo articolo?

59 - LSTM: la cella con porte, equazione per equazione
Machine Learning Engineerfedericocalo.dev

In Breve

Come funziona la cella LSTM: tre porte, uno stato di cella additivo e il bias del forget gate, con le equazioni, un passo calcolato a mano e il codice.

La Long Short-Term Memory (LSTM, Hochreiter e Schmidhuber, 1997) è una RNN con una struttura pensata per un solo scopo: far arrivare il segnale di errore indietro nel tempo senza che si spenga. Lo ottiene separando due cose che la RNN semplice mescola, la memoria (lo stato di cella c) e ciò che la rete mostra all'esterno (lo stato nascosto h), e controllando i flussi con tre porte moltiplicative: dimentica, scrivi, leggi. L'idea chiave in una riga: lo stato di cella si aggiorna per somma, non per rimpasto completo a ogni passo.

Le parole che servono

Una porta (gate) è un vettore di numeri fra 0 e 1 che si moltiplica elemento per elemento con un altro vettore: moltiplicare per 1 lascia passare tutto, per 0 blocca tutto, per 0.3 lascia passare il 30%. È un rubinetto regolabile, uno per ogni casella della memoria, e si costruisce con la sigmoide σ(z) = 1 / (1 + e^(−z)), che trasforma qualunque numero in un valore fra 0 e 1 e si legge come «quanta percentuale lascio passare». Il prodotto elemento per elemento ⊙ (di Hadamard) è [a, b] ⊙ [c, d] = [a·c, b·d] e non va confuso col prodotto riga per colonna fra matrici; la concatenazione [a, b] incolla due vettori in uno più lungo.

Lo stato di cella c è la memoria vera e propria della LSTM, un vettore lungo quanto lo stato nascosto. La differenza con lo stato nascosto h è che c è ciò che la cella possiede, mentre h è ciò che la cella mostra al resto della rete e al passo successivo.

L'analogia: un nastro trasportatore con tre addetti

Immagina un nastro trasportatore che attraversa tutta la sequenza, da sinistra a destra. Sul nastro viaggia la memoria c, e a ogni passo intervengono tre addetti. L'addetto al «dimentica» (forget gate f) decide, per ogni casella del nastro, quanto tenere: 1 vuol dire lascio tutto, 0 svuoto. L'addetto al «scrivi» (input gate i) decide quanta della nuova informazione candidata g appoggiare sul nastro. L'addetto al «leggi» (output gate o) decide quanto del contenuto del nastro mostrare all'esterno come h, senza toglierlo dal nastro.

La RNN semplice, per confronto, era un taccuino da riscrivere integralmente a ogni pagina. Qui il nastro scorre quasi invariato finché nessuno lo tocca.

Le equazioni, una per una

Siano x_t l'ingresso (dimensione d), h_{t-1} e c_{t-1} gli stati precedenti (dimensione n) e [h_{t-1}, x_t] la loro concatenazione. σ è la sigmoide (valori in (0,1), perfetta per «quanto») e ⊙ è il prodotto elemento per elemento.

f_t = σ( W_f · [h_{t-1}, x_t] + b_f )        forget gate
i_t = σ( W_i · [h_{t-1}, x_t] + b_i )        input gate
g_t = tanh( W_g · [h_{t-1}, x_t] + b_g )     contenuto candidato (in (-1,1))
o_t = σ( W_o · [h_{t-1}, x_t] + b_o )        output gate

c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t              aggiornamento della memoria
h_t = o_t ⊙ tanh( c_t )                       stato mostrato all'esterno

Nota dove sta la differenza rispetto alla RNN. Nella RNN semplice, lo stato passa attraverso una matrice e una tanh a ogni passo. Qui c_t è una media pesata a porte fra il vecchio c e il nuovo g, senza matrice nel mezzo. La matrice c'è solo dentro il calcolo delle porte.

Parametri: ognuna delle quattro trasformazioni ha una matrice n × (n + d) e un bias di n, quindi in totale 4·n·(n + d + 1) parametri (PyTorch tiene due vettori di bias, bias_ih e bias_hh, che si sommano, per un totale di 4·n·(n + d + 2)). È quattro volte una RNN semplice di pari dimensione: il primo costo da mettere in conto.

La cella LSTM: il nastro dello stato di cella c in alto attraversa il tempo; il forget gate f ne cancella una parte, l'input gate i e il candidato g ne aggiungono, l'output gate o decide cosa mostrare come h LSTM cell: the cell state c is a belt that runs through time, three gates control it c(t-1) c(t) cell state (memory) σ f (forget) σ i (input) tanh g (candidate) σ o (output) h(t-1) x(t) × + × tanh × h(t) h(t) also feeds the next step × element-wise product + sum c(t) = f × c(t-1) + i × g h(t) = o × tanh( c(t) )
La cella LSTM: il nastro dello stato di cella c in alto attraversa il tempo; il forget gate f ne cancella una parte, l'input gate i e il candidato g ne aggiungono, l'output gate o decide cosa mostrare come h

Come leggere il diagramma. La linea verde spessa in alto è lo stato di cella: entra a sinistra come c(t−1) ed esce a destra come c(t). Passa attraverso soltanto due operazioni: un prodotto (×, il forget gate che ne cancella una frazione) e una somma (+, che aggiunge il nuovo contenuto). In basso, la riga orizzontale rappresenta il vettore [h(t−1), x(t)], cioè la vecchia uscita e il nuovo ingresso, che alimenta quattro piccole reti: tre sigmoidi (f, i, o, le porte) e una tanh (g, il contenuto candidato). La porta i e il candidato g si moltiplicano fra loro e il risultato viene sommato al nastro. Infine una copia del nastro passa per una tanh e viene moltiplicata per o: è l'uscita h(t). Nota cosa manca: sul nastro non c'è nessuna matrice dei pesi. Il gradiente che scorre lungo la linea verde incontra solo prodotti per numeri vicini a 1 e somme.

Perché il gradiente sopravvive

Deriviamo la derivata dello stato di cella rispetto a quello del passo prima. Dalla riga di aggiornamento:

c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t
∂c_t/∂c_{t-1} = diag(f_t)  +  (termini che passano per h_{t-1} dentro le porte)

Il termine dominante è una matrice diagonale con i valori del forget gate. Non c'è nessuna W_hh moltiplicata e nessuna derivata di tanh che schiaccia. Se la rete impara a tenere f ≈ 1 per una casella di memoria, il gradiente attraverso quella casella è conservato per tanti passi. Il prodotto lungo la sequenza diventa:

∂c_T/∂c_t ≈ Π_{j=t+1..T} diag(f_j)

Con f = 0.99 a ogni passo, dopo 100 passi il fattore è 0.99¹⁰⁰ ≈ 0.37: il segnale è ridotto ma vivo. Con f = 0.9 è 0.9¹⁰⁰ ≈ 2.7·10⁻⁵: morto. La differenza fra le due situazioni è piccola nei parametri e enorme nel risultato, ed è per questo che l'inizializzazione del bias del forget gate conta.

Questo meccanismo Hochreiter e Schmidhuber lo chiamarono constant error carousel (carosello a errore costante). Una precisazione storica utile: la LSTM originale del 1997 non aveva il forget gate, era come se valesse sempre f = 1. Il forget gate fu aggiunto da Gers, Schmidhuber e Cummins nel 2000, perché senza di esso la memoria cresce senza limite su sequenze lunghe; la cella che si usa oggi in PyTorch e Keras è questa versione.

Un passo di LSTM con i numeri in mano

Prima di scrivere la cella completa, guardiamo cosa fa il suo cuore, l'aggiornamento c = f·c_prev + i·g, con una sola casella di memoria e le porte date direttamente. Sul nastro c'è il valore 2.0, e proviamo tre scenari.

import numpy as np

def lstm_update(c_prev, f, i, g, o):
    """One LSTM update with the gate values given directly (a single memory slot)."""
    c = f * c_prev + i * g
    h = o * np.tanh(c)
    return c, h

c_prev = 2.0                      # the belt currently holds the value 2.0
cases = {
    "keep everything, write nothing":   dict(f=1.0, i=0.0, g=0.8, o=1.0),
    "keep 90%, write a bit of 0.8":     dict(f=0.9, i=0.5, g=0.8, o=0.7),
    "erase, then write 0.8":            dict(f=0.0, i=1.0, g=0.8, o=1.0),
}
for name, gates in cases.items():
    c, h = lstm_update(c_prev, **gates)
    print(f"{name:34s} c={c:.3f}  h={h:.3f}")
keep everything, write nothing     c=2.000  h=0.964
keep 90%, write a bit of 0.8       c=2.200  h=0.683
erase, then write 0.8              c=0.800  h=0.664

Primo caso: f = 1 e i = 0. La memoria non cambia di una virgola (2.000), indipendentemente da quanto sia lungo l'intervallo. Questa è la situazione che dà al gradiente un percorso libero. Secondo caso: tiene il 90% (1.8) e scrive metà dello 0.8 candidato (0.4): 1.8 + 0.4 = 2.2. Terzo caso: f = 0 svuota il nastro, i = 1 scrive tutto il candidato: resta 0.8, senza traccia del 2.0 di prima. Osserva anche h: nel primo caso è 0.964 = tanh(2.0); nel secondo è 0.7·tanh(2.2) = 0.683, perché la porta di uscita ne mostra solo il 70%, mentre la memoria c resta 2.2. È la separazione fra «ciò che ricordo» e «ciò che dico».

Codice a mano: la cella in NumPy

Implementiamo la cella con una sola matrice grande per le quattro porte, come fanno le librerie, e inizializziamo il bias del forget gate a 1. Il motivo (raccomandato per esempio da Jozefowicz, Zaremba e Sutskever nel 2015) è partire da una cella che, di default, ricorda.

import numpy as np

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

class LSTMCell:
    def __init__(self, d, n, seed=0):
        rng = np.random.default_rng(seed)
        self.n = n
        # One big matrix for the 4 gates, acting on the concatenation [h_prev, x_t]
        self.W = rng.normal(0, 0.3, (4 * n, n + d))
        self.b = np.zeros(4 * n)
        self.b[n:2 * n] = 1.0              # forget-gate bias = 1: start by "remembering"

    def step(self, x, h, c):
        n = self.n
        z = self.W @ np.concatenate([h, x]) + self.b
        i = sigmoid(z[0:n])                # input gate: how much new content to write
        f = sigmoid(z[n:2 * n])            # forget gate: how much old memory to keep
        g = np.tanh(z[2 * n:3 * n])        # candidate content
        o = sigmoid(z[3 * n:4 * n])        # output gate: how much memory to expose
        c_new = f * c + i * g              # the memory update: additive, not a squashing
        h_new = o * np.tanh(c_new)
        return h_new, c_new

cell = LSTMCell(d=3, n=4)
rng = np.random.default_rng(1)
seq = rng.normal(size=(6, 3))
h, c = np.zeros(4), np.zeros(4)
for x in seq:
    h, c = cell.step(x, h, c)
print(h.round(3), c.round(3))

Punti chiave del codice. self.W = rng.normal(0, 0.3, (4*n, n+d)) è una sola matrice grande che contiene i pesi delle quattro trasformazioni impilate (i, f, g, o): calcolare tutto con un solo prodotto matrice-vettore è molto più efficiente che farne quattro, ed è come fanno le librerie. z = self.W @ np.concatenate([h, x]) + self.b applica quella matrice a [h, x]; poi z[0:n], z[n:2*n], … sono i pezzi destinati alle singole porte. La riga self.b[n:2*n] = 1.0 imposta il bias del forget gate a 1: σ(1) ≈ 0.73, quindi all'inizio la cella tende a ricordare invece che dimenticare. c_new = f * c + i * g è l'aggiornamento additivo; h_new = o * np.tanh(c_new) è l'uscita. Il ciclo finale legge sei elementi tenendo le due memorie h e c.

Poi con la libreria: stessa funzione, copiando i pesi

Per essere certi che le equazioni siano quelle giuste, copiamo i pesi della nostra cella in nn.LSTM. Attenzione all'ordine con cui PyTorch impila le porte: input, forget, cell (g), output. Il nostro vettore concatenato [h, x] va diviso in due: weight_hh agisce su h, weight_ih su x.

import torch
import torch.nn as nn

lstm = nn.LSTM(input_size=3, hidden_size=4, batch_first=True)
n = 4
# PyTorch stacks the gates in the order: input, forget, cell(g), output.
# Our matrix is [i; f; g; o] acting on [h, x]; PyTorch splits it into W_ih (acts on x) and W_hh (acts on h).
with torch.no_grad():
    lstm.weight_hh_l0.copy_(torch.tensor(cell.W[:, :n], dtype=torch.float32))
    lstm.weight_ih_l0.copy_(torch.tensor(cell.W[:, n:], dtype=torch.float32))
    lstm.bias_ih_l0.copy_(torch.tensor(cell.b, dtype=torch.float32))
    lstm.bias_hh_l0.zero_()

out, (h_n, c_n) = lstm(torch.tensor(seq, dtype=torch.float32).unsqueeze(0))
print(np.abs(h_n[0, 0].detach().numpy() - h).max(), np.abs(c_n[0, 0].detach().numpy() - c).max())
print(sum(p.numel() for p in lstm.parameters()))     # 4*n*(n+d) + 2*4*n = 4*4*7 + 32 = 144

Il blocco crea nn.LSTM(3, 4) e vi copia i pesi. cell.W[:, :n] è la parte della matrice che moltiplica h (le prime n colonne, perché abbiamo concatenato [h, x]) e va in weight_hh_l0; cell.W[:, n:] moltiplica x e va in weight_ih_l0. Il bias del forget gate (b[n:2n] = 1) è copiato in bias_ih_l0, e bias_hh_l0 viene azzerato per non contarlo due volte. Alla fine si confrontano h_n e c_n con i nostri: entrambi coincidono. Il conteggio dei parametri chiude il cerchio: 4·4·(4+3) = 112 pesi più 2·4·4 = 32 bias = 144.

Differenza massima dell'ordine di 3·10⁻⁸ su h e su c (precisione float32 contro float64), e 144 parametri, cioè 4·4·7 + 2·4·4: la nostra implementazione e la libreria coincidono, e il conteggio conferma la formula.

Misurare il flusso del gradiente

Mettiamo alla prova l'argomento sul gradiente. Misuriamo quanto il primo ingresso di una sequenza riesce ancora a influenzare l'ultimo stato: la norma di ∂(Σ h_T)/∂x_0, per RNN, LSTM con inizializzazione standard di PyTorch e LSTM con bias del forget gate posto a 1.

def grad_to_first_input(module, T, seed=0, forget_bias=None):
    """Norm of d(sum h_T)/d x_0: how much the first input can still influence the last state."""
    torch.manual_seed(seed)
    m = module()
    if forget_bias is not None:                      # LSTM bias layout: [i | f | g | o]
        with torch.no_grad():
            m.bias_ih_l0[m.hidden_size:2 * m.hidden_size] = forget_bias
    x = torch.randn(1, T, 8, requires_grad=True)
    out, _ = m(x)
    out[0, -1].sum().backward()
    return x.grad[0, 0].norm().item()

for T in (10, 50, 100):
    r = grad_to_first_input(lambda: nn.RNN(8, 32, batch_first=True), T)
    l0 = grad_to_first_input(lambda: nn.LSTM(8, 32, batch_first=True), T)
    l1 = grad_to_first_input(lambda: nn.LSTM(8, 32, batch_first=True), T, forget_bias=1.0)
    print(f"T={T:3d}  RNN {r:.1e}   LSTM {l0:.1e}   LSTM(forget bias=1) {l1:.1e}")

La funzione costruisce un modulo, opzionalmente imposta il bias del forget gate (nel layout di PyTorch il vettore dei bias è [i | f | g | o], quindi la fetta [hidden : 2*hidden] è proprio f), poi genera una sequenza casuale di lunghezza T con requires_grad=True e chiede a PyTorch il gradiente della somma dell'ultimo stato rispetto a x. x.grad[0, 0] è il gradiente rispetto al primo ingresso: misura quanto quel primo elemento riesce ancora a influenzare la fine. Usiamo .norm() per ridurre il vettore a un numero. Tutto avviene senza addestramento: misuriamo la struttura, non ciò che la rete ha imparato.

T= 10  RNN 2.9e-03   LSTM 2.2e-03   LSTM(forget bias=1) 3.0e-02
T= 50  RNN 2.9e-14   LSTM 1.6e-11   LSTM(forget bias=1) 9.8e-06
T=100  RNN 0.0e+00   LSTM 1.1e-21   LSTM(forget bias=1) 1.6e-09

Il risultato non dice «la LSTM risolve tutto», e va letto con tre cautele. Alla inizializzazione standard anche la LSTM perde gradiente con la distanza, solo più lentamente della RNN. Con il bias del forget gate a 1 il segnale a lunga distanza resta molto più alto che con la LSTM standard, piccolo in assoluto ma più raggiungibile dall'ottimizzatore. Infine questo è un esperimento a un seme su una rete non addestrata: dice che la struttura apre una strada, non che l'addestramento la percorra, e lo verificheremo su un compito di memoria nell'articolo successivo.

Cancello per cancello: perché ognuno esiste

La LSTM ha tre porte e un candidato. È legittimo chiedersi se servano tutti. Ecco il motivo per cui ognuno è stato introdotto, ragionando su cosa succederebbe senza di lui.

Il candidato g (tanh): proporre contenuto nuovo

È la «bozza» di ciò che la cella potrebbe scrivere: una trasformazione dell'ingresso e dello stato precedente, in (−1, 1). Senza di lui non ci sarebbe nulla da aggiungere alla memoria. La scelta della tanh (e non della sigmoide) è importante: il candidato deve poter essere negativo oltre che positivo, altrimenti la memoria potrebbe solo crescere.

La porta di input i: proteggere la memoria dal rumore

Se ogni ingresso scrivesse nella memoria con piena forza, ogni elemento irrilevante (nell'addizione, tutti i valori non marcati) sovrascriverebbe il contenuto prezioso. La porta di input dice «questo passo è rumore, scrivi 0». Hochreiter e Schmidhuber la motivarono con il problema del conflitto dei pesi di input: lo stesso peso deve talvolta far scrivere nella memoria e talvolta no, e senza una porta che separi le due situazioni l'addestramento è costretto a un compromesso.

La porta di output o: proteggere il resto della rete dalla memoria

La memoria c può contenere un valore che serve solo fra molti passi. La porta di output permette di non mostrarlo (h ≈ 0) finché non è il momento, così gli altri strati non devono continuare a gestire un segnale irrilevante. È il corrispettivo, in uscita, di quello che la porta di input fa in ingresso (conflitto dei pesi di output).

La porta di forget f: poter svuotare il nastro

Nella versione del 1997 la memoria poteva solo accumulare. Su sequenze lunghe o continue, senza alcuna possibilità di azzerarla, i valori di c crescono senza limite e la tanh dell'uscita satura (Gers, Schmidhuber e Cummins, 2000). Il forget gate permette di «resettare» una casella quando comincia un nuovo contesto (una nuova frase, un nuovo documento). Costa una trasformazione in più, ed è la porta con più influenza sul comportamento a lungo termine.

Una prova sul campo: spegniamo un cancello alla volta

Per vedere se i cancelli «servono» davvero, scriviamo la LSTM a mano in PyTorch con la possibilità di forzare una porta a 1 (cioè di eliminarla: f = 1 significa memoria che non dimentica mai, i = 1 che scrive sempre, o = 1 che mostra sempre). Ripetiamo il problema dell'addizione a T = 30. Una cella scritta in Python è molto più lenta di nn.LSTM, ma è l'unico modo di ispezionare dentro.

Due scelte di metodo, prima del codice. La prima: ogni variante è addestrata su 5 semi (0-4) e per ciascuna riportiamo media, minimo e massimo dell'errore. L'addestramento parte da pesi casuali e pesca batch casuali, quindi due corse della stessa rete danno errori diversi: con una corsa sola per variante, la differenza fra le varianti si confonde con il rumore fra le corse. La seconda: torch.set_num_threads(2), fissato apposta per la riproducibilità. Il numero di thread cambia l'ordine delle somme in virgola mobile, e le somme in virgola mobile non sono associative: con un numero di thread diverso gli stessi semi producono numeri diversi. Il programma qui sotto è stato eseguito due volte con uscita identica; su un'altra macchina, o con un altro numero di thread, le cifre esatte possono cambiare, e le conclusioni devono reggere lo stesso.

import numpy as np
import torch
import torch.nn as nn

torch.set_num_threads(2)    # reproducibility: float sums depend on the thread count

class AblatableLSTM(nn.Module):
    """LSTM written by hand so that any single gate can be switched off (forced to 1)."""
    def __init__(self, d, n, drop=None):
        super().__init__()
        self.n, self.drop = n, drop              # drop in {None, "forget", "input", "output"}
        self.lin = nn.Linear(d + n, 4 * n)
        with torch.no_grad():
            self.lin.bias[n:2 * n] = 1.0         # forget bias = 1 for everybody
    def forward(self, x):
        B, T, _ = x.shape
        h = x.new_zeros(B, self.n); c = x.new_zeros(B, self.n)
        for t in range(T):
            z = self.lin(torch.cat([h, x[:, t]], dim=-1))
            i, f, g, o = z.chunk(4, dim=-1)
            i, f, o, g = torch.sigmoid(i), torch.sigmoid(f), torch.sigmoid(o), torch.tanh(g)
            if self.drop == "forget": f = torch.ones_like(f)     # never forgets
            if self.drop == "input":  i = torch.ones_like(i)     # always writes
            if self.drop == "output": o = torch.ones_like(o)     # always shows
            c = f * c + i * g
            h = o * torch.tanh(c)
        return h                                  # final hidden state

class AddingNet(nn.Module):
    def __init__(self, drop=None, n=32):
        super().__init__()
        self.cell = AblatableLSTM(2, n, drop)
        self.head = nn.Linear(n, 1)
    def forward(self, x):
        return self.head(self.cell(x)).squeeze(-1)

def adding_batch(batch, T):
    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)

def reset_batch(batch, T=30, p_reset=0.15):
    """Running sum that must be CLEARED at every reset marker.
    Target: sum of the values that come strictly after the last reset (scaled by 1/5)."""
    values = torch.rand(batch, T)
    resets = (torch.rand(batch, T) < p_reset).float()
    resets[:, 0] = 1.0                                   # at least one reset at the start
    y = torch.zeros(batch)
    for b in range(batch):
        last = int(resets[b].nonzero().max())
        y[b] = values[b, last + 1:].sum()
    return torch.stack([values, resets], dim=-1), y / 5.0

def run(make_batch, drop, steps=2500, seed=0):
    torch.manual_seed(seed)
    net = AddingNet(drop)
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    for _ in range(steps):
        x, y = make_batch(64)
        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 = make_batch(1000)
        return nn.functional.mse_loss(net(x), y).item()

def table(make_batch, seeds=range(5)):
    for drop in (None, "forget", "input", "output"):
        errs = np.array([run(make_batch, drop, seed=s) for s in seeds])
        print(f"gate removed: {str(drop):7s} mean {errs.mean():.4f}  min {errs.min():.4f}  max {errs.max():.4f}  "
              f"per seed {np.round(errs, 4).tolist()}")

table(lambda batch: adding_batch(batch, 30))
print("baseline (always predict the mean): 0.1667")
gate removed: None    mean 0.0004  min 0.0002  max 0.0010  per seed [0.0002, 0.001, 0.0003, 0.0002, 0.0002]
gate removed: forget  mean 0.0006  min 0.0003  max 0.0008  per seed [0.0007, 0.0008, 0.0004, 0.0003, 0.0006]
gate removed: input   mean 0.0015  min 0.0010  max 0.0022  per seed [0.001, 0.001, 0.002, 0.0014, 0.0022]
gate removed: output  mean 0.0005  min 0.0001  max 0.0009  per seed [0.0004, 0.0004, 0.0009, 0.0001, 0.0005]
baseline (always predict the mean): 0.1667

Spiegazione. AblatableLSTM calcola le quattro trasformazioni con un unico nn.Linear(d+n, 4n) e le separa con chunk(4); poi, a seconda di drop, sostituisce una porta con un tensore di uni. Il forward restituisce solo lo stato nascosto finale e AddingNet ci appoggia una testa lineare. run addestra una rete per 2500 passi con un seme dato; table la ripete sui 5 semi e stampa media, minimo, massimo e i cinque valori. Che cosa dicono i numeri: tutte e quattro le varianti risolvono il compito su tutti i semi (l'errore peggiore è 0.0022, contro la baseline 0.1667 di chi predice sempre la media). Fra le varianti, però, quasi nulla si separa. Forget e output hanno intervalli (0.0003-0.0008 e 0.0001-0.0009) che stanno dentro quello della cella completa (0.0002-0.0010): non sono distinguibili. La porta di input ha l'errore medio più alto (0.0015 contro 0.0004) e nessun suo seme scende sotto 0.0010, ma un seme della cella completa arriva proprio a 0.0010: gli intervalli si toccano. È un indizio che togliere l'input costi qualcosa, non una differenza netta, e con cinque semi non la affermo.

Perché l'esperimento non distingue le varianti. Il compito è facile: tutti gli errori sono da cento a mille volte sotto la baseline, e le differenze sono di millesimi. La rete è sovradimensionata: 32 unità per sommare due numeri sono abbondanti, e una rete abbondante trova strade alternative quando le si toglie un cancello. E l'addizione non chiede mai di dimenticare, quindi un forget gate fisso a 1 funziona (è la LSTM del 1997), mentre la porta di output è ridondante con una testa lineare che già sa scegliere cosa leggere. Per vedere una differenza servirebbero un compito più duro (sequenze molto più lunghe), una rete più piccola, meno passi di addestramento o molti più semi; io qui non ho provato sequenze più lunghe, e non posso dire che cosa mostrerebbero.

Per far servire il forget gate serve un compito che chieda di svuotare la memoria. Ne costruiamo uno: una somma corrente con azzeramento. La rete riceve valori casuali e un marcatore di reset; il bersaglio è la somma dei valori dopo l'ultimo reset. Si riusano le classi e la funzione table del blocco precedente.

def reset_batch(batch, T=30, p_reset=0.15):
    """Running sum that must be CLEARED at every reset marker.
    Target: sum of the values that come strictly after the last reset (scaled by 1/5)."""
    values = torch.rand(batch, T)
    resets = (torch.rand(batch, T) < p_reset).float()
    resets[:, 0] = 1.0                                   # at least one reset at the start
    y = torch.zeros(batch)
    for b in range(batch):
        last = int(resets[b].nonzero().max())
        y[b] = values[b, last + 1:].sum()
    return torch.stack([values, resets], dim=-1), y / 5.0

table(lambda batch: reset_batch(batch, 30))
torch.manual_seed(0)
print(f"variance of the target = {reset_batch(2000)[1].var().item():.4f}")
gate removed: None    mean 0.0004  min 0.0001  max 0.0007  per seed [0.0005, 0.0001, 0.0007, 0.0007, 0.0002]
gate removed: forget  mean 0.0014  min 0.0009  max 0.0021  per seed [0.0013, 0.0021, 0.0013, 0.0009, 0.0012]
gate removed: input   mean 0.0003  min 0.0001  max 0.0004  per seed [0.0003, 0.0002, 0.0001, 0.0003, 0.0004]
gate removed: output  mean 0.0003  min 0.0001  max 0.0006  per seed [0.0006, 0.0002, 0.0001, 0.0002, 0.0003]
variance of the target = 0.3687

Qui il quadro è diverso, ed è netto. Senza forget gate ogni seme sta fra 0.0009 e 0.0021, mentre tutte le altre varianti restano sotto 0.0007 (cella completa 0.0001-0.0007, senza input 0.0001-0.0004, senza output 0.0001-0.0006): gli intervalli sono disgiunti, e l'errore medio senza forget (0.0014) è più alto di quello delle altre tre (0.0003-0.0004). Questa differenza regge su tutti e cinque i semi, ed è coerente con l'idea che il forget gate serva per azzerare. Senza input e senza output, invece, le medie (0.0003) sono appena sotto quella della cella completa (0.0004), ma gli intervalli si sovrappongono: non posso dire che togliere quei cancelli aiuti o sia indifferente, solo che questo esperimento non li distingue. E anche l'effetto del forget è piccolo in assoluto (la varianza del bersaglio è 0.3687): una cella con 32 unità trova comunque il modo di compensare, per esempio scrivendo valori che cancellano il contenuto precedente tramite il candidato.

La lezione è doppia. Sul piano del contenuto: le porte danno un vantaggio misurabile ma modesto su compiti piccoli e facili, e l'unico effetto che i dati sostengono con chiarezza è quello del forget gate nel compito che chiede di dimenticare; il quadro è coerente con lo studio sistematico di Greff e colleghi (2017, «LSTM: A Search Space Odyssey»), che confrontano molte varianti della cella e concludono che il forget gate e la funzione di attivazione dell'uscita sono le componenti più critiche, mentre nessuna delle varianti proposte migliora in modo significativo la LSTM standard.

Sul piano del metodo: una corsa sola non basta, perché l'addestramento è casuale e la differenza fra due varianti va confrontata con la variazione fra semi della stessa variante; e il numero di thread cambia i risultati in virgola mobile, per cui va fissato e dichiarato. Nella prima stesura di questo articolo un seme e i thread predefiniti davano una classifica («l'input costa di più», «senza forget l'errore triplica») che, con 2 thread, si rovesciava: era rumore, non un risultato.

Trade-off

  • Memoria lunga a pagamento. Quattro volte i parametri e circa quattro volte i calcoli per passo rispetto alla RNN semplice.
  • Ancora sequenziale. Il passo t aspetta il passo t−1: nessuna parallelizzazione nel tempo.
  • Capacità limitata a n numeri. Una LSTM non ha un indirizzamento: tutto il passato resta compresso in c.
  • Più iperparametri che incidono: dimensione dello stato, bias del forget gate, dropout fra strati.

Dopo l'elenco dei compromessi, la valutazione che conta è questa: la LSTM compra memoria a lunga distanza pagando in parametri e in tempo di calcolo, e non cambia la natura sequenziale della RNN. Se la tua dipendenza è corta, la RNN semplice o la GRU costano meno; se è lunga, la LSTM è il primo strumento da provare prima di passare ad architetture più pesanti.

Trappole reali

La prima è l'ordine delle porte sbagliato quando si copiano pesi fra framework (in PyTorch: i, f, g, o): il codice non dà errore, produce solo risultati sbagliati. La seconda è dimenticare che esistono due stati: nn.LSTM restituisce (h_n, c_n) e per riprendere una sequenza servono entrambi, perché passare solo h vuota la memoria. La terza è usare h come se fosse la memoria: h è ciò che l'output gate decide di mostrare e può essere quasi zero mentre c conserva informazione. La quarta è pensare che la LSTM elimini il clipping: può ancora esplodere, e il clipping resta una buona abitudine.

Verificati con quattro conti

Una LSTM con input 10 e stato 20 ha, con i due vettori di bias di PyTorch, 4·20·(20+10+2) = 2560 parametri. Se il forget gate vale sempre 1 e il gate di input sempre 0 si ha c_t = c_{t−1}: lo stato di cella resta costante per sempre, una memoria congelata. h può essere vicino a zero mentre c contiene informazione perché h = o ⊙ tanh(c), e con la porta di output chiusa h è piccolo anche se c è grande. Con f = 0.98 costante un'informazione scritta si riduce a metà quando 0.98^k = 0.5, cioè k = ln 0.5 / ln 0.98 ≈ 34 passi: un'«emivita» di circa 34 passi.

Un passo completo di LSTM, con ogni numero in vista

Facciamo un solo passo di una cella con una unità, così ogni gate è un numero. Stato nascosto precedente h = 0.5, memoria precedente c = 2.0, ingresso x = 1.0. I pesi (ognuno applicato a h e a x, più un bias) sono: forget (0.4, 0.6, bias 1.0), input (0.3, −0.2, 0), candidato (0.5, 0.8, 0), output (0.1, 0.7, 0).

1. Le quattro combinazioni lineari (peso su h × 0.5 + peso su x × 1.0 + bias):

z_f = 0.4·0.5 + 0.6·1.0 + 1.0 = 1.80
z_i = 0.3·0.5 − 0.2·1.0 + 0   = −0.05
z_g = 0.5·0.5 + 0.8·1.0 + 0   = 1.05
z_o = 0.1·0.5 + 0.7·1.0 + 0   = 0.75

2. Le attivazioni. Le tre porte passano per la sigmoide, il candidato per la tanh:

f = σ(1.80)  = 0.858      (tengo l'85.8% della vecchia memoria)
i = σ(−0.05) = 0.488      (scrivo meno della metà)
g = tanh(1.05) = 0.782    (contenuto candidato)
o = σ(0.75)  = 0.679      (mostro il 67.9%)

3. L'aggiornamento della memoria. c_nuovo = f·c + i·g = 0.858·2.0 + 0.488·0.782 = 1.716 + 0.381 = 2.097. La vecchia memoria (2.0) è stata ridotta a 1.716 e vi si è sommato un contributo nuovo di 0.381.

4. L'uscita. h_nuovo = o · tanh(c_nuovo) = 0.679 · tanh(2.097) = 0.679 · 0.970 = 0.659.

Nota tre cose. La memoria è cambiata di poco (da 2.0 a 2.097) perché f è alto e i è basso: la cella è in modalità «conserva». L'uscita 0.659 è molto più piccola della memoria (2.097): è il risultato di due compressioni, la tanh e la porta o, e mostra che h e c sono due numeri diversi. E se il contributo i·g fosse stato negativo avrebbe sottratto dalla memoria: il candidato in (−1, 1) permette sia di aggiungere sia di togliere.

Domande frequenti

Perché le porte usano la sigmoide e non la tanh? Perché una porta è una percentuale (quanto lasciar passare) e deve stare fra 0 e 1. La tanh darebbe valori negativi, che invertirebbero il segno del segnale invece di attenuarlo.

Perché il candidato usa la tanh? Perché il contenuto da scrivere deve poter essere sia positivo sia negativo, e limitato in (−1, 1) per non far esplodere la memoria a ogni passo.

La LSTM «ricorda» davvero per sempre? No. Con f = 0.98 costante, l'informazione ha un'emivita di circa 34 passi (0.98^34 ≈ 0.5). Per ricordare più a lungo f deve avvicinarsi a 1, e deve restarci: ecco perché il bias iniziale conta.

Perché due stati (h e c) e non uno? Perché «cosa ricordo» e «cosa dico» sono compiti diversi: la memoria può contenere un valore importante che per ora non serve (porta di output chiusa). La GRU rinuncia a questa separazione per risparmiare parametri (vedi la GRU).

Quante unità devo usare? Parti da 32-128 e misura: abbiamo visto che 8 sono troppe poche per il problema dell'addizione e 32 bastano; 128 non portano vantaggio. Più unità = più parametri (crescita quadratica con la dimensione dello stato) e più rischio di overfitting.

Si può addestrare una LSTM con una GPU? Sì, ma il calcolo nel tempo resta sequenziale: la GPU accelera i prodotti di matrici dentro ogni passo e il parallelismo sul batch, non i passi stessi. Per questo i modelli ad attenzione, che elaborano tutta la sequenza insieme, sono più veloci su sequenze lunghe.

Che cosa sai fare adesso

Sai scrivere le sei equazioni della cella LSTM, spiegare perché l'aggiornamento additivo dello stato di cella dà al gradiente un percorso con fattore vicino a 1, e seguire un passo completo con ogni numero in vista. Sai implementare la cella in NumPy, copiarne i pesi in nn.LSTM rispettando l'ordine delle porte e misurare il flusso del gradiente, sapendo che la struttura apre la strada ma non la percorre da sola. Il passo dopo è metterla alla prova su compiti di memoria in LSTM alla prova: il problema dell'addizione; per ripartire dalle basi, BPTT e gradient clipping.

Nella serie, la lezione precedente è RNN su una serie temporale: finestre, baseline e previsione a più passi.

Fonti

  • PyTorch: sorgente di torch/nn/modules/rnn.py - l'impilamento delle porte (input, forget, cell, output), i due vettori di bias bias_ih e bias_hh e le uscite h_n, c_n, consultata il 2026-11-12
  • K. Greff et al., LSTM: A Search Space Odyssey (arXiv:1503.04069) - nessuna variante migliora significativamente la LSTM standard; forget gate e funzione di attivazione dell'uscita sono i componenti più critici; letto l'abstract il 2026-11-12
  • S. Hochreiter, J. Schmidhuber, Long Short-Term Memory, Neural Computation 9(8), 1997; F. A. Gers, J. Schmidhuber, F. Cummins, Learning to forget: continual prediction with LSTM, Neural Computation 12(10), 2000; R. Jozefowicz, W. Zaremba, I. Sutskever, An empirical exploration of recurrent network architectures, ICML 2015 - la cella originale, l'aggiunta del forget gate e il bias del forget gate a 1 (citazioni bibliografiche, non consultate 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