25. Introducción a redes recurrentes (RNN)

25.1 Introducción

Hasta ahora hemos trabajado con MLP para datos tabulares y con CNN para datos con estructura espacial, como las imágenes.

Pero existe otro tipo de dato muy importante en inteligencia artificial: los datos que tienen orden o que llegan como una secuencia.

Ejemplos típicos son palabras en una oración, valores de una serie temporal, eventos en el tiempo o notas musicales. Para este tipo de situaciones aparecieron las redes recurrentes, o RNN (Recurrent Neural Networks).

25.2 Por qué el orden importa

En muchos problemas, no basta con conocer qué valores aparecen: también importa en qué orden aparecen.

Por ejemplo, en lenguaje natural no significa lo mismo:

"el perro persigue al gato"
"el gato persigue al perro"

Las palabras son casi las mismas, pero el orden cambia completamente el significado.

25.3 Qué problema intentan resolver las RNN

Las RNN intentan procesar secuencias manteniendo información de pasos anteriores.

La idea básica es que la salida o el estado del paso actual no depende solo del dato actual, sino también de lo que la red “recuerda” de pasos anteriores.

Esa memoria es la característica distintiva de las redes recurrentes.

25.4 La intuición central: leer paso a paso

Un MLP suele recibir toda la entrada como un bloque fijo. Una RNN, en cambio, puede pensarse como una red que avanza elemento por elemento a través de la secuencia.

En cada paso recibe un nuevo dato y actualiza un estado interno.

Ese estado interno funciona como una especie de resumen de lo que la red ha visto hasta ese momento.

25.5 Qué es el estado oculto

En una RNN aparece una variable muy importante llamada estado oculto o hidden state.

Ese estado se actualiza en cada paso de la secuencia y transporta información desde un instante al siguiente.

Gracias a él, la red puede incorporar contexto y no tratar cada elemento como si estuviera aislado.

25.6 Una forma simple de visualizar una RNN

Podemos pensar una RNN así:

entrada_t + estado_anterior -> nuevo_estado -> salida_t

Esto ocurre para cada posición de la secuencia.

Por eso, en lugar de una sola transformación fija, la red va recorriendo el tiempo o el orden de los datos.

25.7 Un ejemplo intuitivo

Imagina que queremos leer una secuencia de temperaturas de varios días para decidir si la tendencia general es ascendente o descendente.

Una RNN puede procesar un día a la vez y mantener una representación interna de cómo viene evolucionando la serie.

Esa es precisamente la clase de problema donde el concepto de estado oculto resulta natural.

25.8 Diferencia con un MLP

Un MLP no tiene una memoria explícita del orden temporal. Si queremos usar un MLP con secuencias, tendríamos que transformar la secuencia en un vector fijo y esperar que la red aprenda a interpretarlo.

Una RNN, en cambio, fue pensada directamente para trabajar con secuencias y para mantener un estado entre pasos.

Eso la hace conceptualmente más adecuada para problemas donde el contexto importa.

25.9 Tipos de tareas con RNN

Las RNN pueden aparecer en distintos tipos de tareas:

  • Secuencia a etiqueta: por ejemplo, clasificar una serie completa.
  • Secuencia a secuencia: por ejemplo, traducir o etiquetar cada paso.
  • Predicción del siguiente valor en una secuencia.

La forma exacta de la salida depende del problema específico.

25.10 Unrolling o despliegue en el tiempo

Cuando se explica una RNN, muchas veces se dibuja como si la misma red estuviera repetida varias veces a lo largo del tiempo.

Eso se llama desplegarla en el tiempo o unrolling.

En realidad, no son redes distintas: es la misma celda recurrente aplicada sucesivamente a distintos pasos de la secuencia.

25.11 La idea de compartir parámetros

Una característica importante de las RNN es que usan los mismos parámetros en todos los pasos de la secuencia.

Es decir, no se aprende un conjunto de pesos distinto para cada posición temporal.

Esto permite tratar secuencias de longitud variable y mantener una lógica coherente a lo largo del tiempo.

25.12 Ventaja principal de una RNN

La principal ventaja de una RNN es su capacidad para incorporar contexto secuencial.

En problemas donde el orden importa, esto es una diferencia clave frente a arquitecturas pensadas para entradas fijas sin noción temporal.

Por eso fueron muy importantes en tareas de lenguaje, audio y series temporales.

25.13 Limitaciones de las RNN simples

Las RNN básicas también tienen limitaciones. Una de las más conocidas es que pueden tener dificultades para mantener dependencias de muy largo alcance.

En la práctica, esto significa que a veces les cuesta conservar información importante cuando la secuencia es larga.

