Módulo 5 · Redes neuronales
Regularización y normalización
En esta páginaEl problema
El problema
Tienes 100 ejemplos etiquetados para entrenar y 500 para validar. Cada ejemplo tiene 20 columnas: las dos primeras son las coordenadas de make_moons con ruido 0.3 y las otras 18 son ruido gaussiano sin relación con la clase. Un MLP 20 → 128 → 128 → 2 con ReLU se entrena con Adam (lr=1e-3) en batches de 25 durante 100 épocas. Estas son sus pérdidas de entropía cruzada:
| época | entrenamiento | validación |
|---|---|---|
| 1 | 0.649 | 0.667 |
| 100 | 0.000 | 1.035 |
El modelo termina con una pérdida de entrenamiento casi nula y una pérdida de validación más alta que después de la primera época. Memorizó los 100 ejemplos, incluido el ruido de las 18 columnas.
Early stopping
La celda plegada genera los datos del problema: 600 puntos de make_moons con 18 columnas de ruido, 100 para entrenamiento y 500 para validación con la misma proporción de clases. StandardScaler se ajusta solo con el entrenamiento, estandariza las dos partes y el resultado se convierte en tensores.
Mostrar códigoOcultar código
import numpy as np
import torch
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
lunas, etiquetas = make_moons(n_samples=600, noise=0.3, random_state=0)
ruido = np.random.default_rng(0).normal(size=(600, 18))
X = np.hstack([lunas, ruido])
X_ent, X_val, y_ent, y_val = train_test_split(
X, etiquetas, train_size=100, stratify=etiquetas, random_state=0
)
escala = StandardScaler().fit(X_ent)
X_ent = torch.tensor(escala.transform(X_ent), dtype=torch.float32)
X_val = torch.tensor(escala.transform(X_val), dtype=torch.float32)
y_ent = torch.tensor(y_ent)
y_val = torch.tensor(y_val)
X_ent tiene forma (100, 20) y X_val, (500, 20): una fila por ejemplo y 20 columnas estandarizadas. y_ent y y_val guardan la clase, 0 o 1. La proporción de clase 1 es 0.5 en entrenamiento y 0.5 en validación.
mlp construye la red 20 → 128 → 128 → 2. perdida_fn es la entropía cruzada, y evaluar la calcula en modo eval y sin registrar gradientes.
from torch import nn
def mlp():
return nn.Sequential(
nn.Linear(20, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, 2),
)
perdida_fn = nn.CrossEntropyLoss()
def evaluar(modelo, X, y):
modelo.eval()
with torch.no_grad():
return perdida_fn(modelo(X), y).item()
La celda plegada define dos auxiliares del loop de entrenamiento. preparar fija la semilla con torch.manual_seed, construye el modelo, crea torch.optim.Adam con lr=1e-3 y el weight_decay recibido, y un DataLoader con batches de 25 cuyo orden depende de la misma semilla. entrenar_epoca recorre los batches una vez con zero_grad, forward, pérdida, backward y step.
Mostrar códigoOcultar código
from torch.utils.data import DataLoader, TensorDataset
def preparar(construir, weight_decay, semilla):
torch.manual_seed(semilla)
modelo = construir()
optimizador = torch.optim.Adam(
modelo.parameters(), lr=1e-3, weight_decay=weight_decay
)
lotes = DataLoader(
TensorDataset(X_ent, y_ent),
batch_size=25,
shuffle=True,
generator=torch.Generator().manual_seed(semilla),
)
return modelo, optimizador, lotes
def entrenar_epoca(modelo, optimizador, lotes):
modelo.train()
for xb, yb in lotes:
optimizador.zero_grad()
perdida_fn(modelo(xb), yb).backward()
optimizador.step()
entrenar repite entrenar_epoca y guarda en historial, al final de cada época, la pérdida de entrenamiento y la de validación. Cada vez que la pérdida de validación alcanza un mínimo nuevo, guarda una copia de los pesos con copy.deepcopy(modelo.state_dict()). Con paciencia=p, el entrenamiento se detiene cuando pasan p épocas sin un mínimo nuevo, y load_state_dict devuelve el modelo a los pesos guardados. Esa regla se llama early stopping.
import copy
def entrenar(construir, epocas=100, weight_decay=0.0, paciencia=None, semilla=0):
modelo, optimizador, lotes = preparar(construir, weight_decay, semilla)
historial = []
mejor_val, mejor_epoca, mejores_pesos = float("inf"), 0, None
for epoca in range(1, epocas + 1):
entrenar_epoca(modelo, optimizador, lotes)
historial.append((evaluar(modelo, X_ent, y_ent), evaluar(modelo, X_val, y_val)))
if historial[-1][1] < mejor_val:
mejor_val, mejor_epoca = historial[-1][1], epoca
mejores_pesos = copy.deepcopy(modelo.state_dict()) # copia, no referencia
elif paciencia is not None and epoca - mejor_epoca >= paciencia:
break
if paciencia is not None:
modelo.load_state_dict(mejores_pesos)
return modelo, historial, mejor_epoca
Pregunta. Sin early stopping, ¿en qué época aproximada queda la pérdida de validación más baja? En esa época, ¿la pérdida de entrenamiento ya es casi cero?
Ver resultado
La tabla muestra, para algunas épocas de entrenar(mlp), la pérdida de entrenamiento y la de validación al final de esa época. Por ejemplo, la fila con 10, 0.194 y 0.418 dice que al terminar la época 10 la pérdida de entrenamiento es 0.194 y la de validación es 0.418.
| época | entrenamiento | validación |
|---|---|---|
| 1 | 0.649 | 0.667 |
| 5 | 0.434 | 0.528 |
| 10 | 0.194 | 0.418 |
| 15 | 0.067 | 0.477 |
| 25 | 0.009 | 0.668 |
| 50 | 0.001 | 0.870 |
| 100 | 0.000 | 1.035 |
El mínimo de validación está en la época 10.
Pregunta. Con paciencia=10 y la misma semilla, ¿cuántas épocas se ejecutan? ¿Qué pérdida de validación tiene el modelo devuelto?
Ver resultado
La corrida es entrenar(mlp, paciencia=10). len(historial) cuenta las épocas ejecutadas y evaluar(modelo, X_val, y_val) mide el modelo devuelto:
Se ejecutan 20 épocas, la mejor es la 10 y el modelo devuelto tiene pérdida de validación 0.418.
Weight decay y dropout
Early stopping elige cuándo parar. Otras técnicas cambian lo que el modelo aprende mientras se entrena.
El argumento weight_decay de torch.optim.Adam suma weight_decay · w al gradiente de cada parámetro w. Eso equivale a sumar (weight_decay / 2) · Σ w² a la pérdida: la misma penalización L2 que Ridge controla con alpha. torch.optim.AdamW aplica el decaimiento directamente a los pesos, fuera del gradiente, y su efecto por unidad de weight_decay es distinto.
La celda plegada define resumen, que imprime en una línea la mejor pérdida de validación con su época, las pérdidas de la última época y la norma de los parámetros del modelo.
Mostrar códigoOcultar código
def resumen(nombre, modelo, historial, mejor_epoca):
mejor_val = historial[mejor_epoca - 1][1]
ent_final, val_final = historial[-1]
print(
f"{nombre:<13} mejor val {mejor_val:.3f} (época {mejor_epoca:>2}) "
f"final: ent {ent_final:.3f} val {val_final:.3f} norma {norma(modelo):.1f}"
)
norma junta todos los parámetros del modelo en un solo vector con torch.cat y calcula su longitud euclídea con .norm(). En resumen("weight decay", *entrenar(...)), el * pasa las tres salidas de entrenar como argumentos.
Pregunta. Con weight_decay=0.05 y la misma semilla, ¿qué cambia más: la mejor pérdida de validación o la pérdida de validación de la época 100? ¿La norma de los parámetros al final será mayor o menor?
def norma(modelo):
return torch.cat([p.detach().flatten() for p in modelo.parameters()]).norm().item()
resumen("base", *entrenar(mlp))
resumen("weight decay", *entrenar(mlp, weight_decay=0.05))
La salida esperada está oculta.
nn.Dropout(p) actúa distinto según el modo del módulo. En modo train, pone en cero cada valor con probabilidad p y multiplica los que sobreviven por 1 / (1 − p).
capa.train() y capa.eval() cambian el modo de la capa, y torch.manual_seed(0) fija qué valores se apagan.
Pregunta. Con p=0.5 y un vector de diez unos, ¿qué valores puede tener la salida en modo train? ¿Qué devuelve en modo eval?
capa = nn.Dropout(p=0.5)
unos = torch.ones(10)
torch.manual_seed(0)
capa.train()
print(capa(unos))
capa.eval()
print(capa(unos))
La salida esperada está oculta.
Durante el entrenamiento, cada batch usa una red con neuronas apagadas al azar. Ninguna neurona puede depender de que otra específica esté presente.
mlp_dropout(p) es mlp() con nn.Dropout(p) después de cada ReLU. entrenar llama a construir() sin argumentos, así que lambda: mlp_dropout(0.5) le pasa una función sin argumentos que construye esa red con p=0.5.
Pregunta. Con nn.Dropout(0.5) después de cada ReLU, ¿la mejor época llega antes o después que en la base? ¿La pérdida de validación final baja tanto como con weight decay?
def mlp_dropout(p):
return nn.Sequential(
nn.Linear(20, 128),
nn.ReLU(),
nn.Dropout(p),
nn.Linear(128, 128),
nn.ReLU(),
nn.Dropout(p),
nn.Linear(128, 2),
)
resumen("dropout 0.5", *entrenar(lambda: mlp_dropout(0.5)))
La salida esperada está oculta.
Inicialización de pesos
Antes de la primera época, cada capa nn.Linear ya tiene pesos aleatorios. Su escala decide cuánto crecen o se encogen las activaciones al pasar por varias capas. Por defecto, PyTorch toma los pesos de una distribución uniforme con desviación estándar 1 / √(3 · entradas). La inicialización de He (nn.init.kaiming_normal_) usa una normal con desviación estándar √(2 / entradas).
iniciar reescribe los pesos de una capa Linear según el nombre de la inicialización. nn.init.zeros_ pone los sesgos en cero y nn.init.normal_ toma los pesos de una normal con la desviación estándar std. nonlinearity="relu" elige la fórmula de He para ReLU. Con "defecto", la capa queda como la creó PyTorch.
def iniciar(capa, inicio):
if inicio == "defecto":
return
nn.init.zeros_(capa.bias)
if inicio == "normal 0.01":
nn.init.normal_(capa.weight, std=0.01)
elif inicio == "normal 0.5":
nn.init.normal_(capa.weight, std=0.5)
elif inicio == "He":
nn.init.kaiming_normal_(capa.weight, nonlinearity="relu")
La celda plegada define pila(inicio, entradas, capas, ancho), que encadena capas bloques Linear + ReLU de ancho neuronas y aplica iniciar a cada Linear.
Mostrar códigoOcultar código
def pila(inicio, entradas, capas, ancho):
modulos = []
for _ in range(capas):
lineal = nn.Linear(entradas, ancho)
iniciar(lineal, inicio)
modulos += [lineal, nn.ReLU()]
entradas = ancho
return nn.Sequential(*modulos)
Una pila de diez capas Linear(128, 128) con ReLU recibe 512 vectores con desviación estándar 1. Con cada inicialización y la misma semilla, la desviación estándar de la salida de cada ReLU mide la escala de las activaciones.
Pregunta. Para cada inicialización, ¿la desviación estándar de las activaciones en la capa 10 será cercana a 1, mucho menor o mucho mayor?
Ver resultado
La tabla tiene una fila por inicialización. Cada columna da la desviación estándar de las activaciones después de la ReLU de la capa 1, la 5 y la 10. Por ejemplo, la fila de He dice que esa desviación estándar es 0.829 en la capa 1, 0.752 en la capa 5 y 0.644 en la capa 10.
| inicialización | capa 1 | capa 5 | capa 10 |
|---|---|---|---|
| defecto | 0.341 | 0.0323 | 0.0318 |
| normal 0.01 | 0.0663 | 2.46e-06 | 6.91e-12 |
| normal 0.5 | 3.32 | 770 | 6.75e+05 |
| He | 0.829 | 0.752 | 0.644 |
√(2 / entradas) compensa que la ReLU anule la mitad de los valores y conserva, en promedio, la media de los cuadrados de las activaciones. La desviación estándar queda por debajo de 1 porque las salidas de la ReLU no están centradas en cero. La inicialización por defecto encoge la señal en las primeras capas y después se estabiliza en un valor pequeño, sostenida por los sesgos.La escala de las activaciones también fija la escala de los gradientes, porque backward() recorre las mismas capas en sentido inverso. La comparación usa una red de ocho capas ocultas de 64 neuronas sobre los datos de las lunas: profunda(inicio) es pila(inicio, entradas=20, capas=8, ancho=64) seguida de una capa de salida Linear(64, 2). Con red = profunda(inicio), tras un backward() sobre los 100 ejemplos de entrenamiento, weight.grad.norm() mide el tamaño del gradiente de los pesos de la primera capa:
perdida = perdida_fn(red(X_ent), y_ent)
perdida.backward()
red[0][0].weight.grad.norm() # primera Linear de la pila
Después, entrenar(lambda: profunda(inicio), epocas=20) entrena una red con la misma inicialización.
Pregunta. ¿Qué inicialización tiene el gradiente más pequeño en la primera capa? ¿Cuál tendrá una pérdida inicial muy lejos de ln 2 ≈ 0.693, la de un modelo que asigna 0.5 a cada clase?
Ver resultado
La tabla tiene una fila por inicialización. Las columnas dan la pérdida antes de entrenar, la norma del gradiente de los pesos de la primera capa en ese punto y las pérdidas de entrenamiento y validación al terminar la época 20. Por ejemplo, la fila de He dice que la pérdida inicial es 0.711, el gradiente de la primera capa mide 1.3e-01 y, en la época 20, la pérdida de entrenamiento es 0.000 y la de validación 1.643.
| inicialización | pérdida inicial | gradiente capa 1 | entrenamiento época 20 | validación época 20 |
|---|---|---|---|---|
| defecto | 0.705 | 5.6e-04 | 0.070 | 1.033 |
| normal 0.01 | 0.693 | 2.6e-10 | 0.693 | 0.693 |
| normal 0.5 | 464.387 | 2.6e+02 | 0.000 | 96.251 |
| He | 0.711 | 1.3e-01 | 0.000 | 1.643 |
BatchNorm y LayerNorm
Una capa de normalización resta una media y divide entre una desviación estándar. Después aplica una escala weight y un desplazamiento bias que se aprenden y empiezan en 1 y 0. Las dos capas más comunes difieren en qué valores promedian. En un batch de forma (ejemplos, características), nn.BatchNorm1d normaliza cada característica con los ejemplos del batch. nn.LayerNorm normaliza cada ejemplo con sus propias características.
lote es un batch de cuatro ejemplos con tres características.
lote = torch.tensor(
[
[1.0, 10.0, 100.0],
[2.0, 20.0, 300.0],
[3.0, 30.0, 200.0],
[4.0, 40.0, 400.0],
]
)
nn.BatchNorm1d(3) y nn.LayerNorm(3) reciben el número de características. mean(dim=0) promedia cada columna y mean(dim=1), cada fila.
Pregunta. En el batch de cuatro ejemplos, la segunda columna es 10 veces la primera. ¿Qué relación tendrán esas dos columnas en la salida de BatchNorm? ¿Qué salida tendrá media 0 en cada columna y cuál en cada fila? ¿Cuál cambiaría si se agregara un quinto ejemplo al batch?
bn = nn.BatchNorm1d(3)
ln = nn.LayerNorm(3)
with torch.no_grad():
salida_bn = bn(lote)
salida_ln = ln(lote)
print(salida_bn.round(decimals=2))
print(salida_ln.round(decimals=2))
print("medias por columna, BatchNorm:", salida_bn.mean(dim=0).abs().round(decimals=2))
print("medias por fila, LayerNorm: ", salida_ln.mean(dim=1).abs().round(decimals=2))
La salida esperada está oculta.
Al evaluar, un modelo puede recibir un solo ejemplo, y BatchNorm necesita aun así una media y una varianza por característica. En modo train, usa las del batch y además actualiza running_mean y running_var con momentum=0.1: nuevo = 0.9 · anterior + 0.1 · estadística del batch. En modo eval, usa esos promedios acumulados en lugar de las estadísticas del batch. torch.equal devuelve True si dos tensores tienen la misma forma y los mismos valores.
Pregunta. Después de la única pasada en modo train, ¿cuánto vale running_mean de la primera columna, cuya media en el batch es 2.5? En modo eval, ¿la primera columna de la salida seguirá teniendo media 0?
print("running_mean:", bn.running_mean)
print("running_var: ", bn.running_var.round(decimals=2))
bn.eval()
ln.eval()
with torch.no_grad():
print(bn(lote).round(decimals=2))
print(torch.equal(ln(lote), salida_ln))
La salida esperada está oculta.
Comparación controlada
Una comparación atribuye una diferencia a un cambio solo si ese cambio es lo único distinto. En estas variantes, la partición, el número de épocas, el optimizador, el learning rate, el tamaño de batch y las semillas son los mismos. mlp_normalizada inserta una capa de normalización después de cada Linear oculta. Esa variante tiene más capas que mlp(). Recibe la clase de la capa, nn.BatchNorm1d o nn.LayerNorm, y la construye con 128 características.
def mlp_normalizada(Capa):
return nn.Sequential(
nn.Linear(20, 128),
Capa(128),
nn.ReLU(),
nn.Linear(128, 128),
Capa(128),
nn.ReLU(),
nn.Linear(128, 2),
)
La celda siguiente fija la misma semilla antes de construir cada red y compara los pesos de su primera capa.
Pregunta. ¿Los pesos iniciales de la primera capa de mlp() y de mlp_normalizada(nn.LayerNorm) serán iguales con torch.manual_seed(0)?
torch.manual_seed(0)
pesos_base = mlp()[0].weight
torch.manual_seed(0)
pesos_ln = mlp_normalizada(nn.LayerNorm)[0].weight
print(torch.equal(pesos_base, pesos_ln))
La salida esperada está oculta.
Una sola semilla tampoco basta. Otra semilla cambia los pesos iniciales, el orden de los batches y las máscaras de dropout, y eso ya mueve el resultado.
variantes guarda, para cada nombre, los argumentos de entrenar que definen la variante. Todo lo que no aparece toma el valor por defecto de entrenar.
variantes = {
"base": dict(construir=mlp),
"weight decay": dict(construir=mlp, weight_decay=0.05),
"dropout 0.5": dict(construir=lambda: mlp_dropout(0.5)),
"BatchNorm": dict(construir=lambda: mlp_normalizada(nn.BatchNorm1d)),
"LayerNorm": dict(construir=lambda: mlp_normalizada(nn.LayerNorm)),
}
En el loop, entrenar(semilla=semilla, **ajustes) pasa el diccionario de cada variante como argumentos con nombre. La tabla entrena cada variante con las semillas 0, 1 y 2, y reporta la media y el rango (mínimo–máximo) de la mejor pérdida de validación y de la pérdida de validación final.
Pregunta. El rango de la base mide cuánto cambia el resultado solo por la semilla. ¿Qué variantes tendrán una mejor pérdida de validación claramente fuera de ese rango? ¿Y una pérdida final fuera de él?
print(f"{'variante':<13} {'mejor val':>21} {'val final':>21}")
for nombre, ajustes in variantes.items():
mejores, finales = [], []
for semilla in [0, 1, 2]:
_, hist, epoca = entrenar(semilla=semilla, **ajustes)
mejores.append(hist[epoca - 1][1]) # validación en la mejor época
finales.append(hist[-1][1])
print(
f"{nombre:<13} {np.mean(mejores):.3f} ({min(mejores):.3f}–{max(mejores):.3f}) "
f"{np.mean(finales):.3f} ({min(finales):.3f}–{max(finales):.3f})"
)
La salida esperada está oculta.
Práctica
Traza guiada
Antes de procesar, escribe la salida de ln_2(x) fila por fila, la salida de bn_2(x) columna por columna y el valor de bn_2.running_mean después de la llamada.
x = torch.tensor([[0.0, 4.0], [2.0, 8.0]])
bn_2 = nn.BatchNorm1d(2)
ln_2 = nn.LayerNorm(2)
Comprueba con print dentro de torch.no_grad(). Cada fila de la salida de LayerNorm y cada columna de la de BatchNorm debe tener media 0. Si tu cálculo a mano difiere solo en los últimos decimales, la causa es el pequeño eps que ambas capas suman a la varianza. Para running_mean, aplica momentum=0.1 a la media de cada columna de x.
Intensidad de weight decay
Repite la comparación controlada con la base y weight_decay en [0.01, 0.05, 0.2], con las semillas 0, 1 y 2. Antes de procesar, predice cómo cambian la pérdida de entrenamiento final y la pérdida de validación final al aumentar weight_decay. Comprueba con assert que la norma final de los parámetros baja cuando weight_decay sube. Si la pérdida de validación final deja de bajar con el valor más alto, explica qué muestra la pérdida de entrenamiento final.
Inicialización en la comparación
Escribe mlp_he(), igual que mlp() pero con nn.init.kaiming_normal_(..., nonlinearity="relu") en las dos capas Linear ocultas. Compárala contra mlp() con la misma función entrenar, las semillas 0 a 4 y sin ningún otro cambio. Reporta:
- la media y el rango de la mejor pérdida de validación de cada una
- la media de la época del mínimo
- si la diferencia entre medias es mayor que el rango de la base
- una conclusión de una oración que solo afirme lo que muestran los números
Reto opcional
Escribe una función batchnorm_manual(x, media_corrida, var_corrida) que reproduzca nn.BatchNorm1d en modo train con weight=1, bias=0 y eps=1e-5: normaliza con la varianza sesgada del batch y actualiza las estadísticas corridas con la varianza insesgada. Comprueba con torch.allclose que la salida y running_var coinciden con las de nn.BatchNorm1d sobre lote.
Errores que revisar
- Si BatchNorm lanza
Expected more than 1 value per channel when training, un batch tiene un solo ejemplo. Suele ser el último batch delDataLoader; usadrop_last=Trueo cambiabatch_size. - Si el mismo modelo da predicciones distintas para la misma entrada, sigue en modo
train: dropout apaga neuronas distintas en cada llamada y BatchNorm usa las estadísticas del batch. Llama amodelo.eval()antes de evaluar. - Si la pérdida de dos clases se queda en 0.693 desde la primera época, la red predice 0.5 para todo. Revisa la escala de la inicialización y la norma del gradiente de la primera capa.
- Si una variante cambia a la vez el dropout y el learning rate, la diferencia no se puede atribuir a ninguno. Cambia una cosa y conserva semillas, partición y épocas.
- Si reportas como resultado final la pérdida de validación de la época que elegiste con esa misma validación, la cifra es optimista. Esa validación ya participó en la decisión.
Cierre
Una variante agrega dropout 0.3 y a la vez sube el learning rate de 1e-3 a 1e-2. Con una sola semilla, su pérdida de validación final es 0.60 y la de la base es 1.10. Explica por qué ese resultado no demuestra que el dropout causó la mejora y describe la comparación que sí lo pondría a prueba.
Lista de comprobación
- ¿Qué parte de la curva de validación cambia early stopping y cuál cambia weight decay?
- ¿Por qué
nn.Dropout(0.5)multiplica por 2 los valores que conserva en modotrain? - ¿Sobre qué eje de un batch
(ejemplos, características)normaliza BatchNorm y sobre cuál LayerNorm? - ¿Qué estadísticas usa BatchNorm en modo
evaly de dónde salen? - ¿Qué desviación estándar de la inicialización hace que las activaciones se encojan capa tras capa y cuál las conserva con ReLU?
Documentación oficial
- torch.optim.Adam: algoritmo de Adam y cómo
weight_decaymodifica el gradiente. - torch.nn.Dropout: probabilidad
p, escalado por1 / (1 − p)y comportamiento eneval. - torch.nn.init: funciones de inicialización, incluidas
normal_ykaiming_normal_. - torch.nn.BatchNorm1d: normalización por característica,
momentumy estadísticas corridas eneval. - torch.nn.LayerNorm: normalización por ejemplo sobre las últimas dimensiones.