Salta al contenuto principale

56 - Reti neurali ricorrenti (RNN): dare una memoria alla rete

Una RNN legge una sequenza con uno stato nascosto e pesi condivisi. La costruiamo in NumPy, ne contiamo i parametri e la addestriamo con gradienti a mano.

Cosa ne pensi di questo articolo?

56 - Reti neurali ricorrenti (RNN): dare una memoria alla rete
Machine Learning Engineerfedericocalo.dev

In Breve

Una RNN legge una sequenza con uno stato nascosto e pesi condivisi. La costruiamo in NumPy, ne contiamo i parametri e la addestriamo con gradienti a mano.

Una rete neurale ricorrente (RNN) è una rete che legge una sequenza un elemento alla volta e porta con sé un piccolo vettore, lo stato nascosto, che riassume ciò che ha letto finora. A ogni passo combina l'elemento nuovo con lo stato precedente e produce lo stato successivo, usando sempre gli stessi pesi. Per questo una RNN gestisce sequenze di qualsiasi lunghezza con un numero fisso di parametri, e per questo è il punto di partenza di questo modulo: LSTM, GRU e reti bidirezionali sono varianti della stessa idea. In questo articolo la costruiamo con NumPy, dalla singola moltiplicazione fino a un addestramento completo con gradienti scritti a mano, e controlliamo ogni numero eseguendo il codice.

Il vocabolario che serve

Una sequenza è una lista ordinata in cui l'ordine conta (le parole di una frase, le misure di un sensore, le note di un brano); ogni elemento è un passo temporale con indice t, da t = 1 fino a t = T, e il «tempo» può essere tempo vero oppure solo la posizione nella lista. Ogni elemento, prima di entrare nella rete, diventa un vettore di lunghezza fissa d, e la rete è fatta di pesi, numeri che impara durante l'addestramento e che stanno in matrici che trasformano un vettore in un altro. Lo stato nascosto h è il vettore di lunghezza n che la rete aggiorna a ogni passo: è la sua memoria di lavoro, «nascosta» perché non è né l'ingresso né l'uscita e nessuno l'ha etichettata, e n è un iperparametro che scegli tu.

A ogni passo i numeri dello stato passano per la tanh, una funzione non lineare che schiaccia qualunque valore fra −1 e 1; infine un batch è un gruppo di sequenze elaborate insieme, cioè soltanto una dimensione in più nei tensori.

Il problema: una rete feedforward non ha il concetto di «prima» e «dopo»

Una rete densa riceve un input di forma fissa e lo trasforma in un output. Se le dai una frase di 5 parole e poi una di 12, il primo strato non sa che farsene: si aspetta un numero preciso di ingressi. Puoi forzare tutto a una lunghezza fissa con il padding, ma restano due difetti. Il primo è che l'ordine non è nella struttura: «il cane morde l'uomo» e «l'uomo morde il cane» diventano due vettori diversi senza che la rete sappia che le parole sono le stesse in posizioni diverse.

Il secondo è che non c'è condivisione: il significato di «non» davanti a un aggettivo è lo stesso alla posizione 2 e alla posizione 40, ma con pesi separati per ogni posizione la rete deve imparare la stessa regola quaranta volte. La convoluzione risolve la condivisione ma guarda soltanto una finestra locale; una RNN, invece, trasporta l'informazione lungo tutta la sequenza.

Quanto costa non condividere i pesi: un conto concreto

Mettiamo dei numeri sull'idea «impara la stessa cosa quaranta volte». Prendi una sequenza di T = 100 passi con d = 50 numeri per passo (ad esempio 100 parole, ognuna descritta da 50 numeri) e uno strato di n = 128 unità. Una rete densa deve ricevere la sequenza «stirata» in un solo vettore di 100 × 50 = 5000 numeri, e ogni unità ha un peso per ciascuno. Una RNN legge un passo alla volta e riusa le stesse due matrici. Il listato conta i parametri nei due casi con l'aritmetica delle forme (nessuna libreria di deep learning serve per contare) e poi esegue una RNN scritta in NumPy su tre lunghezze diverse, con gli stessi pesi, per verificare che lo stato finale abbia sempre la stessa forma.

import numpy as np

T, d, n = 100, 50, 128                    # 100 steps, 50 features per step, 128 hidden units

dense_params = (T * d) * n + n            # dense layer fed with the whole flattened sequence
rnn_params = n * d + n * n + n            # W_xh, W_hh, b_h of one recurrent layer
print("dense:", dense_params)
print("rnn  :", rnn_params)
print("ratio:", round(dense_params / rnn_params, 1))

rng = np.random.default_rng(0)
W_xh = rng.normal(0, 0.1, (n, d))
W_hh = rng.normal(0, 0.1, (n, n))
b_h = np.zeros(n)

def run(sequence):
    h = np.zeros(n)
    states = []
    for x_t in sequence:
        h = np.tanh(W_xh @ x_t + W_hh @ h + b_h)
        states.append(h)
    return np.array(states), h

# The same weights read ANY length; the final state always has shape (n,).
for length in (5, 100, 1000):
    states, h_last = run(rng.normal(size=(length, d)))
    print(length, states.shape, h_last.shape)

# Output:
# dense: 640128
# rnn  : 22912
# ratio: 27.9
# 5 (5, 128) (128,)
# 100 (100, 128) (128,)
# 1000 (1000, 128) (128,)

Lo strato denso ha 640.128 parametri (5000 × 128 pesi più 128 bias), lo strato ricorrente 22.912 (128 × 50 pesi di ingresso, 128 × 128 pesi ricorrenti, 128 bias): circa 28 volte meno per lo stesso numero di unità.

Un dettaglio che spiega una differenza apparente: torch.nn.RNN tiene due vettori di bias separati, bias_ih e bias_hh, che il suo codice somma (lo puoi leggere nel sorgente di PyTorch), quindi lì il conto è 23.040; matematicamente basta un bias, e noi ne usiamo uno.

Meno parametri non è solo risparmio di memoria: con meno parametri a parità di dati si rischia meno l'overfitting, cioè imparare a memoria gli esempi invece di una regola generale. Le tre righe finali del risultato mostrano la proprietà che conta: con lunghezze 5, 100 e 1000 gli stati intermedi crescono con la sequenza, ma lo stato finale resta un vettore di 128 numeri. È questa forma costante che permette di attaccare una testa di dimensione fissa e classificare frasi di qualsiasi lunghezza. Il costo da pagare è un altro, e lo vedi nel ciclo di run: il passo t non può partire prima che finisca il passo t-1.

L'analogia: leggere un libro con un taccuino

Immagina di leggere un romanzo con un taccuino di una sola pagina. Dopo ogni frase fai la stessa operazione: guardi la frase nuova, guardi gli appunti attuali, e riscrivi la pagina. Non puoi tenere tutto, quindi decidi cosa conservare e cosa buttare. Il taccuino è lo stato nascosto h; le regole con cui riscrivi sono i pesi, e sono le stesse alla pagina 3 e alla pagina 300. Alla fine, per rispondere a «chi è l'assassino?», consulti solo il taccuino. L'analogia annuncia già il difetto principale: una pagina di dimensione fissa, riscritta di continuo, dimentica. Lo vedremo nell'articolo sulla backpropagation through time.

La matematica, passo per passo

Indichiamo con x_t l'elemento al tempo t (un vettore di dimensione d) e con h_t lo stato nascosto (dimensione n). Lo stato iniziale h_0 è il vettore nullo. La RNN «classica», detta di Elman dal lavoro del 1990, è:

h_t = tanh( W_xh · x_t + W_hh · h_{t-1} + b_h )
y_t = W_hy · h_t + b_y

x_t  : (d,)        W_xh : (n, d)
h_t  : (n,)        W_hh : (n, n)      b_h : (n,)
y_t  : (k,)        W_hy : (k, n)      b_y : (k,)

Le due righe in alto sono la regola; le forme in basso dicono quali dimensioni devono combaciare per poter moltiplicare.

Tre conseguenze contano più della formula. La prima riguarda la tanh: senza una non linearità l'intera sequenza sarebbe un prodotto di matrici e lo stato crescerebbe o morirebbe in modo esponenziale, mentre la tanh tiene ogni componente in (−1, 1). La seconda riguarda i pesi condivisi: W_xh, W_hh e b_h sono gli stessi a ogni passo, quindi i parametri dello strato sono n·d + n·n + n a prescindere dalla lunghezza; con d = 3 e n = 4 sono 12 + 16 + 4 = 32.

La terza è l'idea di srotolare la rete: se disegni il calcolo passo per passo ottieni una catena di copie identiche della stessa cella, collegate da h, cioè una rete profonda quanto la sequenza è lunga con i pesi condivisi fra gli strati. Questa immagine spiega come si addestra (backpropagation through time) e perché si rompe (gradienti che svaniscono o esplodono).

