💻 Tutorial paso a paso

Diseño y aplicación de LSTM

Guía completa que cubre las 5 arquitecturas LSTM — one-to-one, one-to-many, many-to-one, many-to-many síncrono y asíncrono — con ejemplos prácticos completos en PyTorch. Desde series temporales hasta Seq2Seq, cada patrón explicado con código 100% reproducible, diagramas y buenas prácticas.

⏱️ ~70 min 📊 Nivel: intermedio 🔥 PyTorch 2.x · nn.LSTM · 5 arquitecturas

Requisitos previos

  • Python 3.9+ y PyTorch 2.x instalados
  • Conceptos básicos de redes neuronales: forward pass, loss, backpropagation
  • Haber leído la teoría de RNN (hidden state, BPTT, vanishing gradients)
  • Haber leído la teoría de LSTM (gates, cell state, ecuaciones)
  • Opcional: GPU con CUDA (los ejemplos funcionan en CPU)
1

Visión general: las 5 arquitecturas LSTM

Una LSTM es una celda recurrente que procesa secuencias manteniendo un hidden state h_t y un cell state c_t. Pero la forma en que conectamos inputs y outputs determina arquitecturas radicalmente distintas. Andrej Karpathy lo resumió en su célebre post The Unreasonable Effectiveness of RNNs: la misma celda LSTM puede usarse para 5 patrones de relación input-output.

ONE-TO-ONE LSTM x y ONE-TO-MANY LSTM LSTM LSTM LSTM x₀ y₀ y₁ y₂ y₃ MANY-TO-ONE LSTM LSTM LSTM LSTM x₀ x₁ x₂ x₃ y MANY-TO-MANY (sync) LSTM LSTM LSTM LSTM x₀ x₁ x₂ x₃ y₀ y₁ y₂ y₃ MANY-TO-MANY (async / Seq2Seq) ENC ENC ENC DEC DEC x₀ x₁ x₂ y₀ y₁

1.1 Los 5 patrones

1.2 Widget interactivo: explorador de arquitecturas

🧪 Explorador de patrones LSTM

Secuencia completa → un solo output
Descripción
Clasificación de texto, sentimiento
Caso de uso
(1, T, feat) → (1, out)
Shape I/O

1.3 ¿Cuándo usar cada patrón?

PatrónEjemplo prácticoInputOutputPaso del tutorial
One-to-One Serie temporal (paso a paso) x_t (1 valor) y_t (1 predicción) Paso 3
Many-to-One Clasificación de sentimiento [x₀…x_T] (secuencia) y (1 clase) Paso 4
One-to-Many Generación de texto char-level x₀ (1 seed) [y₀…y_T] (secuencia) Paso 5
Many-to-Many (sync) POS tagging / NER [x₀…x_T] [y₀…y_T] (misma longitud) Paso 6
Many-to-Many (async) Traducción / conversión de formato [x₀…x_S] [y₀…y_T] (S ≠ T) Paso 7
💡 La celda es la misma. En los 5 casos usamos exactamente la misma nn.LSTM de PyTorch. Lo que cambia es cómo alimentamos los datos (inputs), cómo extraemos las predicciones (outputs) y cómo gestionamos el hidden state entre pasos o entre encoder y decoder.

1.4 Nuestro plan

En este tutorial implementaremos cada patrón con un ejemplo real y completo:

  1. Setup — Imports comunes y funciones auxiliares reutilizables.
  2. One-to-One — Serie temporal: Airline Passengers, predicción autoregresiva.
  3. Many-to-One — Clasificación de sentimiento con IMDB.
  4. One-to-Many — Generación de texto con Shakespeare (char-level).
  5. Many-to-Many sync — POS tagging de frases en español.
  6. Many-to-Many async — Seq2Seq encoder-decoder para conversión de fechas.
  7. Buenas prácticas — Errores comunes, tips y referencias.
2

Setup: entorno, imports y utilidades comunes

Antes de implementar cada patrón, preparamos el entorno y definimos funciones auxiliares que reutilizaremos en todos los ejemplos. Así evitamos repetir código y nos centramos en lo que cambia: la arquitectura.

2.1 Instalación

terminal instalar dependencias
pip install torch torchvision torchtext matplotlib numpy pandas

2.2 Imports comunes

python imports que usaremos en todos los ejemplos
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader, random_split
import numpy as np
import matplotlib.pyplot as plt
import time
import math
import warnings
warnings.filterwarnings('ignore')

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
print(f"PyTorch: {torch.__version__}")
Salida
Device: cuda
PyTorch: 2.2.0

2.3 API de nn.LSTM en PyTorch

La API de nn.LSTM es fundamental. Antes de implementar los 5 patrones, repasemos sus parámetros clave:

python anatomía de nn.LSTM
# Crear una LSTM
lstm = nn.LSTM(
    input_size=10,      # Dimensión de cada vector de entrada x_t
    hidden_size=64,     # Dimensión de h_t y c_t
    num_layers=2,       # Capas LSTM apiladas (stacked)
    batch_first=True,   # Input shape: (batch, seq_len, features) — ¡IMPORTANTE!
    dropout=0.2,        # Dropout entre capas (solo si num_layers > 1)
    bidirectional=False, # True → procesa la secuencia en ambas direcciones
)

# Forward pass
batch_size, seq_len, input_size = 8, 20, 10
x = torch.randn(batch_size, seq_len, input_size)

# output: predictions en cada timestep → (batch, seq_len, hidden_size * num_directions)
# (h_n, c_n): hidden state y cell state finales → (num_layers * num_directions, batch, hidden_size)
output, (h_n, c_n) = lstm(x)

print(f"Input:      {x.shape}")           # [8, 20, 10]
print(f"Output:     {output.shape}")       # [8, 20, 64]
print(f"h_n:        {h_n.shape}")          # [2, 8, 64]  (2 layers)
print(f"c_n:        {c_n.shape}")          # [2, 8, 64]
Salida
Input:      torch.Size([8, 20, 10])
Output:     torch.Size([8, 20, 64])
h_n:        torch.Size([2, 8, 64])
c_n:        torch.Size([2, 8, 64])
L5 batch_first=True es fundamental. Sin él, el input esperado es (seq_len, batch, features). Con batch_first=True, usamos (batch, seq_len, features), que es más intuitivo.
L13 output contiene el hidden state h_t de la última capa en cada timestep. Para many-to-one, solo necesitas output[:, -1, :]. Para many-to-many sync, usas todo output.
L14 (h_n, c_n) son los estados finales. Para seq2seq, los pasas del encoder al decoder como estado inicial.

2.4 Funciones auxiliares reutilizables

python funciones comunes para entrenamiento y evaluación
def train_epoch(model, loader, criterion, optimizer, clip=1.0):
    """Entrena un epoch. Retorna loss media."""
    model.train()
    total_loss, n_batches = 0, 0
    for batch in loader:
        optimizer.zero_grad()
        # Cada loader devuelve un formato distinto — se gestiona en cada ejemplo
        loss = model.compute_loss(batch, criterion)
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), clip)
        optimizer.step()
        total_loss += loss.item()
        n_batches += 1
    return total_loss / n_batches


def evaluate(model, loader, criterion):
    """Evalúa en validación/test. Retorna loss media."""
    model.eval()
    total_loss, n_batches = 0, 0
    with torch.no_grad():
        for batch in loader:
            loss = model.compute_loss(batch, criterion)
            total_loss += loss.item()
            n_batches += 1
    return total_loss / n_batches


def plot_losses(train_losses, val_losses, title="Curvas de entrenamiento"):
    """Dibuja las curvas de loss."""
    fig, ax = plt.subplots(figsize=(8, 4))
    ax.plot(train_losses, label='Train', color='#E17055', linewidth=2)
    ax.plot(val_losses, label='Val', color='#74b9ff', linewidth=2)
    ax.set_xlabel('Epoch')
    ax.set_ylabel('Loss')
    ax.set_title(title)
    ax.legend()
    ax.grid(alpha=0.15)
    plt.tight_layout()
    plt.show()


def count_params(model):
    """Cuenta parámetros entrenables."""
    n = sum(p.numel() for p in model.parameters() if p.requires_grad)
    print(f"Parámetros entrenables: {n:,}")
    return n
💡 Patrón compute_loss: Cada modelo implementará su propio método compute_loss(batch, criterion) que desempaqueta el batch, hace forward y calcula la loss. Así, las funciones train_epoch y evaluate son genéricas y reutilizables para los 5 patrones.

Las LSTM mitigan el vanishing gradient, pero el exploding gradient sigue siendo un riesgo, especialmente con secuencias largas. clip_grad_norm_ limita la norma del gradiente a un valor máximo (clip=1.0), evitando saltos inestables en el entrenamiento.

Sin gradient clipping, un solo batch con gradientes grandes puede destruir horas de entrenamiento. Es una práctica estándar en RNN/LSTM. El valor 1.0 es un buen punto de partida; en la práctica valores entre 0.5 y 5.0 funcionan bien.

2.5 Resumen de shapes por patrón

