Modelli Encoder-Decoder: Guida Completa all'Architettura Seq2Seq

Encoder-Decoder: dal funzionamento Sequence-to-Sequence alla formulazione matematica, implementazione PyTorch.

Modelli Encoder-Decoder: Guida Completa all'Architettura Seq2Seq
Condividi:

L'architettura Encoder-Decoder rappresenta la spina dorsale di molte applicazioni di Natural Language Processing, come la traduzione automatica e la text summarization. In questo articolo esploriamo come due reti neurali distinte collaborino per processare una sequenza di input e generare un output coerente. Analizzeremo la teoria matematica dietro il passaggio di informazioni nel latent space e forniremo un'implementazione moderna basata su Transformer in PyTorch.

Seppur ad oggi nel 2026 l'architettura del 99% degli LLM sia basata su decoder-only, il transformer originale, basato su encoder-decoder, continua ad essere aggiornato e trovare i suoi casi d'uso. Vedi la famiglia di modelli T5 e GemmaT5.

Che cos'è un modello Encoder Decoder?

Un modello Encoder-Decoder è un design architetturale composto da due blocchi neurali principali che collaborano per risolvere task di tipo Sequence-to-Sequence (Seq2Seq). L'obiettivo primario è mappare una sequenza di input di lunghezza variabile XX in una sequenza di output di lunghezza variabile YY.

Questa struttura ha rivoluzionato il deep learning, permettendo alle macchine di gestire input e output che non hanno una corrispondenza 1:1 rigida, come avviene invece nella classificazione di immagini o nel tagging.

L'approccio sequence-to-sequence risale ben prima del Transformer, trovando le sue radici nel 2014, un anno fondamentale per il Deep Learning applicato all'NLP. In quel periodo, ricercatori come Ilya Sutskever (Google) e Kyunghyun Cho introdussero le prime architetture Encoder-Decoder basate interamente su reti ricorrenti, in particolare LSTM (Long Short-Term Memory) e GRU (Gated Recurrent Units).

All'epoca, il paradigma prevedeva che l'Encoder leggesse l'input sequenzialmente per comprimerlo in un unico vettore a dimensione fissa, detto context vector. Sebbene rivoluzionario per la Machine Translation, questo metodo soffriva di un evidente "collo di bottiglia": costringere l'intera semantica di una frase, soprattutto se lunga, in un singolo vettore portava a una rapida perdita di informazioni.

La vera svolta arrivò nel 2015, quando Bahdanau et al. proposero il meccanismo di Attention. Questa innovazione permise al Decoder di non affidarsi più solo all'ultimo stato nascosto dell'Encoder, ma di effettuare una ricerca ("soft-search") su tutti gli stati nascosti della sequenza di input a ogni passo di generazione. Fu proprio questo concetto di allineamento dinamico tra input e output a preparare il terreno, due anni più tardi, per l'arrivo dell'architettura Transformer.

Il funzionamento teorico

Il processo si divide in due fasi distinte: la codifica e la decodifica, unite da uno stato latente o, nelle architetture più moderne, dal meccanismo di Cross-Attention.

1. L'Encoder

La prima componente neurale, l'Encoder, riceve la sequenza di input (es. una frase in italiano, un documento qualsiasi). Il suo compito è "comprimere" le informazioni semantiche e sintattiche dell'input in una rappresentazione vettoriale numerica. Nelle architetture RNN-based classiche, questo risultava in un context vector finale di shape fissa DD; nei Transformer, l'output è una sequenza TT di vettori contestualizzati (hidden states), ognuno di dimensione DD.

Matematicamente, dato un input X={x1,x2,...,xT}X = \{x_1, x_2, ..., x_T\}, l'Encoder aggiorna il suo stato nascosto hth_t:

ht=Encoder(xt,ht1)h_t = \text{Encoder}(x_t, h_{t-1})

Potete ben immaginare come l'espressività di un tensore TxDTxD sia ben superiore a quella di un tensore DD, che cerca di comprimere tutto in uno spazio costante.

