Módulo 5 · Redes neuronales
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.
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())
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.
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))
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?
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))
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.
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.
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.
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?
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))
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.
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.
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.
| época | pérdida ent. | pérdida val. | exactitud ent. | exactitud val. |
|---|---|---|---|---|
| 1 | 0.4776 | 0.4958 | 0.866 | 0.878 |
| 5 | 0.0729 | 0.1398 | 0.989 | 0.969 |
| 10 | 0.0310 | 0.1279 | 0.997 | 0.972 |
| 15 | 0.0136 | 0.1313 | 1.000 | 0.967 |
| 20 | 0.0056 | 0.1259 | 1.000 | 0.972 |
| 25 | 0.0037 | 0.1350 | 1.000 | 0.972 |
| 30 | 0.0025 | 0.1432 | 1.000 | 0.969 |
La pérdida de validación mínima es 0.1207, en la época 12.
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?
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}")
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.
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?
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}"
)
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.
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):
- separa prueba y validación con
stratifyyrandom_state=0 - ajusta un
StandardScalersolo con entrenamiento y aplícalo a las tres partes - obtén el baseline con
DummyClassifieryLogisticRegression - prueba el loop con un batch de 32 hasta exactitud 1.0
- registra curvas, escribe un diagnóstico y haz una ablation de un solo factor con tres semillas
- 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
evaluarsin entrenar entre ellas dan resultados distintos, dropout sigue activo. Revisa queevaluarllame amodelo.eval(). - Si dos ejecuciones con la misma semilla dan tablas distintas, falta fijar una fuente de azar. Revisa
torch.manual_seedantes de crear las capas y elgeneratordelDataLoader. - Si una ablation cambia la pérdida pero también cambiaste
lro 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
- load_digits: descripción del conjunto de dígitos 8 × 8 y su formato.
- LogisticRegression: el clasificador lineal usado como baseline.
- Optimizing Model Parameters: loop de entrenamiento y validación por época en PyTorch.
- torch.nn.Dropout: comportamiento de dropout en modo
trainy en modoeval. - Reproducibility: fuentes de azar en PyTorch y cómo fijarlas.