Salta al contenuto principale

62 - RNN bidirezionali: leggere la sequenza in entrambe le direzioni

Una RNN bidirezionale legge la sequenza in entrambe le direzioni: quando serve il contesto destro, come costruirla a mano e quando il futuro diventa leakage.

Cosa ne pensi di questo articolo?

62 - RNN bidirezionali: leggere la sequenza in entrambe le direzioni
Machine Learning Engineerfedericocalo.dev

In Breve

Una RNN bidirezionale legge la sequenza in entrambe le direzioni: quando serve il contesto destro, come costruirla a mano e quando il futuro diventa leakage.

Una RNN bidirezionale (Schuster e Paliwal, 1997) sono due RNN indipendenti sulla stessa sequenza: una la legge da sinistra a destra, l'altra da destra a sinistra, e a ogni posizione i loro stati vengono incollati. Così, per ogni elemento, la rappresentazione contiene un riassunto di ciò che viene prima e uno di ciò che viene dopo. È uno strumento potente per tutti i compiti in cui hai la sequenza intera a disposizione, e una trappola per quelli in cui non ce l'hai. Questa lezione mostra entrambe le cose con numeri.

Le parole di questa lezione

Il compito che useremo è l'etichettatura di sequenze (sequence labelling): assegnare un'etichetta a ogni elemento di una sequenza, come la parte del discorso di ogni parola, invece di una sola etichetta per l'intera sequenza. Per ogni posizione parleremo di contesto: quello sinistro è ciò che viene prima, quello destro ciò che viene dopo. Le reti producono un logit, l'uscita grezza prima della trasformazione in probabilità (un logit positivo vuol dire probabilità sopra il 50%), e imparano minimizzando l'entropia incrociata binaria, che confronta la probabilità prevista con l'etichetta 0/1 ed è piccola quando la rete è sicura e ha ragione, enorme quando è sicura e ha torto. Il numero che riportiamo alla fine è l'accuratezza, la frazione di previsioni giuste.

Il problema: una RNN è cieca al futuro

Quando una RNN elabora la posizione t, il suo stato h_t dipende solo da x_1 … x_t. Se vuoi etichettare la parola «Washington» in «Ho visto Washington vincere le elezioni» come persona o luogo, ciò che segue («vincere le elezioni») aiuta moltissimo, ma una RNN unidirezionale non l'ha ancora letto quando arriva a «Washington».

L'analogia: correggere un testo con due penne

Un correttore di bozze legge il testo dall'inizio verso la fine e annota cosa si aspetta per ogni parola in base a ciò che ha già letto. Un secondo correttore lo legge dalla fine verso l'inizio e annota lo stesso in base a ciò che viene dopo. Alla fine, per ogni parola hai due note. Nessuno dei due ha visto tutto il contesto, ma insieme sì.

La matematica

RNN bidirezionale: una cella legge da sinistra a destra (blu), una da destra a sinistra (verde); gli stati delle due direzioni vengono concatenati a ogni posizione Bidirectional RNN: two independent readings, glued at every position x1 fwd cell bwd cell [ → ; ← ] x2 fwd cell bwd cell [ → ; ← ] x3 fwd cell bwd cell [ → ; ← ] x4 fwd cell bwd cell [ → ; ← ] blue: reads left to right (knows the past) green: reads right to left (knows the future) input x_t reaches both cells (arrows omitted for readability)
RNN bidirezionale: una cella legge da sinistra a destra (blu), una da destra a sinistra (verde); gli stati delle due direzioni vengono concatenati a ogni posizione

