PyTorch: tensores, autograd y el bucle de entrenamiento

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: el clasificador de prendas, con un informe de errores
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan lo que construiste en la teoría: torch, nn, np, plt, device, los tensores X_ent, y_ent, X_val, y_val, X_pru y y_pru, conjunto_ent, los cargadores cargador_ent, cargador_val y cargador_pru, la clase RedPrendas, el modelo entrenado con su historial, las funciones entrenar y evaluar, la lista NOMBRES y las predicciones de prueba predicho. Corre primero todo el código de la teoría, en orden. Si cierras el cuaderno, vuelve a correrlo desde el principio.

Ejercicios

1. Veinte formas

Sin computadora, escribe la forma del resultado de cada expresión, o «error» si no se puede calcular. Los tensores son a = torch.zeros(64, 1, 28, 28) (un lote de fotos), b = torch.zeros(3, 4) y c = torch.zeros(5).

  1. a.flatten(1)
  2. a.squeeze(1)
  3. a.permute(0, 2, 3, 1)
  4. a.sum(dim=0)
  5. a.mean(dim=(2, 3))
  6. a[0]
  7. a[:10]
  8. a.view(64, -1)
  9. a.reshape(-1, 28)
  10. nn.Linear(784, 10)(a.flatten(1))
  11. b.T
  12. b.unsqueeze(0)
  13. b.unsqueeze(-1)
  14. b.sum(dim=1)
  15. b.sum(dim=1, keepdim=True)
  16. torch.cat([b, b])
  17. torch.cat([b, b], dim=1)
  18. torch.stack([b, b])
  19. b @ torch.zeros(4, 2)
  20. c.unsqueeze(1) * c
Respuesta

Ninguna da error. Las que más se fallan son la 5 (promediar los ejes 2 y 3 deja uno por foto y por canal), la 9 (el −1 junta todo lo demás: 64 · 1 · 28 = 1792 filas de 28), la 15 (keepdim=True deja el eje con tamaño 1 en lugar de borrarlo) y la 20, que es broadcasting (tema 3): (5, 1) por (5,) se estira a (5, 5). Compruébalo:

a = torch.zeros(64, 1, 28, 28)
b = torch.zeros(3, 4)
c = torch.zeros(5)
resultados = [
    ("a.flatten(1)", a.flatten(1)),
    ("a.squeeze(1)", a.squeeze(1)),
    ("a.permute(0, 2, 3, 1)", a.permute(0, 2, 3, 1)),
    ("a.sum(dim=0)", a.sum(dim=0)),
    ("a.mean(dim=(2, 3))", a.mean(dim=(2, 3))),
    ("a[0]", a[0]),
    ("a[:10]", a[:10]),
    ("a.view(64, -1)", a.view(64, -1)),
    ("a.reshape(-1, 28)", a.reshape(-1, 28)),
    ("nn.Linear(784, 10)(a.flatten(1))",
     nn.Linear(784, 10)(a.flatten(1))),
    ("b.T", b.T),
    ("b.unsqueeze(0)", b.unsqueeze(0)),
    ("b.unsqueeze(-1)", b.unsqueeze(-1)),
    ("b.sum(dim=1)", b.sum(dim=1)),
    ("b.sum(dim=1, keepdim=True)", b.sum(dim=1, keepdim=True)),
    ("torch.cat([b, b])", torch.cat([b, b])),
    ("torch.cat([b, b], dim=1)", torch.cat([b, b], dim=1)),
    ("torch.stack([b, b])", torch.stack([b, b])),
    ("b @ torch.zeros(4, 2)", b @ torch.zeros(4, 2)),
    ("c.unsqueeze(1) * c", c.unsqueeze(1) * c),
]
for numero, (texto, t) in enumerate(resultados, start=1):
    print(f"{numero:2d}. {texto:33s} {tuple(t.shape)}")
 1. a.flatten(1)                      (64, 784)
 2. a.squeeze(1)                      (64, 28, 28)
 3. a.permute(0, 2, 3, 1)             (64, 28, 28, 1)
 4. a.sum(dim=0)                      (1, 28, 28)
 5. a.mean(dim=(2, 3))                (64, 1)
 6. a[0]                              (1, 28, 28)
 7. a[:10]                            (10, 1, 28, 28)
 8. a.view(64, -1)                    (64, 784)
 9. a.reshape(-1, 28)                 (1792, 28)
