Aprender actuando: refuerzo e imitación

Tema 20 bis (optativo) · Fase 4, Redes neuronales · Toda la ruta

Los 19 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

Agente, entorno y recompensa

import numpy as np
import matplotlib.pyplot as plt

# La cuadrícula: «I» inicio, «M» meta, «T» trampa, «#» pared, «.» libre
MAPA = np.array([list(fila) for fila in [
    "I..#....",
    ".#.#.##.",
    ".#...#..",
    ".##T.#.#",
    "....##..",
    ".#T.....",
    ".#.##T#.",
    "...#..TM",
]])
# Arriba, derecha, abajo, izquierda: cuánto cambian la fila y la columna
MOVIMIENTOS = [(-1, 0), (0, 1), (1, 0), (0, -1)]
FLECHAS = ["↑", "→", "↓", "←"]


def paso(mapa, estado, accion):
    """Mueve al robot; devuelve (nuevo estado, recompensa, terminado)."""
    fila, col = estado
    df, dc = MOVIMIENTOS[accion]
    nf, nc = fila + df, col + dc
    # 0 <= nf < 8 se lee «nf está entre 0 y 7». Contra una pared
    # o el borde, el robot se queda donde estaba.
    if not (0 <= nf < 8 and 0 <= nc < 8) or mapa[nf, nc] == "#":
        nf, nc = fila, col
    if mapa[nf, nc] == "M":
        return (nf, nc), 1.0, True
    if mapa[nf, nc] == "T":
        return (nf, nc), -1.0, True
    return (nf, nc), 0.0, False


print(MAPA.shape)
# Tres pasos desde el inicio: arriba (choca con el borde), abajo, derecha
estado = (0, 0)
for accion in [0, 2, 1]:
    estado, recompensa, fin = paso(MAPA, estado, accion)
    print(FLECHAS[accion], estado, recompensa, fin)
rng = np.random.default_rng(0)
resultados = {"meta": 0, "trampa": 0, "se acabaron los pasos": 0}
for episodio in range(1000):
    estado = (0, 0)
    for t in range(100):
        estado, recompensa, fin = paso(MAPA, estado, rng.integers(4))
        if fin:
            break
    if recompensa > 0:
        resultados["meta"] += 1
    elif recompensa < 0:
        resultados["trampa"] += 1
    else:
        resultados["se acabaron los pasos"] += 1
print(resultados)
gamma = 0.9
for pasos in [1, 2, 5, 14, 30]:
    print(f"Meta en {pasos:2d} pasos: retorno {gamma ** (pasos - 1):.3f}")

Iteración de valor: cuando conocemos el mapa

def valores_q(mapa, V, estado, gamma):
    """Lo que vale cada una de las 4 acciones desde `estado`, según V."""
    q = np.zeros(4)
    for a in range(4):
        siguiente, r, fin = paso(mapa, estado, a)
        q[a] = r if fin else r + gamma * V[siguiente]
    return q


def iteracion_de_valor(mapa, gamma=0.9, tolerancia=1e-6):
    V = np.zeros((8, 8))
    for vuelta in range(1, 1001):
        V_nuevo = np.zeros((8, 8))
        for f in range(8):
            for c in range(8):
                # Paredes, meta y trampas no tienen futuro: valor 0
                if mapa[f, c] in "#MT":
                    continue
                V_nuevo[f, c] = valores_q(mapa, V, (f, c), gamma).max()
        cambio = np.abs(V_nuevo - V).max()
        V = V_nuevo
        if cambio < tolerancia:
            break
    return V, vuelta


V, vueltas = iteracion_de_valor(MAPA)
print(f"Se estabilizó en {vueltas} vueltas")
print(f"Valor del inicio: {V[0, 0]:.3f}")
print(np.round(V, 2))
def politica_desde_v(mapa, V, gamma=0.9):
    politica = np.zeros((8, 8), dtype=int)
    for f in range(8):
        for c in range(8):
            politica[f, c] = valores_q(mapa, V, (f, c), gamma).argmax()
    return politica