Una RNN srotolata nel tempo: la stessa cella, con gli stessi pesi, ripetuta a ogni passo; lo stato nascosto h passa da un passo al successivo Same cell, same weights W, repeated along time tanh cell x1 y1 tanh cell x2 y2 tanh cell x3 y3 tanh cell x4 y4 h0 = 0 h1 h2 h3 h4 h_t = tanh( W_xh x_t + W_hh h_(t-1) + b ) (same W_xh, W_hh, b at every step)
Una RNN srotolata nel tempo: la stessa cella, con gli stessi pesi, ripetuta a ogni passo; lo stato nascosto h passa da un passo al successivo

Come si legge il disegno. In basso entrano gli elementi x1 … x4, uno per passo. Ogni scatola è la stessa cella ripetuta, non quattro celle diverse: è il significato di «pesi condivisi». La freccia arancione orizzontale è lo stato nascosto h, che esce da un passo ed entra nel successivo, ed è l'unico canale con cui il passato raggiunge il presente. In alto esce, se serve, un output y per ogni passo; se ti interessa solo il verdetto finale (ad esempio «recensione positiva») usi soltanto l'ultima uscita.

Un passo completo con matrici vere (2 ingressi, 2 unità)

Per togliere ogni dubbio su cosa significhino le forme, eseguiamo due passi con d = 2 e n = 2. W_xh è 2×2 (due unità, due feature), W_hh è 2×2 (due unità, due unità di stato) e b ha due componenti. La variabile pre è la pre-attivazione, cioè la somma prima della tanh; stamparla permette di controllare a mano ogni passaggio.

import numpy as np

W_xh = np.array([[0.5, -0.3],
                 [0.2,  0.8]])
W_hh = np.array([[ 0.1, 0.4],
                 [-0.6, 0.3]])
b = np.array([0.1, -0.1])

h = np.zeros(2)
for t, x in enumerate([np.array([1.0, 2.0]), np.array([0.0, 1.0])], start=1):
    pre = W_xh @ x + W_hh @ h + b          # the "pre-activation" a_t
    h = np.tanh(pre)
    print(f"t={t}  W_xh@x={W_xh @ x}  pre-activation a={pre.round(4)}  h={h.round(4)}")

# Output:
# t=1  W_xh@x=[-0.1  1.8]  pre-activation a=[0.  1.7]  h=[0.     0.9354]
# t=2  W_xh@x=[-0.3  0.8]  pre-activation a=[0.1742 0.9806]  h=[0.1724 0.7533]

Verifichiamo il primo passo con la matita. Con x_1 = (1, 2), la prima componente di W_xh x_1 è 0.5·1 + (−0.3)·2 = −0.1; la seconda è 0.2·1 + 0.8·2 = 1.8. Lo stato iniziale è zero, quindi W_hh h_0 = (0, 0). Sommando il bias (0.1, −0.1) si ottiene a = (0.0, 1.7), e la tanh, che agisce componente per componente, dà tanh(0) = 0 e tanh(1.7) = 0.9354.

Al secondo passo, con x_2 = (0, 1), l'ingresso contribuisce con (−0.3, 0.8) e la memoria con W_hh h_1 = (0.1·0 + 0.4·0.9354, −0.6·0 + 0.3·0.9354) = (0.3742, 0.2806). Con il bias: (−0.3 + 0.3742 + 0.1, 0.8 + 0.2806 − 0.1) = (0.1742, 0.9806), e dopo la tanh h_2 = (0.1724, 0.7533). Osserva che la seconda unità ha ereditato dal passo 1 un contributo (0.2806) che dipende da un ingresso ormai sparito dalla vista: questa è la memoria in azione.

Che cosa si può chiedere a questa struttura

  • Many-to-one: leggi tutta la sequenza e usi solo l'ultimo stato, ad esempio per classificare il sentimento di una recensione.
  • Many-to-many sincrono: un output per ogni ingresso, ad esempio l'etichetta grammaticale di ogni parola o la previsione del valore successivo di una serie.
  • One-to-many: un solo ingresso, una sequenza in uscita, ad esempio una didascalia generata da un vettore che rappresenta un'immagine.
  • Many-to-many asincrono (encoder-decoder): leggi una sequenza e ne produci un'altra di lunghezza diversa, come nella traduzione automatica; richiede due reti collegate e non è l'oggetto di questa serie.