10. nn.Linear(784, 10)(a.flatten(1))  (64, 10)
11. b.T                               (4, 3)
12. b.unsqueeze(0)                    (1, 3, 4)
13. b.unsqueeze(-1)                   (3, 4, 1)
14. b.sum(dim=1)                      (3,)
15. b.sum(dim=1, keepdim=True)        (3, 1)
16. torch.cat([b, b])                 (6, 4)
17. torch.cat([b, b], dim=1)          (3, 8)
18. torch.stack([b, b])               (2, 3, 4)
19. b @ torch.zeros(4, 2)             (3, 2)
20. c.unsqueeze(1) * c                (5, 5)

2. Autograd contra tu lápiz

Sea f = x² · y + 3y, con x = 2 e y = −1.

a) Calcula a mano el valor de f y las derivadas parciales ∂f/∂x y ∂f/∂y.

b) Compruébalo con requires_grad y .backward().

c) Con la clase Valor del tema 17, ¿qué tendrías que escribir para obtener lo mismo?

Respuesta

a) f = 4 · (−1) + 3 · (−1) = −7. Derivando: ∂f/∂x = 2x·y = 2 · 2 · (−1) = −4 y ∂f/∂y = x² + 3 = 4 + 3 = 7.

b)

x = torch.tensor(2.0, requires_grad=True)
y = torch.tensor(-1.0, requires_grad=True)
f = x ** 2 * y + 3 * y
f.backward()
print("f =", f.item(), "  df/dx =", x.grad.item(),
      "  df/dy =", y.grad.item())
f = -7.0   df/dx = -4.0   df/dy = 7.0

c) Lo mismo con Valor(2.0) y Valor(-1.0) en lugar de los tensores, y la retropropagación de tu clase en lugar de f.backward(). Fíjate en que y aparece dos veces en la fórmula: su gradiente llega por dos caminos (x² y 3) y se suma, que es justo por qué tu motor usaba +=.

3. Gradientes que se acumulan

Predice qué imprime tres_vueltas() antes de llamarla. Después, di qué línea falta para que imprima siempre lo mismo, y en qué lugar de la función entrenar está esa línea.

def tres_vueltas():
    w = torch.tensor(1.0, requires_grad=True)
    for vuelta in range(3):
        perdida = (3 * w) ** 2
        perdida.backward()
        print(w.grad.item())
Respuesta
tres_vueltas()
18.0
36.0
54.0

La pérdida es 9w², y su derivada es 18w = 18 cuando w = 1. Como .backward() suma al .grad que ya había, imprime 18, 36 y 54. Falta w.grad.zero_() (o, con un optimizador, optimizador.zero_grad()) antes de cada backward. En entrenar es la primera línea dentro del bucle de los lotes, justo después de mover x e y al dispositivo.

4. Tu propio Dataset

Escribe una clase PrendasEspejo que herede de torch.utils.data.Dataset y que, a partir de unos tensores X e y, devuelva cada foto volteada de izquierda a derecha junto con su etiqueta. Pista: necesita __init__, __len__ y __getitem__, y torch.flip(t, dims=[k]) voltea el tensor t a lo largo del eje k.

a) ¿En qué eje hay que voltear una foto de forma (1, 28, 28)?

b) Arma un DataLoader con las fotos de validación en espejo y comprueba la forma de un lote de 32.

c) Mide la exactitud del modelo de la teoría con esas fotos. ¿Sube, baja o queda igual? ¿Por qué?

Respuesta

a) En el eje 2, el ancho: la forma es (canales, alto, ancho).

from torch.utils.data import Dataset


class PrendasEspejo(Dataset):
    """Las fotos de X, volteadas de izquierda a derecha, con su etiqueta."""

    def __init__(self, X, y):
        self.X = X
        self.y = y

    def __len__(self):
        return len(self.X)

    def __getitem__(self, i):
        return torch.flip(self.X[i], dims=[2]), self.y[i]


