PyTorch: tensores, autograd y el bucle de entrenamiento

Tema 18 · Fase 4, Redes neuronales · Toda la ruta

Los 27 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.

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 NumPy

Cambiar 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
NOMBRES = ["Camiseta", "Pantalón", "Suéter", "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
    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)

x_lote, y_lote = next(iter(cargador_ent))     # el primer lote
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 historial
torch.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)
arriba.plot(epocas, historial["perdida_ent"], color="C2",
            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")
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)
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=8, 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]]}")