🏭 Caso de Uso

DQN para Pac-Man: Laberintos, Fantasmas y Aprendizaje por Refuerzo

Implementación completa de un agente DQN en PyTorch para Pac-Man: generación procedural de niveles, entorno MDP con fantasmas inteligentes, vector de estado de 20 features, entrenamiento multi-nivel, Huber loss y exportación del modelo.

🐍 Python 📓 Jupyter Notebook

👻 Deep Q-Learning desde Cero: Entrenando una IA para Jugar a Pac-Man


Objetivos de este notebook

En este caso de uso vamos a entrenar un agente de Deep Reinforcement Learning (DRL) para que aprenda a jugar a una versión simplificada de Pac-Man en laberintos generados proceduralmente. A diferencia de nuestro caso de uso con Snake, Pac-Man introduce retos fundamentales adicionales:

  1. Entornos adversariales: los fantasmas persiguen activamente al agente.
  2. Laberintos estructurados: la topología del nivel condiciona las estrategias posibles.
  3. Power-ups temporales: las power pellets cambian la dinámica del juego por un tiempo limitado.
  4. Objetivos múltiples: comer dots, evitar fantasmas, y aprovechar las power pellets.

Al finalizar, habrás implementado y comprendido:

  1. Generación procedural de niveles con simetría y conectividad garantizada.
  2. Un entorno MDP completo con Pac-Man, fantasmas con IA, dots y power pellets.
  3. Un vector de estado compacto de 20 dimensiones que captura toda la información relevante.
  4. Una red DQN más profunda (256→128→64→4 acciones) adaptada a la mayor complejidad.
  5. Entrenamiento con múltiples niveles para que el agente generalice.
  6. Evaluación visual con animaciones del agente jugando.
  7. Exportación del modelo a JSON para la demo web interactiva.

Diferencias clave respecto al caso Snake

Aspecto Snake Pac-Man
Espacio de estados 12 features 20 features
Acciones 3 relativas (recto, izq, der) 4 absolutas (↑→↓←)
Adversarios Ninguno (solo paredes y cuerpo) 2 fantasmas con IA activa
Estructura del nivel Tablero abierto Laberinto con paredes complejas
Objetivo Crecer (1 tipo de comida) Limpiar dots + power pellets + sobrevivir
Mecánica especial Power pellets (comer fantasmas temporalmente)
Red neuronal 12→128→128→3 20→256→128→64→4

Estos factores hacen que Pac-Man sea un reto de RL significativamente más difícil que Snake, requiriendo más episodios de entrenamiento y una red con más capacidad.


Arquitectura modular

Este proyecto está dividido en módulos Python reutilizables:

scripts/pacman/
  ├── level_generator.py   # Generación procedural de laberintos
  ├── game.py              # PacmanEnv — el entorno MDP completo
  └── renderer.py          # Visualización matplotlib (frames → animación)

En este notebook simplemente importamos estos módulos y nos centramos en el entrenamiento DQN.


Herramientas y librerías

  • PyTorch: construcción y entrenamiento de la red DQN.
  • NumPy: manipulación numérica.
  • Matplotlib: visualización de métricas y animación del agente.
  • Módulos propios (scripts.pacman): entorno, niveles y renderizado.

💡 Nota: a diferencia de Snake donde implementamos el juego en el propio notebook, aquí usamos una arquitectura modular. Esto es más realista y permite reutilizar el entorno tanto en el notebook como en la demo web.

[1]
# Configuración general y dependencias
import sys
import os
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

# Asegurar que scripts/ está en el path
PROJECT_ROOT = os.path.abspath(os.path.join(os.getcwd(), "..", "..", "..", ".."))
if PROJECT_ROOT not in sys.path:
    sys.path.insert(0, PROJECT_ROOT)

from scripts.pacman.level_generator import generate_level, generate_training_levels, Level
from scripts.pacman.game import PacmanEnv, STATE_DIM, NUM_ACTIONS, ACTION_NAMES
from scripts.pacman.renderer import render_frame, record_episode, create_animation, render_level_static

# 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}")
print(f"Dimensión del estado: {STATE_DIM}")
print(f"Número de acciones: {NUM_ACTIONS} ({', '.join(ACTION_NAMES)})")
Dispositivo disponible: cuda
Dimensión del estado: 20
Número de acciones: 4 (UP, RIGHT, DOWN, LEFT)

1. Generación del laberinto y diseño del entorno

Generación procedural de niveles

El generador de niveles (scripts/pacman/level_generator.py) crea laberintos con las siguientes propiedades:

  • Simetría horizontal: la mitad derecha es un espejo de la izquierda (como los laberintos clásicos de Pac-Man).
  • Conectividad garantizada: un algoritmo de flood-fill verifica que todas las celdas transitables están conectadas.
  • Casa de fantasmas: zona central donde aparecen los fantasmas al inicio.
  • Variabilidad: cada semilla produce un laberinto diferente, útil para entrenar con múltiples niveles.

Diseño del MDP

Espacio de estados $S$ — Vector compacto de 20 dimensiones:

Índices Característica Rango
0-3 Pared inmediata en 4 dirs (↑→↓←) ${0, 1}$
4-7 Peligro fantasma en 4 dirs (fantasma a ≤3 celdas) ${0, 1}$
8-11 Dirección al dot más cercano (↑→↓←) ${0, 1}$
12 Distancia Manhattan normalizada al dot $[0, 1]$
13-16 Dirección al fantasma más cercano (↑→↓←) ${0, 1}$
17 Distancia Manhattan normalizada al fantasma $[0, 1]$
18 Power mode activo ${0, 1}$
19 Proporción de dots restantes $[0, 1]$

Espacio de acciones $A$: 4 acciones absolutas — 0=UP, 1=RIGHT, 2=DOWN, 3=LEFT.

Función de recompensa $R$:

Evento Recompensa Justificación
Comer dot $+1.0$ Objetivo principal: limpiar el nivel
Comer power pellet $+5.0$ Recurso estratégico valioso
Comer fantasma (power mode) $+20.0$ Recompensa alta por acción arriesgada/precisa
Muerte (fantasma te atrapa) $-15.0$ Castigo terminal fuerte
Ganar (todos los dots) $+50.0$ Bonus por completar el objetivo
Paso sin evento $-0.1$ Penalización de tiempo: evitar vagar sin objetivo
Acercarse al dot $+0.2$ Señal densa: guía paso a paso
Alejarse del dot $-0.2$ Señal densa: desincentiva movimientos ineficientes

Fantasmas — IA simple con 3 modos:

  • Chase (70%): se mueven hacia Pac-Man por distancia Manhattan.
  • Scatter (30%): se mueven aleatoriamente.
  • Frightened: huyen de Pac-Man cuando hay power pellet activa (30 pasos).
[2]
# Generar un nivel de ejemplo y visualizarlo
level = generate_level(width=15, height=15, seed=42)
env = PacmanEnv(level=level, max_steps=500)
state = env.reset()

print("=== Nivel generado ===")
level.print()
print(f"\nPac-Man spawn: {level.pacman_spawn}")
print(f"Ghost spawns:  {level.ghost_spawns}")
print(f"Dots: {level.total_dots} | Power Pellets: {level.total_pellets}")

# Visualización gráfica del nivel
render_level_static(env, cell_size=16, figsize=(7, 7))
=== Nivel generado ===
███████████████
█◉···········◉█
█··███···███··█
█····██·██····█
█·█···█·█···█·█
█·············█
█·██·█···█·██·█
█·██·█☻☻G█·██·█
█····█████····█
█·············█
█··█·······█··█
█··█·······█··█
█·████···████·█
█◉·····P·····◉█
███████████████
Dots: 118 | Power Pellets: 4

Pac-Man spawn: (13, 7)
Ghost spawns:  [(7, 6), (7, 7)]
Dots: 118 | Power Pellets: 4
Output

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

Como en el caso de Snake, nuestro "EDA" consiste en interactuar con el entorno:

  • Verificar la dimensión del estado y sus rangos.
  • Observar el rendimiento de un agente aleatorio como línea base.
  • Comprender la distribución de recompensas y la dificultad del juego.
[3]
# Inspección del espacio de estados
state = env.reset()
print(f"Dimensión del estado: {state.shape}")
print(f"Ejemplo de estado inicial:\n{np.round(state, 2)}")
print(f"\nFeatures:")
feature_names = [
    "wall_up", "wall_right", "wall_down", "wall_left",
    "ghost_danger_up", "ghost_danger_right", "ghost_danger_down", "ghost_danger_left",
    "dot_up", "dot_right", "dot_down", "dot_left",
    "dot_dist_norm",
    "ghost_up", "ghost_right", "ghost_down", "ghost_left",
    "ghost_dist_norm",
    "power_active",
    "dots_remaining_ratio",
]
for i, (name, val) in enumerate(zip(feature_names, state)):
    print(f"  [{i:2d}] {name:25s} = {val:.2f}")

print(f"\nAcciones válidas desde posición actual: {env.get_valid_actions()}")
print(f"  (0=UP, 1=RIGHT, 2=DOWN, 3=LEFT)")
Dimensión del estado: (20,)
Ejemplo de estado inicial:
[0.   0.   1.   0.   0.   0.   0.   0.   0.   1.   0.   0.   0.03 1.
 0.   0.   0.   0.2  0.   1.  ]

Features:
  [ 0] wall_up                   = 0.00
  [ 1] wall_right                = 0.00
  [ 2] wall_down                 = 1.00
  [ 3] wall_left                 = 0.00
  [ 4] ghost_danger_up           = 0.00
  [ 5] ghost_danger_right        = 0.00
  [ 6] ghost_danger_down         = 0.00
  [ 7] ghost_danger_left         = 0.00
  [ 8] dot_up                    = 0.00
  [ 9] dot_right                 = 1.00
  [10] dot_down                  = 0.00
  [11] dot_left                  = 0.00
  [12] dot_dist_norm             = 0.03
  [13] ghost_up                  = 1.00
  [14] ghost_right               = 0.00
  [15] ghost_down                = 0.00
  [16] ghost_left                = 0.00
  [17] ghost_dist_norm           = 0.20
  [18] power_active              = 0.00
  [19] dots_remaining_ratio      = 1.00

Acciones válidas desde posición actual: [0, 1, 3]
  (0=UP, 1=RIGHT, 2=DOWN, 3=LEFT)
[4]
# Línea base: rendimiento de un agente aleatorio
random_rewards = []
random_scores = []
random_steps = []
random_wins = []

for ep in range(50):
    s = env.reset()
    done = False
    total_r = 0
    while not done:
        valid = env.get_valid_actions()
        a = random.choice(valid) if valid else 0
        s, r, done, info = env.step(a)
        total_r += r
    random_rewards.append(total_r)
    random_scores.append(info["score"])
    random_steps.append(info["steps"])
    random_wins.append(info["won"])

print("=== Línea base: agente aleatorio (50 episodios) ===")
print(f"Reward medio:  {np.mean(random_rewards):.1f} ± {np.std(random_rewards):.1f}")
print(f"Score medio:   {np.mean(random_scores):.1f} ± {np.std(random_scores):.1f}")
print(f"Pasos medios:  {np.mean(random_steps):.0f}")
print(f"Victorias:     {sum(random_wins)} / 50")

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].hist(random_rewards, bins=15, color="#7c5cff", alpha=0.7, edgecolor="white")
axes[0].set_title("Distribución de recompensas (aleatorio)")
axes[0].set_xlabel("Recompensa total")
axes[1].hist(random_scores, bins=15, color="#22c55e", alpha=0.7, edgecolor="white")
axes[1].set_title("Distribución de puntuación (aleatorio)")
axes[1].set_xlabel("Score")
axes[2].hist(random_steps, bins=15, color="#f59e0b", alpha=0.7, edgecolor="white")
axes[2].set_title("Distribución de pasos (aleatorio)")
axes[2].set_xlabel("Pasos hasta muerte/timeout")
plt.tight_layout()
plt.show()
=== Línea base: agente aleatorio (50 episodios) ===
Reward medio:  -3.2 ± 4.6
Score medio:   15.8 ± 5.9
Pasos medios:  39
Victorias:     0 / 50
Output

3. Agente DQN: Arquitectura y Componentes

¿Por qué una red más profunda?

Pac-Man es significativamente más complejo que Snake:

  • 20 features en el estado (vs. 12 en Snake).
  • 4 acciones (vs. 3 en Snake).
  • Dinámica adversarial: los fantasmas cambian de comportamiento según el modo.
  • Decisiones multi-objetivo: comer dots, evitar fantasmas, usar power pellets estratégicamente.

Por tanto, necesitamos una red con más capacidad:

Entrada (20) → Lineal(256) → ReLU → Lineal(128) → ReLU → Lineal(64) → ReLU → Salida (4)
Capa Dimensiones Propósito
Entrada $20 \to 256$ Proyectar el estado rico a alta dimensión
Oculta 1 $256 \to 128$ Representaciones de alto nivel (peligro + oportunidad)
Oculta 2 $128 \to 64$ Refinamiento de la política
Salida $64 \to 4$ $Q(s, a)$ para cada dirección

Hiperparámetros adaptados

Parámetro Snake Pac-Man Justificación
$\gamma$ 0.99 0.99 Mismo horizonte largo (~100 pasos)
LR $10^{-3}$ $5 \times 10^{-4}$ Red más grande → LR más bajo para estabilidad
Batch size 128 128 Suficiente para laberintos
Buffer 30K 50K Más variedad de experiencias
$\tau$ (soft update) 0.005 0.005 Funciona bien
$\varepsilon$ decay 0.995 0.998 Decaimiento más lento: Pac-Man requiere más exploración
$\varepsilon_{\min}$ 0.05 0.05 Mantener algo de exploración

Entrenamiento con múltiples niveles

Una diferencia clave respecto a Snake es que Pac-Man usa laberintos variados. Si entrenamos siempre con el mismo laberinto, el agente memorizará rutas específicas en vez de aprender estrategias generales. Generamos 5 niveles y rotamos entre ellos en cada episodio.

[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):
        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 más profunda)
# ──────────────────────────────────────────────────
class QNetwork(nn.Module):
    """
    MLP de 3 capas ocultas para aproximar Q(s, a) en Pac-Man.
    Entrada:  vector de estado (dim = 20)
    Salida:   Q-values para cada acción (dim = 4)
    """

    def __init__(self, state_dim: int, action_dim: int,
                 hidden_sizes: tuple = (256, 128, 64)):
        super().__init__()
        layers = []
        prev = state_dim
        for h in hidden_sizes:
            layers.append(nn.Linear(prev, h))
            layers.append(nn.ReLU())
            prev = h
        layers.append(nn.Linear(prev, action_dim))
        self.net = nn.Sequential(*layers)

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


# ──────────────────────────────────────────────────
# 3.3  Configuración de hiperparámetros
# ──────────────────────────────────────────────────
@dataclass
class DQNConfig:
    # --- Red ---
    hidden_sizes: tuple = (256, 128, 64)

    # --- Descuento y optimización ---
    gamma: float = 0.99
    lr: float = 5e-4

    # --- Replay Buffer ---
    buffer_capacity: int = 50_000
    min_buffer_size: int = 2_000
    batch_size: int = 128

    # --- Target Network ---
    use_soft_update: bool = True
    tau: float = 0.005
    hard_update_every: int = 200

    # --- Exploración (Epsilon-Greedy) ---
    epsilon_start: float = 1.0
    epsilon_end: float = 0.05
    epsilon_decay: float = 0.998  # Más lento que Snake


# ──────────────────────────────────────────────────
# 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

        self.q_net = QNetwork(state_dim, action_dim, config.hidden_sizes).to(device)
        self.target_net = QNetwork(state_dim, action_dim, config.hidden_sizes).to(device)
        self.target_net.load_state_dict(self.q_net.state_dict())
        self.target_net.eval()

        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

    def act(self, state: np.ndarray, explore: bool = True) -> int:
        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())

    def remember(self, s, a, r, ns, d):
        self.replay.push(s, a, r, ns, d)

    def train_step(self):
        if len(self.replay) < self.cfg.min_buffer_size:
            return None

        states, actions, rewards, next_states, dones = self.replay.sample(self.cfg.batch_size)

        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)

        q_values = self.q_net(states_t).gather(1, actions_t)

        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

        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)
        self.optimizer.step()

        self.learn_steps += 1
        if self.cfg.use_soft_update:
            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:
            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())

    def decay_epsilon(self):
        self.epsilon = max(self.cfg.epsilon_end, self.epsilon * self.cfg.epsilon_decay)

print("✅ Componentes DQN definidos")
print(f"   Red: {STATE_DIM} → {DQNConfig.hidden_sizes} → {NUM_ACTIONS}")
total_params = sum(p.numel() for p in QNetwork(STATE_DIM, NUM_ACTIONS).parameters())
print(f"   Parámetros totales: {total_params:,}")
✅ Componentes DQN definidos
   Red: 20 → (256, 128, 64) → 4
   Parámetros totales: 46,788

4. Bucle de entrenamiento

Estrategia de entrenamiento para Pac-Man

Las diferencias clave respecto al entrenamiento de Snake:

  1. Múltiples niveles: rotamos entre 5 laberintos diferentes para forzar generalización.
  2. Más episodios (800): la mayor complejidad requiere más experiencia.
  3. $\varepsilon$ decay más lento (0.998): necesitamos más exploración para descubrir estrategias como usar power pellets en el momento adecuado.
  4. Buffer más grande (50K): almacenamos más variedad de experiencias de diferentes niveles.

Función de evaluación

Evaluamos con 10 episodios greedy (sin exploración) cada 25 episodios, usando los mismos niveles de entrenamiento para medir progreso real.

[6]
# Función de evaluación (sin exploración, modo greedy puro)
def evaluate_agent(agent, levels, episodes_per_level=2, max_steps=500):
    """
    Ejecuta el agente greedy en todos los niveles de entrenamiento.
    """
    rewards, scores, wins = [], [], []

    for lv in levels:
        eval_env = PacmanEnv(level=lv, max_steps=max_steps)
        for _ in range(episodes_per_level):
            s = eval_env.reset()
            done = False
            total_r = 0.0
            while not done:
                a = agent.act(s, explore=False)
                s, r, done, info = eval_env.step(a)
                total_r += r
            rewards.append(total_r)
            scores.append(info["score"])
            wins.append(info["won"])

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


# Generar niveles de entrenamiento (variados)
training_levels = generate_training_levels(n=5, width=15, height=15, base_seed=42)
print(f"Niveles de entrenamiento: {len(training_levels)}")
for i, lv in enumerate(training_levels):
    print(f"  Nivel {i}: {lv.total_dots} dots, {lv.total_pellets} pellets")

# Crear entorno con rotación de niveles
env = PacmanEnv(levels=training_levels, max_steps=500)
Niveles de entrenamiento: 5
  Nivel 0: 118 dots, 4 pellets
  Nivel 1: 120 dots, 4 pellets
  Nivel 2: 134 dots, 4 pellets
  Nivel 3: 124 dots, 4 pellets
  Nivel 4: 124 dots, 4 pellets
[7]
# Bucle de entrenamiento principal
state_dim = STATE_DIM
action_dim = NUM_ACTIONS

agent = DQNAgent(state_dim, action_dim)

num_episodes = 4000
eval_every = 25

history = {
    'train_reward': [],
    'train_score': [],
    'train_wins': [],
    'mean_loss': [],
    'epsilon': [],
    'eval_reward': [],
    'eval_score': [],
    'eval_wins': [],
    '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['train_wins'].append(info['won'])
    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, eval_w = evaluate_agent(agent, training_levels)
        history['eval_reward'].append(eval_r)
        history['eval_score'].append(eval_s)
        history['eval_wins'].append(eval_w)
        history['eval_episode'].append(ep)

    if ep % 50 == 0:
        last50 = history['train_score'][-50:]
        wins50 = sum(history['train_wins'][-50:])
        print(
            f"Ep {ep:4d} | "
            f"score medio(últ.50)={np.mean(last50):.1f} | "
            f"wins(últ.50)={wins50} | "
            f"epsilon={agent.epsilon:.4f}"
        )
Ep   50 | score medio(últ.50)=14.5 | wins(últ.50)=0 | epsilon=0.9047
Ep  100 | score medio(últ.50)=17.3 | wins(últ.50)=0 | epsilon=0.8186
Ep  150 | score medio(últ.50)=21.4 | wins(últ.50)=0 | epsilon=0.7406
Ep  200 | score medio(últ.50)=21.6 | wins(últ.50)=0 | epsilon=0.6701
Ep  250 | score medio(últ.50)=25.5 | wins(últ.50)=0 | epsilon=0.6062
Ep  300 | score medio(últ.50)=29.9 | wins(últ.50)=0 | epsilon=0.5485
Ep  350 | score medio(últ.50)=37.4 | wins(últ.50)=0 | epsilon=0.4962
Ep  400 | score medio(últ.50)=41.7 | wins(últ.50)=0 | epsilon=0.4490
Ep  450 | score medio(últ.50)=52.3 | wins(últ.50)=0 | epsilon=0.4062
Ep  500 | score medio(últ.50)=52.1 | wins(últ.50)=0 | epsilon=0.3675
Ep  550 | score medio(últ.50)=59.1 | wins(últ.50)=0 | epsilon=0.3325
Ep  600 | score medio(últ.50)=69.2 | wins(últ.50)=0 | epsilon=0.3008
Ep  650 | score medio(últ.50)=65.1 | wins(últ.50)=0 | epsilon=0.2722
Ep  700 | score medio(últ.50)=72.3 | wins(últ.50)=0 | epsilon=0.2463
Ep  750 | score medio(últ.50)=75.9 | wins(últ.50)=0 | epsilon=0.2228
Ep  800 | score medio(últ.50)=72.5 | wins(últ.50)=0 | epsilon=0.2016
Ep  850 | score medio(últ.50)=81.6 | wins(últ.50)=0 | epsilon=0.1824
Ep  900 | score medio(últ.50)=82.0 | wins(últ.50)=0 | epsilon=0.1650
Ep  950 | score medio(últ.50)=92.0 | wins(últ.50)=0 | epsilon=0.1493
Ep 1000 | score medio(últ.50)=86.6 | wins(últ.50)=0 | epsilon=0.1351
Ep 1050 | score medio(últ.50)=92.4 | wins(últ.50)=0 | epsilon=0.1222
Ep 1100 | score medio(últ.50)=88.4 | wins(últ.50)=0 | epsilon=0.1106
Ep 1150 | score medio(últ.50)=97.6 | wins(últ.50)=0 | epsilon=0.1000
Ep 1200 | score medio(últ.50)=100.9 | wins(últ.50)=0 | epsilon=0.0905
Ep 1250 | score medio(últ.50)=93.0 | wins(últ.50)=1 | epsilon=0.0819
Ep 1300 | score medio(últ.50)=103.5 | wins(últ.50)=1 | epsilon=0.0741
Ep 1350 | score medio(últ.50)=104.8 | wins(últ.50)=0 | epsilon=0.0670
Ep 1400 | score medio(últ.50)=105.8 | wins(últ.50)=2 | epsilon=0.0606
Ep 1450 | score medio(últ.50)=102.8 | wins(últ.50)=0 | epsilon=0.0549
Ep 1500 | score medio(últ.50)=101.3 | wins(últ.50)=0 | epsilon=0.0500
Ep 1550 | score medio(últ.50)=106.0 | wins(últ.50)=1 | epsilon=0.0500
Ep 1600 | score medio(últ.50)=107.1 | wins(últ.50)=0 | epsilon=0.0500
Ep 1650 | score medio(últ.50)=103.9 | wins(últ.50)=0 | epsilon=0.0500
Ep 1700 | score medio(últ.50)=103.1 | wins(últ.50)=0 | epsilon=0.0500
Ep 1750 | score medio(últ.50)=107.8 | wins(últ.50)=1 | epsilon=0.0500
Ep 1800 | score medio(últ.50)=100.0 | wins(últ.50)=0 | epsilon=0.0500
Ep 1850 | score medio(últ.50)=102.4 | wins(últ.50)=0 | epsilon=0.0500
Ep 1900 | score medio(últ.50)=105.7 | wins(últ.50)=0 | epsilon=0.0500
Ep 1950 | score medio(últ.50)=103.6 | wins(últ.50)=0 | epsilon=0.0500
Ep 2000 | score medio(últ.50)=105.0 | wins(últ.50)=0 | epsilon=0.0500
Ep 2050 | score medio(últ.50)=102.0 | wins(últ.50)=1 | epsilon=0.0500
Ep 2100 | score medio(últ.50)=101.5 | wins(últ.50)=0 | epsilon=0.0500
Ep 2150 | score medio(últ.50)=102.9 | wins(últ.50)=1 | epsilon=0.0500
Ep 2200 | score medio(últ.50)=101.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2250 | score medio(últ.50)=103.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2300 | score medio(últ.50)=104.1 | wins(últ.50)=0 | epsilon=0.0500
Ep 2350 | score medio(últ.50)=107.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2400 | score medio(últ.50)=111.1 | wins(últ.50)=1 | epsilon=0.0500
Ep 2450 | score medio(últ.50)=104.6 | wins(últ.50)=0 | epsilon=0.0500
Ep 2500 | score medio(últ.50)=105.7 | wins(últ.50)=0 | epsilon=0.0500
Ep 2550 | score medio(últ.50)=114.7 | wins(últ.50)=1 | epsilon=0.0500
Ep 2600 | score medio(últ.50)=107.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2650 | score medio(últ.50)=107.9 | wins(últ.50)=0 | epsilon=0.0500
Ep 2700 | score medio(últ.50)=99.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2750 | score medio(últ.50)=101.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 2800 | score medio(últ.50)=103.5 | wins(últ.50)=1 | epsilon=0.0500
Ep 2850 | score medio(últ.50)=107.9 | wins(últ.50)=0 | epsilon=0.0500
Ep 2900 | score medio(últ.50)=110.6 | wins(últ.50)=0 | epsilon=0.0500
Ep 2950 | score medio(últ.50)=109.1 | wins(últ.50)=0 | epsilon=0.0500
Ep 3000 | score medio(últ.50)=102.0 | wins(últ.50)=1 | epsilon=0.0500
Ep 3050 | score medio(últ.50)=103.0 | wins(últ.50)=0 | epsilon=0.0500
Ep 3100 | score medio(últ.50)=112.5 | wins(últ.50)=1 | epsilon=0.0500
Ep 3150 | score medio(últ.50)=108.1 | wins(últ.50)=1 | epsilon=0.0500
Ep 3200 | score medio(últ.50)=105.6 | wins(últ.50)=1 | epsilon=0.0500
Ep 3250 | score medio(últ.50)=113.5 | wins(últ.50)=0 | epsilon=0.0500
Ep 3300 | score medio(últ.50)=108.5 | wins(últ.50)=1 | epsilon=0.0500
Ep 3350 | score medio(últ.50)=110.6 | wins(últ.50)=3 | epsilon=0.0500
Ep 3400 | score medio(últ.50)=105.6 | wins(últ.50)=0 | epsilon=0.0500
Ep 3450 | score medio(últ.50)=102.3 | wins(últ.50)=0 | epsilon=0.0500
Ep 3500 | score medio(últ.50)=114.0 | wins(últ.50)=1 | epsilon=0.0500
Ep 3550 | score medio(últ.50)=112.3 | wins(últ.50)=2 | epsilon=0.0500
Ep 3600 | score medio(últ.50)=114.9 | wins(últ.50)=0 | epsilon=0.0500
Ep 3650 | score medio(últ.50)=102.4 | wins(últ.50)=0 | epsilon=0.0500
Ep 3700 | score medio(últ.50)=114.2 | wins(últ.50)=0 | epsilon=0.0500
Ep 3750 | score medio(últ.50)=112.0 | wins(últ.50)=1 | epsilon=0.0500
Ep 3800 | score medio(últ.50)=112.3 | wins(últ.50)=0 | epsilon=0.0500
Ep 3850 | score medio(últ.50)=118.6 | wins(últ.50)=0 | epsilon=0.0500
Ep 3900 | score medio(últ.50)=113.6 | wins(últ.50)=1 | epsilon=0.0500
Ep 3950 | score medio(últ.50)=110.3 | wins(últ.50)=0 | epsilon=0.0500
Ep 4000 | score medio(últ.50)=114.8 | wins(últ.50)=1 | epsilon=0.0500

5. Curvas de aprendizaje

Las métricas clave son:

  • Score (comidas) y wins (niveles completados): indicadores directos de aprendizaje.
  • Reward: incluye señales densas (acercamiento/alejamiento) + terminales.
  • Loss: error de predicción de la función Q.
  • Epsilon: nivel de exploración restante.
[8]
def moving_average(x, window=30):
    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])

fig, axes = plt.subplots(2, 3, figsize=(16, 10))

# (1) Reward
ax = axes[0, 0]
ax.plot(history['train_reward'], alpha=0.25, label='Train reward')
ax.plot(moving_average(history['train_reward'], 30), linewidth=2, label='MA(30)')
ax.plot(history['eval_episode'], history['eval_reward'], marker='o', markersize=4, label='Eval reward')
ax.set_title('Recompensa por episodio')
ax.set_xlabel('Episodio')
ax.legend(fontsize=8)
ax.grid(alpha=0.3)

# (2) Score
ax = axes[0, 1]
ax.plot(history['train_score'], alpha=0.25, label='Train score')
ax.plot(moving_average(history['train_score'], 30), linewidth=2, label='MA(30)')
ax.plot(history['eval_episode'], history['eval_score'], marker='o', markersize=4, label='Eval score')
ax.set_title('Score (comida) por episodio')
ax.set_xlabel('Episodio')
ax.legend(fontsize=8)
ax.grid(alpha=0.3)

# (3) Wins acumulados
ax = axes[0, 2]
cumulative_wins = np.cumsum(history['train_wins'])
ax.plot(cumulative_wins, color='green', linewidth=2)
ax.set_title('Victorias acumuladas (entrenamiento)')
ax.set_xlabel('Episodio')
ax.set_ylabel('Wins totales')
ax.grid(alpha=0.3)

# (4) Loss
ax = axes[1, 0]
loss_clean = np.nan_to_num(history['mean_loss'], nan=0.0)
ax.plot(loss_clean, alpha=0.3, label='Loss media')
ax.plot(moving_average(loss_clean, 30), linewidth=2, label='MA(30)')
ax.set_title('Pérdida de Bellman (Huber)')
ax.set_xlabel('Episodio')
ax.legend(fontsize=8)
ax.grid(alpha=0.3)

# (5) Epsilon
ax = axes[1, 1]
ax.plot(history['epsilon'], color='purple', linewidth=2)
ax.set_title('Decaimiento de epsilon')
ax.set_xlabel('Episodio')
ax.grid(alpha=0.3)

# (6) Eval wins
ax = axes[1, 2]
if history['eval_wins']:
    ax.bar(history['eval_episode'], history['eval_wins'], width=15, color='#22c55e', alpha=0.7)
ax.set_title('Victorias en evaluación')
ax.set_xlabel('Episodio')
ax.set_ylabel('Wins (de 10 eval eps)')
ax.grid(alpha=0.3)

plt.tight_layout()
plt.show()
Output
[9]
# Evaluación final exhaustiva
final_eval_r, final_eval_s, final_eval_w = evaluate_agent(
    agent, training_levels, episodes_per_level=10, max_steps=500
)
total_eval_eps = len(training_levels) * 10
print(f"=== Evaluación final ({total_eval_eps} episodios, {len(training_levels)} niveles) ===")
print(f"Recompensa media: {final_eval_r:.1f}")
print(f"Score medio:      {final_eval_s:.1f}")
print(f"Victorias:        {final_eval_w} / {total_eval_eps}")
=== Evaluación final (50 episodios, 5 niveles) ===
Recompensa media: 101.9
Score medio:      128.5
Victorias:        1 / 50

6. Tests de sanidad

Verificamos que los componentes funcionan correctamente antes de visualizar.

[10]
# Tests de sanidad
test_lv = generate_level(width=15, height=15, seed=999)
test_env = PacmanEnv(level=test_lv, max_steps=200)
s0 = test_env.reset()
assert s0.shape == (STATE_DIM,), f"Estado debe ser {STATE_DIM}D, es {s0.shape}"

test_agent = DQNAgent(state_dim=STATE_DIM, action_dim=NUM_ACTIONS)

# 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, NUM_ACTIONS), f"Red debe devolver Q para {NUM_ACTIONS} acciones"

# Test train_step con buffer lleno
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, NUM_ACTIONS - 1)
        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 loss finita"

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

7. Demostración del agente entrenado

Ejecutamos el agente en modo greedy en un nivel y visualizamos la partida completa como animación.

  • 🟡 Amarillo: Pac-Man.
  • 🔴🩷🩵🟠 Colores: fantasmas (Blinky, Pinky, Inky, Clyde).
  • 🔵 Azul oscuro: fantasma asustado (power mode).
  • Puntos blancos: dots y power pellets.
  • 🟦 Azul: paredes del laberinto.
[18]
# Grabar un episodio demo con el agente entrenado
demo_level = training_levels[0]
demo_env = PacmanEnv(level=demo_level, max_steps=500)

def agent_action(state):
    return agent.act(state, explore=False)

frames, demo_info = record_episode(demo_env, agent_action, max_steps=500, cell_size=12)
print(f"Demo: score={demo_info['score']} | pasos={demo_info['steps']} | won={demo_info['won']} | frames={len(frames)}")
Demo: score=156 | pasos=194 | won=False | frames=195
[19]
# Animación inline del episodio
ani = create_animation(frames, interval=100)
HTML(ani.to_jshtml())

8. Exportar modelo para la demo web interactiva