espejo = PrendasEspejo(X_val, y_val)
imagen, etiqueta = espejo[0]
print(len(espejo), "fotos; la primera:", tuple(imagen.shape),
      NOMBRES[etiqueta])

x_lote, y_lote = next(iter(DataLoader(espejo, batch_size=32)))
print("Un lote:", tuple(x_lote.shape), tuple(y_lote.shape))

cargador_espejo = DataLoader(espejo, batch_size=1000)
_, exactitud_espejo = evaluar(modelo, cargador_espejo,
                              nn.CrossEntropyLoss(), device)
_, exactitud_normal = evaluar(modelo, cargador_val,
                              nn.CrossEntropyLoss(), device)
print(f"Exactitud normal {exactitud_normal:.3f}, "
      f"en espejo {exactitud_espejo:.3f}")

# ¿Qué clases sufren más con el espejo?
modelo.eval()
with torch.no_grad():
    predicho_espejo = torch.cat([modelo(x.to(device)).argmax(dim=1).cpu()
                                 for x, _ in cargador_espejo])
for k in range(10):
    es_k = y_val == k
    exactitud_k = (predicho_espejo[es_k] == k).float().mean().item()
    print(f"{NOMBRES[k]:10s} {exactitud_k:.2f}")
10000 fotos; la primera: (1, 28, 28) Camisa
Un lote: (32, 1, 28, 28) (32,)
Exactitud normal 0.867, en espejo 0.617
Camiseta   0.88
Pantalón   0.90
Suéter     0.87
Vestido    0.88
Abrigo     0.61
Sandalia   0.24
Camisa     0.53
Zapatilla  0.33
Bolso      0.93
Botín      0.00

c) Baja mucho, de 0,867 a 0,617, y casi todo por el calzado: el botín pasa de casi perfecto a 0,00, la sandalia queda en 0,24 y la zapatilla en 0,33. En FashionMNIST todos los zapatos miran hacia el mismo lado (mira el mosaico de la teoría), y el modelo aprendió esa dirección como parte de lo que es un zapato. Volteados, son algo que nunca vio. Las prendas casi simétricas, como camisetas, pantalones y bolsos, apenas cambian. La lección: un modelo solo sabe de lo que hay en sus datos. Si en la vida real los zapatos pudieran mirar a los dos lados, habría que entrenar también con fotos volteadas; esa idea, la aumentación de datos, vuelve en los temas 19 y 20.

5. Cuatro errores en el bucle

Este bucle corre sin mostrar ningún mensaje de error, pero tiene cuatro errores. Encuéntralos comparándolo con entrenar y evaluar.

def entrenar_con_errores(modelo, cargador, optimizador, epocas):
    funcion_perdida = nn.CrossEntropyLoss()
    modelo.to(device)
    for epoca in range(epocas):
        for x, y in cargador:
            salida = torch.softmax(modelo(x), dim=1)
            perdida = funcion_perdida(salida, y)
            perdida.backward()
            optimizador.step()


def exactitud_con_errores(modelo, cargador):
    aciertos = 0
    for x, y in cargador:
        aciertos += (modelo(x).argmax(dim=1) == y).sum().item()
    return aciertos / len(cargador.dataset)
Respuesta
  1. Falta optimizador.zero_grad(). Los gradientes de todos los lotes anteriores se acumulan, y cada paso sigue una dirección vieja y cada vez más grande.
  2. Aplica softmax antes de CrossEntropyLoss. La pérdida ya aplica softmax por dentro: así se aplica dos veces. Las probabilidades, entre 0 y 1, quedan como «logits» casi iguales entre sí, y los gradientes se vuelven pequeños.
  3. Evalúa sin modelo.eval() ni torch.no_grad(). Además, el entrenamiento no llama a modelo.train().
  4. No mueve los datos al dispositivo. El modelo va a device, pero x e y no. En una CPU no pasa nada; en Colab con GPU, el código se detiene con un error que dice que se esperaban todos los tensores en el mismo dispositivo.

Los dos primeros se pueden medir. Esta función entrena una época con cada error por separado y mide la exactitud de validación:

def probar(con_zero_grad=True, con_softmax=False):
    """Una época con o sin cada error; devuelve la exactitud de validación."""
    torch.manual_seed(0)
    red = RedPrendas().to(device)
    optimizador = torch.optim.SGD(red.parameters(), lr=0.1)
    funcion_perdida = nn.CrossEntropyLoss()
    cargador = DataLoader(conjunto_ent, batch_size=128, shuffle=True,
                          generator=torch.Generator().manual_seed(0))
    red.train()
    for x, y in cargador:
        x, y = x.to(device), y.to(device)
        if con_zero_grad:
            optimizador.zero_grad()
        salida = red(x)
        if con_softmax:
            salida = torch.softmax(salida, dim=1)
        funcion_perdida(salida, y).backward()
        optimizador.step()
    return evaluar(red, cargador_val, funcion_perdida, device)[1]


print(f"Correcto:            {probar():.3f}")
print(f"Sin zero_grad:       {probar(con_zero_grad=False):.3f}")
print(f"Softmax de más:      {probar(con_softmax=True):.3f}")
Correcto:            0.829
Sin zero_grad:       0.215
Softmax de más:      0.773

Sin zero_grad, la red casi no aprende: 0,215, apenas mejor que adivinar al azar (0,10). Con el softmax de más aprende, pero más despacio: 0,773 contra 0,829 después de una época.

El tercero no cambia nada con RedPrendas, porque no tiene capas que se porten distinto al entrenar. Con una capa de dropout (tema 19), que apaga neuronas al azar mientras entrena, sí cambia: la misma foto da salidas distintas en modo entrenamiento y la misma salida en modo evaluación.

torch.manual_seed(0)
con_dropout = nn.Sequential(nn.Flatten(), nn.Linear(784, 128), nn.ReLU(),
                            nn.Dropout(0.5), nn.Linear(128, 10))
foto = X_val[:1]
with torch.no_grad():
    con_dropout.train()
    con_dropout.train()
    print("train():", con_dropout(foto)[0, :3])
    print("train():", con_dropout(foto)[0, :3])
    con_dropout.eval()
    print("eval(): ", con_dropout(foto)[0, :3])
    print("eval(): ", con_dropout(foto)[0, :3])
train(): tensor([-0.4290,  0.3889, -0.4592])
train(): tensor([-0.4090,  0.6321, -0.1920])
eval():  tensor([-0.3772,  0.1972, -0.2865])
eval():  tensor([-0.3772,  0.1972, -0.2865])

6. Leer el historial

Con el historial de la teoría:

a) ¿En qué época fue mejor la exactitud de validación? ¿Con qué modelo te quedaste al final de entrenar?

b) Calcula, para cada época, la diferencia entre la exactitud de entrenamiento y la de validación. ¿Qué tendencia tiene? ¿Qué te dice?

c) Si entrenaras 40 épocas en lugar de 10, ¿qué esperarías ver en las dos curvas de pérdida?

Respuesta
mejor = int(np.argmax(historial["exactitud_val"]))
print(f"Mejor validación: época {mejor + 1}, "
      f"{historial['exactitud_val'][mejor]:.3f}")
for e, (ent, val) in enumerate(zip(historial["exactitud_ent"],
                                   historial["exactitud_val"]), start=1):
    print(f"Época {e:2d}: entrenamiento {ent:.3f}, validación {val:.3f},"
          f" diferencia {ent - val:+.3f}")
Mejor validación: época 9, 0.882
Época  1: entrenamiento 0.797, validación 0.838, diferencia -0.041
Época  2: entrenamiento 0.854, validación 0.848, diferencia +0.006
Época  3: entrenamiento 0.869, validación 0.859, diferencia +0.010
Época  4: entrenamiento 0.877, validación 0.866, diferencia +0.012
Época  5: entrenamiento 0.884, validación 0.874, diferencia +0.010
Época  6: entrenamiento 0.890, validación 0.871, diferencia +0.019
Época  7: entrenamiento 0.894, validación 0.871, diferencia +0.023
Época  8: entrenamiento 0.899, validación 0.878, diferencia +0.020
Época  9: entrenamiento 0.903, validación 0.882, diferencia +0.022
Época 10: entrenamiento 0.907, validación 0.867, diferencia +0.040

