📖 Teoría

Fundamentos de RNN

Redes Neuronales Recurrentes: desde la necesidad de procesar datos secuenciales hasta las matemáticas del hidden state, backpropagation through time y los problemas fundamentales de vanishing y exploding gradients.

📊 Datos secuenciales: cuando el orden importa

La mayoría de modelos que hemos estudiado hasta ahora — perceptrones, MLPs, CNNs — asumen que los datos de entrada son independientes entre sí. Cada imagen se clasifica sin «recordar» las anteriores. Pero el mundo real está lleno de datos donde el orden temporal es fundamental:

  • Texto y lenguaje natural: «El gato se sentó en la alfombra» tiene sentido; «alfombra la en sentó se gato el» no. Cada palabra depende de las anteriores.
  • Series temporales financieras: El precio de una acción hoy depende de su trayectoria pasada, no es un punto aislado.
  • Audio y habla: Un fonema solo tiene significado en contexto con los fonemas vecinos.
  • Señales biológicas: Electrocardiogramas (ECG), electroencefalogramas (EEG), datos de sensores biomédicos.
  • Vídeo: Cada frame tiene relación con los anteriores y posteriores.
  • Trayectorias y movimiento: GPS, movimiento de robots, tracking de objetos.
💡

Idea clave: En datos secuenciales, la información no está solo en los valores individuales, sino en el patrón temporal — cómo los valores cambian a lo largo del tiempo. Necesitamos modelos que puedan «recordar» y usar información pasada.

TIPOS DE DATOS SECUENCIALES Series temporales t₁ t₂ t₃ ... tₙ Texto / NLP El → gato → se sentó → en → la alfombra → . Audio / Voz Señales bio Propiedad común El significado de cada elemento depende de su contexto temporal x(t) no es independiente de x(t-1), x(t-2), … Necesitamos modelos con MEMORIA
Todos estos datos comparten una propiedad: el orden importa y hay dependencias temporales.

¿Qué es una secuencia?

Formalmente, una secuencia es una colección ordenada de elementos \(x_1, x_2, \ldots, x_T\), donde \(T\) es la longitud de la secuencia. Cada elemento \(x_t\) puede ser:

  • Un escalar — por ejemplo, la temperatura en el instante \(t\).
  • Un vector — por ejemplo, un word embedding de dimensión 300.
  • Un tensor — por ejemplo, un frame de vídeo de dimensiones \(H \times W \times 3\).
Secuencia genérica $$\mathbf{X} = (x_1, x_2, \ldots, x_T), \quad x_t \in \mathbb{R}^d$$

Lo que distingue a los datos secuenciales de un simple conjunto de datos es que existe una dependencia estadística entre elementos vecinos: \(p(x_t | x_{t-1}, x_{t-2}, \ldots)\) no es igual a \(p(x_t)\).

Dependencias temporales: cortas y largas

No todas las dependencias son iguales. Algunas son de corto plazo y otras de largo plazo:

Dependencias de corto plazo
  • La siguiente palabra en «Buenos ___» → «días»
  • El siguiente valor en una serie temporal suave
  • Predicción del siguiente fonema en habla
Dependencias de largo plazo
  • Concordancia sujeto-verbo: «Los alumnos que vinieron ayer aprobaron»
  • Patrones estacionales en series temporales (ciclos anuales)
  • Contexto narrativo en textos largos

Como veremos, las RNN simples son buenas capturando dependencias de corto plazo, pero tienen dificultades serias con las de largo plazo — un problema fundamental que motivará arquitecturas más avanzadas como LSTM y GRU.

🌍 Ejemplos y aplicaciones

Antes de entrar en la arquitectura de las RNNs, veamos la diversidad de problemas que involucran datos secuenciales:

💹
Predicción financiera
Predecir precios de acciones, divisas o criptomonedas usando el historial de precios, volumen y otros indicadores.
Entrada: \((x_1, \ldots, x_T)\) → Salida: \(x_{T+1}\)
🔤
Traducción automática
Traducir una oración de un idioma a otro, procesando la secuencia de entrada y generando una secuencia de salida.
Entrada: seq → Salida: seq (seq-to-seq)
💬
Análisis de sentimiento
Clasificar la opinión de un texto (positivo/negativo/neutro) procesando la secuencia de palabras.
Entrada: seq → Salida: clase (many-to-one)
🎵
Generación de música
Generar secuencias de notas musicales, aprendiendo patrones rítmicos y melódicos de composiciones existentes.
Entrada: seed → Salida: seq (one-to-many)
🏥
Diagnóstico médico
Detectar arritmias cardíacas en señales ECG, o predecir crisis epilépticas en señales EEG.
Entrada: señal → Salida: clase/alarma
🗣️
Reconocimiento de voz
Transcribir habla a texto, procesando la secuencia temporal de la señal de audio.
Entrada: audio → Salida: texto

Taxonomía de problemas secuenciales

Los problemas con secuencias se clasifican según la relación entre la entrada y la salida:

TAXONOMÍA DE PROBLEMAS SECUENCIALES One-to-One x y MLP clásico Clasificación de imagen One-to-Many x Image captioning Generación de música Many-to-One y Análisis de sentimiento Clasificación de vídeo Many-to-Many (sincronizado) NER, POS tagging, etiquetado de frames Many-to-Many (seq2seq) ctx Traducción, resumen, chatbots Encoder–Decoder architecture
Los problemas secuenciales se clasifican según la relación entre las longitudes de entrada y salida.
📝

