Saltar al contenido principal
Fundamentos y conceptos clave Arquitecturas de redes~ 35 min de lectura
PorSergio Perea· intermedio

Redes Neuronales Recurrentes (RNN): memoria, secuencias y el tiempo en una red neuronal

Guía completa de RNN, LSTM y GRU. Cómo procesan secuencias, por qué fallan con dependencias largas, y construye un modelo de series temporales con PyTorch desde cero.

RNNLSTMGRUseries temporalesNLPPyTorchintermedio

Respuesta rápida

Una Red Neuronal Recurrente (RNN) es una red que procesa datos secuenciales manteniendo una memoria interna. A diferencia de una red densa, donde cada entrada es independiente, una RNN recuerda qué ha visto antes para influir en lo que predice ahora. Es la tecnología que antiguamente dominaba la traducción automática, el reconocimiento de voz y la predicción de series temporales, hasta que los Transformers la superaron en la mayoría de tareas. Aun así, entender las RNN es esencial para comprender por qué surgieron los mecanismos de atención y las arquitecturas modernas.

El problema que las RNN resuelven

Imagina que quieres predecir la temperatura de mañana. Tienes datos de los últimos 30 días. Una red neuronal densa tradicional te obligaría a meter esos 30 días como un único vector de entrada de 30 números. Funciona, pero es rígido: ¿qué pasa si quieres usar 60 días? Tendrías que cambiar la arquitectura entera. Y peor aún: la red no entiende que el día 29 está más cerca del día 30 que del día 1. Para ella, los 30 números son solo coordenadas en un espacio de 30 dimensiones.

Ahora imagina que quieres traducir una frase del inglés al español. La frase puede tener 5 palabras o 50. Una red densa necesitaría saber de antemano la longitud máxima y rellenar con ceros. Pero el orden importa: "El perro mordió al hombre" no significa lo mismo que "El hombre mordió al perro".

Las RNN nacieron para este tipo de problemas. Procesan los datos uno a uno, en orden, llevando una memoria de lo visto hasta el momento. Es como leer un libro página a página en lugar de mirar todas las páginas a la vez.


La intuición: un bucle con memoria

El concepto central de una RNN es el estado oculto (hidden state). Piensa en él como una nota que la red se escribe a sí misma en cada paso.

En el paso 1, la RNN recibe la primera entrada x1x_1 y genera una salida y1y_1 y un estado oculto h1h_1.

En el paso 2, recibe la segunda entrada x2x_2 y también el estado oculto anterior h1h_1. Con ambas cosas genera y2y_2 y h2h_2.

En el paso 3, recibe x3x_3 y h2h_2, y genera y3y_3 y h3h_3.

Y así sucesivamente. El estado oculto es el vehículo de la memoria: lleva información de todas las entradas anteriores condensada en un vector de números.


Las matemáticas de una RNN simple

Aunque la idea es intuitiva, las ecuaciones son sencillas. En cada paso temporal tt, la RNN calcula:

ht=tanh(Whhht1+Wxhxt+bh)h_t = \tanh(W_{hh} \, h_{t-1} + W_{xh} \, x_t + b_h)

yt=Whyht+byy_t = W_{hy} \, h_t + b_y

Vamos a desglosar cada pieza:

  • xtx_t: la entrada en el paso tt (por ejemplo, la palabra número tt de una frase, representada como un vector).
  • ht1h_{t-1}: el estado oculto del paso anterior. Es la "memoria".
  • WxhW_{xh}: matriz de pesos que transforma la entrada actual.
  • WhhW_{hh}: matriz de pesos que transforma el estado oculto anterior.
  • bh,byb_h, b_y: sesgos (biases).
  • tanh\tanh: función de activación que comprime los valores entre -1 y 1.
  • yty_t: la salida en el paso tt (por ejemplo, la siguiente palabra predicha).
  • WhyW_{hy}: matriz que transforma el estado oculto en la salida.