a) La mejor exactitud de validación fue 0,882, en la época 9. entrenar devuelve el modelo tal como quedó al final, el de la época 10, con 0,867. Para quedarte con el mejor tendrías que guardar su state_dict en la época 9; eso es la parada temprana del tema 19.

b) En la primera época la diferencia es negativa (−0,041): la exactitud de entrenamiento se mide mientras el modelo aprende, y al principio de la época el modelo era casi aleatorio. Después la diferencia crece poco a poco, de +0,006 a +0,040: el modelo va mejorando más con las fotos que ve que con las nuevas. Es el comienzo del sobreajuste del tema 7.

c) Es de esperar que la pérdida de entrenamiento siga bajando y que la de validación deje de bajar y empiece a subir: la brecha se abriría cada vez más. No lo medimos aquí; en el tema 19 vas a ver esas curvas y cómo frenar el sobreajuste.

7. Guardar, cargar y repetir

a) Guarda el state_dict del modelo de la teoría, cárgalo en un RedPrendas nuevo y comprueba que los dos dan las mismas predicciones con las fotos de prueba.

b) Entrena dos veces una época con la semilla 0 y una vez con la semilla 1 (la misma semilla para el modelo y para el DataLoader). ¿Qué ejecuciones dan exactamente lo mismo?

Respuesta

a)

torch.save(modelo.state_dict(), "prendas.pt")
copia = RedPrendas()
copia.load_state_dict(torch.load("prendas.pt", map_location="cpu"))
copia.eval()
with torch.no_grad():
    predicho_copia = torch.cat([copia(x).argmax(dim=1)
                                for x, _ in cargador_pru])
print("Mismas predicciones:", torch.equal(predicho_copia, predicho))
Mismas predicciones: True

b)

def una_epoca(semilla):
    """Entrena una época de RedPrendas con la semilla dada."""
    torch.manual_seed(semilla)
    red = RedPrendas()
    cargador = DataLoader(conjunto_ent, batch_size=128, shuffle=True,
                          generator=torch.Generator().manual_seed(semilla))
    optimizador = torch.optim.SGD(red.parameters(), lr=0.1)
    h = entrenar(red, cargador, cargador_val, 1, optimizador,
                 dispositivo=device, mostrar=False)
    return h["exactitud_val"][0], red.salida.bias.detach().cpu()


exactitud_a, sesgos_a = una_epoca(0)
exactitud_b, sesgos_b = una_epoca(0)
exactitud_c, sesgos_c = una_epoca(1)
print(f"Semilla 0: {exactitud_a:.4f}  otra vez semilla 0: "
      f"{exactitud_b:.4f}  semilla 1: {exactitud_c:.4f}")
print("Mismos sesgos con la semilla 0:", torch.equal(sesgos_a, sesgos_b))
print("Mismos sesgos con 0 y con 1:   ", torch.equal(sesgos_a, sesgos_c))
Semilla 0: 0.8290  otra vez semilla 0: 0.8290  semilla 1: 0.8389
Mismos sesgos con la semilla 0: True
Mismos sesgos con 0 y con 1:    False

a) El modelo cargado da exactamente las mismas 10 000 predicciones.

b) Las dos ejecuciones con la semilla 0 dan exactamente lo mismo, hasta el último decimal de los sesgos. Con la semilla 1 los pesos iniciales y el orden de los lotes cambian, y el resultado también (0,8389 en lugar de 0,8290). Por eso, para comparar dos ideas (por ejemplo, dos tasas de aprendizaje) hay que fijar la semilla: si no, no sabes si la diferencia viene de la idea o del azar. Y por eso también conviene probar más de una semilla antes de creer en una diferencia pequeña.

Mini-proyecto: el clasificador de prendas, con un informe de errores