Más adelante, esta dificultad llevó al desarrollo de variantes como LSTM y GRU.

25.14 Backpropagation through time

El entrenamiento de una RNN se basa en una idea relacionada con backpropagation, pero adaptada a la estructura temporal.

Esta técnica se suele llamar backpropagation through time.

La idea general es que el error se propaga hacia atrás no solo a través de capas, sino también a través de los pasos temporales desplegados.

25.15 RNN en PyTorch

PyTorch ya implementa capas recurrentes listas para usar, como nn.RNN, nn.LSTM y nn.GRU.

En esta introducción nos concentraremos en la forma más simple, usando nn.RNN.

La idea es aprender primero el mecanismo básico antes de pasar a variantes más avanzadas.

25.16 Qué forma tiene la entrada en una RNN

Este punto es muy importante: una RNN no recibe simplemente un vector plano, sino una estructura que representa lotes y secuencias.

Cuando usamos batch_first=True, la entrada suele tener forma:

[batch_size, longitud_secuencia, cantidad_de_features]

Esto significa: varios ejemplos, cada uno con varios pasos, y cada paso con ciertas características.

25.17 Salida de una RNN

Una RNN en PyTorch suele devolver dos cosas:

  • La secuencia de salidas para cada paso.
  • El último estado oculto.

En muchos problemas de clasificación de secuencias completas, se usa el último estado oculto como resumen final de toda la secuencia.

25.18 Un ejemplo de definición

Una capa recurrente simple podría declararse así:

self.rnn = nn.RNN(input_size=1, hidden_size=16, batch_first=True)

Aquí estamos diciendo que cada paso de la secuencia tiene 1 característica y que el estado oculto tendrá tamaño 16.

25.19 Qué significa hidden_size

El parámetro hidden_size indica cuánta capacidad tendrá el estado interno de la RNN.

Un estado oculto más grande puede representar patrones más complejos, aunque también implica más parámetros.

Por eso, igual que en otras arquitecturas, aquí también aparecen decisiones de capacidad y de hiperparámetros.

25.20 Una salida final para clasificar

Si usamos una RNN para clasificar secuencias completas, una estrategia habitual es:

  1. Procesar toda la secuencia con la RNN.
  2. Tomar el último estado oculto.
  3. Pasarlo por una capa lineal final.

Ese último paso transforma el resumen de la secuencia en una predicción concreta.

25.21 Qué problema usaremos al final

Para que la idea sea clara y no depender de datasets externos pesados, en la aplicación final construiremos un problema sintético.

La tarea será clasificar pequeñas secuencias numéricas en dos grupos:

  • Secuencias con tendencia ascendente.
  • Secuencias con tendencia descendente.

Así podremos ver con claridad cómo la RNN utiliza el orden de los datos.

25.22 Por qué este ejemplo es didáctico

Si usáramos un ejemplo demasiado complejo desde el principio, se mezclarían demasiadas ideas a la vez.

En cambio, con secuencias simples numéricas es mucho más fácil entender que la red está procesando paso a paso y construyendo una representación temporal.

El objetivo aquí es fijar el concepto de recurrencia, no impresionar con un benchmark complicado.

25.23 Código completo para ejecutar

En este tema también conviene trabajar con dos versiones de la aplicación.

La primera versión contiene solo las partes esenciales de una red recurrente: generación de secuencias, definición del modelo, entrenamiento y predicción.

La segunda versión implementa una interfaz visual más atractiva con Tkinter, donde el usuario puede cargar o escribir una secuencia, visualizar su forma y pedir la predicción del modelo.

Versión 1: código esencial para entender una RNN

import torch
import torch.nn as nn
import torch.optim as optim

# Fijamos semilla para repetir el experimento.
torch.manual_seed(25)

def generar_secuencia_ascendente(longitud):
    inicio = torch.rand(1).item()
    pasos = torch.linspace(0.0, 1.0, steps=longitud)
    secuencia = inicio + pasos + 0.10 * torch.randn(longitud)
    return secuencia.unsqueeze(1)

def generar_secuencia_descendente(longitud):
    inicio = 1.0 + torch.rand(1).item()
    pasos = torch.linspace(0.0, 1.0, steps=longitud)
    secuencia = inicio - pasos + 0.10 * torch.randn(longitud)
    return secuencia.unsqueeze(1)

def generar_dataset(n_por_clase, longitud):
    secuencias = []
    etiquetas = []

    for _ in range(n_por_clase):
        secuencias.append(generar_secuencia_ascendente(longitud))
        etiquetas.append(1.0)

        secuencias.append(generar_secuencia_descendente(longitud))
        etiquetas.append(0.0)

    X = torch.stack(secuencias)  # forma: [N, longitud, 1]
    y = torch.tensor(etiquetas, dtype=torch.float32).unsqueeze(1)
    return X, y

