PyTorch: tensores, autograd y el bucle de entrenamiento
Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.
Los 27 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.
Tensores: arreglos de NumPy con superpoderes
import numpy as np
import torch
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
t = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]])
print(t)
print("Forma:", t.shape, " dtype:", t.dtype)
print("Suma por columnas:", t.sum(dim=0))
print("Promedio de cada fila:", t.mean(dim=1))
print("t @ t.T:", t @ t.T)ceros = torch.zeros(2, 3)
azar = torch.rand(2, 3) # uniformes entre 0 y 1
enteros = torch.arange(6) # 0, 1, ..., 5
print(azar)
print(enteros, enteros.dtype)
print(ceros.dtype, "en", ceros.device)a = np.array([1.0, 2.0, 3.0])
compartido = torch.from_numpy(a) # comparte la memoria con a
copia = torch.tensor(a, dtype=torch.float32) # una copia nueva
a[0] = 100
print(compartido)
print(copia)
print(copia.numpy()) # de vuelta a NumPyCambiar la forma
x = torch.arange(24).reshape(2, 3, 4)
print("x: ", tuple(x.shape))
print("x.view(6, 4): ", tuple(x.view(6, 4).shape))
print("x.reshape(2, -1):", tuple(x.reshape(2, -1).shape))
print("x.flatten(): ", tuple(x.flatten().shape))
print("x.flatten(1): ", tuple(x.flatten(1).shape))
print("x.permute(2, 0, 1):", tuple(x.permute(2, 0, 1).shape))
print("x.unsqueeze(0): ", tuple(x.unsqueeze(0).shape))
print("x.unsqueeze(0).squeeze(0):",
tuple(x.unsqueeze(0).squeeze(0).shape))
print("x.sum(dim=1): ", tuple(x.sum(dim=1).shape))a = torch.zeros(2, 3)
b = torch.ones(2, 3)
print("cat: ", tuple(torch.cat([a, b]).shape))
print("cat, dim=1: ", tuple(torch.cat([a, b], dim=1).shape))
print("stack: ", tuple(torch.stack([a, b]).shape))
print("stack, dim=1: ", tuple(torch.stack([a, b], dim=1).shape))lote = torch.rand(8, 3, 28, 28) # 8 imágenes RGB de 28 × 28
print("Lote en PyTorch: ", tuple(lote.shape))
# Para matplotlib, los canales van al final
print("Lote para matplotlib:", tuple(lote.permute(0, 2, 3, 1).shape))
una = lote[0] # una sola imagen
print("Una imagen: ", tuple(una.shape))
# Las redes esperan siempre un lote: uno de una sola imagen
print("Lote de una imagen: ", tuple(una.unsqueeze(0).shape))Autograd: tu motor del tema 17, ya hecho
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-3.0, requires_grad=True)
c = torch.tensor(10.0, requires_grad=True)
L = (a * b + c) ** 2
print(L)
L.backward() # hacia atrás: llena los .grad
print("dL/da =", a.grad.item(), " dL/db =", b.grad.item(),
" dL/dc =", c.grad.item())L = (a * b + c) ** 2 # el mismo cálculo otra vez
L.backward()
print("Sin borrar, dL/da =", a.grad.item())
a.grad.zero_() # borrar el gradiente acumulado
L = (a * b + c) ** 2
L.backward()
print("Después de borrar, dL/da =", a.grad.item())# Los mismos datos sintéticos del tema 6
rng = np.random.default_rng(42)
horas = rng.uniform(0, 8, size=30)
nota = 30 + 7 * horas + rng.normal(0, 6, size=30)
x = torch.tensor(horas, dtype=torch.float32)
y = torch.tensor(nota, dtype=torch.float32)
w = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
tasa = 0.02
for paso in range(2000):
perdida = ((w * x + b - y) ** 2).mean() # el MSE
perdida.backward() # dMSE/dw y dMSE/db
with torch.no_grad(): # el paso no va al grafo
w -= tasa * w.grad
b -= tasa * b.grad
w.grad.zero_()
b.grad.zero_()
print(f"w = {w.item():.2f} b = {b.item():.2f}"
f" MSE = {perdida.item():.1f}")from torch import nn
torch.manual_seed(0)
recta = nn.Linear(1, 1) # w y b, con valores al azar
funcion_perdida = nn.MSELoss()
optimizador = torch.optim.SGD(recta.parameters(), lr=0.02)
# nn.Linear espera una tabla (ejemplos, características): (30, 1)
X_horas = x.unsqueeze(1)
Y_notas = y.unsqueeze(1)
for paso in range(2000):
optimizador.zero_grad() # 1. gradientes en cero
perdida = funcion_perdida(recta(X_horas), Y_notas) # 2. adelante
perdida.backward() # 3. atrás
optimizador.step() # 4. un paso en contra
print(f"w = {recta.weight.item():.2f} b = {recta.bias.item():.2f}")Las prendas: FashionMNIST
from torchvision import datasets
datos_ent = datasets.FashionMNIST(root="datos", train=True,
download=True)
datos_pru = datasets.FashionMNIST(root="datos", train=False,
download=True)
print(len(datos_ent), "fotos para entrenar y", len(datos_pru),
"para la prueba final")
print("Fotos:", tuple(datos_ent.data.shape), datos_ent.data.dtype)
print("Etiquetas:", datos_ent.targets[:10])
# Las 10 clases, en el orden de las etiquetas 0 a 9.
# Polera es T-shirt/top y Chompa, Pullover, en el original en inglés.
NOMBRES = ["Polera", "Pantalón", "Chompa", "Vestido", "Abrigo",
"Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from torchvision.utils import make_grid
# Del blanco al color "C0": la prenda oscura sobre fondo claro
tinta = LinearSegmentedColormap.from_list("tinta", ["white", "C0"])
filas = []
for k in range(10):
# Las posiciones de las primeras 8 fotos de la clase k: nonzero da
# los índices donde vale True, en forma (n, 1), y squeeze(1) quita
# esa columna de más
indices = (datos_ent.targets == k).nonzero().squeeze(1)[:8]
filas.append(datos_ent.data[indices]) # (8, 28, 28)
lote = torch.cat(filas).unsqueeze(1) # (80, 1, 28, 28)
mosaico = make_grid(lote, nrow=8, padding=2) # (3, 302, 242)
print("Lote:", tuple(lote.shape), " mosaico:", tuple(mosaico.shape))
plt.figure(figsize=(4.8, 4.8))
plt.imshow(mosaico[0], cmap=tinta) # un canal basta
plt.yticks([16 + 30 * k for k in range(10)], NOMBRES)
plt.xticks([])
plt.grid(False)
plt.title("Ocho prendas de cada clase")
plt.show()Datos listos para entrenar: Dataset y DataLoader
# De 0–255 a 0–1, con un eje de canales: (N, 1, 28, 28)
X = datos_ent.data.float().div(255).unsqueeze(1)
X_pru = datos_pru.data.float().div(255).unsqueeze(1)
y_pru = datos_pru.targets
# 50 000 fotos para entrenar y 10 000 para validar, al azar
orden = torch.randperm(len(X), generator=torch.Generator().manual_seed(0))
i_val, i_ent = orden[:10000], orden[10000:]
X_ent, y_ent = X[i_ent], datos_ent.targets[i_ent]
X_val, y_val = X[i_val], datos_ent.targets[i_val]
# Estandarizar con lo que se aprende del entrenamiento
media, desviacion = X_ent.mean(), X_ent.std()
X_ent = (X_ent - media) / desviacion
X_val = (X_val - media) / desviacion
X_pru = (X_pru - media) / desviacion
print(f"Media {media:.3f}, desviación {desviacion:.3f}")
print("Entrenar:", tuple(X_ent.shape))
print("Validar: ", tuple(X_val.shape))
print("Prueba: ", tuple(X_pru.shape))from torch.utils.data import TensorDataset, DataLoader
conjunto_ent = TensorDataset(X_ent, y_ent)
imagen, etiqueta = conjunto_ent[0]
print("Un ejemplo:", tuple(imagen.shape), "->", NOMBRES[etiqueta])
cargador_ent = DataLoader(conjunto_ent, batch_size=128, shuffle=True,
generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(TensorDataset(X_val, y_val), batch_size=1000)
cargador_pru = DataLoader(TensorDataset(X_pru, y_pru), batch_size=1000)
# iter prepara el recorrido de los lotes y next pide el primero
x_lote, y_lote = next(iter(cargador_ent))
print("Un lote:", tuple(x_lote.shape), tuple(y_lote.shape))
print("Lotes por época:", len(cargador_ent))El modelo: una clase de `nn.Module`
class RedPrendas(nn.Module):
def __init__(self):
super().__init__()
self.aplanar = nn.Flatten() # (N, 1, 28, 28) -> (N, 784)
self.oculta = nn.Linear(784, 128)
self.salida = nn.Linear(128, 10)
def forward(self, x):
x = self.aplanar(x)
x = torch.relu(self.oculta(x))
return self.salida(x) # 10 puntajes por foto: (N, 10)
torch.manual_seed(0)
modelo = RedPrendas().to(device)
print(modelo)
print("Parámetros:", sum(p.numel() for p in modelo.parameters()))
salida = modelo(x_lote.to(device))
print("Entrada:", tuple(x_lote.shape), " salida:", tuple(salida.shape))otra = nn.Sequential(nn.Flatten(), nn.Linear(784, 128), nn.ReLU(),
nn.Linear(128, 10))
print(tuple(otra(x_lote).shape))funcion_perdida = nn.CrossEntropyLoss()
perdida = funcion_perdida(salida, y_lote.to(device))
print(f"Pérdida sin entrenar: {perdida.item():.3f}")
print(f"ln(10) = {np.log(10):.3f}")
probabilidades = torch.softmax(salida, dim=1)
print("Probabilidades de la primera foto:")
print(np.round(probabilidades[0].detach().cpu().numpy(), 2))El bucle de entrenamiento, a mano
torch.manual_seed(0)
modelo = RedPrendas().to(device)
funcion_perdida = nn.CrossEntropyLoss()
optimizador = torch.optim.SGD(modelo.parameters(), lr=0.1)
modelo.train() # modo entrenamiento
for paso, (x, y) in enumerate(cargador_ent):
x, y = x.to(device), y.to(device) # al dispositivo del modelo
optimizador.zero_grad() # 1. gradientes en cero
salida = modelo(x) # 2. adelante: logits (128, 10)
perdida = funcion_perdida(salida, y) # 3. la pérdida del lote
perdida.backward() # 4. atrás: los gradientes
optimizador.step() # 5. un paso en contra
if paso % 100 == 0:
print(f"Paso {paso:3d}: pérdida del lote {perdida.item():.3f}")modelo.eval() # modo evaluación
aciertos = 0
with torch.no_grad(): # sin grafo: solo medimos
for x, y in cargador_val:
x, y = x.to(device), y.to(device)
predicho = modelo(x).argmax(dim=1)
aciertos += (predicho == y).sum().item()
print(f"Exactitud de validación tras una época: "
f"{aciertos / len(X_val):.3f}")La función `entrenar()`: el bucle que usarás en toda la ruta
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 historialtorch.manual_seed(0)
modelo = RedPrendas()
optimizador = torch.optim.SGD(modelo.parameters(), lr=0.1)
historial = entrenar(modelo, cargador_ent, cargador_val, epocas=10,
optimizador=optimizador, dispositivo=device)epocas = range(1, len(historial["perdida_ent"]) + 1)
mejor = int(np.argmax(historial["exactitud_val"])) # índice desde 0
fig, (arriba, abajo) = plt.subplots(2, 1, figsize=(4.8, 4.8),
sharex=True)
# Entrenamiento a rayas, para distinguirlo de validación
arriba.plot(epocas, historial["perdida_ent"], color="C2", linestyle="--",
label="Entrenamiento")
arriba.plot(epocas, historial["perdida_val"], color="C0",
label="Validación")
arriba.set_ylabel("Pérdida")
arriba.set_title("Diez épocas con las prendas")
arriba.legend()
abajo.plot(epocas, historial["exactitud_ent"], color="C2", linestyle="--")
abajo.plot(epocas, historial["exactitud_val"], color="C0")
abajo.scatter([mejor + 1], [historial["exactitud_val"][mejor]],
color="C1", s=120, zorder=3, label="Mejor validación")
abajo.set_xlabel("Época")
abajo.set_ylabel("Exactitud")
abajo.legend()
plt.show()La prueba final: matriz de confusión
perdida_pru, exactitud_pru = evaluar(modelo, cargador_pru,
nn.CrossEntropyLoss(), device)
print(f"Prueba: pérdida {perdida_pru:.3f}, exactitud {exactitud_pru:.3f}")
# Las predicciones de las 10 000 fotos de prueba, en orden
modelo.eval()
with torch.no_grad():
predicho = torch.cat([modelo(x.to(device)).argmax(dim=1).cpu()
for x, _ in cargador_pru])
print("Predicciones:", tuple(predicho.shape))from sklearn.metrics import confusion_matrix
matriz = confusion_matrix(y_pru, predicho)
# La confusión más frecuente: la celda más alta fuera de la diagonal
fuera = matriz.copy()
np.fill_diagonal(fuera, 0)
# argmax da la posición en la matriz aplanada; unravel_index la
# convierte en (fila, columna)
real, dice = np.unravel_index(fuera.argmax(), fuera.shape)
print(f"Lo que más confunde: {NOMBRES[real]} -> {NOMBRES[dice]}, "
f"{matriz[real, dice]} veces")
plt.figure(figsize=(4.8, 4.8))
plt.imshow(matriz, cmap=tinta)
for i in range(10):
for j in range(10):
plt.text(j, i, matriz[i, j], ha="center", va="center",
fontsize=10, color="white" if matriz[i, j] > 500 else "C0")
plt.gca().add_patch(plt.Rectangle((dice - 0.5, real - 0.5), 1, 1,
fill=False, edgecolor="C1", linewidth=3))
plt.xticks(range(10), NOMBRES, rotation=90)
plt.yticks(range(10), NOMBRES)
plt.xlabel("Lo que dice el modelo")
plt.ylabel("Lo real")
plt.title("Matriz de confusión (prueba)")
plt.grid(False)
plt.show()por_clase = matriz.diagonal() / matriz.sum(axis=1)
for k in np.argsort(por_clase): # de la peor a la mejor
print(f"{NOMBRES[k]:10s} {por_clase[k]:.2f}")errores = (predicho != y_pru).nonzero().squeeze(1)
print("Fotos mal clasificadas:", len(errores), "de", len(y_pru))
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, k in zip(ejes.flat, errores[:4]):
eje.imshow(datos_pru.data[k], cmap=tinta)
eje.set_title(f"Es: {NOMBRES[y_pru[k]]}\n"
f"Dice: {NOMBRES[predicho[k]]}")
eje.axis("off")
plt.show()Guardar y cargar un modelo
torch.save(modelo.state_dict(), "prendas.pt")
for nombre, tensor in modelo.state_dict().items():
print(f"{nombre:15s} {tuple(tensor.shape)}")
nuevo = RedPrendas() # pesos al azar...
nuevo.load_state_dict(torch.load("prendas.pt", map_location="cpu"))
nuevo.eval() # ...y ahora, los entrenados
una = X_pru[0] # una foto: (1, 28, 28)
with torch.no_grad():
probabilidades = torch.softmax(nuevo(una.unsqueeze(0)), dim=1)
k = probabilidades.argmax().item()
print(f"Predice {NOMBRES[k]} con probabilidad "
f"{probabilidades[0, k]:.2f}; es {NOMBRES[y_pru[0]]}")¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.