Lo crucial: los pesos WxhW_{xh}, WhhW_{hh} y WhyW_{hy} son los mismos en todos los pasos. Eso es lo que permite que la RNN procese secuencias de cualquier longitud con un número fijo de parámetros.

En código Python puro, una RNN sería:

import numpy as np

def rnn_step(x_t, h_prev, Wxh, Whh, Why, bh, by):
    """Un solo paso de RNN"""
    h_t = np.tanh(np.dot(Whh, h_prev) + np.dot(Wxh, x_t) + bh)
    y_t = np.dot(Why, h_t) + by
    return h_t, y_t

# Dimensiones
input_size = 10   # tamaño del vector de entrada (ej: embedding de palabra)
hidden_size = 20  # tamaño del estado oculto
output_size = 10  # tamaño de la salida

# Pesos compartidos en todos los pasos
Wxh = np.random.randn(hidden_size, input_size) * 0.01
Whh = np.random.randn(hidden_size, hidden_size) * 0.01
Why = np.random.randn(output_size, hidden_size) * 0.01
bh = np.zeros((hidden_size, 1))
by = np.zeros((output_size, 1))

# Estado inicial (sin memoria)
h = np.zeros((hidden_size, 1))

# Procesar una secuencia de 5 palabras
sequence = [np.random.randn(input_size, 1) for _ in range(5)]

for t, x_t in enumerate(sequence):
    h, y_t = rnn_step(x_t, h, Wxh, Whh, Why, bh, by)
    print(f"Paso {t}: salida shape = {y_t.shape}, estado shape = {h.shape}")

Esto es todo. No hay magia: multiplicación de matrices, suma de sesgos, y tanh\tanh.


Tipos de arquitecturas RNN

Las RNN no son un único modelo. Según el problema, la relación entre entradas y salidas cambia:

TipoEntradaSalidaEjemplo
One-to-OneUn vectorUn vectorClasificación clásica (no es RNN propiamente)
One-to-ManyUn vectorSecuenciaGenerar una descripción a partir de una imagen
Many-to-OneSecuenciaUn vectorAnálisis de sentimiento, clasificación de texto
Many-to-ManySecuenciaSecuenciaTraducción automática, subtitulado de video
Many-to-Many sincronizadoSecuenciaSecuencia (misma longitud)Etiquetado de cada palabra (NER), predicción frame a frame

El talón de Aquiles: gradientes que desaparecen y explotan

Las RNN simples tienen un problema mortal. Imagina que estás entrenando una RNN para predecir el siguiente número de una serie: 1, 2, 3, 4, 5, .... Funciona bien porque la dependencia es inmediata. Pero ahora imagina esta serie:

0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5

Para predecir el 1, la red necesita recordar que hace 10 pasos había un 0. Pero el estado oculto hth_t pasa por tanh\tanh en cada paso. La función tanh\tanh satura: valores grandes se comprimen a 1, valores negativos grandes a -1. Cada vez que pasa por tanh\tanh, la información se "aplasta" un poco. Después de 10 pasos, la señal original está tan diluida que es prácticamente inexistente.

Esto se llama problema del gradiente que desaparece (vanishing gradient). Durante el entrenamiento, cuando calculamos cuánto debe cambiar cada peso para mejorar, el gradiente se multiplica por matrices de pesos una y otra vez al viajar hacia atrás en el tiempo. Si los valores propios de esas matrices son menores que 1, el gradiente se encoge exponencialmente. Al cabo de 50 pasos, es tan pequeño que el peso casi no se actualiza. La red no aprende dependencias a largo plazo.

El problema opuesto también existe: si los valores propios son mayores que 1, el gradiente explota (exploding gradient) y los pesos se disparan hacia infinito. Se soluciona parcialmente con gradient clipping: si el gradiente supera un umbral, se recorta.

Pero el gradiente que desaparece es mucho más difícil de arreglar. Necesitamos una arquitectura que preserve la información durante cientos de pasos sin que tanh\tanh la destruya. Ahí es donde entran las LSTM.


LSTM: la memoria a largo plazo

Las Long Short-Term Memory (LSTM), inventadas por Hochreiter y Schmidhuber en 1997, son una variante de RNN diseñada explícitamente para resolver el problema del gradiente que desaparece. No usan un estado oculto simple. Usan una celda de memoria (cell state) que fluye a través de la secuencia con mínimas modificaciones, más tres puertas que controlan qué información entra, qué se queda y qué sale.

La celda de memoria (CtC_t)

Es un vector que fluye horizontalmente a través de la celda, de izquierda a derecha, con solo algunas interacciones lineales. Es como una cinta transportadora: la información puede permanecer intacta durante cientos de pasos si las puertas lo permiten.

La puerta de olvido (Forget Gate)

Decide qué información de la celda anterior se descarta. Usa una sigmoid para producir valores entre 0 y 1:

ft=σ(Wf[ht1,xt]+bf)f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f)

Si ft=0f_t = 0 para una dimensión, esa información se borra. Si ft=1f_t = 1, pasa intacta.

La puerta de entrada (Input Gate)

Decide qué nueva información se almacena en la celda. Tiene dos partes:

it=σ(Wi[ht1,xt]+bi)i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i)

C~t=tanh(WC[ht1,xt]+bC)\tilde{C}_t = \tanh(W_C \cdot [h_{t-1}, x_t] + b_C)

iti_t es una máscara de sigmoid (qué dimensiones actualizar). C~t\tilde{C}_t es el candidato a nuevo valor. La celda se actualiza así:

Ct=ftCt1+itC~tC_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t

Donde \odot es multiplicación elemento a elemento. Es una combinación ponderada: conservas lo viejo (ponderado por ftf_t) y añades lo nuevo (ponderado por iti_t).

La puerta de salida (Output Gate)

Decide qué parte de la celda se expone como estado oculto hth_t:

ot=σ(Wo[ht1,xt]+bo)o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o)

ht=ottanh(Ct)h_t = o_t \odot \tanh(C_t)

La celda pasa por tanh\tanh para comprimir valores, y luego oto_t filtra qué dimensiones salen.

¿Por qué funcionan?

La clave está en la actualización de la celda: Ct=ftCt1+itC~tC_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t. Es una suma, no una multiplicación por una matriz de pesos seguida de tanh\tanh. Eso significa que el gradiente puede fluir a través de la celda durante cientos de pasos sin atenuarse. La celda actúa como una autopista para gradientes.


GRU: la versión simplificada

Las LSTM son potentes pero tienen muchos parámetros (4 conjuntos de pesos). En 2014, Kyunghyun Cho propuso la Gated Recurrent Unit (GRU), que fusiona la celda de memoria y el estado oculto en un solo vector, y reduce las puertas de tres a dos.

Puerta de reinicio (Reset Gate): rt=σ(Wr[ht1,xt])r_t = \sigma(W_r \cdot [h_{t-1}, x_t]). Decide qué parte del estado anterior se ignora al calcular el candidato.

Puerta de actualización (Update Gate): zt=σ(Wz[ht1,xt])z_t = \sigma(W_z \cdot [h_{t-1}, x_t]). Decide cuánto del estado anterior se conserva versus cuánto del candidato nuevo se adopta.

h~t=tanh(W[rtht1,xt])\tilde{h}_t = \tanh(W \cdot [r_t \odot h_{t-1}, x_t])

ht=(1zt)ht1+zth~th_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t

Las GRU tienen aproximadamente un 25% menos de parámetros que las LSTM, se entrenan más rápido, y en muchas tareas alcanzan resultados similares. La elección entre LSTM y GRU suele ser empírica: prueba ambas y quédate con la que mejor valide.


Backpropagation Through Time (BPTT)

Entrenar una RNN requiere una adaptación del algoritmo de retropropagación estándar. La red se "desenrolla" en el tiempo: una secuencia de 100 palabras se convierte en una red de 100 capas, todas compartiendo los mismos pesos.