2. Il Decoder

Il Decoder è una rete generativa autoregressiva. Utilizza le informazioni prodotte dall'Encoder per predire il token successivo nella sequenza di output, condizionato dai token precedentemente generati.

La probabilità di generare il prossimo token yty_t è data da:

P(yty<t,C)=Decoder(yt1,st1,C)P(y_t | y_{<t}, C) = \text{Decoder}(y_{t-1}, s_{t-1}, C)

Dove y<ty_{<t} sono i token già generati, st1s_{t-1} è lo stato nascosto del Decoder al passo precedente e CC è il contesto derivato dall'Encoder.

In fase di addestramento tecnicamente questo approccio è noto come "Teacher Forcing" e consiste nell'alimentare il Decoder non con il token che ha appena generato (che potrebbe essere errato, specialmente all'inizio del training), ma con il token corretto (ground truth) proveniente dalla sequenza target del dataset.

Questa strategia previene l'accumulo di errori: se il modello sbagliasse il primo token e noi usassimo quella predizione per generare il secondo, l'intero contesto si degraderebbe rapidamente, rendendo l'addestramento instabile e lentissimo.

In pratica, come si ottiene ciò? Assumendo che gli output del modello (i logits) abbiano shape B×T×VB \times T \times V, la sequenza target deve essere allineata temporalmente in modo che la predizione al passo tt venga confrontata con il token reale al passo t+1t+1.

In PyTorch, questo si traduce nel "tagliare" l'ultimo step dai logits (poiché non abbiamo un target successivo) e il primo step dalle labels (poiché cerchiamo di predire dal secondo token in poi).

Le due sequenze vengono poi confrontate usando la cross entropy, che determinerà di quanto la distribuzione di probabilità predetta dal modello si discosta dal target reale.