Nel disegno, le due file di celle non si scambiano informazione fra loro: sono due RNN indipendenti. La fila blu (in avanti) a ogni posizione ha già visto gli elementi a sinistra; la fila verde (all'indietro) quelli a destra. Solo in alto, nella scatola gialla, i due riassunti vengono incollati, e da lì in poi la rete (una testa lineare, un altro strato) può usare entrambi.

Due set di pesi indipendenti (non condivisi). Lo stato in avanti è quello della RNN di sempre; quello all'indietro percorre la sequenza al contrario:

→h_t = f( →W_x x_t + →W_h →h_{t-1} + →b )        t = 1, 2, …, T
←h_t = f( ←W_x x_t + ←W_h ←h_{t+1} + ←b )        t = T, T−1, …, 1
h_t  = [ →h_t ; ←h_t ]                            concatenazione: dimensione 2n

Il costo è esattamente il doppio di parametri e di calcolo. La dimensione dell'uscita raddoppia: la testa di classificazione deve accettare 2n ingressi.

Codice a mano

La parte sottile è l'allineamento: la passata all'indietro produce gli stati in ordine inverso, quindi vanno rigirati prima di concatenare. Un errore comune è dimenticarlo, e i due lati finiscono disallineati di una posizione.

import numpy as np

rng = np.random.default_rng(0)
d, n, T = 3, 4, 5

def make_rnn_params():
    return dict(Wx=rng.normal(0, .5, (n, d)), Wh=rng.normal(0, .5, (n, n)), b=np.zeros(n))

def run_rnn(p, seq):
    h, out = np.zeros(n), []
    for x in seq:
        h = np.tanh(p["Wx"] @ x + p["Wh"] @ h + p["b"])
        out.append(h)
    return np.array(out)                                  # (T, n)

fwd, bwd = make_rnn_params(), make_rnn_params()           # two INDEPENDENT sets of weights
seq = rng.normal(size=(T, d))

H_fwd = run_rnn(fwd, seq)                                 # reads left -> right
H_bwd = run_rnn(bwd, seq[::-1])[::-1]                     # reads right -> left, then re-aligned in time
H = np.concatenate([H_fwd, H_bwd], axis=1)                # (T, 2n): at each t, past AND future summary
print(H.shape)                                            # (5, 8)

Punti da notare. make_rnn_params restituisce un dizionario di pesi; lo chiamiamo due volte per ottenere due insiemi diversi (fwd e bwd). run_rnn è la RNN che già conosciamo. La riga chiave è run_rnn(bwd, seq[::-1])[::-1]: seq[::-1] rovescia la sequenza, la RNN la legge «da sinistra a destra» di questa copia rovesciata (cioè da destra a sinistra dell'originale), e l'ultimo [::-1] riallinea gli stati ai tempi originali, in modo che la riga t di H_bwd descriva la posizione t. np.concatenate(..., axis=1) incolla i due stati: forma (5, 8).

Poi con la libreria

In PyTorch è un flag, bidirectional=True. I pesi dell'altra direzione hanno il suffisso _reverse. Copiamoli e verifichiamo che coincida.

import torch
import torch.nn as nn

rnn = nn.RNN(d, n, batch_first=True, bidirectional=True)
with torch.no_grad():
    for suffix, p in [("", fwd), ("_reverse", bwd)]:
        getattr(rnn, f"weight_ih_l0{suffix}").copy_(torch.tensor(p["Wx"], dtype=torch.float32))
        getattr(rnn, f"weight_hh_l0{suffix}").copy_(torch.tensor(p["Wh"], dtype=torch.float32))
        getattr(rnn, f"bias_ih_l0{suffix}").copy_(torch.tensor(p["b"], dtype=torch.float32))
        getattr(rnn, f"bias_hh_l0{suffix}").zero_()

out, h_n = rnn(torch.tensor(seq, dtype=torch.float32).unsqueeze(0))
print(out.shape, h_n.shape)                      # (1, 5, 8) and (2, 1, 4): [forward, backward]
print(np.abs(out[0].detach().numpy() - H).max()) # ~1e-7 (float32 against float64)
# h_n[0] is the forward state AFTER the last element; h_n[1] is the backward state after reading the FIRST element.

Due cose da ricordare sulla forma. out ha dimensione 2n nell'ultimo asse, forward prima, backward dopo. h_n ha come primo asse num_layers × 2 (una riga per direzione): h_n[0] è lo stato in avanti dopo l'ultimo elemento, h_n[1] è lo stato all'indietro dopo aver letto il primo. Per una classificazione dell'intera sequenza si concatenano proprio questi due, non l'ultima posizione di out: out[:, -1] contiene lo stato all'indietro dopo aver letto solo l'ultimo elemento, informazione povera.

Esperimento: un'etichetta che dipende da entrambi i lati

Costruiamo un compito su misura. Cifre casuali da 0 a 9; per ogni posizione l'etichetta è 1 se x_{t−1} + x_{t+1} > 9 (ai bordi il vicino mancante vale 0). L'etichetta dipende dai vicini di destra e sinistra. La classe positiva copre il 45% dei casi (45 coppie su 100 hanno somma ≥ 10), quindi indovinare sempre «0» dà circa il 55% di accuratezza.

def make_batch(batch, T=12):
    x = torch.randint(0, 10, (batch, T)).float()
    left = torch.cat([torch.zeros(batch, 1), x[:, :-1]], dim=1)     # x_{t-1}, 0 at the border
    right = torch.cat([x[:, 1:], torch.zeros(batch, 1)], dim=1)     # x_{t+1}, 0 at the border
    y = ((left + right) > 9).float()      # label depends on the neighbour on BOTH sides
    return (x / 9).unsqueeze(-1), y

class Tagger(nn.Module):
    def __init__(self, bidirectional):
        super().__init__()
        self.rec = nn.GRU(1, 32, batch_first=True, bidirectional=bidirectional)
        self.head = nn.Linear(32 * (2 if bidirectional else 1), 1)
    def forward(self, x):
        out, _ = self.rec(x)
        return self.head(out).squeeze(-1)                            # one logit per time step

def accuracy_after_training(bidirectional, steps=1500, seed=0):
    torch.manual_seed(seed)
    net = Tagger(bidirectional)
    opt = torch.optim.Adam(net.parameters(), lr=5e-3)
    for _ in range(steps):
        x, y = make_batch(64)
        loss = nn.functional.binary_cross_entropy_with_logits(net(x), y)
        opt.zero_grad(); loss.backward(); opt.step()
    with torch.no_grad():
        x, y = make_batch(2000)
        return ((net(x) > 0).float() == y).float().mean().item()

print("unidirectional", round(accuracy_after_training(False), 3))
print("bidirectional ", round(accuracy_after_training(True), 3))

Il generatore make_batch crea cifre casuali x; left e right sono la stessa sequenza spostata di un posto da una parte e dall'altra (con zero al bordo): sono i vicini x_{t−1} e x_{t+1} di ogni posizione. L'etichetta y è 1 quando la loro somma supera 9. L'ingresso della rete è solo x / 9 (le cifre riscalate in [0, 1]): i vicini non sono dati alla rete come colonne separate, deve ricavarli dalla sequenza. Tagger ha una GRU, unidirezionale o bidirezionale a seconda del flag; la testa lineare riceve 32 o 64 feature (il doppio se bidirezionale) e produce un logit per ogni posizione (out contiene tutti i passi, non solo l'ultimo). La perdita è binary_cross_entropy_with_logits, che applica la sigmoide internamente in modo numericamente stabile; l'accuratezza finale conta quante volte logit > 0 coincide con l'etichetta.

unidirectional 0.791
bidirectional  1.0

La GRU unidirezionale arriva al 79.1%: usa il vicino sinistro (che ha letto) e deve indovinare quello destro. La bidirezionale raggiunge il 100%. Un seed e un compito sintetico, ma il meccanismo è chiaro: senza il lato destro, l'informazione per rispondere non è nella rete.

Quando è vietata: il leakage del futuro

Qui c'è la trappola più grave dell'intera lezione. Una rete bidirezionale usa il futuro per calcolare la rappresentazione di ogni istante. Se il tuo problema è prevedere qualcosa che nel mondo reale non conosci ancora, quel futuro non esisterà.

  • Previsione di serie temporali: usare una bidirezionale per prevedere x_{t+1} significa che l'ingresso contiene, passando dalla direzione inversa, il valore stesso che vuoi prevedere. Sui dati di test otterrai errori bellissimi e in produzione nulla funzionerà.
  • Generazione di testo: un modello che predice la parola successiva non può guardare le parole che seguono.
  • Riconoscimento in streaming (sottotitoli in tempo reale, rilevamento di anomalie online): devi rispondere prima che arrivi il resto; al massimo accetti una piccola latenza e una finestra limitata.

Dove va bene: etichettatura di sequenze offline (parti del discorso, entità), classificazione di documenti interi, encoder di traduzione (si legge la frase sorgente completa), e tutti i casi in cui la sequenza è completa al momento della decisione. ELMo (Peters e colleghi, 2018) usava per esempio LSTM in entrambe le direzioni per produrre rappresentazioni di parole.

Vedere il leakage con i propri occhi

La trappola più costosa della bidirezionalità non è teorica. Costruiamo un errore realistico: una pipeline di previsione in cui, per una svista, la finestra data alla rete comprende anche il valore da prevedere (succede, ad esempio, quando si sbaglia un indice di taglio o si costruisce la finestra con un'estremità inclusiva invece che esclusiva). Una rete bidirezionale, leggendo la sequenza anche da destra, vede subito quel valore. Misuriamo cosa promette l'errore rispetto alla pipeline corretta.

# A leaky pipeline: the window handed to a bidirectional GRU accidentally CONTAINS the value to predict.
import numpy as np
import torch
import torch.nn as nn

rng = np.random.default_rng(0)
t = np.arange(2000)
series = np.sin(0.1 * t) + 0.5 * np.sin(0.31 * t) + rng.normal(0, 0.1, t.size)
split = int(0.8 * len(series))
mu, sigma = series[:split].mean(), series[:split].std()
norm = (series - mu) / sigma

def make(x, size=30, leaky=False):
    """Honest: input = x[i:i+size], target = x[i+size].  Leaky: input also includes x[i+size]."""
    n = len(x) - size
    if leaky:
        X = np.stack([x[i:i + size + 1] for i in range(n)])
    else:
        X = np.stack([x[i:i + size] for i in range(n)])
    return (torch.tensor(X[..., None], dtype=torch.float32),
            torch.tensor(x[size:size + n], dtype=torch.float32))

class BiGRUForecaster(nn.Module):
    def __init__(self, hidden=32):
        super().__init__()
        self.rnn = nn.GRU(1, hidden, batch_first=True, bidirectional=True)
        self.head = nn.Linear(2 * hidden, 1)
    def forward(self, x):
        _, h_n = self.rnn(x)                              # h_n: (2, batch, hidden) = [forward, backward]
        return self.head(torch.cat([h_n[0], h_n[1]], dim=-1)).squeeze(-1)

def experiment(leaky, epochs=10, seed=0):
    torch.manual_seed(seed)
    Xa, ya = make(norm[:split], leaky=leaky)
    Xb, yb = make(norm[split:], leaky=leaky)
    net = BiGRUForecaster()
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    for _ in range(epochs):
        perm = torch.randperm(len(Xa))
        for i in range(0, len(Xa), 64):
            idx = perm[i:i + 64]
            loss = nn.functional.mse_loss(net(Xa[idx]), ya[idx])
            opt.zero_grad(); loss.backward(); opt.step()
    with torch.no_grad():
        return (net(Xb) - yb).abs().mean().item()

print("honest window :", round(experiment(False), 3))
print("leaky window  :", round(experiment(True), 3))
persistence = np.abs(norm[split + 30:] - norm[split + 29:-1]).mean()   # "tomorrow = today"
print("persistence baseline:", round(float(persistence), 3))

Il programma addestra due volte la stessa rete con lo stesso seme, una con le finestre oneste e una con quelle contaminate, e stampa l'errore assoluto medio sul tratto finale della serie, mai visto in addestramento (dati normalizzati, quindi l'errore è in unità di deviazione standard). Le tre righe sono, nell'ordine, finestra onesta, finestra contaminata e baseline di persistenza:

honest window : 0.117
leaky window  : 0.065
persistence baseline: 0.2

make(x, size, leaky) costruisce le finestre. Con leaky=False la finestra va da i a i+size−1 e il bersaglio è x[i+size]; con leaky=True la finestra arriva fino a i+size incluso, cioè contiene il bersaglio come ultimo elemento. BiGRUForecaster concatena lo stato in avanti e quello all'indietro (h_n[0] e h_n[1]) e li passa a una testa lineare. La baseline di persistenza («domani sarà uguale a oggi») si calcola con una riga e serve da punto di riferimento: se un modello non la batte, non ha imparato niente di utile.

Con la finestra onesta l'errore assoluto medio è 0.117, con quella contaminata 0.065: circa il 44% in meno, un «miglioramento» che farebbe festa in qualunque presentazione e che è completamente falso. In produzione, quando devi prevedere x[i+size], quel valore non esiste. L'errore non va a zero (la rete, con sole 10 epoche, non copia perfettamente l'ultimo valore), e questo rende il bug più insidioso: un risultato «buono ma non assurdo» non fa suonare nessun campanello.

Le contromisure sono concrete. Scrivi un test automatico che verifichi che, per ogni esempio, nessun elemento dell'ingresso abbia indice maggiore o uguale a quello del bersaglio. Confronta sempre con la baseline di persistenza. Sospetta di qualunque modello che batta di molto la baseline senza una ragione chiara.

Un'osservazione sul confronto: la rete bidirezionale onesta (0.117) batte la persistenza (0.2), ma non va letto come un vantaggio della bidirezionalità. Una finestra di 30 valori passati non contiene «futuro» rispetto al bersaglio, e la lettura all'indietro offre soltanto un secondo riassunto della stessa finestra. Il confronto giusto sarebbe con una GRU unidirezionale a parità di parametri ed epoche, che qui non abbiamo misurato.

Tabella delle forme (da tenere vicino)

nn.GRU / nn.LSTM / nn.RNN(input, H, num_layers=L, bidirectional=True, batch_first=True)

ingresso x : (B, T, input)
out        : (B, T, 2H)           forward in [..., :H], backward in [..., H:]
h_n        : (2L, B, H)           ordine: layer0-fwd, layer0-bwd, layer1-fwd, layer1-bwd, ...
                                   ultimo layer: h_n[-2] (fwd) e h_n[-1] (bwd)
parametri  : il doppio di una rete unidirezionale con lo stesso H
strato successivo: riceve 2H come dimensione di ingresso

La riga più fraintesa è quella di h_n: gli strati sono impilati in ordine, e per ogni strato c'è prima la direzione in avanti e poi quella all'indietro. Per classificare una sequenza con una rete a più strati, i due stati da concatenare sono h_n[-2] e h_n[-1], non h_n[0] e h_n[1] (che appartengono al primo strato). Con un solo strato le due scelte coincidono, e per questo l'errore passa inosservato finché non aggiungi il secondo.

Sui parametri vale la regola «il doppio». Una GRU ha tre blocchi di pesi (porte di aggiornamento, di reset e candidato), ciascuno con una matrice sull'ingresso, una sullo stato e due vettori di bias, quindi per una direzione 3·H·(H + input + 2). Per nn.GRU(10, 16, bidirectional=True) fa 2·3·16·(16+10+2) = 2688, e sum(p.numel() for p in rete.parameters()) restituisce proprio 2688 (misurato; con nn.GRU(20, 64, bidirectional=True) il conto dà 33.024, e anche quello coincide). L'uscita ha 2·16 = 32 feature per posizione.

Un caso dove la bidirezionalità è indispensabile: le parentesi

L'esperimento sulle cifre vicine mostrava che il contesto destro serve. Ecco un esempio più «strutturato», che somiglia a problemi veri (analisi di codice, linguaggio con costruzioni annidate): una stringa casuale di parentesi aperte ( e chiuse ). Per ogni simbolo, l'etichetta dice se quella parentesi trova la sua compagna, secondo la regola della pila: ogni ( si «apre» e si chiude con la prima ) successiva libera; una ) senza ( aperta resta senza compagna. Per esempio, nella stringa )())((()(()()((( le etichette sono 0110001101111000: 1 = ha una compagna, 0 = no.

Per le parentesi chiuse la risposta dipende solo dal passato (c'è una parentesi aperta libera?). Per le parentesi aperte dipende dal futuro (arriverà una chiusa libera?). Una rete unidirezionale dovrebbe quindi andare bene sulle prime e male sulle seconde.

import torch
import torch.nn as nn

L = 16
def make_brackets(batch, seed=None):
    """Random strings of '(' and ')'. Label 1 = this bracket gets matched (stack matching), 0 = it stays unmatched.
    Token ids: 0 = '(', 1 = ')'."""
    g = torch.Generator().manual_seed(seed) if seed is not None else None
    x = torch.randint(0, 2, (batch, L), generator=g)
    y = torch.zeros(batch, L)
    for b in range(batch):
        stack = []
        for t in range(L):
            if x[b, t] == 0:
                stack.append(t)
            elif stack:                                  # a ')' that finds an open '(' closes it
                y[b, stack.pop()] = 1.0; y[b, t] = 1.0
    return x, y

class BracketTagger(nn.Module):
    def __init__(self, bidirectional, hidden=64):
        super().__init__()
        self.emb = nn.Embedding(2, 8)
        self.rnn = nn.GRU(8, hidden, batch_first=True, bidirectional=bidirectional)
        self.head = nn.Linear(hidden * (2 if bidirectional else 1), 1)
    def forward(self, x):
        out, _ = self.rnn(self.emb(x))
        return self.head(out).squeeze(-1)

def run_brackets(bidirectional, steps=2500, seed=0):
    torch.manual_seed(seed)
    net = BracketTagger(bidirectional)
    opt = torch.optim.Adam(net.parameters(), lr=3e-3)
    for _ in range(steps):
        x, y = make_brackets(64)
        loss = nn.functional.binary_cross_entropy_with_logits(net(x), y)
        opt.zero_grad(); loss.backward(); opt.step()
    x, y = make_brackets(3000, seed=99)
    with torch.no_grad():
        pred = (net(x) > 0).float()
    opens, closes = (x == 0), (x == 1)
    acc = lambda m: ((pred == y) & m).sum().item() / m.sum().item()
    return acc(opens), acc(closes), (pred == y).float().mean().item()

x, y = make_brackets(1, seed=5)
print("example:", "".join("()"[i] for i in x[0].tolist()), "\nlabels :", "".join(str(int(v)) for v in y[0].tolist()))
for bi in (False, True):
    o, c, a = run_brackets(bi)
    print(f"{'bidirectional' if bi else 'unidirectional':15s} accuracy on '(' tokens {o:.3f}   on ')' tokens {c:.3f}   overall {a:.3f}")
example: )())((()(()()((( 
labels : 0110001101111000
unidirectional  accuracy on '(' tokens 0.713   on ')' tokens 0.995   overall 0.854
bidirectional   accuracy on '(' tokens 1.000   on ')' tokens 1.000   overall 1.000

Spiegazione del codice. make_brackets genera stringhe casuali di lunghezza 16 (id 0 = (, 1 = )) e calcola le etichette con una pila: ogni ( viene messa nella pila; quando arriva una ) e la pila non è vuota, la ( in cima e la ) corrente ricevono etichetta 1. BracketTagger è un embedding di 8 numeri, una GRU a 64 unità (unidirezionale o bidirezionale) e una testa lineare che produce un logit per ogni posizione. L'addestramento è standard (BCE, Adam, 2500 passi) e la valutazione, su 3000 stringhe nuove con seed fisso, separa l'accuratezza sulle ( da quella sulle ).

Il risultato è netto e coincide con l'analisi. La rete unidirezionale è quasi perfetta sulle ) (99.5%), perché per esse il passato basta (deve solo ricordare «quante parentesi aperte libere ci sono»), ma sulle ( arriva appena al 71.3%: non può sapere se più avanti compare la chiusa. La bidirezionale è perfetta su entrambe (100%): le due direzioni, insieme, contengono ciò che serve. Il 71.3% non è il caso (che sarebbe ~50% o la frequenza della classe maggioritaria): la rete unidirezionale ha imparato la statistica «una ( all'inizio della stringa ha più probabilità di essere chiusa che una alla fine», che è la parte di informazione a disposizione. Questo è l'esempio da ricordare per capire cosa significa avere bisogno del contesto destro: non un po' di accuratezza in più, ma una classe di problemi risolti o non risolti.

Trade-off e trappole

La bidirezionalità si paga in tre modi. Il primo è il costo: parametri e tempo raddoppiano, e la passata all'indietro non può iniziare finché non hai l'intera sequenza, quindi hai anche latenza. Il secondo è la memoria: in addestramento servono gli stati di entrambe le passate. Il terzo è un limite, non un costo: bidirezionale non vuol dire che ogni direzione ricordi meglio. Se le dipendenze sono lontane servono ancora una LSTM o una GRU, e infatti le due idee si combinano nella BiLSTM e nella BiGRU di uso comune.

Le trappole reali sono quattro, e la prima domina le altre. Usare una bidirezionale su un problema di previsione produce risultati ottimi offline e inutili in produzione: la domanda di controllo è «al momento della previsione reale avrò già questi dati?». Nell'implementazione a mano la seconda trappola è dimenticare di rigirare gli stati della passata all'indietro, e i due lati restano sfasati. La terza è leggere out[:, -1] per classificare una sequenza: la metà all'indietro ha visto un solo elemento, e la rappresentazione giusta sta in h_n. La quarta riguarda il padding: la passata all'indietro parte dagli zeri aggiunti in coda se non usi pack_padded_sequence, quindi il suo stato iniziale è già contaminato, più che nel caso unidirezionale.

Un esempio svolto a mano: tre passi in due direzioni

Per toccare con mano l'allineamento, usiamo una RNN a una sola unità con W_x = 1, W_h = 0.5 e sequenza di tre numeri x = (1, 0, −1). Gli stati sono h_t = tanh(x_t + 0.5·h_{t−1}), con h_0 = 0.

Passata in avanti (legge 1, poi 0, poi −1):

→h_1 = tanh(1 + 0.5·0)       = tanh(1)       =  0.7616
→h_2 = tanh(0 + 0.5·0.7616)  = tanh(0.3808)  =  0.3634
→h_3 = tanh(−1 + 0.5·0.3634) = tanh(−0.8183) = −0.6741

Passata all'indietro (legge −1, poi 0, poi 1, con pesi uguali per semplicità; nella rete vera sono pesi diversi):

←h_3 = tanh(−1 + 0)          = −0.7616
←h_2 = tanh(0 + 0.5·(−0.7616)) = tanh(−0.3808) = −0.3634
←h_1 = tanh(1 + 0.5·(−0.3634)) = tanh(0.8183)  =  0.6741

Concatenazione posizione per posizione ([→h_t ; ←h_t]). Ora si prende, per ogni posizione t, lo stato in avanti e quello all'indietro e li si mette uno accanto all'altro. Il punto delicato è che gli stati all'indietro sono stati calcolati nell'ordine 3, 2, 1: qui sono già rimessi nell'ordine delle posizioni, come farebbe il secondo [::-1] del codice.

posizione 1:  [  0.7616,  0.6741 ]
posizione 2:  [  0.3634, −0.3634 ]
posizione 3:  [ −0.6741, −0.7616 ]

Osserva due cose. Alla posizione 1, lo stato in avanti ha visto solo «1», mentre quello all'indietro ha visto «1, poi 0, poi −1» (tutta la sequenza dal fondo): la rappresentazione concatenata contiene sia il passato (vuoto) sia il futuro. E alla posizione 3 è il contrario. È l'allineamento che nel codice si fa con il secondo [::-1]: senza rigirare, il valore 0.6741 finirebbe alla posizione 3 invece che alla 1.

Domande frequenti

Una bidirezionale è «più intelligente» di una unidirezionale? Ha più informazione a disposizione (il futuro), non più capacità di ragionamento. Dove il futuro non serve, aggiunge costo (parametri doppi) e nessun beneficio.

Per classificare un'intera frase quale stato uso? Concatena lo stato in avanti dopo l'ultima parola e quello all'indietro dopo la prima: h_n[-2] e h_n[-1] con più strati. È ciò che «ha letto tutto» in entrambe le direzioni.

Si può fare previsione online con una bidirezionale? Sì, ma solo su una finestra già completa e trattando la previsione come «riempire un vuoto in mezzo» (ad esempio ricostruire un valore mancante), non come «prevedere il futuro». Per il futuro vero, solo unidirezionale.

E nei modelli moderni? I modelli ad attenzione usati per comprendere il testo guardano entrambe le direzioni per costruzione (con una maschera che decide cosa possono vedere). Il principio è lo stesso: la direzione consentita dipende dal compito, non dall'architettura.

Come testo che non ci sia leakage nel mio codice? Con il test dell'indice unico: costruisci una serie in cui ogni valore è diverso (x[i] = i) e verifica automaticamente che il bersaglio non compaia fra gli elementi dell'ingresso. Va fatto prima di addestrare.

Il padding peggiora di più con una bidirezionale? Sì: la passata all'indietro inizia dagli zeri di padding, quindi il suo stato iniziale reale è già contaminato. pack_padded_sequence risolve il problema per entrambe le direzioni.

Che cosa sai fare adesso

Sai costruire a mano una RNN bidirezionale, con due insiemi di pesi e gli stati all'indietro riallineati, e sai riprodurla con bidirectional=True leggendo giusto out e h_n. Hai visto la differenza che fa il contesto destro (accuratezza dal 79.1% al 100% sul compito delle cifre vicine, dal 71.3% al 100% sulle parentesi aperte) e sai riconoscere quando è vietato: se al momento della decisione la sequenza intera non esiste, la bidirezionale ti dà numeri belli e falsi. Il passo successivo è confrontare RNN, LSTM e GRU su un esperimento controllato, nella lezione seguente della serie.

Nella serie, la lezione precedente è GRU: la versione snella della memoria a porte; la successiva è RNN, LSTM e GRU a confronto: un esperimento controllato su più seed.

Fonti

  • PyTorch, documentazione di torch.nn.GRU - firma con bidirectional e batch_first, significato del flag; consultata il 2026-11-12. Le forme di out e h_n riportate sopra sono state verificate eseguendo il codice (PyTorch 2.12.0, CPU).
  • Peters e colleghi, «Deep contextualized word representations» (ELMo), arXiv 1802.05365 - rappresentazioni di parole da un modello del linguaggio bidirezionale; letto l'abstract, consultata il 2026-11-12.
  • M. Schuster, K. K. Paliwal, «Bidirectional recurrent neural networks», IEEE Transactions on Signal Processing, 1997 (DOI 10.1109/78.650093) - riferimento bibliografico dell'architettura; l'editore non risponde a un client automatico, testo non letto online per questo lavoro.

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