def mostrar(mapa, politica):
    """Imprime la política como flechas, con paredes, meta y trampas."""
    for f in range(8):
        fila = [mapa[f, c] if mapa[f, c] in "#MT"
                else FLECHAS[politica[f, c]] for c in range(8)]
        print(" ".join(fila))


politica_optima = politica_desde_v(MAPA, V)
mostrar(MAPA, politica_optima)
from matplotlib.colors import LinearSegmentedColormap

blanco_tinta = LinearSegmentedColormap.from_list("bt", ["white", "C0"])
# Paredes, meta y trampas sin color de valor: las pintamos aparte.
# np.isin dice, casilla por casilla, si la letra está en la lista.
valor = np.ma.masked_where(np.isin(MAPA, ["#", "M", "T"]), V)
plt.imshow(valor, cmap=blanco_tinta, vmin=0, vmax=1)
plt.colorbar(label="Valor V(s)")
filas, cols = np.indices((8, 8))
libres = ~np.isin(MAPA, ["#", "M", "T"])
# .flat recorre el arreglo casilla por casilla, fila tras fila
dx = np.array([MOVIMIENTOS[a][1] for a in politica_optima.flat])
dy = np.array([MOVIMIENTOS[a][0] for a in politica_optima.flat])
# Flechas blancas sobre el fondo oscuro y de tinta sobre el claro
colores = np.where(V.flat > 0.5, "white", "C0")[libres.flat]
plt.quiver(cols[libres], filas[libres], dx[libres.flat], dy[libres.flat],
           color=colores, angles="xy", scale_units="xy", scale=2.2,
           pivot="middle", width=0.012)
# Solo para dibujar: paredes, trampas y meta
paredes = np.argwhere(MAPA == "#")
plt.scatter(paredes[:, 1], paredes[:, 0], marker="s", s=330, color="C3")
trampas = np.argwhere(MAPA == "T")
plt.scatter(trampas[:, 1], trampas[:, 0], marker="x", s=90, color="C2")
plt.scatter([7], [7], s=160, color="C1")
plt.xticks([])
plt.yticks([])
plt.title("Valor y mejor acción")
plt.show()

Q-learning: aprender probando

def q_learning(mapa, episodios=500, alfa=0.5, gamma=0.9, epsilon=0.1,
               semilla=0):
    rng = np.random.default_rng(semilla)
    Q = np.zeros((8, 8, 4))
    recompensas = []
    for episodio in range(episodios):
        s, total = (0, 0), 0.0
        for t in range(100):
            if rng.random() < epsilon:
                a = rng.integers(4)                  # explorar
            else:
                # Explotar: la mejor acción; si empatan, una de ellas
                mejores = np.flatnonzero(Q[s] == Q[s].max())
                a = rng.choice(mejores)
            s2, r, fin = paso(mapa, s, a)
            objetivo = r if fin else r + gamma * Q[s2].max()
            Q[s][a] += alfa * (objetivo - Q[s][a])
            total += r
            s = s2
            if fin:
                break
        recompensas.append(total)
    return Q, np.array(recompensas)


Q, recompensas = q_learning(MAPA)
print(f"Recompensa media, primeros 100 episodios: "
      f"{recompensas[:100].mean():.2f}")
print(f"Recompensa media, últimos 100 episodios: "
      f"{recompensas[-100:].mean():.2f}")
print(f"Q del inicio: {Q[0, 0].max():.3f}  (V del inicio: {V[0, 0]:.3f})")
politica_q = Q.argmax(axis=2)
mostrar(MAPA, politica_q)
libres = ~np.isin(MAPA, ["#", "M", "T"])
iguales = (politica_q == politica_optima)[libres].sum()
print(f"Coincide con la óptima en {iguales} de {libres.sum()} casillas")
import pandas as pd