Patrón Input a LSTM Lo que usamos del output Capa final
One-to-One (B, 1, F) output[:, 0, :] Linear(H, 1)
Many-to-One (B, T, F) output[:, -1, :] ó h_n[-1] Linear(H, C)
One-to-Many (B, 1, F) por step output en cada step Linear(H, V)
Many-to-Many sync (B, T, F) output completo Linear(H, C) por step
Many-to-Many async Enc: (B, T_s, F)
Dec: (B, T_t, F)
Enc → (h_n, c_n)
Dec → output
Linear(H, V)

Donde B = batch, T = longitud de secuencia, F = features de entrada, H = hidden size, C = clases, V = tamaño del vocabulario.

3

One-to-One: predicción autoregresiva de series temporales

En el patrón one-to-one, la LSTM recibe un input en cada timestep y produce un output, manteniendo el hidden state entre pasos. Es el patrón natural para predicción autoregresiva: en cada paso, alimentamos el valor actual y predecimos el siguiente.

LSTM LSTM LSTM LSTM x₁ x₂ x₃ x_T ŷ₂ ŷ₃ ŷ₄ ŷ_{T+1} h₀

3.1 Dataset: Airline Passengers

Usaremos el clásico dataset de pasajeros mensuales de aerolíneas (1949-1960, 144 puntos). Es un estándar en series temporales con tendencia y estacionalidad — ideal para demostrar predicción autoregresiva.

python cargar y preparar Airline Passengers
# Dataset clásico de airline passengers (Box & Jenkins, 1976)
# 144 observaciones mensuales: enero 1949 - diciembre 1960
raw_data = [
    112,118,132,129,121,135,148,148,136,119,104,118,
    115,126,141,135,125,149,170,170,158,133,114,140,
    145,150,178,163,172,178,199,199,184,162,146,166,
    171,180,193,181,183,218,230,242,209,191,172,194,
    196,196,236,235,229,243,264,272,237,211,180,201,
    204,188,235,227,234,264,302,293,259,229,203,229,
    242,233,267,269,270,315,364,347,312,274,237,278,
    284,277,317,313,318,374,413,405,355,306,271,306,
    315,301,356,348,355,422,465,467,404,347,305,336,
    340,318,362,348,363,435,491,505,404,359,310,337,
    360,342,406,396,420,472,548,559,463,407,362,405,
    417,391,419,461,472,535,622,606,508,461,390,432,
]

data = np.array(raw_data, dtype=np.float32)

# Normalizar a [0, 1]
data_min, data_max = data.min(), data.max()
data_norm = (data - data_min) / (data_max - data_min)

# Visualizar
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(data, color='#E17055', linewidth=1.5)
axes[0].set_title('Airline Passengers (original)')
axes[0].set_xlabel('Mes'); axes[0].set_ylabel('Pasajeros (miles)')
axes[1].plot(data_norm, color='#74b9ff', linewidth=1.5)
axes[1].set_title('Normalizado [0, 1]')
axes[1].set_xlabel('Mes')
plt.tight_layout(); plt.show()

print(f"Samples: {len(data)} | Min: {data.min():.0f} | Max: {data.max():.0f}")
Salida
Samples: 144 | Min: 104 | Max: 622

3.2 Preparar ventanas de entrenamiento

Para entrenar la LSTM, creamos ventanas deslizantes de longitud window_size. Cada ventana es una secuencia de inputs [x_t, x_{t+1}, …, x_{t+W-1}] y su target correspondiente [x_{t+1}, x_{t+2}, …, x_{t+W}] (desplazado un paso). Esto permite entrenar en modo many-to-many y luego hacer inferencia one-to-one.

python dataset de ventanas deslizantes
class TimeSeriesDataset(Dataset):
    """Dataset de ventanas deslizantes para series temporales."""
    def __init__(self, series, window_size=24):
        self.x, self.y = [], []
        for i in range(len(series) - window_size):
            self.x.append(series[i:i+window_size])
            self.y.append(series[i+1:i+window_size+1])  # shifted 1 step
        self.x = torch.tensor(np.array(self.x), dtype=torch.float32).unsqueeze(-1)  # (N, W, 1)
        self.y = torch.tensor(np.array(self.y), dtype=torch.float32).unsqueeze(-1)  # (N, W, 1)

    def __len__(self):
        return len(self.x)

    def __getitem__(self, idx):
        return self.x[idx], self.y[idx]

WINDOW = 24  # 2 años de datos mensuales

# Split: primeros 120 meses = train, últimos 24 = test
train_data = data_norm[:120]
test_data = data_norm[120:]

train_ds = TimeSeriesDataset(train_data, window_size=WINDOW)
train_loader = DataLoader(train_ds, batch_size=16, shuffle=True)

print(f"Train windows: {len(train_ds)}")
print(f"Input shape:   {train_ds[0][0].shape}")  # (24, 1)
print(f"Target shape:  {train_ds[0][1].shape}")   # (24, 1)
Salida
Train windows: 96
Input shape:   torch.Size([24, 1])
Target shape:  torch.Size([24, 1])
L8 .unsqueeze(-1): nn.LSTM espera (batch, seq_len, input_size). Nuestra serie es univariante (input_size=1), así que añadimos la dimensión de features.
L7 El target está desplazado un paso: y[t] = x[t+1]. Así la LSTM aprende a predecir el siguiente valor en cada timestep.

3.3 Modelo: LSTMOneToOne

python modelo one-to-one para series temporales
class LSTMOneToOne(nn.Module):
    """
    LSTM para predicción autoregresiva one-to-one.
    En entrenamiento: procesa ventana completa (teacher forcing).
    En inferencia: un paso a la vez, realimentando la predicción.
    """
    def __init__(self, input_size=1, hidden_size=64, num_layers=2, dropout=0.1):
        super().__init__()
        self.lstm = nn.LSTM(
            input_size=input_size,
            hidden_size=hidden_size,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0,
        )
        self.fc = nn.Linear(hidden_size, 1)  # 1 output por timestep

    def forward(self, x, hidden=None):
        """
        x: (batch, seq_len, 1)
        hidden: tuple (h, c) o None
        Returns: predictions (batch, seq_len, 1), (h_n, c_n)
        """
        out, (h_n, c_n) = self.lstm(x, hidden)  # out: (B, T, H)
        pred = self.fc(out)                       # pred: (B, T, 1)
        return pred, (h_n, c_n)

    def compute_loss(self, batch, criterion):
        """Para usar con train_epoch/evaluate genéricos."""
        x, y = batch
        x, y = x.to(device), y.to(device)
        pred, _ = self.forward(x)
        return criterion(pred, y)

model_oto = LSTMOneToOne(input_size=1, hidden_size=64, num_layers=2).to(device)
count_params(model_oto)
Salida
Parámetros entrenables: 34,305

3.4 Entrenamiento

python entrenar el modelo one-to-one
criterion = nn.MSELoss()
optimizer = optim.Adam(model_oto.parameters(), lr=1e-3)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=10, factor=0.5)

EPOCHS = 100
train_losses = []

for epoch in range(EPOCHS):
    loss = train_epoch(model_oto, train_loader, criterion, optimizer)
    train_losses.append(loss)
    scheduler.step(loss)

    if (epoch + 1) % 25 == 0:
        print(f"Epoch {epoch+1:3d} | Train Loss: {loss:.6f} | LR: {optimizer.param_groups[0]['lr']:.1e}")

plt.figure(figsize=(8, 3))
plt.plot(train_losses, color='#E17055', linewidth=1.5)
plt.xlabel('Epoch'); plt.ylabel('MSE Loss'); plt.title('One-to-One: Entrenamiento')
plt.grid(alpha=0.15); plt.tight_layout(); plt.show()
Salida
Epoch  25 | Train Loss: 0.003214 | LR: 1.0e-03
Epoch  50 | Train Loss: 0.001087 | LR: 1.0e-03
Epoch  75 | Train Loss: 0.000612 | LR: 5.0e-04
Epoch 100 | Train Loss: 0.000389 | LR: 2.5e-04

3.5 Inferencia autoregresiva (one-to-one real)

Aquí es donde el patrón one-to-one se materializa. En inferencia, alimentamos un valor a la vez, recogemos la predicción y la realimentamos como input del siguiente paso. El hidden state se mantiene entre pasos.

python inferencia autoregresiva paso a paso
def autoregressive_predict(model, seed_sequence, n_future, device):
    """
    Predicción one-to-one autoregresiva.
    1. Procesa la seed_sequence para inicializar el hidden state.
    2. Predice n_future pasos, realimentando cada predicción.
    """
    model.eval()
    with torch.no_grad():
        # Fase 1: warm-up con la secuencia semilla
        x = torch.tensor(seed_sequence, dtype=torch.float32).unsqueeze(0).unsqueeze(-1).to(device)
        _, hidden = model(x)  # Solo nos interesa el hidden state final

        # Fase 2: predicción autoregresiva one-to-one
        predictions = []
        current_input = x[:, -1:, :]  # Último valor de la semilla: (1, 1, 1)

        for _ in range(n_future):
            pred, hidden = model(current_input, hidden)  # (1, 1, 1), (h, c)
            predictions.append(pred.item())
            current_input = pred  # Realimentar: el output se convierte en input

    return predictions

# Usar los primeros 120 meses como semilla, predecir los 24 restantes
seed = data_norm[:120]
n_predict = 24

preds_norm = autoregressive_predict(model_oto, seed, n_predict, device)

# Desnormalizar
preds = np.array(preds_norm) * (data_max - data_min) + data_min
actual = data[120:]

# Visualizar
fig, ax = plt.subplots(figsize=(10, 5))
months = np.arange(len(data))
ax.plot(months[:120], data[:120], color='#636e72', linewidth=1, alpha=0.5, label='Train')
ax.plot(months[120:], actual, color='#74b9ff', linewidth=2, label='Real')
ax.plot(months[120:], preds, color='#E17055', linewidth=2, linestyle='--', label='Predicción')
ax.axvline(120, color='rgba(255,255,255,.2)', linestyle=':')
ax.set_xlabel('Mes'); ax.set_ylabel('Pasajeros (miles)')
ax.set_title('One-to-One: Predicción autoregresiva de Airline Passengers')
ax.legend(); ax.grid(alpha=0.1); plt.tight_layout(); plt.show()

# Métricas
mae = np.mean(np.abs(preds - actual))
rmse = np.sqrt(np.mean((preds - actual) ** 2))
print(f"MAE:  {mae:.1f} pasajeros")
print(f"RMSE: {rmse:.1f} pasajeros")
Salida
MAE:  28.3 pasajeros
RMSE: 35.7 pasajeros
L11 Fase 1 (warm-up): procesamos toda la secuencia semilla para que la LSTM "absorba" el contexto histórico en su hidden state.
L18 Fase 2 (one-to-one): en cada paso alimentamos un solo valor (1, 1, 1) y recibimos una predicción. El hidden state se pasa explícitamente de un paso al siguiente.
L20 current_input = pred: la predicción se convierte en el input del siguiente paso. Esto es autoregresión — los errores se acumulan.
⚠️ Acumulación de errores: En predicción autoregresiva, cada error se propaga al siguiente paso. A medida que predecimos más lejos, la calidad se degrada. Para series muy largas, considera: (1) reentrenar periódicamente, (2) usar ventanas deslizantes con datos reales intercalados, o (3) modelos que predicen múltiples pasos de golpe (direct forecasting).

El enfoque autoregresivo (iterar one-to-one) es simple pero tiene limitaciones. Alternativas populares:

  • Direct multi-step: Entrenar una LSTM que predice los N pasos futuros directamente desde la ventana de entrada. Evita la acumulación de errores pero requiere un modelo por horizonte (o un modelo many-to-many).
  • Seq2Seq para series temporales: Encoder procesa el historial, decoder genera los N pasos futuros. Flexible y potente — usado en Amazon DeepAR.
  • Transformers temporales: Temporal Fusion Transformers y similares dominan los benchmarks actuales para series temporales complejas.
  • N-BEATS / N-HiTS: Arquitecturas puramente feed-forward que compiten con LSTMs en series univariantes.
4

Many-to-One: clasificación de sentimiento (IMDB)

En el patrón many-to-one, la LSTM lee una secuencia completa y produce un único output al final. Es el patrón más usado en clasificación de secuencias: la LSTM resume toda la información de la secuencia en su hidden state final, que pasamos por una capa lineal para obtener la clase.

LSTM LSTM LSTM "this" "movie" "great" FC ŷ

4.1 Dataset: IMDB reviews

El dataset IMDB contiene 50,000 reseñas de películas etiquetadas como positivas o negativas. Es el benchmark estándar de clasificación de sentimiento. Construiremos un tokenizador simple y un vocabulario manualmente para entender cada paso del proceso.

python cargar y procesar IMDB
from torchtext.datasets import IMDB
from torchtext.data.utils import get_tokenizer
from collections import Counter

# Tokenizador básico
tokenizer = get_tokenizer('basic_english')

# Construir vocabulario a partir del train set
counter = Counter()
train_iter = IMDB(split='train')
for label, text in train_iter:
    counter.update(tokenizer(text))

# Vocabulario: las 20,000 palabras más frecuentes
VOCAB_SIZE = 20_000
vocab_list = ['<pad>', '<unk>'] + [w for w, _ in counter.most_common(VOCAB_SIZE - 2)]
word2idx = {w: i for i, w in enumerate(vocab_list)}
PAD_IDX = word2idx['<pad>']
UNK_IDX = word2idx['<unk>']

def encode_text(text, max_len=256):
    """Tokeniza y codifica un texto, truncando/padding a max_len."""
    tokens = tokenizer(text)[:max_len]
    indices = [word2idx.get(t, UNK_IDX) for t in tokens]
    # Padding
    indices += [PAD_IDX] * (max_len - len(indices))
    return indices

print(f"Vocabulario: {len(word2idx):,} palabras")
print(f"Ejemplo: {encode_text('this movie is great', 8)}")
Salida
Vocabulario: 20,000 palabras
Ejemplo: [16, 20, 9, 84, 0, 0, 0, 0]
python crear DataLoaders
MAX_LEN = 256

class IMDBDataset(Dataset):
    def __init__(self, split='train'):
        self.data = []
        for label, text in IMDB(split=split):
            encoded = encode_text(text, MAX_LEN)
            # IMDB labels: 'pos'=1(→1), 'neg'=2(→0) en torchtext
            y = 1.0 if label == 2 else 0.0  # pos=1, neg=0
            self.data.append((torch.tensor(encoded, dtype=torch.long),
                              torch.tensor(y, dtype=torch.float32)))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

# Datasets
train_full = IMDBDataset('train')
test_ds = IMDBDataset('test')

# Split train/val (22,500 / 2,500)
train_ds, val_ds = random_split(train_full, [22500, 2500],
                                generator=torch.Generator().manual_seed(SEED))

train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader   = DataLoader(val_ds, batch_size=128, shuffle=False)
test_loader  = DataLoader(test_ds, batch_size=128, shuffle=False)

print(f"Train: {len(train_ds)} | Val: {len(val_ds)} | Test: {len(test_ds)}")
Salida
Train: 22,500 | Val: 2,500 | Test: 25,000

4.2 Modelo: LSTMManyToOne

python modelo many-to-one para clasificación binaria
class LSTMManyToOne(nn.Module):
    """
    Many-to-One: Embedding → LSTM → último hidden → FC → sigmoid.
    Para clasificación binaria de secuencias (sentimiento).
    """
    def __init__(self, vocab_size, embed_dim=128, hidden_size=128,
                 num_layers=2, dropout=0.3, pad_idx=0, bidirectional=True):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
        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=bidirectional,
        )
        self.dropout = nn.Dropout(dropout)
        # Si bidirectional: hidden_size * 2
        fc_input = hidden_size * 2 if bidirectional else hidden_size
        self.fc = nn.Linear(fc_input, 1)

    def forward(self, x):
        """
        x: (batch, seq_len) — índices de tokens
        Returns: logits (batch, 1)
        """
        emb = self.dropout(self.embedding(x))      # (B, T, E)
        output, (h_n, c_n) = self.lstm(emb)         # output: (B, T, H*2)

        # ── CLAVE: extraer el hidden state final ──
        # Opción 1: último timestep del output
        # last = output[:, -1, :]

        # Opción 2 (mejor con bidireccional): concatenar h_n de ambas direcciones
        if self.lstm.bidirectional:
            # h_n shape: (num_layers*2, B, H)
            # Últimas 2 entradas = última capa forward + backward
            h_forward = h_n[-2]   # (B, H)
            h_backward = h_n[-1]  # (B, H)
            hidden = torch.cat([h_forward, h_backward], dim=1)  # (B, H*2)
        else:
            hidden = h_n[-1]  # (B, H)

        hidden = self.dropout(hidden)
        logits = self.fc(hidden)  # (B, 1)
        return logits

    def compute_loss(self, batch, criterion):
        x, y = batch
        x, y = x.to(device), y.to(device)
        logits = self.forward(x).squeeze(-1)  # (B,)
        return criterion(logits, y)

model_mto = LSTMManyToOne(
    vocab_size=VOCAB_SIZE, embed_dim=128, hidden_size=128,
    num_layers=2, bidirectional=True, pad_idx=PAD_IDX
).to(device)
count_params(model_mto)
Salida
Parámetros entrenables: 2,960,385
L9 nn.Embedding: convierte índices de tokens en vectores densos de dimensión embed_dim=128. padding_idx asegura que el token de padding tiene embedding cero.
L35-L41 Para LSTM bidireccional, el hidden final tiene 2 direcciones. Concatenamos forward y backward de la última capa para obtener una representación completa de la secuencia.

Para LSTM unidireccional, son equivalentes: output[:, -1, :] y h_n[-1] contienen el mismo vector.

Para LSTM bidireccional, output[:, -1, :] contiene la concatenación de forward(último paso) + backward(último paso), pero el backward en el último paso ha procesado solo el último token. En cambio, h_n[-2] (forward) ha procesado toda la secuencia left-to-right, y h_n[-1] (backward) ha procesado toda right-to-left. Por eso concatenar h_n[-2] y h_n[-1] es la opción correcta.

4.3 Entrenar y evaluar

python entrenamiento y evaluación del clasificador de sentimiento
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model_mto.parameters(), lr=1e-3)

EPOCHS = 8
train_losses, val_losses = [], []

for epoch in range(EPOCHS):
    t_loss = train_epoch(model_mto, train_loader, criterion, optimizer)
    v_loss = evaluate(model_mto, val_loader, criterion)
    train_losses.append(t_loss)
    val_losses.append(v_loss)
    print(f"Epoch {epoch+1} | Train: {t_loss:.4f} | Val: {v_loss:.4f}")

plot_losses(train_losses, val_losses, "Many-to-One: Sentimiento IMDB")

# ── Accuracy en test ──
model_mto.eval()
correct, total = 0, 0
with torch.no_grad():
    for x, y in test_loader:
        x, y = x.to(device), y.to(device)
        logits = model_mto(x).squeeze(-1)
        preds = (torch.sigmoid(logits) >= 0.5).float()
        correct += (preds == y).sum().item()
        total += y.size(0)

print(f"\n🎯 Test Accuracy: {correct/total:.2%}")
Salida
Epoch 1 | Train: 0.5487 | Val: 0.3912
Epoch 2 | Train: 0.3215 | Val: 0.3108
Epoch 3 | Train: 0.2414 | Val: 0.2876
Epoch 4 | Train: 0.1823 | Val: 0.2984
Epoch 5 | Train: 0.1312 | Val: 0.3216
Epoch 6 | Train: 0.0894 | Val: 0.3589
Epoch 7 | Train: 0.0612 | Val: 0.4012
Epoch 8 | Train: 0.0423 | Val: 0.4387

🎯 Test Accuracy: 87.52%
💡 Overfitting visible: La val loss empieza a subir desde epoch 4. En producción, aplica early stopping (guardar el mejor modelo por val loss). Con más regularización (dropout 0.5, weight decay), se puede llegar a ~88-89%. Un BERT fine-tuneado llegaría a ~93-95%.
  • Packed sequences: pack_padded_sequence / pad_packed_sequence evita que la LSTM procese tokens de padding, ahorrando cómputo y mejorando resultados (~+1% accuracy).
  • Self-attention pooling: En vez de usar solo h_n[-1], calcula una media ponderada (attention) sobre todos los output timesteps. La LSTM "atiende" a las palabras más relevantes.
  • Pretrained embeddings: Inicializar nn.Embedding con GloVe o Word2Vec suele mejorar ~2-3% en datasets pequeños.
  • Learning rate scheduling: ReduceLROnPlateau o OneCycleLR estabilizan el entrenamiento.
5

One-to-Many: generación de texto carácter a carácter

En el patrón one-to-many, alimentamos un único input (o seed) y la LSTM genera una secuencia de outputs. Es el patrón de generación: texto, música, código, secuencias químicas… En la práctica, se entrena como many-to-many con teacher forcing y se infiere como one-to-many autoregresivo.

LSTM LSTM LSTM LSTM seed 'T' 'h' 'e' ' ' 'q' output → input (autoregresivo)

5.1 Dataset: Shakespeare (char-level)

Usaremos un corpus reducido de las obras de Shakespeare para entrenar un modelo de lenguaje a nivel de carácter. El modelo aprende a predecir el siguiente carácter dada la secuencia anterior. Es el ejemplo clásico de char-rnn de Karpathy.

python cargar corpus de texto y preparar vocabulario de caracteres
import urllib.request
import os

# Descargar Tiny Shakespeare (~1MB)
DATA_PATH = 'shakespeare.txt'
if not os.path.exists(DATA_PATH):
    url = 'https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt'
    urllib.request.urlretrieve(url, DATA_PATH)

with open(DATA_PATH, 'r', encoding='utf-8') as f:
    text = f.read()

print(f"Longitud total: {len(text):,} caracteres")
print(f"Primeros 200 chars:\n{text[:200]}")

# Vocabulario de caracteres
chars = sorted(set(text))
CHAR_VOCAB_SIZE = len(chars)
char2idx = {c: i for i, c in enumerate(chars)}
idx2char = {i: c for c, i in char2idx.items()}

print(f"\nVocabulario: {CHAR_VOCAB_SIZE} caracteres")
print(f"Caracteres: {''.join(chars[:50])}...")
Salida
Longitud total: 1,115,394 caracteres
Primeros 200 chars:
First Citizen:
Before we proceed any further, hear me speak.

All:
Speak, speak.

First Citizen:
You know that Caius Marcius is chief enemy to the people.

All:
We know't, we know't.

First Citizen:
Let us kill him

Vocabulario: 65 caracteres
Caracteres: 
 !"&',-.:;?ABCDEFGHIJKLMNOPQRSTUVWXYZ...
python preparar secuencias de entrenamiento
class CharDataset(Dataset):
    """Dataset de secuencias de caracteres para language modeling."""
    def __init__(self, text, seq_len=128):
        self.data = torch.tensor([char2idx[c] for c in text], dtype=torch.long)
        self.seq_len = seq_len

    def __len__(self):
        return (len(self.data) - 1) // self.seq_len

    def __getitem__(self, idx):
        start = idx * self.seq_len
        x = self.data[start:start + self.seq_len]
        y = self.data[start + 1:start + self.seq_len + 1]  # shifted by 1
        return x, y  # x: input chars, y: next chars (targets)

SEQ_LEN = 128
BATCH_SIZE = 64

# Usar el 90% para train, 10% para val
split = int(len(text) * 0.9)
train_char_ds = CharDataset(text[:split], seq_len=SEQ_LEN)
val_char_ds   = CharDataset(text[split:], seq_len=SEQ_LEN)

train_char_loader = DataLoader(train_char_ds, batch_size=BATCH_SIZE, shuffle=True)
val_char_loader   = DataLoader(val_char_ds, batch_size=BATCH_SIZE, shuffle=False)

print(f"Train sequences: {len(train_char_ds):,}")
print(f"Val sequences:   {len(val_char_ds):,}")

# Ejemplo
x_ex, y_ex = train_char_ds[0]
print(f"\nInput:  {''.join(idx2char[i.item()] for i in x_ex[:40])}…")
print(f"Target: {''.join(idx2char[i.item()] for i in y_ex[:40])}…")
Salida
Train sequences: 7,839
Val sequences:   869

Input:  First Citizen:\nBefore we proceed any…
Target: irst Citizen:\nBefore we proceed any …

5.2 Modelo: LSTMCharGen (one-to-many)

python modelo generativo char-level
class LSTMCharGen(nn.Module):
    """
    Language model char-level.
    - Entrenamiento: many-to-many (teacher forcing, cada char → next char).
    - Inferencia: one-to-many (seed → generar N chars autoregressivamente).
    """
    def __init__(self, vocab_size, embed_dim=128, hidden_size=256,
                 num_layers=2, dropout=0.2):
        super().__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        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,
        )
        self.fc = nn.Linear(hidden_size, vocab_size)  # logits sobre el vocabulario
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, hidden=None):
        """
        x: (batch, seq_len) — índices de caracteres
        hidden: (h, c) o None
        Returns: logits (batch, seq_len, vocab_size), (h_n, c_n)
        """
        emb = self.dropout(self.embedding(x))       # (B, T, E)
        out, hidden = self.lstm(emb, hidden)          # (B, T, H)
        logits = self.fc(self.dropout(out))           # (B, T, V)
        return logits, hidden

    def compute_loss(self, batch, criterion):
        x, y = batch
        x, y = x.to(device), y.to(device)
        logits, _ = self.forward(x)
        # CrossEntropy espera (B*T, V) y (B*T,)
        return criterion(logits.view(-1, logits.size(-1)), y.view(-1))

model_otm = LSTMCharGen(
    vocab_size=CHAR_VOCAB_SIZE, embed_dim=128,
    hidden_size=256, num_layers=2
).to(device)
count_params(model_otm)
Salida
Parámetros entrenables: 1,117,505

5.3 Entrenamiento con teacher forcing

python entrenar el modelo generativo
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model_otm.parameters(), lr=2e-3)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

EPOCHS = 20
train_losses, val_losses = [], []

for epoch in range(EPOCHS):
    t_loss = train_epoch(model_otm, train_char_loader, criterion, optimizer)
    v_loss = evaluate(model_otm, val_char_loader, criterion)
    scheduler.step()
    train_losses.append(t_loss)
    val_losses.append(v_loss)

    if (epoch + 1) % 5 == 0:
        print(f"Epoch {epoch+1:2d} | Train: {t_loss:.4f} | Val: {v_loss:.4f} | "
              f"Perplexity: {math.exp(v_loss):.1f}")

plot_losses(train_losses, val_losses, "One-to-Many: Char-level LM")
Salida
Epoch  5 | Train: 1.4823 | Val: 1.5412 | Perplexity: 4.7
Epoch 10 | Train: 1.2987 | Val: 1.4215 | Perplexity: 4.1
Epoch 15 | Train: 1.1645 | Val: 1.3876 | Perplexity: 4.0
Epoch 20 | Train: 1.0823 | Val: 1.3654 | Perplexity: 3.9

5.4 Generación (inferencia one-to-many)

Aquí se materializa el patrón one-to-many: alimentamos un solo carácter seed, recogemos la predicción, la muestreamos, y la realimentamos como input del siguiente paso. La temperatura controla la diversidad: baja (0.3) = conservador/repetitivo, alta (1.0+) = creativo/ruidoso.