Le quattro forme differiscono solo per quali stati vengono letti e quali ingressi vengono forniti: la cella è la stessa. Per questo, davanti a un problema nuovo, la prima domanda è dove ti serve l'uscita (alla fine, a ogni passo, o in un'altra sequenza) e non quale cella usare. Nel resto dell'articolo lavoriamo sul caso many-to-one, il più semplice da addestrare e da verificare.

Un esempio minuscolo da seguire con la calcolatrice

Prima del codice più grande, la rete più piccola possibile: un solo ingresso, uno stato di un solo numero, tre parametri. Scegliamo W_xh = 0.5, W_hh = 0.8, b = 0 e diamo in ingresso 1, 0, 0, 0: un impulso al primo passo, poi silenzio. Così vediamo che cosa ricorda la rete di quell'impulso.

import numpy as np

# The smallest possible RNN: one input, one state, three numbers.
W_xh, W_hh, b = 0.5, 0.8, 0.0
h = 0.0
for t, x in enumerate([1.0, 0.0, 0.0, 0.0], start=1):
    h = np.tanh(W_xh * x + W_hh * h + b)
    print(f"t={t}  x={x}  h={h:.4f}")

# Output:
# t=1  x=1.0  h=0.4621
# t=2  x=0.0  h=0.3537
# t=3  x=0.0  h=0.2757
# t=4  x=0.0  h=0.2170

Al primo passo lo stato era 0, quindi h = tanh(0.5·1 + 0.8·0) = tanh(0.5) = 0.4621. Al secondo l'ingresso è zero ma lo stato no: h = tanh(0.8·0.4621) = tanh(0.3697) = 0.3537, poi 0.2757, poi 0.2170. L'impulso lascia una traccia che decade a ogni passo, perché il peso ricorrente 0.8 è minore di 1 e la tanh comprime ulteriormente. Con un W_hh maggiore di 1 la traccia decadrebbe più lentamente o addirittura crescerebbe, finché la tanh non la fermasse vicino a ±1. Questo fattore che moltiplica lo stato a ogni passo è la radice di ciò che vedremo nell'articolo successivo: la memoria di una RNN semplice dura quanto dura una potenza di W_hh.

Il forward pass a mano, in NumPy

Il forward pass di una RNN sta in dieci righe: un ciclo for e uno stato che si aggiorna. Scriverlo a mano serve a vedere che non c'è niente di misterioso. Il listato ha anche una seconda parte, che riscrive lo stesso calcolo come prodotto di matrici su un batch di due sequenze (la seconda è la prima letta al contrario): serve a mostrare come si parallelizza sul batch, non sul tempo, e a verificare che i due modi diano la stessa funzione.

import numpy as np

rng = np.random.default_rng(0)
input_size, hidden_size = 3, 4

# The same three parameters are reused at every time step.
W_xh = rng.normal(0, 0.5, (hidden_size, input_size))
W_hh = rng.normal(0, 0.5, (hidden_size, hidden_size))
b_h = np.zeros(hidden_size)

def rnn_forward(sequence):
    """sequence: array (T, input_size). Returns all hidden states (T, hidden_size)."""
    h = np.zeros(hidden_size)           # h_0: the "empty memory"
    states = []
    for x_t in sequence:                # one step per element, in order
        h = np.tanh(W_xh @ x_t + W_hh @ h + b_h)
        states.append(h)
    return np.array(states)

sequence = rng.normal(size=(5, input_size))
H = rnn_forward(sequence)
print(H.shape)          # (5, 4)
print(H[-1].round(3))   # the state after reading everything

# The same function, written as a matrix product over a whole batch of 2 sequences.
batch = np.stack([sequence, sequence[::-1]])              # (batch=2, T=5, 3)
h = np.zeros((2, hidden_size))
for t in range(5):
    h = np.tanh(batch[:, t, :] @ W_xh.T + h @ W_hh.T + b_h)
print(np.abs(h[0] - H[-1]).max())                          # same function, 0.0
print(np.abs(h[1] - rnn_forward(sequence[::-1])[-1]).max())

# Output:
# (5, 4)
# [ 0.397 -0.681 -0.183  0.777]
# 0.0
# 0.0

Le righe che contano sono quattro. rng.normal(0, 0.5, ...) crea pesi piccoli e casuali: non si inizializza mai a zero, perché tutte le unità farebbero lo stesso calcolo e resterebbero identiche per sempre (problema di simmetria). h = np.zeros(hidden_size) è h_0, la «memoria vuota» prima del primo elemento. Dentro il ciclo, W_xh @ x_t trasforma l'ingresso, W_hh @ h trasforma la vecchia memoria, la somma le fonde e np.tanh le comprime. states.append(h) conserva ogni stato, perché serviranno per dare un output a ogni passo e per calcolare i gradienti.

