Módulo 5 · Redes neuronales

Lección 6 de 7

Misión de entrenamiento

En esta páginaEl problema

El problema

load_digits de scikit-learn contiene 1797 imágenes de dígitos escritos a mano. Cada imagen mide 8 × 8 píxeles y cada píxel es un entero de 0 a 16. Esta es la primera imagen, etiquetada como 0:

 0  0  5 13  9  1  0  0
 0  0 13 15 10 15  5  0
 0  3 15  2  0 11  8  0
 0  4 12  0  0  8  8  0
 0  5  8  0  0  9  8  0
 0  4 11  0  1 12  7  0
 0  2 14  5 10 12  0  0
 0  0  6 13 10  0  0  0

Aplanada, cada imagen es un vector de 64 valores. Las etiquetas van de 0 a 9 y cada clase tiene unas 180 imágenes.

La tarea es entrenar un MLP que clasifique estos vectores y compararlo con un modelo clásico de M3. El resultado debe ser una frase con números: cuántos errores comete cada modelo en un conjunto de prueba que no se usó para ninguna decisión, y si la diferencia es mayor que la variación entre semillas.

Partición y baseline

return_X_y=True hace que load_digits devuelva directamente la matriz de imágenes aplanadas y el vector de etiquetas. Dividir entre 16 lleva los píxeles al rango 0–1. La constante se conoce de antemano, así que el escalado no usa información de validación ni de prueba.

Python 3 · ejemplo reproducible
import numpy as np
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)
X = X / 16.0
print(X.shape, X.min(), X.max())
Salida esperada

La salida esperada está oculta.

La partición se hace antes de entrenar cualquier modelo. train_test_split con test_size=360 separa 360 imágenes de prueba y, del resto, otras 360 de validación. stratify conserva la proporción de clases en cada parte y random_state fija qué filas caen en cada una. np.bincount cuenta cuántas veces aparece cada etiqueta.

Pregunta. Escribe la forma de las tres partes y el número aproximado de imágenes de cada clase en prueba.

Python 3 · ejemplo reproducible
X_resto, X_prueba, y_resto, y_prueba = train_test_split(
    X, y, test_size=360, stratify=y, random_state=0
)
X_ent, X_val, y_ent, y_val = train_test_split(
    X_resto, y_resto, test_size=360, stratify=y_resto, random_state=0
)

print(X_ent.shape, X_val.shape, X_prueba.shape)
print(np.bincount(y_prueba))
Salida esperada

La salida esperada está oculta.

El baseline tiene dos niveles. DummyClassifier con strategy="most_frequent" predice siempre la clase más frecuente. La regresión logística de M3 es el modelo clásico que el MLP debe superar; max_iter=1000 le da iteraciones suficientes para converger. score devuelve la exactitud.

Pregunta. ¿Qué exactitud de validación tendrá el dummy con diez clases casi balanceadas?

Python 3 · ejemplo reproducible
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression

dummy = DummyClassifier(strategy="most_frequent").fit(X_ent, y_ent)
logistica = LogisticRegression(max_iter=1000).fit(X_ent, y_ent)

print("dummy     val:", round(dummy.score(X_val, y_val), 3))
print("logística ent:", round(logistica.score(X_ent, y_ent), 3))
print("logística val:", round(logistica.score(X_val, y_val), 3))
Salida esperada

La salida esperada está oculta.

Prueba del loop

Antes de entrenar con los 1077 ejemplos, el loop se prueba con un batch diminuto. Un MLP con miles de parámetros debe memorizar 32 imágenes. Si no lo logra, el error está en el código, no en los datos.

Primero, los datos pasan a tensores. dtype=torch.float32 iguala el tipo de los pesos de las capas; las etiquetas se quedan como enteros, que es lo que espera nn.CrossEntropyLoss. Esa pérdida recibe los logits sin normalizar y aplica softmax internamente.

Python 3 · ejemplo reproducible
X_ent_t = torch.tensor(X_ent, dtype=torch.float32)
y_ent_t = torch.tensor(y_ent)
X_val_t = torch.tensor(X_val, dtype=torch.float32)
y_val_t = torch.tensor(y_val)
perdida_fn = nn.CrossEntropyLoss()

crear_mlp fija la semilla con torch.manual_seed antes de crear las capas, así que la misma semilla produce los mismos pesos iniciales. nn.Sequential encadena una capa oculta de 64 neuronas, ReLU, Dropout y una capa de salida con un logit por clase.