El gradiente del error en yTy_T fluye hacia atrás a través de hTh_T, luego hT1h_{T-1}, y así sucesivamente hasta h1h_1. En cada paso, se multiplica por la derivada de tanh\tanh (que es 1\leq 1) y por la matriz de pesos WhhTW_{hh}^T.

Si los valores propios de WhhW_{hh} son <1< 1, el gradiente se encoge exponencialmente. Si son >1> 1, explota. Esa es la razón matemática del problema.

En la práctica, se usa BPTT truncado: en lugar de propagar el gradiente hasta el inicio de la secuencia, se propaga solo unos 20-50 pasos hacia atrás. Es un compromiso entre eficiencia y capacidad de aprender dependencias largas.


RNN Bidireccionales

Hasta ahora hemos asumido que la RNN lee la secuencia de izquierda a derecha. Pero en muchos problemas, el contexto futuro también importa. Para clasificar el sentimiento de "Esa película no me gustó nada", la palabra "no" modifica "gustó", pero si lees solo hasta "gustó" no sabes que viene "nada".

Las RNN Bidireccionales (BiRNN) solucionan esto ejecutando dos RNN en paralelo:

  • Una lee la secuencia de izquierda a derecha (forward).
  • Otra la lee de derecha a izquierda (backward).

En cada paso, los estados ocultos de ambas direcciones se concatenan para formar el estado final.

BiLSTM y BiGRU son el estándar en tareas de NLP donde el contexto completo está disponible de antemano, como clasificación de texto o reconocimiento de entidades.


Encoder-Decoder: de secuencia a secuencia

Para traducción automática, necesitas una arquitectura many-to-many donde las secuencias de entrada y salida tienen longitudes diferentes. La solución es el modelo Encoder-Decoder.

El Encoder lee toda la frase de entrada y comprime su significado en un único vector de contexto cc (típicamente el último estado oculto).

El Decoder es otra RNN que genera la frase de salida palabra por palabra, usando cc como estado inicial. En cada paso, predice la siguiente palabra y la usa como entrada para el siguiente paso (autoregresivo).

El problema es que el vector cc es un cuello de botella. Una frase larga debe comprimirse en unos pocos cientos de números. Información del principio de la frase se pierde. La solución fue el mecanismo de atención, que permite al decoder "mirar" directamente a cualquier palabra del encoder en cada paso. Eso fue el puente hacia los Transformers.


Atención en RNNs: el puente hacia los Transformers

El mecanismo de atención resuelve el cuello de botella del vector de contexto. En lugar de depender solo de cc, el decoder calcula una combinación ponderada de todos los estados ocultos del encoder.

En cada paso del decoder:

  1. Calcula una puntuación de alineación entre el estado actual del decoder y cada estado del encoder.
  2. Aplica softmax para obtener pesos que suman 1.
  3. Calcula un vector de contexto como media ponderada de los estados del encoder.
  4. Usa ese vector de contexto para predecir la siguiente palabra.

Esto permite que el decoder se "enfoque" en la palabra relevante del encoder en cada momento. Al traducir "gato", el decoder pondrá atención sobre "cat", independientemente de si "cat" estaba al principio o al final de la frase inglesa.

La atención fue tan efectiva que los investigadores se preguntaron: ¿y si eliminamos las RNN por completo y usamos solo atención? Así nació el paper Attention Is All You Need y los Transformers.


Implementación en PyTorch

Vamos a construir una LSTM para predecir el siguiente valor de una serie temporal sintética: una onda senoidal con ruido.

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt

# 1. Generar datos: senoidal con ruido
def generate_data(seq_len=1000):
    t = np.linspace(0, 50, seq_len)
    data = np.sin(t) + 0.1 * np.random.randn(seq_len)
    return data.astype(np.float32)

data = generate_data()

# 2. Crear secuencias de entrada/salida
def create_sequences(data, seq_length):
    xs, ys = [], []
    for i in range(len(data) - seq_length):
        x = data[i:i+seq_length]
        y = data[i+seq_length]
        xs.append(x)
        ys.append(y)
    return np.array(xs), np.array(ys)