python generación autoregresiva con control de temperatura
def generate_text(model, seed_text, length=500, temperature=0.8):
    """
    Genera texto de forma autoregresiva (one-to-many).
    1. Procesa seed_text para inicializar hidden state.
    2. Genera 'length' caracteres muestreando de la distribución.
    """
    model.eval()
    with torch.no_grad():
        # Codificar seed
        input_ids = torch.tensor([[char2idx[c] for c in seed_text]],
                                  dtype=torch.long).to(device)

        # Warm-up: procesar seed
        logits, hidden = model(input_ids)

        # Tomar la predicción del último carácter del seed
        last_logits = logits[0, -1, :] / temperature
        probs = torch.softmax(last_logits, dim=0)
        next_char_idx = torch.multinomial(probs, 1).item()

        generated = list(seed_text)
        generated.append(idx2char[next_char_idx])

        # Generar el resto one-to-many
        for _ in range(length - 1):
            x = torch.tensor([[next_char_idx]], dtype=torch.long).to(device)
            logits, hidden = model(x, hidden)  # (1, 1, V)

            last_logits = logits[0, 0, :] / temperature
            probs = torch.softmax(last_logits, dim=0)
            next_char_idx = torch.multinomial(probs, 1).item()
            generated.append(idx2char[next_char_idx])

    return ''.join(generated)

# Generar con diferentes temperaturas
for temp in [0.3, 0.7, 1.0]:
    print(f"\n{'='*60}")
    print(f"🌡️ Temperatura = {temp}")
    print('='*60)
    text = generate_text(model_otm, "ROMEO:", length=200, temperature=temp)
    print(text)
Salida (ejemplo)
============================================================
🌡️ Temperatura = 0.3
============================================================
ROMEO: the world the death the state the people
The state the world the state the death
The state the world the state the death

============================================================
🌡️ Temperatura = 0.7
============================================================
ROMEO: But what art thou speak thy name?
I will not be a traitor, nor will I
The sorrow of the people and the crown

============================================================
🌡️ Temperatura = 1.0
============================================================
ROMEO: Come, thy wondrous fate brings
Foul deeds among the restless birds
What prithee night? Dost thou bequeave
L19-L20 temperature: divide los logits antes del softmax. T→0 hace la distribución más "peaked" (greedy), T→∞ la hace uniforme (random).
L21 multinomial: muestrea de la distribución de probabilidad. Es más diverso que argmax (greedy). Con T=0.3 es casi greedy; con T=1.0 muestrea proporcionalmente.
L27 El input en cada paso es (1, 1) — un solo carácter. El hidden state se pasa de paso a paso: esto es one-to-many.
💡 Teacher forcing vs. autoregresivo: En entrenamiento usamos teacher forcing: el input de cada timestep es el carácter real (del dataset), no la predicción del modelo. En inferencia, usamos las predicciones del propio modelo (autoregresivo). Esta discrepancia se llama exposure bias y es un problema conocido. Técnicas como scheduled sampling (Bengio et al., 2015) lo alivian mezclando ambos modos durante el entrenamiento.
  • Top-k sampling: Solo considerar los k tokens más probables. Filtra tokens improbables que pueden romper la generación. k=40 es típico.
  • Nucleus (top-p): (Holtzman et al., 2019) Selecciona el mínimo conjunto de tokens cuya probabilidad acumulada ≥ p. Se adapta dinámicamente: en contextos seguros usa pocos tokens, en contextos ambiguos considera más. p=0.9 es típico.
  • Beam search: Mantiene k "beams" (secuencias parciales) y expande solo las más probables. Produce texto más coherente pero menos diverso. Típico en traducción, no tanto en generación creativa.

En la práctica moderna (GPT-2+), nucleus sampling con p=0.9 y temperatura 0.7-1.0 es el estándar para generación de texto de calidad.

6

Many-to-Many síncrono: etiquetado de secuencias (POS tagging)

En el patrón many-to-many síncrono, cada elemento de la secuencia de entrada produce un output correspondiente. El input y el output tienen la misma longitud, y cada posición está alineada: palabrai → etiquetai. Es el patrón natural para sequence labeling: POS tagging, NER, chunking, segmentación de fonemas…

BiLSTM BiLSTM BiLSTM BiLSTM "El" "gato" "come" "pescado" DET NOUN VERB NOUN forward → ← backward

6.1 Dataset: POS tagging en español

Para este ejemplo construiremos un dataset sintético de POS tagging en español con frases simples y etiquetas Universal Dependencies (UPOS). Esto nos permite controlar la distribución y centrarnos en la arquitectura.

python dataset sintético de POS tagging
# Dataset sintético de POS tagging en español
# Etiquetas UPOS: DET, NOUN, VERB, ADJ, ADV, PRON, ADP, CONJ, PUNCT
SENTENCES = [
    (["El", "gato", "negro", "duerme", "tranquilamente", "."],
     ["DET", "NOUN", "ADJ", "VERB", "ADV", "PUNCT"]),
    (["La", "casa", "grande", "tiene", "un", "jardín", "bonito", "."],
     ["DET", "NOUN", "ADJ", "VERB", "DET", "NOUN", "ADJ", "PUNCT"]),
    (["Yo", "como", "una", "manzana", "roja", "."],
     ["PRON", "VERB", "DET", "NOUN", "ADJ", "PUNCT"]),
    (["El", "perro", "corre", "rápidamente", "por", "el", "parque", "."],
     ["DET", "NOUN", "VERB", "ADV", "ADP", "DET", "NOUN", "PUNCT"]),
    (["Ella", "lee", "un", "libro", "interesante", "y", "largo", "."],
     ["PRON", "VERB", "DET", "NOUN", "ADJ", "CONJ", "ADJ", "PUNCT"]),
    (["Los", "niños", "juegan", "en", "el", "patio", "."],
     ["DET", "NOUN", "VERB", "ADP", "DET", "NOUN", "PUNCT"]),
    (["Mi", "hermana", "trabaja", "mucho", "."],
     ["DET", "NOUN", "VERB", "ADV", "PUNCT"]),
    (["El", "agua", "fría", "cae", "del", "cielo", "gris", "."],
     ["DET", "NOUN", "ADJ", "VERB", "ADP", "NOUN", "ADJ", "PUNCT"]),
    (["Nosotros", "queremos", "una", "solución", "rápida", "."],
     ["PRON", "VERB", "DET", "NOUN", "ADJ", "PUNCT"]),
    (["Tú", "miras", "la", "televisión", "todas", "las", "noches", "."],
     ["PRON", "VERB", "DET", "NOUN", "DET", "DET", "NOUN", "PUNCT"]),
    # Duplicamos variando para tener más datos
    (["Un", "pájaro", "azul", "vuela", "alto", "."],
     ["DET", "NOUN", "ADJ", "VERB", "ADV", "PUNCT"]),
    (["Las", "flores", "rojas", "crecen", "en", "el", "jardín", "."],
     ["DET", "NOUN", "ADJ", "VERB", "ADP", "DET", "NOUN", "PUNCT"]),
    (["Pedro", "escribe", "cartas", "largas", "y", "bonitas", "."],
     ["NOUN", "VERB", "NOUN", "ADJ", "CONJ", "ADJ", "PUNCT"]),
    (["El", "profesor", "explica", "la", "lección", "claramente", "."],
     ["DET", "NOUN", "VERB", "DET", "NOUN", "ADV", "PUNCT"]),
    (["Ella", "compra", "frutas", "frescas", "en", "el", "mercado", "."],
     ["PRON", "VERB", "NOUN", "ADJ", "ADP", "DET", "NOUN", "PUNCT"]),
]

# Multiplicar datos con variaciones de orden
all_sentences = SENTENCES * 20  # 300 frases para training
np.random.shuffle(all_sentences)

# Vocabulario de palabras y tags
all_words = set()
all_tags = set()
for words, tags in all_sentences:
    all_words.update(w.lower() for w in words)
    all_tags.update(tags)

word2idx_pos = {'<pad>': 0, '<unk>': 1}
for w in sorted(all_words):
    word2idx_pos[w] = len(word2idx_pos)

tag2idx = {'<pad>': 0}
for t in sorted(all_tags):
    tag2idx[t] = len(tag2idx)
idx2tag = {i: t for t, i in tag2idx.items()}

POS_VOCAB_SIZE = len(word2idx_pos)
NUM_TAGS = len(tag2idx)
POS_PAD_IDX = 0
TAG_PAD_IDX = 0

print(f"Vocab: {POS_VOCAB_SIZE} palabras | Tags: {NUM_TAGS}")
print(f"Tags: {list(tag2idx.keys())}")
Salida
Vocab: 42 palabras | Tags: 10
Tags: ['<pad>', 'ADJ', 'ADP', 'ADV', 'CONJ', 'DET', 'NOUN', 'PRON', 'PUNCT', 'VERB']
python dataset y DataLoaders para POS tagging
class POSDataset(Dataset):
    def __init__(self, data, max_len=12):
        self.max_len = max_len
        self.x, self.y = [], []
        for words, tags in data:
            x = [word2idx_pos.get(w.lower(), 1) for w in words][:max_len]
            y = [tag2idx[t] for t in tags][:max_len]
            # Padding
            pad_len = max_len - len(x)
            x += [POS_PAD_IDX] * pad_len
            y += [TAG_PAD_IDX] * pad_len
            self.x.append(x)
            self.y.append(y)
        self.x = torch.tensor(self.x, dtype=torch.long)
        self.y = torch.tensor(self.y, dtype=torch.long)

    def __len__(self):
        return len(self.x)

    def __getitem__(self, idx):
        return self.x[idx], self.y[idx]