Nota: El paradigma one-to-one (arriba izquierda) es el caso estándar sin secuencia — un MLP o CNN clásico. Lo incluimos para completar la taxonomía y resaltar que las RNNs extienden las capacidades a los otros cuatro paradigmas.

🚫 Limitaciones del MLP para secuencias

¿Por qué no podemos simplemente usar un perceptrón multicapa (MLP) para procesar secuencias? Intentémoslo y veamos qué problemas aparecen:

Problema 1: Tamaño de entrada fijo

Un MLP necesita un vector de entrada de dimensión fija. Si queremos procesar una oración de 5 palabras, necesitamos una capa de entrada de tamaño \(5 \times d\) (donde \(d\) es la dimensión del embedding). Pero:

  • ¿Qué pasa con oraciones de 10, 50 o 200 palabras?
  • Tendríamos que hacer padding (rellenar con ceros) hasta la longitud máxima — desperdiciando cómputo
  • O truncar las secuencias largas — perdiendo información
MLP: entrada fija $$\text{MLP}: \mathbb{R}^{n_{\text{fijo}}} \to \mathbb{R}^m \qquad \text{(n es fijo y predefinido)}$$

Problema 2: Sin noción de orden

Para un MLP, las características de entrada son intercambiables. Si alimentamos «el gato come» como \([x_\text{el}, x_\text{gato}, x_\text{come}]\) o «come gato el» como \([x_\text{come}, x_\text{gato}, x_\text{el}]\), el MLP puede aprender a distinguirlos, pero no tiene un mecanismo innato para entender la posición.

⚠️

Simetría posicional: Un MLP trata la posición 1 y la posición 100 de una secuencia exactamente igual. No tiene la inductive bias de que los elementos cercanos en el tiempo están más relacionados.

Problema 3: Sin compartición de parámetros temporal

Imagina que entrenas un MLP para detectar el patrón «subida rápida» en una serie temporal. Si ese patrón aparece en la posición 5, el MLP necesita pesos diferentes a si aparece en la posición 50. No puede reutilizar lo aprendido en una posición temporal para aplicarlo en otra.

MLP vs RNN: COMPARTICIÓN DE PARÁMETROS MLP (sin compartir) x₁ x₂ x₃ W₁ ≠ W₂ ≠ W₃ (pesos diferentes) RNN (pesos compartidos) W W W x₁ x₂ x₃ W = W = W (mismos pesos en cada paso)
Un MLP necesita pesos distintos por posición; una RNN reutiliza los mismos pesos en cada paso temporal.

Problema 4: Sin memoria

El problema más fundamental es que un MLP no tiene memoria. Cada entrada se procesa de forma completamente independiente. No hay mecanismo para que la información de \(x_1\) influya en cómo se procesa \(x_5\).

Esto es como intentar entender una película viendo cada fotograma por separado, sin recordar los anteriores. Puedes describir lo que ves en cada instante, pero no puedes entender la trama.

🧪 Experimenta: ¿Puede un MLP capturar el orden?

Reorganiza las palabras de la oración. Observa cómo un MLP las trataría — el vector de entrada es la suma de todos los embeddings (bag of words), perdiendo el orden.

Resumen de limitaciones

Limitación Descripción ¿RNN lo resuelve?
Entrada fija El MLP necesita un vector de dimensión predefinida ✅ Procesa secuencias de cualquier longitud
Sin orden Las posiciones son intercambiables para el MLP ✅ Procesa paso a paso, respetando el orden
Sin compartir pesos Pesos diferentes por posición temporal ✅ Mismos pesos en cada paso temporal
Sin memoria Cada entrada se procesa de forma independiente ✅ El hidden state acumula información

La solución: Necesitamos una arquitectura que pueda procesar secuencias de longitud variable, que respete el orden temporal, que comparta parámetros entre pasos de tiempo, y que tenga memoria. Esa arquitectura es la Red Neuronal Recurrente (RNN).

🔁 La idea de la recurrencia

La clave para procesar secuencias es sorprendentemente simple: darle memoria a la red. En cada paso temporal, la red no solo recibe la entrada actual \(x_t\), sino también un resumen de todo lo que ha visto hasta el momento. A este resumen lo llamamos estado oculto (hidden state) \(h_t\).

🔑

Idea central de la recurrencia: En cada paso \(t\), la red produce una salida y también un nuevo hidden state que pasará al siguiente paso. La misma función (con los mismos pesos) se aplica en cada paso temporal.

De un bucle en código a una neurona recurrente

La idea de la recurrencia es natural en programación. Piensa en un bucle que acumula información:

# El concepto de recurrencia en pseudocódigo
estado = inicializar()  # h₀ = 0

for t in range(len(secuencia)):
    # En cada paso: la función f recibe el estado anterior Y la entrada actual
    estado = f(estado, secuencia[t])
    # El "estado" acumula información de TODOS los pasos anteriores

# Al final, "estado" contiene un resumen de toda la secuencia
salida = g(estado)

Esta es exactamente la idea de una RNN: una función \(f\) que se aplica repetidamente, actualizando un estado interno con cada nueva entrada. La diferencia es que \(f\) es una red neuronal con parámetros aprendibles.

La celda RNN: vista plegada

La forma más compacta de representar una RNN es la vista plegada (folded view). Un solo bloque con una flecha que «vuelve a sí mismo», indicando la recurrencia:

VISTA PLEGADA (folded) RNN hₜ xₜ yₜ recurrencia
Vista plegada: la flecha de recurrencia indica que el hidden state se retroalimenta paso a paso.

Vista desplegada (unrolled)

Si «desenrollamos» la recurrencia en el tiempo, obtenemos la vista desplegada (unrolled view). Aquí se ve claramente cómo la misma celda procesa cada paso temporal:

VISTA DESPLEGADA EN EL TIEMPO (unrolled) h₀ = 0 RNN h₀ x₁ y₁ RNN h₁ x₂ y₂ RNN h₂ x₃ y₃ ··· RNN h_{T-1} x_T y_T h_T ⬆ Todas las celdas comparten los MISMOS pesos W
Vista desplegada: la misma celda RNN se aplica en cada paso temporal. El hidden state h fluye de izquierda a derecha, acumulando información.
ℹ️

Vista plegada vs desplegada: Son dos formas de representar lo mismo. La vista plegada es compacta, la desplegada muestra explícitamente cómo fluye la información en el tiempo. Para entender el backpropagation through time, la vista desplegada es fundamental.

🧠 El estado oculto (hidden state)

El hidden state \(h_t \in \mathbb{R}^{d_h}\) es el corazón de la RNN. Es un vector que actúa como memoria comprimida de toda la secuencia procesada hasta el paso \(t\).

¿Qué captura el hidden state?

Imagina que la RNN procesa la frase «El gato negro duerme en el sofá». En cada paso, el hidden state acumula información:

t=1
«El» → \(h_1\) captura: artículo determinado, inicio de frase
t=2
«gato» → \(h_2\) captura: sustantivo, animal, sujeto probable
t=3
«negro» → \(h_3\) captura: hay un gato negro, adjetivo modifica al sustantivo
t=4
«duerme» → \(h_4\) captura: el gato negro realiza acción de dormir

Cada \(h_t\) es un vector de números reales. La red aprende a codificar la información relevante en estos vectores durante el entrenamiento.

Dimensión del hidden state

La dimensión \(d_h\) del hidden state es un hiperparámetro que controla la capacidad de la memoria:

Mayor \(d_h\) 📈

  • Más capacidad para representar información compleja
  • Puede capturar patrones más sofisticados
  • Necesario para tareas complejas

Menor \(d_h\) 📉

  • Menos parámetros → entrenamiento más rápido
  • Menos riesgo de overfitting
  • Suficiente para tareas simples

Valores típicos de \(d_h\): 32–64 para tareas simples, 128–256 para tareas medias, 512–1024 para modelos grandes.

Inicialización del hidden state

Antes de procesar el primer elemento de la secuencia, necesitamos un hidden state inicial \(h_0\). Lo más común es:

1
Inicializar a ceros: \(h_0 = \mathbf{0}\). Es la opción más simple y habitual.
2
Parámetro aprendible: \(h_0\) como un parámetro más de la red que se optimiza. Útil cuando el «contexto inicial» importa.
3
Salida de otra red: En modelos encoder-decoder, \(h_0\) del decoder es la salida del encoder.

🧪 Visualiza el flujo del hidden state

Observa cómo el hidden state se transforma en cada paso temporal. El tamaño del círculo representa la magnitud (norma) del vector \(h_t\).

4

Todo lo que la RNN «sabe» sobre la secuencia procesada hasta el momento \(t\) debe caber en un vector de \(d_h\) dimensiones. Esto crea un cuello de botella: cuanto más larga sea la secuencia, más información debe comprimirse en el mismo vector.

Es como intentar resumir un libro entero en un párrafo. Para oraciones cortas funciona bien, pero para secuencias muy largas, la información de los primeros pasos puede «difuminarse» o perderse por completo. Esta limitación será clave cuando hablemos de los problemas de las RNN.

📐 Matemáticas de la RNN

Ahora formalizamos la intuición anterior con las ecuaciones exactas de una RNN vanilla (también llamada Elman RNN). El forward pass en cada paso temporal \(t\) se define con dos ecuaciones:

Forward pass — ecuación del hidden state $$h_t = \tanh\!\bigl(W_{hh}\,h_{t-1} \;+\; W_{xh}\,x_t \;+\; b_h\bigr)$$
Forward pass — ecuación de la salida $$y_t = W_{hy}\,h_t \;+\; b_y$$

Donde:

Símbolo Dimensión Descripción
\(x_t\) \(\mathbb{R}^{d_x}\) Entrada en el paso temporal \(t\)
\(h_t\) \(\mathbb{R}^{d_h}\) Hidden state en el paso \(t\)
\(y_t\) \(\mathbb{R}^{d_y}\) Salida en el paso \(t\)
\(W_{xh}\) \(\mathbb{R}^{d_h \times d_x}\) Pesos entrada → hidden
\(W_{hh}\) \(\mathbb{R}^{d_h \times d_h}\) Pesos hidden → hidden (recurrencia)
\(W_{hy}\) \(\mathbb{R}^{d_y \times d_h}\) Pesos hidden → salida
\(b_h\) \(\mathbb{R}^{d_h}\) Sesgo del hidden state
\(b_y\) \(\mathbb{R}^{d_y}\) Sesgo de la salida
🔑

¡Clave! Los pesos \(W_{xh}\), \(W_{hh}\), \(W_{hy}\), \(b_h\) y \(b_y\) son los mismos en todos los pasos temporales. Esto es la compartición de pesos temporal que hace posible procesar secuencias de cualquier longitud.