seq_length = 50
X, y = create_sequences(data, seq_length)

# Convertir a tensores y añadir dimensión de features
X_tensor = torch.from_numpy(X).unsqueeze(-1)  # (batch, seq, features)
y_tensor = torch.from_numpy(y).unsqueeze(-1)

# 3. Definir modelo LSTM
class LSTMPredictor(nn.Module):
    def __init__(self, input_size=1, hidden_size=50, num_layers=2):
        super(LSTMPredictor, self).__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
                            batch_first=True)
        self.fc = nn.Linear(hidden_size, 1)

    def forward(self, x):
        # lstm_out: (batch, seq, hidden)
        # hidden: (num_layers, batch, hidden)
        lstm_out, (hidden, cell) = self.lstm(x)
        # Usamos solo el último paso de la secuencia
        last_hidden = lstm_out[:, -1, :]
        return self.fc(last_hidden)

model = LSTMPredictor()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 4. Entrenar
epochs = 100
batch_size = 64
dataset = torch.utils.data.TensorDataset(X_tensor, y_tensor)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)

for epoch in range(epochs):
    total_loss = 0
    for batch_x, batch_y in dataloader:
        optimizer.zero_grad()
        output = model(batch_x)
        loss = criterion(output, batch_y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()

    if (epoch + 1) % 20 == 0:
        print(f'Época {epoch+1}, pérdida: {total_loss/len(dataloader):.6f}')

# 5. Predicción autoregresiva
model.eval()
with torch.no_grad():
    # Tomar los últimos 50 valores reales como semilla
    seed = X_tensor[-1].unsqueeze(0)
    predictions = []

    for _ in range(200):
        pred = model(seed)
        predictions.append(pred.item())
        # Desplazar la ventana: quitar el primero, añadir la predicción
        seed = torch.cat([seed[:, 1:, :], pred.unsqueeze(0)], dim=1)

# Visualizar
plt.figure(figsize=(12, 4))
plt.plot(data, label='Datos reales')
plt.plot(range(len(data), len(data) + 200), predictions, label='Predicciones', color='red')
plt.legend()
plt.title('Predicción de serie temporal con LSTM')
plt.show()

Este código entrena una LSTM para aprender el patrón de una onda senoidal y luego predice 200 valores futuros. La clave está en la línea seed = torch.cat([seed[:, 1:, :], pred.unsqueeze(0)], dim=1), que desliza la ventana de entrada un paso hacia adelante en cada iteración.


Aplicaciones donde las RNN siguen siendo útiles

A pesar de que los Transformers dominan el NLP, las RNN tienen nichos donde siguen siendo competitivas:

Series temporales con pocos datos. Cuando tienes cientos de puntos en lugar de millones, una LSTM pequeña puede superar a un Transformer enormemente sobreparametrizado.

Procesamiento en tiempo real. Las RNN procesan un elemento por vez. Un Transformer necesita ver toda la secuencia antes de empezar. En aplicaciones de streaming (detección de anomalías en sensores IoT), la naturaleza secuencial de las RNN es una ventaja.

Dispositivos con recursos limitados. Una LSTM de unos pocos miles de parámetros cabe en un microcontrolador. Los Transformers necesitan megabytes de memoria y GPUs potentes.

Audio corto. Para detección de palabras clave ("Hey Siri", "OK Google"), una GRU pequeña es suficiente y mucho más eficiente que un modelo de atención completo.


Preguntas frecuentes

¿Por qué las RNN usan tanh\tanh y no ReLU?

En la actualización del estado oculto, tanh\tanh acota los valores entre -1 y 1, evitando que los estados crezcan sin control. Sin embargo, en las puertas de LSTM/GRU se usa sigmoid (entre 0 y 1) porque las puertas deben actuar como interruptores. ReLU no es adecuado aquí porque no acota superiormente.

¿Cuántas capas LSTM debo apilar?

Para la mayoría de problemas, 1-2 capas bastan. Apilar 3 o más capas LSTM raramente mejora el rendimiento y aumenta drásticamente el tiempo de entrenamiento. Los Transformers escalan mejor en profundidad.

¿Puedo usar RNN con texto sin embeddings?

Técnicamente sí: podrías usar one-hot encoding donde cada palabra es un vector de 50.000 ceros y un uno. Pero eso es ineficiente y la red no captura relaciones semánticas. Los embeddings (Word2Vec, GloVe, o aprendidos durante el entrenamiento) son prácticamente obligatorios.

¿Qué es teacher forcing?

Durante el entrenamiento de un decoder, en lugar de usar la predicción del modelo como entrada del siguiente paso, usas la palabra correcta real. Esto estabiliza el entrenamiento porque el modelo no se desvía acumulando errores. Durante la inferencia, claro, no hay palabra correcta: usa sus propias predicciones.

¿Por qué seq2seq con atención fue reemplazado por Transformers?

Por tres razones: (1) Los Transformers procesan toda la secuencia en paralelo, entrenándose mucho más rápido. (2) La atención multi-cabezal captura relaciones más ricas que la atención simple de seq2seq. (3) No hay recurrencia, así que no hay gradientes que desaparezcan. La única desventaja es el coste cuadrático con la longitud de la secuencia.

¿Las RNN son obsoletas?

No, pero su dominio se ha reducido. Para NLP a gran escala, los Transformers son superiores. Para series temporales pequeñas, procesamiento en tiempo real y dispositivos embebidos, las LSTM y GRU siguen siendo opciones válidas y a veces óptimas.

¿Cómo elijo entre LSTM y GRU?

Si tienes recursos computacionales abundantes, prueba LSTM primero. Si necesitas velocidad o trabajas con datasets pequeños, empieza con GRU. En la práctica, los resultados suelen ser similares; la GRU se entrena más rápido.

¿Qué es el número de capas (num_layers) en una LSTM de PyTorch?

Es cuántas LSTM se apilan verticalmente. La salida de la primera LSTM se convierte en entrada de la segunda. Una num_layers=2 significa que la secuencia pasa por dos celdas LSTM en cada paso temporal.

¿Puedo usar RNN para imágenes?

Sí, pero no es lo habitual. Una forma es tratar los píxeles de una imagen como una secuencia (por filas). Otra es usar RNN para generar descripciones de imágenes, donde la entrada es un vector de features extraído por una CNN. Las RNN solas no compiten con las CNN en visión.

¿Cuál es la diferencia entre batch_first=True y False en PyTorch?

Por defecto, PyTorch espera tensores de forma (seq_len, batch, features). Con batch_first=True, espera (batch, seq_len, features), que es más intuitivo y coincide con el formato de la mayoría de frameworks y datasets.


Relacionado: Si quieres entender la arquitectura que reemplazó a las RNN en casi todo el NLP moderno, lee Transformadores: qué son, cómo funcionan y por qué lo cambiaron todo.

Sobre el autor

Sergio Perea — Fundador & Editor en Eurekadev
Sergio Perea· Fundador & Editor

Apasionado por la tecnología y la innovación, con más de 20 años de experiencia en desarrollo de software y consultoría tecnológica. Su trayectoria profesional comenzó en 2001 como programador, evolucionando desde entonces combinando su amor por el código con una sólida visión de negocio.

Ha trabajado tanto en España como en el extranjero, en sectores diversos como telecomunicaciones, banca, seguros y marketing digital. Esta experiencia multidisciplinar le permite entender los retos técnicos desde una perspectiva de negocio real.

Hoy aporta su experiencia asesorando en la modernización de procesos y la implementación de herramientas tecnológicas que optimizan la gestión y las relaciones con clientes. Se especializa en ayudar a equipos a integrar inteligencia artificial de forma práctica y responsable.

Cree firmemente en el aprendizaje continuo y que el verdadero progreso solo se logra creciendo juntos.