Aprender actuando: refuerzo e imitación
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 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)
# 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")¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.