Vas a entrenar tu propio clasificador de FashionMNIST y a escribir un informe corto de sus errores, como el que escribiría alguien que tiene que decidir si el modelo sirve.

  1. Una red distinta. Define con nn.Sequential una red con dos capas ocultas, de 256 y 128 neuronas, con ReLU. Cuenta sus parámetros antes de entrenar: ¿cuántas veces más que RedPrendas?
  2. Entrénala con entrenar, la misma tasa (0,1) y 10 épocas, con semillas fijas.
  3. Mide en la prueba su exactitud y compárala con la de RedPrendas. Usa la prueba una sola vez, al final.
  4. Por clase. Calcula la precisión y la exhaustividad de cada prenda con classification_report (tema 9). ¿Qué clase es la peor? ¿Es un problema de precisión o de exhaustividad?
  5. Las tres confusiones más frecuentes de tu red, sacadas de su matriz de confusión.
  6. El informe: cinco a ocho líneas con la exactitud, las clases más débiles, las confusiones típicas, si alguna te parece grave para el uso del modelo, y una idea para mejorar.
Una solución posible

Pasos 1 y 2:

from sklearn.metrics import classification_report, confusion_matrix

torch.manual_seed(0)
profunda = nn.Sequential(
    nn.Flatten(),
    nn.Linear(784, 256), nn.ReLU(),
    nn.Linear(256, 128), nn.ReLU(),
    nn.Linear(128, 10),
)
n_profunda = sum(p.numel() for p in profunda.parameters())
n_simple = sum(p.numel() for p in RedPrendas().parameters())
print(f"Parámetros: {n_profunda} ({n_profunda / n_simple:.1f} veces más)")

cargador = DataLoader(conjunto_ent, batch_size=128, shuffle=True,
                      generator=torch.Generator().manual_seed(0))
optimizador = torch.optim.SGD(profunda.parameters(), lr=0.1)
historial_p = entrenar(profunda, cargador, cargador_val, 10, optimizador,
                       dispositivo=device)
Parámetros: 235146 (2.3 veces más)
Época 1: pérdida 0.616, validación 0.483, exactitud de validación 0.824
Época 2: pérdida 0.407, validación 0.439, exactitud de validación 0.836
Época 3: pérdida 0.361, validación 0.461, exactitud de validación 0.823
Época 4: pérdida 0.331, validación 0.387, exactitud de validación 0.857
Época 5: pérdida 0.310, validación 0.353, exactitud de validación 0.868
Época 6: pérdida 0.291, validación 0.363, exactitud de validación 0.867
Época 7: pérdida 0.279, validación 0.366, exactitud de validación 0.872
Época 8: pérdida 0.264, validación 0.319, exactitud de validación 0.884
Época 9: pérdida 0.252, validación 0.329, exactitud de validación 0.879
Época 10: pérdida 0.241, validación 0.312, exactitud de validación 0.887

Pasos 3 a 5:

_, exactitud_p = evaluar(profunda, cargador_pru, nn.CrossEntropyLoss(),
                         device)
_, exactitud_s = evaluar(modelo, cargador_pru, nn.CrossEntropyLoss(),
                         device)
print(f"Prueba: dos capas {exactitud_p:.3f}, RedPrendas {exactitud_s:.3f}")

profunda.eval()
with torch.no_grad():
    predicho_p = torch.cat([profunda(x.to(device)).argmax(dim=1).cpu()
                            for x, _ in cargador_pru])
print(classification_report(y_pru, predicho_p, target_names=NOMBRES,
                            digits=2))

matriz_p = confusion_matrix(y_pru, predicho_p)
np.fill_diagonal(matriz_p, 0)          # solo los errores
for celda in np.argsort(matriz_p, axis=None)[::-1][:3]:
    real, dice = np.unravel_index(celda, matriz_p.shape)
    print(f"{NOMBRES[real]} tomado por {NOMBRES[dice]}: "
          f"{matriz_p[real, dice]} veces")
Prueba: dos capas 0.883, RedPrendas 0.861
              precision    recall  f1-score   support

    Camiseta       0.81      0.87      0.84      1000
    Pantalón       0.99      0.97      0.98      1000
      Suéter       0.82      0.76      0.79      1000
     Vestido       0.90      0.88      0.89      1000
      Abrigo       0.78      0.84      0.81      1000
    Sandalia       0.98      0.94      0.96      1000
      Camisa       0.72      0.69      0.71      1000
   Zapatilla       0.92      0.96      0.94      1000
       Bolso       0.96      0.97      0.97      1000
       Botín       0.95      0.96      0.95      1000

    accuracy                           0.88     10000
   macro avg       0.88      0.88      0.88     10000