Le due differenze massime stampate in fondo sono 0.0: la versione sul batch calcola esattamente gli stessi numeri, perché fa le stesse operazioni nello stesso ordine; è la stessa funzione scritta con le forme (batch, tempo, feature), la convenzione che trovi nelle librerie quando si passa batch_first=True.

La libreria fa la stessa cosa, e lo si può controllare. Il blocco seguente continua quello precedente (usa W_xh, W_hh, b_h, sequence e H): copia i nostri pesi dentro torch.nn.RNN, con la corrispondenza dei nomi ricavata dal sorgente del modulo, e confronta le uscite con i nostri stati. È stato eseguito con PyTorch 2.12 su CPU, 2 thread, due volte con la stessa uscita. Un controllo che vale la pena fare, la prima volta che usi una libreria, è proprio questo: copiare i tuoi pesi dentro il modulo e confrontare le uscite con le tue.

# Continua il blocco precedente: usa W_xh, W_hh, b_h, sequence e H.
import torch, torch.nn as nn

torch.set_num_threads(2)
x = torch.tensor(sequence, dtype=torch.float32).unsqueeze(0)   # (batch=1, T=5, 3)

rnn = nn.RNN(input_size=3, hidden_size=4, batch_first=True, nonlinearity="tanh")
with torch.no_grad():
    rnn.weight_ih_l0.copy_(torch.tensor(W_xh, dtype=torch.float32))   # il nostro W_xh
    rnn.weight_hh_l0.copy_(torch.tensor(W_hh, dtype=torch.float32))   # il nostro W_hh
    rnn.bias_ih_l0.copy_(torch.tensor(b_h, dtype=torch.float32))      # il nostro b_h
    rnn.bias_hh_l0.zero_()                    # PyTorch ha un secondo bias: lo azzeriamo
    out, h_n = rnn(x)
print(out.shape, h_n.shape)                   # lo stato di ogni passo, e solo l'ultimo
print(f"differenza massima, 32 bit: {np.abs(out[0].numpy() - H).max():.2e}")
print("h_n è l'ultimo stato di out:", bool(torch.equal(h_n[0, 0], out[0, -1])))

# Stessi pesi, ma in 64 bit come NumPy.
rnn64 = nn.RNN(3, 4, batch_first=True, nonlinearity="tanh").double()
with torch.no_grad():
    rnn64.weight_ih_l0.copy_(torch.tensor(W_xh))
    rnn64.weight_hh_l0.copy_(torch.tensor(W_hh))
    rnn64.bias_ih_l0.zero_(); rnn64.bias_hh_l0.zero_()   # b_h è zero
    out64, _ = rnn64(torch.tensor(sequence).unsqueeze(0))
print(f"differenza massima, 64 bit: {np.abs(out64[0].numpy() - H).max():.2e}")

# Output:
# torch.Size([1, 5, 4]) torch.Size([1, 1, 4])
# differenza massima, 32 bit: 1.06e-07
# h_n è l'ultimo stato di out: True
# differenza massima, 64 bit: 1.67e-16

Che cosa mostra l'uscita. Le forme: out è (1, 5, 4), lo stato di ogni passo, e h_n è (1, 1, 4), solo l'ultimo, con davanti (strati, batch, unità); i due coincidono sull'ultimo passo. Con i pesi copiati e in 32 bit la differenza massima con i nostri stati è 1.06e-07, l'ordine di grandezza dell'arrotondamento a 32 bit; riportando il modulo a 64 bit, come NumPy, scende a 1.67e-16, cioè al rumore della doppia precisione. La nostra funzione e la libreria calcolano la stessa cosa. Le convenzioni da ricordare sono tre: i nomi (weight_ih_l0 è W_xh, weight_hh_l0 è W_hh); i due bias, che PyTorch somma (bias_ih_l0 + bias_hh_l0) e che noi sostituiamo con uno solo, quindi ne azzeriamo uno; e batch_first=True, che fa leggere l'ingresso come (batch, tempo, feature) invece dell'ordine predefinito (tempo, batch, feature).

Un compito vero: la rete impara a contare

