Curso práctico · snake_red_neuronal · 6 lecciones + ejercicios

Cómo enseñar a una red neuronal a jugar Snake

Un recorrido desde cero: cómo traducir el tablero a números que la red pueda aprovechar, cómo diseñar las recompensas para que no dé vueltas en círculo, cómo funcionan Deep Q-Learning (DQN) y la Neuroevolución, y cómo evitar que la serpiente se encierre en su propio cuerpo cuando crece y acelera.

Cómo correr los scripts mientras lees: cada sección tiene su archivo Python con verificaciones automáticas.

cd snake_red_neuronal
uv run --no-project --index https://download.pytorch.org/whl/cpu --index-strategy unsafe-best-match \
    --with-requirements requirements.txt python 01_el_juego_y_el_estado.py
1

El tablero, el giro relativo y la invarianza del estado

01_el_juego_y_el_estado.py · snake.py

En el Snake clásico (tablero de 10×10), la serpiente empieza con 3 casillas de largo y velocidad de 6 pasos por segundo. Cada vez que come una manzana, su cola crece en 1 casilla y su velocidad sube según:

velocidad(largo) = 6,0 + 0,45 · (largo − 3)

Por qué la red tiene 3 salidas y no 4

Un humano juega con 4 flechas (derecha, abajo, izquierda, arriba). Pero si la serpiente va hacia la derecha y aprietas izquierda, choca de frente contra su propio cuello. En lugar de obligar a la red a aprender qué botón está prohibido según hacia dónde mire, le damos 3 acciones relativas a su cabeza:

  • 0 · seguir recto: mantiene el rumbo actual.
  • 1 · girar a la derecha: rota 90° en sentido horario.
  • 2 · girar a la izquierda: rota 90° en sentido antihorario.

Qué ve la red: coordenadas crudas vs sensores egocéntricos

Si le damos las coordenadas crudas (cabeza_x, cabeza_y, manzana_x, manzana_y, dx, dy), una jugada aprendida en la esquina superior izquierda (2, 2) tiene números completamente distintos a la misma jugada en (6, 7). En cambio, con 11 sensores binarios centrados en la cabeza (estado_11), ambas situaciones producen exactamente el mismo vector:

Widget · Compara el estado crudo (6) contra el estado invariante (11)

Posición
Mirando

Qué observar

Cambia entre el Rincón A y el Rincón B: el estado crudo cambia por completo, mientras que estado_11 permanece idéntico. Lo que la red aprende en una casilla sirve instantáneamente en las 100 casillas del tablero.

2

Políticas, recompensas escasas y la trampa de girar en círculo

02_recompensas_y_politica.py

Una política π(s) → a es cualquier regla que elija una acción dado el estado. En 02_recompensas_y_politica.py medimos dos políticas sin aprendizaje en 80 partidas:

  • Al azar: promedia 0,25 manzanas y muere en 22 pasos (79 de 80 veces contra la pared).
  • Heurística codiciosa: evita choques a 1 casilla y se acerca a la manzana. Promedia 18,73 manzanas y 0 choques con la pared, pero muere las 80 veces encerrada en su propio cuerpo.

Por qué una red con recompensa escasa aprende a bailar en círculos

Si solo damos +10 al comer, −10 al chocar y 0 en los demás pasos, ocurre una trampa clásica: al principio la red casi nunca come por azar, pero sí choca seguido. Rápido descubre que si repite girar derecha en cada turno, da vueltas en un cuadrado de 2×2 casillas para siempre y recibe 0 en vez de −10.

Para romper el bucle (Tesis H2), agregamos dos reglas: un límite de hambre de 100 × largo pasos sin comer, y una guía asimétrica por distancia Manhattan a la manzana:

Widget · Qué gana una serpiente que da 3 vueltas en círculo (12 pasos sin comer)

Si el premio y el castigo fueran simétricos (+0,20 y −0,20), dar una vuelta completa (2 pasos acercándose y 2 alejándose) sumaría 0,00. Con +0,20 y −0,25 cada vuelta pierde −0,10: oscilar deja de ser negocio.

3

Q-Learning y la ecuación de Bellman

03_q_learning_y_bellman.py

En lugar de decirle a la red qué giro tomar, le pedimos que prediga tres números para el estado actual s: Q(s, recto), Q(s, derecha) y Q(s, izquierda). Cada número estima la recompensa total descontada que obtendrá de aquí al final de la partida si elige ese giro.

¿Cómo entrenamos la red si al principio no sabemos el valor real de Q(s, a)? Damos un paso en el juego, observamos la recompensa inmediata r y el nuevo estado s', y usamos la ecuación de Bellman como objetivo de regresión:

Qobjetivo(s, a) = r + γ · máxa' Q(s', a') · (1 − terminó)

Widget · Un paso de actualización de Bellman

Paso

4

Experience Replay, Red Objetivo y aceleración por cadencia

04_replay_y_red_objetivo.py

Entrenar la red con la última jugada que acaba de ocurrir (como en la lección 3) tiene dos problemas graves:

  1. Olvido catastrófico por correlación: si la serpiente pasa 15 turnos seguidos en la parte baja del tablero, los 15 pasos de gradiente consecutivos empujan los pesos en la misma dirección y borran lo que había aprendido para el resto del mapa.
  2. Cuello de botella de CPU: ejecutar loss.backward() y opt.step() en PyTorch en cada turno consume más del 75 % del tiempo total.