¿Por qué la tangente hiperbólica?

La activación \(\tanh\) se usa por defecto en la ecuación del hidden state por varias razones:

  • Rango \([-1, 1]\): Mantiene los valores del hidden state acotados, evitando explosión numérica
  • Centrada en cero: A diferencia de la sigmoide, tiene media cero, facilitando el entrenamiento
  • Gradientes más fuertes: El gradiente máximo de \(\tanh\) es 1 (en \(x=0\)), mejor que la sigmoide (máximo 0.25)
Tangente hiperbólica $$\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}} \qquad \tanh'(z) = 1 - \tanh^2(z)$$
⚠️

Atención: Aunque \(\tanh\) es mejor que la sigmoide, su derivada sigue siendo \(\leq 1\). Esto tendrá consecuencias graves cuando analicemos el backpropagation through time: al multiplicar muchas derivadas menores que 1, los gradientes se desvanecen.

Flujo computacional paso a paso

1
Inicializar: \(h_0 = \mathbf{0}\) (o valor aprendible)
2
Transformar entrada: Calcular \(W_{xh} \cdot x_t\) — proyectar la entrada al espacio del hidden state
3
Transformar hidden state anterior: Calcular \(W_{hh} \cdot h_{t-1}\) — la «memoria» pasa por una transformación lineal
4
Combinar y activar: Sumar ambas contribuciones + bias, y aplicar \(\tanh\) → \(h_t\)
5
Producir salida: Calcular \(y_t = W_{hy} \cdot h_t + b_y\). Opcionalmente, aplicar softmax si es clasificación.
6
Repetir: Avanzar a \(t+1\) usando \(h_t\) como nuevo hidden state anterior.

Diagrama del flujo computacional

FLUJO COMPUTACIONAL — un paso temporal h_{t-1} × W_hh x_t × W_xh + + b_h tanh h_t → t+1 × W_hy y_t + b_y
Un paso temporal: dos transformaciones lineales se combinan, se activan con tanh → hidden state h_t. De h_t se deriva también la salida y_t.

🔢 Ejemplo numérico

Veamos un ejemplo concreto con dimensiones pequeñas para entender las operaciones matriciales. Supongamos:

  • \(d_x = 2\) (entrada bidimensional)
  • \(d_h = 3\) (hidden state de 3 dimensiones)
  • \(d_y = 1\) (salida escalar)

Parámetros (inventados para el ejemplo):

Matrices de pesos del ejemplo $$W_{xh} = \begin{pmatrix} 0.5 & -0.3 \\ 0.1 & 0.8 \\ -0.2 & 0.4 \end{pmatrix},\quad W_{hh} = \begin{pmatrix} 0.2 & 0.1 & -0.1 \\ -0.3 & 0.5 & 0.2 \\ 0.1 & -0.2 & 0.6 \end{pmatrix}$$

\(b_h = [0, 0, 0]^T\), \(h_0 = [0, 0, 0]^T\)

Paso t=1 con entrada \(x_1 = [1.0,\; 0.5]\):

Cálculo de h₁ $$z_1 = W_{hh}\cdot h_0 + W_{xh}\cdot x_1 = \mathbf{0} + \begin{pmatrix} 0.5\cdot1 + (-0.3)\cdot0.5 \\ 0.1\cdot1 + 0.8\cdot0.5 \\ -0.2\cdot1 + 0.4\cdot0.5 \end{pmatrix} = \begin{pmatrix} 0.35 \\ 0.50 \\ 0.00 \end{pmatrix}$$ $$h_1 = \tanh(z_1) = \begin{pmatrix} 0.337 \\ 0.462 \\ 0.000 \end{pmatrix}$$

Paso t=2 con entrada \(x_2 = [0.2,\; -0.8]\):

Cálculo de h₂ $$z_2 = W_{hh}\cdot h_1 + W_{xh}\cdot x_2 = \begin{pmatrix} 0.114 \\ 0.130 \\ -0.059 \end{pmatrix} + \begin{pmatrix} 0.340 \\ -0.620 \\ -0.360 \end{pmatrix} = \begin{pmatrix} 0.454 \\ -0.490 \\ -0.419 \end{pmatrix}$$ $$h_2 = \tanh(z_2) = \begin{pmatrix} 0.425 \\ -0.454 \\ -0.396 \end{pmatrix}$$

Observa cómo \(h_2\) depende tanto de \(x_2\) (la entrada actual) como de \(h_1\) (que a su vez depende de \(x_1\)). Así, \(h_2\) contiene información de toda la secuencia hasta el momento.

🧪 Calculadora de forward pass

Modifica los valores de entrada y observa cómo cambia el hidden state en cada paso. Los pesos están fijados para simplificar.

Conteo de parámetros

El número total de parámetros de una RNN vanilla es:

Total de parámetros aprendibles $$\text{Parámetros} = \underbrace{d_h \times d_x}_{W_{xh}} + \underbrace{d_h \times d_h}_{W_{hh}} + \underbrace{d_h}_{b_h} + \underbrace{d_y \times d_h}_{W_{hy}} + \underbrace{d_y}_{b_y}$$

Para nuestro ejemplo: \(3\times2 + 3\times3 + 3 + 1\times3 + 1 = 6+9+3+3+1 = \mathbf{22}\) parámetros.