Finora il calcolo era a mano. Vediamo un addestramento completo su un compito piccolo ma non banale: data una sequenza di 15 bit, dire se i 1 sono più degli 0. Per riuscirci la rete deve accumulare un conteggio approssimato lungo la sequenza e non può decidere guardando un solo elemento. È un caso many-to-one: usiamo l'ultimo stato e una testa lineare con un solo numero in uscita, il logit (positivo significa «più 1»).

Qui i gradienti li scriviamo noi, anche se PyTorch li calcolerebbe da solo, ed è un vantaggio didattico. La funzione loss_and_grads fa il passaggio all'indietro nel tempo: parte dalla derivata della perdita rispetto all'ultimo stato, risale i passi in ordine inverso, a ogni passo passa attraverso la tanh (1 − h²) e accumula i contributi ai pesi condivisi con +=, perché lo stesso peso compare a ogni passo. L'ottimizzatore Adam è scritto per esteso nel ciclo di addestramento. La perdita è l'entropia incrociata binaria calcolata dal logit con np.logaddexp, che evita overflow quando il logit è grande. Il test di accuratezza usa 2000 sequenze nuove ogni volta.

import numpy as np

rng = np.random.default_rng(0)
T_TRAIN, HIDDEN = 15, 16

def make_batch(batch, T=T_TRAIN):
    """Binary sequences. Label 1 if there are MORE ones than zeros (T odd: no ties)."""
    x = rng.integers(0, 2, size=(batch, T)).astype(float)
    y = (x.sum(axis=1) > T / 2).astype(float)
    return x, y

# Parameters: W_xh (n,1) as a vector, W_hh (n,n), b (n,), head w (n,), c scalar.
k = 1 / np.sqrt(HIDDEN)
params = {
    "Wx": rng.uniform(-k, k, HIDDEN), "Wh": rng.uniform(-k, k, (HIDDEN, HIDDEN)),
    "b": np.zeros(HIDDEN), "w": rng.uniform(-k, k, HIDDEN), "c": np.zeros(()),
}

def forward(p, x):
    """x: (batch, T). Returns logits and the list of states [h_0 .. h_T]."""
    h = np.zeros((x.shape[0], HIDDEN))
    hs = [h]
    for t in range(x.shape[1]):
        h = np.tanh(x[:, t:t + 1] * p["Wx"] + h @ p["Wh"].T + p["b"])
        hs.append(h)
    return hs[-1] @ p["w"] + p["c"], hs

def loss_and_grads(p, x, y):
    logits, hs = forward(p, x)
    prob = 1 / (1 + np.exp(-logits))
    loss = np.mean(np.logaddexp(0, logits) - y * logits)       # stable BCE with logits
    dlogit = (prob - y) / len(y)
    g = {k_: np.zeros_like(v) for k_, v in p.items()}
    g["w"], g["c"] = hs[-1].T @ dlogit, dlogit.sum()
    dh = dlogit[:, None] * p["w"]                               # dL/dh_T
    for t in range(x.shape[1], 0, -1):                          # backwards in time
        da = dh * (1 - hs[t] ** 2)                              # through the tanh
        g["Wx"] += (da * x[:, t - 1:t]).sum(axis=0)
        g["Wh"] += da.T @ hs[t - 1]
        g["b"] += da.sum(axis=0)
        dh = da @ p["Wh"]                                       # towards h_{t-1}
    return loss, g

def accuracy(p, T, n=2000):
    x, y = make_batch(n, T)
    return ((forward(p, x)[0] > 0) == (y == 1)).mean()

# Adam, written out
m = {k_: np.zeros_like(v) for k_, v in params.items()}
s = {k_: np.zeros_like(v) for k_, v in params.items()}
lr, b1, b2, eps = 1e-2, 0.9, 0.999, 1e-8
for step in range(1, 801):
    x, y = make_batch(64)
    loss, g = loss_and_grads(params, x, y)
    for k_ in params:
        m[k_] = b1 * m[k_] + (1 - b1) * g[k_]
        s[k_] = b2 * s[k_] + (1 - b2) * g[k_] ** 2
        params[k_] = params[k_] - lr * (m[k_] / (1 - b1 ** step)) / (np.sqrt(s[k_] / (1 - b2 ** step)) + eps)
    if step % 200 == 0:
        print(f"step {step:3d}  loss {loss:.3f}  test accuracy {accuracy(params, T_TRAIN):.3f}")

# Same weights, longer sequences, no retraining
for T in (15, 31, 101):
    print(f"T={T:3d} accuracy {accuracy(params, T):.3f}")