Python 3 · ejemplo reproducible
def crear_mlp(semilla, p_dropout=0.0):
    torch.manual_seed(semilla)
    return nn.Sequential(
        nn.Linear(64, 64),
        nn.ReLU(),
        nn.Dropout(p_dropout),  # con 0.0 no apaga ninguna neurona
        nn.Linear(64, 10),
    )

evaluar pone el modelo en modo eval, que desactiva dropout, y calcula sin registrar gradientes gracias a torch.no_grad. Devuelve pérdida y exactitud; .item() convierte un tensor de un solo valor en un número de Python.

Python 3 · ejemplo reproducible
def evaluar(modelo, X_t, y_t):
    modelo.eval()
    with torch.no_grad():
        logits = modelo(X_t)
    perdida = perdida_fn(logits, y_t).item()
    # argmax(dim=1): la clase con mayor logit en cada fila
    exactitud = (logits.argmax(dim=1) == y_t).float().mean().item()
    return round(perdida, 4), round(exactitud, 3)

La prueba usa torch.optim.Adam con lr=0.01 sobre las primeras 32 imágenes. Cada paso llama a zero_grad para borrar los gradientes anteriores, calcula la pérdida, la propaga con backward y actualiza los pesos con step. modelo.train() activa el modo de entrenamiento, el opuesto de eval.

Pregunta. Con pesos iniciales pequeños, las diez clases reciben casi la misma probabilidad. ¿Qué pérdida de entropía cruzada esperas en el primer paso? ¿En cuál de los pasos impresos estará ya por debajo de 0.1?

Python 3 · ejemplo reproducible
modelo = crear_mlp(semilla=0)
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
x_mini, y_mini = X_ent_t[:32], y_ent_t[:32]

modelo.train()
for paso in range(1, 101):
    optimizador.zero_grad()
    perdida = perdida_fn(modelo(x_mini), y_mini)
    perdida.backward()
    optimizador.step()
    if paso in (1, 25, 50, 100):
        print(paso, round(perdida.item(), 4))

print(evaluar(modelo, x_mini, y_mini))
Salida esperada

La salida esperada está oculta.

Curvas y diagnóstico

Para entrenar con todos los ejemplos hacen falta batches. TensorDataset empareja cada imagen con su etiqueta y DataLoader las entrega en grupos de batch_size=32. shuffle=True cambia el orden en cada época; el argumento generator recibe un torch.Generator con semilla, así que ese orden se repite entre ejecuciones.

Python 3 · ejemplo reproducible
from torch.utils.data import DataLoader, TensorDataset


def crear_cargador(semilla):
    generador = torch.Generator().manual_seed(semilla)
    return DataLoader(
        TensorDataset(X_ent_t, y_ent_t),
        batch_size=32,
        shuffle=True,
        generator=generador,
    )

entrenar repite el loop de la prueba sobre cada batch durante varias épocas. Al final de cada época llama a evaluar en entrenamiento y en validación y guarda una fila en historia.

Python 3 · ejemplo reproducible
def entrenar(modelo, epocas, semilla, lr=0.01):
    cargador = crear_cargador(semilla)
    optimizador = torch.optim.Adam(modelo.parameters(), lr=lr)
    historia = []
    for epoca in range(1, epocas + 1):
        modelo.train()  # evaluar dejó el modelo en modo eval
        for xb, yb in cargador:
            optimizador.zero_grad()
            perdida_fn(modelo(xb), yb).backward()
            optimizador.step()
        p_ent, ex_ent = evaluar(modelo, X_ent_t, y_ent_t)
        p_val, ex_val = evaluar(modelo, X_val_t, y_val_t)
        historia.append((epoca, p_ent, ex_ent, p_val, ex_val))
    return historia

entrenar(crear_mlp(semilla=0), epocas=30, semilla=0) entrena 30 épocas con la semilla 0 y devuelve las 30 filas de historia. La fila de menor pérdida de validación se busca con min(historia, key=lambda fila: fila[3]), porque fila[3] es la pérdida de validación.

Pregunta. Durante 30 épocas con Adam y lr=0.01, ¿qué pérdida sigue bajando hasta el final? ¿La pérdida de validación tendrá un mínimo antes de la época 30?

