Long Short-Term Memory (LSTM)
De la memoria que se desvanece a la memoria controlada: cell state, forget/input/output gates, matemáticas del LSTM, implementación práctica en PyTorch y TensorFlow, y el sorprendente descubrimiento de la «neurona sintiente» de OpenAI.
🧠 ¿Por qué necesitamos LSTM?
En el módulo de Fundamentos de RNN vimos que la RNN vanilla sufre un problema fundamental: el vanishing gradient. Al propagar gradientes hacia atrás a través de muchos pasos temporales, se produce un producto de Jacobianas:
Cuando \(\|W_{hh}\| < 1\), este producto decae exponencialmente — los gradientes se desvanecen. En la práctica, esto significa que la RNN solo puede «recordar» información de los últimos ~10-20 pasos temporales.
El problema real: No es que la RNN «olvide» — es que no puede aprender que debe recordar. Los gradientes que llegan desde pasos lejanos son tan pequeños que los pesos no se actualizan para capturar esas dependencias.
📝 La analogía: tomar apuntes
Imagina que estás en una clase magistral de 2 horas. Si solo confías en tu memoria de trabajo (la RNN vanilla), al final de la clase habrás olvidado la mayoría de los detalles del principio. Pero si tomas apuntes — un soporte externo donde puedes escribir, tachar y consultar selectivamente — puedes retener la información importante durante toda la sesión.
El LSTM implementa exactamente esta idea: además del hidden state \(h_t\) (la «memoria de trabajo»), introduce un cell state \(C_t\) (los «apuntes»), que es un canal de información separado controlado por puertas (gates) que deciden qué información escribir, mantener y borrar.
📜 Breve historia del LSTM
El Long Short-Term Memory fue propuesto por Sepp Hochreiter y Jürgen Schmidhuber en 1997, precisamente para resolver el problema del vanishing gradient que Hochreiter había identificado formalmente en su tesis doctoral de 1991.
Dato clave: El LSTM original de 1997 no tenía forget gate. Se añadió en 2000 por Gers, Schmidhuber y Cummins. Hoy, cuando hablamos de «LSTM» nos referimos siempre a la versión con forget gate, que es la estándar en todos los frameworks de deep learning.
La idea central del LSTM es elegantemente simple: en lugar de forzar toda la información a fluir a través de un único vector \(h_t\) con una función de activación que comprime continuamente los valores, crear un camino directo (highway) para que la información pueda fluir sin ser transformada — y usar puertas aprendibles para controlar qué información fluye por ese camino.
🛤️ El Cell State: la cinta transportadora
La innovación central del LSTM es el cell state \(C_t\) — un vector que recorre toda la secuencia como una cinta transportadora. A diferencia del hidden state \(h_t\), que se transforma con una función de activación no lineal en cada paso, el cell state fluye a través del tiempo con solo operaciones lineales: multiplicaciones y sumas elemento a elemento.
¿Por qué es lineal? Si \(C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t\), entonces \(\frac{\partial C_t}{\partial C_{t-1}} = \text{diag}(f_t)\). No hay multiplicaciones por matrices de pesos ni paso por funciones de activación saturantes. Si la forget gate está cerca de 1, el gradiente fluye sin modificación — esto es exactamente lo que resuelve el vanishing gradient.
🚪 Las tres puertas del LSTM
El LSTM controla el flujo de información a través de tres puertas (gates), cada una implementada como una capa sigmoide seguida de una multiplicación elemento a elemento. La sigmoide produce valores entre 0 y 1 que actúan como «reguladores»: 0 = cerrar completamente, 1 = abrir completamente.
Forget Gate \(f_t\)
«¿Qué borrar?» Decide qué información del cell state anterior debe ser descartada. Mira \(h_{t-1}\) y \(x_t\) y produce un vector de valores entre 0 (olvidar completamente) y 1 (mantener todo).
Input Gate \(i_t\)
«¿Qué escribir?» Decide qué información nueva se va a almacenar en el cell state. Trabaja junto con la candidate cell state \(\tilde{C}_t\) que propone los nuevos valores.
Output Gate \(o_t\)
«¿Qué leer?» Decide qué partes del cell state se exponen como el hidden state \(h_t\). El cell state pasa por tanh (normalización entre -1 y 1) y luego se filtra con esta puerta.
🔬 Anatomía de una celda LSTM
Veamos el diagrama completo de una celda LSTM. Cada paso temporal recibe tres entradas (\(x_t\), \(h_{t-1}\), \(C_{t-1}\)) y produce dos salidas (\(h_t\), \(C_t\)):
Paso a paso: flujo de datos en una celda LSTM
Ejemplo: al leer un nuevo sujeto, olvidar el género del sujeto anterior para concordancia.
Ejemplo: al leer «ella», almacenar que el nuevo sujeto es femenino singular.
El cell state se actualiza con solo operaciones lineales → el gradiente fluye limpio.
Ejemplo: si la siguiente palabra es un verbo, emitir la info de persona/número del sujeto almacenado.
Intuición de las puertas: Piensa en cada puerta como un regulador suave. Un valor de 0.9 en la forget gate dice «mantén el 90% de esta dimensión del cell state». Un valor de 0.1 en la input gate dice «apenas escribas en esta dimensión». La red aprende a abrir y cerrar estas puertas en función del contexto.
🎛️ Explorador interactivo de gates
Usa el widget para visualizar cómo los valores de las puertas afectan al cell state y la salida. Ajusta los controles y observa el flujo de información:
📐 Ecuaciones del LSTM
Formalicemos las operaciones de la celda LSTM. En cada paso temporal \(t\), se computa el siguiente conjunto de ecuaciones. La entrada es la concatenación \([h_{t-1}, x_t]\):
Donde \(\sigma\) es la función sigmoide, \(\odot\) denota multiplicación elemento a elemento (Hadamard product), y \([h_{t-1}, x_t]\) es la concatenación de los dos vectores.
¿Por qué σ para las gates y tanh para los candidatos?
- Sigmoide σ ∈ (0, 1): Actúa como un «regulador de flujo» — controla qué proporción de información pasa.
- Tanh ∈ (-1, 1): Genera los valores candidatos que pueden ser positivos o negativos. También normaliza el cell state al pasarlo por tanh antes de la output gate.
📏 Dimensiones y parámetros
Sea \(d_x\) la dimensión de la entrada y \(d_h\) la dimensión del hidden state (que es la misma que la del cell state). Cada puerta tiene su propia matriz de pesos y bias:
| Componente | Matriz de pesos | Dimensiones | Bias |
|---|---|---|---|
| Forget gate | \(W_f\) | \(d_h \times (d_h + d_x)\) | \(b_f \in \mathbb{R}^{d_h}\) |
| Input gate | \(W_i\) | \(d_h \times (d_h + d_x)\) | \(b_i \in \mathbb{R}^{d_h}\) |
| Candidato | \(W_C\) | \(d_h \times (d_h + d_x)\) | \(b_C \in \mathbb{R}^{d_h}\) |
| Output gate | \(W_o\) | \(d_h \times (d_h + d_x)\) | \(b_o \in \mathbb{R}^{d_h}\) |
Conteo total de parámetros
El LSTM tiene 4 conjuntos idénticos de pesos (uno para cada gate + candidato), frente a 1 solo conjunto en la RNN vanilla:
Un LSTM tiene ~4× más parámetros que una RNN vanilla con las mismas dimensiones. Esto se traduce en más cómputo por paso temporal y más memoria, pero a cambio se obtiene la capacidad de aprender dependencias a largo plazo que la RNN vanilla simplemente no puede capturar.
🔢 Calculadora de parámetros
📈 ¿Por qué el LSTM resuelve el vanishing gradient?
El secreto está en la derivada del cell state. Al calcular \(\frac{\partial C_t}{\partial C_{t-1}}\):
Si la forget gate \(f_t \approx 1\), entonces \(\frac{\partial C_t}{\partial C_{t-1}} \approx I\) (la identidad). Esto significa que el gradiente puede fluir sin atenuación a través de muchos pasos temporales, similar a una conexión residual (skip connection):
| Aspecto | RNN Vanilla | LSTM |
|---|---|---|
| Gradiente a T pasos | \(\prod W_{hh}^T \cdot \text{diag}(\tanh')\) → vanishing | \(\prod \text{diag}(f_t)\) → controlable |
| ¿Multiplicación por \(W_{hh}\)? | Sí, en cada paso | No en el camino del cell state |
| ¿Función de activación en el camino? | tanh en cada paso (\(\tanh' \leq 1\)) | Solo la forget gate (σ, aprendible) |
| Dependencias capturables | ~10-20 pasos | Cientos de pasos |
| Analogía | Teléfono estropeado | Mensaje escrito en papel |
Conexión con ResNets: La actualización del cell state \(C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t\) es análoga a las conexiones residuales \(y = F(x) + x\). Ambas crean «autopistas» para el gradiente. De hecho, las skip connections de los ResNets (2015) se inspiraron parcialmente en el mismo principio que el LSTM (1997).
⚙️ Truco práctico: inicialización del forget bias
Un detalle práctico importante: al inicializar un LSTM, se recomienda inicializar el bias de la forget gate a un valor positivo (típicamente 1.0 o 2.0). ¿Por qué?
- Si \(b_f\) empieza en 0, \(f_t = \sigma(0) = 0.5\) → la celda «medio olvida» desde el principio, antes de aprender nada.
- Con \(b_f = 1\), \(f_t = \sigma(1) \approx 0.73\) → la celda empieza manteniendo más información, y gradualmente aprende qué olvidar.
# PyTorch: inicializar forget bias a 1.0
import torch.nn as nn
lstm = nn.LSTM(input_size=100, hidden_size=256, batch_first=True)
# El bias del LSTM en PyTorch se almacena como:
# bias_ih: [b_i, b_f, b_g, b_o] concatenados
# bias_hh: [b_i, b_f, b_g, b_o] concatenados
# Cada uno tiene tamaño hidden_size
for name, param in lstm.named_parameters():
if 'bias' in name:
n = param.size(0)
# El forget gate es el segundo cuarto del bias
start = n // 4
end = n // 2
param.data[start:end].fill_(1.0)
print(f"{name}: forget bias → 1.0")
# TensorFlow: LSTM tiene unit_forget_bias=True por defecto
import tensorflow as tf
# Por defecto, TF inicializa b_f = 1.0
lstm = tf.keras.layers.LSTM(
units=256,
unit_forget_bias=True, # ← True por defecto
return_sequences=True
)
# Si quieres desactivarlo (no recomendado):
# lstm = tf.keras.layers.LSTM(256, unit_forget_bias=False)
🔥 LSTM en PyTorch
PyTorch ofrece dos interfaces para LSTM: nn.LSTM (procesa secuencias
completas y soporta stacking/bidireccionalidad) y nn.LSTMCell
(procesa un solo paso temporal, más control manual).
import torch
import torch.nn as nn
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes,
num_layers=2, dropout=0.3, bidirectional=True):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True, # entrada: (batch, seq, features)
dropout=dropout, # dropout entre capas (no en la última)
bidirectional=bidirectional
)
# Si es bidireccional, la salida tiene 2 * hidden_dim
direction_factor = 2 if bidirectional else 1
self.fc = nn.Linear(hidden_dim * direction_factor, num_classes)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# x: (batch, seq_len) — índices de tokens
embedded = self.dropout(self.embedding(x)) # (batch, seq, embed_dim)
# output: (batch, seq, hidden*directions)
# h_n: (layers*directions, batch, hidden) — último hidden state
# c_n: (layers*directions, batch, hidden) — último cell state
output, (h_n, c_n) = self.lstm(embedded)
# Usar el último hidden state de ambas direcciones
if self.lstm.bidirectional:
# Concatenar forward y backward del último layer
h_final = torch.cat([h_n[-2], h_n[-1]], dim=1)
else:
h_final = h_n[-1]
logits = self.fc(self.dropout(h_final))
return logits
# Ejemplo de uso
model = LSTMClassifier(
vocab_size=10000, embed_dim=128,
hidden_dim=256, num_classes=5
)
x = torch.randint(0, 10000, (32, 50)) # batch=32, seq_len=50
logits = model(x) # (32, 5)
print(f"Salida: {logits.shape}") # torch.Size([32, 5])
import torch
import torch.nn as nn
class ManualLSTM(nn.Module):
"""LSTM paso a paso con LSTMCell para máximo control."""
def __init__(self, input_dim, hidden_dim):
super().__init__()
self.hidden_dim = hidden_dim
self.cell = nn.LSTMCell(input_dim, hidden_dim)
def forward(self, x):
# x: (batch, seq_len, input_dim)
batch, seq_len, _ = x.shape
h = torch.zeros(batch, self.hidden_dim, device=x.device)
c = torch.zeros(batch, self.hidden_dim, device=x.device)
outputs = []
for t in range(seq_len):
h, c = self.cell(x[:, t, :], (h, c))
outputs.append(h)
# Aquí puedes inspeccionar h, c en cada paso
# o aplicar lógica condicional
return torch.stack(outputs, dim=1), (h, c)
# Ejemplo: inspeccionar el cell state en cada paso
model = ManualLSTM(input_dim=64, hidden_dim=128)
x = torch.randn(1, 10, 64) # 1 secuencia de 10 pasos
outputs, (h_final, c_final) = model(x)
print(f"Cell state final — norma: {c_final.norm():.4f}")
print(f"Cell state final — rango: [{c_final.min():.3f}, {c_final.max():.3f}]")
¿Cuándo usar LSTMCell? Cuando necesitas lógica custom en cada paso
temporal: atención personalizada, teacher forcing con scheduling, o cuando quieres
inspeccionar/modificar los estados intermedios. Para todo lo demás, nn.LSTM
es más rápido porque usa kernels optimizados (cuDNN).
🟧 LSTM en TensorFlow/Keras
import tensorflow as tf
def build_lstm_model(vocab_size=10000, embed_dim=128, hidden_dim=256,
max_len=200, num_classes=2):
model = tf.keras.Sequential([
# Embedding
tf.keras.layers.Embedding(vocab_size, embed_dim,
input_length=max_len),
tf.keras.layers.SpatialDropout1D(0.2),
# LSTM bidireccional stacked (2 capas)
tf.keras.layers.Bidirectional(
tf.keras.layers.LSTM(hidden_dim, return_sequences=True,
dropout=0.2, recurrent_dropout=0.2)
),
tf.keras.layers.Bidirectional(
tf.keras.layers.LSTM(hidden_dim // 2,
dropout=0.2, recurrent_dropout=0.2)
# return_sequences=False → solo el último hidden state
),
# Clasificación
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.3),
tf.keras.layers.Dense(num_classes, activation='softmax')
])
model.compile(
optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
return model
model = build_lstm_model()
model.summary()
import tensorflow as tf
import numpy as np
def build_time_series_lstm(seq_len=60, n_features=1, hidden_dim=64):
"""LSTM para predicción de series temporales (e.g., precios)."""
model = tf.keras.Sequential([
tf.keras.layers.LSTM(
hidden_dim, return_sequences=True,
input_shape=(seq_len, n_features)
),
tf.keras.layers.LSTM(hidden_dim // 2),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dense(1) # Predicción del siguiente valor
])
model.compile(optimizer='adam', loss='mse', metrics=['mae'])
return model
# Ejemplo: predecir el siguiente valor de una serie sintética
np.random.seed(42)
t = np.linspace(0, 20*np.pi, 2000)
series = np.sin(t) + 0.3 * np.sin(3*t) + 0.1 * np.random.randn(len(t))
# Crear ventanas deslizantes
SEQ_LEN = 60
X = np.array([series[i:i+SEQ_LEN] for i in range(len(series)-SEQ_LEN-1)])
y = series[SEQ_LEN+1:]
X = X.reshape(-1, SEQ_LEN, 1)
model = build_time_series_lstm(seq_len=SEQ_LEN)
model.fit(X[:1500], y[:1500], epochs=10, batch_size=32,
validation_data=(X[1500:], y[1500:]))
🔀 Variantes del LSTM
Desde la publicación original, se han propuesto varias variantes del LSTM. Un estudio exhaustivo de Greff et al. (2016), «LSTM: A Search Space Odyssey», evaluó las variantes más populares:
Las puertas miran directamente al cell state además de a \([h_{t-1}, x_t]\):
Conclusión (Greff et al.): Las peephole connections no mejoran significativamente el rendimiento en la mayoría de tareas. Aumentan la complejidad sin beneficio claro.
En lugar de tener \(f_t\) e \(i_t\) independientes, forzar que sumen 1:
Esto reduce parámetros y fuerza una «conservación de memoria»: solo puedes escribir nuevo contenido en la medida que borras algo viejo. Es la idea que luego inspiró el mecanismo del GRU.
Stacked: Cada capa LSTM procesa la salida de la capa inferior. Permite
aprender representaciones jerárquicas (la primera capa captura patrones locales, las
superiores patrones más abstractos).
Bidireccional: Dos LSTMs procesan la secuencia en ambas direcciones.
La salida concatena ambas. Útil cuando se tiene acceso a toda la secuencia (clasificación),
no para generación online.
Conclusión de Greff et al. (2016): «Ninguna de las variantes mejora significativamente sobre el LSTM estándar. La forget gate y la output gate son los componentes más críticos. Si hay que simplificar, la coupled forget-input gate (base del GRU) es la mejor opción.»
🌍 Aplicaciones del LSTM en el mundo real
El LSTM dominó el procesamiento de secuencias desde ~2013 hasta ~2018, antes de que los Transformers tomaran el relevo. Algunas de sus aplicaciones más impactantes:
🔮 La «neurona sintiente»: emergencia en LSTMs
En abril de 2017, investigadores de OpenAI publicaron un resultado fascinante que anticipó muchos de los fenómenos que luego veríamos en los grandes modelos de lenguaje: un LSTM entrenado de forma completamente no supervisada para predecir el siguiente carácter en textos de reseñas de Amazon desarrolló espontáneamente una neurona que codificaba el sentimiento del texto.
El experimento:
- Un LSTM de una sola capa con 4096 unidades en el cell state.
- Entrenado para predecir el siguiente carácter en 82 millones de reseñas de Amazon (tarea completamente no supervisada).
- Al analizar las 4096 dimensiones del cell state, descubrieron que una sola unidad (la unidad #2388) había aprendido a representar el sentimiento del texto de forma casi perfecta.
- Esta unidad por sí sola igualaba al estado del arte en análisis de sentimientos en el benchmark Stanford Sentiment Treebank.
¿Por qué es esto tan importante?
Este resultado de 2017 fue uno de los primeros ejemplos claros de emergencia en modelos de lenguaje — capacidades que surgen de manera no programada durante el entrenamiento no supervisado:
- Modelo: mLSTM multiplicativo de 4096 unidades, entrenado carácter a carácter.
- Datos: 82 millones de reseñas de Amazon (~38GB de texto).
- Tarea: Predecir el siguiente byte/carácter (language modeling).
- Resultado: La unidad #2388 sola logró 91.8% de accuracy en Stanford Sentiment Treebank (binario), igualando sistemas supervisados entrenados específicamente.
- Generación controlada: Fijando la unidad #2388 a valores extremos, el LSTM generaba reseñas consistentemente positivas o negativas.
- Paper: «Learning to Generate Reviews and Discovering Sentiment», Alec Radford, Rafal Jozefowicz, Ilya Sutskever (2017).
- Nota: Alec Radford es el mismo investigador que después lideraría GPT-1, GPT-2 y DALL-E en OpenAI.
Conexión con la actualidad: Este experimento demostró que los modelos de lenguaje no supervisados pueden aprender representaciones ricas del mundo como efecto secundario de aprender a predecir texto. Es exactamente el mismo principio que impulsa GPT-3, GPT-4 y todos los LLMs modernos — pero descubierto primero en un humilde LSTM.
🏛️ El legado del LSTM
Aunque los Transformers han reemplazado al LSTM como arquitectura dominante en NLP desde ~2018, el LSTM sigue siendo relevante y ampliamente utilizado:
| Escenario | LSTM sigue siendo útil | Mejor usar Transformers |
|---|---|---|
| Datos limitados | ✅ Menos parámetros, no necesita pretraining | ❌ Transformers necesitan muchos datos |
| Secuencias muy largas (>10K) | ✅ Memoria constante O(1) en seq_len | ⚠️ Atención cuadrática O(n²) |
| Edge/dispositivos pequeños | ✅ Ligero, pocos parámetros | ❌ Transformers son pesados |
| Streaming en tiempo real | ✅ Procesa token a token | ⚠️ Necesita contexto completo |
| NLP de alta calidad | ⚠️ Limitado sin pretraining masivo | ✅ BERT, GPT, etc. |
| Series temporales simples | ✅ Excelente, especialmente bidireccional | ✅ Competitive con más datos |
¿Por qué estudiar LSTM hoy? Porque sus ideas — gates, cell state, caminos directos de gradiente — son fundamentales para entender la evolución de la arquitectura de redes neuronales. Los mecanismos de atención, las skip connections de ResNet, y hasta las técnicas de control en LLMs modernos tienen raíces conceptuales en el LSTM. Además, sigue siendo la mejor opción en muchos escenarios prácticos donde los Transformers son excesivos.