Phase 05 - Lesson 09
Modelos Secuencia a Secuencia
Dos RNNs fingiendo ser un traductor. El cuello de botella al que se enfrentan es la razón por la cual existe la atención.
Tipo: Build Idiomas: Python Prerrequisitos: Fase 5 · 08 (CNNs + RNNs para texto), Fase 3 · 11 (Intro a PyTorch) Tiempo: ~75 minutos
El Problema
La clasificación mapeia una secuencia de longitud variable a una sola etiqueta. La traducción mapea una secuencia de longitud variable a otra secuencia de longitud variable. La entrada y la salida viven en vocabularios diferentes, posiblemente en diferentes idiomas, sin ninguna garantía de paridad de longitud.
La arquitectura seq2seq (Sutskever, Vinyals, Le, 2014) resolvió esto con una receta deliberadamente simple. Dos RNNs. Una lee la oración de origen y produce un vector de contexto de tamaño fijo. La otra lee ese vector y genera la oración de destino token por token. El mismo código que escribiste para la lección 08, pegado de manera diferente.
Vale a la pena estudiar esto por dos razones. Primero, el cuello de botella del vector de contexto es la falla más útil pedagógicamente en PLN. Motiva todo en lo que la atención y los transformers son buenos. Segundo, la receta de entrenamiento (teacher forcing, scheduled sampling, beam search en la inferencia) todavía se aplica a todos los sistemas de generación modernos, incluidos los LLMs.
El Concepto
Codificador (encoder). Una RNN que lee la oración de origen. Su estado oculto final es el vector de contexto — un resumen de tamaño fijo de toda la entrada. Supuestamente, no se pierde nada excepto la fuente.
Decodificador (decoder). Otra RNN inicializada a partir del vector de contexto. En cada paso, toma el token generado anteriormente como entrada y produce una distribución sobre el vocabulario de destino. Haz un muestreo o argmax para elegir el próximo token. Aliméntalo de vuelta. Repite hasta que se produzca un token <EOS> o se alcance la longitud máxima.
Entrenamiento: Pérdida de entropía cruzada en cada paso del decodificador, sumada a lo largo de la secuencia. Backpropagation estándar a través del tiempo en ambas redes.
Teacher forcing. Durante el entrenamiento, la entrada del decodificador en el paso t es el token real (ground-truth) en la posición t-1, no la predicción anterior del propio decodificador. Esto estabiliza el entrenamiento; sin esto, los errores iniciales se propagan en cascata y el modelo nunca aprende. En la inferencia, debes usar las predicciones del propio modelo, por lo que siempre existe una brecha de distribución entre el entrenamiento y la inferencia. Esa brecha se llama exposure bias (sesgo de exposición).
El cuello de botella. Todo lo que el codificador aprendió sobre la fuente debe comprimirse en ese único vector de contexto. Las oraciones largas pierden detalles. Las palabras raras se vuelven borrosas. La reordenación (chat noir vs. black cat) tiene que ser memorizada, no computada.
La atención (lección 10) corrige esto permitiendo que el decodificador mire cada estado oculto del codificador, no solo el último. Esa es toda la propuesta.
Constrúyelo
Paso 1: un codificador
import torch
import torch.nn as nn
class Encoder(nn.Module):
def __init__(self, src_vocab_size, embed_dim, hidden_dim):
super().__init__()
self.embed = nn.Embedding(src_vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(embed_dim, hidden_dim, batch_first=True)
def forward(self, src):
e = self.embed(src)
outputs, hidden = self.gru(e)
return outputs, hidden
outputs tiene la forma [batch, seq_len, hidden_dim] — un estado oculto por posición de entrada. hidden tiene la forma [1, batch, hidden_dim] — el paso final. La lección 08 decía "haz pooling sobre las salidas (outputs) para la clasificación". Aquí mantenemos el último estado oculto como el vector de contexto e ignoramos las salidas por paso.
Paso 2: un decodificador
class Decoder(nn.Module):
def __init__(self, tgt_vocab_size, embed_dim, hidden_dim):
super().__init__()
self.embed = nn.Embedding(tgt_vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(embed_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, tgt_vocab_size)
def forward(self, token, hidden):
e = self.embed(token)
out, hidden = self.gru(e, hidden)
logits = self.fc(out)
return logits, hidden
El decodificador se llama un paso a la vez. Entrada: un lote (batch) de tokens individuales y el estado oculto actual. Salida: logits del vocabulario para el siguiente token y el estado oculto actualizado.
Paso 3: bucle de entrenamiento con teacher forcing
def train_batch(encoder, decoder, src, tgt, bos_id, optimizer, teacher_forcing_ratio=0.9):
optimizer.zero_grad()
_, hidden = encoder(src)
batch_size, tgt_len = tgt.shape
input_token = torch.full((batch_size, 1), bos_id, dtype=torch.long)
loss = 0.0
loss_fn = nn.CrossEntropyLoss(ignore_index=0)
for t in range(tgt_len):
logits, hidden = decoder(input_token, hidden)
step_loss = loss_fn(logits.squeeze(1), tgt[:, t])
loss += step_loss
use_teacher = torch.rand(1).item() < teacher_forcing_ratio
if use_teacher:
input_token = tgt[:, t].unsqueeze(1)
else:
input_token = logits.argmax(dim=-1)
loss.backward()
optimizer.step()
return loss.item() / tgt_len
Dos perillas que vale la pena mencionar. ignore_index=0 omite la pérdida en los tokens de relleno (padding). teacher_forcing_ratio es la probabilidad de usar el token real frente a la predicción del modelo en cada paso. Comienza en 1.0 (teacher forcing completo) y redúcelo gradualmente hasta ~0.5 a lo largo del entrenamiento para cerrar la brecha de exposure bias.
Paso 4: bucle de inferencia (greedy)
@torch.no_grad()
def greedy_decode(encoder, decoder, src, bos_id, eos_id, max_len=50):
_, hidden = encoder(src)
batch_size = src.shape[0]
input_token = torch.full((batch_size, 1), bos_id, dtype=torch.long)
output_ids = []
for _ in range(max_len):
logits, hidden = decoder(input_token, hidden)
next_token = logits.argmax(dim=-1)
output_ids.append(next_token)
input_token = next_token
if (next_token == eos_id).all():
break
return torch.cat(output_ids, dim=1)
La decodificación greedy elige el token de mayor probabilidad en cada paso. Puede desviarse: una vez que te comprometes con un token, no puedes retractarte. Beam search (búsqueda de haz) mantiene vivas las k secuencias parciales de mayor puntuación y elige la secuencia completa de mayor puntuación al final. Un ancho de haz (beam width) de 3-5 es lo estándar.
Paso 5: el cuello de botella, demostrado
Entrena el modelo en una tarea de copia de juguete: origen [a, b, c, d, e], destino [a, b, c, d, e]. Incrementa la longitud de la secuencia. Observa la precisión.
seq_len=5 copy accuracy: 98%
seq_len=10 copy accuracy: 91%
seq_len=20 copy accuracy: 62%
seq_len=40 copy accuracy: 23%
Un solo estado oculto de GRU no puede memorizar sin pérdidas una entrada de 40 tokens. La información está allí en cada paso del codificador, pero el decodificador solo ve el último estado. La atención corrige esto directamente.
Úsalo
PyTorch tiene nn.Transformer y plantillas seq2seq basadas en nn.LSTM. La biblioteca transformers de Hugging Face distribuye modelos codificador-decodificador completos (BART, T5, mBART, NLLB) entrenados con miles de millones de tokens.
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained("facebook/bart-base")
model = AutoModelForSeq2SeqLM.from_pretrained("facebook/bart-base")
src = tok("Translate this to French: Hello, how are you?", return_tensors="pt")
out = model.generate(**src, max_new_tokens=50, num_beams=4)
print(tok.decode(out[0], skip_special_tokens=True))
Los codificadores-decodificadores modernos abandonaron las RNNs por los transformers. La estructura de alto nivel (codificador, decodificador, generar token por token) es idéntica a la del artículo seq2seq de 2014. El mecanismo dentro de cada bloque es diferente.
Cuándo seguir recurriendo a seq2seq basado en RNN
Casi nunca, para proyectos nuevos. Excepciones específicas:
- Traducción en streaming donde consumes la entrada un token a la vez con memoria limitada.
- Generación de texto en el dispositivo (on-device) donde el costo de memoria del transformer es prohibitivo.
- Pedagogía. Comprender el cuello de botella del codificador-decodificador es el camino más rápido para entender por qué ganaron los transformers.
Exposure bias y sus mitigaciones
- Scheduled sampling (muestreo programado). Reduce gradualmente la tasa de teacher forcing durante el entrenamiento para que el modelo aprenda a recuperarse de sus propios errores.
- Minimum risk training (entrenamiento de riesgo mínimo). Entrena sobre la puntuación BLEU a nivel de oración en lugar de la entropía cruzada a nivel de token. Más cercano a lo que realmente deseas.
- Ajuste fino por aprendizaje por refuerzo (reinforcement learning fine-tuning). Recompensa al generador de secuencias con una métrica. Utilizado en el RLHF de los LLMs modernos.
Los tres se siguen aplicando a la generación basada en transformers.
Entrégalo
Guarda como outputs/prompt-seq2seq-design.md:
---
name: seq2seq-design
description: Design a sequence-to-sequence pipeline for a given task.
phase: 5
lesson: 09
---
Given a task (translation, summarization, paraphrase, question rewrite), output:
1. Architecture. Pretrained transformer encoder-decoder (BART, T5, mBART, NLLB) is the default. RNN-based seq2seq only for specific constraints.
2. Starting checkpoint. Name it (`facebook/bart-base`, `google/flan-t5-base`, `facebook/nllb-200-distilled-600M`). Match the checkpoint to task and language coverage.
3. Decoding strategy. Greedy for deterministic output, beam search (width 4-5) for quality, sampling with temperature for diversity. One sentence justification.
4. One failure mode to verify before shipping. Exposure bias manifests as generation drift on longer outputs; sample 20 outputs at the 90th-percentile length and eyeball.
Refuse to recommend training a seq2seq from scratch for under a million parallel examples. Flag any pipeline that uses greedy decoding for user-facing content as fragile (greedy repeats and loops).
Ejercicios
- Fácil. Implementa la tarea de copia de juguete. Entrena un seq2seq con GRU en pares de entrada-salida donde el destino sea igual a la fuente. Mide la precisión en longitudes de 5, 10, 20. Reproduce el cuello de botella.
- Medio. Agrega decodificación por beam search con un ancho de haz de 3. Mide el BLEU en un corpus paralelo pequeño en comparación con greedy. Documenta dónde gana beam search (generalmente en los últimos tokens) y dónde no hace diferencia.
- Difícil. Realiza el ajuste fino (fine-tune) de
facebook/bart-baseen un conjunto de datos de paráfrasis de 10k pares. Compara la salida beam-4 del modelo ajustado con la del modelo base en entradas de prueba (held-out). Reporta el BLEU y elige 10 ejemplos cualitativos.
Términos Clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Encoder | RNN de entrada | Lee la fuente. Produce estados ocultos por paso y un vector de contexto final. |
| Decoder | RNN de salida | Inicializado a partir del vector de contexto. Genera tokens de destino uno a la vez. |
| Context vector | El resumen | Estado oculto final del codificador. Tamaño fijo. El cuello de botella que la atención resuelve. |
| Teacher forcing | Usar tokens reales | Alimenta el token anterior real (ground-truth) en el momento del entrenamiento. Estabiliza el aprendizaje. |
| Exposure bias | Brecha de entrenamiento/prueba | El modelo entrenado con tokens reales nunca practicó recuperarse de sus propios errores. |
| Beam search | Mejor decodificación | Mantiene vivas las top-k secuencias parciales en cada paso en lugar de comprometerse de forma greedy. |
Lecturas Adicionales
- Sutskever, Vinyals, Le (2014). Sequence to Sequence Learning with Neural Networks — el artículo original de seq2seq. Cuatro páginas.
- Cho et al. (2014). Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation — introdujo la GRU y el marco de codificador-decodificador.
- Bahdanau, Cho, Bengio (2014). Neural Machine Translation by Jointly Learning to Align and Translate — el artículo de atención. Léelo inmediatamente después de esta lección.
- PyTorch NLP from Scratch tutorial — código de seq2seq + atención construible.