Ver resultado

La tabla muestra la época 1 y cada quinta época. Cada fila da, al final de esa época, la pérdida en entrenamiento (ent.) y en validación (val.), y luego la exactitud en entrenamiento y en validación. Por ejemplo, la fila de la época 5 dice que la pérdida de entrenamiento vale 0.0729, la de validación 0.1398, y que el modelo acierta 0.989 de entrenamiento y 0.969 de validación.

épocapérdida ent.pérdida val.exactitud ent.exactitud val.
10.47760.49580.8660.878
50.07290.13980.9890.969
100.03100.12790.9970.972
150.01360.13131.0000.967
200.00560.12591.0000.972
250.00370.13501.0000.972
300.00250.14321.0000.969

La pérdida de validación mínima es 0.1207, en la época 12.

Lectura. En las filas de la tabla, la pérdida de entrenamiento baja hasta 0.0025 y la exactitud de entrenamiento llega a 1.000 en la época 15. La pérdida de validación toca su mínimo, 0.121, en la época 12; después oscila y termina en 0.143. Desde la época 5, en las filas de la tabla, la exactitud de validación se queda entre 0.967 y 0.972. El diagnóstico es sobreajuste. Después de la época 12, el modelo sigue ajustando el conjunto de entrenamiento, pero ya no mejora en validación. La pérdida de validación sube aunque la exactitud apenas cambia: el modelo se vuelve más seguro en las imágenes que clasifica mal.

Las respuestas conocidas al sobreajuste son regularizar (dropout, weight decay) o detener antes. La ablation siguiente prueba solo una: dropout.

Ablation controlada

Una ablation cambia un solo factor y deja todo lo demás igual. Aquí cambia p_dropout de 0.0 a 0.2. Se conservan la arquitectura, el optimizador, lr, las 30 épocas y la partición. Cada semilla fija los pesos iniciales y el orden de los batches, y las dos configuraciones usan las mismas tres semillas. El diccionario modelos guarda los seis modelos entrenados para la comparación final.

Pregunta. ¿Bajará la pérdida de validación final en las tres semillas? ¿La diferencia de exactitud entre configuraciones será mayor que la diferencia entre semillas de una misma configuración?

Python 3 · ejemplo reproducible
modelos = {}
print("p_dropout semilla  p_val  ex_val")
for p in (0.0, 0.2):
    for semilla in (0, 1, 2):
        modelo = crear_mlp(semilla, p_dropout=p)
        # [-1] es la última época; *_ descarta las tres primeras columnas
        *_, p_val, ex_val = entrenar(modelo, epocas=30, semilla=semilla)[-1]
        modelos[(p, semilla)] = modelo
        print(f"{p:9.1f} {semilla:7d} {p_val:.4f}  {ex_val:.3f}")
Salida esperada

La salida esperada está oculta.

La configuración elegida es p_dropout=0.2. La decisión se tomó con validación, así que el conjunto de prueba sigue sin usar.

Comparación final

El conjunto de prueba se evalúa una sola vez, con la regresión logística y con los tres MLP de la configuración elegida. Primero se obtienen las predicciones. predict da la clase de la logística para cada imagen. predecir_mlp hace lo mismo con un MLP: modo eval, sin gradientes, la clase de mayor logit y .numpy() para comparar con y_prueba.

Python 3 · ejemplo reproducible
X_prueba_t = torch.tensor(X_prueba, dtype=torch.float32)
pred_log = logistica.predict(X_prueba)


def predecir_mlp(modelo):
    modelo.eval()
    with torch.no_grad():
        return modelo(X_prueba_t).argmax(dim=1).numpy()

Además de la exactitud, se cuentan dos tipos de desacuerdo: imágenes que solo acierta el MLP e imágenes que solo acierta la logística. & combina dos máscaras booleanas elemento a elemento y sum cuenta los True.

Pregunta. La logística falló 12 imágenes de validación. ¿Cuántas fallará en prueba? ¿Los tres MLP cometerán el mismo número de errores?

Python 3 · ejemplo reproducible
errores_log = (pred_log != y_prueba).sum()
print(f"logística   errores={errores_log}  exactitud={1 - errores_log / 360:.3f}")