# Output:
# step 200  loss 0.054  test accuracy 0.988
# step 400  loss 0.130  test accuracy 0.919
# step 600  loss 0.008  test accuracy 0.996
# step 800  loss 0.092  test accuracy 0.991
# T= 15 accuracy 0.991
# T= 31 accuracy 0.866
# T=101 accuracy 0.722

Prima di fidarci dei numeri, verifichiamo che i gradienti scritti a mano siano giusti confrontandoli con le differenze finite. Il blocco sotto va incollato dopo le definizioni di accuracy e prima del ciclo Adam (richiede le funzioni del listato precedente): perturba alcuni pesi di ±10⁻⁶, ricalcola la perdita e confronta la pendenza numerica con quella analitica. Una differenza vicina a 10⁻¹⁰ dice che la derivazione è corretta; una differenza grande dice che c'è un errore di segno o di indice.

x, y = make_batch(8)
loss, g = loss_and_grads(params, x, y)
worst = 0
for name in ("Wx", "Wh", "b", "w"):
    flat = params[name].reshape(-1)
    for i in range(min(flat.size, 6)):
        old = flat[i]; flat[i] = old + 1e-6; lp, _ = loss_and_grads(params, x, y)
        flat[i] = old - 1e-6; lm, _ = loss_and_grads(params, x, y); flat[i] = old
        num = (lp - lm) / 2e-6; an = g[name].reshape(-1)[i]
        worst = max(worst, abs(num - an))
print("max |numeric - analytic| =", f"{worst:.1e}")

# Output:
# max |numeric - analytic| = 7.0e-11

Con una differenza massima di circa 7·10⁻¹¹ sui parametri controllati (i primi sei di ciascuna matrice o vettore, non tutti), possiamo leggere l'uscita dell'addestramento come comportamento della rete e non come effetto di un errore nel codice.

Tre osservazioni, con un solo seme e quindi indicative, non definitive. La rete impara davvero: dal 50% del caso al 98,8% già a 200 passi, con 16 unità e uno strato.

L'addestramento però non è stabile: a 400 passi l'accuratezza scende al 91,9% pur con una perdita del batch bassa, e risale al 99,6% a 600; la perdita stampata è quella di un singolo batch da 64 sequenze, quindi oscilla anche per puro caso statistico, e il learning rate (0.01) è alto e non c'è clipping dei gradienti. In pratica si salva il modello con la migliore accuratezza su un insieme di validazione (early stopping), non l'ultimo.

Infine la generalizzazione a sequenze più lunghe è parziale: 86,6% a 31 bit e 72,2% a 101, con gli stessi pesi e senza riaddestrare. I pesi sono condivisi, quindi la rete può girare su qualunque lunghezza, ma in addestramento non ha mai visto sequenze lunghe e il conteggio accumulato in uno stato che satura non si estende da solo. «Accetta qualsiasi lunghezza» è una proprietà dell'architettura, non una garanzia sul risultato.

Varianti e notazioni che incontrerai

La rete studiata è quella di Elman (1990), in cui lo stato nascosto ritorna in ingresso al passo successivo; in quella di Jordan (1986) a ritornare è invece l'uscita, e i due nomi si incontrano nei testi storici. Le RNN impilate (num_layers > 1 nelle librerie) fanno diventare la sequenza degli stati del primo strato la sequenza di ingresso del secondo: aumentano la capacità di astrazione ma si addestrano con più fatica. Lo stato iniziale h_0 = 0 può essere trattato come un parametro appreso, utile quando le sequenze hanno un «inizio tipico».

Sulla notazione, alcuni testi scrivono h_t = tanh(W [h_{t−1}, x_t] + b) con una sola matrice grande, altri separano W_xh e W_hh; sono la stessa cosa, perché la matrice grande è la concatenazione delle due, e alcuni usano U e W al posto di W_xh e W_hh, quindi controlla sempre quale è quale.

Trade-off

  • Pro: parametri costanti e lunghezza libera. Una stessa RNN legge 10 o 10.000 elementi.
  • Contro: calcolo sequenziale. Non si parallelizza sul tempo, quindi su GPU è lenta rispetto a una convoluzione o a un Transformer che elabora l'intera sequenza insieme.
  • Contro: la memoria è un collo di bottiglia. Tutto il passato deve stare in n numeri; più la sequenza è lunga, più informazione viene sovrascritta.
  • Contro: la memoria a lungo termine si apprende male. Il segnale di errore che deve raggiungere un passo lontano nel tempo si deforma strada facendo (lo hanno analizzato Bengio, Simard e Frasconi nel 1994), ed è la ragione storica di LSTM e GRU.

