PyTorch: tensores, autograd y el bucle de entrenamiento

Tema 18 · 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 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 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.
# 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 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)
# 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]]}")