for semilla in (0, 1, 2):
    pred_mlp = predecir_mlp(modelos[(0.2, semilla)])
    errores_mlp = (pred_mlp != y_prueba).sum()
    solo_mlp = ((pred_mlp == y_prueba) & (pred_log != y_prueba)).sum()
    solo_log = ((pred_log == y_prueba) & (pred_mlp != y_prueba)).sum()
    print(
        f"MLP semilla {semilla}  errores={errores_mlp}  "
        f"exactitud={1 - errores_mlp / 360:.3f}  "
        f"solo_mlp={solo_mlp}  solo_log={solo_log}"
    )
Salida esperada

La salida esperada está oculta.

Práctica

Traza guiada

Antes de procesar, escribe el número de parámetros de red, la forma de red(lote) y si dos llamadas seguidas a red(lote) dan el mismo resultado en modo train y en modo eval.

Python 3 · ejemplo reproducible
torch.manual_seed(0)
red = nn.Sequential(
    nn.Linear(64, 32),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(32, 10),
)
lote = torch.rand(5, 64)

Comprueba el conteo con sum(p.numel() for p in red.parameters()) y la igualdad con torch.equal. Si los parámetros no suman lo que calculaste, cuenta los sesgos: cada nn.Linear tiene uno por neurona de salida.

Tamaño de capa oculta

Añade a crear_mlp un argumento oculta que fije el número de neuronas de la capa oculta. Con p_dropout=0.2 fijo, compara oculta=32 contra oculta=64 con las semillas 0, 1 y 2. Antes de procesar, predice si la pérdida de validación con oculta=32 será menor que con oculta=64 en las tres semillas. La tabla debe tener seis filas y solo debe cambiar oculta entre las dos mitades. Si una fila de oculta=64 no coincide con la tabla de la ablation, algún otro factor cambió.

Protocolo con load_breast_cancer

Repite la misión con load_breast_cancer (569 tumores, 30 características, dos clases):

  1. separa prueba y validación con stratify y random_state=0
  2. ajusta un StandardScaler solo con entrenamiento y aplícalo a las tres partes
  3. obtén el baseline con DummyClassifier y LogisticRegression
  4. prueba el loop con un batch de 32 hasta exactitud 1.0
  5. registra curvas, escribe un diagnóstico y haz una ablation de un solo factor con tres semillas
  6. evalúa prueba una sola vez y escribe la comparación en errores, no solo en exactitud

Comprueba con assert que la salida del MLP tenga forma (n, 2) y que escalador.n_samples_seen_ sea igual al número de filas de entrenamiento.

Reto opcional

Repite la comparación final con cinco particiones distintas: cambia random_state de 0 a 4 en ambos train_test_split. Para cada partición, entrena la logística y un MLP con p_dropout=0.2 y semilla 0. Registra en cuántas particiones el MLP comete menos errores y el rango de las diferencias.

Errores que revisar

  • Si la pérdida del batch diminuto se queda cerca de 2.3, el loop no actualiza los pesos. Revisa que se llame optimizador.step() y que el optimizador haya recibido los parámetros del mismo modelo que entrenas.
  • Si dos llamadas a evaluar sin entrenar entre ellas dan resultados distintos, dropout sigue activo. Revisa que evaluar llame a modelo.eval().
  • Si dos ejecuciones con la misma semilla dan tablas distintas, falta fijar una fuente de azar. Revisa torch.manual_seed antes de crear las capas y el generator del DataLoader.
  • Si una ablation cambia la pérdida pero también cambiaste lr o las épocas, la diferencia no se puede atribuir al factor. Repite cambiando solo uno.
  • Si elegiste dropout, épocas o arquitectura mirando la exactitud de prueba, ese número ya no estima el desempeño en imágenes nuevas. Decide con validación y evalúa prueba al final.

Cierre

Escribe la conclusión de la misión en dos frases: cuántos errores de prueba comete cada modelo y por qué una sola semilla no basta para afirmar que el MLP es mejor.

Lista de comprobación

  • ¿Por qué la pérdida inicial de un clasificador de diez clases sin entrenar está cerca de 2.3?
  • ¿Qué demuestra el sobreajuste de un batch de 32 y qué no demuestra?
  • ¿Qué curva indica sobreajuste en esta misión y en qué época empieza?
  • ¿Qué factores permanecieron fijos en la ablation de dropout?
  • ¿Por qué la diferencia final se expresa en errores sobre 360 imágenes y con varias semillas?

Documentación oficial

Cargando progreso…