L'unico vantaggio dell'elenco, la lunghezza libera con pochi parametri, è anche la causa dei tre svantaggi: tutto discende dall'avere un solo stato che attraversa il tempo con gli stessi pesi. Scegli la RNN semplice quando le sequenze sono corte e il tuo vincolo è il numero di parametri; passa a una cella con porte o a un'altra architettura quando la dipendenza che ti serve sta lontana nel tempo.

Le trappole in cui si cade davvero

La prima è confondere la dimensione dello stato con la lunghezza della sequenza: hidden_size è la capacità della memoria, non il numero di passi, e raddoppiarla non fa ricordare sequenze più lunghe in modo garantito. La seconda è sbagliare l'ordine degli assi: passare (batch, tempo, feature) a un modulo che si aspetta (tempo, batch, feature) non dà errore, la rete tratta il batch come se fosse il tempo e impara male, quindi stampa sempre le forme.

La terza è dimenticare che lo stato iniziale riparte da zero a ogni chiamata: va bene se ogni esempio è indipendente, è un errore se i batch sono pezzi consecutivi di un unico testo lungo e vuoi mantenere la continuità. La quarta riguarda sequenze di lunghezze diverse nello stesso batch: se riempi con zeri e poi usi l'ultimo stato, stai leggendo lo stato dopo una serie di zeri e non dopo l'ultimo elemento vero; si risolve con il packing delle sequenze (pack_padded_sequence in PyTorch) o scegliendo l'indice giusto per ciascuna.

Verificati con quattro conti

Prova a rispondere senza rileggere. Una RNN con ingresso di dimensione 10 e stato di dimensione 20 ha 20×10 = 200 pesi in W_xh e 20×20 = 400 in W_hh, quindi 600 pesi più 20 bias. Se raddoppi la lunghezza delle sequenze da 50 a 100 il numero di parametri non cambia: cambiano il tempo di calcolo, circa il doppio perché i passi sono sequenziali, e la memoria necessaria al passaggio all'indietro. Se devi classificare intere frasi, alla testa lineare passi l'ultimo stato, che ha visto tutta la frase; con il padding, l'ultimo stato vero di ciascuna. Se vuoi un controllo sui tuoi gradienti, usa le differenze finite come nel blocco precedente.

Che cosa sai fare adesso

Sai scrivere il forward pass di una RNN in NumPy, contare i suoi parametri e spiegare perché non dipendono dalla lunghezza della sequenza, seguire a mano due passi con matrici vere e riconoscere i quattro modi di usarla. Sai anche addestrarne una su un compito vero con gradienti scritti a mano, verificarli con le differenze finite e leggere un risultato instabile senza scambiarlo per un errore. Il passo successivo è capire perché quel gradiente, su sequenze lunghe, svanisce o esplode: lo trovi in Backpropagation through time. Se vuoi ripassare le basi usate qui, la discesa del gradiente, la backpropagation passo per passo e NumPy e il broadcasting sono i prerequisiti.

Lezione precedente del percorso: Transfer learning: riusare una CNN già addestrata. Nella stessa serie rimandano a questa lezione anche: RNN su una serie temporale: finestre, baseline e previsione a più passi, GRU: la versione snella della memoria a porte, Tokenizzazione: come il testo diventa numeri, e perché il modello non sa contare le lettere.

Fonti

  • PyTorch: sorgente di torch/nn/modules/rnn.py - i nomi dei parametri (weight_ih_l0, bias_hh_l0…), i due bias che si sommano e il significato di batch_first, letto il 2026-11-12
  • NumPy: numpy.tanh - la tangente iperbolica applicata componente per componente, consultata il 2026-11-12
  • NumPy: Random Generator - default_rng, normal, uniform e integers con seme fisso per rendere le uscite ripetibili, consultata il 2026-11-12
  • J. L. Elman, Finding structure in time, Cognitive Science 14(2), 1990; M. I. Jordan, Serial order: a parallel distributed processing approach, ICS Report 8604, 1986; Y. Bengio, P. Simard, P. Frasconi, Learning long-term dependencies with gradient descent is difficult, IEEE Transactions on Neural Networks 5(2), 1994 - le reti di Elman e di Jordan e il problema del gradiente a lungo termine (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