def llega_desde(mapa, Q):
    """Desde cuántas casillas libres llegan a la meta las flechas de Q."""
    llegan = 0
    for f, c in np.argwhere(~np.isin(mapa, ["#", "M", "T"])):
        s = (f, c)
        for t in range(100):
            s, r, fin = paso(mapa, s, Q[s].argmax())
            if fin:
                break
        llegan += r > 0
    return llegan


for epsilon, color in [(0.0, "C2"), (0.1, "C1"), (0.3, "C0")]:
    Q_e, rec = q_learning(MAPA, epsilon=epsilon)
    print(f"ε = {epsilon}: recompensa media al final "
          f"{rec[-100:].mean():.2f}, llega desde "
          f"{llega_desde(MAPA, Q_e)} de 40 casillas")
    plt.plot(pd.Series(rec).rolling(20).mean(), color=color,
             label=rf"$\varepsilon$ = {epsilon}")
plt.xlabel("Episodio")
plt.ylabel("Recompensa (media de 20)")
plt.ylim(-1.5, 1.1)
plt.legend(loc="lower right")
plt.title(r"Q-learning con tres valores de $\varepsilon$")
plt.show()

Aprender imitando: clonación de comportamiento

from collections import deque


def distancias_bfs(mapa, meta):
    """Pasos que faltan hasta la meta desde cada casilla (−1: no llega)."""
    dist = np.full((8, 8), -1)
    dist[meta] = 0
    # Una cola: se agrega por la derecha y se saca por la izquierda
    cola = deque([meta])
    while cola:
        f, c = cola.popleft()
        for df, dc in MOVIMIENTOS:
            nf, nc = f + df, c + dc
            if (0 <= nf < 8 and 0 <= nc < 8 and mapa[nf, nc] != "#"
                    and dist[nf, nc] < 0):
                dist[nf, nc] = dist[f, c] + 1
                cola.append((nf, nc))
    return dist


def experto(dist, estado):
    """La acción que se mueve a una casilla un paso más cerca."""
    f, c = estado
    for a, (df, dc) in enumerate(MOVIMIENTOS):
        nf, nc = f + df, c + dc
        if 0 <= nf < 8 and 0 <= nc < 8 and dist[nf, nc] == dist[f, c] - 1:
            return a
def mapa_al_azar(rng, prob_pared=0.25):
    """Un mapa con paredes al azar, su inicio y sus distancias."""
    while True:
        mapa = np.where(rng.random((8, 8)) < prob_pared, "#", ".")
        libres = np.argwhere(mapa == ".")
        elegidas = rng.choice(len(libres), 2, replace=False)
        inicio, meta = [tuple(int(v) for v in libres[k]) for k in elegidas]
        mapa[meta] = "M"
        dist = distancias_bfs(mapa, meta)
        if dist[inicio] >= 4:
            return mapa, inicio, dist


rng = np.random.default_rng(1)
mapa, inicio, dist = mapa_al_azar(rng)
for fila in mapa:
    print(" ".join(fila))
print(f"Inicio {inicio}, a {dist[inicio]} pasos de la meta")
def codificar(mapa, estado):
    x = np.zeros((3, 8, 8), dtype=np.float32)
    x[0] = mapa == "#"
    x[1][estado] = 1
    x[2] = mapa == "M"
    return x


def demostraciones(mapas):
    """Cada paso del experto en cada mapa: (situación, acción)."""
    X, Y = [], []
    for mapa, inicio, dist in mapas:
        s = inicio
        while mapa[s] != "M":
            a = experto(dist, s)
            X.append(codificar(mapa, s))
            Y.append(a)
            s, _, _ = paso(mapa, s, a)
    return np.array(X), np.array(Y)