weighted avg       0.88      0.88      0.88     10000

Camisa tomado por Camiseta: 142 veces
Suéter tomado por Abrigo: 122 veces
Suéter tomado por Camisa: 91 veces

La red de dos capas tiene 235 146 parámetros, 2,3 veces más que RedPrendas, y llega a 0,883 de exactitud en la prueba contra 0,861. Un informe posible:

Informe del clasificador de prendas (MLP de dos capas ocultas, 10 épocas). Acierta el 88,3 % de las 10 000 fotos de prueba. Pantalones, bolsos, sandalias, zapatillas y botines tienen precisión y exhaustividad de 0,92 o más. La clase más débil es la camisa: de las camisas reales encuentra solo el 69 % (exhaustividad 0,69), y cuando dice «camisa» acierta el 72 % de las veces (precisión 0,72); falla en las dos cosas, un poco más en encontrarlas. Las confusiones más frecuentes son camisa tomada por camiseta (142 veces), suéter tomado por abrigo (122) y suéter tomado por camisa (91): todas entre prendas de la parte de arriba del cuerpo, que a 28 × 28 se distinguen por detalles como el cuello o los botones. Para una tienda que ordena su catálogo, confundir un suéter con un abrigo es tolerable; si separar camisas de camisetas importa, este modelo no alcanza. Idea para mejorar: una red que mire vecindarios de píxeles (tema 20).

Tus números pueden ser un poco distintos si cambias la arquitectura o las semillas; lo importante es que cada afirmación del informe salga de una cifra que calculaste.

Errores comunes

  • Olvidar optimizador.zero_grad(). Los gradientes se acumulan de un lote al siguiente y el modelo aprende mal sin ningún mensaje de error.
  • Poner softmax al final de la red y usar nn.CrossEntropyLoss. La pérdida ya aplica softmax: recibe logits. El softmax se usa aparte, solo cuando quieres ver probabilidades.
  • Medir sin modelo.eval() ni torch.no_grad(). Con dropout o normalización por lotes, las medidas salen mal; sin no_grad, además, se gasta memoria armando un grafo que nadie usa. Y después de medir, modelo.train() antes de seguir entrenando (entrenar lo hace en cada época).
  • Modelo y datos en dispositivos distintos. Si mueves el modelo a la GPU, mueve también cada lote con .to(device). Para pasar a NumPy, vuelve con .cpu().
  • Darle a la red una sola foto sin eje de lote. Una red entrenada con lotes espera (N, 1, 28, 28): una foto de (1, 28, 28) necesita unsqueeze(0).
  • Olvidar aplanar. nn.Linear trabaja sobre la última dimensión: un lote de fotos sin nn.Flatten() da mat1 and mat2 shapes cannot be multiplied.
  • Confundir cat y stack. cat pega en un eje que ya existe; stack crea uno nuevo.
  • Usar view después de permute. Puede fallar porque los números no quedaron en orden en la memoria; reshape funciona siempre.
  • Mezclar float64 y float32. Los arreglos de NumPy son float64 y las capas esperan float32: convierte con torch.tensor(a, dtype=torch.float32).
  • Tomar decisiones con la prueba. La validación es para elegir la tasa, la red o las épocas; la prueba se usa una vez, al final (tema 7).
  • Quedarse con la última época sin mirar las curvas. La validación salta: el modelo de la última época no siempre es el mejor.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Crear y manipular tensores y predecir sus formas después de view, reshape, permute, unsqueeze, cat y stack (la actividad y el ejercicio 1).
  • Usar requires_grad y .backward(), y compararlos con tu motor del tema 17 (ejercicios 2 y 3).
  • Definir un modelo con nn.Module, cargar datos con Dataset y DataLoader y escribir el bucle completo de entrenar y evaluar (ejercicios 4 y 5, y pasos 1 a 3 del mini-proyecto).
  • Leer un entrenamiento en su historial (ejercicio 6) y estudiar los errores de un clasificador (pasos 4 a 6 del mini-proyecto).
  • Escribir código que corra igual en CPU y GPU y fijar semillas (ejercicios 5 y 7).
  • Guardar y cargar un modelo y hacer inferencia con torch.no_grad() y model.eval() (ejercicio 7).