In termini più rigorosi, la loss penalizza il modello logaritmicamente in base alla probabilità che esso ha assegnato al token corretto (la ground truth). Se il modello assegna una probabilità bassa al token giusto, la loss sarà molto alta; se assegna una probabilità vicina a 1, la loss tenderà a zero. Il numero finale è il segnale che guida la backpropagation: viene utilizzato per calcolare i gradienti e aggiornare i pesi (θ\theta) di tutti i layer (sia dell'Encoder che del Decoder) affinché, alla prossima iterazione, la predizione sia statisticamente più vicina alla sequenza target.

import torch
import torch.nn as nn

def next_token_loss(logits: torch.Tensor, target_ids: torch.Tensor):
    # ESEMPIO PRATICO:
    # Assumiamo che la frase target completa (inclusi token speciali) sia:
    # "|CIAO, MI CHIAMO MAURO|"
    # Dove '|' rappresenta Start-of-sentence (BOS) o End-of-sentence (EOS)

    # 1. Shift dei Logits (Input del confronto)
    # Prendiamo tutto tranne l'ultima predizione temporale.
    # La predizione all'indice t (fatta vedendo il token t) serve per indovinare il token t+1.
    shift_logits = logits[..., :-1, :].contiguous()
    # Logica temporale vista dal modello: [| -> C], [C -> I], [I -> A]...
    # Tensore effettivo: [|CIAO, MI CHIAMO MAURO] (l'ultimo '|' è escluso)

    # 2. Shift delle Labels (Target del confronto)
    # Prendiamo tutto tranne il primo token.
    # Il primo token '|' è stato usato come input per generare la prima predizione.
    # Il target reale che il modello doveva indovinare è il secondo token 'C'.
    shift_labels = target_ids[..., 1:].contiguous()
    # Tensore effettivo: [CIAO, MI CHIAMO MAURO|] (il primo '|' è escluso)

    # 3. Calcolo della Loss
    # PyTorch CrossEntropyLoss si aspetta input appiattiti
    loss_fct = nn.CrossEntropyLoss()
    
    # Confronto visivo:
    # Input (Logits):    [|  C  I  A  O  ,     M  I     C  H  I  A  M  O     M  A  U  R  O ]
    # Target (Labels):   [C  I  A  O  ,     M  I     C  H  I  A  M  O     M  A  U  R  O  | ]
    #
    # Qui il modello impara la relazione causale: 
    # dato '|' -> predici 'C'
    # dato 'C' -> predici 'I'
    # ...
    # dato 'O' -> predici '|' (EOS)
    
    loss = loss_fct(
        shift_logits.view(-1, shift_logits.size(-1)), 
        shift_labels.view(-1)
    )
    return loss

Questa operazione garantisce che il modello venga penalizzato solo se, vedendo la sequenza fino a tt, non riesce a predire correttamente t+1t+1. Senza questo shift, staremmo chiedendo al modello di predire il token che sta attualmente vedendo in input, trasformando il task in una banale funzione identità invece che in generazione predittiva.

La verità è che proprio qui, parlando in gergo AI, nascono le allucinazioni. Per quanto elegante sia l'approccio del teacher forcing, esso introduce una discrepanza nota come Exposure Bias. Poiché il modello non è mai esposto ai propri errori durante il training, in fase di inference (dove il "maestro" non è presente e il modello deve usare i propri output precedenti) potrebbe risultare fragile e incapace di recuperare da una predizione subottimale.

Mauro, perchè ciò non avviene? Perchè non far generare i tokens successivi in fase di training?

La risposta principale risiede nel fatto che la backpropagation richiede che ogni operazione all'interno del grafo computazionale sia differenziabile (o "smooth").

L'atto di selezionare un singolo token dalla distribuzione di probabilità (operazione nota come sampling) è un processo discreto, non continuo. Matematicamente, questo rappresenta una funzione "a gradini" la cui derivata è zero ovunque o indefinita. Se passassimo il token generato (un numero intero discreto) come input allo step successivo, spezzeremmo la catena del gradiente (chain rule). Di conseguenza, l'errore non potrebbe propagarsi all'indietro attraverso il tempo per aggiornare i pesi del modello. Il Teacher Forcing aggira questo ostacolo mantenendo tutto nel dominio continuo delle probabilità fino al calcolo della loss.

Ultimamente, ci sono studi come Reinforcement Pretraining (Dong et al., 2025) che propongono un cambio di paradigma radicale per aggirare questo ostacolo.

L'idea centrale è spostare il Reinforcement Learning (RL) dalla classica fase di post-training (come avviene con RLVR, RLHF, ecc) direttamente nel cuore del Pre-training. Invece di trattare la predizione del prossimo token puramente come una minimizzazione dell'errore (classificazione), il modello viene addestrato come un agente in un ambiente: la generazione del token è l'azione, e la correttezza rispetto al testo originale fornisce una ricompensa verificabile.

Poiché gli algoritmi di RL (come Policy Gradient) sono matematicamente formulati per ottimizzare un'aspettativa di ricompensa anche attraverso operazioni stocastiche e non differenziabili, questo approccio permette di includere il sampling nel ciclo di training. Il modello non viene più solo "forzato" a copiare il maestro, ma viene incentivato a sviluppare strategie di ragionamento interne robuste per massimizzare la probabilità di azzeccare il token successivo, mitigando così l'Exposure Bias alla radice.

Evoluzione: Da RNN a Transformer

Storicamente, Encoder e Decoder erano implementati tramite LSTM o GRU. Il limite principale era il "bottleneck" informativo: l'intera frase di input doveva essere compressa in un unico vettore di dimensione fissa.

Oggi, lo state of the art è rappresentato dai Transformer (come T5 o BART). Qui, il Decoder non guarda solo un vettore statico, ma utilizza la Cross-Attention per focalizzarsi su parti specifiche dell'output dell'Encoder a ogni step di generazione.

Implementazione in PyTorch

Di seguito, un'implementazione pulita di un modello Encoder-Decoder basato su Transformer. Utilizziamo i moduli nativi nn.Transformer per costruire una struttura pronta per task generici di Seq2Seq, come traduzione o summarization.

import torch
import torch.nn as nn
import math

class TransformerEncoderDecoder(nn.Module):
    def __init__(
        self, 
        src_vocab_size: int, 
        tgt_vocab_size: int, 
        d_model: int = 512, 
        nhead: int = 8, 
        num_encoder_layers: int = 6, 
        num_decoder_layers: int = 6, 
        dim_feedforward: int = 2048, 
        dropout: float = 0.1
    ):
        super().__init__()
        
        # Embedding layers
        self.src_embedding = nn.Embedding(src_vocab_size, d_model)
        self.tgt_embedding = nn.Embedding(tgt_vocab_size, d_model)
        
        # Positional Encoding (essenziale per i Transformer)
        self.pos_encoder = PositionalEncoding(d_model, dropout)
        
        # Core Transformer Architecture
        # PyTorch gestisce internamente la logica Encoder-Decoder e Cross-Attention
        self.transformer = nn.Transformer(
            d_model=d_model,
            nhead=nhead,
            num_encoder_layers=num_encoder_layers,
            num_decoder_layers=num_decoder_layers,
            dim_feedforward=dim_feedforward,
            dropout=dropout,
            batch_first=True,
            norm_first=True,
            activation="gelu"
        )
        
        # Linear layer finale per proiettare sul vocabolario target
        self.lm_head = nn.Linear(d_model, tgt_vocab_size)

    def forward(self, src, tgt, src_mask=None, tgt_mask=None, src_padding_mask=None, tgt_padding_mask=None):
        """
        src: Tensor [batch_size, src_seq_len]
        tgt: Tensor [batch_size, tgt_seq_len]
        """
        # Applicazione embeddings + positional encoding
        src_emb = self.pos_encoder(self.src_embedding(src))
        tgt_emb = self.pos_encoder(self.tgt_embedding(tgt))
        
        # Passaggio attraverso il Transformer
        # L'output ha shape [batch_size, tgt_seq_len, d_model]
        outs = self.transformer(
            src=src_emb, 
            tgt=tgt_emb, 
            src_mask=src_mask, 
            tgt_mask=tgt_mask,
            memory_key_padding_mask=src_padding_mask,
            tgt_key_padding_mask=tgt_padding_mask
        )
        
        # Proiezione finale per ottenere i logits
        logits = self.lm_head(outs)
        return logits

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout=0.1, max_len=5000):
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer('pe', pe.unsqueeze(0))

    def forward(self, x):
        x = x + self.pe[:, :x.size(1)]
        return self.dropout(x)