# Creamos entrenamiento y validacion.
longitud_secuencia = 12
X_train, y_train = generar_dataset(120, longitud_secuencia)
X_val, y_val = generar_dataset(60, longitud_secuencia)

class ClasificadorRNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.rnn = nn.RNN(input_size=1, hidden_size=16, batch_first=True)
        self.fc = nn.Linear(16, 1)

    def forward(self, x):
        # salida_rnn tiene la salida en cada paso temporal.
        # h_n contiene el ultimo estado oculto.
        salida_rnn, h_n = self.rnn(x)

        # Tomamos el ultimo estado oculto y lo pasamos por una capa final.
        ultimo_estado = h_n[-1]
        salida = self.fc(ultimo_estado)
        return salida

def accuracy_desde_logits(logits, y_real):
    probs = torch.sigmoid(logits)
    pred = (probs >= 0.5).float()
    return (pred == y_real).float().mean().item()

modelo = ClasificadorRNN()
criterio = nn.BCEWithLogitsLoss()
optimizador = optim.Adam(modelo.parameters(), lr=0.01)

for epoca in range(150):
    modelo.train()
    logits_train = modelo(X_train)
    loss_train = criterio(logits_train, y_train)

    optimizador.zero_grad()
    loss_train.backward()
    optimizador.step()

    if (epoca + 1) % 25 == 0:
        modelo.eval()
        with torch.no_grad():
            logits_train_eval = modelo(X_train)
            logits_val = modelo(X_val)
            train_loss = criterio(logits_train_eval, y_train).item()
            val_loss = criterio(logits_val, y_val).item()
            train_acc = accuracy_desde_logits(logits_train_eval, y_train)
            val_acc = accuracy_desde_logits(logits_val, y_val)
        print(f"Epoca {epoca+1:3d} | train loss={train_loss:.4f} | val loss={val_loss:.4f} | train acc={train_acc:.3f} | val acc={val_acc:.3f}")

modelo.eval()
with torch.no_grad():
    logits_train = modelo(X_train)
    logits_val = modelo(X_val)
    train_loss = criterio(logits_train, y_train).item()
    val_loss = criterio(logits_val, y_val).item()
    train_acc = accuracy_desde_logits(logits_train, y_train)
    val_acc = accuracy_desde_logits(logits_val, y_val)

print()
print("RESUMEN FINAL")
print(f"train loss={train_loss:.4f} | val loss={val_loss:.4f}")
print(f"train acc={train_acc:.3f} | val acc={val_acc:.3f}")

# Probamos con secuencias nuevas.
ejemplos_nuevos = torch.stack([
    generar_secuencia_ascendente(longitud_secuencia),
    generar_secuencia_descendente(longitud_secuencia),
    generar_secuencia_ascendente(longitud_secuencia)
])

print()
print("SECUENCIAS NUEVAS (paso a paso):")

for i, seq in enumerate(ejemplos_nuevos):
    print(f"\nSecuencia {i+1}:")
    for t, valor in enumerate(seq):
        print(f"t={t:2d} -> {valor.item():.3f}")

with torch.no_grad():
    probs = torch.sigmoid(modelo(ejemplos_nuevos))
    preds = (probs >= 0.5).float()
    print()
    print("Probabilidades para secuencias nuevas:")
    print(probs)
    print("Predicciones finales:")
    print(preds)

Esta primera versión es la recomendable para concentrarse en el funcionamiento interno de la RNN sin distracciones de interfaz.

Versión 2: aplicación visual con interfaz en Tkinter

Si además queremos una aplicación más atractiva, podemos usar una interfaz gráfica que muestre el entrenamiento, permita ingresar una secuencia manualmente, generar ejemplos automáticos y visualizar la serie en un gráfico.

Aplicacion visual para clasificar secuencias con una RNN
import tkinter as tk
from tkinter import ttk, messagebox
import torch
import torch.nn as nn
import torch.optim as optim
import random

# =========================================================
# CONFIGURACIÓN GENERAL
# =========================================================
torch.manual_seed(25)
random.seed(25)
torch.set_printoptions(precision=3, sci_mode=False)

LONGITUD_SECUENCIA = 12
N_ENTRENAMIENTO = 120
N_VALIDACION = 60
TOTAL_EPOCAS = 150

COLOR_FONDO = "#0f172a"
COLOR_PANEL = "#1e293b"
COLOR_PANEL2 = "#334155"
COLOR_TEXTO = "#e2e8f0"
COLOR_SUBTEXTO = "#94a3b8"
COLOR_BOTON = "#2563eb"
COLOR_BOTON2 = "#0ea5e9"
COLOR_OK = "#22c55e"
COLOR_ALERTA = "#f59e0b"
COLOR_GRAFICO = "#38bdf8"
COLOR_PUNTO = "#f97316"

