Introduzione: Reti Neurali per le Sequenze
Le Reti Neurali Ricorrenti (RNN) sono progettate per elaborare dati sequenziali: testo, serie temporali, audio, sequenze di azioni. A differenza delle reti feedforward che processano input indipendenti, le RNN mantengono uno stato nascosto (hidden state) che funge da memoria, permettendo alla rete di considerare il contesto delle informazioni precedenti nella sequenza.
Tuttavia, le RNN classiche soffrono del vanishing gradient problem: durante il training, il gradiente si attenua rapidamente attraverso i time step, rendendo impossibile apprendere dipendenze a lungo termine. Le LSTM (Long Short-Term Memory) e le GRU (Gated Recurrent Unit) risolvono questo problema con meccanismi di gating sofisticati.
Cosa Imparerai
- Come le RNN mantengono stato attraverso le sequenze
- Il vanishing gradient problem e perchè limita le RNN
- LSTM: input gate, forget gate, output gate e cell state
- GRU: un'alternativa più leggera alle LSTM
- RNN bidirezionali e modelli sequence-to-sequence
- Implementazione pratica: text generation e sentiment analysis
RNN: Architettura e Hidden State
Una RNN elabora una sequenza un elemento alla volta, aggiornando ad ogni passo il proprio hidden state. Questo vettore di stato cattura un riassunto compresso di tutte le informazioni viste fino a quel momento. L'output di ogni time step dipende sia dall'input corrente che dallo hidden state precedente.
Formalmente, ad ogni time step t la RNN calcola:
- h_t = tanh(W_hh * h_(t-1) + W_xh * x_t + b_h): nuovo hidden state combinando stato precedente e input corrente
- y_t = W_hy * h_t + b_y: output al time step corrente
import torch
import torch.nn as nn
# RNN semplice in PyTorch
class SimpleRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
self.fc = nn.Linear(hidden_size, output_size)
def forward(self, x):
# x shape: (batch, seq_len, input_size)
# h0 shape: (1, batch, hidden_size)
h0 = torch.zeros(1, x.size(0), self.hidden_size).to(x.device)
output, hidden = self.rnn(x, h0)
# Usa l'ultimo hidden state per classificazione
out = self.fc(hidden.squeeze(0))
return out
# Esempio: sequenza di 20 time step con 10 feature ciascuno
model = SimpleRNN(input_size=10, hidden_size=64, output_size=2)
x = torch.randn(8, 20, 10) # batch=8, seq=20, features=10
output = model(x)
print(f"Output: {output.shape}") # [8, 2]
Il Vanishing Gradient Problem
Il vanishing gradient e il tallone d'Achille delle RNN classiche. Durante la backpropagation through time (BPTT), il gradiente viene moltiplicato ripetutamente per la matrice dei pesi W_hh ad ogni time step. Se gli autovalori di questa matrice sono minori di 1, il gradiente decresce esponenzialmente; se maggiori di 1, esplode.
In pratica, questo significa che una RNN classica non riesce ad apprendere dipendenze che superano i 10-20 time step. Se la parola chiave per capire il sentimento di una frase e all'inizio e l'output alla fine, il gradiente si sarà quasi azzerato prima di raggiungere quella parola.
perchè Servono le LSTM
Le LSTM risolvono il vanishing gradient con un'intuizione elegante: invece di forzare tutte le informazioni attraverso moltiplicazioni ripetute, aggiungono un cell state separato che funge da "autostrada" per le informazioni. I gate controllano quali informazioni aggiungere, dimenticare o leggere dal cell state, permettendo al gradiente di fluire inalterato per centinaia di time step.
LSTM: Long Short-Term Memory
Le LSTM, introdotte da Hochreiter e Schmidhuber nel 1997, risolvono il vanishing gradient con quattro componenti chiave:
I Tre Gate
- Forget Gate (f_t): decide quali informazioni scartare dal cell state. Un valore sigmoid tra 0 (dimentica tutto) e 1 (mantieni tutto) per ogni dimensione
- Input Gate (i_t): decide quali nuove informazioni aggiungere al cell state. Combina un gate sigmoid (quanto aggiungere) con un vettore candidato tanh (cosa aggiungere)
- Output Gate (o_t): decide quale parte del cell state utilizzare come output/hidden state. Filtra il cell state attraverso tanh e sigmoid
Cell State
Il cell state e il cuore della LSTM. Scorre attraverso la catena temporale con sole operazioni lineari (moltiplicazione e addizione), permettendo al gradiente di propagarsi facilmente. I gate regolano il flusso di informazione dentro e fuori dal cell state.
import torch
import torch.nn as nn
class LSTMClassifier(nn.Module):
"""LSTM per classificazione di sequenze"""
def __init__(self, vocab_size, embed_dim, hidden_size,
num_layers, num_classes, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=True
)
self.dropout = nn.Dropout(dropout)
# Bidirezionale: hidden_size * 2
self.fc = nn.Linear(hidden_size * 2, num_classes)
def forward(self, x):
embedded = self.dropout(self.embedding(x))
lstm_out, (hidden, cell) = self.lstm(embedded)
# Concatena hidden state forward e backward
hidden_cat = torch.cat((hidden[-2], hidden[-1]), dim=1)
output = self.fc(self.dropout(hidden_cat))
return output
# Sentiment analysis: vocab 10000, embedding 128, hidden 256
model = LSTMClassifier(
vocab_size=10000,
embed_dim=128,
hidden_size=256,
num_layers=2,
num_classes=2 # positivo/negativo
)
# Input: batch di 16 frasi, max 50 token
x = torch.randint(0, 10000, (16, 50))
output = model(x)
print(f"Output: {output.shape}") # [16, 2]
GRU: Un'Alternativa Più Leggera
Le GRU (Gated Recurrent Unit), introdotte da Cho et al. nel 2014, sono una versione semplificata delle LSTM. Combinano forget gate e input gate in un unico update gate e fondono cell state e hidden state, riducendo il numero di parametri di circa il 25%.
Le GRU hanno due gate:
- Reset Gate (r_t): quanto del vecchio hidden state ignorare quando si calcola il nuovo candidato
- Update Gate (z_t): quanto del vecchio hidden state mantenere vs quanto del nuovo candidato usare
In pratica, le GRU raggiungono performance comparabili alle LSTM su molti task, con tempi di training inferiori. La scelta dipende dal task: per sequenze molto lunghe le LSTM tendono a essere superiori, per dataset più piccoli le GRU possono essere preferibili per la minore tendenza all'overfitting.
RNN Bidirezionali e Sequence-to-Sequence
Bidirezionali
Una RNN bidirezionale processa la sequenza sia in avanti (da sinistra a destra) che all'indietro (da destra a sinistra), concatenando i due hidden state. Questo permette a ogni posizione di avere contesto sia dal passato che dal futuro, fondamentale per task come il Named Entity Recognition dove il significato di una parola dipende da tutto il contesto.
Sequence-to-Sequence (Seq2Seq)
L'architettura Seq2Seq usa un encoder RNN per comprimere la sequenza di input in un vettore di contesto fisso, e un decoder RNN per generare la sequenza di output. Questa architettura e stata fondamentale per la traduzione automatica prima dell'avvento dei Transformer.
class TextGenerator(nn.Module):
"""Generatore di testo carattere per carattere"""
def __init__(self, vocab_size, embed_dim, hidden_size, num_layers):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers,
batch_first=True, dropout=0.2)
self.fc = nn.Linear(hidden_size, vocab_size)
def forward(self, x, hidden=None):
embedded = self.embedding(x)
output, hidden = self.lstm(embedded, hidden)
logits = self.fc(output)
return logits, hidden
def generate(self, start_token, max_len=100, temperature=0.8):
"""Genera testo auto-regressivamente"""
self.eval()
current = start_token.unsqueeze(0).unsqueeze(0)
hidden = None
generated = [start_token.item()]
with torch.no_grad():
for _ in range(max_len):
logits, hidden = self(current, hidden)
logits = logits[:, -1, :] / temperature
probs = torch.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, 1)
generated.append(next_token.item())
current = next_token
return generated
L'Avvento dell'Attention
Il limite principale del modello Seq2Seq e il bottleneck: tutta l'informazione della sequenza di input viene compressa in un singolo vettore fisso. Per sequenze lunghe, questo vettore non riesce a catturare tutti i dettagli. Il meccanismo di Attention, introdotto da Bahdanau et al. nel 2014, risolve questo problema permettendo al decoder di "guardare" direttamente tutte le posizioni dell'encoder. Questa idea ha portato ai Transformer, che vedremo nel prossimo articolo.
Applicazioni Pratiche
Le RNN e LSTM trovano applicazione in numerosi domini:
- Sentiment Analysis: classificare il sentimento di recensioni, tweet, commenti. LSTM bidirezionali catturano contesto completo
- Time Series Forecasting: previsione di prezzi di borsa, consumo energetico, metriche di sistema. LSTM eccellono nel catturare pattern stagionali
- Text Generation: generare testo carattere per carattere o parola per parola, dai chatbot alla poesia computazionale
- Machine Translation: traduzione automatica con architettura Seq2Seq + Attention (predecessore dei Transformer)
- Speech Recognition: conversione audio-testo, dove la sequenza acustica viene mappata a sequenze di fonemi e parole
Prossimi Passi nella Serie
- Nel prossimo articolo esploreremo i Transformers, l'architettura che ha reso obsolete le RNN per la maggior parte dei task NLP
- Vedremo self-attention, multi-head attention e positional encoding
- Analizzeremo BERT e GPT: come hanno rivoluzionato il Natural Language Processing