ℹ️

Independiente de la longitud de la secuencia: Observa que el número de parámetros no depende de \(T\) (la longitud de la secuencia). Una RNN con 22 parámetros puede procesar secuencias de 5, 50 o 5000 pasos. Esto es gracias a la compartición de pesos temporal.

🏗️ Arquitecturas y tipos de RNN

Dependiendo de la tarea, podemos usar la RNN de diferentes formas: tomando solo la última salida, todas las salidas, o combinaciones. Estas variantes se clasifican según la relación entre entrada y salida:

ONE-TO-MANY x RNN RNN RNN y₁ y₂ y₃ Generación de música, image captioning MANY-TO-ONE RNN RNN RNN x₁ x₂ x₃ y Análisis de sentimientos, clasificación de texto MANY-TO-MANY RNN RNN RNN x₁ x₂ x₃ y₁ y₂ y₃ NER, POS tagging, predicción de series ENCODER-DECODER ENC ENC DEC DEC x₁ x₂ y₁ y₂ Traducción, resumen de texto
Las cuatro variantes principales de RNN según la relación entrada/salida.

💻 RNN en la práctica: PyTorch y TensorFlow

Veamos cómo implementar una RNN en los dos frameworks más populares. Empezamos con la API de alto nivel y luego exploramos el uso a nivel de celda.

PyTorch: nn.RNN

import torch
import torch.nn as nn

class SentimentRNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        
        # RNN: procesa la secuencia completa
        # batch_first=True → entrada shape: (batch, seq_len, embed_dim)
        self.rnn = nn.RNN(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=1,
            batch_first=True,
            nonlinearity='tanh'    # activación por defecto
        )
        
        # Capa de salida: del último hidden state a la predicción
        self.fc = nn.Linear(hidden_dim, output_dim)
    
    def forward(self, x):
        # x shape: (batch, seq_len) — índices de palabras
        embedded = self.embedding(x)         # (batch, seq_len, embed_dim)
        
        # output: (batch, seq_len, hidden_dim) — todos los hidden states
        # h_n:    (1, batch, hidden_dim)       — último hidden state
        output, h_n = self.rnn(embedded)
        
        # Usamos el ÚLTIMO hidden state para clasificar
        last_hidden = h_n.squeeze(0)         # (batch, hidden_dim)
        logits = self.fc(last_hidden)        # (batch, output_dim)
        return logits

# Ejemplo de uso
model = SentimentRNN(vocab_size=10000, embed_dim=128, hidden_dim=256, output_dim=2)
x = torch.randint(0, 10000, (32, 50))       # batch=32, seq_len=50
logits = model(x)                            # (32, 2) — positivo/negativo
print(f"Shape de salida: {logits.shape}")
print(f"Parámetros totales: {sum(p.numel() for p in model.parameters()):,}")
import torch
import torch.nn as nn

class ManualRNN(nn.Module):
    """RNN paso a paso usando RNNCell — máximo control."""
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.hidden_dim = hidden_dim
        
        # RNNCell: un solo paso temporal
        self.rnn_cell = nn.RNNCell(input_dim, hidden_dim)
        self.fc = nn.Linear(hidden_dim, output_dim)
    
    def forward(self, x):
        # x shape: (batch, seq_len, input_dim)
        batch_size, seq_len, _ = x.shape
        
        # Inicializar hidden state a ceros
        h_t = torch.zeros(batch_size, self.hidden_dim, device=x.device)
        
        # Lista para guardar todos los hidden states
        all_h = []
        
        for t in range(seq_len):
            # Un paso temporal: h_t = tanh(W_xh · x_t + W_hh · h_{t-1} + b)
            h_t = self.rnn_cell(x[:, t, :], h_t)
            all_h.append(h_t)
        
        # all_h[-1] == último hidden state
        # Podemos devolver el último o todos
        output = self.fc(h_t)  # many-to-one: solo el último
        return output, torch.stack(all_h, dim=1)  # (batch, seq_len, hidden_dim)

# Uso
model = ManualRNN(input_dim=10, hidden_dim=64, output_dim=3)
x = torch.randn(16, 20, 10)  # batch=16, seq_len=20, features=10
out, hidden_states = model(x)
print(f"Salida: {out.shape}")              # (16, 3)
print(f"Hidden states: {hidden_states.shape}")  # (16, 20, 64)

TensorFlow / Keras: SimpleRNN

import tensorflow as tf
from tensorflow.keras import layers, Model

class SentimentRNN(Model):
    def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim):
        super().__init__()
        self.embedding = layers.Embedding(vocab_size, embed_dim)
        
        # SimpleRNN: equivalente a nn.RNN de PyTorch
        # return_sequences=False → solo devuelve el último hidden state
        self.rnn = layers.SimpleRNN(
            units=hidden_dim,
            activation='tanh',
            return_sequences=False  # many-to-one
        )
        
        self.fc = layers.Dense(output_dim)
    
    def call(self, x):
        embedded = self.embedding(x)    # (batch, seq_len, embed_dim)
        h_last = self.rnn(embedded)     # (batch, hidden_dim)
        logits = self.fc(h_last)        # (batch, output_dim)
        return logits

# Ejemplo de uso
model = SentimentRNN(vocab_size=10000, embed_dim=128, hidden_dim=256, output_dim=2)
x = tf.random.uniform((32, 50), maxval=10000, dtype=tf.int32)
logits = model(x)
print(f"Shape de salida: {logits.shape}")
model.summary()
import tensorflow as tf
from tensorflow.keras import layers, Sequential

