La Gated Recurrent Unit (GRU, Cho e colleghi, 2014) arriva alla stessa idea della LSTM con meno parti: niente stato di cella separato, due porte invece di tre. Lo stato nascosto h fa da memoria e da uscita insieme, e a ogni passo viene aggiornato come miscela fra il vecchio stato e uno stato candidato nuovo, secondo un'unica porta di aggiornamento. Il risultato è una cella con circa tre quarti dei parametri di una LSTM e, nei compiti provati qui, prestazioni equivalenti.
Le parole che servono
Questo articolo dà per acquisite le definizioni di stato nascosto, sigmoide, porta e prodotto elemento per elemento introdotte negli articoli su RNN e LSTM. Aggiungiamo due termini. Una miscela convessa è una media pesata (1 − z)·a + z·b con z fra 0 e 1: il risultato sta sempre «fra» a e b (con z = 0 è a, con z = 1 è b, con z = 0.5 la media), e se a e b stanno in (−1, 1) anche la miscela ci sta. Il candidato è il «nuovo contenuto» che la cella potrebbe scrivere nella memoria, calcolato a ogni passo; quanto ne finisce davvero nello stato lo decide una porta.
L'analogia: una lavagna con un cursore
La LSTM era un nastro con tre addetti. La GRU è una lavagna con due manopole. La prima, la porta di aggiornamento, decide quanto della lavagna lasciare com'è e quanto cancellare per scrivere il contenuto nuovo; ha un cursore solo, quindi «quanto tengo» e «quanto scrivo» sono legati: più tengo, meno scrivo. La seconda, la porta di reset, decide quanto della lavagna guardare mentre si prepara il contenuto nuovo: se vale 0, il candidato viene scritto come se non ci fosse nessun passato.
Le equazioni
Come leggere il diagramma. La linea in alto è lo stato h, che qui fa insieme da memoria e da uscita. Lo stato precedente viene moltiplicato per z (quanto tenere). In basso, la porta di reset r si moltiplica con lo stato precedente solo per costruire il candidato n (la tanh): se r è vicino a 0 il candidato ignora il passato. Il candidato, moltiplicato per 1 − z, viene sommato in alto. Il confronto con la LSTM è immediato: manca il nastro separato, manca una porta, e «tenere» e «scrivere» sono controllate dalla stessa z.
Qui seguiamo la convenzione di PyTorch, che è quella da conoscere se scrivi codice reale. Attenzione: nel lavoro originale di Cho il ruolo di z è invertito (h_t = (1−z)·h_{t−1} + z·candidato), e due libri diversi possono scrivere formule opposte per la stessa cella.
r_t = σ( W_ir x_t + b_ir + W_hr h_{t-1} + b_hr ) reset gate
z_t = σ( W_iz x_t + b_iz + W_hz h_{t-1} + b_hz ) update gate (quanto TENERE)
n_t = tanh( W_in x_t + b_in + r_t ⊙ (W_hn h_{t-1} + b_hn) ) stato candidato
h_t = (1 − z_t) ⊙ n_t + z_t ⊙ h_{t-1} miscela
Due dettagli che cambiano il risultato numerico. Primo: in PyTorch il reset moltiplica W_hn h_{t-1} + b_hn, cioè è applicato dopo il prodotto con la matrice; la formula originale lo applicava prima (W (r ⊙ h)). Le due varianti sono equivalenti per capacità ma non per i numeri, quindi se copi i pesi fra framework devi seguire la convenzione giusta. Secondo: ci sono tre matrici n × d e tre n × n (reset, update, candidato), da cui 3·n·(n + d + 2) parametri con i due bias di PyTorch.
Perché il gradiente sopravvive
Dalla riga di miscela:
h_t = (1 − z_t) ⊙ n_t + z_t ⊙ h_{t-1}
∂h_t/∂h_{t-1} = diag(z_t) + (termini che passano per le porte e per n_t)
È lo stesso ragionamento della LSTM. Il termine diagonale diag(z_t) è un percorso senza matrice ricorrente e senza derivata di tanh: se una casella dello stato ha z ≈ 1, il suo valore e il suo gradiente passano quasi intatti al passo successivo. La differenza strutturale: nella LSTM «quanto tengo» (f) e «quanto scrivo» (i) sono due porte indipendenti, nella GRU sono complementari (z e 1 − z). Meno libertà, meno parametri.
Un passo di GRU con i numeri in mano
Con una sola casella di stato, h_prev = 0.9 (la memoria ricorda un valore alto) e candidato n = −0.5 (il nuovo contenuto è negativo), applichiamo h = (1 − z)·n + z·h_prev con tre valori di z. Con z = 1.0 si ha h = 0·(−0.5) + 1·0.9 = 0.9: la cella ignora il nuovo e conserva il vecchio, ed è il «filo diretto» che trasporta il gradiente. Con z = 0.0 si ha h = 1·(−0.5) + 0·0.9 = −0.5: la cella dimentica il passato e adotta il candidato, come una RNN semplice. Con z = 0.8 si ha h = 0.2·(−0.5) + 0.8·0.9 = −0.1 + 0.72 = 0.62: un aggiornamento morbido, il 20% di novità e l'80% di memoria.
Il comportamento della cella è dunque tutto nel numero z, appreso a ogni passo in funzione di ingresso e stato.
Codice a mano
import numpy as np
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-z))
class GRUCell:
"""GRU with PyTorch's conventions (reset applied AFTER the hidden matrix, z = how much to KEEP)."""
def __init__(self, d, n, seed=0):
rng = np.random.default_rng(seed)
self.n = n
self.W_ih = rng.normal(0, 0.3, (3 * n, d)) # rows: [reset | update | new]
self.W_hh = rng.normal(0, 0.3, (3 * n, n))
self.b_ih = rng.normal(0, 0.1, 3 * n)
self.b_hh = rng.normal(0, 0.1, 3 * n)
def step(self, x, h):
n = self.n
gi = self.W_ih @ x + self.b_ih
gh = self.W_hh @ h + self.b_hh
r = sigmoid(gi[0:n] + gh[0:n]) # reset gate: how much past to use for the candidate
z = sigmoid(gi[n:2 * n] + gh[n:2 * n]) # update gate: how much old state to keep
cand = np.tanh(gi[2 * n:] + r * gh[2 * n:]) # candidate state
return (1 - z) * cand + z * h # convex mix of new and old
cell = GRUCell(d=3, n=4)
rng = np.random.default_rng(1)
seq = rng.normal(size=(6, 3))
h = np.zeros(4)
for x in seq:
h = cell.step(x, h)
print(h.round(3))
La struttura ricalca la LSTM, ma le matrici sono tre invece di quattro e sono già separate in W_ih (agisce sull'ingresso) e W_hh (agisce sullo stato), come in PyTorch. In step si calcolano gi (contributo dell'ingresso) e gh (contributo dello stato); le porte r e z sommano i due contributi e passano per la sigmoide. Il candidato è la riga più delicata: np.tanh(gi[2*n:] + r * gh[2*n:]) moltiplica il reset per il solo contributo dello stato, che è la convenzione di PyTorch. L'ultima riga è la miscela: (1 - z) * cand + z * h. Con z alto lo stato quasi non cambia.
Poi con la libreria, e conteggio dei parametri
Copiamo i pesi in nn.GRU (le righe delle matrici sono impilate nell'ordine reset, update, nuovo) e confrontiamo i parametri dei tre tipi di strato ricorrente con ingresso 2 e stato 64.
import torch
import torch.nn as nn
gru = nn.GRU(input_size=3, hidden_size=4, batch_first=True)
with torch.no_grad():
for name, value in [("weight_ih_l0", cell.W_ih), ("weight_hh_l0", cell.W_hh),
("bias_ih_l0", cell.b_ih), ("bias_hh_l0", cell.b_hh)]:
getattr(gru, name).copy_(torch.tensor(value, dtype=torch.float32))
_, h_n = gru(torch.tensor(seq, dtype=torch.float32).unsqueeze(0))
print(np.abs(h_n[0, 0].detach().numpy() - h).max()) # ~1e-8: same function
def n_params(m):
return sum(p.numel() for p in m.parameters())
for name, cls in [("RNN", nn.RNN), ("GRU", nn.GRU), ("LSTM", nn.LSTM)]:
print(name, n_params(cls(2, 64, batch_first=True))) # input 2, hidden 64
Il ciclo sui nomi copia le quattro matrici/vettori dalla nostra cella in nn.GRU: a differenza della LSTM, qui non serve spezzare nulla, perché avevamo già tenuto ingresso e stato separati. Il valore 1.36e-08 è l'errore numerico float32/float64. L'ultimo ciclo istanzia i tre tipi di strato ricorrente con le stesse dimensioni (ingresso 2, stato 64) e conta i parametri con sum(p.numel() ...): 4352 per la RNN, 13056 per la GRU, 17408 per la LSTM. I rapporti 1 : 3 : 4 sono il numero di trasformazioni (una, tre, quattro).
1.3557848121958926e-08
RNN 4352
GRU 13056
LSTM 17408
La GRU ha 13.056 parametri contro 17.408 della LSTM (il 75%, esattamente 3 su 4 come prevede la formula) e il triplo della RNN semplice. È un conto, non una misura di qualità.
Prova sul problema dell'addizione
Riusiamo il benchmark dell'articolo precedente (due numeri marcati da sommare, baseline 0.167) con una GRU, 4000 passi di addestramento, clipping a 1.0, un seed.
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)
# Same adding-problem benchmark as the LSTM article, with a GRU instead of an LSTM
class NetGRU(nn.Module):
def __init__(self, hidden=64):
super().__init__()
self.rec = nn.GRU(2, hidden, batch_first=True)
self.head = nn.Linear(hidden, 1)
def forward(self, x):
out, _ = self.rec(x)
return self.head(out[:, -1]).squeeze(-1)
def run_gru(T, steps=4000, seed=0):
torch.manual_seed(seed)
net = NetGRU()
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()
for T in (50, 100):
print(f"T={T:3d} GRU {run_gru(T):.4f}")
Il codice ridefinisce adding_batch (identica a quella dell'articolo precedente), quindi stesso compito, stessa baseline (0.167), stessi 4000 passi. L'unica differenza è nn.GRU al posto di nn.LSTM, e l'assenza dell'inizializzazione speciale del bias. Il fatto che funzioni «dal primo colpo» è coerente con il termine diag(z) visto sopra, ma ricorda il limite: un seed, un compito sintetico. Non dimostra che la GRU sia migliore della LSTM, dimostra che non è peggiore su questo compito.
T= 50 GRU 0.0001
T=100 GRU 0.0001
La GRU risolve il compito a 50 e a 100 passi, con errori dello stesso ordine della LSTM (0.0005 e 0.0002 nell'esperimento precedente; con un solo seed le differenze fra 0.0001 e 0.0005 non sono significative). Nota che qui non abbiamo dovuto impostare nessun bias speciale: la GRU non ha un forget gate da inizializzare.
Guardare dentro: cosa fa la porta di aggiornamento dopo l'addestramento
Finora abbiamo affermato che z alto significa «tengo» e z basso «scrivo». Verifichiamolo guardando la porta in una rete addestrata. Alleniamo una GRU da 32 unità sul problema dell'addizione a T = 50 e poi, con i pesi appresi, rifacciamo il calcolo a mano registrando z a ogni passo, per 500 sequenze. Poi confrontiamo il valore medio di z nei passi in cui c'era il marcatore (quelli da scrivere in memoria) con quello nei passi senza marcatore.
# Train a GRU on the adding problem (T=50), then LOOK at its update gate z during a forward pass.
torch.manual_seed(0)
gru_net = NetGRU(hidden=32)
opt = torch.optim.Adam(gru_net.parameters(), lr=1e-2)
for _ in range(1500):
x, y = adding_batch(64, 50)
loss = nn.functional.mse_loss(gru_net(x), y)
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(gru_net.parameters(), 1.0)
opt.step()
@torch.no_grad()
def update_gate_trace(gru, x):
"""Recompute the GRU by hand with the trained weights and return z at every step (PyTorch convention)."""
n = gru.hidden_size
W_ih, W_hh = gru.weight_ih_l0, gru.weight_hh_l0
b_ih, b_hh = gru.bias_ih_l0, gru.bias_hh_l0
h = torch.zeros(x.size(0), n)
zs = []
for t in range(x.size(1)):
gi = x[:, t] @ W_ih.T + b_ih
gh = h @ W_hh.T + b_hh
r = torch.sigmoid(gi[:, :n] + gh[:, :n])
z = torch.sigmoid(gi[:, n:2 * n] + gh[:, n:2 * n]) # z = how much to KEEP
cand = torch.tanh(gi[:, 2 * n:] + r * gh[:, 2 * n:])
h = (1 - z) * cand + z * h
zs.append(z)
return torch.stack(zs, dim=1) # (batch, T, n)
x, y = adding_batch(500, 50)
z = update_gate_trace(gru_net.rec, x) # (500, 50, 32)
marked = x[:, :, 1] == 1 # positions of the two markers
print("mean z (keep) at MARKED steps :", round(z.mean(dim=-1)[marked].mean().item(), 3))
print("mean z (keep) at UNMARKED steps:", round(z.mean(dim=-1)[~marked].mean().item(), 3))
print("final loss:", round(nn.functional.mse_loss(gru_net(x), y).item(), 5))
mean z (keep) at MARKED steps : 0.672
mean z (keep) at UNMARKED steps: 0.869
final loss: 8e-05
Spiegazione del codice. update_gate_trace non usa nn.GRU come scatola nera: legge i pesi addestrati (weight_ih_l0 ecc.) e ripete il calcolo della nostra cella a mano, passo per passo, salvando z a ogni passo. È lo stesso codice della cella NumPy di prima, ma in PyTorch e su batch. Poi marked = x[:, :, 1] == 1 costruisce una maschera booleana dei passi con marcatore, e z.mean(dim=-1) media su tutte e 32 le unità. Risultato: ai passi con marcatore la porta tiene in media il 67.2% dello stato vecchio, ai passi senza marcatore l'86.9%. La differenza va nella direzione prevista (a un marcatore la rete lascia più spazio a nuova informazione), ma non è un interruttore netto: è la media su 32 unità, molte delle quali non fanno nulla di specifico, e sono medie su un solo modello addestrato. Un'ispezione più fine guarderebbe unità per unità. In ogni caso è un modo semplice per non fidarsi ciecamente del racconto: le porte sono numeri che puoi leggere, e vale la pena farlo quando un modello si comporta in modo strano.
La GRU come LSTM semplificata: una tabella di corrispondenze
Confrontando le equazioni, la GRU si ottiene dalla LSTM con tre semplificazioni. Eccole in tabella.
LSTM GRU
stato di cella c + stato nascosto h un solo stato h
forget gate f e input gate i una sola porta z: tieni = z, scrivi = 1 − z
output gate o (filtra c verso h) nessuna: lo stato intero è l'uscita
candidato g da [h, x] candidato n da [r ⊙ h, x] (porta di reset)
3 porte + candidato = 4 trasformazioni 2 porte + candidato = 3 trasformazioni
Le conseguenze sono tre. Primo, la GRU espone tutto il suo stato ad ogni passo: non può tenere un'informazione «nascosta» al resto della rete come fa la LSTM con la porta di output. Secondo, non ha una memoria illimitata: lo stato è una miscela convessa di valori in (−1, 1), quindi resta in (−1, 1), mentre c della LSTM può crescere. Terzo, il reset r è una novità senza equivalente nella LSTM: permette al candidato di «ripartire da zero» ignorando la memoria, pur mantenendo la memoria nello stato.
Verificati con quattro conti
Una GRU con ingresso 5 e stato 10 ha, con i due bias di PyTorch, 3·10·(10+5+2) = 510 parametri. Se z = 1 in tutte le unità e a tutti i passi, h_t = h_{t−1}: la cella ignora gli ingressi e mantiene lo stato iniziale (zero), una cella «congelata». Se r = 0 e z = 0 il candidato non vede la memoria e lo stato è interamente sostituito dal candidato, h_t = tanh(W x_t + b), cioè una rete senza memoria (uno strato denso applicato a ogni passo). Non serve un bias del forget gate perché non c'è un forget gate separato; ha comunque senso inizializzare il bias della porta di aggiornamento verso valori positivi per partire con tendenza a «tenere», ma i risultati sopra mostrano che la GRU ha imparato anche senza.
Una gara a parità di parametri, su tre seed
Dire «la GRU è più leggera» è un conto, dire «a parità di parametri è anche più veloce da addestrare» è un'altra affermazione e va misurata. Facciamo una gara onesta sul problema dell'addizione a T = 50: GRU con stato 64 (13.121 parametri, testa inclusa), LSTM con stato 64 (17.473) e LSTM con stato 55 (13.036, praticamente lo stesso numero di parametri della GRU). Metrica: i passi necessari perché la media mobile della perdita sugli ultimi 100 passi scenda sotto 0.05 (la metrica della lezione precedente), con tre seed per configurazione.
class AnyNet(nn.Module):
def __init__(self, kind, hidden):
super().__init__()
self.rec = {"GRU": nn.GRU, "LSTM": nn.LSTM}[kind](2, hidden, batch_first=True)
self.head = nn.Linear(hidden, 1)
if kind == "LSTM":
with torch.no_grad():
self.rec.bias_ih_l0[hidden:2 * hidden] = 1.0
def forward(self, x):
out, _ = self.rec(x)
return self.head(out[:, -1]).squeeze(-1)
def solve_steps(kind, hidden, seed, T=50, max_steps=4000, threshold=0.05):
torch.manual_seed(seed)
net = AnyNet(kind, hidden)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
recent = []
for step in range(1, max_steps + 1):
x, y = adding_batch(64, T)
loss = nn.functional.mse_loss(net(x), y)
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
recent.append(loss.item()); recent = recent[-100:]
if step >= 100 and sum(recent) / 100 < threshold:
return step
return None
count = lambda m: sum(p.numel() for p in m.parameters())
configs = [("GRU", 64), ("LSTM", 64), ("LSTM", 55)] # LSTM(55) has about as many parameters as GRU(64)
for kind, hidden in configs:
n_par = count(AnyNet(kind, hidden))
steps = [solve_steps(kind, hidden, seed) for seed in range(3)]
print(f"{kind:4s} hidden={hidden:3d} params={n_par:6d} steps to solve per seed: {steps}")
GRU hidden= 64 params= 13121 steps to solve per seed: [423, 393, 399]
LSTM hidden= 64 params= 17473 steps to solve per seed: [1027, 841, 947]
LSTM hidden= 55 params= 13036 steps to solve per seed: [708, 694, 1035]
Spiegazione del codice. AnyNet è una rete con una GRU o una LSTM e una testa lineare (con il bias del forget gate a 1 per la LSTM, per non svantaggiarla). solve_steps è lo stesso addestramento di prima (Adam, learning rate 0.003, clipping 1.0) con una condizione di arresto. count somma i parametri. L'elenco configs sceglie le tre configurazioni. Risultato: la GRU risolve il compito in circa 400 passi su tutti e tre i seed (393-423), la LSTM con stato 64 in circa 940 (841-1027), la LSTM a parametri pareggiati in 694-1035. La GRU è più veloce di un fattore due o più in questo esperimento, e la differenza è molto più grande della variabilità fra seed. Attenzione a cosa si può dire: vale per questo compito, a questa lunghezza, con questo learning rate e questa inizializzazione della LSTM. Non è una legge. Ma è un dato che l'argomento «la GRU è solo una LSTM più povera» non prevede.
Spiegazioni plausibili, non verificate separatamente: la GRU ha un percorso diretto dello stato più semplice (una sola porta controlla tenere e scrivere), ha meno parametri da coordinare e non deve imparare a gestire due stati. Per esserne certi bisognerebbe fare ablazioni. Il messaggio pratico resta: se il tuo budget di addestramento è limitato, la GRU merita di essere provata per prima; se la validazione mostra che la LSTM generalizza meglio, passa a quella.
GRU o LSTM: come scegliere
- Parametri e velocità. La GRU è più piccola e un po' più veloce per passo; con dataset piccoli ha meno rischio di overfitting.
- Prestazioni. Il confronto empirico di Chung e colleghi (2014) su modellazione musicale e vocale e quello di Jozefowicz e colleghi (2015) non mostrano un vincitore assoluto: dipende dal compito.
- Contare. Weiss, Goldberg e Yahav (2018) mostrano che una LSTM può realizzare un contatore illimitato, mentre lo stato di una GRU, miscela convessa di valori in (−1, 1), resta limitato.
- Regola pratica. Parti dalla GRU per rapidità, passa alla LSTM se la validazione lo giustifica, e tratta la scelta come un iperparametro da misurare.
I quattro criteri non pesano allo stesso modo: i primi due dicono che a priori non c'è un vincitore, il terzo indica l'unico caso in cui la differenza è strutturale (compiti che richiedono di contare), il quarto traduce tutto in una procedura. In pratica la decisione si prende con una prova su un budget fisso, non leggendo tabelle altrui.
Trappole reali
La prima trappola sono le formule contrarie fra fonti: se z significhi «tieni» o «scrivi» cambia da fonte a fonte, quindi controlla il codice e non solo la formula. La seconda è il reset prima o dopo la matrice: copiare pesi fra framework senza controllarlo dà risultati diversi senza alcun errore. La terza è lo stato iniziale dimenticato: la GRU ha un solo stato, quindi è più facile da portare avanti fra batch, ma va staccato dal grafo (h.detach()) se lo riusi. L'ultima è pensare che meno porte significhi meno ottimizzazione: il clipping resta utile.
Un passo di GRU con i numeri, nella convenzione di PyTorch
Una sola unità: ingresso x = 1.0, stato precedente h = 0.5. Pesi (sullo stato w_h, sull'ingresso w_x, bias): reset (0.2, 0.4, 0), aggiornamento (0.3, 0.5, 0.5), candidato (w_x = 0.8, w_h = 0.6, 0).
r = σ(0.4·1.0 + 0.2·0.5) = σ(0.50) = 0.622 (uso il 62% del passato per il candidato)
z = σ(0.5·1.0 + 0.3·0.5 + 0.5) = σ(1.15) = 0.760 (TENGO il 76% del vecchio stato)
n = tanh( 0.8·1.0 + r · 0.6·0.5 ) = tanh(0.8 + 0.622·0.3) = tanh(0.987) = 0.756 (candidato)
h' = (1 − z)·n + z·h = 0.240·0.756 + 0.760·0.5 = 0.181 + 0.380 = 0.562
Lettura. Il nuovo stato (0.562) è una miscela: il 24% viene dal candidato (0.756) e il 76% dal vecchio stato (0.5). Poiché z è alto, lo stato cambia poco, da 0.5 a 0.562. Se z fosse stato 0.1 il nuovo stato sarebbe stato 0.9·0.756 + 0.1·0.5 = 0.730: quasi interamente il candidato. Nota anche dove entra il reset: moltiplica solo il contributo dello stato dentro il candidato (r · 0.6 · 0.5), non l'ingresso e non il vecchio stato che viene miscelato alla fine. È questo che permette alla GRU di «ripartire da zero» nel calcolo del candidato mantenendo comunque il vecchio stato per la miscela.
Domande frequenti
Il mio libro scrive h = z·h_prev + (1 − z)·n, PyTorch no. Chi ha ragione? Nessuno sbaglia: è una convenzione. In PyTorch z è la frazione di stato vecchio che si tiene (come nel nostro codice); nel lavoro di Cho e colleghi (2014) z pesava il candidato. Il comportamento è identico a meno di rinominare z → 1 − z. Quando copi pesi fra librerie, verifica la convenzione con un test numerico come quello della lezione.
Anche Keras ha il reset «dopo» la matrice? Dipende dalla versione e da un parametro: nelle versioni recenti di TensorFlow/Keras lo strato GRU ha un'opzione chiamata reset_after che sceglie fra le due varianti (applicare il reset prima o dopo la matrice dello stato), con un valore predefinito che privilegia quella compatibile con cuDNN. Se porti pesi da PyTorch a Keras controlla quell'opzione, o i numeri non torneranno.
La GRU è sempre meglio della LSTM perché ha meno parametri? No: nei nostri esperimenti ha imparato più in fretta (≈400 passi contro ≈950 a T = 50), ma la letteratura non trova un vincitore assoluto, e per compiti che richiedono di contare o di memorizzare con precisione l'LSTM può essere preferibile. Misura su dati tuoi.
Quante porte ha davvero una GRU? Due porte (reset e aggiornamento) e un candidato: tre trasformazioni lineari, contro le quattro della LSTM (tre porte più candidato). Da qui il rapporto 3 a 4 nei parametri (13.056 contro 17.408 con ingresso 2 e stato 64).
Posso impilare GRU bidirezionali? Sì: nn.GRU(..., num_layers=2, bidirectional=True); l'uscita del primo strato ha dimensione 2H e diventa l'ingresso del secondo. Vale la regola dell'articolo sulle RNN bidirezionali: solo se la sequenza è completa al momento della decisione.
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.GRU(10, 20) con i due bias di PyTorch? Soluzione: 3·20·(20 + 10 + 2) = 1.920.
Esercizio 2. Con z = 0.9, candidato n = −1 e stato precedente h = 0.5 (convenzione PyTorch), quanto vale il nuovo stato? Soluzione: (1 − 0.9)·(−1) + 0.9·0.5 = −0.1 + 0.45 = 0.35: lo stato cambia poco perché z è alto.
Esercizio 3. Che cosa succede se z = 1 ovunque? Soluzione: h_t = h_{t−1} a ogni passo: la cella ignora gli ingressi e mantiene per sempre lo stato iniziale, il caso limite in cui il gradiente passa senza alcuna perdita. Esercizio 4. A parità di dimensioni, che rapporto c'è fra i parametri di una GRU e quelli di una LSTM? Soluzione: 3 a 4, perché la GRU ha tre trasformazioni (reset, aggiornamento, candidato) contro le quattro della LSTM (tre porte più candidato).
Che cosa sai fare adesso
Sai scrivere le equazioni della GRU nella convenzione di PyTorch, spiegare perché il termine diag(z) conserva il gradiente come il forget gate della LSTM, e seguire un passo completo con ogni numero in vista. Sai copiare i pesi di una cella scritta a mano in nn.GRU e contare i parametri (rapporto 1 : 3 : 4 fra RNN, GRU e LSTM), leggere la porta di aggiornamento in una rete addestrata e confrontare GRU e LSTM sui passi necessari a risolvere un compito. Il passo successivo è leggere la sequenza in entrambe le direzioni, con le RNN bidirezionali.
Fonti
- PyTorch: sorgente di torch/nn/modules/rnn.py - le equazioni di
nn.GRU(reset applicato dopo la matrice, miscela(1 - z) n + z h) e l'ordine delle matrici reset, update, new, letto il 2026-11-12 - K. Cho et al., Learning Phrase Representations using RNN Encoder-Decoder (arXiv:1406.1078) - l'origine della GRU, scheda raggiunta il 2026-11-12
- J. Chung et al., Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling (arXiv:1412.3555) - il confronto fra LSTM e GRU su musica e voce, letto l'abstract il 2026-11-12
- G. Weiss, Y. Goldberg, E. Yahav, On the Practical Computational Power of Finite Precision RNNs (arXiv:1805.04908) - la differenza di potenza computazionale fra LSTM e GRU a precisione finita, letto l'abstract il 2026-11-12
- Keras: strato GRU - l'opzione
reset_after(predefinita a True), consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare