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.
👻 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:
- Entornos adversariales: los fantasmas persiguen activamente al agente.
- Laberintos estructurados: la topología del nivel condiciona las estrategias posibles.
- Power-ups temporales: las power pellets cambian la dinámica del juego por un tiempo limitado.
- Objetivos múltiples: comer dots, evitar fantasmas, y aprovechar las power pellets.
Al finalizar, habrás implementado y comprendido:
- Generación procedural de niveles con simetría y conectividad garantizada.
- Un entorno MDP completo con Pac-Man, fantasmas con IA, dots y power pellets.
- Un vector de estado compacto de 20 dimensiones que captura toda la información relevante.
- Una red DQN más profunda (256→128→64→4 acciones) adaptada a la mayor complejidad.
- Entrenamiento con múltiples niveles para que el agente generalice.
- Evaluación visual con animaciones del agente jugando.
- 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.
# 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).
# 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
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.
# 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)
# 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
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.
# ──────────────────────────────────────────────────
# 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:
- Múltiples niveles: rotamos entre 5 laberintos diferentes para forzar generalización.
- Más episodios (800): la mayor complejidad requiere más experiencia.
- $\varepsilon$ decay más lento (0.998): necesitamos más exploración para descubrir estrategias como usar power pellets en el momento adecuado.
- 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.
# 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
# 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.
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()
# 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.
# 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.
# 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
# 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)).
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
- Complejidad adversarial: los fantasmas añaden una dimensión de planificación defensiva que Snake no tiene.
- Entornos estructurados: la topología del laberinto condiciona las rutas posibles, a diferencia del tablero abierto de Snake.
- Power-ups temporales: el agente debe aprender cuándo usar las power pellets estratégicamente.
- Generalización: el entrenamiento con múltiples niveles fuerza al agente a aprender estrategias transferibles.
Ideas para experimentar
- Prioritized Experience Replay: muestrear experiencias difíciles más frecuentemente.
- Double DQN: reducir sobreestimación de Q-values.
- Curriculum Learning: empezar con niveles simples (sin fantasmas) y añadir dificultad gradualmente.
- Representación visual (CNN): dar la cuadrícula como imagen en vez de vector de features.
- 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.