# Many-to-many: predecir el siguiente valor en cada paso
model = Sequential([
    # return_sequences=True → devuelve output en CADA paso temporal
    layers.SimpleRNN(64, activation='tanh', return_sequences=True,
                     input_shape=(None, 1)),  # None = cualquier longitud
    
    # Segunda capa RNN (stacked/apilada)
    layers.SimpleRNN(32, activation='tanh', return_sequences=True),
    
    # Capa Dense aplicada a cada paso temporal
    layers.TimeDistributed(layers.Dense(1))
])

model.compile(optimizer='adam', loss='mse')
model.summary()

# Datos de ejemplo: secuencia senoidal
import numpy as np
t = np.linspace(0, 10*np.pi, 1000)
x = np.sin(t).reshape(1, -1, 1).astype('float32')
y = np.cos(t).reshape(1, -1, 1).astype('float32')  # predecir cos a partir de sin

model.fit(x, y, epochs=50, verbose=0)
pred = model.predict(x)
print(f"Predicción shape: {pred.shape}")  # (1, 1000, 1)
ℹ️

PyTorch vs TensorFlow — API comparison:

  • nn.RNN(batch_first=True)SimpleRNN()
  • nn.RNNCellSimpleRNNCell
  • PyTorch devuelve (output, h_n); Keras con return_sequences/return_state
  • En PyTorch se gestiona el hidden state manualmente; Keras lo abstrae

RNN multicapa (stacked RNN)

Podemos apilar varias capas RNN: la salida de la primera capa en cada paso temporal se convierte en la entrada de la segunda capa:

RNN MULTICAPA (stacked) L2 L2 L2 L2 L1 L1 L1 L1 x₁ x₂ x₃ x₄ Capa 2 Capa 1 Cada capa tiene sus propios pesos W_hh, W_xh
RNN de 2 capas: la salida h de la capa 1 en cada paso temporal alimenta la capa 2.
import torch.nn as nn

# RNN de 3 capas apiladas con dropout entre capas
rnn = nn.RNN(
    input_size=128,
    hidden_size=256,
    num_layers=3,       # 3 capas apiladas
    batch_first=True,
    dropout=0.3,        # dropout entre capas (no en la última)
    bidirectional=False  # unidireccional
)

# h_n shape: (num_layers, batch, hidden_dim)
# output shape: (batch, seq_len, hidden_dim)
x = torch.randn(32, 50, 128)
output, h_n = rnn(x)
print(f"output: {output.shape}")   # (32, 50, 256)
print(f"h_n: {h_n.shape}")         # (3, 32, 256) — un h por capa

RNN bidireccional

Una RNN bidireccional procesa la secuencia en ambas direcciones: de izquierda a derecha y de derecha a izquierda. Esto permite que cada hidden state contenga información tanto del pasado como del futuro.

import torch.nn as nn

# RNN bidireccional
rnn_bidir = nn.RNN(
    input_size=128,
    hidden_size=256,
    num_layers=2,
    batch_first=True,
    bidirectional=True   # ← bidireccional
)

x = torch.randn(32, 50, 128)
output, h_n = rnn_bidir(x)

# output: (batch, seq_len, 2 * hidden_dim) — forward + backward concatenados
# h_n: (2 * num_layers, batch, hidden_dim)
print(f"output: {output.shape}")   # (32, 50, 512) ← 256*2
print(f"h_n: {h_n.shape}")         # (4, 32, 256)  ← 2 capas * 2 direcciones
⚠️

Bidireccional ≠ siempre mejor. Solo puede usarse cuando tienes acceso a toda la secuencia de antemano. No es aplicable en tiempo real (streaming) ni en generación autoregresiva, donde no conoces los tokens futuros.

Backpropagation Through Time (BPTT)

¿Cómo entrenamos una RNN? Usando Backpropagation Through Time (BPTT), que es el algoritmo de backpropagation estándar aplicado a la RNN desplegada en el tiempo. Es decir, tratamos la RNN desplegada como una red muy profunda donde cada «capa» es un paso temporal.

La función de pérdida

La pérdida total es la suma de las pérdidas en cada paso temporal:

Pérdida total $$\mathcal{L} = \sum_{t=1}^{T} \mathcal{L}_t(y_t, \hat{y}_t) = \sum_{t=1}^{T} \mathcal{L}_t$$

Para optimizar los pesos, necesitamos calcular \(\frac{\partial \mathcal{L}}{\partial W}\) para cada matriz de pesos. Veamos cómo se propagan los gradientes hacia atrás en el tiempo.

Algoritmo BPTT paso a paso

1
Forward pass completo: Procesar toda la secuencia \(x_1, x_2, \ldots, x_T\), almacenando todos los \(h_t\) y \(y_t\).
2
Calcular pérdida: Evaluar \(\mathcal{L}_t\) en cada paso y sumar.
3
Backward pass: Desde \(t=T\) hasta \(t=1\), propagar gradientes hacia atrás. Los gradientes respecto a \(W_{hh}\) se acumulan en cada paso.
4
Actualizar pesos: Aplicar los gradientes acumulados para actualizar \(W_{xh}\), \(W_{hh}\), \(W_{hy}\), \(b_h\), \(b_y\).

El gradiente clave: ¿cómo depende \(h_t\) de \(h_k\)?

