Secuencia a Secuencia (Seq2Seq)

S1: Arquitecturas Codificador-Decodificador

Prof. Francisco Suárez

Universidad Católica Boliviana

2026-03-25

Agenda de Hoy

  1. 🔄 De clasificación a generación
  2. 📥 El Codificador (Encoder)
  3. 📤 El Decodificador (Decoder)
  4. 🎓 Entrenamiento: Teacher Forcing
  5. 🔍 Estrategias de Decodificación
  6. 🧪 Experimento: modelo Seq2Seq completo

Objetivo

Entender la arquitectura Codificador-Decodificador (Seq2Seq) que permite mapear secuencias de longitud variable a otras secuencias de longitud variable.

Prerequisitos: RNNs, LSTM, GRU (Semana 6)

De Clasificación a Generación

¿Qué hemos resuelto hasta ahora?

Con RNNs, LSTM y GRU hemos aprendido a:

Clasificación de secuencias

  • Entrada: secuencia de tokens
  • Salida: una etiqueta (sentimiento, tema, etc.)
  • \(h_T \rightarrow \text{softmax} \rightarrow y\)

Etiquetado de secuencias

  • Entrada: secuencia de tokens
  • Salida: una etiqueta por token (POS tagging)
  • \(h_t \rightarrow \text{softmax} \rightarrow y_t\)

Limitación

En ambos casos, la longitud de la salida está determinada por la longitud de la entrada (o es un escalar).

¿Qué pasa cuando la salida tiene longitud diferente?

El Problema: Salida de Longitud Variable

Muchas tareas de NLP requieren generar secuencias de longitud diferente a la entrada:

Tarea Entrada Salida Longitud
Traducción “el gato duerme” (3 tokens) “the cat sleeps” (3 tokens) Puede diferir
Resumen Artículo (500 tokens) Resumen (50 tokens) Salida << Entrada
Chatbot “¿Cómo estás?” (2 tokens) “¡Muy bien, gracias!” (3 tokens) Impredecible
Descripción de imágenes Imagen (vector fijo) “Un gato sentado en…” Variable

El Reto

Necesitamos una arquitectura que acepte una secuencia de longitud \(T_x\) y genere otra de longitud \(T_y\), donde \(T_x \neq T_y\) en general.

La Solución: Codificador-Decodificador

La idea clave de Sutskever et al. (2014) y Cho et al. (2014):

Dividir el problema en dos partes:

  1. Codificador (Encoder): lee la secuencia de entrada y la comprime en un vector de contexto \(\mathbf{c}\)
  2. Decodificador (Decoder): genera la secuencia de salida token por token, condicionado en \(\mathbf{c}\)

El Codificador (Encoder) 📥

Arquitectura del Encoder

El codificador es una RNN (LSTM o GRU) que procesa la secuencia de entrada:

\[x_1, x_2, \ldots, x_{T_x} \xrightarrow{\text{Encoder}} \mathbf{c}\]

Paso a paso

  1. Cada token \(x_t\) se convierte en un vector con una capa de embedding
  2. La RNN procesa la secuencia de embeddings
  3. El último estado oculto \(h_{T_x}\) se usa como vector de contexto:

\[\mathbf{c} = h_{T_x}\]

Variantes del vector de contexto

  • Último estado: \(\mathbf{c} = h_{T_x}\) (Cho et al., 2014)
  • Concatenación: \(\mathbf{c} = [\overrightarrow{h}_{T_x}; \overleftarrow{h}_1]\) (bidireccional)
  • Promedio: \(\mathbf{c} = \frac{1}{T_x}\sum_t h_t\)
  • En LSTM: se pasan tanto \(h_{T_x}\) como \(c_{T_x}\)

Intuición

El encoder comprime toda la información de la secuencia de entrada en un vector de dimensión fija \(\mathbf{c} \in \mathbb{R}^{D_h}\).

Encoder en PyTorch

Code
import torch
import torch.nn as nn

class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_layers=1, dropout=0.0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.rnn = nn.GRU(embed_dim, hidden_dim,
                          num_layers=n_layers, batch_first=True,
                          dropout=dropout if n_layers > 1 else 0.0)
    
    def forward(self, src):
        """
        src: (batch, src_len) — índices de tokens
        Retorna: hidden (n_layers, batch, hidden_dim) — vector de contexto
        """
        embedded = self.embedding(src)           # (batch, src_len, embed_dim)
        outputs, hidden = self.rnn(embedded)     # hidden: (n_layers, batch, hidden_dim)
        return hidden                            # Último estado oculto = contexto