En 04_replay_y_red_objetivo.py implementamos tres mejoras (Tesis H3):

  • Replay Buffer: guardamos las últimas 10 000 transiciones (s, a, r, s', terminó) y entrenamos con lotes de 64 tomadas al azar.
  • Double DQN con Red Objetivo: la red principal elige la mejor acción siguiente a*, pero una copia congelada (Qobj, actualizada cada 8–10 episodios) evalúa su valor, evitando oscilaciones.
  • Cadencia K = 4: la serpiente juega 4 turnos en el tablero por cada actualización de mini-batch. En la prueba medida en 04_replay_y_red_objetivo.py, el tiempo baja de 2,83 s a 0,83 s (3,41× más rápido) y la media sube de 16,5 a 20,0 manzanas.
5

Neuroevolución: aprender sin derivadas ni ecuación de Bellman

05_neuroevolucion_snake.py

¿Hace falta calcular gradientes para entrenar la red? En la lección 5 probamos el camino evolutivo: creamos una población de 45 redes pequeñas en NumPy (11 → 12 → 3, apenas 183 parámetros). Cada red juega varias partidas completas y recibe un fitness igual a 100 × manzanas + recompensa_acumulada.

Dos trampas críticas en Neuroevolución

1. Mutación excesiva: en 05_neuroevolucion_snake.py medimos 40 hijos de un campeón de 25 manzanas. Con mutación agresiva (cambiar 80 % de los pesos con desviación 0,60), la media de los hijos cae a 0,10 manzanas (ninguno conserva lo aprendido). Con mutación suave (12 % de los pesos, desviación 0,15), el 55 % de los hijos conserva al menos el 70 % del nivel del padre.

2. Cruza uniforme sin alinear (Competing Conventions): en una red densa común, la neurona oculta 4 de un padre puede detectar "peligro a la derecha" y en otro padre detectar "manzana arriba". Mezclar al azar 50 % de los pesos de dos padres destruye ambas funciones. La reproducción clonal de la élite con mutación suave resuelve el problema y pasa de 0,6 a 25,0 manzanas en 30 generaciones.

6

Por qué se encierra la serpiente larga y el sensor Flood-Fill

06_percepcion_de_cola_y_espacio.py

Tanto la heurística codiciosa como el DQN de 11 entradas chocan contra un techo alrededor de las 18–20 manzanas. Al inspeccionar las causas de muerte, el 90 % son choques contra el propio cuerpo. ¿Por qué? Porque los 3 sensores de peligro de estado_11 solo miran a 1 casilla de distancia.

Cuando el cuerpo mide 22 casillas y forma un lazo cerrado con 4 casillas vacías adentro, la casilla de entrada al lazo está libre: peligro = 0. La red entra buscando la manzana y descubre demasiado tarde que no hay salida.

En la Tesis H4 (estado_14) agregamos 3 sensores continuos entre 0 y 1: desde cada una de las 3 casillas vecinas (recto, derecha, izquierda), ejecutamos una búsqueda en anchura (flood-fill) acotada para contar qué fracción del espacio libre es realmente alcanzable:

Widget · La trampa de la cola (lección 6): 11 entradas vs 14 entradas

Escenario
Acción candidataCasilla destinoPeligro a 1 paso (estado_11)Casillas alcanzables (BFS)Sensor Flood-Fill (estado_14)Decisión

La manzana está hacia la derecha. Con 11 entradas, girar a la derecha parece seguro (peligro = 0) y acerca a la manzana, así que la serpiente entra al callejón y muere. Con 14 entradas, ve que la derecha tiene 0,12 de espacio y gira a la izquierda (1,00).

7

Soluciones de ejercicios.py

ejercicios.py · 5 funciones autocorregibles

Intenta resolver primero ejercicios.py por tu cuenta corriendo el archivo. Aquí están las soluciones de referencia:

def giro_relativo(dir_idx, accion):
    if accion == 1:
        return (dir_idx + 1) % 4
    if accion == 2:
        return (dir_idx - 1) % 4
    return dir_idx


def recompensa_paso(comio, murio, dist_antes, dist_ahora):
    if murio:
        return -10.0
    if comio:
        return 10.0
    return 0.2 if dist_ahora < dist_antes else -0.25


def objetivo_bellman(recompensas, q_siguientes, terminados, gamma=0.9):
    max_q = np.max(q_siguientes, axis=1)
    return recompensas + gamma * max_q * (1.0 - np.asarray(terminados, dtype=float))


def espacio_alcanzable(ocupadas, lado, inicio):
    x0, y0 = inicio
    if x0 < 0 or x0 >= lado or y0 < 0 or y0 >= lado or inicio in ocupadas:
        return 0
    visitadas = {inicio}
    cola = deque([inicio])
    while cola:
        x, y = cola.popleft()
        for dx, dy in ((1, 0), (0, 1), (-1, 0), (0, -1)):
            nx, ny = x + dx, y + dy
            vec = (nx, ny)
            if 0 <= nx < lado and 0 <= ny < lado and vec not in ocupadas and vec not in visitadas:
                visitadas.add(vec)
                cola.append(vec)
    return len(visitadas)


def mutar_pesos(pesos, tasa, escala, rng):
    hijo = pesos.copy()
    mascara = rng.random(hijo.shape) < tasa
    hijo[mascara] += rng.normal(0.0, escala, size=int(mascara.sum())).astype(hijo.dtype)
    return hijo