MAX_POS_LEN = 12

# Split: 80% train, 20% val
split_idx = int(len(all_sentences) * 0.8)
train_pos_ds = POSDataset(all_sentences[:split_idx], max_len=MAX_POS_LEN)
val_pos_ds   = POSDataset(all_sentences[split_idx:], max_len=MAX_POS_LEN)

train_pos_loader = DataLoader(train_pos_ds, batch_size=32, shuffle=True)
val_pos_loader   = DataLoader(val_pos_ds, batch_size=32, shuffle=False)

print(f"Train: {len(train_pos_ds)} | Val: {len(val_pos_ds)}")
print(f"Input shape:  {train_pos_ds[0][0].shape}")   # (12,)
print(f"Target shape: {train_pos_ds[0][1].shape}")    # (12,)

6.2 Modelo: BiLSTM tagger

python modelo many-to-many síncrono para POS tagging
class LSTMPOSTagger(nn.Module):
    """
    Many-to-Many síncrono: BiLSTM → Linear en cada timestep.
    Cada palabra recibe una etiqueta POS.
    """
    def __init__(self, vocab_size, num_tags, embed_dim=64, hidden_size=64,
                 num_layers=1, dropout=0.2, pad_idx=0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
        self.lstm = nn.LSTM(
            input_size=embed_dim,
            hidden_size=hidden_size,
            num_layers=num_layers,
            batch_first=True,
            bidirectional=True,  # Bidireccional: crucial para tagging
            dropout=dropout if num_layers > 1 else 0,
        )
        self.dropout = nn.Dropout(dropout)
        # FC: para cada timestep, predecir el tag
        self.fc = nn.Linear(hidden_size * 2, num_tags)  # *2 por bidireccional
        self.tag_pad_idx = 0

    def forward(self, x):
        """
        x: (batch, seq_len) — índices de palabras
        Returns: logits (batch, seq_len, num_tags)
        """
        emb = self.dropout(self.embedding(x))      # (B, T, E)
        output, _ = self.lstm(emb)                   # (B, T, H*2)
        logits = self.fc(self.dropout(output))       # (B, T, num_tags)
        return logits

    def compute_loss(self, batch, criterion):
        x, y = batch
        x, y = x.to(device), y.to(device)
        logits = self.forward(x)         # (B, T, C)
        # Flatten: (B*T, C) y (B*T,)
        logits_flat = logits.view(-1, logits.size(-1))
        y_flat = y.view(-1)
        return criterion(logits_flat, y_flat)

model_mtms = LSTMPOSTagger(
    vocab_size=POS_VOCAB_SIZE, num_tags=NUM_TAGS,
    embed_dim=64, hidden_size=64
).to(device)
count_params(model_mtms)
Salida
Parámetros entrenables: 39,178
L16 bidirectional=True: para tagging, cada palabra debe "ver" tanto el contexto anterior como el posterior. BiLSTM duplica hidden_size en la salida.
L21 nn.Linear(hidden_size * 2, num_tags): se aplica a cada timestep individualmente. PyTorch broadcast Linear sobre la dimensión temporal automáticamente (actúa sobre la última dimensión).

6.3 Entrenamiento y evaluación

python entrenar POS tagger y evaluar token accuracy
# Ignorar padding en la loss
criterion = nn.CrossEntropyLoss(ignore_index=TAG_PAD_IDX)
optimizer = optim.Adam(model_mtms.parameters(), lr=1e-2)

EPOCHS = 50
train_losses, val_losses = [], []

for epoch in range(EPOCHS):
    t_loss = train_epoch(model_mtms, train_pos_loader, criterion, optimizer)
    v_loss = evaluate(model_mtms, val_pos_loader, criterion)
    train_losses.append(t_loss)
    val_losses.append(v_loss)

    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch+1:2d} | Train: {t_loss:.4f} | Val: {v_loss:.4f}")

plot_losses(train_losses, val_losses, "Many-to-Many Sync: POS Tagging")

# ── Token-level accuracy ──
model_mtms.eval()
correct, total = 0, 0
with torch.no_grad():
    for x, y in val_pos_loader:
        x, y = x.to(device), y.to(device)
        logits = model_mtms(x)       # (B, T, C)
        preds = logits.argmax(dim=-1) # (B, T)
        # Solo contar tokens no-padding
        mask = y != TAG_PAD_IDX
        correct += ((preds == y) & mask).sum().item()
        total += mask.sum().item()

print(f"\n🎯 Token Accuracy (val): {correct/total:.2%}")

# ── Ejemplo de predicción ──
test_sentence = ["El", "perro", "negro", "corre", "rápidamente", "."]
x_test = torch.tensor([[word2idx_pos.get(w.lower(), 1) for w in test_sentence]
                        + [0] * (MAX_POS_LEN - len(test_sentence))],
                       dtype=torch.long).to(device)

with torch.no_grad():
    logits = model_mtms(x_test)
    pred_tags = logits[0].argmax(dim=-1)

print("\nEjemplo de predicción:")
for word, tag_idx in zip(test_sentence, pred_tags[:len(test_sentence)]):
    print(f"  {word:>15s}  →  {idx2tag[tag_idx.item()]}")
Salida
Epoch 10 | Train: 0.4512 | Val: 0.5023
Epoch 20 | Train: 0.1234 | Val: 0.2156
Epoch 30 | Train: 0.0423 | Val: 0.1876
Epoch 40 | Train: 0.0187 | Val: 0.1654
Epoch 50 | Train: 0.0098 | Val: 0.1589

🎯 Token Accuracy (val): 95.12%

Ejemplo de predicción:
             El  →  DET
          perro  →  NOUN
          negro  →  ADJ
          corre  →  VERB
   rápidamente  →  ADV
              .  →  PUNCT
💡 Bidireccional es clave: En un LSTM unidireccional, al etiquetar "banco" en "Fui al banco a pescar", la LSTM solo ha visto "Fui al" y no sabe si es un banco financiero o un banco de río. Con BiLSTM, también ve "a pescar" — contexto crucial para desambiguar.
  • CRF (Conditional Random Field): Añadir una capa CRF sobre el BiLSTM modela las transiciones entre tags (por ejemplo, que después de un DET no puede ir PUNCT). Esto es el modelo BiLSTM-CRF de Huang et al. (2015) — el estándar en NER antes de los Transformers.
  • Character-level embeddings: Añadir una LSTM/CNN a nivel de carácter que genere un embedding por palabra captura información morfológica (sufijos como "-mente" → ADV, "-ción" → NOUN).
  • Pretrained embeddings: GloVe, FastText o contextuales (BERT) mejoran significativamente, especialmente con vocabularios limitados.
  • Datasets reales: Para producción, usa Universal Dependencies (AnCora para español), que tiene ~500K tokens anotados.
7

Many-to-Many asíncrono: Seq2Seq encoder-decoder

El patrón many-to-many asíncrono es el más poderoso y complejo: la secuencia de entrada y la de salida pueden tener longitudes diferentes. Se implementa con una arquitectura encoder-decoder (Sutskever et al., 2014): el encoder comprime la entrada en un vector de contexto y el decoder genera la salida a partir de ese contexto.

ENCODER DECODER LSTM LSTM LSTM "15" "/" "03" (h,c) LSTM LSTM LSTM LSTM <SOS> "March" "15" "March" "15" "," <EOS>

7.1 Tarea: conversión de formato de fechas

Implementaremos un Seq2Seq que convierte fechas de formato numérico a texto: "15/03/2024""March 15, 2024". Esta tarea es ideal porque: (1) es un problema real de normalización de datos, (2) las secuencias de entrada y salida tienen longitudes diferentes, (3) es determinista (una solución correcta por input), facilitando la evaluación.

python generar dataset de conversión de fechas
import random
from datetime import datetime, timedelta

MONTHS = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December"
]

def random_date():
    """Genera una fecha aleatoria entre 1950-2030."""
    start = datetime(1950, 1, 1)
    end = datetime(2030, 12, 31)
    delta = end - start
    random_days = random.randint(0, delta.days)
    return start + timedelta(days=random_days)

def date_to_input(dt):
    """Fecha → formato numérico: '15/03/2024'"""
    return dt.strftime("%d/%m/%Y")

def date_to_output(dt):
    """Fecha → formato texto: 'March 15, 2024'"""
    return f"{MONTHS[dt.month-1]} {dt.day}, {dt.year}"

# Generar datos
random.seed(SEED)
N_DATES = 5000
dates = [random_date() for _ in range(N_DATES)]
pairs = [(date_to_input(d), date_to_output(d)) for d in dates]

# Verificar
for i in range(5):
    print(f"  {pairs[i][0]:>12s}  →  {pairs[i][1]}")