Exportamos los pesos a JSON, transponiendo las matrices de pesos como hicimos con Snake (PyTorch almacena weight con shape (out, in), mientras que el JS necesita (in, out)).

[20]
import json

def export_pacman_dqn_to_json(model: QNetwork, filepath: str,
                               state_dim: int = 20, action_dim: int = 4):
    """
    Exporta los pesos de la QNetwork a JSON compatible con la demo JS.
    Transpone weight de PyTorch (out, in) a JS (in, out).
    """
    layers_export = []
    layer_modules = [m for m in model.net if isinstance(m, nn.Linear)]

    for i, linear in enumerate(layer_modules):
        w = linear.weight.detach().cpu().numpy().T  # (in, out)
        b = linear.bias.detach().cpu().numpy()       # (out,)

        is_last = (i == len(layer_modules) - 1)
        layers_export.append({
            "type": "linear" if is_last else "relu",
            "weight": w.flatten().tolist(),
            "weight_rows": w.shape[0],
            "weight_cols": w.shape[1],
            "bias": b.tolist(),
            "bias_cols": len(b),
        })

    export_data = {
        "architecture": {
            "input_size": state_dim,
            "hidden_sizes": [l.out_features for l in layer_modules[:-1]],
            "output_size": action_dim,
        },
        "state_features": [
            "wall_up", "wall_right", "wall_down", "wall_left",
            "ghost_danger_up", "ghost_danger_right", "ghost_danger_down", "ghost_danger_left",
            "dot_up", "dot_right", "dot_down", "dot_left",
            "dot_dist_norm",
            "ghost_up", "ghost_right", "ghost_down", "ghost_left",
            "ghost_dist_norm",
            "power_active",
            "dots_remaining_ratio",
        ],
        "action_mapping": {
            "0": "up", "1": "right", "2": "down", "3": "left"
        },
        "layers": layers_export,
    }

    os.makedirs(os.path.dirname(filepath), exist_ok=True)
    with open(filepath, "w") as f:
        json.dump(export_data, f)

    size_kb = os.path.getsize(filepath) / 1024
    print(f"✅ Modelo exportado a: {filepath}")
    print(f"   Arquitectura: {state_dim} → {[l.out_features for l in layer_modules[:-1]]} → {action_dim}")
    print(f"   Capas exportadas: {len(layers_export)}")
    print(f"   Tamaño: {size_kb:.1f} KB")

# Exportar
project_root = os.path.abspath(os.path.join(os.getcwd(), "..", "..", "..", ".."))
export_filepath = os.path.join(project_root, "static", "data", "pacman-drl", "pretrained_dqn.json")

export_pacman_dqn_to_json(agent.q_net, export_filepath)

# También exportar un nivel de ejemplo para la demo JS
level_filepath = os.path.join(project_root, "static", "data", "pacman-drl", "sample_level.json")
with open(level_filepath, "w") as f:
    json.dump(training_levels[0].to_dict(), f)
print(f"✅ Nivel de ejemplo exportado a: {level_filepath}")
✅ Modelo exportado a: /home/nuberu/xuan/naux/static/data/pacman-drl/pretrained_dqn.json
   Arquitectura: 20 → [256, 128, 64] → 4
   Capas exportadas: 4
   Tamaño: 973.4 KB
✅ Nivel de ejemplo exportado a: /home/nuberu/xuan/naux/static/data/pacman-drl/sample_level.json

9. Conclusiones

Lo que hemos construido

Componente Descripción
Level Generator Generador procedural de laberintos simétricos con conectividad garantizada
PacmanEnv Entorno MDP completo con fantasmas IA (chase/scatter/frightened)
Vector de estado 20 features: paredes, peligro fantasmas, dirección dots/fantasmas, power mode
QNetwork Red más profunda (20→256→128→64→4) para manejar la mayor complejidad
Entrenamiento multi-nivel 5 laberintos variados para evitar memorización
Renderer Visualización matplotlib con colores arcade
Exportación JSON Modelo compatible con la demo web interactiva

Diferencias clave con Snake

  1. Complejidad adversarial: los fantasmas añaden una dimensión de planificación defensiva que Snake no tiene.
  2. Entornos estructurados: la topología del laberinto condiciona las rutas posibles, a diferencia del tablero abierto de Snake.
  3. Power-ups temporales: el agente debe aprender cuándo usar las power pellets estratégicamente.
  4. Generalización: el entrenamiento con múltiples niveles fuerza al agente a aprender estrategias transferibles.

Ideas para experimentar

  1. Prioritized Experience Replay: muestrear experiencias difíciles más frecuentemente.
  2. Double DQN: reducir sobreestimación de Q-values.
  3. Curriculum Learning: empezar con niveles simples (sin fantasmas) y añadir dificultad gradualmente.
  4. Representación visual (CNN): dar la cuadrícula como imagen en vez de vector de features.
  5. Más fantasmas: aumentar a 4 fantasmas con personalidades diferentes (como el Pac-Man original).

📘 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, consulta la demo Pac-Man DRL.