# Ejemplo
enc = Encoder(vocab_size=100, embed_dim=32, hidden_dim=64)
x = torch.randint(0, 100, (4, 8))  # batch=4, longitud=8
context = enc(x)
print(f"Entrada:  {x.shape}")           # (4, 8)
print(f"Contexto: {context.shape}")     # (1, 4, 64)
Entrada:  torch.Size([4, 8])
Contexto: torch.Size([1, 4, 64])

Observación

El encoder toma una secuencia de cualquier longitud y produce un vector de tamaño fijo (1, batch, 64). Toda la información de la entrada queda comprimida en 64 dimensiones.

El Decodificador (Decoder) 📤

Arquitectura del Decoder

El decodificador es otra RNN que genera la secuencia de salida token por token:

\[\mathbf{c} \xrightarrow{\text{Decoder}} y_1, y_2, \ldots, y_{T_y}\]

Generación Autoregresiva

  1. Estado inicial: \(s_0 = \mathbf{c}\) (vector de contexto)
  2. Primer input: token especial <SOS> (Start of Sequence)
  3. En cada paso \(t\):
    • \(s_t = \text{GRU}(y_{t-1}, s_{t-1})\)
    • \(P(y_t) = \text{softmax}(W_o \cdot s_t)\)
  4. Se detiene al generar <EOS> (End of Sequence)

Clave: Autoregresión

Cada predicción depende de las predicciones anteriores. Un error temprano puede propagarse → error cascading.

Decoder en PyTorch

Code
class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_layers=1, dropout=0.0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.rnn = nn.GRU(embed_dim, hidden_dim,
                          num_layers=n_layers, batch_first=True,
                          dropout=dropout if n_layers > 1 else 0.0)
        self.fc_out = nn.Linear(hidden_dim, vocab_size)
    
    def forward(self, input_token, hidden):
        """
        input_token: (batch, 1) — token anterior
        hidden: (n_layers, batch, hidden_dim) — estado oculto
        Retorna: prediction (batch, vocab_size), hidden actualizado
        """
        embedded = self.embedding(input_token)       # (batch, 1, embed_dim)
        output, hidden = self.rnn(embedded, hidden)  # output: (batch, 1, hidden_dim)
        prediction = self.fc_out(output.squeeze(1))  # (batch, vocab_size)
        return prediction, hidden

# Ejemplo
dec = Decoder(vocab_size=100, embed_dim=32, hidden_dim=64)
sos_token = torch.zeros(4, 1, dtype=torch.long)  # <SOS> = 0
pred, new_hidden = dec(sos_token, context)
print(f"Token entrada:  {sos_token.shape}")     # (4, 1)
print(f"Predicción:     {pred.shape}")          # (4, 100)
print(f"Nuevo hidden:   {new_hidden.shape}")    # (1, 4, 64)
Token entrada:  torch.Size([4, 1])
Predicción:     torch.Size([4, 100])
Nuevo hidden:   torch.Size([1, 4, 64])

Nota

El decoder genera un token a la vez. Para generar una secuencia completa, llamamos al decoder en un bucle, alimentando cada predicción como entrada del siguiente paso.

Entrenamiento: Teacher Forcing 🎓

El Dilema del Entrenamiento

Sin Teacher Forcing (Free Running)

  • El decoder usa sus propias predicciones como entrada
  • Si comete un error temprano → se acumulan
  • Entrenamiento inestable y lento

Con Teacher Forcing

  • El decoder recibe el token correcto (ground truth) como entrada en cada paso
  • Ignora sus propias predicciones durante entrenamiento
  • Convergencia más rápida y estable

Exposure Bias

Con teacher forcing, el modelo nunca ve sus propios errores durante entrenamiento. En inferencia, debe usar sus predicciones → distribución diferente. Esto se llama exposure bias.

Seq2Seq Completo en PyTorch

Code
import random

class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder, sos_idx):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.sos_idx = sos_idx
    
    def forward(self, src, trg_len, trg=None, teacher_forcing_ratio=0.5):
        """
        src: (batch, src_len)
        trg_len: longitud de la secuencia objetivo
        trg: (batch, trg_len) — solo durante entrenamiento
        """
        batch_size = src.size(0)
        vocab_size = self.decoder.fc_out.out_features
        
        # 1. Codificar: secuencia → vector de contexto
        hidden = self.encoder(src)
        
        # 2. Decodificar: vector de contexto → secuencia
        outputs = torch.zeros(batch_size, trg_len, vocab_size)
        input_token = torch.full((batch_size, 1), self.sos_idx, dtype=torch.long)
        
        for t in range(trg_len):
            prediction, hidden = self.decoder(input_token, hidden)
            outputs[:, t] = prediction
            
            # Teacher forcing: usar token real o predicción
            if trg is not None and random.random() < teacher_forcing_ratio:
                input_token = trg[:, t:t+1]                       # Token correcto
            else:
                input_token = prediction.argmax(1, keepdim=True)  # Predicción
        
        return outputs