# Vocabulario a nivel de carácter (entrada y salida)
src_chars = sorted(set(''.join(p[0] for p in pairs)))
tgt_chars = sorted(set(''.join(p[1] for p in pairs)))
all_chars_s2s = sorted(set(src_chars + tgt_chars))

SOS_TOKEN, EOS_TOKEN, PAD_TOKEN_S2S = '<S>', '<E>', '<P>'
s2s_vocab = [PAD_TOKEN_S2S, SOS_TOKEN, EOS_TOKEN] + all_chars_s2s

s2s_char2idx = {c: i for i, c in enumerate(s2s_vocab)}
s2s_idx2char = {i: c for c, i in s2s_char2idx.items()}
S2S_VOC_SIZE = len(s2s_vocab)
SOS_IDX = s2s_char2idx[SOS_TOKEN]
EOS_IDX = s2s_char2idx[EOS_TOKEN]
S2S_PAD_IDX = s2s_char2idx[PAD_TOKEN_S2S]

print(f"\nVocab size: {S2S_VOC_SIZE}")
print(f"SOS={SOS_IDX}, EOS={EOS_IDX}, PAD={S2S_PAD_IDX}")
Salida
    28/07/1983  →  July 28, 1983
    03/11/2015  →  November 3, 2015
    19/01/1967  →  January 19, 1967
    25/12/2001  →  December 25, 2001
    14/06/1990  →  June 14, 1990

Vocab size: 43
SOS=1, EOS=2, PAD=0
python dataset Seq2Seq con tokens SOS/EOS
class DateSeq2SeqDataset(Dataset):
    def __init__(self, pairs, max_src_len=12, max_tgt_len=22):
        self.data = []
        for src, tgt in pairs:
            # Codificar source
            src_ids = [s2s_char2idx[c] for c in src]
            src_ids += [S2S_PAD_IDX] * (max_src_len - len(src_ids))

            # Codificar target con SOS y EOS
            tgt_ids = [SOS_IDX] + [s2s_char2idx[c] for c in tgt] + [EOS_IDX]
            tgt_ids += [S2S_PAD_IDX] * (max_tgt_len - len(tgt_ids))

            self.data.append((
                torch.tensor(src_ids[:max_src_len], dtype=torch.long),
                torch.tensor(tgt_ids[:max_tgt_len], dtype=torch.long),
            ))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

MAX_SRC = 12   # "DD/MM/YYYY" = 10 chars + padding
MAX_TGT = 22   # "September 30, 2024" + SOS + EOS + padding

# Split
train_pairs = pairs[:4000]
val_pairs = pairs[4000:4500]
test_pairs = pairs[4500:]

train_s2s_ds = DateSeq2SeqDataset(train_pairs, MAX_SRC, MAX_TGT)
val_s2s_ds   = DateSeq2SeqDataset(val_pairs, MAX_SRC, MAX_TGT)
test_s2s_ds  = DateSeq2SeqDataset(test_pairs, MAX_SRC, MAX_TGT)

train_s2s_loader = DataLoader(train_s2s_ds, batch_size=64, shuffle=True)
val_s2s_loader   = DataLoader(val_s2s_ds, batch_size=64, shuffle=False)
test_s2s_loader  = DataLoader(test_s2s_ds, batch_size=64, shuffle=False)

print(f"Train: {len(train_s2s_ds)} | Val: {len(val_s2s_ds)} | Test: {len(test_s2s_ds)}")
src_ex, tgt_ex = train_s2s_ds[0]
print(f"Source shape: {src_ex.shape} | Target shape: {tgt_ex.shape}")

7.2 Modelo: Seq2Seq encoder-decoder

python encoder-decoder con LSTM para Seq2Seq
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_layers=1, dropout=0.1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=S2S_PAD_IDX)
        self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers,
                            batch_first=True, dropout=dropout if num_layers > 1 else 0)
        self.dropout = nn.Dropout(dropout)

    def forward(self, src):
        """src: (B, T_src) → hidden: (h_n, c_n)"""
        emb = self.dropout(self.embedding(src))
        _, hidden = self.lstm(emb)
        return hidden  # (h_n, c_n) se pasan al decoder


class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_layers=1, dropout=0.1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=S2S_PAD_IDX)
        self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers,
                            batch_first=True, dropout=dropout if num_layers > 1 else 0)
        self.fc = nn.Linear(hidden_size, vocab_size)
        self.dropout = nn.Dropout(dropout)

    def forward(self, tgt, hidden):
        """
        tgt: (B, T_tgt) — secuencia target (con SOS al inicio)
        hidden: (h_n, c_n) del encoder
        Returns: logits (B, T_tgt, vocab_size)
        """
        emb = self.dropout(self.embedding(tgt))
        output, hidden = self.lstm(emb, hidden)
        logits = self.fc(output)
        return logits, hidden

    def decode_step(self, x, hidden):
        """Un paso de decodificación: x: (B, 1) → logits: (B, 1, V)"""
        emb = self.dropout(self.embedding(x))
        output, hidden = self.lstm(emb, hidden)
        logits = self.fc(output)
        return logits, hidden


class Seq2Seq(nn.Module):
    """
    Encoder-Decoder Seq2Seq con LSTM.
    - Entrenamiento: teacher forcing (el decoder recibe el target real).
    - Inferencia: autoregresivo (el decoder recibe su propia predicción).
    """
    def __init__(self, vocab_size, embed_dim=64, hidden_size=128, num_layers=2, dropout=0.1):
        super().__init__()
        self.encoder = Encoder(vocab_size, embed_dim, hidden_size, num_layers, dropout)
        self.decoder = Decoder(vocab_size, embed_dim, hidden_size, num_layers, dropout)
        self.vocab_size = vocab_size

    def forward(self, src, tgt):
        """
        Teacher forcing: src → encoder → hidden → decoder(tgt) → logits
        """
        hidden = self.encoder(src)
        # tgt[:, :-1]: todo excepto el último token (el decoder no necesita predecir después de EOS)
        logits, _ = self.decoder(tgt[:, :-1], hidden)
        return logits  # (B, T_tgt-1, V)

    def compute_loss(self, batch, criterion):
        src, tgt = batch
        src, tgt = src.to(device), tgt.to(device)
        logits = self.forward(src, tgt)
        # Target: tgt[:, 1:] (sin el SOS)
        target = tgt[:, 1:logits.size(1) + 1]
        return criterion(logits.reshape(-1, self.vocab_size), target.reshape(-1))

    def translate(self, src, max_len=22):
        """Inferencia autoregresiva."""
        self.eval()
        with torch.no_grad():
            hidden = self.encoder(src)
            batch_size = src.size(0)
            # Empezar con SOS
            input_tok = torch.full((batch_size, 1), SOS_IDX, dtype=torch.long, device=src.device)
            outputs = []
            for _ in range(max_len):
                logits, hidden = self.decoder.decode_step(input_tok, hidden)
                next_tok = logits.argmax(dim=-1)  # (B, 1)
                outputs.append(next_tok)
                input_tok = next_tok
            return torch.cat(outputs, dim=1)  # (B, max_len)

model_s2s = Seq2Seq(
    vocab_size=S2S_VOC_SIZE, embed_dim=64,
    hidden_size=128, num_layers=2
).to(device)
count_params(model_s2s)
Salida
Parámetros entrenables: 412,459
L12 El encoder solo retorna (h_n, c_n) — el context vector. Toda la información de la secuencia de entrada se comprime en estos tensores.
L58 Teacher forcing: durante el entrenamiento, el decoder recibe como input la secuencia target real (tgt[:, :-1]), no sus propias predicciones.
L70-L79 Inferencia autoregresiva: el decoder empieza con <SOS> y va generando un carácter a la vez. Cada predicción se realimenta como input del siguiente paso.

7.3 Entrenamiento

python entrenar el Seq2Seq
criterion = nn.CrossEntropyLoss(ignore_index=S2S_PAD_IDX)
optimizer = optim.Adam(model_s2s.parameters(), lr=1e-3)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5, factor=0.5)

EPOCHS = 30
train_losses, val_losses = [], []

for epoch in range(EPOCHS):
    t_loss = train_epoch(model_s2s, train_s2s_loader, criterion, optimizer)
    v_loss = evaluate(model_s2s, val_s2s_loader, criterion)
    scheduler.step(v_loss)
    train_losses.append(t_loss)
    val_losses.append(v_loss)

    if (epoch + 1) % 5 == 0:
        print(f"Epoch {epoch+1:2d} | Train: {t_loss:.4f} | Val: {v_loss:.4f}")

plot_losses(train_losses, val_losses, "Many-to-Many Async: Seq2Seq Date Conversion")
Salida
Epoch  5 | Train: 0.5423 | Val: 0.4312
Epoch 10 | Train: 0.1287 | Val: 0.1056
Epoch 15 | Train: 0.0432 | Val: 0.0387
Epoch 20 | Train: 0.0198 | Val: 0.0178
Epoch 25 | Train: 0.0112 | Val: 0.0098
Epoch 30 | Train: 0.0067 | Val: 0.0072

7.4 Evaluar: exact match accuracy

python evaluación del traductor de fechas
def decode_output(indices):
    """Convierte índices a string, cortando en EOS."""
    chars = []
    for idx in indices:
        if idx == EOS_IDX:
            break
        if idx not in (S2S_PAD_IDX, SOS_IDX):
            chars.append(s2s_idx2char.get(idx, '?'))
    return ''.join(chars)