# =========================================================
# GENERACIÓN DE DATOS
# =========================================================
def generar_secuencia_ascendente(longitud):
    inicio = torch.rand(1).item()
    pasos = torch.linspace(0.0, 1.0, steps=longitud)
    secuencia = inicio + pasos + 0.10 * torch.randn(longitud)
    return secuencia.unsqueeze(1)

def generar_secuencia_descendente(longitud):
    inicio = 1.0 + torch.rand(1).item()
    pasos = torch.linspace(0.0, 1.0, steps=longitud)
    secuencia = inicio - pasos + 0.10 * torch.randn(longitud)
    return secuencia.unsqueeze(1)

def generar_dataset(n_por_clase, longitud):
    secuencias = []
    etiquetas = []

    for _ in range(n_por_clase):
        secuencias.append(generar_secuencia_ascendente(longitud))
        etiquetas.append(1.0)

        secuencias.append(generar_secuencia_descendente(longitud))
        etiquetas.append(0.0)

    X = torch.stack(secuencias)  # [N, longitud, 1]
    y = torch.tensor(etiquetas, dtype=torch.float32).unsqueeze(1)
    return X, y

# =========================================================
# MODELO RNN
# =========================================================
class ClasificadorRNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.rnn = nn.RNN(input_size=1, hidden_size=16, batch_first=True)
        self.fc = nn.Linear(16, 1)

    def forward(self, x):
        salida_rnn, h_n = self.rnn(x)
        ultimo_estado = h_n[-1]
        salida = self.fc(ultimo_estado)
        return salida

def accuracy_desde_logits(logits, y_real):
    probs = torch.sigmoid(logits)
    pred = (probs >= 0.5).float()
    return (pred == y_real).float().mean().item()