Experimento: Invirtiendo Secuencias 🔬

Tarea: Invertir una Secuencia

Para validar nuestra implementación, entrenamos un modelo Seq2Seq en una tarea simple:

\[[3, 7, 1, 5] \xrightarrow{\text{Seq2Seq}} [5, 1, 7, 3]\]

Code
import torch.optim as optim

# --- Configuración ---
PAD, SOS, EOS = 0, 1, 2
NUM_DIGITS = 10
VOCAB_SIZE = NUM_DIGITS + 3   # PAD, SOS, EOS + dígitos 0-9 (mapeados a 3-12)

def generate_pairs(n, min_len=4, max_len=8):
    """Genera pares (secuencia, secuencia invertida) con padding"""
    src_list, trg_list = [], []
    for _ in range(n):
        length = random.randint(min_len, max_len)
        seq = [random.randint(3, VOCAB_SIZE - 1) for _ in range(length)]
        src_list.append(seq + [PAD] * (max_len - length))
        trg_list.append(list(reversed(seq)) + [EOS] + [PAD] * (max_len - length))
    return torch.tensor(src_list), torch.tensor(trg_list)

# Datos
torch.manual_seed(42)
random.seed(42)
train_src, train_trg = generate_pairs(3000)
test_src, test_trg = generate_pairs(200)

print(f"Ejemplo entrada:  {train_src[0].tolist()}")
print(f"Ejemplo objetivo: {train_trg[0].tolist()}")
print(f"(PAD=0, SOS=1, EOS=2, dígitos=3..12)")
Ejemplo entrada:  [3, 7, 6, 6, 0, 0, 0, 0]
Ejemplo objetivo: [6, 6, 7, 3, 2, 0, 0, 0, 0]
(PAD=0, SOS=1, EOS=2, dígitos=3..12)

Entrenamiento

Code
# Modelo
EMBED_DIM = 32
HIDDEN_DIM = 64

encoder_model = Encoder(VOCAB_SIZE, EMBED_DIM, HIDDEN_DIM)
decoder_model = Decoder(VOCAB_SIZE, EMBED_DIM, HIDDEN_DIM)
model = Seq2Seq(encoder_model, decoder_model, sos_idx=SOS)

optimizer = optim.Adam(model.parameters(), lr=0.003)
criterion = nn.CrossEntropyLoss(ignore_index=PAD)

# Entrenamiento
losses = []
BATCH_SIZE = 128
N_EPOCHS = 40

for epoch in range(N_EPOCHS):
    model.train()
    epoch_loss = 0
    n_batches = 0
    indices = torch.randperm(len(train_src))
    
    for i in range(0, len(train_src), BATCH_SIZE):
        batch_idx = indices[i:i+BATCH_SIZE]
        src = train_src[batch_idx]
        trg = train_trg[batch_idx]
        
        output = model(src, trg.size(1), trg, teacher_forcing_ratio=0.5)
        loss = criterion(output.reshape(-1, VOCAB_SIZE), trg.reshape(-1))
        
        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        epoch_loss += loss.item()
        n_batches += 1
    
    avg_loss = epoch_loss / n_batches
    losses.append(avg_loss)
    if (epoch + 1) % 10 == 0:
        print(f"Época {epoch+1:3d}/{N_EPOCHS} | Loss: {avg_loss:.4f}")
Época  10/40 | Loss: 0.6181
Época  20/40 | Loss: 0.2410
Época  30/40 | Loss: 0.1194
Época  40/40 | Loss: 0.0487

Curva de Aprendizaje

Evaluación: ¿Funciona?

Code
model.eval()
with torch.no_grad():
    # Decodificación greedy (sin trg → usa predicciones propias)
    test_output = model(test_src, test_trg.size(1))
    predictions = test_output.argmax(dim=-1)

# Calcular accuracy (ignorando PAD)
correct = 0
total = 0
for i in range(len(test_src)):
    for j in range(test_trg.size(1)):
        if test_trg[i, j] != PAD:
            total += 1
            if predictions[i, j] == test_trg[i, j]:
                correct += 1

print(f"Accuracy token-level: {correct/total:.1%}\n")

# Mostrar ejemplos
print("=" * 55)
print(f"{'Entrada':>25s}{'Predicción':<25s}")
print("=" * 55)
for i in range(8):
    src_tokens = [t.item() for t in test_src[i] if t.item() != PAD]
    pred_tokens = [t.item() for t in predictions[i] if t.item() not in (PAD, EOS, SOS)]
    trg_tokens = [t.item() for t in test_trg[i] if t.item() not in (PAD, EOS, SOS)]
    
    src_str = str(src_tokens)
    pred_str = str(pred_tokens)
    match = "✅" if pred_tokens == trg_tokens else "❌"
    print(f"{src_str:>25s}{pred_str:<20s} {match}")