In questo codice, il metodo forward gestisce il flusso dati. Notiamo nuovamente che durante il training, utilizziamo il Teacher Forcing: passiamo al Decoder la sequenza target corretta (shiftata di una posizione), applicando una tgt_mask causale per impedire al modello di "vedere nel futuro".

Concretamente, ecco un esempio:

CAUSAL ATTENTION MASK (0 = Mascherato/-inf, 1 = Visibile):
         <BOS> Ciao  mi  chiamo Mauro
<BOS>      1     0    0    0      0
Ciao       1     1    0    0      0
mi         1     1    1    0      0
chiamo     1     1    1    1      0
Mauro      1     1    1    1      1

Questa matrice triangolare inferiore assicura che, ad esempio, quando il modello sta processando la parola "mi" (riga 3), possa guardare indietro a "<BOS>", "Ciao" e se stesso, ma non possa accedere a "chiamo" o "Mauro", preservando così la proprietà autoregressiva!

Approfondimenti sugli Encoder-Decoder

Mauro Sciancalepore - Notizie AI, Deep Learning e Ricerca

Resta aggiornato sulle ultime notizie di Intelligenza Artificiale e Deep Learning. Approfondimenti completi sulla ricerca e stato dell'arte.

© 2026 mauroscia.it
Tutti i diritti riservati.