# =========================================================
# APLICACIÓN
# =========================================================
class AplicacionRNN:
    def __init__(self, root):
        self.root = root
        self.root.title("Clasificador de Secuencias con RNN")
        self.root.geometry("1180x760")
        self.root.configure(bg=COLOR_FONDO)

        self.X_train = None
        self.y_train = None
        self.X_val = None
        self.y_val = None

        self.modelo = None
        self.criterio = None
        self.optimizador = None
        self.epoca_actual = 0

        self.valores = [round(0.5 + i * 0.05, 2) for i in range(LONGITUD_SECUENCIA)]
        self.entries = []

        self.crear_estilos()
        self.crear_interfaz()
        self.preparar_entrenamiento()

        self.root.after(400, self.entrenar_paso)

    # -----------------------------------------------------
    # ESTILOS
    # -----------------------------------------------------
    def crear_estilos(self):
        estilo = ttk.Style()
        estilo.theme_use("clam")

        estilo.configure(
            "Horizontal.TProgressbar",
            troughcolor="#172033",
            background=COLOR_OK,
            bordercolor="#172033",
            lightcolor=COLOR_OK,
            darkcolor=COLOR_OK
        )

    # -----------------------------------------------------
    # INTERFAZ
    # -----------------------------------------------------
    def crear_interfaz(self):
        contenedor = tk.Frame(self.root, bg=COLOR_FONDO)
        contenedor.pack(fill="both", expand=True, padx=12, pady=12)

        # TÍTULO
        titulo = tk.Label(
            contenedor,
            text="Red Recurrente (RNN) para reconocer secuencias",
            bg=COLOR_FONDO,
            fg=COLOR_TEXTO,
            font=("Arial", 24, "bold")
        )
        titulo.pack(pady=(0, 6))

        subtitulo = tk.Label(
            contenedor,
            text="La red aprenderá a clasificar si una secuencia es ascendente o descendente",
            bg=COLOR_FONDO,
            fg=COLOR_SUBTEXTO,
            font=("Arial", 12)
        )
        subtitulo.pack(pady=(0, 12))

        # ZONA SUPERIOR
        zona_superior = tk.Frame(contenedor, bg=COLOR_FONDO)
        zona_superior.pack(fill="x", pady=(0, 12))

        # PANEL ENTRENAMIENTO
        panel_entrenamiento = tk.Frame(
            zona_superior,
            bg=COLOR_PANEL,
            bd=0,
            highlightthickness=1,
            highlightbackground="#475569"
        )
        panel_entrenamiento.pack(side="left", fill="both", expand=True, padx=(0, 6))

        tk.Label(
            panel_entrenamiento,
            text="Entrenamiento de la RNN",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Arial", 16, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 6))

        self.label_estado = tk.Label(
            panel_entrenamiento,
            text="Preparando datos...",
            bg=COLOR_PANEL,
            fg=COLOR_SUBTEXTO,
            font=("Arial", 11)
        )
        self.label_estado.pack(anchor="w", padx=14, pady=(0, 8))

        self.barra = ttk.Progressbar(
            panel_entrenamiento,
            orient="horizontal",
            length=420,
            mode="determinate",
            maximum=TOTAL_EPOCAS
        )
        self.barra.pack(fill="x", padx=14, pady=(0, 8))

        self.label_porcentaje = tk.Label(
            panel_entrenamiento,
            text="0%",
            bg=COLOR_PANEL,
            fg=COLOR_OK,
            font=("Arial", 12, "bold")
        )
        self.label_porcentaje.pack(anchor="w", padx=14, pady=(0, 8))

        self.label_metricas = tk.Label(
            panel_entrenamiento,
            text="Métricas aún no disponibles",
            justify="left",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Consolas", 12)
        )
        self.label_metricas.pack(anchor="w", padx=14, pady=(0, 14))

        # PANEL RESULTADO
        panel_resultado = tk.Frame(
            zona_superior,
            bg=COLOR_PANEL,
            bd=0,
            highlightthickness=1,
            highlightbackground="#475569"
        )
        panel_resultado.pack(side="left", fill="both", expand=True, padx=(6, 0))

        tk.Label(
            panel_resultado,
            text="Resultado de predicción",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Arial", 16, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 6))

        self.label_clase = tk.Label(
            panel_resultado,
            text="Esperando fin del entrenamiento...",
            bg=COLOR_PANEL,
            fg=COLOR_ALERTA,
            font=("Arial", 18, "bold")
        )
        self.label_clase.pack(anchor="w", padx=14, pady=(0, 10))

        self.label_probs = tk.Label(
            panel_resultado,
            text="Ascendente: --\nDescendente: --",
            justify="left",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Consolas", 14)
        )
        self.label_probs.pack(anchor="w", padx=14, pady=(0, 10))

        self.label_explicacion = tk.Label(
            panel_resultado,
            text="La RNN observa los valores en orden temporal.\nEl último estado oculto resume la secuencia.",
            justify="left",
            bg=COLOR_PANEL,
            fg=COLOR_SUBTEXTO,
            font=("Arial", 11)
        )
        self.label_explicacion.pack(anchor="w", padx=14, pady=(0, 14))

        # ZONA CENTRAL
        zona_central = tk.Frame(contenedor, bg=COLOR_FONDO)
        zona_central.pack(fill="both", expand=True)

        # PANEL IZQUIERDO: entradas
        panel_izq = tk.Frame(
            zona_central,
            bg=COLOR_PANEL,
            highlightthickness=1,
            highlightbackground="#475569"
        )
        panel_izq.pack(side="left", fill="both", expand=True, padx=(0, 6))

        tk.Label(
            panel_izq,
            text="Ingresar secuencia",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Arial", 16, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 6))

        tk.Label(
            panel_izq,
            text="Podés escribir los 12 valores o generar ejemplos automáticos.",
            bg=COLOR_PANEL,
            fg=COLOR_SUBTEXTO,
            font=("Arial", 11)
        ).pack(anchor="w", padx=14, pady=(0, 10))

        marco_entries = tk.Frame(panel_izq, bg=COLOR_PANEL)
        marco_entries.pack(padx=14, pady=(0, 10))

        for i in range(LONGITUD_SECUENCIA):
            sub = tk.Frame(marco_entries, bg=COLOR_PANEL)
            sub.grid(row=i // 4, column=i % 4, padx=6, pady=6)

            tk.Label(
                sub,
                text=f"t{i}",
                bg=COLOR_PANEL,
                fg=COLOR_SUBTEXTO,
                font=("Arial", 10, "bold")
            ).pack()

            entry = tk.Entry(
                sub,
                width=8,
                justify="center",
                font=("Arial", 12),
                bg="#e2e8f0",
                fg="#0f172a"
            )
            entry.pack(pady=(4, 0))
            entry.insert(0, str(self.valores[i]))
            self.entries.append(entry)

        marco_botones = tk.Frame(panel_izq, bg=COLOR_PANEL)
        marco_botones.pack(padx=14, pady=10, anchor="w")

        self.boton_predecir = tk.Button(
            marco_botones,
            text="Predecir secuencia",
            command=self.predecir,
            bg=COLOR_BOTON,
            fg="white",
            activebackground="#1d4ed8",
            activeforeground="white",
            font=("Arial", 11, "bold"),
            relief="flat",
            padx=16,
            pady=8,
            state="disabled",
            cursor="hand2"
        )
        self.boton_predecir.grid(row=0, column=0, padx=5, pady=5)

        self.boton_limpiar = tk.Button(
            marco_botones,
            text="Limpiar",
            command=self.limpiar,
            bg=COLOR_PANEL2,
            fg="white",
            activebackground="#475569",
            activeforeground="white",
            font=("Arial", 11, "bold"),
            relief="flat",
            padx=16,
            pady=8,
            state="disabled",
            cursor="hand2"
        )
        self.boton_limpiar.grid(row=0, column=1, padx=5, pady=5)

        self.boton_asc = tk.Button(
            marco_botones,
            text="Generar ascendente",
            command=self.cargar_ejemplo_ascendente,
            bg=COLOR_BOTON2,
            fg="white",
            activebackground="#0284c7",
            activeforeground="white",
            font=("Arial", 11, "bold"),
            relief="flat",
            padx=16,
            pady=8,
            state="disabled",
            cursor="hand2"
        )
        self.boton_asc.grid(row=1, column=0, padx=5, pady=5)

        self.boton_desc = tk.Button(
            marco_botones,
            text="Generar descendente",
            command=self.cargar_ejemplo_descendente,
            bg=COLOR_BOTON2,
            fg="white",
            activebackground="#0284c7",
            activeforeground="white",
            font=("Arial", 11, "bold"),
            relief="flat",
            padx=16,
            pady=8,
            state="disabled",
            cursor="hand2"
        )
        self.boton_desc.grid(row=1, column=1, padx=5, pady=5)

        self.boton_ruido = tk.Button(
            marco_botones,
            text="Generar aleatoria",
            command=self.cargar_ejemplo_aleatorio,
            bg="#7c3aed",
            fg="white",
            activebackground="#6d28d9",
            activeforeground="white",
            font=("Arial", 11, "bold"),
            relief="flat",
            padx=16,
            pady=8,
            state="disabled",
            cursor="hand2"
        )
        self.boton_ruido.grid(row=1, column=2, padx=5, pady=5)

        # PANEL DERECHO: gráfico
        panel_der = tk.Frame(
            zona_central,
            bg=COLOR_PANEL,
            highlightthickness=1,
            highlightbackground="#475569"
        )
        panel_der.pack(side="left", fill="both", expand=True, padx=(6, 0))

        tk.Label(
            panel_der,
            text="Visualización de la secuencia",
            bg=COLOR_PANEL,
            fg=COLOR_TEXTO,
            font=("Arial", 16, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 6))

        self.canvas = tk.Canvas(
            panel_der,
            width=500,
            height=320,
            bg="#0b1220",
            highlightthickness=1,
            highlightbackground="#334155"
        )
        self.canvas.pack(padx=14, pady=(0, 12))

        self.label_detalle = tk.Label(
            panel_der,
            text="La línea azul representa la evolución temporal de los datos.",
            bg=COLOR_PANEL,
            fg=COLOR_SUBTEXTO,
            font=("Arial", 11)
        )
        self.label_detalle.pack(anchor="w", padx=14, pady=(0, 10))

        self.dibujar_secuencia_en_canvas(self.obtener_valores_entries())

    # -----------------------------------------------------
    # ENTRENAMIENTO
    # -----------------------------------------------------
    def preparar_entrenamiento(self):
        self.label_estado.config(text="Generando secuencias de entrenamiento y validación...")
        self.X_train, self.y_train = generar_dataset(N_ENTRENAMIENTO, LONGITUD_SECUENCIA)
        self.X_val, self.y_val = generar_dataset(N_VALIDACION, LONGITUD_SECUENCIA)

        self.modelo = ClasificadorRNN()
        self.criterio = nn.BCEWithLogitsLoss()
        self.optimizador = optim.Adam(self.modelo.parameters(), lr=0.01)

    def entrenar_paso(self):
        if self.epoca_actual >= TOTAL_EPOCAS:
            self.finalizar_entrenamiento()
            return

        self.modelo.train()

        logits_train = self.modelo(self.X_train)
        loss_train = self.criterio(logits_train, self.y_train)

        self.optimizador.zero_grad()
        loss_train.backward()
        self.optimizador.step()

        self.epoca_actual += 1
        self.barra["value"] = self.epoca_actual

        porcentaje = int((self.epoca_actual / TOTAL_EPOCAS) * 100)
        self.label_porcentaje.config(text=f"{porcentaje}%")
        self.label_estado.config(text=f"Entrenando época {self.epoca_actual} de {TOTAL_EPOCAS}")

        if self.epoca_actual % 10 == 0 or self.epoca_actual == 1:
            self.modelo.eval()
            with torch.no_grad():
                logits_train_eval = self.modelo(self.X_train)
                logits_val = self.modelo(self.X_val)

                train_loss = self.criterio(logits_train_eval, self.y_train).item()
                val_loss = self.criterio(logits_val, self.y_val).item()
                train_acc = accuracy_desde_logits(logits_train_eval, self.y_train)
                val_acc = accuracy_desde_logits(logits_val, self.y_val)

            self.label_metricas.config(
                text=(
                    f"Época:      {self.epoca_actual:3d}/{TOTAL_EPOCAS}\n"
                    f"Train loss: {train_loss:.4f}\n"
                    f"Val loss:   {val_loss:.4f}\n"
                    f"Train acc:  {train_acc:.3f}\n"
                    f"Val acc:    {val_acc:.3f}"
                )
            )

        self.root.after(25, self.entrenar_paso)

    def finalizar_entrenamiento(self):
        self.modelo.eval()

        with torch.no_grad():
            logits_train = self.modelo(self.X_train)
            logits_val = self.modelo(self.X_val)

            train_loss = self.criterio(logits_train, self.y_train).item()
            val_loss = self.criterio(logits_val, self.y_val).item()
            train_acc = accuracy_desde_logits(logits_train, self.y_train)
            val_acc = accuracy_desde_logits(logits_val, self.y_val)

        self.label_estado.config(text="Entrenamiento finalizado")
        self.label_porcentaje.config(text="100%")
        self.label_metricas.config(
            text=(
                f"FINAL\n"
                f"Train loss: {train_loss:.4f}\n"
                f"Val loss:   {val_loss:.4f}\n"
                f"Train acc:  {train_acc:.3f}\n"
                f"Val acc:    {val_acc:.3f}"
            )
        )

        self.label_clase.config(text="Modelo listo", fg=COLOR_OK)
        self.label_probs.config(text="Ascendente: --\nDescendente: --")

        self.boton_predecir.config(state="normal")
        self.boton_limpiar.config(state="normal")
        self.boton_asc.config(state="normal")
        self.boton_desc.config(state="normal")
        self.boton_ruido.config(state="normal")

    # -----------------------------------------------------
    # UTILIDADES DE ENTRADA
    # -----------------------------------------------------
    def obtener_valores_entries(self):
        valores = []
        for entry in self.entries:
            texto = entry.get().strip().replace(",", ".")
            if texto == "":
                valores.append(0.0)
            else:
                valores.append(float(texto))
        return valores

    def setear_valores_entries(self, valores):
        for i, v in enumerate(valores):
            self.entries[i].delete(0, tk.END)
            self.entries[i].insert(0, f"{v:.3f}")
        self.dibujar_secuencia_en_canvas(valores)

    def limpiar(self):
        ceros = [0.0] * LONGITUD_SECUENCIA
        self.setear_valores_entries(ceros)
        self.label_clase.config(text="Secuencia limpiada", fg=COLOR_ALERTA)
        self.label_probs.config(text="Ascendente: --\nDescendente: --")

    def cargar_ejemplo_ascendente(self):
        seq = generar_secuencia_ascendente(LONGITUD_SECUENCIA).squeeze().tolist()
        self.setear_valores_entries(seq)

    def cargar_ejemplo_descendente(self):
        seq = generar_secuencia_descendente(LONGITUD_SECUENCIA).squeeze().tolist()
        self.setear_valores_entries(seq)

    def cargar_ejemplo_aleatorio(self):
        seq = [random.uniform(0.0, 2.0) for _ in range(LONGITUD_SECUENCIA)]
        self.setear_valores_entries(seq)

    # -----------------------------------------------------
    # PREDICCIÓN
    # -----------------------------------------------------
    def predecir(self):
        try:
            valores = self.obtener_valores_entries()
        except ValueError:
            messagebox.showerror("Error", "Todos los valores deben ser numéricos.")
            return

        self.dibujar_secuencia_en_canvas(valores)

        entrada = torch.tensor(valores, dtype=torch.float32).view(1, LONGITUD_SECUENCIA, 1)

        with torch.no_grad():
            logit = self.modelo(entrada)
            prob_asc = torch.sigmoid(logit).item()
            prob_desc = 1.0 - prob_asc

        if prob_asc >= 0.5:
            clase = "ASCENDENTE"
            color = COLOR_OK
        else:
            clase = "DESCENDENTE"
            color = "#f43f5e"

        self.label_clase.config(text=f"Predicción: {clase}", fg=color)
        self.label_probs.config(
            text=(
                f"Ascendente : {prob_asc:.3f}\n"
                f"Descendente: {prob_desc:.3f}"
            )
        )

    # -----------------------------------------------------
    # DIBUJO EN CANVAS
    # -----------------------------------------------------
    def dibujar_secuencia_en_canvas(self, valores):
        self.canvas.delete("all")

        ancho = 500
        alto = 320
        margen_izq = 45
        margen_der = 20
        margen_sup = 20
        margen_inf = 35

        # Ejes
        self.canvas.create_line(margen_izq, alto - margen_inf, ancho - margen_der, alto - margen_inf, fill="#64748b", width=2)
        self.canvas.create_line(margen_izq, margen_sup, margen_izq, alto - margen_inf, fill="#64748b", width=2)

        # Si todos son iguales, evitar división por cero
        min_v = min(valores)
        max_v = max(valores)
        if abs(max_v - min_v) < 1e-6:
            max_v = min_v + 1.0

        # Líneas guía horizontales
        for i in range(5):
            y = margen_sup + i * ((alto - margen_sup - margen_inf) / 4)
            self.canvas.create_line(margen_izq, y, ancho - margen_der, y, fill="#1e293b", dash=(3, 3))

        # Etiquetas Y
        for i in range(5):
            valor_y = max_v - i * ((max_v - min_v) / 4)
            y = margen_sup + i * ((alto - margen_sup - margen_inf) / 4)
            self.canvas.create_text(20, y, text=f"{valor_y:.2f}", fill="#94a3b8", font=("Arial", 9))

        # Etiquetas X
        for i in range(LONGITUD_SECUENCIA):
            x = margen_izq + i * ((ancho - margen_izq - margen_der) / (LONGITUD_SECUENCIA - 1))
            self.canvas.create_text(x, alto - 15, text=str(i), fill="#94a3b8", font=("Arial", 9))

        # Convertir puntos
        puntos = []
        for i, v in enumerate(valores):
            x = margen_izq + i * ((ancho - margen_izq - margen_der) / (LONGITUD_SECUENCIA - 1))
            y = margen_sup + (max_v - v) * ((alto - margen_sup - margen_inf) / (max_v - min_v))
            puntos.append((x, y))

        # Línea
        for i in range(len(puntos) - 1):
            self.canvas.create_line(
                puntos[i][0], puntos[i][1],
                puntos[i+1][0], puntos[i+1][1],
                fill=COLOR_GRAFICO,
                width=3,
                smooth=True
            )

        # Puntos
        for i, (x, y) in enumerate(puntos):
            self.canvas.create_oval(x-5, y-5, x+5, y+5, fill=COLOR_PUNTO, outline="")
            self.canvas.create_text(x, y-14, text=f"{valores[i]:.2f}", fill=COLOR_TEXTO, font=("Arial", 8))

# =========================================================
# PROGRAMA PRINCIPAL
# =========================================================
if __name__ == "__main__":
    root = tk.Tk()
    app = AplicacionRNN(root)
    root.mainloop()

De esta forma podemos estudiar primero una versión compacta para entender la red recurrente y, después, trabajar con una versión visual más atractiva que permite cargar secuencias, ver su gráfico y obtener una predicción interactiva.

25.24 Errores comunes al empezar con RNN

  • Olvidar que el orden de la secuencia importa.
  • Confundir la forma esperada de la entrada.
  • No entender qué representa el estado oculto.
  • Usar la salida equivocada de la RNN para la tarea.
  • Esperar que una RNN simple resuelva fácilmente dependencias muy largas.

25.25 Buenas prácticas para estudiantes

Si estás aprendiendo RNN, estas recomendaciones suelen ayudar:

  • Comenzar con secuencias cortas y problemas simples.
  • Revisar siempre la forma de los tensores de entrada y salida.
  • Entender primero la RNN básica antes de pasar a LSTM o GRU.
  • Relacionar el estado oculto con la idea de memoria.
  • No perder de vista qué parte de la secuencia resume el modelo final.

25.26 Qué debes recordar de este tema

  • Las RNN están diseñadas para procesar secuencias donde el orden importa.
  • Su idea central es mantener un estado oculto que transporta información entre pasos.
  • En PyTorch pueden definirse con capas como nn.RNN.
  • La entrada de una RNN tiene una estructura especial que representa batch, secuencia y features.
  • Las RNN simples son una base importante para entender modelos secuenciales más avanzados.

25.27 Cierre conceptual

Las redes recurrentes fueron un paso importante en el desarrollo del Deep Learning para datos secuenciales. Aunque después aparecieron arquitecturas más potentes para ciertas tareas, las RNN siguen siendo una excelente puerta de entrada para entender cómo una red puede incorporar memoria y contexto temporal.

Dominar esta introducción significa empezar a pensar no solo en entradas estáticas, sino también en procesos que evolucionan paso a paso.