Capítulo 5: Secuencias, RNNs y embeddings — el camino hacia los Transformers¶
"Las RNN nos enseñaron a leer. Los Transformers nos enseñaron a leerlo todo a la vez. Pero nadie entiende de verdad la atención si no ha sufrido antes el cuello de botella de un vector de 512 números intentando contener 'Guerra y Paz'."
Bienvenido al capítulo 5 del módulo de Deep Learning. Hasta ahora hemos trabajado con datos "estáticos": un vector de características (MLP, capítulos 1-3) o una imagen (CNN, capítulo 4). En ambos casos, la entrada tiene tamaño fijo y sus partes no tienen un orden temporal.
Pero el mundo real está lleno de datos que suceden en orden: frases, series temporales de ventas, audio, logs de servidores, secuencias de clics de un usuario. Este capítulo te enseña cómo el deep learning aprendió a procesarlos — y, sobre todo, por qué ese camino desembocó inevitablemente en los Transformers que verás en el capítulo 6.
Este es probablemente el capítulo más importante del módulo desde el punto de vista conceptual: aquí nacen los embeddings (la idea que sostiene todo el módulo 06-RAG), la atención (el corazón de GPT, Claude y compañía) y la comprensión profunda de por qué los LLMs son como son.
Índice¶
- Datos secuenciales: por qué son especiales
- Representar texto para redes neuronales
- Embeddings a fondo
- La RNN vanilla a fondo
- LSTM y GRU: la solución con puertas
- Proyecto: clasificación de sentimiento con RNN en PyTorch
- Seq2seq y el cuello de botella: nace la atención
- Por qué las RNN perdieron la guerra
- Caso empresarial: predicción de demanda energética con LSTM
- Buenas prácticas
- Malas prácticas
- Errores comunes
- FAQ — Preguntas frecuentes
- Resumen del capítulo
- Bibliografía y lecturas recomendadas
1. Datos secuenciales: por qué son especiales¶
1.1 ¿Qué es una secuencia?¶
Una secuencia es una colección ordenada de elementos donde el orden forma parte de la información. Formalmente, una secuencia de longitud $T$ es:
$$x = (x_1, x_2, x_3, \dots, x_T)$$
donde cada $x_t$ es un elemento (una palabra, una medición, una muestra de audio) y el subíndice $t$ indica su posición temporal o posicional.
Compara estas dos "bolsas" de palabras:
Mismas palabras. Mismo conteo. Significado radicalmente distinto. Un modelo que ignore el orden (como el TF-IDF que usaste en el módulo 02) es literalmente incapaz de distinguirlas. Esa es la primera propiedad clave.
1.2 Las tres propiedades que hacen difíciles a las secuencias¶
| Propiedad | Descripción | Ejemplo | Por qué rompe a un MLP |
|---|---|---|---|
| El orden importa | Permutar los elementos cambia el significado | "no me gusta" vs "me gusta, no?" | El MLP trata cada entrada como características independientes sin noción de posición |
| Longitud variable | Cada muestra puede tener un número distinto de elementos | Un tuit de 5 palabras y una reseña de 300 | El MLP exige un vector de entrada de tamaño fijo |
| Dependencias a distancia | Un elemento puede depender de otro muy lejano | "El libro que me regalaste por mi cumpleaños hace tres años todavía está sin leer" — la concordancia libro↔está cruza 12 palabras | Ninguna arquitectura vista hasta ahora conecta posiciones arbitrariamente lejanas de forma natural |
1.3 Ejemplos de datos secuenciales en el mundo real¶
| Dominio | Secuencia | Elemento $x_t$ | Tarea típica |
|---|---|---|---|
| Texto / NLP | Frase, documento | Palabra o token | Clasificación, traducción, generación |
| Series temporales | Ventas diarias, demanda eléctrica | Valor numérico (+ features) | Forecasting, detección de anomalías |
| Audio | Señal de voz | Muestra o espectrograma por frame | Reconocimiento de voz, clasificación |
| Logs de servidores | Eventos de un sistema | Línea de log / evento | Detección de intrusiones, predicción de fallos |
| Clickstream | Navegación de un usuario | Página / acción | Recomendación, predicción de abandono |
| Biología | ADN, proteínas | Nucleótido / aminoácido | Predicción de estructura (AlphaFold usa atención) |
1.4 Por qué ni el MLP ni la CNN encajan de forma natural¶
El MLP falla porque:
- Entrada fija:
nn.Linear(in_features=?, ...)necesita saberin_featuresde antemano. ¿Qué haces con frases de 5 y de 500 palabras? ¿Truncas? ¿Rellenas hasta un máximo gigante? Ambas opciones son parches. - Sin compartición de parámetros: la palabra "excelente" en la posición 3 usa pesos completamente distintos que "excelente" en la posición 47. El modelo tendría que aprender el mismo concepto una vez por posición. Desperdicio brutal de datos y parámetros.
- Sin noción de orden: para el MLP, la entrada es un vector plano. Permutar sus componentes de manera consistente no le afecta conceptualmente.
La CNN casi encaja, pero no del todo:
Las CNN 1D sobre texto existen (y funcionan sorprendentemente bien para clasificación). Un filtro de tamaño 3 detecta n-gramas como "no me gusta". Pero:
- Campo receptivo limitado: un filtro ve una ventana local. Para conectar la palabra 1 con la 100 necesitas apilar muchas capas (igual que en imágenes, capítulo 4). Las dependencias a larga distancia requieren profundidad artificial.
- Longitud variable: se puede parchear con global pooling, pero pierdes la estructura fina.
- No hay "estado": la CNN no acumula información a medida que "lee". Procesa ventanas, no historias.
Nota
Esta reflexión — "necesitamos una arquitectura cuyo diseño refleje la estructura de los datos" — es el hilo conductor de todo el deep learning. Las CNN codifican el prior de "la información visual es local y trasladable". Las RNN codifican el prior de "la información secuencial se acumula en orden". Los Transformers codifican "cualquier posición puede relacionarse con cualquier otra". Elegir arquitectura es elegir un prior.
Ejercicio rápido 1¶
¿Cuál de estos problemas es genuinamente secuencial y cuál puede resolverse razonablemente con un modelo "bolsa de características"? (a) Detectar spam en emails. (b) Predecir la siguiente palabra de una frase. (c) Clasificar si una reseña es positiva o negativa. (d) Traducir del español al inglés.
Ver solución
- **(a) Spam**: en gran medida funciona como bolsa de palabras — la presencia de "VIAGRA", "GRATIS", "haz clic" delata el spam sin importar el orden. Un TF-IDF + regresión logística sigue siendo un baseline durísimo de batir. - **(b) Siguiente palabra**: **genuinamente secuencial**. "El gato se subió al ___" — la predicción depende del orden completo del contexto. Imposible sin modelar la secuencia. - **(c) Sentimiento**: intermedio. Muchas reseñas se clasifican por vocabulario ("horrible", "maravilloso"), pero la negación ("no está nada mal") y el sarcasmo exigen orden. Con datasets pequeños, la bolsa de palabras suele ganar (lo comprobaremos en la sección 6). - **(d) Traducción**: **totalmente secuencial**, tanto en entrada como en salida. Además la salida tiene longitud variable y distinta de la entrada. Es LA tarea que motivó seq2seq y la atención.2. Representar texto para redes neuronales¶
Las redes neuronales solo entienden números. Antes de entrenar nada sobre texto necesitamos un pipeline de representación: texto crudo → tokens → índices → vectores. En el módulo 01 viste esto por encima; aquí lo hacemos con la profundidad que exige el resto del máster.
flowchart LR
A["Texto crudo<br/>'me encanta este producto'"] --> B["Tokenización<br/>[me, encanta, este, producto]"]
B --> C["Vocabulario + índices<br/>[12, 847, 33, 291]"]
C --> D["Padding y máscara<br/>[12, 847, 33, 291, 0, 0]"]
D --> E["Embedding<br/>matriz 6 × d"]
E --> F["Red neuronal<br/>RNN / Transformer"]
2.1 Tokenización: ¿cuáles son las unidades?¶
Tokenizar es partir el texto en unidades (tokens). Hay tres filosofías, cada una con su trade-off:
| Estrategia | Ejemplo con "inteligentemente" | Tamaño de vocabulario | Ventajas | Problemas |
|---|---|---|---|---|
| Por palabra | [inteligentemente] |
50 000 – 500 000+ | Tokens con significado directo | Vocabulario explota; palabras nuevas → token <UNK> (desconocido); "inteligente" e "inteligentemente" son tokens sin relación |
| Por carácter | [i,n,t,e,l,i,g,e,n,t,e,m,e,n,t,e] |
~100 | Vocabulario mínimo; nunca hay <UNK> |
Secuencias larguísimas; el modelo debe aprender la ortografía desde cero; el significado queda muy diluido |
| Por subpalabra (BPE, WordPiece, SentencePiece) | [inteligente, mente] |
30 000 – 100 000 | Lo mejor de ambos mundos: palabras frecuentes = 1 token, palabras raras = composición de piezas; sin <UNK> |
Los cortes no siempre son morfológicamente "bonitos" |
Advertencia
La tokenización por subpalabras no es un detalle académico: es EXACTAMENTE lo que usan GPT, Claude, Llama y todos los LLMs que verás en el módulo 04. Cuando una API te cobra "por token", te está cobrando por unidades BPE (o variantes). Entender BPE ahora te ahorrará muchas confusiones después (¿por qué "chihuahua" cuesta 3 tokens y "the" cuesta 1? ¿por qué los LLMs cuentan mal las letras de una palabra?).
2.2 BPE (Byte-Pair Encoding) paso a paso, a mano¶
BPE parte de caracteres y fusiona iterativamente el par de tokens adyacentes más frecuente del corpus, añadiendo la fusión al vocabulario. Vamos a ejecutar el algoritmo a mano.
Corpus de juguete (con frecuencia de cada palabra):
Paso 0 — inicialización. Cada palabra se representa como caracteres (usamos _ como marca de fin de palabra, importante para que BPE distinga "bajo" de "bajofondo"):
Vocabulario inicial: {a, b, i, j, o, r, t, _} — 8 tokens.
Paso 1 — contar pares adyacentes:
| Par | Apariciones | Cálculo |
|---|---|---|
(b, a) |
14 | 5 + 4 + 3 + 2 (en "trabajo" aparece en "ba") |
(a, j) |
14 | 5 + 4 + 3 + 2 |
(j, o) |
7 | 5 + 2 |
(j, a) |
4 | de "bajar" |
(o, _) |
7 | 5 + 2 |
| resto | <7 |
Empate entre (b,a) y (a,j); rompemos el empate por orden de aparición → fusionamos (b,a) → ba.
Paso 2 — recontar. Ahora el par más frecuente es (ba, j) con 14 apariciones → fusión ba + j → baj:
Paso 3. Par más frecuente: (baj, o) con 5 + 2 = 7 → fusión → bajo:
Paso 4. Par más frecuente: (bajo, _) con 7 → fusión → bajo_ (palabra completa "bajo" y sufijo de "trabajo" ya son un solo token).
Si seguimos unas iteraciones más aparecerían bajar_, bajito_, etc. Fíjate en lo que ha pasado:
- La raíz
bajemergió sola como token, sin que nadie le enseñara morfología española. BPE la descubrió por pura estadística de co-ocurrencia. - Una palabra nueva jamás vista, como "bajista", se tokenizaría como
[baj, i, s, t, a, _]— sin necesidad de<UNK>. El modelo puede inferir algo de su significado por compartir la piezabajcon "bajo" y "bajar". - El número de fusiones es un hiperparámetro: GPT-2 usó ~50 000 fusiones; los tokenizadores modernos rondan 100 000-200 000.
Consejo profesional
Cuando en el módulo 04 uses tiktoken o el tokenizador de Hugging Face, juega cinco minutos a tokenizar palabras españolas. Verás que el español "cuesta más tokens" que el inglés (los corpus de entrenamiento de los tokenizadores tienen más inglés, así que las fusiones favorecen palabras inglesas). Esto afecta directamente a tu factura de API y a la ventana de contexto efectiva.
2.3 Vocabulario e índices¶
Tras tokenizar, construimos el vocabulario: un diccionario token → índice entero.
# Corpus de ejemplo ya tokenizado
corpus = [["me", "encanta", "este", "producto"],
["no", "me", "gusta", "nada"]]
# Tokens especiales SIEMPRE primero (por convención)
# <PAD> = relleno, <UNK> = token desconocido en inferencia
vocab = {"<PAD>": 0, "<UNK>": 1}
for frase in corpus: # recorremos cada frase
for token in frase: # y cada token de la frase
if token not in vocab: # si es la primera vez que lo vemos
vocab[token] = len(vocab) # le asignamos el siguiente índice libre
print(vocab)
# {'<PAD>': 0, '<UNK>': 1, 'me': 2, 'encanta': 3, 'este': 4,
# 'producto': 5, 'no': 6, 'gusta': 7, 'nada': 8}
# Codificar una frase nueva: los tokens fuera de vocabulario van a <UNK>
def codificar(tokens, vocab):
return [vocab.get(t, vocab["<UNK>"]) for t in tokens]
print(codificar(["me", "encanta", "python"], vocab)) # [2, 3, 1] ← "python" → <UNK>
2.4 Padding y máscaras: el precio de los tensores rectangulares¶
Las GPUs quieren tensores rectangulares: un batch de 4 frases debe ser una matriz (4, T). Pero las frases tienen longitudes distintas. Solución: padding — rellenar las cortas con el token <PAD> (índice 0) hasta la longitud de la más larga del batch.
Batch original (longitudes 4, 2, 6, 3):
Frase 1: [ 12, 847, 33, 291 ]
Frase 2: [ 6, 9 ]
Frase 3: [ 12, 6, 55, 91, 14, 33 ]
Frase 4: [ 7, 21, 8 ]
Tras padding a T = 6: Máscara (1 = real, 0 = relleno):
Frase 1: [ 12, 847, 33, 291, 0, 0 ] [ 1, 1, 1, 1, 0, 0 ]
Frase 2: [ 6, 9, 0, 0, 0, 0 ] [ 1, 1, 0, 0, 0, 0 ]
Frase 3: [ 12, 6, 55, 91, 14, 33 ] [ 1, 1, 1, 1, 1, 1 ]
Frase 4: [ 7, 21, 8, 0, 0, 0 ] [ 1, 1, 1, 0, 0, 0 ]
¿Y la máscara para qué sirve? Para que el modelo ignore el relleno:
- En una RNN: no queremos que el estado oculto final incluya el "procesamiento" de 3 tokens
<PAD>sin significado. PyTorch ofrecepack_padded_sequencepara esto. - En la pérdida: si generamos texto, no queremos penalizar predicciones sobre posiciones de padding (
ignore_index=0enCrossEntropyLoss). - En los Transformers (capítulo 6): la attention mask impide que la atención "mire" posiciones de relleno. La máscara que acabas de ver es literalmente el mismo objeto que enviarás en
attention_maskcuando uses Hugging Face.
Advertencia
Olvidar la máscara es uno de los bugs silenciosos más comunes en NLP. El modelo entrena, la pérdida baja... pero parte de su capacidad se desperdicia aprendiendo que "después de una reseña corta vienen muchos ceros". Rendimiento peor sin ningún error visible.
Ejercicio rápido 2¶
Aplica dos fusiones BPE al corpus: "sol" ×4, "sola" ×3, "solar" ×2 (con marca _ de fin de palabra). ¿Qué tokens nuevos se crean?
Ver solución
Inicialización: `s o l _ (×4)`, `s o l a _ (×3)`, `s o l a r _ (×2)`. **Conteo 1:** `(s,o)` = 9, `(o,l)` = 9, `(l,_)` = 4, `(l,a)` = 5, `(a,_)` = 3, `(a,r)` = 2, `(r,_)` = 2. Empate `(s,o)` y `(o,l)` → fusionamos el primero: **`so`**. Corpus: `so l _`, `so l a _`, `so l a r _`. **Conteo 2:** `(so,l)` = 9 → fusión: **`sol`**. Tokens nuevos: `so` y `sol`. La raíz "sol" emerge en solo dos pasos. Con una fusión más aparecería `sol_` (la palabra completa, 4 apariciones) o `sola` (5 apariciones de `(sol,a)`) — ganaría `sola`.3. Embeddings a fondo¶
Esta es la sección estrella del capítulo. Los embeddings son posiblemente la idea más influyente del NLP moderno: convertir símbolos discretos en puntos de un espacio geométrico donde la distancia significa algo. Todo el módulo 06-RAG (búsqueda semántica, bases de datos vectoriales) es esta idea a escala industrial.
3.1 One-hot vs embedding denso¶
La forma más ingenua de vectorizar un token es el one-hot: un vector del tamaño del vocabulario, todo ceros salvo un 1 en la posición del token.
Vocabulario de 50 000 palabras:
"gato" = [0, 0, 0, ..., 1, ..., 0, 0] ← 50 000 dimensiones, un solo 1
"perro" = [0, 0, 1, ..., 0, ..., 0, 0]
"felino"= [0, 1, 0, ..., 0, ..., 0, 0]
Problemas devastadores:
| Problema | Consecuencia |
|---|---|
| Dimensión = tamaño del vocabulario | 50 000 dimensiones por palabra. Con la primera capa lineal de 512 neuronas: 25.6 millones de parámetros solo para "entrar" |
| Todos los vectores son ortogonales | distancia("gato","felino") = distancia("gato","tornillo"). Cero información semántica: cada palabra es una isla |
| Ninguna generalización | Lo aprendido sobre "gato" no transfiere nada a "felino", aunque signifiquen casi lo mismo |
La alternativa: el embedding denso. Cada token se representa con un vector de dimensión pequeña (64, 300, 1024...) de valores reales, y — aquí está la clave — ese vector es un parámetro entrenable. No lo diseñamos: la red lo aprende mediante backpropagation, igual que aprende cualquier peso.
d = 4 (en la práctica, 64–4096):
"gato" = [ 0.21, -1.30, 0.77, 0.05]
"felino" = [ 0.19, -1.25, 0.81, 0.11] ← ¡cerca de "gato"!
"perro" = [ 0.35, -1.10, 0.60, -0.02] ← cerca, pero menos
"tornillo"=[-1.80, 0.42, -0.95, 1.31] ← lejos de los tres
La red, al minimizar su pérdida en la tarea (clasificar, predecir la siguiente palabra...), descubre que le conviene colocar cerca las palabras que se comportan parecido. La semántica emerge de la optimización.
3.2 nn.Embedding: qué es de verdad¶
nn.Embedding tiene fama de misterioso y es lo más simple del mundo: una matriz entrenable con una operación de lookup (consulta por fila).
import torch
import torch.nn as nn
torch.manual_seed(42) # reproducibilidad
VOCAB_SIZE = 10 # 10 tokens en nuestro vocabulario
EMBED_DIM = 4 # cada token → vector de 4 números
emb = nn.Embedding(num_embeddings=VOCAB_SIZE, # filas de la tabla
embedding_dim=EMBED_DIM, # columnas de la tabla
padding_idx=0) # la fila 0 (<PAD>) queda fija a ceros
# y NO recibe gradiente
# La "tabla" es literalmente un parámetro: una matriz 10×4
print(emb.weight.shape) # torch.Size([10, 4])
print(emb.weight.requires_grad) # True ← se entrena con backprop, como todo
# Uso: le pasas ÍNDICES (enteros), te devuelve FILAS de la tabla
indices = torch.tensor([[2, 5, 3], # batch de 2 frases,
[7, 2, 0]]) # 3 tokens cada una (la 2ª acaba en <PAD>)
vectores = emb(indices)
print(vectores.shape) # torch.Size([2, 3, 4]) ← (batch, longitud, embed_dim)
# Equivalencia conceptual: emb(i) == emb.weight[i]
assert torch.equal(emb(torch.tensor(5)), emb.weight[5])
Nota
Matemáticamente, emb(i) equivale a multiplicar el one-hot de i por la matriz emb.weight. Pero multiplicar por un one-hot es carísimo e innecesario: como el one-hot solo tiene un 1, el resultado es simplemente seleccionar una fila. nn.Embedding implementa esa selección directa. Por eso se dice que "un embedding es una capa lineal optimizada para entradas one-hot".
Durante el entrenamiento, el gradiente de la pérdida fluye hasta las filas de emb.weight que se usaron en el batch, y solo esas filas se actualizan. Palabra a palabra, batch a batch, la tabla se va organizando geométricamente.
3.3 word2vec: la hipótesis distribucional hecha algoritmo¶
En 2013, Mikolov y su equipo en Google publicaron word2vec y demostraron algo que dejó atónita a la comunidad: entrenando embeddings con una tarea trivial sobre miles de millones de palabras, los vectores resultantes capturaban relaciones semánticas y sintácticas como operaciones aritméticas.
La hipótesis distribucional (Firth, 1957): "conocerás una palabra por las compañías que frecuenta". O en versión castiza: "dime con quién vienes y te diré qué significas". Las palabras "café" y "té" aparecen en contextos casi intercambiables ("tomar una taza de ___ caliente por la mañana"), y por eso intuimos que significan cosas parecidas. word2vec convierte esta intuición en función de pérdida.
Skip-gram, la variante estrella, intuitivamente:
La tarea de entrenamiento es absurdamente simple: dada una palabra central, predice sus palabras de contexto (las que aparecen en una ventana alrededor).
Frase: "me gusta tomar café por la mañana"
─────
Palabra central: "café" (ventana de ±2)
Contextos a predecir: "gusta", "tomar", "por", "la"
Pares de entrenamiento generados:
(café → gusta), (café → tomar), (café → por), (café → la)
El modelo es mínimo: dos tablas de embeddings (una para palabras centrales, otra para contextos). La probabilidad de que el contexto $c$ aparezca junto a la central $w$ se modela con el producto escalar de sus vectores:
$$P(c \mid w) = \frac{\exp(u_c^\top v_w)}{\sum_{c'} \exp(u_{c'}^\top v_w)}$$
¿Por qué esto produce geometría semántica? Sigue la cadena lógica:
- Para predecir bien los contextos de "café", el vector de "café" debe tener producto escalar alto con los vectores de "taza", "tomar", "caliente", "desayuno"...
- Pero "té" aparece en contextos casi idénticos. Así que el vector de "té" también necesita producto escalar alto con esos mismos contextos.
- Dos vectores con productos escalares altos contra el mismo conjunto de vectores acaban apuntando en direcciones parecidas.
- Conclusión: contextos similares ⟹ vectores similares. La hipótesis distribucional queda grabada en la geometría.
El famoso rey − hombre + mujer ≈ reina, revisitado con más profundidad:
En el módulo 01 viste esta analogía como curiosidad. Ahora puedes entender por qué funciona. La diferencia entre los contextos de "rey" y "reina" es, estadísticamente, un conjunto consistente de señales de género: "rey" co-ocurre con "él", "su esposa", "príncipe heredero"; "reina" con "ella", "su esposo", "princesa". Esa diferencia sistemática de contextos se traduce en una dirección sistemática en el espacio: el vector reina − rey apunta aproximadamente igual que mujer − hombre, actriz − actor, yegua − caballo. Las relaciones que el corpus expresa de forma consistente se convierten en direcciones lineales reutilizables. Por eso:
$$\vec{rey} - \vec{hombre} + \vec{mujer} \approx \vec{reina}$$
No es magia: es que la resta de vectores cancela lo compartido (realeza) y aísla la dirección de género, que luego se suma al otro lado.
Consejo profesional
En la práctica, la analogía se evalúa con similitud coseno y excluyendo las palabras de entrada de los candidatos. Y no funciona siempre: es más un síntoma de que la geometría es buena que una herramienta fiable. Lo que SÍ es fiable y explotarás industrialmente en el módulo 06 es lo básico: cerca en el espacio = parecido en significado.
La limitación fatal de word2vec: un vector por palabra.
Considera:
"Fui al banco a pedir una hipoteca."
"Me senté en un banco del parque."
"Un banco de peces rodeó la barca."
Tres significados totalmente distintos de "banco"... y word2vec le asigna UN ÚNICO vector, que acaba siendo una mezcla borrosa de los tres, sesgada hacia el sentido más frecuente en el corpus. Lo mismo con "vela", "cura", "cola", "mango". Esta es la limitación estructural: los embeddings de word2vec son estáticos — el vector depende de la palabra, no de la frase donde aparece.
3.4 Embeddings contextuales: el adelanto¶
La solución conceptual es obvia una vez la ves: el vector de una palabra debería calcularse a partir de su contexto en cada aparición. "Banco" en la frase de la hipoteca debería recibir un vector cercano a "entidad financiera"; en la frase del parque, uno cercano a "asiento".
Eso es exactamente lo que hacen los Transformers (capítulo 6): cada capa de atención permite que el vector de cada token se mezcle con los de su contexto, produciendo embeddings contextuales — un vector distinto para cada aparición de cada palabra.
Nota (puente al módulo 06-RAG)
Cuando en el futuro llames a una API de embeddings (Voyage, OpenAI, Cohere...) para tu sistema RAG, lo que recibes son embeddings contextuales producidos por un Transformer: el vector de una frase entera calculado teniendo en cuenta cómo cada palabra modifica a las demás. word2vec es el abuelo conceptual de esa tecnología; la atención es lo que la hizo posible. Este capítulo te está dando el eslabón intermedio.
3.5 Visualizar embeddings con PCA¶
Los embeddings viven en 100-1000 dimensiones. Para verlos, proyectamos a 2D con PCA (¿recuerdas el módulo 02?). Ejemplo autocontenido con embeddings sintéticos que simulan la estructura que word2vec aprendería:
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(0)
# Simulamos embeddings de dimensión 50 con estructura de grupos:
# tres "temas" (animales, comida, tecnología) = tres centros distintos
palabras = ["gato", "perro", "caballo", "conejo", # animales
"pizza", "pasta", "ensalada", "tarta", # comida
"ordenador", "teclado", "pantalla", "ratón_pc"] # tecnología
centros = {"animal": np.random.randn(50), "comida": np.random.randn(50),
"tec": np.random.randn(50)}
grupos = ["animal"]*4 + ["comida"]*4 + ["tec"]*4
# Cada palabra = centro de su tema + ruido pequeño (así quedan agrupadas)
E = np.stack([centros[g]*3 + np.random.randn(50)*0.6 for g in grupos])
# --- PCA a mano en 4 líneas (módulo 02, ¡ya lo sabías hacer!) ---
E_centrada = E - E.mean(axis=0) # 1. centrar los datos
U, S, Vt = np.linalg.svd(E_centrada) # 2. descomposición SVD
proy = E_centrada @ Vt[:2].T # 3. proyectar sobre los 2
# componentes principales
# --- dibujar ---
plt.figure(figsize=(7, 5))
plt.scatter(proy[:, 0], proy[:, 1])
for i, p in enumerate(palabras): # etiquetar cada punto
plt.annotate(p, (proy[i, 0], proy[i, 1]))
plt.title("Embeddings proyectados con PCA: los temas forman clústeres")
plt.show()
# Verás tres nubes separadas: animales, comida y tecnología.
Con embeddings reales (word2vec, GloVe o los de tu propia red tras entrenar) el resultado es el mismo fenómeno: los clústeres semánticos saltan a la vista. Es la mejor herramienta de depuración cualitativa de un embedding: si tras entrenar, "excelente" y "horrible" aparecen pegados, algo va mal.
Ejercicio rápido 3¶
nn.Embedding(5000, 128): (a) ¿cuántos parámetros entrenables tiene? (b) Si le pasas un tensor de índices de shape (32, 20), ¿qué shape sale? (c) ¿Qué pasa si le pasas el índice 5000?
Ver solución
- **(a)** La tabla es 5000 × 128 = **640 000 parámetros**. (Si usas `padding_idx`, esa fila existe pero no se actualiza.) - **(b)** `(32, 20, 128)` — cada uno de los 32×20 índices se sustituye por su vector de 128 dims. - **(c)** **`IndexError`** (en GPU, a veces un error CUDA críptico o corrupción silenciosa). Los índices válidos son 0..4999. Es uno de los crashes más típicos: construyes el vocabulario con el train, llega un índice fuera de rango y explota. Regla: `num_embeddings = len(vocab)` siempre, y valida `indices.max() < num_embeddings` si dudas.4. La RNN vanilla a fondo¶
4.1 La idea: leer tomando notas¶
¿Cómo lees tú una frase? No la absorbes entera de golpe: la recorres palabra a palabra, manteniendo en la cabeza un resumen de lo leído hasta el momento que actualizas con cada palabra nueva. Al llegar al final, ese resumen mental es tu comprensión de la frase.
Una RNN (Recurrent Neural Network) formaliza exactamente eso:
- Un estado oculto $h_t$: el "resumen mental" — un vector que representa todo lo procesado hasta el paso $t$.
- Una función de actualización: en cada paso, combina el resumen anterior $h_{t-1}$ con la entrada nueva $x_t$ para producir el resumen actualizado $h_t$.
- Los mismos pesos en todos los pasos: la "manera de leer" no cambia según la posición. Esto resuelve la compartición de parámetros que el MLP no tenía, y permite longitud variable (simplemente das más o menos pasos).
4.2 La ecuación de recurrencia, desglosada¶
$$h_t = \tanh(W_{xh}\, x_t + W_{hh}\, h_{t-1} + b_h)$$ $$y_t = W_{hy}\, h_t + b_y$$
Pieza a pieza:
| Símbolo | Qué es | Shape (con entrada de dim $d$ y estado de dim $H$) | Intuición |
|---|---|---|---|
| $x_t$ | Entrada del paso $t$ (p. ej. embedding del token $t$) | $(d,)$ | "La palabra que estoy leyendo ahora" |
| $h_{t-1}$ | Estado oculto anterior | $(H,)$ | "Mi resumen mental hasta ahora" |
| $W_{xh}$ | Pesos entrada→estado | $(H, d)$ | "Cómo incorporo la palabra nueva" |
| $W_{hh}$ | Pesos estado→estado (¡la recurrencia!) | $(H, H)$ | "Cómo transformo/conservo mi memoria" |
| $b_h$ | Sesgo | $(H,)$ | Ajuste fino |
| $\tanh$ | No linealidad | — | Mantiene el estado acotado en $(-1, 1)$ y añade expresividad |
| $y_t$ | Salida del paso $t$ (opcional en cada paso) | $(V,)$ | P. ej. logits sobre el vocabulario para predecir el siguiente token |
El punto crucial: $W_{xh}$, $W_{hh}$, $W_{hy}$ son los mismos en todos los pasos de tiempo. Una RNN no es una red por token: es una única red aplicada en bucle, arrastrando su estado.
4.3 Unrolling: la RNN desplegada en el tiempo¶
Aunque la RNN se define como un bucle, para entenderla (y para calcular gradientes) conviene "desenrollarla": dibujar una copia de la celda por cada paso de tiempo, compartiendo pesos.
LA MISMA CELDA, DESPLEGADA EN EL TIEMPO
(W compartidos en TODOS los pasos)
y_1 y_2 y_3 y_4
↑ ↑ ↑ ↑
W_hy W_hy W_hy W_hy
│ │ │ │
h_0 ──►[RNN]──h_1──►[RNN]──h_2──►[RNN]──h_3──►[RNN]──► h_4
▲ W_hh ▲ W_hh ▲ W_hh ▲
W_xh W_xh W_xh W_xh
│ │ │ │
x_1 x_2 x_3 x_4
"me" "encanta" "este" "curso"
flowchart LR
h0(["h₀ = 0"]) --> C1
subgraph t1["t = 1"]
x1["x₁: 'me'"] --> C1["celda RNN<br/>(pesos W)"]
end
C1 -- "h₁"--> C2
subgraph t2["t = 2"]
x2["x₂: 'encanta'"] --> C2["celda RNN<br/>(mismos W)"]
end
C2 -- "h₂"--> C3
subgraph t3["t = 3"]
x3["x₃: 'este'"] --> C3["celda RNN<br/>(mismos W)"]
end
C3 -- "h₃"--> C4
subgraph t4["t = 4"]
x4["x₄: 'curso'"] --> C4["celda RNN<br/>(mismos W)"]
end
C4 -- "h₄ = resumen de toda la frase"--> OUT["clasificador /<br/>siguiente token"]
El estado final $h_4$ es un resumen comprimido de la frase entera en un vector. Guarda esta idea: será el origen del "cuello de botella" de la sección 7.
4.4 Proyecto: generador de nombres carácter a carácter (estilo Karpathy)¶
Vamos a construir una RNN a nivel de carácter que aprende a generar nombres de pila españoles. Es el "hola mundo" de los modelos de lenguaje: la misma idea que GPT, en miniatura — dado lo escrito hasta ahora, predice el siguiente carácter. Todo autocontenido, comentado línea por línea.
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(123) # resultados reproducibles
# ---------------------------------------------------------------
# 1. DATASET INLINE: ~50 nombres españoles
# ---------------------------------------------------------------
nombres = [
"sofia", "mateo", "lucia", "martin", "valentina", "hugo", "emma",
"leo", "olivia", "daniel", "julia", "pablo", "carmen", "alvaro",
"sara", "diego", "alba", "adrian", "noa", "mario", "vega", "manuel",
"claudia", "david", "elena", "javier", "laura", "sergio", "paula",
"andres", "irene", "carlos", "marta", "ivan", "ana", "ruben",
"silvia", "oscar", "nuria", "victor", "teresa", "jaime", "rocio",
"gonzalo", "pilar", "rodrigo", "aurora", "esteban", "celia", "bruno",
]
# ---------------------------------------------------------------
# 2. VOCABULARIO DE CARACTERES
# ---------------------------------------------------------------
# "." actuará como token especial de INICIO y de FIN de nombre
chars = sorted(set("".join(nombres))) # caracteres únicos, ordenados
stoi = {c: i + 1 for i, c in enumerate(chars)} # char → índice (desde 1)
stoi["."] = 0 # el token especial es el 0
itos = {i: c for c, i in stoi.items()} # índice → char (inverso)
V = len(stoi) # tamaño del vocabulario (~28)
# ---------------------------------------------------------------
# 3. PARES DE ENTRENAMIENTO: (contexto_hasta_t, siguiente_char)
# ---------------------------------------------------------------
# "sofia" genera: (.)→s, (.s)→o, (.so)→f, (.sof)→i, (.sofi)→a, (.sofia)→.
def nombre_a_ejemplo(nombre):
seq = "." + nombre + "." # añadimos inicio y fin
xs = [stoi[c] for c in seq[:-1]] # entrada: todo menos el último
ys = [stoi[c] for c in seq[1:]] # objetivo: todo desplazado +1
return torch.tensor(xs), torch.tensor(ys)
# ---------------------------------------------------------------
# 4. LA RNN "A MANO": la ecuación de recurrencia, sin nn.RNN
# ---------------------------------------------------------------
class CharRNN(nn.Module):
def __init__(self, vocab_size, embed_dim=16, hidden_dim=64):
super().__init__()
self.emb = nn.Embedding(vocab_size, embed_dim) # tabla de chars
self.W_xh = nn.Linear(embed_dim, hidden_dim) # entrada → estado
self.W_hh = nn.Linear(hidden_dim, hidden_dim) # estado → estado
self.W_hy = nn.Linear(hidden_dim, vocab_size) # estado → logits
self.hidden_dim = hidden_dim
def forward(self, indices):
# indices: (T,) — un nombre, carácter a carácter
T = indices.shape[0]
h = torch.zeros(self.hidden_dim) # h_0 = vector de ceros
logits_lista = []
for t in range(T): # ← EL BUCLE: esto ES la RNN
x_t = self.emb(indices[t]) # embedding del char actual
# LA ECUACIÓN DE RECURRENCIA, literal:
h = torch.tanh(self.W_xh(x_t) + self.W_hh(h))
logits_lista.append(self.W_hy(h)) # predicción del char siguiente
return torch.stack(logits_lista) # (T, V)
modelo = CharRNN(V)
opt = torch.optim.Adam(modelo.parameters(), lr=0.005)
# ---------------------------------------------------------------
# 5. ENTRENAMIENTO (simple: un nombre por paso, 3000 pasos)
# ---------------------------------------------------------------
import random
random.seed(0)
for paso in range(3000):
xs, ys = nombre_a_ejemplo(random.choice(nombres)) # nombre al azar
logits = modelo(xs) # (T, V): un logit por posición
loss = F.cross_entropy(logits, ys) # comparar con el char correcto
opt.zero_grad() # limpiar gradientes anteriores
loss.backward() # ← BPTT: backprop por el bucle
# recorte de gradiente: el seguro de vida de toda RNN (ver 4.6)
torch.nn.utils.clip_grad_norm_(modelo.parameters(), 5.0)
opt.step() # actualizar pesos
if paso % 500 == 0:
print(f"paso {paso:4d} | loss {loss.item():.3f}")
# ---------------------------------------------------------------
# 6. GENERACIÓN: muestrear nombres nuevos carácter a carácter
# ---------------------------------------------------------------
@torch.no_grad() # sin gradientes: solo inferimos
def generar():
h = torch.zeros(modelo.hidden_dim) # estado inicial
idx = torch.tensor(stoi["."]) # empezamos por el token inicio
salida = []
for _ in range(20): # máximo 20 caracteres
x = modelo.emb(idx) # embedding del char actual
h = torch.tanh(modelo.W_xh(x) + modelo.W_hh(h)) # actualizar memoria
probs = F.softmax(modelo.W_hy(h), dim=-1) # logits → probs
idx = torch.multinomial(probs, 1)[0] # MUESTREAR (no argmax:
# queremos variedad, no siempre
# el mismo nombre)
if idx.item() == 0: # si sale ".", el nombre acabó
break
salida.append(itos[idx.item()])
return "".join(salida)
for _ in range(10):
print(generar())
# Salida típica tras entrenar: "maria", "lucio", "sonia", "davia",
# "rodrio", "carla"... — nombres plausibles, algunos inventados pero
# "españoles" en su fonotáctica. El modelo aprendió QUÉ SUELE SEGUIR A QUÉ.
Nota
Este juguete de 60 líneas es, conceptualmente, un modelo de lenguaje completo: modela $P(\text{carácter}{t+1} \mid \text{caracteres})$ y genera muestreando de esa distribución. GPT hace lo mismo con tokens BPE en vez de caracteres, un Transformer en vez de una RNN, y 10^12 más datos. La receta — predecir lo siguiente + muestrear — es idéntica. Cuando en el módulo 04 hablemos de temperature y sampling, ya lo habrás implementado tú.
4.5 Backpropagation Through Time (BPTT): la intuición¶
¿Cómo se entrena esto? La red desenrollada (sección 4.3) es un grafo computacional normal — solo que muy profundo en el tiempo. Backprop sobre ese grafo desenrollado se llama BPTT, y no requiere matemática nueva: loss.backward() de PyTorch la hace automáticamente al recorrer el grafo del bucle.
La intuición importante: si la pérdida en el paso $t=20$ depende de la entrada en $t=1$ (dependencia a distancia), el gradiente debe viajar hacia atrás atravesando 19 aplicaciones de la celda. Cada travesía multiplica el gradiente por (aproximadamente) $W_{hh}^\top \cdot \text{diag}(\tanh')$. Y aquí llegan los problemas.
4.6 Las dos enfermedades: gradientes que se desvanecen y explotan¶
¿Recuerdas el capítulo 2, donde vimos que las redes MUY profundas sufren vanishing/exploding gradients porque el gradiente se multiplica capa a capa? Una RNN sobre una secuencia de 100 pasos es, a efectos de gradiente, una red de 100 capas... que además comparten los mismos pesos. El problema se agrava:
$$\frac{\partial h_T}{\partial h_1} \approx \prod_{t=2}^{T} W_{hh}^\top \, \text{diag}(\tanh'(\cdot))$$
- Multiplicar 99 veces por la misma matriz: si sus valores propios dominantes son < 1, el producto tiende a cero exponencialmente → vanishing gradient. Traducción práctica: la RNN no puede aprender dependencias largas. La señal de "el sujeto era plural, 40 palabras atrás" nunca llega. La RNN olvida.
- Si son > 1, el producto explota exponencialmente → exploding gradient. Traducción práctica: pérdida que de repente se dispara a
NaN. Solución estándar: gradient clipping (clip_grad_norm_), que ya incluimos en el código anterior — recorta la norma del gradiente a un máximo. Es un parche, pero un parche universalmente usado.
| Enfermedad | Síntoma | Causa | Remedio |
|---|---|---|---|
| Vanishing | El modelo aprende dependencias cortas pero ignora todo lo que quede a >10-20 pasos; la loss se estanca | Producto de muchos jacobianos con norma < 1 | Cambiar de arquitectura: LSTM/GRU (sección 5); inicialización cuidadosa; y a la larga... atención |
| Exploding | Loss oscila violentamente o se va a NaN de golpe |
Producto de jacobianos con norma > 1 | Gradient clipping (funciona muy bien); learning rate menor |
Advertencia
El vanishing gradient NO se arregla con clipping (el clipping actúa sobre gradientes grandes, no sobre gradientes muertos). Es un problema estructural de la recurrencia multiplicativa. Por eso hizo falta rediseñar la celda: entra en escena la LSTM.
Ejercicio rápido 4¶
En la RNN a mano de la sección 4.4, ¿cuántas veces se usan los pesos W_hh en el forward de un nombre de 6 caracteres (incluyendo los tokens de inicio/fin)? ¿Y cuántas copias de W_hh hay en memoria?
Ver solución
"sofia" con inicio/fin es ".sofia." → la entrada `xs` tiene 7 caracteres (".sofia"), así que el bucle da **7 pasos** y `W_hh` se **usa 7 veces** (una por paso). Pero en memoria hay **UNA sola copia** de `W_hh`: es el mismo `nn.Linear` llamado en bucle. En el backward, los gradientes de las 7 usos **se suman** sobre esa única matriz. Esa es la esencia de "compartir pesos en el tiempo" — y también la razón de que los gradientes se multipliquen por la misma matriz una y otra vez (sección 4.6).5. LSTM y GRU: la solución con puertas¶
5.1 La idea: una memoria con control de acceso¶
El problema de la RNN vanilla es que su memoria se reescribe entera en cada paso: $h_t$ es una transformación no lineal completa de $h_{t-1}$. Es como si, para tomar nota de una palabra nueva, tuvieras que reescribir todo el cuaderno. Tras 50 reescrituras, de la palabra 1 no queda nada.
En 1997, Hochreiter y Schmidhuber propusieron la LSTM (Long Short-Term Memory) con una idea quirúrgica: separar la memoria en un canal protegido — el estado de celda $c_t$ — por el que la información fluye aditivamente (suma, no multiplicación), y controlar el acceso a esa memoria con puertas aprendidas.
Analogía de la cinta transportadora: imagina el estado de celda como una cinta transportadora que recorre toda la secuencia. Los objetos (información) viajan sobre ella sin ser tocados... salvo que una puerta decida activamente quitar algo o poner algo. Por defecto, lo que está en la cinta, sigue en la cinta. Esa es la diferencia radical con la RNN vanilla, donde nada sobrevive intacto ni un paso.
Analogía del cuaderno: la LSTM lleva un cuaderno ($c_t$) escrito con rotulador permanente, pero con tres herramientas: - Un borrador selectivo (puerta de olvido): "esta nota ya no sirve, la borro". - Un rotulador (puerta de entrada): "esto es importante, lo apunto". - Una ventanita de lectura (puerta de salida): "de todo el cuaderno, ahora mismo enseño solo esto".
5.2 La LSTM puerta a puerta¶
En cada paso $t$, con entrada $x_t$ y estado oculto anterior $h_{t-1}$:
1. Puerta de olvido (forget gate) — ¿qué borro del cuaderno?
$$f_t = \sigma(W_f [h_{t-1}, x_t] + b_f)$$
Un vector de valores entre 0 y 1 (sigmoide), uno por posición de la memoria. $f_t^{(i)} = 1$ significa "conserva la posición $i$ intacta"; $0$ significa "bórrala". Ejemplo lingüístico: al leer un punto y empezar frase nueva, conviene olvidar el género del sujeto anterior.
2. Puerta de entrada (input gate) + candidato — ¿qué apunto nuevo?
$$i_t = \sigma(W_i [h_{t-1}, x_t] + b_i) \qquad \tilde{c}t = \tanh(W_c [h, x_t] + b_c)$$
$\tilde{c}_t$ es lo que podría apuntarse (contenido candidato); $i_t$ decide cuánto de ello se apunta de verdad, posición a posición.
3. Actualización de la celda — la cinta transportadora en acción:
$$c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t$$
Lee esta ecuación despacio, porque es la línea más importante de la sección: la memoria nueva es (lo viejo, filtrado por el olvido) más (lo nuevo, filtrado por la entrada). Es una suma. Cuando el gradiente fluye hacia atrás por $c_t = f_t \odot c_{t-1} + \dots$, atraviesa una operación aditiva con factor $f_t$: si la red aprende $f_t \approx 1$ para cierta información, el gradiente viaja intacto decenas de pasos. Adiós vanishing (dentro de lo razonable). ¿Te suena la jugada? Es la misma que las skip connections de ResNet del capítulo 4: crear autopistas aditivas para el gradiente.
4. Puerta de salida (output gate) — ¿qué enseño ahora mismo?
$$o_t = \sigma(W_o [h_{t-1}, x_t] + b_o) \qquad h_t = o_t \odot \tanh(c_t)$$
El estado oculto $h_t$ (lo que ve el resto de la red) es una vista filtrada de la memoria. El cuaderno completo ($c_t$) sigue guardado; solo enseñas la página relevante.
flowchart TB
subgraph CELDA["Celda LSTM en el paso t"]
direction LR
cprev(["c_{t-1}<br/>cinta transportadora"]) --> MUL1(("⊗"))
F["puerta olvido f_t<br/>σ: ¿qué borro?"] --> MUL1
MUL1 --> SUM(("⊕"))
I["puerta entrada i_t<br/>σ: ¿cuánto apunto?"] --> MUL2(("⊗"))
CAND["candidato c̃_t<br/>tanh: ¿qué apunto?"] --> MUL2
MUL2 --> SUM
SUM --> cnew(["c_t"])
SUM --> TANH["tanh"]
O["puerta salida o_t<br/>σ: ¿qué enseño?"] --> MUL3(("⊗"))
TANH --> MUL3
MUL3 --> hnew(["h_t"])
end
X["x_t y h_{t-1}"] --> F
X --> I
X --> CAND
X --> O
5.3 GRU: la simplificación que casi siempre basta¶
En 2014, Cho et al. propusieron la GRU (Gated Recurrent Unit): una LSTM simplificada que fusiona celda y estado oculto en un solo vector y usa dos puertas en vez de tres:
- Puerta de actualización $z_t$: mezcla en un solo control el papel de olvido+entrada — "¿cuánto conservo del pasado vs cuánto renuevo?": $h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$.
- Puerta de reinicio $r_t$: "¿cuánto del pasado uso para calcular el candidato nuevo?".
| Aspecto | LSTM | GRU |
|---|---|---|
| Puertas | 3 (olvido, entrada, salida) | 2 (actualización, reinicio) |
| Estados | 2 ($h_t$ y $c_t$) | 1 (solo $h_t$) |
| Parámetros | ~4·(d+H)·H | ~3·(d+H)·H (≈25% menos) |
| Velocidad | Más lenta | Más rápida |
| Rendimiento | Ligera ventaja en secuencias muy largas / datasets grandes | Empate práctico en la mayoría de tareas |
| ¿Cuál elegir? | Si dudas y tienes cómputo | Si priorizas velocidad/tamaño (edge, móvil) |
5.4 nn.LSTM en PyTorch: parámetros y shapes (zona de peligro )¶
import torch
import torch.nn as nn
lstm = nn.LSTM(
input_size=100, # dimensión de cada elemento de entrada (p.ej. embed_dim)
hidden_size=256, # dimensión del estado oculto h_t (y de la celda c_t)
num_layers=2, # LSTMs apiladas: la salida de la capa 1 alimenta a la 2
batch_first=True, # ⚠ CRÍTICO: entrada (batch, seq, features).
# Por defecto es False → (seq, batch, features)
dropout=0.3, # dropout ENTRE capas (solo si num_layers > 1)
bidirectional=False, # True = otra LSTM leyendo de derecha a izquierda
)
x = torch.randn(32, 50, 100) # batch=32 frases, 50 tokens, embed de 100
salida, (h_n, c_n) = lstm(x) # la LSTM devuelve DOS cosas:
print(salida.shape) # (32, 50, 256) → h_t de la ÚLTIMA capa en TODOS los pasos
print(h_n.shape) # (2, 32, 256) → h final de CADA capa (num_layers, batch, H)
print(c_n.shape) # (2, 32, 256) → c final de cada capa
Advertencia (la trampa nº 1 de PyTorch con RNNs)
batch_first=False es el valor por defecto por razones históricas de eficiencia. Si tus tensores son (batch, seq, dim) — lo natural viniendo de CNNs — y olvidas batch_first=True, la LSTM interpretará tu dimensión de batch como tiempo y viceversa. No dará error: las shapes son compatibles. Simplemente entrenará basura. Revísalo SIEMPRE. Segunda trampa: salida[:, -1, :] (último paso de la última capa) y h_n[-1] son lo mismo solo si no hay padding y no es bidireccional — con padding, el "último paso real" de cada frase está en posiciones distintas.
5.5 ¿Se usan todavía LSTM/GRU en 2026? Tabla honesta¶
Los Transformers dominan NLP de forma aplastante, pero declarar muerta a la LSTM es no haber trabajado en industria:
| Escenario | ¿LSTM/GRU en 2026? | Por qué |
|---|---|---|
| NLP general (clasificación, NER, QA) | No | Un Transformer preentrenado fine-tuneado gana casi siempre; el ecosistema (HF) lo hace trivial |
| Generación de texto / chat | No | Los LLMs son Transformers; no hay debate |
| Series temporales pequeñas/medianas | Sí, muy vivas | Con pocos miles de muestras, los Transformers sobreajustan; LSTM + features bien hechas es fortísimo (y a menudo pierde contra... ¡gradient boosting!) |
| Edge / móvil / microcontroladores | Sí | Una GRU pequeña cabe en KB de memoria y corre con latencia mínima sin GPU |
| Streaming con latencia mínima (audio en vivo, sensores) | Sí | La RNN procesa muestra a muestra con estado O(1); un Transformer necesita re-atender una ventana entera |
| Sistemas legacy en producción | Abundan | Millones de LSTM siguen sirviendo predicciones cada noche; migrar tiene coste y riesgo |
| Investigación de nuevas arquitecturas recurrentes | Renacimiento | Los SSM (Mamba y sucesores) recuperan la recurrencia con truco paralelo: la historia da vueltas |
Consejo profesional
En entrevistas técnicas de ML es un clásico preguntar "¿por qué la LSTM mitiga el vanishing gradient?". La respuesta que distingue a un senior: "porque la actualización del estado de celda es aditiva ($c_t = f_t \odot c_{t-1} + i_t \odot \tilde c_t$) en lugar de multiplicativa, creando un camino de gradiente casi sin atenuación cuando la puerta de olvido está abierta — el mismo principio que las skip connections de ResNet". Dos arquitecturas, una idea.
Ejercicio rápido 5¶
Si en una LSTM la puerta de olvido devuelve $f_t = [1, 1, 0, 1]$ y la de entrada $i_t = [0, 0, 1, 0]$ para cierto paso, ¿qué le pasa a cada posición de la memoria $c_t$?
Ver solución
Aplicando $c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t$ posición a posición: - Posiciones 1, 2 y 4: $f=1, i=0$ → $c_t = c_{t-1}$. **Memoria conservada intacta** (la cinta transportadora sigue su curso). - Posición 3: $f=0, i=1$ → $c_t = \tilde{c}_t$. **Se borra lo viejo y se escribe lo nuevo** (borrador + rotulador). Este ejemplo extremo (puertas binarias) ilustra el mecanismo; en la práctica las puertas toman valores continuos en (0,1) y hacen mezclas suaves. Fíjate: en las posiciones conservadas, el gradiente de $c_t$ respecto a $c_{t-1}$ es exactamente 1 — la autopista del gradiente.6. Proyecto: clasificación de sentimiento con RNN en PyTorch¶
Vamos a montar el pipeline completo de clasificación de texto: dataset inline de reseñas en español, tokenización, vocabulario, padding, Embedding + LSTM + Linear, entrenamiento, evaluación... y una comparación honesta con el baseline clásico del módulo 02.
import torch
import torch.nn as nn
import torch.nn.functional as F
import random
torch.manual_seed(7); random.seed(7)
# =================================================================
# 1. DATASET INLINE: 60 reseñas sintéticas en español (30 pos / 30 neg)
# =================================================================
datos = [
# --- POSITIVAS (etiqueta 1) ---
("me encanta este producto es fantastico", 1),
("calidad excelente y envio rapido lo recomiendo", 1),
("funciona de maravilla estoy muy contento", 1),
("el mejor movil que he tenido nunca", 1),
("servicio impecable y atencion al cliente perfecta", 1),
("superó todas mis expectativas una compra genial", 1),
("materiales de primera se nota la calidad", 1),
("muy facil de usar y el manual es claro", 1),
("precio inmejorable para lo que ofrece", 1),
("llegó antes de tiempo y en perfecto estado", 1),
("la bateria dura muchisimo estoy encantado", 1),
("diseño precioso y muy comodo de llevar", 1),
("repetire la compra sin ninguna duda", 1),
("todo perfecto vendedor muy recomendable", 1),
("una maravilla de aparato funciona genial", 1),
("relacion calidad precio excelente muy satisfecho", 1),
("mis hijos estan felices con el regalo", 1),
("cumple perfectamente con lo prometido", 1),
("el sonido es espectacular merece la pena", 1),
("compra perfecta cinco estrellas merecidas", 1),
("estoy muy satisfecha con el resultado final", 1),
("gran producto lo volveria a comprar mil veces", 1),
("la pantalla se ve increible colores vivos", 1),
("atencion rapida y producto tal como se describe", 1),
("no puedo estar mas contento funciona perfecto", 1),
("muy buena experiencia de compra todo genial", 1),
("es justo lo que buscaba calidad excelente", 1),
("embalaje cuidado y producto de gran calidad", 1),
("va rapidisimo y no se calienta nada", 1),
("me ha sorprendido para bien totalmente recomendable", 1),
# --- NEGATIVAS (etiqueta 0) ---
("una decepcion total no funciona bien", 0),
("llegó roto y el vendedor no responde", 0),
("calidad pesima se rompió a la semana", 0),
("no lo recomiendo para nada dinero tirado", 0),
("el peor producto que he comprado nunca", 0),
("la bateria dura poquisimo muy decepcionado", 0),
("servicio horrible tardaron un mes en enviarlo", 0),
("no cumple con lo que promete un engaño", 0),
("materiales baratos y acabados horribles", 0),
("dejo de funcionar al segundo dia fatal", 0),
("muy dificil de configurar y el manual es inutil", 0),
("me arrepiento de la compra no lo compreis", 0),
("caro para la calidad tan mala que tiene", 0),
("el sonido es horrible se oye fatal", 0),
("vino sin accesorios y con la caja destrozada", 0),
("una estafa no es lo que aparece en las fotos", 0),
("se calienta muchisimo y va lentisimo", 0),
("pesima atencion al cliente nunca mas", 0),
("no me gusta nada la pantalla se ve mal", 0),
("devolucion inmediata producto defectuoso", 0),
("mala experiencia el envio tardó semanas", 0),
("horrible se rompio el primer dia de uso", 0),
("no funciona como esperaba muy decepcionante", 0),
("un desastre de producto evitadlo por favor", 0),
("la peor compra del año dinero perdido", 0),
("defectuoso de fabrica y sin garantia real", 0),
("publicidad engañosa la calidad es malisima", 0),
("incomodo feo y encima carisimo", 0),
("tela de mala calidad se descosió enseguida", 0),
("cero estrellas si pudiera una verguenza", 0),
]
# =================================================================
# 2. TOKENIZACIÓN SIMPLE + VOCABULARIO
# =================================================================
def tokenizar(texto):
return texto.lower().split() # por espacios: suficiente aquí
vocab = {"<PAD>": 0, "<UNK>": 1} # especiales primero
for texto, _ in datos:
for tok in tokenizar(texto):
if tok not in vocab:
vocab[tok] = len(vocab) # índice incremental
V = len(vocab) # ~230 palabras únicas
print(f"Vocabulario: {V} tokens")
# =================================================================
# 3. CODIFICAR + PADDING (a la longitud máxima del dataset)
# =================================================================
MAX_LEN = max(len(tokenizar(t)) for t, _ in datos) # ~9 tokens
def codificar(texto):
idx = [vocab.get(t, 1) for t in tokenizar(texto)] # palabra→índice (<UNK>=1)
idx = idx[:MAX_LEN] # truncar si excede
longitud = len(idx) # longitud REAL (sin pad)
idx = idx + [0] * (MAX_LEN - longitud) # rellenar con <PAD>=0
return idx, longitud
X, L, y = [], [], []
for texto, etiqueta in datos:
idx, lon = codificar(texto)
X.append(idx); L.append(lon); y.append(etiqueta)
X = torch.tensor(X) # (60, MAX_LEN) índices
L = torch.tensor(L) # (60,) longitudes reales
y = torch.tensor(y, dtype=torch.float32) # (60,) etiquetas 0/1
# train/test split 80/20 estratificado "a mano"
perm = torch.randperm(len(datos))
X, L, y = X[perm], L[perm], y[perm]
n_test = 12
X_tr, L_tr, y_tr = X[:-n_test], L[:-n_test], y[:-n_test]
X_te, L_te, y_te = X[-n_test:], L[-n_test:], y[-n_test:]
# =================================================================
# 4. EL MODELO: Embedding → LSTM → Linear
# =================================================================
class ClasificadorSentimiento(nn.Module):
def __init__(self, vocab_size, embed_dim=32, hidden_dim=64):
super().__init__()
self.emb = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True) # ⚠
self.fc = nn.Linear(hidden_dim, 1) # 1 logit: positivo/negativo
def forward(self, x, longitudes):
e = self.emb(x) # (B, T) → (B, T, embed_dim)
# empaquetamos para que la LSTM IGNORE el padding y el estado
# final corresponda al ÚLTIMO TOKEN REAL de cada frase
packed = nn.utils.rnn.pack_padded_sequence(
e, longitudes.cpu(), batch_first=True, enforce_sorted=False)
_, (h_n, _) = self.lstm(packed) # h_n: (1, B, hidden_dim)
return self.fc(h_n[-1]).squeeze(-1) # (B,) logits
modelo = ClasificadorSentimiento(V)
opt = torch.optim.Adam(modelo.parameters(), lr=0.01)
criterio = nn.BCEWithLogitsLoss() # binaria con logits (estable)
# =================================================================
# 5. ENTRENAMIENTO (full-batch: 48 ejemplos caben de sobra)
# =================================================================
for epoca in range(120):
modelo.train()
logits = modelo(X_tr, L_tr) # forward
loss = criterio(logits, y_tr) # pérdida
opt.zero_grad(); loss.backward() # backward (BPTT dentro)
torch.nn.utils.clip_grad_norm_(modelo.parameters(), 5.0) # seguro anti-explosión
opt.step() # actualizar
if epoca % 20 == 0:
modelo.eval()
with torch.no_grad():
acc_te = ((modelo(X_te, L_te) > 0) == y_te.bool()).float().mean()
print(f"época {epoca:3d} | loss {loss:.3f} | acc test {acc_te:.2f}")
# =================================================================
# 6. PROBAR CON FRASES NUEVAS
# =================================================================
modelo.eval()
for frase in ["este producto es fantastico lo recomiendo",
"horrible no funciona dinero tirado",
"no me gusta nada la calidad es pesima"]:
idx, lon = codificar(frase)
with torch.no_grad():
p = torch.sigmoid(modelo(torch.tensor([idx]), torch.tensor([lon])))
print(f"{frase!r:55s} → P(positiva) = {p.item():.2f}")
Resultado típico: accuracy de test entre 0.83 y 1.00 según la semilla (con 12 ejemplos de test, ¡cada acierto son 8.3 puntos!). Las frases de prueba salen bien clasificadas, incluida la negación ("no me gusta nada..."), porque la LSTM lee en orden.
6.1 La comparación honesta: baseline TF-IDF + regresión logística¶
En el módulo 02 habrías resuelto esto así:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
textos = [t for t, _ in datos]
etiquetas = [e for _, e in datos]
pipe = make_pipeline(TfidfVectorizer(), LogisticRegression(max_iter=1000))
scores = cross_val_score(pipe, textos, etiquetas, cv=5)
print(f"TF-IDF + LogReg: {scores.mean():.2f} ± {scores.std():.2f}")
# Típico: 0.93 ± 0.06 — igual o MEJOR que la LSTM, entrenando en 0.1 segundos
| Aspecto | LSTM + embeddings | TF-IDF + LogReg |
|---|---|---|
| Accuracy en ESTE dataset (60 ej.) | ~0.85-1.00 (alta varianza) | ~0.90-0.95 (estable) |
| Tiempo de entrenamiento | segundos y 100+ épocas | milisegundos |
| Parámetros | ~35 000 | ~230 |
| ¿Entiende el orden/negación? | Sí | No (pero los bigramas ayudan) |
| ¿Cuándo gana claramente? | Datasets grandes (10k+), matices de orden, transferencia | Datasets pequeños, prototipos, baseline |
Nota (lección honesta)
Con 60 ejemplos, el deep learning no tiene ventaja — de hecho, su mayor capacidad es un riesgo de sobreajuste, y la varianza entre semillas es enorme. La lección es de las más valiosas del máster: empieza SIEMPRE por el baseline simple. Si TF-IDF + logística te da 0.93, cualquier modelo complejo debe justificar su coste superando ese número en validación. El deep learning para texto despega cuando hay muchos datos... o cuando puedes usar un modelo preentrenado en montañas de texto ajeno — que es exactamente la revolución del capítulo 6 y del módulo 04.
Ejercicio rápido 6¶
En el modelo anterior, ¿por qué usamos h_n[-1] (estado final) para clasificar en vez de la media de todos los estados salida.mean(dim=1)? ¿Sería incorrecta la media?
Ver solución
No sería incorrecta — es una alternativa legítima (mean pooling) y a veces funciona mejor. La diferencia: - **Estado final `h_n[-1]`**: apuesta a que la LSTM ha acumulado toda la frase en su último estado. Riesgo: en frases largas, lo del principio puede haberse diluido (¡las enfermedades de la sección 4.6, aunque mitigadas!). - **Media de estados**: da igual peso a cada posición; es robusta pero diluye — un "no" crucial pesa lo mismo que un artículo. OJO: con padding hay que hacer la media SOLO sobre posiciones reales (usando la máscara), o estarás promediando estados de relleno. Un truco mejor que ambos: **max pooling sobre el tiempo**, o... dejar que el modelo aprenda *cuánto pesa cada posición*. Eso último tiene nombre: **atención**. Sigue leyendo — la sección 7 te está esperando.7. Seq2seq y el cuello de botella: nace la atención¶
7.1 Encoder-decoder: traducir es comprimir y descomprimir¶
En 2014, Sutskever et al. (Google) propusieron seq2seq para traducción automática: dos RNNs (en la práctica LSTMs) acopladas.
- El encoder lee la frase fuente token a token y produce un estado final: un único vector $z$ que "resume" toda la frase.
- El decoder parte de ese vector y genera la traducción token a token, alimentándose de sus propias predicciones.
flowchart LR
subgraph ENC["ENCODER (lee español)"]
e1["'el'"] --> E1["LSTM"]
e2["'gato'"] --> E2["LSTM"]
e3["'duerme'"] --> E3["LSTM"]
E1 --> E2 --> E3
end
E3 -- "z: UN SOLO VECTOR<br/>(el cuello de botella )"--> D1
subgraph DEC["DECODER (escribe inglés)"]
D1["LSTM"] --> D2["LSTM"] --> D3["LSTM"] --> D4["LSTM"]
D1 --> s1["'the'"]
D2 --> s2["'cat'"]
D3 --> s3["'sleeps'"]
D4 --> s4["'<EOS>'"]
end
Funcionó sorprendentemente bien para frases cortas y fue un hito histórico. Pero mira otra vez el diagrama y encuentra el defecto de diseño...
7.2 El problema del vector único¶
Toda la información de la frase fuente debe pasar por $z$: un vector de dimensión fija (digamos 512 números). Da igual que la frase tenga 4 palabras o 400: mismo embudo.
Es como pedirle a alguien que lea "Guerra y Paz" entera, la resuma en una sola frase, y le pases esa frase a un traductor que debe reconstruir la novela completa en otro idioma sin volver a mirar el original. Para un tuit, vale. Para la novela, imposible: la compresión destruye información.
Los síntomas empíricos eran claros y medibles: la calidad de traducción (BLEU) de los seq2seq se desplomaba con la longitud de la frase. A partir de ~30 palabras, el decoder "alucinaba": empezaba bien y degeneraba, porque el principio de la frase fuente ya no estaba en $z$ con suficiente fidelidad.
7.3 El nacimiento de la atención (Bahdanau, Cho y Bengio, 2014)¶
La solución de Bahdanau es de esas ideas que parecen obvias después: ¿por qué obligar al decoder a trabajar de memoria? Déjale mirar el original mientras traduce.
Concretamente:
- El encoder ya no entrega solo su estado final: conserva TODOS sus estados ocultos $h_1, h_2, \dots, h_T$ (uno por palabra fuente — cada uno es una representación de esa palabra en su contexto).
- En cada paso de generación, el decoder (con estado $s_t$) calcula una puntuación de relevancia entre su estado y cada estado del encoder: $e_{t,i} = \text{score}(s_t, h_i)$ — una pequeña red aprende a puntuarla.
- Las puntuaciones pasan por un softmax → pesos de atención $\alpha_{t,i}$ que suman 1: "¿a qué palabras del original debo mirar AHORA?".
- Se construye el vector de contexto como media ponderada: $c_t = \sum_i \alpha_{t,i} \, h_i$ — un resumen del original hecho a medida para este paso concreto.
- El decoder genera su siguiente palabra usando $s_t$ y $c_t$.
$$\alpha_{t,i} = \frac{\exp(e_{t,i})}{\sum_j \exp(e_{t,j})} \qquad c_t = \sum_{i=1}^{T} \alpha_{t,i} \, h_i$$
Ejemplo concreto. Traducimos "el gato negro duerme" → "the black cat sleeps". Observa que "gato negro" invierte su orden en inglés ("black cat"). La matriz de pesos de atención $\alpha$ que aprende el modelo (filas = palabra generada, columnas = palabra fuente mirada):
FRASE FUENTE (encoder)
el gato negro duerme
┌────────┬────────┬────────┬────────┐
g "the" │ 0.87 │ 0.06 │ 0.04 │ 0.03 │ ← mira a "el"
e ├────────┼────────┼────────┼────────┤
n "black" │ 0.02 │ 0.09 │ 0.85 │ 0.04 │ ← ¡mira a "negro"!
e ├────────┼────────┼────────┼────────┤
r "cat" │ 0.03 │ 0.88 │ 0.06 │ 0.03 │ ← ¡vuelve atrás, a "gato"!
a ├────────┼────────┼────────┼────────┤
d "sleeps" │ 0.02 │ 0.05 │ 0.04 │ 0.89 │ ← mira a "duerme"
o └────────┴────────┴────────┴────────┘
cada fila suma 1.00 (softmax)
Fíjate en las filas 2 y 3: para generar "black" el decoder atiende a "negro" (posición 3 de la fuente) y para "cat" retrocede a "gato" (posición 2). La atención aprendió el reordenamiento sintáctico español→inglés sin que nadie se lo programara — emergió del entrenamiento. Estas matrices se pueden visualizar en modelos reales y producen mapas de alineamiento palabra a palabra asombrosamente interpretables.
Resultados: la degradación con frases largas desapareció casi por completo. La atención se convirtió en el accesorio imprescindible de todo seq2seq entre 2015 y 2017.
7.4 La pregunta del millón¶
Recapitulemos la arquitectura de 2015: una RNN encoder (lenta, secuencial), una RNN decoder (lenta, secuencial)... y un mecanismo de atención que, en cada paso, conecta directamente con cualquier posición de la fuente, sin importar la distancia, y encima es un montón de productos escalares y softmax — pura multiplicación de matrices, paralelizable al máximo.
La parte que funciona de maravilla es la atención. La parte lenta y con problemas de memoria es la recurrencia. Entonces...
¿Y si la atención es tan buena... para qué necesitamos la recurrencia?
Esa pregunta, formulada en serio por un equipo de Google en 2017, tiene por respuesta un artículo cuyo título ya lo dice todo: "Attention Is All You Need". Su arquitectura — el Transformer — eliminó la recurrencia por completo y se quedó solo con atención (aplicada de la secuencia hacia sí misma: self-attention). Es el tema del capítulo 6, y la base de todos los LLMs que usarás el resto del máster.
Ejercicio rápido 7¶
En la matriz de atención del ejemplo, ¿qué esperarías en la fila de "sleeps" si la frase fuente fuera "el gato negro no duerme"? ¿Y si el modelo tradujera al alemán, donde el verbo puede ir al final ("...schläft nicht")?
Ver solución
- Con "no duerme" → "doesn't sleep": al generar "doesn't", la atención debería repartirse entre "no" y "duerme" (en inglés la negación se fusiona con el auxiliar, que depende del verbo). Al generar "sleep", el pico estaría en "duerme". Las palabras funcionales suelen producir atenciones más repartidas que las de contenido. - En alemán, generar "schläft" al FINAL de la frase requeriría atender a "duerme", que está en una posición muy anterior de la fuente. Para la atención, la distancia es irrelevante: el peso $\alpha_{t,i}$ conecta cualquier par de posiciones con el mismo coste. Esa es exactamente la ventaja estructural sobre la RNN, donde la información de "duerme" tendría que sobrevivir muchos pasos de recurrencia. Los idiomas con reordenamientos largos (alemán, japonés) fueron donde la atención más mejoró la traducción.8. Por qué las RNN perdieron la guerra¶
Tres razones fundamentales, por orden de importancia:
8.1 No son paralelizables (las GPUs se aburren)¶
La recurrencia es secuencial por definición: para calcular $h_{100}$ necesitas $h_{99}$, que necesita $h_{98}$... No hay forma de calcular los 100 pasos a la vez: es una cadena de dependencias. Mientras tanto, una GPU moderna tiene decenas de miles de núcleos deseando multiplicar matrices gigantes en paralelo... y la RNN les da el trabajo en gotitas, paso a paso.
RNN (secuencial): paso1 → paso2 → paso3 → ... → paso512 ⏱ O(T) pasos dependientes
la GPU usa una fracción mínima de su capacidad
Transformer: TODAS las posiciones a la vez en una multiplicación
de matrices gigante ⏱ O(1) pasos dependientes
la GPU trabaja a plena capacidad
Esto no es una molestia menor: es LA razón económica. Entrenar sobre billones de tokens solo es viable si cada paso de entrenamiento exprime el hardware. El Transformer ganó, ante todo, porque escala con el hardware disponible. Los LLMs existen porque el preentrenamiento masivo se volvió computacionalmente asequible.
8.2 Memoria limitada en la práctica¶
La LSTM mitiga el vanishing gradient, pero no lo cura: en la práctica, mantener información útil más allá de unos cientos de pasos es difícil. Todo debe caber en un estado de tamaño fijo que se actualiza secuencialmente — el cuello de botella nunca desapareció del todo, solo se ensanchó. La atención, en cambio, ofrece acceso directo O(1) en "saltos" a cualquier posición: nada tiene que "sobrevivir" viajando por la recurrencia.
8.3 La tabla comparativa (anticipo del capítulo 6)¶
| Criterio | RNN / LSTM | Transformer |
|---|---|---|
| Procesamiento de la secuencia | Secuencial, paso a paso | Todas las posiciones en paralelo |
| Camino de información entre posiciones distantes ($i$ ↔ $j$) | $O(\lvert i-j \rvert)$ pasos de recurrencia | $O(1)$: un salto de atención |
| Paralelización en entrenamiento | Mínima en la dimensión temporal | Total |
| Coste computacional por capa | $O(T \cdot H^2)$ — lineal en longitud | $O(T^2 \cdot d)$ — cuadrático en longitud |
| Memoria en inferencia streaming | Estado O(1): ideal para edge | Cache que crece con el contexto (KV-cache) |
| Dependencias largas | Difíciles (memoria comprimida) | Naturales (acceso directo) |
| Sesgo inductivo de orden | Incorporado (procesa en orden) | Hay que añadirlo (codificación posicional, cap. 6) |
| Escalado con datos masivos | Se estanca | Sigue mejorando (leyes de escala) |
| Ganador en NLP 2018-2026 | — | Aplastante |
Advertencia
Observa la fila del coste cuadrático: la atención compara cada posición con todas las demás ($T^2$ pares). Con contextos de 128k tokens, eso es un problema serio — y es la razón de una década de investigación en atención eficiente, y de que los SSM/Mamba (recurrencias modernas paralelizables) hayan resucitado el interés por las ideas de este capítulo. En ingeniería nada es gratis: el Transformer compró paralelismo y memoria perfecta pagando coste cuadrático. Entender este trade-off te hará mejor ingeniero que memorizar "Transformer bueno, RNN mala".
9. Caso empresarial: predicción de demanda energética con LSTM¶
Caso empresarial
EnerNorte (nombre ficticio, caso representativo del sector), una comercializadora eléctrica española con ~400 000 clientes, necesita predecir la demanda eléctrica horaria agregada con 24 horas de antelación. ¿Por qué importa? Porque la energía se compra por adelantado en el mercado mayorista (OMIE): si compras de menos, pagas los desvíos a precio penalizado; si compras de más, malvendes el excedente. El equipo estimó que cada 1% de error medio de predicción costaba ~180 000 € al año en desvíos.
9.1 Los datos y las features¶
Serie histórica de 3 años de demanda horaria (26 280 puntos) más variables exógenas:
| Feature | Tipo | Por qué importa |
|---|---|---|
| Demanda de las últimas 168 h (7 días) | Ventana histórica | El patrón semanal es el rey: el lunes se parece al lunes pasado |
| Hora del día (codificada como sin/cos) | Cíclica | La demanda tiene doble pico diario (mediodía y noche); sin/cos evita el salto artificial 23h→0h |
| Día de la semana (sin/cos) | Cíclica | Laborables ≠ fines de semana |
| Festivo (0/1) y víspera de festivo (0/1) | Binaria | Un martes festivo se comporta como domingo |
| Temperatura prevista | Numérica | Calefacción y aire acondicionado dominan los extremos (relación en U: consume más con frío Y con calor) |
| Mes (sin/cos) | Cíclica | Estacionalidad anual |
9.2 Ventanas deslizantes: convertir una serie en un dataset supervisado¶
Una serie temporal no es directamente un dataset de (X, y). Hay que fabricar los ejemplos con una ventana deslizante: cada ejemplo usa las últimas VENTANA horas para predecir las siguientes HORIZONTE horas.
Serie: d₁ d₂ d₃ d₄ d₅ d₆ d₇ d₈ d₉ d₁₀ ...
Ejemplo 1: X = [d₁ ... d₁₆₈] → y = [d₁₆₉ ... d₁₉₂] (ventana desliza →)
Ejemplo 2: X = [d₂ ... d₁₆₉] → y = [d₁₇₀ ... d₁₉₃]
Ejemplo 3: X = [d₃ ... d₁₇₀] → y = [d₁₇₁ ... d₁₉₄]
import numpy as np
import torch
import torch.nn as nn
np.random.seed(1); torch.manual_seed(1)
# ----------------------------------------------------------------
# 1. SERIE SINTÉTICA pero realista: 2 años de demanda horaria (MWh)
# = base + ciclo diario + ciclo semanal + estacionalidad + ruido
# ----------------------------------------------------------------
horas = np.arange(24 * 365 * 2) # 17 520 horas
demanda = (
1000 # carga base (MWh)
+ 220 * np.sin(2*np.pi*horas/24 - np.pi/2) # ciclo diario (pico tarde)
+ 90 * np.sin(2*np.pi*horas/(24*7)) # ciclo semanal
+ 160 * np.sin(2*np.pi*horas/(24*365)) # estacionalidad anual
+ np.random.randn(len(horas)) * 35 # ruido de mercado
)
# ----------------------------------------------------------------
# 2. FEATURES CÍCLICAS (así el modelo "sabe" qué hora/día es)
# ----------------------------------------------------------------
hora_sin = np.sin(2*np.pi*(horas % 24)/24) # hora del día como seno
hora_cos = np.cos(2*np.pi*(horas % 24)/24) # ...y coseno (juntas: reloj)
dia_sin = np.sin(2*np.pi*(horas % (24*7))/(24*7)) # posición en la semana
dia_cos = np.cos(2*np.pi*(horas % (24*7))/(24*7))
serie = np.stack([demanda, hora_sin, hora_cos, dia_sin, dia_cos], axis=1)
# serie: (17520, 5) — demanda + 4 features de calendario por hora
# ----------------------------------------------------------------
# 3. NORMALIZAR la demanda (⚠ estadísticos SOLO del tramo de train)
# ----------------------------------------------------------------
n_train = int(len(serie) * 0.8) # 80% inicial = train
mu, sigma = serie[:n_train, 0].mean(), serie[:n_train, 0].std()
serie[:, 0] = (serie[:, 0] - mu) / sigma # z-score de la demanda
# ----------------------------------------------------------------
# 4. VENTANAS DESLIZANTES: (168h de historia) → (24h siguientes)
# ----------------------------------------------------------------
VENTANA, HORIZONTE = 168, 24
def crear_ventanas(datos, ventana, horizonte):
X, y = [], []
# la ventana se desliza posición a posición por toda la serie
for i in range(len(datos) - ventana - horizonte + 1):
X.append(datos[i : i+ventana]) # entrada: 168 h × 5 feat
y.append(datos[i+ventana : i+ventana+horizonte, 0]) # objetivo: solo
# la DEMANDA de las 24 h sig.
return (torch.tensor(np.array(X), dtype=torch.float32),
torch.tensor(np.array(y), dtype=torch.float32))
# ⚠ SPLIT TEMPORAL, nunca aleatorio: el test es EL FUTURO.
# Un split aleatorio filtraría información del futuro al pasado (leakage)
# y daría métricas de fantasía que se derrumban en producción.
X_tr, y_tr = crear_ventanas(serie[:n_train], VENTANA, HORIZONTE)
X_te, y_te = crear_ventanas(serie[n_train:], VENTANA, HORIZONTE)
print(X_tr.shape, y_tr.shape) # (13849, 168, 5) (13849, 24)
# ----------------------------------------------------------------
# 5. MODELO: LSTM que resume 168 h y proyecta 24 valores de golpe
# ----------------------------------------------------------------
class PredictorDemanda(nn.Module):
def __init__(self, n_feats=5, hidden=128, horizonte=24):
super().__init__()
self.lstm = nn.LSTM(n_feats, hidden, num_layers=2,
batch_first=True, dropout=0.2) # ⚠ batch_first
self.fc = nn.Linear(hidden, horizonte) # estado final → 24 predicciones
def forward(self, x):
_, (h_n, _) = self.lstm(x) # h_n: (2 capas, B, hidden)
return self.fc(h_n[-1]) # última capa → (B, 24)
modelo = PredictorDemanda()
opt = torch.optim.Adam(modelo.parameters(), lr=1e-3)
# ----------------------------------------------------------------
# 6. ENTRENAMIENTO con mini-batches
# ----------------------------------------------------------------
loader = torch.utils.data.DataLoader(
torch.utils.data.TensorDataset(X_tr, y_tr), batch_size=256, shuffle=True)
for epoca in range(8): # 8 épocas bastan aquí
modelo.train(); total = 0.0
for xb, yb in loader:
pred = modelo(xb) # (B, 24)
loss = nn.functional.mse_loss(pred, yb) # error cuadrático medio
opt.zero_grad(); loss.backward()
torch.nn.utils.clip_grad_norm_(modelo.parameters(), 5.0)
opt.step()
total += loss.item() * len(xb)
print(f"época {epoca} | MSE train {total/len(X_tr):.4f}")
# ----------------------------------------------------------------
# 7. EVALUACIÓN en unidades reales (MWh) con MAPE
# ----------------------------------------------------------------
modelo.eval()
with torch.no_grad():
pred = modelo(X_te)
pred_mwh = pred.numpy() * sigma + mu # des-normalizar
real_mwh = y_te.numpy() * sigma + mu
mape = np.mean(np.abs((pred_mwh - real_mwh) / real_mwh)) * 100
print(f"MAPE test: {mape:.2f}%") # típico en este sintético: 2-3%
9.3 Resultados y decisiones de negocio¶
| Modelo evaluado por el equipo | MAPE (datos reales) | Latencia | Comentario del equipo |
|---|---|---|---|
| Persistencia ("mañana = hoy hace una semana") | 6.1% | 0 ms | El baseline que TODO forecast debe batir |
| SARIMA | 4.4% | ms | Sólido, pero rígido con festivos y temperatura |
| Gradient boosting con lags | 3.2% | ms | Durísimo de batir; features manuales |
| LSTM 2 capas (elegido) | 2.7% | ~5 ms | Mejor en picos y transiciones festivo/laborable |
| Transformer temporal (probado) | 2.6% | ~20 ms | Mejora marginal, más coste; descartado |
¿Por qué NO usaron un Transformer? La pregunta que el CTO hizo al equipo, y la respuesta que te llevas de este capítulo:
- Datos "pequeños": 3 años de una serie horaria son ~26k puntos. Los Transformers brillan con datos masivos; aquí no hay volumen para amortizar su capacidad, y el riesgo de sobreajuste crece. La mejora medida (2.7% → 2.6%) no era estadísticamente sólida entre re-entrenamientos.
- Latencia y coste: el modelo se re-entrena cada noche y predice cada hora en una máquina modesta sin GPU. La LSTM es pequeña, estable y barata.
- Simplicidad operativa: menos hiperparámetros delicados, equipo ya familiarizado, monitorización sencilla. En producción, el mejor modelo es el que puedes mantener.
Resultado de negocio: pasar del 4.4% (SARIMA) al 2.7% de MAPE redujo los costes de desvío en ~300 000 €/año. El modelo lleva dos años en producción re-entrenándose cada noche.
Consejo profesional
Fíjate en que el gradient boosting quedó a 0.5 puntos de la LSTM con una fracción del esfuerzo. En series temporales tabulares de tamaño pequeño/medio, XGBoost/LightGBM con buenas features de calendario y lags sigue siendo en 2026 el primer modelo serio a probar, tal y como aprendiste en el módulo 02. El deep learning se justifica cuando hay muchas series relacionadas (miles de clientes/productos), patrones complejos multivariados, o datos masivos.
Ejercicio rápido 8¶
¿Por qué el split train/test de la sección 9.2 es temporal (primer 80% / último 20%) en vez de aleatorio? Da un ejemplo concreto de qué saldría mal con split aleatorio.
Ver solución
Porque en forecasting el test debe simular **el futuro**, que es donde el modelo trabajará. Con split aleatorio, ejemplos de test quedarían rodeados (temporalmente) de ejemplos de train: la ventana de un ejemplo de train podría contener horas que son el objetivo de un ejemplo de test — **fuga de información (leakage) directa**. Ejemplo concreto: el ejemplo de test que predice las 24 h del 15 de marzo tendría "vecinos" de train cuya ventana de entrada incluye... el propio 15 de marzo. El modelo obtendría un MAPE de fantasía (p. ej. 1%) que en producción se convertiría en 4-6%. Es el error nº 1 en proyectos de series temporales, y una de las causas más comunes de "el modelo funcionaba en la demo y falló en producción".10. Buenas prácticas¶
- Empieza siempre por un baseline no-neuronal. TF-IDF + logística para texto; persistencia y gradient boosting para series. Si tu red no lo bate en validación, la red sobra (por ahora).
batch_first=Trueexplícito y verificado. Y escribe las shapes esperadas como comentario al lado de cada línea con tensores. Los bugs de shapes en RNNs no dan error: dan modelos que aprenden basura en silencio.- Gradient clipping SIEMPRE en redes recurrentes.
clip_grad_norm_(model.parameters(), 1.0–5.0). Cuesta una línea y evita el 90% de losNaN. padding_idx=0ennn.Embedding+ máscara/pack_padded_sequenceen la RNN. El padding debe ser invisible para el modelo y para la pérdida.- Split temporal en series temporales, sin excepciones. Y normaliza con estadísticos calculados SOLO en el tramo de entrenamiento.
- Features cíclicas con sin/cos para hora, día de semana y mes. Un entero 0-23 le dice al modelo que las 23h y las 0h están "lejísimos"; el par sin/cos le dice la verdad.
- Visualiza tus embeddings con PCA tras entrenar: es un chequeo cualitativo barato de que el modelo aprendió algo con sentido.
- Muestrea (no hagas argmax) al generar texto y controla la diversidad; guarda la semilla para reproducir.
- Cronometra entrenamiento por época desde el primer día. Si tu RNN es lenta, plantéate truncar secuencias, reducir hidden size... o si esta tarea ya la resuelve mejor un Transformer preentrenado (capítulo 6).
- Documenta tu tokenización y congélala con el modelo. Un modelo servido con un tokenizador distinto al de entrenamiento produce disparates difíciles de diagnosticar.
11. Malas prácticas¶
- Saltar directamente a la red neuronal sin baseline, sin mirar los datos y sin métricas de referencia. La sección 6 demostró que con 60 ejemplos pierdes.
- Ignorar
<UNK>en inferencia: si tu función de codificación lanzaKeyErrorcon palabras nuevas, tu modelo se caerá el primer día en producción. - Split aleatorio en series temporales. Leakage garantizado, métricas de fantasía, fracaso en producción (ejercicio 8).
- Entrenar embeddings desde cero con 500 ejemplos y esperar geometría semántica. La semántica emerge de millones de contextos; con pocos datos, usa embeddings preentrenados o modelos preentrenados.
- Usar el estado
h_nsin empaquetar cuando hay padding: obtienes el estado tras procesar los<PAD>, no tras la última palabra real. - Secuencias kilométricas en una RNN "porque cabe en memoria": más allá de unos cientos de pasos, la RNN no aprovecha el contexto y el entrenamiento se eterniza. Trunca o cambia de arquitectura.
- Tratar los pesos de atención como explicación causal definitiva: son una pista de interpretabilidad valiosa, no una prueba judicial de "por qué" el modelo decidió.
- Copiar hiperparámetros de un paper de 2016 sin cuestionarlos (hidden 1000, dropout 0.5, SGD...) para tu dataset de 5 000 ejemplos.
12. Errores comunes¶
| Error | Síntoma | Causa | Solución |
|---|---|---|---|
Olvidar batch_first=True |
Entrena "algo" pero rendimiento absurdo; shapes no cuadran con tu intuición | Por defecto nn.LSTM espera (seq, batch, feat) |
Ponerlo explícito; comentar shapes; test unitario de shapes |
IndexError / crash CUDA en nn.Embedding |
index out of range in self |
Índice ≥ num_embeddings (vocab desincronizado) |
num_embeddings=len(vocab); validar X.max() |
Loss → NaN de repente |
Todo iba bien y explota | Exploding gradients (sección 4.6) | clip_grad_norm_; bajar lr |
| Loss se estanca alta desde el inicio | La red no aprende dependencias largas | Vanishing gradients en RNN vanilla | LSTM/GRU; secuencias más cortas; revisar inicialización |
| Métricas de test espectaculares que fallan en producción | 1% de error en test, 5% en real | Leakage temporal por split aleatorio o normalización con todo el dataset | Split temporal; estadísticos solo de train |
| El modelo "aprende" del padding | Rendimiento mediocre inexplicable; predicciones sesgadas por longitud | Sin máscara ni pack_padded_sequence; sin padding_idx |
Enmascarar en RNN y en pérdida |
| Frases nuevas rompen la inferencia | KeyError: 'palabra' |
Vocabulario sin <UNK> |
vocab.get(token, UNK_IDX) siempre |
| Generación de texto repetitiva ("la la la...") | Bucles degenerados al generar | Decodificación con argmax puro | Muestreo con temperatura; top-k (módulo 04) |
Confundir salida con h_n en nn.LSTM |
Usas el tensor equivocado para clasificar | salida = todos los pasos de la última capa; h_n = último paso de cada capa |
Revisar la sección 5.4; dibujar las shapes |
| Embedding de dimensión gigante "por si acaso" | Sobreajuste y lentitud con vocabulario pequeño | dim 1024 para 2 000 palabras | Regla práctica: 50-300 para modelos propios pequeños |
13. FAQ — Preguntas frecuentes¶
1. ¿Todavía merece la pena aprender RNNs en 2026, o debería saltar directo a Transformers? Merece, por tres razones. (a) Conceptual: sin el cuello de botella seq2seq no entiendes por qué existe la atención — y la atención ES el Transformer. (b) Práctica: LSTM/GRU siguen en producción en series temporales, edge y streaming (tabla 5.5). (c) De futuro: las arquitecturas recurrentes modernas (SSM/Mamba) están recuperando terreno; quien entiende recurrencia entiende hacia dónde va esa línea. Además, medio módulo (embeddings, tokenización, padding, máscaras) es exactamente igual en Transformers.
2. ¿Qué dimensión de embedding elijo? Para modelos propios con vocabularios de miles de palabras: 50-300. word2vec clásico usaba 300. Los LLMs usan 4096-16384, pero tienen vocabularios de 100k+ tokens y billones de palabras de entrenamiento. Regla práctica honesta: es un hiperparámetro poco sensible dentro de un rango razonable; prueba 64/128/256 y elige por validación.
3. ¿Por qué los LLMs "no saben" contar las letras de una palabra o hacer rimas raras? Por la tokenización BPE (sección 2.2): el modelo no ve caracteres, ve tokens. "fresa" puede ser UN token opaco — el modelo nunca ve la "f", la "r"... Cuando algo falla de forma extraña con deletreo, conteo de caracteres o palíndromos, sospecha del tokenizador. Lo verás más a fondo en el módulo 04.
4. ¿LSTM o GRU? Empate técnico en la mayoría de tareas. GRU: menos parámetros, más rápida — buena por defecto en edge/series pequeñas. LSTM: ligera ventaja empírica en secuencias muy largas y datasets grandes. Lo honesto: probar ambas cuesta poco y la diferencia rara vez cambia una decisión de negocio.
5. ¿Puedo usar embeddings preentrenados (word2vec/GloVe/fastText) en mi nn.Embedding?
Sí: copia los vectores a emb.weight.data para las palabras de tu vocabulario y decide si congelas (requires_grad=False) o afinas. Con datasets pequeños suele ayudar mucho. Aunque en 2026, para texto, la opción dominante es directamente un Transformer preentrenado (módulo 04) — los embeddings estáticos quedan para sistemas ligeros o como herramienta didáctica.
6. ¿La atención de Bahdanau es la misma que la de los Transformers? La misma idea (puntuar relevancia + softmax + media ponderada), con dos diferencias: (a) Bahdanau usa una red pequeña aditiva para puntuar; el Transformer usa producto escalar escalado (más barato y paralelizable); (b) en Bahdanau, el decoder atiende al encoder (cross-attention); el Transformer añade self-attention — la secuencia se atiende a sí misma, eliminando la recurrencia. El capítulo 6 desarrolla ambas.
7. ¿Por qué mi RNN char-level genera nombres inventados que "suenan bien"? Porque no memoriza nombres: aprende la distribución $P(\text{siguiente} \mid \text{anterior(es)})$ — la fonotáctica: qué letras suelen seguir a cuáles en español. Al muestrear, recombina esos patrones en secuencias nuevas y plausibles. Exactamente el mismo motivo por el que un LLM escribe frases que nunca vio: modela la distribución del lenguaje, no una base de datos de frases.
8. ¿Cómo manejo secuencias larguísimas (documentos de 50 páginas) con una LSTM? Opciones clásicas: truncar BPTT a ventanas (arrastrando estado entre ventanas), jerarquías (RNN de frases sobre RNN de palabras), o resumir por tramos. Pero seamos francos: en 2026, para documentos largos, la respuesta práctica es un Transformer con contexto largo o un pipeline RAG (módulo 06). La LSTM no es la herramienta para eso.
9. ¿El estado oculto de la LSTM es interpretable? Parcialmente y con suerte. Hay trabajos famosos (Karpathy, 2015) que encontraron neuronas individuales detectando cosas concretas: una que se activaba dentro de comillas, otra que contaba la longitud de línea, otra sensible a paréntesis. Pero en general el estado es una representación distribuida y opaca. Herramientas: probes lineales, visualizar activaciones sobre texto coloreado.
10. ¿Para series temporales siempre gana el deep learning al ARIMA/boosting? No, y quien te diga lo contrario vende algo. Con una sola serie corta y patrones estacionales limpios, SARIMA/ETS o boosting con buenas features suelen empatar o ganar (tabla 9.3), con mucho menos coste. El deep learning gana con: muchas series relacionadas (aprendizaje cruzado), relaciones no lineales complejas con exógenas, y volumen. Evalúa siempre contra el baseline de persistencia — es humillante lo difícil que es batirlo algunos días.
14. Resumen del capítulo¶
- Los datos secuenciales exigen arquitecturas que respeten el orden, acepten longitud variable y capturen dependencias a distancia. Ni el MLP ni la CNN lo hacen de forma natural.
- El texto llega a la red como tokens → índices → vectores. La tokenización BPE (fusiones iterativas de pares frecuentes) es la que usan los LLMs: palabras frecuentes = un token, palabras raras = piezas. Padding + máscara hacen rectangulares los batches sin contaminar el aprendizaje.
- Un embedding es una tabla de vectores entrenable (
nn.Embedding= lookup de filas). word2vec demostró que entrenar con la hipótesis distribucional produce geometría semántica (analogías como rey−hombre+mujer≈reina), pero asigna un solo vector por palabra — la polisemia ("banco") exige embeddings contextuales, que son justo lo que producirá el Transformer. - La RNN procesa la secuencia paso a paso actualizando un estado oculto (leer tomando notas), con los mismos pesos en cada paso. Se entrena con BPTT y sufre vanishing/exploding gradients: en secuencias largas, olvida.
- La LSTM cura (en parte) el olvido con una memoria de actualización aditiva (la cinta transportadora $c_t$) controlada por puertas (olvido/entrada/salida) — la misma jugada anti-vanishing que las skip connections de ResNet. La GRU la simplifica. En 2026 siguen vivas en series temporales, edge y streaming.
- Seq2seq (encoder-decoder) tradujo comprimiendo la frase en un solo vector: el cuello de botella que degradaba las frases largas. La atención de Bahdanau lo rompió dejando al decoder mirar todas las posiciones del encoder con pesos softmax — y las matrices de atención aprenden alineamientos interpretables.
- Las RNN perdieron por no ser paralelizables (la GPU se aburre), por su memoria limitada en la práctica y porque la atención sola escala mejor. La pregunta "¿para qué la recurrencia si tenemos atención?" es el título del siguiente capítulo: "Attention Is All You Need".
- En la práctica empresarial, una LSTM con ventanas deslizantes y features de calendario sigue ganando concursos internos de forecasting frente a Transformers cuando los datos son pequeños y la latencia importa — y el gradient boosting nunca queda lejos.
15. Bibliografía y lecturas recomendadas¶
- Mikolov, T., Chen, K., Corrado, G., & Dean, J. (2013). Efficient Estimation of Word Representations in Vector Space. arXiv:1301.3781 — el paper de word2vec: skip-gram, CBOW y las famosas analogías vectoriales.
- Mikolov, T., Sutskever, I., Chen, K., Corrado, G., & Dean, J. (2013). Distributed Representations of Words and Phrases and their Compositionality. NeurIPS 2013 — negative sampling y los trucos que hicieron word2vec entrenable a escala.
- Hochreiter, S., & Schmidhuber, J. (1997). Long Short-Term Memory. Neural Computation, 9(8), 1735-1780 — el paper original de la LSTM, adelantado dos décadas a su tiempo.
- Cho, K., van Merriënboer, B., Gulcehre, C., et al. (2014). Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation. EMNLP 2014 — encoder-decoder y la GRU.
- Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. NeurIPS 2014 — el seq2seq con LSTMs que abrió la traducción neuronal.
- Bahdanau, D., Cho, K., & Bengio, Y. (2014). Neural Machine Translation by Jointly Learning to Align and Translate. ICLR 2015 (arXiv:1409.0473) — el nacimiento de la atención. Si solo lees un paper de esta lista, que sea este: es la bisagra entre este capítulo y el siguiente.
- Karpathy, A. (2015). The Unreasonable Effectiveness of Recurrent Neural Networks. Blog post (karpathy.github.io) — el ensayo clásico sobre RNNs char-level generando Shakespeare, LaTeX y código C. Nuestra sección 4.4 es hija directa de este texto. Lectura obligatoria y deliciosa.
- Olah, C. (2015). Understanding LSTM Networks. Blog post (colah.github.io) — la explicación visual de las puertas LSTM más citada de la historia; complemento perfecto de la sección 5.
- Sennrich, R., Haddow, B., & Birch, A. (2016). Neural Machine Translation of Rare Words with Subword Units. ACL 2016 — BPE aplicado a NLP, el tokenizador de la era LLM.
- Vaswani, A., et al. (2017). Attention Is All You Need. NeurIPS 2017 — lo leeremos a fondo en el capítulo 6. Tras este capítulo, su título ya te suena a conclusión inevitable.
| Anterior | Módulo | Siguiente |
|---|---|---|
| Capítulo 4: Visión por computador: CNNs | 03-DEEP-LEARNING | Capítulo 6: Transformers |