rng = np.random.default_rng(0)
mapas_ent = [mapa_al_azar(rng) for _ in range(2000)]
mapas_val = [mapa_al_azar(rng) for _ in range(400)]
X_ent, y_ent = demostraciones(mapas_ent)
X_val, y_val = demostraciones(mapas_val)
print("Entrenamiento:", X_ent.shape, " Validación:", X_val.shape)
print("Acciones del experto:", np.bincount(y_ent))
import torch
from torch import nn


def evaluar(modelo, cargador, funcion_perdida, dispositivo="cpu"):
    """Pérdida media y exactitud de `modelo` sobre `cargador`."""
    modelo.eval()
    suma, aciertos, total = 0.0, 0, 0
    with torch.no_grad():
        for x, y in cargador:
            x, y = x.to(dispositivo), y.to(dispositivo)
            salida = modelo(x)
            suma += funcion_perdida(salida, y).item() * len(y)
            aciertos += (salida.argmax(dim=1) == y).sum().item()
            total += len(y)
    return suma / total, aciertos / total


def entrenar(modelo, cargador_ent, cargador_val, epocas, optimizador,
             funcion_perdida=None, planificador=None, dispositivo="cpu",
             mostrar=True):
    """Entrena `modelo` y devuelve el historial de cada época."""
    if funcion_perdida is None:
        funcion_perdida = nn.CrossEntropyLoss()
    modelo.to(dispositivo)
    historial = {"perdida_ent": [], "exactitud_ent": [],
                 "perdida_val": [], "exactitud_val": []}
    for epoca in range(1, epocas + 1):
        modelo.train()
        suma, aciertos, total = 0.0, 0, 0
        for x, y in cargador_ent:
            x, y = x.to(dispositivo), y.to(dispositivo)
            optimizador.zero_grad()
            salida = modelo(x)
            perdida = funcion_perdida(salida, y)
            perdida.backward()
            optimizador.step()
            suma += perdida.item() * len(y)
            aciertos += (salida.argmax(dim=1) == y).sum().item()
            total += len(y)
        if planificador is not None:
            planificador.step()
        perdida_val, exactitud_val = evaluar(
            modelo, cargador_val, funcion_perdida, dispositivo)
        historial["perdida_ent"].append(suma / total)
        historial["exactitud_ent"].append(aciertos / total)
        historial["perdida_val"].append(perdida_val)
        historial["exactitud_val"].append(exactitud_val)
        if mostrar:
            print(f"Época {epoca}: pérdida {suma / total:.3f}, "
                  f"validación {perdida_val:.3f}, "
                  f"exactitud de validación {exactitud_val:.3f}")
    return historial
from torch.utils.data import TensorDataset, DataLoader

device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(0)
cargador_ent = DataLoader(
    TensorDataset(torch.tensor(X_ent), torch.tensor(y_ent)),
    batch_size=64, shuffle=True,
    generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(
    TensorDataset(torch.tensor(X_val), torch.tensor(y_val)),
    batch_size=256)

imitador = nn.Sequential(
    nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(),
    nn.Conv2d(32, 32, kernel_size=3, padding=1), nn.ReLU(),
    nn.Flatten(),
    nn.Linear(32 * 8 * 8, 4),
)
print("Parámetros:", sum(p.numel() for p in imitador.parameters()))
optimizador = torch.optim.Adam(imitador.parameters(), lr=0.001)
historial = entrenar(imitador, cargador_ent, cargador_val, 10, optimizador,
                     dispositivo=device)
def jugar(red, mapa, inicio, limite=40):
    """Deja que la red juegue sola. Devuelve si llegó y su recorrido."""
    red.eval()
    s, camino = inicio, [inicio]
    for t in range(limite):
        x = torch.tensor(codificar(mapa, s)).unsqueeze(0).to(device)
        with torch.no_grad():
            a = red(x).argmax().item()
        s, r, fin = paso(mapa, s, a)
        camino.append(s)
        if fin:
            return True, camino
    return False, camino


rng = np.random.default_rng(2026)
mapas_publicos = [mapa_al_azar(rng) for _ in range(300)]
mapas_ocultos = [mapa_al_azar(rng) for _ in range(300)]

partidas = [jugar(imitador, m, i) for m, i, d in mapas_publicos]
llego = np.array([llega for llega, camino in partidas])
pasos = np.array([len(camino) - 1 for llega, camino in partidas])
distancia = np.array([d[i] for m, i, d in mapas_publicos])
# Sin errores: llega con los mismos pasos que el experto
perfecto = llego & (pasos == distancia)
print(f"Llega a la meta en {llego.mean():.1%} de los mapas públicos")
print(f"Llega sin ningún error en {perfecto.mean():.1%}")
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
# Tres mapas en que llega y el primero en que no llega
elegidos = [0, 1, 2, np.flatnonzero(~llego)[0]]
for eje, k in zip(ejes.flat, elegidos):
    mapa, inicio, dist = mapas_publicos[k]
    llega, camino = jugar(imitador, mapa, inicio)
    camino = np.array(camino)
    eje.imshow(mapa == "#", cmap=blanco_tinta, vmin=0, vmax=3)
    eje.plot(camino[:, 1], camino[:, 0], color="C0", lw=2.5)
    eje.scatter([inicio[1]], [inicio[0]], s=60, color="C2")
    meta = np.argwhere(mapa == "M")[0]
    eje.scatter([meta[1]], [meta[0]], s=200, color="C0", marker="*")
    eje.set_title(f"{len(camino) - 1} pasos (mín. {dist[inicio]})"
                  if llega else "No llega")
    eje.set_xticks([])
    eje.set_yticks([])
plt.show()

Por qué los errores de la imitación se acumulan

def proporcion_buena(red, mapas):
    """En los pasos del experto, cuántas veces la red se acerca a la meta."""
    red.eval()
    buenos, total = 0, 0
    for mapa, inicio, dist in mapas:
        s = inicio
        while mapa[s] != "M":
            x = torch.tensor(codificar(mapa, s)).unsqueeze(0).to(device)
            with torch.no_grad():
                a = red(x).argmax().item()
            s_red, _, _ = paso(mapa, s, a)
            buenos += dist[s_red] == dist[s] - 1
            total += 1
            s, _, _ = paso(mapa, s, experto(dist, s))
    return buenos / total


p = proporcion_buena(imitador, mapas_val)
print(f"Exactitud: {historial['exactitud_val'][-1]:.3f}  "
      f"Pasos buenos: {p:.3f}")
grupos = [(4, 6), (7, 9), (10, 40)]
nombres, llegan, sin_error = [], [], []
for desde, hasta in grupos:
    en_grupo = (distancia >= desde) & (distancia <= hasta)
    nombres.append(f"{desde}–{hasta}" if hasta < 40 else f"{desde} o más")
    llegan.append(llego[en_grupo].mean())
    sin_error.append(perfecto[en_grupo].mean())
    print(f"{nombres[-1]} pasos ({en_grupo.sum()} mapas): llega "
          f"{llegan[-1]:.0%}, sin ningún error {sin_error[-1]:.0%}")

x = np.arange(len(grupos))
plt.bar(x - 0.2, llegan, width=0.4, color="C0", label="Llega")
plt.bar(x + 0.2, sin_error, width=0.4, color="C2", label="Sin ningún error")
plt.xticks(x, nombres)
plt.xlabel("Pasos del camino del experto")
plt.ylabel("Proporción de mapas")
plt.ylim(0, 1)
plt.legend(loc="upper right")
plt.title("Más pasos, más fallas")
plt.show()
llego_oculto = np.array([jugar(imitador, m, i)[0]
                         for m, i, d in mapas_ocultos])
print(f"Llega a la meta en {llego_oculto.mean():.1%} de los mapas ocultos")