Accuracy token-level: 94.4%

=======================================================
                  Entrada → Predicción               
=======================================================
[8, 3, 12, 5, 10, 4, 4, 8] → [8, 4, 10, 5, 12, 3, 12, 8] ❌
          [9, 4, 3, 8, 6] → [6, 8, 3, 4, 9]      ✅
  [3, 12, 5, 6, 3, 3, 10] → [10, 3, 3, 6, 5, 12, 3] ✅
            [9, 12, 8, 3] → [3, 8, 12, 9]        ✅
           [10, 7, 4, 11] → [11, 4, 7, 10]       ✅
[7, 11, 8, 7, 6, 5, 12, 11] → [11, 12, 5, 6, 11, 8, 7, 11] ❌
 [9, 12, 11, 12, 6, 9, 8] → [8, 9, 6, 12, 11, 12, 9] ✅
        [12, 7, 7, 10, 4] → [4, 10, 7, 7, 12]    ✅

Estrategias de Decodificación 🔍

Ejemplo Visual: Beam Search con \(k=2\)

En la práctica

  • Beam width \(k = 4\)\(10\) es típico en traducción automática
  • Se normaliza por longitud para no favorecer secuencias cortas: \(\frac{1}{T_y} \sum_t \log P(y_t \mid y_{<t}, \mathbf{c})\)
  • Greedy decoding es el caso especial donde \(k=1\)

El Cuello de Botella 🔬

El Problema del Vector de Contexto Fijo

La limitación fundamental

Toda la información de la entrada se comprime en un solo vector \(\mathbf{c} \in \mathbb{R}^{D_h}\):

  • Secuencia de 5 tokens → \(\mathbf{c}\) de 64 dims ✅
  • Secuencia de 50 tokens → \(\mathbf{c}\) de 64 dims ⚠️
  • Secuencia de 500 tokens → \(\mathbf{c}\) de 64 dims ❌

El encoder debe comprimir toda la semántica en un vector de dimensión fija, sin importar la longitud de la entrada.

Adelanto: Semana 7 S3

La solución a este cuello de botella será el mecanismo de Atención (Attention), que permite al decoder “mirar” diferentes partes de la entrada en cada paso de generación.

Resumen

Lo Que Aprendimos Hoy

Conceptos

  • La arquitectura Seq2Seq mapea secuencias de longitud variable a otras secuencias
  • El Encoder comprime la entrada en un vector de contexto \(\mathbf{c}\)
  • El Decoder genera la salida autoregresivamente
  • Teacher forcing acelera el entrenamiento pero causa exposure bias
  • El vector de contexto fijo es un cuello de botella

Práctica

  • Encoder: GRU que produce \(h_{T_x}\)
  • Decoder: GRU + Linear que genera token por token
  • Seq2Seq: combina ambos con teacher forcing configurable
  • Greedy decoding: \(\arg\max\) en cada paso
  • Beam search: top-\(k\) candidatos para mejores resultados

Ecuaciones Clave

Componente Ecuación
Encoder \(h_t = \text{GRU}(x_t, h_{t-1})\); \(\mathbf{c} = h_{T_x}\)
Decoder \(s_t = \text{GRU}(y_{t-1}, s_{t-1})\); \(P(y_t) = \text{softmax}(W_o s_t)\)
Teacher Forcing \(\text{input}_t = y_{t-1}^{\text{real}}\) (entrenamiento) vs \(\hat{y}_{t-1}\) (inferencia)
Greedy \(y_t = \arg\max_w P(w \mid y_{<t}, \mathbf{c})\)
Beam Search \(Y^* = \arg\max_Y \frac{1}{T_y}\sum_{t} \log P(y_t \mid y_{<t}, \mathbf{c})\)

Para la Próxima Sesión 📚

Semana 7, S2: Traducción Automática Neuronal (NMT)

  • Aplicación principal de Seq2Seq: traducción automática
  • Pares de idiomas, tokenización, vocabularios separados
  • Métricas de evaluación: BLEU score
  • Entrenamiento en datos paralelos

Lectura:

  • Sutskever et al. (2014): Sequence to Sequence Learning with Neural Networks (secciones 3-4)
  • Jurafsky & Martin, Cap. 10: Machine Translation and Encoder-Decoder Models
  • Wu et al. (2016): Google’s Neural Machine Translation System

¿Preguntas? 🙋

¡Gracias!

📧 fsuarez@ucb.edu.bo

🔗 Materiales: github.com/fjsuarez/ucb-nlp