🏭 Caso de Uso

Deep Q-Learning desde Cero: Entrenando una IA para Jugar a Snake

Implementación completa desde cero de un agente DQN en PyTorch que aprende a jugar a Snake: entorno como MDP, reward shaping, replay buffer, target network con soft update, política epsilon-greedy con schedule de decaimiento y visualización del agente entrenado.

🐍 Python 📓 Jupyter Notebook

🐍 Deep Q-Learning desde Cero: Entrenando una IA para Jugar a Snake


Objetivos de este notebook

En este caso de uso vamos a implementar desde cero todo el pipeline de Deep Reinforcement Learning (DRL) necesario para que una red neuronal aprenda a jugar al clásico juego de la serpiente. No usaremos librerías de RL de alto nivel: construiremos cada pieza a mano para entender en profundidad cómo funciona.

Al finalizar, habrás implementado y comprendido:

  1. El entorno (Snake) como un Proceso de Decisión de Markov (MDP).
  2. La representación del estado que observa el agente.
  3. El diseño de recompensas (reward shaping): premios, castigos y señales densas.
  4. Una red neuronal (MLP) que aproxima la función Q.
  5. La red objetivo (target network) y su actualización suave (soft update / Polyak averaging).
  6. El buffer de experiencia (experience replay) y el muestreo por mini-batches.
  7. La política epsilon-greedy con schedule de decaimiento (exploración vs. explotación).
  8. El bucle de entrenamiento con episodios, evaluación periódica y métricas.
  9. La visualización del agente entrenado jugando.

Fundamentos Matemáticos y Computacionales del Deep Reinforcement Learning

¿Qué es el Aprendizaje por Refuerzo?

El Reinforcement Learning (RL) es un paradigma de aprendizaje automático donde un agente aprende a tomar decisiones secuenciales interactuando con un entorno. A diferencia del aprendizaje supervisado (donde hay etiquetas correctas) o no supervisado (donde se buscan patrones), en RL el agente aprende por prueba y error, guiado únicamente por señales de recompensa.

El ciclo fundamental es:

$$ \text{Agente} \xrightarrow{a_t} \text{Entorno} \xrightarrow{s_{t+1}, r_t} \text{Agente} $$

En cada paso $t$, el agente observa un estado $s_t$, elige una acción $a_t$, recibe una recompensa $r_t$ y transita a un nuevo estado $s_{t+1}$.

Proceso de Decisión de Markov (MDP)

Formalmente, modelamos el problema como un MDP, definido por la tupla $(S, A, P, R, \gamma)$:

Componente Descripción
$S$ Conjunto de estados posibles del entorno
$A$ Conjunto de acciones disponibles
$P(s' \mid s, a)$ Probabilidad de transición: probabilidad de llegar a $s'$ al tomar acción $a$ en estado $s$
$R(s, a, s')$ Función de recompensa: señal escalar que evalúa la transición
$\gamma \in [0, 1)$ Factor de descuento: pondera la importancia de recompensas futuras

La propiedad de Markov establece que el estado futuro solo depende del estado actual y la acción tomada, no de la historia completa:

$$ P(s_{t+1} \mid s_t, a_t, s_{t-1}, a_{t-1}, \ldots) = P(s_{t+1} \mid s_t, a_t) $$

Episodios y trayectorias

Un episodio es una secuencia completa de interacciones desde un estado inicial hasta un estado terminal:

$$ \tau = (s_0, a_0, r_0, s_1, a_1, r_1, \ldots, s_T) $$

En Snake, un episodio termina cuando la serpiente choca contra una pared, se muerde a sí misma, o agota un límite de pasos. El agente juega miles de episodios durante el entrenamiento.

Retorno acumulado y factor de descuento

El objetivo del agente es maximizar el retorno acumulado descontado:

$$ G_t = \sum_{k=0}^{\infty} \gamma^k , r_{t+k} $$

El factor $\gamma$ controla cuánto le importan al agente las recompensas futuras:

  • $\gamma \approx 0$: el agente es miope, solo le importa la recompensa inmediata.
  • $\gamma \approx 1$: el agente es previsor, planifica a largo plazo.

En la práctica, $\gamma = 0.99$ es un valor habitual. El horizonte efectivo (número de pasos que importan significativamente) es aproximadamente $\frac{1}{1-\gamma}$; con $\gamma=0.99$, esto da ~100 pasos.

Funciones de valor y la función Q

La función de valor de estado $V^\pi(s)$ estima el retorno esperado partiendo de $s$ y siguiendo la política $\pi$:

$$ V^\pi(s) = \mathbb{E}_\pi\left[G_t \mid s_t = s\right] $$

La función de valor de acción (o función Q) $Q^\pi(s, a)$ estima el retorno esperado tomando la acción $a$ en $s$ y después siguiendo $\pi$:

$$ Q^\pi(s, a) = \mathbb{E}_\pi\left[G_t \mid s_t = s, a_t = a\right] $$

La relación entre ambas es:

$$ V^\pi(s) = \sum_a \pi(a \mid s) , Q^\pi(s, a) $$

Ecuación de Bellman

Las funciones de valor satisfacen una relación recursiva fundamental, la ecuación de Bellman:

$$ Q^\pi(s, a) = \mathbb{E}\left[r + \gamma \sum_{a'} \pi(a' \mid s') , Q^\pi(s', a')\right] $$

Para la política óptima $\pi^*$, se simplifica a la ecuación de optimalidad de Bellman:

$$ Q^(s, a) = \mathbb{E}\left[r + \gamma \max_{a'} Q^(s', a')\right] $$

Esta ecuación es el corazón de Q-Learning: si conocemos $Q^$, la política óptima es simplemente elegir $\arg\max_a Q^(s, a)$.

De Q-Learning tabular a Deep Q-Network (DQN)

Q-Learning tabular almacena $Q(s,a)$ en una tabla y la actualiza con:

$$ Q(s_t, a_t) \leftarrow Q(s_t, a_t) + \alpha \left[r_t + \gamma \max_{a'} Q(s_{t+1}, a') - Q(s_t, a_t)\right] $$

donde $\alpha$ es la tasa de aprendizaje y el término entre corchetes es el error TD (temporal-difference error).

Problema: cuando el espacio de estados es grande o continuo, la tabla se vuelve inmanejable (maldición de la dimensionalidad).

Solución — DQN: sustituir la tabla por una red neuronal $Q_\theta(s, a)$ parametrizada por $\theta$, que generaliza a estados no vistos:

$$ \mathcal{L}(\theta) = \mathbb{E}{(s,a,r,s',d) \sim \mathcal{D}} \left[\left(Q\theta(s,a) - y\right)^2\right] $$

donde el objetivo TD (TD target) es:

$$ y = r + \gamma (1 - d) \max_{a'} Q_{\bar{\theta}}(s', a') $$

  • $\theta$: parámetros de la red online (la que aprende).
  • $\bar{\theta}$: parámetros de la red objetivo (target network), que se actualiza periódicamente.
  • $d \in {0, 1}$: indicador de estado terminal (done).
  • $\mathcal{D}$: buffer de experiencia (replay buffer).

Experience Replay (Buffer de experiencia)

El entrenamiento de redes neuronales asume que los datos son i.i.d. (independientes e idénticamente distribuidos). Pero en RL, las transiciones consecutivas están fuertemente correlacionadas (el estado $s_{t+1}$ depende de $s_t$). Esto causa inestabilidad si entrenamos con las transiciones en orden.

Solución: almacenar las transiciones $(s, a, r, s', d)$ en un buffer circular y muestrear mini-batches aleatorios:

  1. Se rompe la correlación temporal.
  2. Cada transición puede reutilizarse múltiples veces (sample efficiency).
  3. La distribución de entrenamiento es más estable.

El tamaño del buffer (ej. 30.000-100.000 transiciones) es un hiperparámetro importante.

Mini-batch training (Batching)

En cada paso de entrenamiento:

  1. Se muestrean $B$ transiciones del buffer (ej. $B = 128$).
  2. Se calcula el TD target $y_i$ para cada transición.
  3. Se computa la pérdida media del batch.
  4. Se hace backpropagation y se actualizan los pesos.

El tamaño del batch afecta al trade-off entre:

  • Estabilidad (batches grandes → gradientes más suaves).
  • Velocidad (batches pequeños → actualizaciones más frecuentes).

Target Network (Red Objetivo)

Si usamos la misma red para calcular las predicciones $Q_\theta(s,a)$ y los objetivos $\max_{a'} Q_\theta(s', a')$, el entrenamiento es inestable: los objetivos "se mueven" con cada actualización de pesos, creando un problema de bootstrapping circular.

Solución: mantener una copia separada de la red (la target network $Q_{\bar{\theta}}$) cuyos pesos se actualizan mucho más lentamente.

Hard update (actualización dura)

Copiar los pesos cada $N$ pasos: $$ \bar{\theta} \leftarrow \theta \quad \text{cada } N \text{ pasos} $$

Soft update — Polyak averaging (actualización suave)

Mezclar suavemente los pesos en cada paso: $$ \bar{\theta} \leftarrow \tau , \theta + (1 - \tau) , \bar{\theta} $$

donde $\tau \in (0, 1)$ es un coeficiente pequeño (típicamente $\tau = 0.005$). Esto produce objetivos más estables y transiciones más suaves que el hard update.

Exploración vs. Explotación

El dilema exploración-explotación es central en RL:

  • Explotar: elegir la mejor acción conocida ($\arg\max_a Q(s,a)$).
  • Explorar: probar acciones subóptimas para descubrir mejores estrategias.

Si el agente solo explota, puede quedarse atrapado en un óptimo local. Si solo explora, nunca aprovecha lo aprendido.

Política Epsilon-Greedy

La estrategia más común es epsilon-greedy:

$$ a_t = \begin{cases} \text{acción aleatoria} & \text{con probabilidad } \varepsilon \ \arg\max_a Q_\theta(s_t, a) & \text{con probabilidad } 1 - \varepsilon \end{cases} $$

Schedule de decaimiento de epsilon

$\varepsilon$ se reduce gradualmente durante el entrenamiento:

  • Inicio: $\varepsilon = 1.0$ (exploración pura, el agente no sabe nada).
  • Decaimiento exponencial: $\varepsilon_{t+1} = \max(\varepsilon_{\min}, ; \varepsilon_t \cdot \delta)$ donde $\delta \approx 0.995$.
  • Final: $\varepsilon_{\min} \approx 0.05$ (siempre mantener algo de exploración).

Esto permite que el agente primero explore ampliamente el espacio de estados y acciones, y gradualmente pase a explotar la política aprendida. El ritmo de decaimiento es crítico: demasiado rápido → el agente converge prematuramente; demasiado lento → desperdicia episodios explorando sin necesidad.

Función de pérdida: Huber Loss

En lugar de MSE estándar, DQN suele usar la Huber Loss (SmoothL1Loss en PyTorch):

$$ L_\delta(a) = \begin{cases} \frac{1}{2}a^2 & \text{si } |a| \leq \delta \ \delta\left(|a| - \frac{1}{2}\delta\right) & \text{en caso contrario} \end{cases} $$

Ventaja: es cuadrática para errores pequeños (convergencia precisa) pero lineal para errores grandes (robusta ante outliers y objetivos TD ruidosos).

Resumen del algoritmo DQN completo

  1. Inicializar red online $Q_\theta$ y red objetivo $Q_{\bar{\theta}}$ con pesos aleatorios iguales.
  2. Inicializar el replay buffer $\mathcal{D}$ vacío.
  3. Para cada episodio:
    • Resetear el entorno, obtener $s_0$.
    • Para cada paso $t$:
      • Seleccionar $a_t$ con política $\varepsilon$-greedy.
      • Ejecutar $a_t$, observar $r_t$, $s_{t+1}$, $d_t$.
      • Almacenar $(s_t, a_t, r_t, s_{t+1}, d_t)$ en $\mathcal{D}$.
      • Muestrear un mini-batch de $\mathcal{D}$.
      • Calcular los TD targets $y_i = r_i + \gamma(1-d_i)\max_{a'}Q_{\bar{\theta}}(s'_i, a')$.
      • Actualizar $\theta$ minimizando $\frac{1}{B}\sum_i L(Q_\theta(s_i, a_i), y_i)$.
      • Actualizar $\bar{\theta}$ (soft o hard update).
    • Decaer $\varepsilon$.

Herramientas y librerías

  • PyTorch: construcción y entrenamiento de la red neuronal.
  • NumPy: manipulación numérica.
  • Matplotlib: visualización de métricas y animación del agente.
  • Sin dataset externo: en RL, los datos se generan interactuando con el entorno.

💡 Nota: como no hay dataset estático, no hacemos EDA clásico; en su lugar, inspeccionaremos el entorno y el espacio de estados/acciones.

[1]

# Configuración general y dependencias
import random
import math
from dataclasses import dataclass
from collections import deque

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
from matplotlib import animation
from IPython.display import HTML

# Semillas para reproducibilidad
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Dispositivo disponible: {device}")
Dispositivo disponible: cuda

1. Diseño del entorno Snake como MDP

El primer paso en cualquier proyecto de RL es definir el entorno como un MDP. En nuestro caso, el juego de Snake tiene las siguientes características:

Espacio de estados $S$

En lugar de dar a la red la cuadrícula completa (lo cual sería costoso y lento de aprender), diseñamos un vector de estado compacto con 12 características que capturan toda la información relevante:

Índice Característica Rango
0 Peligro al frente (colisión inminente) ${0, 1}$
1 Peligro a la izquierda ${0, 1}$
2 Peligro a la derecha ${0, 1}$
3-6 Dirección actual (one-hot: arriba, derecha, abajo, izquierda) ${0, 1}$
7 Comida está arriba ${0, 1}$
8 Comida está abajo ${0, 1}$
9 Comida está a la izquierda ${0, 1}$
10 Comida está a la derecha ${0, 1}$
11 Distancia Manhattan normalizada a la comida $[0, 1]$

Este diseño sigue el principio de que el estado debe ser suficientemente informativo para tomar buenas decisiones, pero no tan grande que dificulte el aprendizaje.

Espacio de acciones $A$

Usamos acciones relativas a la dirección actual: 0 = seguir recto, 1 = girar a la izquierda, 2 = girar a la derecha. Esto simplifica el aprendizaje respecto a acciones absolutas (arriba, abajo, izquierda, derecha), ya que la red solo necesita decidir si girar o no.

Condiciones de terminación (fin del episodio)

El episodio termina si:

  • La serpiente choca contra una pared (sale de la cuadrícula).
  • La serpiente se muerde a sí misma.
  • Se supera un límite de pasos sin comer (evita que el agente deambule en bucles infinitos).

Función de recompensa $R$ (Reward Shaping)

El diseño de recompensas es crucial en RL. Una mala función de recompensa puede hacer que el agente aprenda comportamientos indeseados.

Evento Recompensa Justificación
Comer comida $+10$ Refuerzo principal: el objetivo del juego
Colisión (pared o cuerpo) $-10$ Castigo terminal fuerte: aprender a evitar la muerte
Acercarse a la comida $+0.2$ Señal densa: guía al agente hacia la comida paso a paso
Alejarse de la comida $-0.2$ Penalización densa: desincentiva movimientos subóptimos
Paso sin comer $-0.03$ Penalización por paso: evita vagar sin objetivo
Timeout (muchos pasos sin comer) $-2.0$ Castigo moderado por ineficiencia extrema

Las recompensas densas ($\pm 0.2$ por acercarse/alejarse) son clave: sin ellas, la señal de recompensa sería muy escasa (solo al comer o morir), lo que ralentiza enormemente el aprendizaje. Con ellas, el agente recibe feedback en cada paso sobre si se mueve en la dirección correcta.

[2]

# Entorno Snake desde cero
class SnakeEnv:
    def __init__(self, size=10, max_steps_without_food=120):
        self.size = size
        self.max_steps_without_food = max_steps_without_food
        self.reset()

    def reset(self):
        # Dirección: 0=arriba, 1=derecha, 2=abajo, 3=izquierda
        self.direction = 1
        mid = self.size // 2
        self.snake = [(mid, mid), (mid, mid - 1), (mid, mid - 2)]  # cabeza primero
        self._spawn_food()
        self.done = False
        self.score = 0
        self.steps = 0
        self.steps_since_food = 0
        self.prev_distance = self._distance_to_food(self.snake[0])
        return self._get_state()

    def _spawn_food(self):
        empty = [(r, c) for r in range(self.size) for c in range(self.size) if (r, c) not in self.snake]
        self.food = random.choice(empty)

    @staticmethod
    def _move_point(point, direction):
        r, c = point
        if direction == 0:
            return (r - 1, c)
        if direction == 1:
            return (r, c + 1)
        if direction == 2:
            return (r + 1, c)
        return (r, c - 1)

    def _distance_to_food(self, point):
        return abs(point[0] - self.food[0]) + abs(point[1] - self.food[1])

    def _is_collision(self, point):
        r, c = point
        if r < 0 or r >= self.size or c < 0 or c >= self.size:
            return True
        if point in self.snake:
            return True
        return False

    def _rotate(self, action):
        # action: 0=recto, 1=izquierda, 2=derecha
        if action == 1:
            self.direction = (self.direction - 1) % 4
        elif action == 2:
            self.direction = (self.direction + 1) % 4

    def _danger(self, relative_turn):
        # relative_turn: -1 izquierda, 0 frente, +1 derecha
        test_dir = (self.direction + relative_turn) % 4
        next_head = self._move_point(self.snake[0], test_dir)
        return 1.0 if self._is_collision(next_head) else 0.0

    def _get_state(self):
        head_r, head_c = self.snake[0]
        food_r, food_c = self.food

        state = [
            self._danger(0),   # peligro al frente
            self._danger(-1),  # peligro a la izquierda
            self._danger(+1),  # peligro a la derecha
            1.0 if self.direction == 0 else 0.0,
            1.0 if self.direction == 1 else 0.0,
            1.0 if self.direction == 2 else 0.0,
            1.0 if self.direction == 3 else 0.0,
            1.0 if food_r < head_r else 0.0,   # comida arriba
            1.0 if food_r > head_r else 0.0,   # comida abajo
            1.0 if food_c < head_c else 0.0,   # comida izquierda
            1.0 if food_c > head_c else 0.0,   # comida derecha
            self._distance_to_food(self.snake[0]) / (2 * self.size),
        ]
        return np.array(state, dtype=np.float32)

    def step(self, action):
        if self.done:
            raise RuntimeError("El episodio terminó. Llama a reset().")

        self.steps += 1
        self.steps_since_food += 1

        # 1) Actualizar dirección según acción
        self._rotate(action)

        # 2) Proponer nuevo movimiento
        new_head = self._move_point(self.snake[0], self.direction)

        # 3) Recompensas/castigos (reward shaping)
        reward = 0.0

        # Colisión => castigo fuerte y fin
        if self._is_collision(new_head):
            self.done = True
            reward = -10.0
            return self._get_state(), reward, self.done, {"score": self.score}

        # Avance normal
        self.snake.insert(0, new_head)

        # Comer comida => premio alto
        if new_head == self.food:
            self.score += 1
            reward = 10.0
            self.steps_since_food = 0
            self._spawn_food()
        else:
            # Si no come, elimina cola (movimiento normal)
            self.snake.pop()

            # Pequeña penalización por paso para evitar vagar sin objetivo
            reward -= 0.03

            # Recompensa densa por acercarse a la comida
            new_dist = self._distance_to_food(new_head)
            if new_dist < self.prev_distance:
                reward += 0.2
            else:
                reward -= 0.2
            self.prev_distance = new_dist

        # Timeout sin comer => episodio finalizado (castigo moderado)
        if self.steps_since_food >= self.max_steps_without_food:
            self.done = True
            reward -= 2.0

        return self._get_state(), reward, self.done, {"score": self.score}

2. Inspección del entorno (el "EDA" del Reinforcement Learning)

En aprendizaje supervisado, el primer paso es explorar el dataset (EDA). En RL no hay dataset estático: los datos se generan interactuando con el entorno. Por eso, nuestro "EDA" consiste en:

  • Verificar la dimensión del estado y el espacio de acciones.
  • Observar el rango típico de recompensas con un agente aleatorio.
  • Establecer una línea base: ¿cómo de mal lo hace un agente que actúa al azar? Esto nos servirá para evaluar si nuestro DQN realmente aprende algo.
[3]

# Inspección básica del entorno
env = SnakeEnv(size=10)
state = env.reset()

print("Dimensión del estado:", state.shape)
print("Ejemplo de estado inicial:", state)
print("Acciones disponibles: 0=recto, 1=izquierda, 2=derecha")

# Simulación aleatoria corta para observar recompensas
episode_rewards = []
for ep in range(5):
    s = env.reset()
    done = False
    total_r = 0
    while not done:
        a = random.randint(0, 2)
        s, r, done, info = env.step(a)
        total_r += r
    episode_rewards.append(total_r)

print("Recompensas totales (política aleatoria, 5 episodios):", np.round(episode_rewards, 2))
Dimensión del estado: (12,)
Ejemplo de estado inicial: [0.   0.   0.   0.   1.   0.   0.   1.   0.   1.   0.   0.25]
Acciones disponibles: 0=recto, 1=izquierda, 2=derecha
Recompensas totales (política aleatoria, 5 episodios): [-11.5  -11.15 -10.93 -12.35 -12.35]
[4]

# Visualización rápida de recompensas aleatorias
plt.figure(figsize=(7, 4))
plt.plot(episode_rewards, marker='o')
plt.title('Recompensa total por episodio (agente aleatorio)')
plt.xlabel('Episodio')
plt.ylabel('Recompensa total')
plt.grid(True, alpha=0.3)
plt.show()
Output

3. Agente DQN: Arquitectura y Componentes

Ahora construiremos las tres piezas fundamentales del agente DQN:

3.1 Replay Buffer

Una estructura de datos FIFO (cola circular) que almacena transiciones $(s, a, r, s', done)$:

Buffer: [(s₁,a₁,r₁,s'₁,d₁), (s₂,a₂,r₂,s'₂,d₂), ..., (sₙ,aₙ,rₙ,s'ₙ,dₙ)]
                                    ↓ sample(batch_size)
Mini-batch aleatorio de B transiciones → entrenamiento
  • Capacidad: 30.000 transiciones. Si se llena, las más antiguas se descartan.
  • Muestreo uniforme: cada transición tiene la misma probabilidad de ser seleccionada.
  • Tamaño mínimo: no empezamos a entrenar hasta tener al menos 1.000 transiciones (para que los batches sean representativos).

3.2 Arquitectura de la Red Neuronal (QNetwork)

Usamos un Perceptrón Multicapa (MLP) — no necesitamos una CNN porque nuestro estado es un vector, no una imagen:

Entrada (12) → Lineal(128) → ReLU → Lineal(128) → ReLU → Salida (3)
Capa Dimensiones Activación Propósito
Entrada $12 \to 128$ ReLU Proyecta el estado a un espacio de alta dimensión
Oculta $128 \to 128$ ReLU Aprende representaciones no lineales
Salida $128 \to 3$ Ninguna (lineal) Produce $Q(s, a)$ para cada una de las 3 acciones

¿Por qué sin activación en la salida? Los valores Q pueden ser positivos o negativos (recompensas acumuladas con descuento), así que no debemos acotar la salida.

¿Por qué 128 neuronas? Es un balance entre capacidad (suficiente para aprender la función Q de Snake) y eficiencia. Con menos neuronas podría no converger; con más, el entrenamiento sería más lento sin beneficio claro.

3.3 El Agente DQN completo

El agente integra todos los componentes:

  • Red online $Q_\theta$: la red que se entrena activamente.
  • Red objetivo $Q_{\bar\theta}$: copia de la red online que se actualiza periódicamente (cada 200 pasos de entrenamiento). Proporciona objetivos TD estables.
  • Política epsilon-greedy: selección de acción con exploración controlada.
  • Optimizador Adam con learning rate $10^{-3}$.
  • Huber Loss (SmoothL1Loss): robusta ante outliers en los TD targets.
  • Gradient clipping ($\max = 10$): evita actualizaciones de pesos demasiado grandes.
[5]
# ──────────────────────────────────────────────────
# 3.1  Replay Buffer
# ──────────────────────────────────────────────────
class ReplayBuffer:
    """Almacena transiciones (s, a, r, s', done) en una cola circular."""

    def __init__(self, capacity: int = 50_000):
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size: int):
        """Muestreo uniforme aleatorio de un mini-batch."""
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = map(np.array, zip(*batch))
        return states, actions, rewards, next_states, dones

    def __len__(self):
        return len(self.buffer)


# ──────────────────────────────────────────────────
# 3.2  Red Neuronal Q (MLP)
# ──────────────────────────────────────────────────
class QNetwork(nn.Module):
    """
    MLP que aproxima Q(s, a) para todas las acciones simultáneamente.
    Entrada:  vector de estado (dim = state_dim)
    Salida:   Q-values para cada acción (dim = action_dim)
    """

    def __init__(self, state_dim: int, action_dim: int):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, 128),   # Capa de entrada → capa oculta 1
            nn.ReLU(),
            nn.Linear(128, 128),         # Capa oculta 1 → capa oculta 2
            nn.ReLU(),
            nn.Linear(128, action_dim),  # Capa oculta 2 → salida (sin activación)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


# ──────────────────────────────────────────────────
# 3.3  Configuración de hiperparámetros
# ──────────────────────────────────────────────────
@dataclass
class DQNConfig:
    # --- Descuento y optimización ---
    gamma: float = 0.99            # Factor de descuento γ
    lr: float = 1e-3               # Learning rate del optimizador Adam

    # --- Replay Buffer ---
    buffer_capacity: int = 30_000  # Capacidad máxima del buffer
    min_buffer_size: int = 1_000   # Mínimo de transiciones antes de entrenar
    batch_size: int = 128          # Tamaño del mini-batch

    # --- Target Network ---
    use_soft_update: bool = True   # True = Polyak averaging, False = hard update
    tau: float = 0.005             # Coeficiente de soft update (τ)
    hard_update_every: int = 200   # Frecuencia de hard update (solo si use_soft_update=False)

    # --- Exploración (Epsilon-Greedy) ---
    epsilon_start: float = 1.0     # ε inicial (exploración pura)
    epsilon_end: float = 0.05      # ε mínimo (siempre algo de exploración)
    epsilon_decay: float = 0.995   # Factor de decaimiento multiplicativo


# ──────────────────────────────────────────────────
# 3.4  Agente DQN
# ──────────────────────────────────────────────────
class DQNAgent:
    def __init__(self, state_dim: int, action_dim: int, config: DQNConfig = DQNConfig()):
        self.cfg = config
        self.action_dim = action_dim

        # Red online (la que aprende) y red objetivo (proporciona TD targets estables)
        self.q_net = QNetwork(state_dim, action_dim).to(device)
        self.target_net = QNetwork(state_dim, action_dim).to(device)
        self.target_net.load_state_dict(self.q_net.state_dict())  # Iniciar iguales
        self.target_net.eval()  # La target network nunca se entrena directamente

        self.optimizer = optim.Adam(self.q_net.parameters(), lr=self.cfg.lr)
        self.criterion = nn.SmoothL1Loss()  # Huber Loss

        self.replay = ReplayBuffer(self.cfg.buffer_capacity)
        self.epsilon = self.cfg.epsilon_start
        self.learn_steps = 0

    # ---- Selección de acción: epsilon-greedy ----
    def act(self, state: np.ndarray, explore: bool = True) -> int:
        """
        Con probabilidad ε → acción aleatoria (exploración).
        Con probabilidad 1-ε → argmax Q (explotación).
        En modo evaluación (explore=False), siempre explota.
        """
        if explore and random.random() < self.epsilon:
            return random.randint(0, self.action_dim - 1)

        state_t = torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0)
        with torch.no_grad():
            q_values = self.q_net(state_t)
        return int(torch.argmax(q_values, dim=1).item())

    # ---- Almacenar transición en el buffer ----
    def remember(self, s, a, r, ns, d):
        self.replay.push(s, a, r, ns, d)

    # ---- Paso de entrenamiento ----
    def train_step(self):
        """
        1. Muestrear mini-batch del replay buffer.
        2. Calcular Q(s,a) con la red online.
        3. Calcular TD target con la red objetivo.
        4. Backpropagation con Huber Loss.
        5. Actualizar target network (soft o hard).
        """
        if len(self.replay) < self.cfg.min_buffer_size:
            return None  # No hay suficientes datos aún

        # (1) Muestreo aleatorio del buffer
        states, actions, rewards, next_states, dones = self.replay.sample(self.cfg.batch_size)

        # Convertir a tensores de PyTorch
        states_t = torch.tensor(states, dtype=torch.float32, device=device)
        actions_t = torch.tensor(actions, dtype=torch.long, device=device).unsqueeze(1)
        rewards_t = torch.tensor(rewards, dtype=torch.float32, device=device).unsqueeze(1)
        next_states_t = torch.tensor(next_states, dtype=torch.float32, device=device)
        dones_t = torch.tensor(dones, dtype=torch.float32, device=device).unsqueeze(1)

        # (2) Q(s, a) actual — solo para la acción tomada
        #     gather(1, actions_t) selecciona el Q-value de la acción que se ejecutó
        q_values = self.q_net(states_t).gather(1, actions_t)

        # (3) TD target: y = r + γ(1 - done) * max_a' Q_target(s', a')
        with torch.no_grad():
            max_next_q = self.target_net(next_states_t).max(dim=1, keepdim=True)[0]
            td_target = rewards_t + self.cfg.gamma * (1 - dones_t) * max_next_q

        # (4) Calcular pérdida y backpropagation
        loss = self.criterion(q_values, td_target)
        self.optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(self.q_net.parameters(), max_norm=10.0)  # Gradient clipping
        self.optimizer.step()

        # (5) Actualizar target network
        self.learn_steps += 1
        if self.cfg.use_soft_update:
            # Soft update (Polyak averaging): θ̄ ← τθ + (1-τ)θ̄
            for param, target_param in zip(self.q_net.parameters(), self.target_net.parameters()):
                target_param.data.copy_(
                    self.cfg.tau * param.data + (1.0 - self.cfg.tau) * target_param.data
                )
        else:
            # Hard update: copiar pesos cada N pasos
            if self.learn_steps % self.cfg.hard_update_every == 0:
                self.target_net.load_state_dict(self.q_net.state_dict())

        return float(loss.item())

    # ---- Decaimiento de epsilon ----
    def decay_epsilon(self):
        """Decaimiento exponencial: ε ← max(ε_min, ε * δ)"""
        self.epsilon = max(self.cfg.epsilon_end, self.epsilon * self.cfg.epsilon_decay)

4. Bucle de entrenamiento

¿Cómo se entrena un agente de RL?

A diferencia del aprendizaje supervisado (donde iteramos sobre un dataset fijo), en RL el entrenamiento se organiza en episodios:

  1. Se resetea el entorno y se obtiene el estado inicial $s_0$.
  2. El agente interactúa paso a paso: observa $s_t$, elige $a_t$, recibe $r_t$ y $s_{t+1}$.
  3. Cada transición se almacena en el replay buffer.
  4. En cada paso, se muestrea un mini-batch del buffer y se hace una actualización de la red.
  5. Al final del episodio, se decae $\varepsilon$ (reducir exploración).
  6. Se repite durante cientos/miles de episodios.

Métricas que monitorizamos

En RL no existe el concepto de accuracy. En su lugar, evaluamos:

Métrica Analogía supervisado Descripción
train_reward Train loss (inverso) Recompensa acumulada por episodio
train_score Comidas conseguidas por episodio
mean_loss Train loss Pérdida de Bellman media por episodio
eval_reward / eval_score Val metric Rendimiento sin exploración (greedy), como validación
epsilon Nivel actual de exploración

Evaluación periódica (como "validación" en RL)

Cada 25 episodios ejecutamos el agente en modo greedy (sin exploración, $\varepsilon = 0$) durante 15 episodios independientes. Esto es análogo a evaluar en un conjunto de validación: mide el rendimiento real del agente sin el ruido de la exploración aleatoria.

Sobre el equilibrio de recompensas (muy importante)

Si los pesos de recompensa se desbalancean, pueden aparecer comportamientos patológicos:

  • Si el castigo por morir es muy bajo → el agente no aprende a evitar colisiones.
  • Si la recompensa por acercarse es muy alta → el agente orbita cerca de la comida sin comerla.
  • Si no hay penalización por paso → el agente deambula sin objetivo, sobreviviendo indefinidamente.

4.1 Función de evaluación (equivalente a "validación")

Definimos una función que ejecuta el agente sin exploración ($\varepsilon = 0$) en un entorno limpio. Esto nos permite medir el rendimiento real de la política aprendida, separado del ruido de las acciones aleatorias durante el entrenamiento.

[6]
# Función de evaluación (sin exploración, modo greedy puro)
def evaluate_agent(agent, env_size=10, episodes=10):
    """
    Ejecuta el agente en modo puramente greedy (ε=0) durante varios episodios.
    Retorna la recompensa media y el score medio.
    Es el equivalente a "evaluar en validación" en aprendizaje supervisado.
    """
    eval_env = SnakeEnv(size=env_size)
    rewards, scores = [], []

    for _ in range(episodes):
        s = eval_env.reset()
        done = False
        total_r = 0.0
        while not done:
            a = agent.act(s, explore=False)  # explore=False → siempre argmax Q
            s, r, done, info = eval_env.step(a)
            total_r += r
        rewards.append(total_r)
        scores.append(info['score'])

    return float(np.mean(rewards)), float(np.mean(scores))

4.2 Entrenamiento del agente

Ahora ejecutamos el bucle de entrenamiento completo:

  • 350 episodios de entrenamiento (suficiente para ver convergencia en Snake 10×10).
  • Evaluación cada 25 episodios: 15 partidas greedy para medir rendimiento real.
  • Impresión de progreso cada 25 episodios: score medio de las últimas 50 partidas y valor de $\varepsilon$.
[7]

# Bucle de entrenamiento principal
env = SnakeEnv(size=10, max_steps_without_food=120)
state_dim = env.reset().shape[0]
action_dim = 3

agent = DQNAgent(state_dim, action_dim)

num_episodes = 400
eval_every = 25

history = {
    'train_reward': [],
    'train_score': [],
    'mean_loss': [],
    'epsilon': [],
    'eval_reward': [],
    'eval_score': [],
    'eval_episode': [],
}

for ep in range(1, num_episodes + 1):
    s = env.reset()
    done = False
    total_reward = 0.0
    losses = []

    while not done:
        a = agent.act(s, explore=True)
        ns, r, done, info = env.step(a)

        agent.remember(s, a, r, ns, done)
        loss = agent.train_step()
        if loss is not None:
            losses.append(loss)

        s = ns
        total_reward += r

    agent.decay_epsilon()

    history['train_reward'].append(total_reward)
    history['train_score'].append(info['score'])
    history['mean_loss'].append(np.mean(losses) if losses else np.nan)
    history['epsilon'].append(agent.epsilon)

    if ep % eval_every == 0:
        eval_r, eval_s = evaluate_agent(agent, env_size=10, episodes=15)
        history['eval_reward'].append(eval_r)
        history['eval_score'].append(eval_s)
        history['eval_episode'].append(ep)

    if ep % 25 == 0:
        last50 = history['train_score'][-50:]
        print(
            f"Ep {ep:3d} | "
            f"score medio(últ.50)={np.mean(last50):.2f} | "
            f"epsilon={agent.epsilon:.3f}"
        )
Ep  25 | score medio(últ.50)=0.16 | epsilon=0.882
Ep  50 | score medio(últ.50)=0.12 | epsilon=0.778
Ep  75 | score medio(últ.50)=0.16 | epsilon=0.687
Ep 100 | score medio(últ.50)=0.56 | epsilon=0.606
Ep 125 | score medio(últ.50)=0.96 | epsilon=0.534
Ep 150 | score medio(últ.50)=1.36 | epsilon=0.471
Ep 175 | score medio(últ.50)=1.54 | epsilon=0.416
Ep 200 | score medio(últ.50)=1.42 | epsilon=0.367
Ep 225 | score medio(últ.50)=1.64 | epsilon=0.324
Ep 250 | score medio(últ.50)=2.46 | epsilon=0.286
Ep 275 | score medio(últ.50)=2.74 | epsilon=0.252
Ep 300 | score medio(últ.50)=2.62 | epsilon=0.222
Ep 325 | score medio(últ.50)=3.46 | epsilon=0.196
Ep 350 | score medio(últ.50)=5.24 | epsilon=0.173
Ep 375 | score medio(últ.50)=5.76 | epsilon=0.153
Ep 400 | score medio(últ.50)=5.50 | epsilon=0.135

Visualización del schedule de epsilon

Antes de entrenar, veamos cómo evoluciona $\varepsilon$ a lo largo de los episodios con nuestro decaimiento exponencial $\varepsilon_{t+1} = \max(0.05, ; \varepsilon_t \times 0.995)$:

  • Los primeros ~50 episodios el agente explora mucho ($\varepsilon > 0.7$).
  • Alrededor del episodio 200, $\varepsilon \approx 0.37$: mezcla equilibrada de exploración y explotación.
  • Hacia el episodio 500, $\varepsilon$ se acerca a su mínimo ($0.05$): el agente mayoritariamente explota.

Esta transición gradual es clave: si dejamos de explorar demasiado pronto, el agente puede quedarse atrapado en una estrategia subóptima.

[8]
# Visualización del schedule de decaimiento de epsilon
eps = 1.0
eps_min = 0.05
eps_decay = 0.995
epsilons = []
for _ in range(600):
    epsilons.append(eps)
    eps = max(eps_min, eps * eps_decay)

plt.figure(figsize=(8, 4))
plt.plot(epsilons, color='purple', linewidth=2)
plt.axhline(y=eps_min, color='red', linestyle='--', alpha=0.5, label=f'ε_min = {eps_min}')
plt.fill_between(range(len(epsilons)), epsilons, alpha=0.15, color='purple')
plt.title('Schedule de Epsilon: de Exploración a Explotación')
plt.xlabel('Episodio')
plt.ylabel('ε (probabilidad de exploración)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

print(f"ε en episodio 50:  {epsilons[50]:.3f}")
print(f"ε en episodio 200: {epsilons[200]:.3f}")
print(f"ε en episodio 350: {epsilons[350]:.3f}")
print(f"ε en episodio 500: {epsilons[500]:.3f}")
Output
ε en episodio 50:  0.778
ε en episodio 200: 0.367
ε en episodio 350: 0.173
ε en episodio 500: 0.082

5. Curvas de aprendizaje y análisis de métricas

¿Cómo saber si el agente está aprendiendo?

En RL, las curvas de aprendizaje son mucho más ruidosas que en aprendizaje supervisado. Esto es normal: cada episodio es diferente (posición aleatoria de comida, acciones de exploración, etc.). Por eso usamos medias móviles (MA) para suavizar las tendencias.

Las señales de que el entrenamiento va bien son:

  • Reward creciente a lo largo de los episodios (con mucha varianza, pero tendencia al alza).
  • Score creciente: el agente come más comida conforme aprende.
  • Loss decreciente (o estabilizándose): la predicción $Q(s,a)$ se acerca al TD target.
  • Epsilon decreciente: transición de exploración a explotación.
  • Eval score > random: si el rendimiento greedy supera consistentemente al agente aleatorio, hay aprendizaje real.

Train vs. Eval: análogo a Train vs. Validation

  • Train (con exploración): incluye acciones aleatorias, así que el rendimiento es peor de lo que el agente realmente sabe.
  • Eval (sin exploración): muestra el rendimiento real de la política aprendida.

Si eval_score sube mientras train_score se estanca, es señal de que la exploración está "ensuciando" las métricas de entrenamiento, pero el agente realmente mejora.

[9]

# Utilidad para media móvil

def moving_average(x, window=20):
    x = np.asarray(x, dtype=np.float32)
    if len(x) < window:
        return x
    c = np.convolve(x, np.ones(window) / window, mode='valid')
    pad = np.full(window - 1, np.nan)
    return np.concatenate([pad, c])

plt.figure(figsize=(14, 10))

# (1) Reward train vs eval
ax1 = plt.subplot(2, 2, 1)
ax1.plot(history['train_reward'], alpha=0.35, label='Train reward (episodio)')
ax1.plot(moving_average(history['train_reward'], 20), linewidth=2, label='Train reward MA(20)')
ax1.plot(history['eval_episode'], history['eval_reward'], marker='o', label='Eval reward (greedy)')
ax1.set_title('Recompensa: entrenamiento vs evaluación')
ax1.set_xlabel('Episodio')
ax1.set_ylabel('Reward')
ax1.grid(alpha=0.3)
ax1.legend()

# (2) Score train vs eval
ax2 = plt.subplot(2, 2, 2)
ax2.plot(history['train_score'], alpha=0.35, label='Train score (episodio)')
ax2.plot(moving_average(history['train_score'], 20), linewidth=2, label='Train score MA(20)')
ax2.plot(history['eval_episode'], history['eval_score'], marker='o', label='Eval score (greedy)')
ax2.set_title('Score (comida): entrenamiento vs evaluación')
ax2.set_xlabel('Episodio')
ax2.set_ylabel('Comidas')
ax2.grid(alpha=0.3)
ax2.legend()

# (3) Loss vs episodio
ax3 = plt.subplot(2, 2, 3)
ax3.plot(history['mean_loss'], label='Loss media por episodio')
ax3.plot(moving_average(np.nan_to_num(history['mean_loss'], nan=0.0), 20), linewidth=2, label='Loss MA(20)')
ax3.set_title('Pérdida de Bellman (Huber)')
ax3.set_xlabel('Episodio')
ax3.set_ylabel('Loss')
ax3.grid(alpha=0.3)
ax3.legend()

# (4) Epsilon
ax4 = plt.subplot(2, 2, 4)
ax4.plot(history['epsilon'], color='purple')
ax4.set_title('Decaimiento de epsilon (exploración)')
ax4.set_xlabel('Episodio')
ax4.set_ylabel('Epsilon')
ax4.grid(alpha=0.3)

plt.tight_layout()
plt.show()
Output
[10]

# Métricas resumen de evaluación final
final_eval_reward, final_eval_score = evaluate_agent(agent, env_size=10, episodes=50)
print(f"Recompensa media en evaluación (50 episodios): {final_eval_reward:.2f}")
print(f"Score medio en evaluación (50 episodios):      {final_eval_score:.2f}")
Recompensa media en evaluación (50 episodios): 174.19
Score medio en evaluación (50 episodios):      17.34

6. Tests de sanidad

Antes de confiar en los resultados, verificamos que los componentes funcionan correctamente:

  1. Dimensión del estado: debe ser exactamente 12 (nuestras 12 características).
  2. Salida de la red: debe producir 3 Q-values (uno por acción).
  3. Train step funcional: con suficiente buffer, debe producir una pérdida numérica finita.

Estos tests son equivalentes a los unit tests de un proyecto de software: capturan errores en la implementación antes de invertir tiempo en entrenamiento.

[11]

# Tests simples (educativos)

test_env = SnakeEnv(size=8)
s0 = test_env.reset()
assert s0.shape == (12,), "La dimensión del estado debe ser 12"

test_agent = DQNAgent(state_dim=12, action_dim=3)

# Test forward pass
tensor_state = torch.tensor(s0, dtype=torch.float32, device=device).unsqueeze(0)
qvals = test_agent.q_net(tensor_state)
assert qvals.shape == (1, 3), "La red debe devolver Q para 3 acciones"

# Llenar buffer con transiciones aleatorias para habilitar train_step
while len(test_agent.replay) < test_agent.cfg.min_buffer_size:
    s = test_env.reset()
    done = False
    while not done and len(test_agent.replay) < test_agent.cfg.min_buffer_size:
        a = random.randint(0, 2)
        ns, r, done, _ = test_env.step(a)
        test_agent.remember(s, a, r, ns, done)
        s = ns

loss_val = test_agent.train_step()
assert loss_val is not None and np.isfinite(loss_val), "train_step debe devolver una loss válida"

print("✅ Tests de sanidad superados.")
✅ Tests de sanidad superados.

7. Demostración del agente entrenado

La mejor forma de evaluar un agente de RL es verlo jugar. Ejecutaremos un episodio completo en modo greedy y lo visualizaremos como animación:

  • 🟢 Verde brillante: cabeza de la serpiente.
  • 🟢 Verde oscuro: cuerpo de la serpiente.
  • 🔴 Rojo: comida.
  • Negro: espacio vacío.

Si el entrenamiento ha funcionado, deberías observar que la serpiente evita choques, busca la comida activamente y crece significativamente más que un agente aleatorio.

[12]

# Render de un episodio como frames RGB

def render_grid(env):
    grid = np.zeros((env.size, env.size, 3), dtype=np.uint8)

    # Fondo
    grid[:] = np.array([20, 20, 20], dtype=np.uint8)

    # Comida (rojo)
    fr, fc = env.food
    grid[fr, fc] = np.array([220, 50, 50], dtype=np.uint8)

    # Cuerpo serpiente (verde)
    for i, (r, c) in enumerate(env.snake):
        if i == 0:
            grid[r, c] = np.array([50, 220, 120], dtype=np.uint8)  # cabeza
        else:
            grid[r, c] = np.array([40, 140, 80], dtype=np.uint8)

    return grid


def rollout_frames(agent, env_size=10, max_steps=250):
    demo_env = SnakeEnv(size=env_size)
    s = demo_env.reset()
    done = False

    frames = [render_grid(demo_env)]
    total_reward = 0.0

    steps = 0
    while not done and steps < max_steps:
        a = agent.act(s, explore=False)
        s, r, done, info = demo_env.step(a)
        total_reward += r
        frames.append(render_grid(demo_env))
        steps += 1

    return frames, info['score'], total_reward

frames, demo_score, demo_reward = rollout_frames(agent, env_size=10, max_steps=300)
print(f"Demo terminada | score={demo_score} | reward_total={demo_reward:.2f} | frames={len(frames)}")
Demo terminada | score=12 | reward_total=120.54 | frames=116
[13]

# Animación inline en notebook
fig, ax = plt.subplots(figsize=(5, 5))
ax.axis('off')
img = ax.imshow(frames[0], interpolation='nearest')

def animate(i):
    img.set_data(frames[i])
    return [img]

ani = animation.FuncAnimation(fig, animate, frames=len(frames), interval=120, blit=True)
plt.close(fig)
HTML(ani.to_jshtml())

8. Conclusiones y reflexiones

Lo que hemos aprendido

En este notebook hemos implementado desde cero un pipeline completo de Deep Q-Learning:

Componente Concepto clave
Entorno Snake Formulación como MDP: estados, acciones, recompensas, transiciones
Reward shaping Señales densas ($\pm 0.2$) + recompensas terminales ($\pm 10$) para guiar el aprendizaje
QNetwork (MLP) Red neuronal que aproxima $Q(s,a)$ para generalizar a estados no vistos
Target Network Copia lenta que estabiliza los objetivos TD (soft update con $\tau = 0.005$)
Replay Buffer Rompe correlación temporal y reutiliza experiencias pasadas
Epsilon-Greedy Balance entre exploración (descubrir) y explotación (aprovechar lo aprendido)
Epsilon Schedule Decaimiento $\varepsilon \cdot 0.995$ por episodio: de exploración pura a explotación informada
Batching Mini-batches de 128 transiciones para gradientes estables y eficientes
Huber Loss Función de pérdida robusta ante outliers en los TD targets

Lecciones prácticas

  1. El diseño de recompensas es decisivo: una señal bien equilibrada puede marcar la diferencia entre un agente que aprende en 200 episodios y uno que no aprende nunca.
  2. Experience Replay + Target Network son esenciales: sin ellos, el entrenamiento de DQN es extremadamente inestable.
  3. Las métricas de RL son ruidosas: siempre hay que usar medias móviles y evaluación greedy separada.
  4. La representación del estado importa: un vector compacto con información relevante permite aprender mucho más rápido que dar la cuadrícula completa.

Ideas para seguir experimentando

  1. Double DQN: usar la red online para seleccionar la acción y la target para evaluarla, reduciendo sobreestimación de Q.
  2. Dueling DQN: separar $Q(s,a)$ en $V(s) + A(s,a)$ para aprender valor de estado y ventaja de acción por separado.
  3. Representación visual: dar la cuadrícula como imagen y usar una CNN en lugar de un MLP.
  4. Estudio de sensibilidad: variar recompensas, $\gamma$, $\tau$, y observar cómo cambia el comportamiento.
  5. Comparar algoritmos: implementar PPO o REINFORCE en el mismo entorno y comparar curvas de aprendizaje.
  6. Prioritized Experience Replay: muestrear transiciones con mayor error TD con más frecuencia.

📘 Este notebook complementa la teoría del submódulo de Aprendizaje por Refuerzo dentro del módulo de Otros Modelos de Deep Learning. Para una exploración interactiva en tiempo real del agente DQN, consulta la demo interactiva Snake DRL.