El problema central del BPTT aparece al calcular cómo el hidden state en el paso \(t\) depende de un hidden state anterior en el paso \(k\) (con \(k < t\)):

Regla de la cadena a través del tiempo $$\frac{\partial h_t}{\partial h_k} = \prod_{i=k+1}^{t} \frac{\partial h_i}{\partial h_{i-1}} = \prod_{i=k+1}^{t} W_{hh}^T \cdot \text{diag}\!\bigl(\tanh'(z_i)\bigr)$$

Donde \(z_i = W_{hh} h_{i-1} + W_{xh} x_i + b_h\). Esta es una cadena de productos matriciales. Y aquí es donde surgen los problemas:

FLUJO DE GRADIENTES EN BPTT Forward → h₁ h₂ h₃ ··· h_{T-1} h_T ← Backward ∂L/∂h_T ∂L/∂h_{T-1} se atenúa... ≈ 0 ⚠️ Cada × W_hh^T · diag(tanh'(z)) multiplica el gradiente — puede hacerlo desaparecer o explotar
Los gradientes se propagan hacia atrás a través de todos los pasos temporales. En cada paso se multiplican por la Jacobiana de la transición.

📉 Gradientes que se desvanecen (vanishing gradients)

El problema de vanishing gradients ocurre cuando los gradientes se hacen exponencialmente pequeños al propagarse hacia atrás en secuencias largas.

¿Por qué ocurre?

Recordemos que \(\tanh'(z) \in (0, 1]\) y que el valor máximo de la derivada es 1 (solo cuando \(z = 0\)). Si los valores propios de \(W_{hh}\) son menores que 1, cada multiplicación en la cadena encoge el gradiente:

Desvanecimiento exponencial $$\left\|\frac{\partial h_t}{\partial h_k}\right\| \leq \prod_{i=k+1}^{t} \|W_{hh}\| \cdot \|\tanh'(z_i)\| \leq (\gamma)^{t-k}$$ $$\text{Si } \gamma < 1 \Rightarrow (\gamma)^{t-k} \xrightarrow{t-k \to \infty} 0$$

Consecuencia: La RNN no puede aprender dependencias a largo plazo. Si la palabra que determina el sentimiento de una oración está al principio, y la pérdida se calcula al final, el gradiente que llega hasta esa palabra es prácticamente cero.

🔑

Ejemplo práctico: En la frase «The cat, which was sitting on the mat in the living room next to the fireplace, was happy», el verbo «was» (singular) debe concordar con «cat» (singular), no con «fireplace». Una RNN vanilla difícilmente aprenderá esta dependencia por el vanishing gradient.

🧪 Experimenta: Vanishing gradients

Observa cómo la magnitud del gradiente decae exponencialmente con la distancia temporal. Ajusta el factor de escala \(\gamma\) y la longitud de la secuencia.

0.80
20

📈 Gradientes que explotan (exploding gradients)

El problema inverso: si \(\gamma > 1\), los gradientes crecen exponencialmente. Esto causa actualizaciones de pesos enormes que desestabilizan el entrenamiento.

Explosión exponencial $$\text{Si } \gamma > 1 \Rightarrow (\gamma)^{t-k} \xrightarrow{t-k \to \infty} \infty$$

Los síntomas de exploding gradients incluyen:

  • Loss que salta a NaN o Inf de repente
  • Pesos que crecen sin control
  • Entrenamiento inestable con oscilaciones grandes

Solución: Gradient clipping

A diferencia de vanishing gradients (difícil de solucionar sin cambiar la arquitectura), el exploding gradient tiene una solución directa: gradient clipping. Si la norma del gradiente supera un umbral, lo reescalamos:

Gradient clipping $$\hat{g} = \begin{cases} g & \text{si } \|g\| \leq \theta \\ \frac{\theta}{\|g\|} \cdot g & \text{si } \|g\| > \theta \end{cases}$$
import torch
import torch.nn as nn

model = ...  # tu modelo RNN
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
max_grad_norm = 1.0  # umbral de clipping

for batch in dataloader:
    optimizer.zero_grad()
    output = model(batch.input)
    loss = criterion(output, batch.target)
    loss.backward()
    
    # ⚡ Gradient clipping — ANTES del optimizer.step()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
    
    optimizer.step()
import tensorflow as tf

model = ...  # tu modelo RNN

# Opción 1: clipnorm en el optimizador
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3, clipnorm=1.0)

# Opción 2: clipvalue (limita cada componente individualmente)
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3, clipvalue=0.5)

model.compile(optimizer=optimizer, loss='mse')

Comparación de los dos problemas

Vanishing gradients 📉 Exploding gradients 📈
Condición \(\gamma < 1\) → gradientes → 0 \(\gamma > 1\) → gradientes → ∞
Síntoma Entrenamiento lento, no aprende NaN, Inf, entrenamiento inestable
Detección Difícil (la red simplemente no mejora) Fácil (la loss explota)
Solución simple ❌ No hay (requiere nueva arquitectura) ✅ Gradient clipping
Solución real LSTM, GRU (caminos de gradiente directos) Gradient clipping + buena inicialización

Para entender cuándo ocurre vanishing vs exploding, podemos analizar los valores propios de \(W_{hh}\):

  • Si todos los valores propios de \(W_{hh}\) tienen módulo \(< 1\), el gradiente se desvanece
  • Si algún valor propio tiene módulo \(> 1\), el gradiente puede explotar en esa dirección
  • La inicialización ortogonal de \(W_{hh}\) (valores propios con módulo = 1) ayuda a mantener los gradientes estables al inicio del entrenamiento
Radio espectral $$\rho(W_{hh}) = \max_i |\lambda_i| \qquad \begin{cases} \rho < 1 \Rightarrow \text{vanishing} \\ \rho > 1 \Rightarrow \text{exploding} \\ \rho = 1 \Rightarrow \text{estable (ideal)} \end{cases}$$

En la práctica, la no-linealidad \(\tanh\) complica este análisis, pero la intuición se mantiene: si la operación recurrente contrae en promedio, los gradientes se desvanecen. Si expande, explotan.

⏱️ Memoria a corto plazo

Como consecuencia directa del vanishing gradient, las RNN vanilla sufren de memoria a corto plazo: pueden recordar información reciente, pero «olvidan» la información de pasos temporales lejanos.

INFLUENCIA DE CADA ENTRADA EN EL HIDDEN STATE FINAL x₁ x₂ x₃ ··· x_{T-1} x_T Casi olvidado Bien recordado ———————— Tiempo ————————→
En una RNN vanilla, las entradas recientes tienen mucha más influencia en h_T que las entradas lejanas.

Esto es un problema fundamental en muchas tareas reales. Algunos ejemplos:

  • Traducción: El sujeto al inicio de una oración larga afecta la conjugación del verbo al final
  • Resumen: La idea principal puede estar en el primer párrafo de un documento largo
  • Series temporales: Patrones estacionales de hace meses afectan la predicción actual
  • Generación de código: Una variable definida muchas líneas antes se usa ahora
⚠️

La regla práctica: Las RNN vanilla solo son efectivas para dependencias de ~10-20 pasos temporales. Más allá de eso, la información se pierde por el vanishing gradient. Para secuencias largas, necesitamos arquitecturas como LSTM o GRU (que veremos en módulos posteriores).

🧱 Limitaciones de la RNN vanilla

Resumamos todas las limitaciones que hemos identificado en la RNN vanilla y veamos qué motivó la creación de arquitecturas más avanzadas.

Resumen de problemas

Limitación Causa Impacto
Vanishing gradients Productos de Jacobianas con \(\|\cdot\| < 1\) No aprende dependencias a largo plazo
Exploding gradients Productos de Jacobianas con \(\|\cdot\| > 1\) Entrenamiento inestable (NaN)
Memoria limitada Todo en un solo vector \(h_t\) Cuello de botella informacional
Procesamiento secuencial \(h_t\) depende de \(h_{t-1}\) No se puede paralelizar → lento en GPUs
Dificultad con secuencias largas Combinación de vanishing + cuello de botella Efectivo solo para ∼10-20 pasos

Más allá de la RNN vanilla

Estas limitaciones motivaron el desarrollo de arquitecturas más sofisticadas que atacan estos problemas de diferentes formas:

🔒

LSTM

Long Short-Term Memory. Introduce «puertas» (gates) y un cell state separado que permite mantener información durante cientos de pasos temporales. Soluciona el vanishing gradient con caminos de gradiente directos.

📚 Desarrollado en el módulo LSTM

GRU

Gated Recurrent Unit. Una simplificación del LSTM con solo 2 puertas en vez de 3. Menos parámetros, rendimiento similar en muchas tareas. Popular por su eficiencia computacional.

📚 Desarrollado en el módulo GRU
👁️

Atención y Transformers

Mecanismo de atención: permite a la red «mirar» directamente a cualquier paso temporal, sin depender de la cadena de hidden states. Los Transformers eliminan la recurrencia por completo.

📚 Desarrollado en el módulo Transformers

La idea fundamental permanece: Aunque las arquitecturas han evolucionado enormemente, la idea de procesar información secuencial y mantener un «estado» que se actualiza con cada nueva entrada sigue siendo el corazón de todos estos modelos. La RNN vanilla es la base conceptual sobre la que se construyen LSTM, GRU y más allá.

¿Cuándo usar RNN vanilla hoy?

A pesar de sus limitaciones, la RNN vanilla sigue siendo útil en ciertos escenarios:

✅ Usa RNN vanilla cuando

  • Las secuencias son cortas (< 20 pasos)
  • Las dependencias son locales/cercanas
  • Recursos computacionales muy limitados
  • Para aprender y experimentar
  • Como baseline simple para comparar

❌ No uses RNN vanilla cuando

  • Las secuencias son largas (> 50 pasos)
  • Necesitas dependencias a largo plazo
  • El rendimiento es crítico
  • Tienes datos suficientes para LSTM/GRU
  • Puedes usar Transformers
1986
Rumelhart et al. — Backpropagation. La base para entrenar redes neuronales.
1990
Elman — Simple Recurrent Network (Elman RNN). La RNN vanilla que hemos estudiado.
1994
Bengio et al. — Identificación formal del problema de vanishing gradients en RNNs.
1997
Hochreiter & Schmidhuber — LSTM. La solución al vanishing gradient mediante cell state y puertas.
2014
Cho et al. — GRU. Simplificación del LSTM con rendimiento comparable.
2014
Bahdanau et al. — Mecanismo de atención para secuencia a secuencia.
2017
Vaswani et al. — «Attention Is All You Need». El Transformer elimina la recurrencia.
🏭 Casos de uso