Aprender actuando: refuerzo e imitación

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

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 19 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; 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)
# Leyenda debajo del gráfico, para que no tape las barras del medio
plt.legend(loc="upper center", bbox_to_anchor=(0.5, -0.25), ncol=2,
           frameon=False, handlelength=1.6, columnspacing=1.2)
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")