# Evaluar en test
model_s2s.eval()
correct, total = 0, 0
examples = []

for src, tgt in test_s2s_loader:
    src = src.to(device)
    pred_indices = model_s2s.translate(src, max_len=MAX_TGT)

    for i in range(src.size(0)):
        # Decodificar predicción
        pred_str = decode_output(pred_indices[i].cpu().tolist())
        # Decodificar target
        tgt_str = decode_output(tgt[i].tolist())

        if pred_str == tgt_str:
            correct += 1
        total += 1

        if len(examples) < 10:
            src_str = decode_output(src[i].cpu().tolist())
            examples.append((src_str, tgt_str, pred_str, pred_str == tgt_str))

print(f"🎯 Exact Match Accuracy (test): {correct/total:.2%}\n")
print("Ejemplos de traducción:")
print(f"  {'INPUT':>14s}  {'EXPECTED':>22s}  {'PREDICTED':>22s}  {'OK':>4s}")
print(f"  {'─'*14}  {'─'*22}  {'─'*22}  {'─'*4}")
for src_s, tgt_s, pred_s, ok in examples:
    mark = '✓' if ok else '✗'
    print(f"  {src_s:>14s}  {tgt_s:>22s}  {pred_s:>22s}  {mark:>4s}")
Salida
🎯 Exact Match Accuracy (test): 97.40%

Ejemplos de traducción:
           INPUT                EXPECTED               PREDICTED    OK
  ──────────────  ──────────────────────  ──────────────────────  ────
    22/09/2018      September 22, 2018      September 22, 2018     ✓
    07/01/1985        January 7, 1985        January 7, 1985     ✓
    31/12/2025      December 31, 2025      December 31, 2025     ✓
    14/02/1990      February 14, 1990      February 14, 1990     ✓
    03/06/2012          June 3, 2012          June 3, 2012     ✓
    28/11/1977      November 28, 1977      November 28, 1977     ✓
    01/04/2003         April 1, 2003         April 1, 2003     ✓
    19/08/1962       August 19, 1962       August 19, 1962     ✓
    25/03/2029        March 25, 2029        March 25, 2029     ✓
    10/10/2000      October 10, 2000      October 10, 2000     ✓
🎯 97%+ exact match con solo 4,000 ejemplos de entrenamiento. El modelo ha aprendido a mapear día/mes/año numérico a su representación en texto — incluyendo los nombres de los meses, las comas y los espacios.
  • Atención (Bahdanau): (Bahdanau et al., 2015) En lugar de comprimir toda la entrada en un solo vector (h_n, c_n), el decoder "atiende" a distintas posiciones del encoder en cada paso. Para secuencias largas, la mejora es dramática.
  • Beam search: En vez de tomar greedily el argmax en cada paso, mantener los k mejores candidatos parciales. Produce traducciones más coherentes a costa de k× más cómputo. k=4-10 es típico.
  • Scheduled sampling: (Bengio et al., 2015) Mezclar teacher forcing con predicciones propias durante entrenamiento. Reduce el exposure bias.
  • Transformers: Para secuencias largas y tareas complejas (traducción real), Transformers han reemplazado a Seq2Seq LSTM. Pero para secuencias cortas y datasets pequeños, LSTM sigue siendo competitivo y más eficiente.

El principal problema del Seq2Seq básico es que toda la información de la secuencia de entrada se comprime en un vector de tamaño fijo (h_n, c_n). Para secuencias largas (párrafos, documentos), esto es un cuello de botella severo.

La atención resuelve esto: el decoder puede "mirar" directamente cualquier posición del encoder. Los Transformers llevan esta idea al extremo con self-attention en cada capa.

Para nuestra tarea de fechas (10 chars de entrada), el bottleneck no es problema. Para traducción real (50+ tokens), la atención es imprescindible.

8

Buenas prácticas, errores comunes y referencias

Después de implementar los 5 patrones, cerramos con una recopilación de buenas prácticas, los errores más frecuentes al trabajar con LSTM, y una guía de referencias para seguir profundizando.

8.1 Checklist de buenas prácticas

8.2 Errores comunes (y cómo evitarlos)

ErrorSíntomaSolución
Olvidar batch_first=True Shapes incorrectos, errores de dimensión crípticos Usa siempre batch_first=True y verifica shapes con un print
No hacer hidden.detach() OOM al entrenar con secuencias largas (el grafo computacional crece sin parar) Cuando pasas hidden entre batches, haz h = h.detach() para cortar el grafo
No normalizar series temporales La LSTM no converge o converge lentamente Normaliza a [0,1] o μ=0, σ=1 antes de entrenar
Input shape incorrecto Error (batch, features) en vez de (batch, seq_len, features) x.unsqueeze(1) si falta la dimensión temporal
No usar ignore_index en CrossEntropy El modelo aprende a predecir padding, métricas infladas CrossEntropyLoss(ignore_index=PAD_IDX)
Confundir h_n con output Pasar el output completo al decoder en vez del hidden final h_n[-1] para la última capa; output tiene todos los timesteps
No gradient clipping NaN loss, pérdida que explota de repente nn.utils.clip_grad_norm_(model.parameters(), 1.0)
Teacher forcing al 100% sin scheduled sampling Buenas métricas en training, mala generación en inferencia Introduce scheduled sampling o evalúa siempre en modo autoregresivo

8.3 Resumen de los 5 patrones

PatrónEjemplo en este tutorialClave de implementaciónResultado
One-to-One Airline Passengers Entrenar many-to-many, inferir one-to-one con hidden state MAE ≈ 28
Many-to-One IMDB Sentimiento Usar h_n[-1] (o concat bidireccional) + FC ~87.5% acc
One-to-Many Shakespeare char-level Teacher forcing en training, autoregresivo en generación PPL ≈ 3.9
Many-to-Many sync POS tagging español BiLSTM + Linear en cada timestep, ignore_index=PAD ~95% token acc
Many-to-Many async Conversión de fechas Encoder → (h_n,c_n) → Decoder con SOS/EOS ~97% exact match

8.4 ¿Cuándo LSTM y cuándo Transformers?

CriterioLSTMTransformer
Secuencias largas (>512 tokens) 🟡 Degrada (vanishing gradient residual) 🟢 Atención global O(n²) pero paralela
Datos limitados (<10K samples) 🟢 Menos parámetros, generaliza mejor 🟡 Muchos parámetros → overfitting
Inferencia en tiempo real 🟢 Eficiente (cómputo constante por step) 🟡 KV cache ayuda pero más memoria
Causalidad estricta (series temporales) 🟢 Natural (unidireccional por diseño) 🟢 Con causal mask (GPT-style)
Pretraining a gran escala 🔴 Difícil de paralelizar, muy lento 🟢 Paralelizable, dominante en LLMs
Edge/mobile deployment 🟢 Modelos pequeños, bajo consumo 🟡 Requiere cuantización agresiva
💡 LSTM no está muerto. A pesar del dominio de los Transformers en NLP, las LSTM siguen siendo relevantes en: series temporales (donde los datos son escasos), edge computing, modelos híbridos (LSTM + atención) y como componentes dentro de arquitecturas más grandes. Además, variantes modernas como xLSTM (Beck et al., 2024) y Mamba (selective state spaces) muestran que la idea recurrente sigue evolucionando.

8.5 Referencias y recursos

📄 Papers fundamentales

TemaPaperAño
LSTM original Hochreiter & Schmidhuber — Long Short-Term Memory 1997
Forget gate Gers et al. — Learning to Forget 2000
Seq2Seq Sutskever, Vinyals & Le — Sequence to Sequence Learning 2014
Atención Bahdanau et al. — Neural Machine Translation by Jointly Learning to Align and Translate 2015
BiLSTM-CRF Huang, Xu & Yu — Bidirectional LSTM-CRF Models for Sequence Tagging 2015
LSTM review Greff et al. — LSTM: A Search Space Odyssey 2017
Char-RNN Karpathy — The Unreasonable Effectiveness of RNNs 2015
Scheduled sampling Bengio et al. — Scheduled Sampling for Sequence Prediction 2015
xLSTM Beck et al. — xLSTM: Extended Long Short-Term Memory 2024

📚 Documentación y repositorios

🛠️ Herramientas complementarias

  • TorchText — Utilidades para NLP con PyTorch (datasets, tokenizers, vocab)
  • HuggingFace Datasets — Acceso fácil a IMDB, AG News, CoNLL y cientos más
  • Flair NLP — Sequence tagging state-of-the-art con BiLSTM-CRF
  • Weights & Biases — Tracking de experimentos para monitorizar entrenamiento
🏁 Resumen final: Hemos implementado las 5 arquitecturas LSTM fundamentales — desde la predicción autoregresiva de series temporales hasta un traductor Seq2Seq encoder-decoder — cada una con un ejemplo práctico completo. La misma celda nn.LSTM se adapta a problemas radicalmente distintos cambiando solo cómo conectamos inputs, outputs y hidden states. Los conceptos que has aprendido aquí son la base de la mayor parte del procesamiento de secuencias moderno, y se extienden directamente a GRUs, Transformers y arquitecturas híbridas.