Aprender actuando: refuerzo e imitación
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 adef 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 historialfrom 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")