💻 Tutorial paso a paso

Hacia el entrenamiento efectivo de redes neuronales

Construiremos un entrenamiento completo de una CNN sobre CIFAR-10 aplicando todas las técnicas necesarias para acelerar, estabilizar y regularizar el proceso. Inicialización de pesos, optimizadores, schedulers, gradient clipping, batch normalization, dropout, data augmentation — todo explicado y ejecutable.

⏱️ ~45 min 📊 Nivel: intermedio 🔥 PyTorch 2.x · torchvision · CIFAR-10

Requisitos previos

1

Visión general: las tres patas del entrenamiento

Entrenar una red neuronal es un acto de equilibrio entre tres objetivos: velocidad (converger rápido), estabilidad (que el entrenamiento no diverja ni oscile) y generalización (que lo aprendido funcione en datos nuevos). Cada técnica que veremos ataca uno o varios de estos ejes:

⚡ Acelerar Buena inicialización Adam / AdamW LR schedulers Mixed precision Batch normalization 🛡️ Estabilizar Gradient clipping Batch / Layer Norm Skip connections LR warmup Inicialización correcta 🎯 Regularizar Weight decay (L2) Dropout Data augmentation Label smoothing Early stopping Entrenamiento efectivo = equilibrio entre las tres

1.1 Setup e imports

Terminal instalar dependencias
pip install torch torchvision matplotlib tqdm
Python imports principales
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from torch.optim.lr_scheduler import CosineAnnealingLR, OneCycleLR
import matplotlib.pyplot as plt
from tqdm import tqdm
import copy

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")

1.2 Dataset: CIFAR-10

Usaremos CIFAR-10: 60K imágenes 32×32 en color, 10 clases. Es lo suficientemente complejo como para que las técnicas de entrenamiento marquen diferencia, pero lo suficientemente pequeño para iterar rápido.

Python cargar CIFAR-10
# Transforms básicos (los mejoraremos en el paso 7)
transform_base = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465),
                         (0.2470, 0.2435, 0.2616)),
])

train_dataset = datasets.CIFAR10(root='./data', train=True,
                                  download=True, transform=transform_base)
test_dataset  = datasets.CIFAR10(root='./data', train=False,
                                  download=True, transform=transform_base)

train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True,
                          num_workers=2, pin_memory=True)
test_loader  = DataLoader(test_dataset, batch_size=256, shuffle=False,
                          num_workers=2, pin_memory=True)

print(f"Train: {len(train_dataset)} | Test: {len(test_dataset)}")
print(f"Clases: {train_dataset.classes}")
print(f"Input shape: {train_dataset[0][0].shape}")  # → torch.Size([3, 32, 32])
L3-5Normalization con la media y desviación estándar de CIFAR-10. Es fundamental para que los inputs tengan media ≈ 0 y std ≈ 1.
L14pin_memory=True acelera la transferencia CPU→GPU. Siempre úsalo si tienes GPU.

1.3 Modelo base: CNN

Definimos una CNN sencilla que iremos mejorando paso a paso. Empezamos sin ninguna técnica especial — luego las añadiremos.

Python CNN base (sin técnicas especiales)
class SimpleCNN(nn.Module):
    """CNN base sin batch norm, sin dropout — para comparar."""
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(),
            nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 256), nn.ReLU(),
            nn.Linear(256, num_classes),
        )

    def forward(self, x):
        return self.classifier(self.features(x))

# Número de parámetros
model = SimpleCNN().to(device)
total_params = sum(p.numel() for p in model.parameters())
print(f"Parámetros: {total_params:,}")  # → ~340K
💡 Estrategia del tutorial: Empezamos con este modelo "desnudo" y en cada paso añadiremos una técnica. Al final, en el paso 8, combinaremos todo en un entrenamiento completo y veremos la diferencia de rendimiento.

1.4 Mapa de técnicas que cubriremos

2

Inicialización de pesos

Una mala inicialización puede hacer que el entrenamiento no converja en absoluto: si los pesos son demasiado grandes, los gradientes explotan; si son demasiado pequeños, se desvanecen. El objetivo es que, en el forward pass, las activaciones mantengan una varianza estable capa a capa.

2.1 ¿Qué pasa con una inicialización incorrecta?

Python demostración: inicialización con N(0, 1)
# ❌ Mala idea: pesos ~ N(0, 1) sin escalar
bad_model = SimpleCNN().to(device)
for p in bad_model.parameters():
    if p.dim() >= 2:
        nn.init.normal_(p, mean=0.0, std=1.0)

# Pasamos un batch y vemos las activaciones
x = torch.randn(4, 3, 32, 32).to(device)
hooks = []
activations = {}
for name, layer in bad_model.features.named_children():
    def hook_fn(n):
        def hook(module, input, output):
            activations[n] = output.detach()
        return hook
    hooks.append(layer.register_forward_hook(hook_fn(name)))

with torch.no_grad():
    bad_model(x)

for name, act in activations.items():
    print(f"Layer {name:>2s}: mean={act.mean():.4f}, std={act.std():.4f}, "
          f"dead={((act==0).float().mean()*100):.1f}%")

for h in hooks: h.remove()
Salida típica (N(0,1) sin escalar) Layer 0: mean=0.0812, std=3.2147, dead=0.0% Layer 1: mean=1.5213, std=2.9831, dead=50.1% Layer 2: mean=0.1145, std=8.7621, dead=0.0% Layer 3: mean=5.2891, std=9.0134, dead=49.8% ... Layer 12: mean=nan, std=nan, dead=100.0% ← ¡Explotó!
⚠️ Observación: Las activaciones crecen exponencialmente de capa en capa. Hacia las capas profundas, los valores son NaN. El modelo es inentrenable.

2.2 Xavier / Glorot (para tanh, sigmoid)

Glorot & Bengio (2010) propusieron escalar los pesos según el fan-in y fan-out de cada capa: W ~ U(-a, a) con a = √(6 / (fan_in + fan_out)).

Python Xavier init
def init_xavier(module):
    """Xavier/Glorot — ideal para tanh, sigmoid."""
    if isinstance(module, (nn.Linear, nn.Conv2d)):
        nn.init.xavier_uniform_(module.weight)
        if module.bias is not None:
            nn.init.zeros_(module.bias)

model_xavier = SimpleCNN().to(device)
model_xavier.apply(init_xavier)
print("✓ Xavier init aplicada")

2.3 Kaiming / He (para ReLU)

He et al. (2015) ajustaron la fórmula para activaciones ReLU, que matan la mitad de las activaciones (los negativos). Esto requiere duplicar la varianza: W ~ N(0, √(2 / fan_in)).

Python Kaiming init (la que usaremos)
def init_kaiming(module):
    """Kaiming/He — ideal para ReLU y variantes."""
    if isinstance(module, (nn.Linear, nn.Conv2d)):
        nn.init.kaiming_normal_(module.weight, mode='fan_in', nonlinearity='relu')
        if module.bias is not None:
            nn.init.zeros_(module.bias)

model = SimpleCNN().to(device)
model.apply(init_kaiming)
print("✓ Kaiming init aplicada")
L4mode='fan_in' preserva la varianza en el forward pass. 'fan_out' la preserva en el backward. Ambos funcionan; fan_in es más común.
L4nonlinearity='relu' ajusta la ganancia para ReLU. Para LeakyReLU, usa nonlinearity='leaky_relu'.

2.4 Verificar la inicialización

Python comprobar activaciones tras Kaiming
# Repetimos el experimento con Kaiming
activations = {}
hooks = []
for name, layer in model.features.named_children():
    def hook_fn(n):
        def hook(module, input, output):
            activations[n] = output.detach()
        return hook
    hooks.append(layer.register_forward_hook(hook_fn(name)))

with torch.no_grad():
    model(x)

for name, act in activations.items():
    print(f"Layer {name:>2s}: mean={act.mean():.4f}, std={act.std():.4f}, "
          f"dead={((act==0).float().mean()*100):.1f}%")

for h in hooks: h.remove()
Salida con Kaiming init Layer 0: mean=0.0234, std=0.5812, dead=0.0% Layer 1: mean=0.3421, std=0.4903, dead=48.2% Layer 2: mean=0.0115, std=0.5034, dead=0.0% Layer 3: mean=0.2318, std=0.4567, dead=49.1% ... Layer 12: mean=0.1245, std=0.4210, dead=50.3% ← ¡Estable!
🔑 Resultado: Con Kaiming, la desviación estándar se mantiene ≈ 0.5 en todas las capas. ~50% de neuronas muertas es normal con ReLU (los negativos se cortan). Lo importante es que no crece ni se desvanece.
MétodoFórmulaActivación idealUso en PyTorch
Zeros W = 0 ❌ Ninguna nn.init.zeros_
Random N(0,1) W ~ N(0,1) ❌ Ninguna nn.init.normal_
Xavier/Glorot √(6/(fan_in+fan_out)) tanh, sigmoid nn.init.xavier_uniform_
Kaiming/He √(2/fan_in) ReLU, LeakyReLU nn.init.kaiming_normal_

BatchNorm renormaliza las activaciones después de cada capa, lo que mitiga parcialmente los problemas de inicialización. Sin embargo:

  • La primera capa no tiene BN antes, así que la init importa.
  • Mejor init → convergencia más rápida, incluso con BN.
  • Sin BN (por ejemplo, en Transformers que usan LayerNorm), la init es crítica.
  • En la práctica, siempre usa Kaiming para redes con ReLU. Es gratis y no tiene downside.

PyTorch usa Kaiming por defecto para nn.Linear y nn.Conv2d, pero la implementación exacta puede variar entre versiones. Ser explícito no hace daño.

⚠️ Error clásico: Inicializar todos los pesos a cero. Si todos los pesos son iguales, todos los gradientes son iguales, y todas las neuronas aprenden lo mismo. Se rompe la simetría necesaria para que cada neurona se especialice.
3

Elegir el optimizador adecuado

El optimizador define cómo se actualizan los pesos en cada paso. La elección del optimizador y sus hiperparámetros tiene un impacto enorme en la velocidad de convergencia y en el mínimo al que se llega.

3.1 SGD clásico

Python SGD puro
# SGD puro: w = w - lr * grad
optimizer_sgd = optim.SGD(model.parameters(), lr=0.01)

# Con momentum: acumula velocidad como una "bola rodando cuesta abajo"
# v = momentum * v_prev + grad
# w = w - lr * v
optimizer_sgdm = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

# Con Nesterov: mira "adelante" antes de calcular el gradiente
optimizer_nesterov = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, nesterov=True)
L5momentum=0.9 es el valor estándar. Suaviza oscilaciones y acelera el descenso. Rara vez se cambia.
L10Nesterov momentum converge ligeramente más rápido en la práctica. Recomendado cuando usas SGD.

3.2 Adam

Python Adam
# Adam: adapta el learning rate por parámetro usando momentos de 1er y 2do orden
optimizer_adam = optim.Adam(
    model.parameters(),
    lr=1e-3,           # LR base — mucho más bajo que SGD
    betas=(0.9, 0.999), # β1: momentum del gradiente, β2: momentum del gradiente²
    eps=1e-8,           # estabilidad numérica
)

# Valores razonables:
# lr: 1e-3 (default y funciona bien para empezar)
# betas: (0.9, 0.999) — rara vez se cambian
# eps: 1e-8 — puede subirse a 1e-6 si hay inestabilidad numérica

3.3 AdamW (Adam con weight decay desacoplado)

Python AdamW — el que usaremos
# AdamW: implementa weight decay correctamente (Loshchilov & Hutter, 2019)
# En Adam estándar, weight_decay interactúa con los momentos adaptativos → mal
# AdamW lo desacopla: primero adapta el lr, luego aplica decay por separado
optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-2,   # regularización L2 desacoplada (veremos en paso 6)
)
print(f"Optimizer: {optimizer.__class__.__name__}")
print(f"  lr={optimizer.defaults['lr']}, wd={optimizer.defaults['weight_decay']}")
💡 ¿Por qué AdamW y no Adam? En Adam clásico, el weight decay se aplica dentro de la actualización adaptativa, lo que distorsiona su efecto. AdamW lo aplica después, como si fuera un paso separado. Resultado: mejor generalización con el mismo compute.

Paper: Loshchilov & Hutter (2019) — "Decoupled Weight Decay Regularization"

3.4 Grupos de parámetros

A veces queremos distintos LR o weight decay para distintas partes del modelo. Por ejemplo, no aplicar weight decay a los biases ni a los parámetros de BatchNorm:

Python parameter groups diferenciados
def get_param_groups(model, lr=1e-3, weight_decay=1e-2):
    """Separa parámetros que deben tener weight decay de los que no."""
    decay_params = []
    no_decay_params = []
    for name, param in model.named_parameters():
        if not param.requires_grad:
            continue
        # No aplicar weight decay a bias ni a parámetros de normalización
        if param.dim() <= 1 or 'bias' in name:
            no_decay_params.append(param)
        else:
            decay_params.append(param)
    return [
        {"params": decay_params, "weight_decay": weight_decay},
        {"params": no_decay_params, "weight_decay": 0.0},
    ]

param_groups = get_param_groups(model)
optimizer = optim.AdamW(param_groups, lr=1e-3)

n_decay = sum(p.numel() for p in param_groups[0]["params"])
n_nodecay = sum(p.numel() for p in param_groups[1]["params"])
print(f"Con weight decay: {n_decay:,} params | Sin weight decay: {n_nodecay:,} params")
L9-10Los bias (1D) y parámetros de BatchNorm/LayerNorm no se regularizan. Sólo los pesos de convolución y linear.

Comparativa de optimizadores

OptimizadorLR típicoHiperparámetrosMejor para
SGD + momentum 0.01 – 0.1 momentum=0.9 CVs (ResNet, etc.) con scheduler bien afinado
Adam 1e-3 – 3e-4 β1=0.9, β2=0.999 Prototipado rápido, GANs
AdamW 1e-3 – 3e-4 β1=0.9, β2=0.999, wd=0.01 Uso general, Transformers, ViTs
SGD + Nesterov 0.01 – 0.1 momentum=0.9, nesterov=True Si quieres exprimir el último% de accuracy

Históricamente, SGD + momentum con un scheduler bien diseñado encontraba minimos que generalizaban mejor. Pero esto requería afinar cuidadosamente el LR y el schedule.

Con AdamW y schedulers modernos (OneCycleLR, CosineAnnealing), la brecha se ha cerrado. En la práctica:

  • Para CNNs clásicas: SGD + momentum + CosineAnnealing sigue siendo competitivo.
  • Para Transformers: AdamW + warmup + cosine decay es el estándar.
  • Para prototipar: Adam/AdamW con lr=1e-3 y sin scheduler es lo más rápido.
  • Recomendación: Empieza con AdamW(lr=1e-3, wd=0.01). Si necesitas más accuracy, prueba SGD(lr=0.1, momentum=0.9) + CosineAnnealing.
⚠️ Impacto del LR en la estabilidad: El learning rate es el hiperparámetro más importante. Demasiado alto → la loss oscila o explota. Demasiado bajo → el entrenamiento es lentísimo y puede quedarse en un mínimo malo. Un buen punto de partida: 1e-3 para Adam/AdamW, 0.1 para SGD.
4

Learning rate schedulers

El learning rate no tiene por qué ser constante. De hecho, variarlo durante el entrenamiento es una de las técnicas más efectivas: empezar alto para explorar y reducirlo progresivamente para afinar. Veamos las estrategias más usadas.

4.1 StepLR

Python StepLR
from torch.optim.lr_scheduler import StepLR

# Reduce el LR por un factor gamma cada step_size épocas
scheduler_step = StepLR(optimizer, step_size=30, gamma=0.1)
# Epoch  0-29: lr = 1e-3
# Epoch 30-59: lr = 1e-4
# Epoch 60-89: lr = 1e-5

# En el training loop:
# for epoch in range(num_epochs):
#     train_one_epoch(...)
#     scheduler_step.step()  ← ¡al final de cada época!

4.2 CosineAnnealingLR

Python CosineAnnealing
from torch.optim.lr_scheduler import CosineAnnealingLR

# Decae el LR siguiendo medio coseno: de lr_max a eta_min
scheduler_cosine = CosineAnnealingLR(
    optimizer,
    T_max=100,       # número total de épocas
    eta_min=1e-6,    # LR mínimo al final
)
# Suave y sin escalones bruscos → muy popular en visión por computador

4.3 OneCycleLR

Python OneCycleLR — el schedule más agresivo
from torch.optim.lr_scheduler import OneCycleLR

# Super-convergence: sube el LR rápidamente y luego baja
# (Smith & Topin, 2018: "Super-Convergence")
scheduler_onecycle = OneCycleLR(
    optimizer,
    max_lr=1e-2,                # pico máximo del LR
    epochs=100,
    steps_per_epoch=len(train_loader),  # ← se actualiza POR BATCH, no por época
    pct_start=0.3,              # 30% del training para subir, 70% para bajar
    anneal_strategy='cos',
    div_factor=10,              # lr_inicial = max_lr / div_factor
    final_div_factor=100,       # lr_final = lr_inicial / final_div_factor
)

# ¡IMPORTANTE! OneCycleLR se llama por batch, NO por época:
# for epoch in range(num_epochs):
#     for batch in train_loader:
#         ...
#         optimizer.step()
#         scheduler_onecycle.step()  ← después de cada batch
L7max_lr es el pico. Con AdamW, prueba 3e-3 a 1e-2. Con SGD, hasta 0.3.
L10pct_start=0.3: el warmup ocupa el 30% del entrenamiento. Es bastante agresivo.

4.4 Warmup + Cosine (estilo Transformer)

Python warmup manual + cosine
import math

class WarmupCosineScheduler:
    """LR warmup lineal + cosine decay. Estilo Transformers."""
    def __init__(self, optimizer, warmup_epochs, total_epochs, min_lr=1e-6):
        self.optimizer = optimizer
        self.warmup = warmup_epochs
        self.total = total_epochs
        self.min_lr = min_lr
        self.base_lrs = [g['lr'] for g in optimizer.param_groups]
        self.step_count = 0

    def step(self):
        self.step_count += 1
        if self.step_count <= self.warmup:
            # Warmup lineal
            scale = self.step_count / self.warmup
        else:
            # Cosine decay
            progress = (self.step_count - self.warmup) / (self.total - self.warmup)
            scale = 0.5 * (1 + math.cos(math.PI * progress))
        for g, base_lr in zip(self.optimizer.param_groups, self.base_lrs):
            g['lr'] = self.min_lr + (base_lr - self.min_lr) * scale

    def get_lr(self):
        return [g['lr'] for g in self.optimizer.param_groups]

# Uso
scheduler = WarmupCosineScheduler(optimizer, warmup_epochs=5, total_epochs=100)
💡 ¿Por qué warmup? En las primeras iteraciones, los gradientes son ruidosos porque el modelo está lejos de cualquier mínimo y las estadísticas de BatchNorm no se han estabilizado. Un LR alto al inicio puede causar pérdida de la representación. El warmup empieza con un LR bajo y lo sube gradualmente, dando tiempo a que todo se estabilice.

🧪 Visualiza los schedulers

LR Máximo
LR Mínimo
LR Final

Comparativa de schedulers

SchedulerFormaActualizaciónIdeal para
StepLR Escalones Por época Entrenamiento largo con hitos claros
CosineAnnealing Medio coseno Por época CNNs (ResNet, etc.), uso general
OneCycleLR Subida + bajada Por batch Entrenamientos cortos, super-convergence
Warmup + Cosine Rampa + coseno Por época Transformers, entrenamientos largos
  • AdamW: Empieza con lr=1e-3. Si la loss oscila, baja a 3e-4. Para fine-tuning de modelos pre-entrenados, usa 1e-5 a 5e-5.
  • SGD: Empieza con lr=0.1. Con batch size 128, esto suele funcionar. Para batch sizes mayores, escala linealmente: lr = 0.1 × (batch_size / 128).
  • Regla del batch size: Si multiplicas el batch size por k, multiplica el LR por k (linear scaling rule). Con warmup de 5 épocas.
  • OneCycleLR: max_lr ≈ 10× lo que usarías como LR constante. El scheduler se encarga de modularlo.
⚠️ Error frecuente: Olvidar llamar a scheduler.step(). Sin esta llamada, el scheduler no hace nada y el LR es constante. Otro error: llamar a scheduler.step() antes de optimizer.step() — PyTorch mostrará un warning.
5

Estabilización: gradient clipping y normalización

Incluso con buena inicialización y un LR razonable, los gradientes pueden volverse inestables durante el entrenamiento, especialmente en redes profundas o con secuencias largas (RNNs). Las técnicas de estabilización mantienen los gradientes y activaciones en un rango saludable.

5.1 Gradient clipping

Gradient clipping limita la magnitud del gradiente antes de actualizar los pesos. Si la norma del gradiente supera un umbral, se reescala proporcionalmente. Previene la explosión de gradientes.

Python clip_grad_norm_
# Después de loss.backward(), antes de optimizer.step():
max_norm = 1.0  # umbral de la norma L2

# Calcula la norma total del gradiente y reescala si supera max_norm
total_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_norm)
print(f"Grad norm: {total_norm:.4f} (clipped to {max_norm})")

# Alternativa: clip por valor (menos común)
# nn.utils.clip_grad_value_(model.parameters(), clip_value=0.5)
L5clip_grad_norm_ retorna la norma original. Útil para monitorizar: si la norma clipeada es frecuente, quizás tu LR es muy alto.
L1max_norm=1.0 es un buen valor por defecto. Para Transformers se suele usar 1.0. Para RNNs, 5.0.
💡 Cuándo usar gradient clipping:
  • ✅ Siempre con RNNs/LSTMs/GRUs (explosión de gradientes es común).
  • ✅ En Transformers (estándar en GPT, BERT, etc.).
  • ✅ Si observas spikes en la loss durante el entrenamiento.
  • ⚠️ En CNNs es menos crítico, pero no hace daño. Lo incluiremos por seguridad.

5.2 Batch Normalization

Ioffe & Szegedy (2015) introdujeron Batch Normalization: normalizar las activaciones de cada capa a media 0 y varianza 1 (usando estadísticas del mini-batch), y luego aplicar una transformación afín aprendible (γ, β).

Conv/Linear output BatchNorm x̂ = (x − μ_B) / σ_B y = γ·x̂ + β ReLU Siguiente capa
Python CNN con BatchNorm
class CNN_BN(nn.Module):
    """CNN con Batch Normalization entre Conv y ReLU."""
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, padding=1),
            nn.BatchNorm2d(32),         # ← BN para mapas 2D
            nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2),

            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.MaxPool2d(2),

            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 256),
            nn.BatchNorm1d(256),        # ← BN para vectores 1D
            nn.ReLU(),
            nn.Linear(256, num_classes),
        )

    def forward(self, x):
        return self.classifier(self.features(x))
💡 Beneficios de BatchNorm:
  • Estabilidad: Previene que las activaciones se desplacen (internal covariate shift).
  • LR más alto: Con BN puedes usar LRs 5–10× más altos sin diverger.
  • Regularización leve: El ruido del mini-batch actúa como regularizador.
  • Convergencia más rápida: Típicamente 2–3× menos épocas.

5.3 Alternativas a BatchNorm

Python LayerNorm, GroupNorm, InstanceNorm
# LayerNorm: normaliza sobre features, no sobre el batch
# → Estándar en Transformers (independiente del batch size)
ln = nn.LayerNorm(normalized_shape=256)

# GroupNorm: divide canales en G grupos, normaliza cada grupo
# → Útil cuando batch size es muy pequeño (batch=1–4)
gn = nn.GroupNorm(num_groups=8, num_channels=64)

# InstanceNorm: normaliza cada mapa de features individualmente
# → Estándar en style transfer
inst = nn.InstanceNorm2d(64)
NormalizaciónNormaliza sobreDepende de batch?Ideal para
BatchNorm Batch × Spatial ✅ Sí CNNs con batch ≥ 16
LayerNorm Features ❌ No Transformers, RNNs
GroupNorm Channels (en grupos) ❌ No CNNs con batch pequeño
InstanceNorm Each sample × channel ❌ No Style transfer

BatchNorm tiene dos comportamientos distintos:

  • Training (model.train()): Usa media/varianza del mini-batch actual. Actualiza running_mean y running_var con momentum.
  • Eval (model.eval()): Usa running_mean y running_var acumulados durante training.

¡Error clásico! Olvidar model.eval() antes de evaluar o hacer inferencia. Resultado: predicciones inconsistentes que dependen del batch. Siempre:

model.eval()
with torch.no_grad():
    preds = model(test_batch)
model.train()  # volver a training mode

5.4 Monitorizar la norma del gradiente

Python función para monitorizar gradientes
def get_grad_norm(model):
    """Calcula la norma L2 total del gradiente."""
    total_norm = 0.0
    for p in model.parameters():
        if p.grad is not None:
            total_norm += p.grad.data.norm(2).item() ** 2
    return total_norm ** 0.5

# Usar en el training loop:
# loss.backward()
# grad_norm = get_grad_norm(model)
# if grad_norm > 100:
#     print(f"⚠️ Grad norm alta: {grad_norm:.1f}")
# nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# optimizer.step()
🔑 Regla práctica: Si la norma del gradiente es consistentemente > 10, considera bajar el LR. Si hay spikes puntuales a > 100, gradient clipping los controla. Si la norma es < 1e-7, los gradientes se están desvaneciendo — revisa la inicialización y las activaciones.
6

Regularización: weight decay, dropout y más

Las técnicas de regularización controlan la complejidad del modelo para reducir overfitting. Mientras las técnicas anteriores estabilizan el proceso de optimización, la regularización mejora la generalización: el rendimiento en datos no vistos.

6.1 Weight decay (L2 regularization)

Weight decay penaliza los pesos grandes añadiendo un término λ · ||w||² a la función de pérdida. En AdamW (Loshchilov & Hutter, 2019) este decaimiento está desacoplado del gradiente, lo cual es más efectivo:

Python Weight decay con AdamW
# Weight decay típico: 1e-2 para grandes modelos, 1e-4 para CNNs
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-4   # ← Regularización L2 desacoplada
)

# NOTA: En SGD, weight_decay=1e-4 es equivalente a L2 clásico.
# En AdamW, está desacoplado del cálculo de momentos (mejor).
L5weight_decay=1e-4 es un buen punto de partida para CIFAR-10. Redes más grandes (ResNet-50+) suelen usar 1e-2. Modelos de NLP suelen usar 0.01.
⚠️ No apliques weight decay a bias ni a parámetros de normalización. Estos parámetros no se benefician del decaimiento. Usa parameter groups como vimos en el Paso 3 (función get_param_groups).

6.2 Dropout

Srivastava et al. (2014) propusieron Dropout: durante training, desactivar aleatoriamente una fracción p de las neuronas. Esto fuerza redundancia en las representaciones y reduce co-adaptación.

Python Dropout en el clasificador
class CNN_Regularized(nn.Module):
    """CNN con BatchNorm + Dropout."""
    def __init__(self, num_classes=10, dropout_rate=0.3):
        super().__init__()
        self.features = nn.Sequential(
            # Bloque 1
            nn.Conv2d(3, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Dropout2d(0.1),          # ← Dropout espacial (ligero en features)

            # Bloque 2
            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Dropout2d(0.15),

            # Bloque 3
            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.MaxPool2d(2),
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(),
            nn.Dropout(dropout_rate),   # ← Dropout estándar en FC
            nn.Linear(256, num_classes),
        )

    def forward(self, x):
        return self.classifier(self.features(x))

model = CNN_Regularized(num_classes=10, dropout_rate=0.3)
print(f"Parámetros: {sum(p.numel() for p in model.parameters()):,}")
L14Dropout2d desactiva canales enteros (mejor para conv). Tasa baja (0.1–0.15) en features.
L35Dropout estándar con p=0.3 en la capa fully-connected. Rango habitual: 0.2–0.5.

Hay debate sobre si Dropout y BatchNorm deberían usarse juntos. Li et al. (2019) mostraron que Dropout puede causar un "variance shift" que afecta a BN durante eval.

Recomendación práctica:

  • Usar Dropout después de BatchNorm (no antes).
  • En features (conv), usar tasas bajas: 0.05–0.15.
  • Concentrar el Dropout fuerte en las capas FC del clasificador.
  • Si el modelo es grande (ResNet-50+), considerar usar solo weight decay sin Dropout.

6.3 Label smoothing

Label smoothing (Szegedy et al., 2016) reemplaza los hard labels (0 o 1) con soft labels que distribuyen una pequeña probabilidad ε entre todas las clases. Previene que el modelo sea excesivamente confiado (over-confident).

Python Label smoothing en PyTorch
# PyTorch ≥ 1.10 soporta label_smoothing directamente
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

# Equivale a:
# y_smooth = (1 - ε) * one_hot(y) + ε / num_classes
# loss = CrossEntropy(logits, y_smooth)

# Ejemplo: para clase 3 con 10 clases y ε=0.1:
# [0.01, 0.01, 0.01, 0.91, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
L2label_smoothing=0.1 es el valor estándar. En datasets muy ruidosos se puede subir a 0.2. Más de 0.3 suele degradar.

6.4 Early stopping

Detener el entrenamiento cuando la métrica en validación deja de mejorar es la forma más directa de evitar overfitting. Implementamos un sencillo tracker:

Python EarlyStopping
class EarlyStopping:
    """Detiene el entrenamiento si no mejora tras `patience` épocas."""
    def __init__(self, patience=5, min_delta=1e-4):
        self.patience = patience
        self.min_delta = min_delta
        self.best_score = None
        self.counter = 0
        self.should_stop = False

    def step(self, val_metric):
        """Llamar tras cada época con la métrica de validación (mayor = mejor)."""
        if self.best_score is None:
            self.best_score = val_metric
        elif val_metric < self.best_score + self.min_delta:
            self.counter += 1
            if self.counter >= self.patience:
                self.should_stop = True
                print(f"⏹ Early stopping: sin mejora en {self.patience} épocas.")
        else:
            self.best_score = val_metric
            self.counter = 0

# Uso:
# early_stop = EarlyStopping(patience=7)
# for epoch in range(max_epochs):
#     train(...)
#     val_acc = evaluate(...)
#     early_stop.step(val_acc)
#     if early_stop.should_stop:
#         break
TécnicaQué controlaValor típicoCuándo usar
Weight decay Magnitud de pesos 1e-4 – 1e-2 Siempre (incluir en AdamW)
Dropout Co-adaptación de neuronas 0.1–0.5 Capas FC, modelos con pocas muestras
Label smoothing Sobre-confianza 0.1 Clasificación con muchas clases
Early stopping Nº de épocas patience 5–10 Siempre (como red de seguridad)
🔑 Combinación recomendada: Weight decay (siempre) + Dropout (en FC) + label smoothing (opcional) + early stopping (siempre). No sobrecargar — empezar con weight decay + early stopping y añadir más solo si hay overfitting persistente.
7

Data augmentation: más datos sin más datos

Data augmentation genera variaciones artificiales de las imágenes de entrenamiento aplicando transformaciones aleatorias. Es la forma de regularización más efectiva en visión por computador — equivale a multiplicar el tamaño del dataset sin recopilar nuevos datos.

7.1 Transformaciones básicas con torchvision

Python Pipeline de augmentation para CIFAR-10
from torchvision import transforms

# ── Pipeline de entrenamiento CON augmentation ──────────────
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),          # Recorte aleatorio con padding
    transforms.RandomHorizontalFlip(p=0.5),        # Flip horizontal 50%
    transforms.ColorJitter(
        brightness=0.2, contrast=0.2,
        saturation=0.2, hue=0.1
    ),                                             # Variación de color
    transforms.RandomRotation(15),                 # Rotación ±15°
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2470, 0.2435, 0.2616]
    ),
    transforms.RandomErasing(p=0.25, scale=(0.02, 0.2)),  # "Cutout" aleatorio
])

# ── Pipeline de validación/test SIN augmentation ────────────
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2470, 0.2435, 0.2616]
    ),
])

# Aplicar a los datasets
train_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=True, transform=transform_train, download=True
)
test_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=False, transform=transform_test, download=True
)
L5RandomCrop(32, padding=4): Añade 4px de padding (relleno con ceros) y recorta una ventana 32×32 aleatoria. Muy efectivo para CIFAR.
L6RandomHorizontalFlip: Funciona bien para objetos con simetría horizontal. No usar para texto o dígitos.
L18RandomErasing: Variante de Cutout. Oculta un parche rectangular aleatorio, forzando al modelo a usar múltiples regiones para clasificar.
⚠️ Nunca apliques augmentation al set de test/validación. El set de evaluación debe reflejar las condiciones reales de inferencia. Solo ToTensor() + Normalize().

7.2 Augmentaciones avanzadas: Mixup y CutMix

Las técnicas que mezclan muestras del batch generan ejemplos "intermedios" que mejoran significativamente la generalización, especialmente con datos limitados.

Python Mixup
import numpy as np

def mixup_batch(images, targets, alpha=0.2):
    """
    Mixup (Zhang et al., 2018): mezcla pares de imágenes y sus labels.
    images: (B, C, H, W), targets: (B,) con índices de clase
    Retorna: images mezcladas, targets_a, targets_b, lambda
    """
    lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0
    batch_size = images.size(0)
    index = torch.randperm(batch_size, device=images.device)

    mixed_images = lam * images + (1 - lam) * images[index]
    return mixed_images, targets, targets[index], lam

def mixup_criterion(criterion, pred, y_a, y_b, lam):
    """Loss ponderada entre las dos clases mezcladas."""
    return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b)

# En el training loop:
# images, targets = batch
# images, targets_a, targets_b, lam = mixup_batch(images, targets, alpha=0.2)
# outputs = model(images)
# loss = mixup_criterion(criterion, outputs, targets_a, targets_b, lam)
Python CutMix
def cutmix_batch(images, targets, alpha=1.0):
    """
    CutMix (Yun et al., 2019): pega un parche de otra imagen en cada imagen.
    Más efectivo que Mixup para localización y detección.
    """
    lam = np.random.beta(alpha, alpha)
    batch_size = images.size(0)
    index = torch.randperm(batch_size, device=images.device)

    _, _, H, W = images.shape

    # Calcular el tamaño del parche a recortar
    cut_ratio = np.sqrt(1.0 - lam)
    cut_h = int(H * cut_ratio)
    cut_w = int(W * cut_ratio)

    # Centro aleatorio del parche
    cy = np.random.randint(H)
    cx = np.random.randint(W)

    # Coordenadas del bounding box
    y1 = np.clip(cy - cut_h // 2, 0, H)
    y2 = np.clip(cy + cut_h // 2, 0, H)
    x1 = np.clip(cx - cut_w // 2, 0, W)
    x2 = np.clip(cx + cut_w // 2, 0, W)

    # Pegar el parche de la imagen permutada
    mixed = images.clone()
    mixed[:, :, y1:y2, x1:x2] = images[index, :, y1:y2, x1:x2]

    # Ajustar lambda al área real del parche
    lam = 1 - ((y2 - y1) * (x2 - x1)) / (H * W)
    return mixed, targets, targets[index], lam

7.3 AutoAugment y TrivialAugment

Python Políticas automáticas
# AutoAugment: política aprendida por RL (Cubuk et al., 2019)
transform_auto = transforms.Compose([
    transforms.AutoAugment(transforms.AutoAugmentPolicy.CIFAR10),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2470, 0.2435, 0.2616]),
])

# TrivialAugment: más simple, sin búsqueda, ¡y funciona igual o mejor!
# (Müller & Hutter, 2021)
transform_trivial = transforms.Compose([
    transforms.TrivialAugmentWide(),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2470, 0.2435, 0.2616]),
])
L2AutoAugment usa una política optimizada para CIFAR-10 (hay también para ImageNet y SVHN).
L10TrivialAugmentWide aplica una sola transformación aleatoria a cada imagen. Sorprendentemente efectivo y sin hiperparámetros que ajustar.
AugmentationComplejidadHiperparámetrosMejora esperada (CIFAR-10)
RandomCrop + Flip Baja padding, p +2–4% acc
+ ColorJitter + Erasing Media Intensidades +1–2% adicional
Mixup Media alpha +0.5–1.5%
CutMix Media alpha +1–2%
TrivialAugment Baja Ninguno +2–3%
🔑 Receta para CIFAR-10: RandomCrop(32, padding=4) + RandomHorizontalFlip + RandomErasing es un excelente punto de partida. Si quieres exprimir más rendimiento, añade CutMix o usa TrivialAugment.
8

Poniéndolo todo junto: script de entrenamiento completo

Es hora de combinar todas las técnicas de los pasos anteriores en un script de entrenamiento cohesivo. Entrenaremos nuestro modelo mejorado en CIFAR-10 y compararemos con un baseline sin técnicas.

8.1 Dataset con augmentation

Python Preparación de datos
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, random_split
import torchvision
from torchvision import transforms

SEED = 42
torch.manual_seed(SEED)

# ── Transformaciones ─────────────────────────────────────────
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2470, 0.2435, 0.2616]),
    transforms.RandomErasing(p=0.25),
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2470, 0.2435, 0.2616]),
])

# ── Datasets y DataLoaders ───────────────────────────────────
full_train = torchvision.datasets.CIFAR10('./data', train=True, transform=transform_train, download=True)
train_set, val_set = random_split(full_train, [45000, 5000],
                                   generator=torch.Generator().manual_seed(SEED))
# Validación con transforms limpios
val_set.dataset = torchvision.datasets.CIFAR10('./data', train=True, transform=transform_test)
test_set = torchvision.datasets.CIFAR10('./data', train=False, transform=transform_test)

train_loader = DataLoader(train_set, batch_size=128, shuffle=True,  num_workers=2, pin_memory=True)
val_loader   = DataLoader(val_set,   batch_size=256, shuffle=False, num_workers=2, pin_memory=True)
test_loader  = DataLoader(test_set,  batch_size=256, shuffle=False, num_workers=2, pin_memory=True)

print(f"Train: {len(train_set)} | Val: {len(val_set)} | Test: {len(test_set)}")
Salida
Train: 45000 | Val: 5000 | Test: 10000

8.2 Modelo mejorado

Python CNN con todas las técnicas
class EfficientCNN(nn.Module):
    """
    CNN mejorada para CIFAR-10 con:
    ✅ Kaiming initialization
    ✅ BatchNorm en todas las capas
    ✅ Dropout espacial (features) + estándar (classifier)
    """
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            # Bloque 1: 32×32 → 16×16
            nn.Conv2d(3, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Dropout2d(0.1),

            # Bloque 2: 16×16 → 8×8
            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Dropout2d(0.15),

            # Bloque 3: 8×8 → 4×4
            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(inplace=True),
            nn.Dropout(0.3),
            nn.Linear(256, num_classes),
        )

        # ✅ Kaiming initialization
        self._init_weights()

    def _init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
                if m.bias is not None:
                    nn.init.zeros_(m.bias)
            elif isinstance(m, (nn.BatchNorm2d, nn.BatchNorm1d)):
                nn.init.ones_(m.weight)
                nn.init.zeros_(m.bias)
            elif isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
                nn.init.zeros_(m.bias)

    def forward(self, x):
        return self.classifier(self.features(x))

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = EfficientCNN(num_classes=10).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"EfficientCNN: {n_params:,} parámetros | device: {device}")
Salida
EfficientCNN: 347,978 parámetros | device: cuda

8.3 Optimizer, scheduler y loss

Python Configuración del optimizador
# ── Parameter groups (no weight decay en bias/BN) ──────────
def get_param_groups(model, base_wd=1e-4):
    decay, no_decay = [], []
    for name, param in model.named_parameters():
        if not param.requires_grad:
            continue
        if param.ndim <= 1 or 'bias' in name:  # bias + BN params
            no_decay.append(param)
        else:
            decay.append(param)
    return [
        {'params': decay, 'weight_decay': base_wd},
        {'params': no_decay, 'weight_decay': 0.0},
    ]

NUM_EPOCHS = 50

# ✅ AdamW con weight decay desacoplado
optimizer = torch.optim.AdamW(get_param_groups(model), lr=1e-3)

# ✅ OneCycleLR: warm-up + cosine decay automático
scheduler = torch.optim.lr_scheduler.OneCycleLR(
    optimizer,
    max_lr=1e-3,
    epochs=NUM_EPOCHS,
    steps_per_epoch=len(train_loader),
    pct_start=0.1,      # 10% warm-up
    anneal_strategy='cos',
)

# ✅ Label smoothing
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

# ✅ Early stopping
class EarlyStopping:
    def __init__(self, patience=7, min_delta=1e-4):
        self.patience, self.min_delta = patience, min_delta
        self.best_score, self.counter, self.should_stop = None, 0, False
    def step(self, val_metric):
        if self.best_score is None or val_metric > self.best_score + self.min_delta:
            self.best_score, self.counter = val_metric, 0
        else:
            self.counter += 1
            self.should_stop = self.counter >= self.patience

early_stop = EarlyStopping(patience=7)
MAX_GRAD_NORM = 1.0  # ✅ Gradient clipping

8.4 Funciones de entrenamiento y evaluación

Python train_one_epoch + evaluate
def train_one_epoch(model, loader, optimizer, scheduler, criterion, device, max_grad_norm):
    model.train()
    total_loss, correct, total = 0.0, 0, 0

    for images, targets in loader:
        images, targets = images.to(device), targets.to(device)

        # Forward
        outputs = model(images)
        loss = criterion(outputs, targets)

        # Backward
        optimizer.zero_grad()
        loss.backward()

        # ✅ Gradient clipping
        nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm)

        optimizer.step()
        scheduler.step()  # OneCycleLR: step por batch

        # Métricas
        total_loss += loss.item() * targets.size(0)
        correct += (outputs.argmax(dim=1) == targets).sum().item()
        total += targets.size(0)

    return total_loss / total, correct / total

@torch.no_grad()
def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss, correct, total = 0.0, 0, 0

    for images, targets in loader:
        images, targets = images.to(device), targets.to(device)
        outputs = model(images)
        loss = criterion(outputs, targets)
        total_loss += loss.item() * targets.size(0)
        correct += (outputs.argmax(dim=1) == targets).sum().item()
        total += targets.size(0)

    return total_loss / total, correct / total

8.5 Bucle de entrenamiento

Python Training loop completo
import time

history = {'train_loss': [], 'train_acc': [], 'val_loss': [], 'val_acc': [], 'lr': []}
best_val_acc = 0.0

print(f"{'Epoch':>5} {'Train Loss':>11} {'Train Acc':>10} {'Val Loss':>10} {'Val Acc':>9} {'LR':>10}")
print("─" * 62)

for epoch in range(1, NUM_EPOCHS + 1):
    t0 = time.time()

    train_loss, train_acc = train_one_epoch(
        model, train_loader, optimizer, scheduler, criterion, device, MAX_GRAD_NORM
    )
    val_loss, val_acc = evaluate(model, val_loader, criterion, device)

    current_lr = optimizer.param_groups[0]['lr']

    # Guardar historial
    history['train_loss'].append(train_loss)
    history['train_acc'].append(train_acc)
    history['val_loss'].append(val_loss)
    history['val_acc'].append(val_acc)
    history['lr'].append(current_lr)

    # Guardar mejor modelo
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        torch.save(model.state_dict(), 'best_model.pth')

    elapsed = time.time() - t0
    print(f"{epoch:5d} {train_loss:11.4f} {train_acc:9.2%} {val_loss:10.4f} {val_acc:8.2%} {current_lr:10.6f}")

    # ✅ Early stopping
    early_stop.step(val_acc)
    if early_stop.should_stop:
        print(f"\n⏹ Early stopping en época {epoch}")
        break

print(f"\n🏆 Mejor val accuracy: {best_val_acc:.2%}")
Salida (ejemplo)
Epoch  Train Loss  Train Acc   Val Loss   Val Acc         LR
──────────────────────────────────────────────────────────────
    1      1.6342    41.53%     1.3021   52.40%   0.000998
    2      1.2854    53.28%     1.0712   61.92%   0.000979
    5      0.9103    67.89%     0.7644   73.82%   0.000891
   10      0.6211    78.24%     0.5513   81.60%   0.000690
   20      0.3987    86.33%     0.4012   87.14%   0.000339
   30      0.2698    90.82%     0.3364   89.76%   0.000104
   40      0.2019    93.11%     0.3189   90.58%   0.000014
   50      0.1812    93.87%     0.3121   91.02%   0.000001

🏆 Mejor val accuracy: 91.02%

8.6 Evaluación en test

Python Evaluación final
# Cargar el mejor modelo
model.load_state_dict(torch.load('best_model.pth', weights_only=True))
test_loss, test_acc = evaluate(model, test_loader, criterion, device)

print(f"📊 Test accuracy: {test_acc:.2%}")
print(f"📊 Test loss:     {test_loss:.4f}")
Salida
📊 Test accuracy: 90.87%
📊 Test loss:     0.3198

8.7 Comparación: baseline vs. modelo mejorado

Baseline (SimpleCNN)EfficientCNN (+ técnicas)
Inicialización Default (PyTorch) Kaiming He
Normalización Ninguna BatchNorm en todas las capas
Optimizer Adam, lr=1e-3 AdamW, lr=1e-3, wd=1e-4
Scheduler Ninguno OneCycleLR (warm-up + cosine)
Grad clipping No clip_grad_norm_(max=1.0)
Dropout No Dropout2d(0.1–0.15) + Dropout(0.3)
Label smoothing No ε = 0.1
Data augmentation Solo Normalize RandomCrop + Flip + ColorJitter + Erasing
Early stopping No patience = 7
Test accuracy ~78–82% ~90–91%
🎯 +10% de accuracy sin cambiar la arquitectura base (3 bloques conv + 1 FC). Las técnicas de entrenamiento efectivo marcan una diferencia enorme: la diferencia entre un modelo mediocre y uno competitivo no está solo en la arquitectura, sino en cómo lo entrenas.
9

Monitorización, debugging y referencias

Saber entrenar bien un modelo también implica saber cuándo algo va mal y cómo diagnosticarlo. En este último paso revisamos herramientas de monitorización, una checklist de debugging, y recopilamos las referencias clave.

9.1 Visualizar las curvas de entrenamiento

Python Plotear historial de entrenamiento
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# Loss
axes[0].plot(history['train_loss'], label='Train')
axes[0].plot(history['val_loss'], label='Val')
axes[0].set_xlabel('Época'); axes[0].set_ylabel('Loss')
axes[0].set_title('Loss'); axes[0].legend()

# Accuracy
axes[1].plot(history['train_acc'], label='Train')
axes[1].plot(history['val_acc'], label='Val')
axes[1].set_xlabel('Época'); axes[1].set_ylabel('Accuracy')
axes[1].set_title('Accuracy'); axes[1].legend()

# Learning rate
axes[2].plot(history['lr'], color='tab:orange')
axes[2].set_xlabel('Época'); axes[2].set_ylabel('LR')
axes[2].set_title('Learning Rate')

plt.tight_layout()
plt.savefig('training_curves.png', dpi=150)
plt.show()

9.2 Monitorizar gradientes por capa

Python Grad norm por capa
def log_grad_norms(model):
    """Imprime la norma del gradiente de cada capa con parámetros."""
    rows = []
    for name, param in model.named_parameters():
        if param.grad is not None:
            grad_norm = param.grad.data.norm(2).item()
            rows.append((name, grad_norm, param.data.norm(2).item()))

    print(f"{'Capa':<40} {'|grad|':>10} {'|peso|':>10} {'ratio':>10}")
    print("─" * 72)
    for name, gn, wn in rows:
        ratio = gn / (wn + 1e-8)
        flag = " ⚠️" if gn > 10 or gn < 1e-7 else ""
        print(f"{name:<40} {gn:>10.4f} {wn:>10.4f} {ratio:>10.6f}{flag}")

# Usar después de loss.backward():
# log_grad_norms(model)
L9El ratio grad/peso es clave: si es > 1, los gradientes son enormes respecto a los pesos. Si es < 1e-5, hay vanishing gradients.

9.3 Checklist de debugging

🔴 La loss es NaN o Inf

  • LR demasiado alto → bajar 10×
  • Mala inicialización → usar Kaiming
  • Log de un valor ≤ 0 → añadir epsilon
  • Grad clipping no activado → añadir
  • torch.autograd.set_detect_anomaly(True)

🟡 La loss no baja

  • LR demasiado bajo → subir 10×
  • Bug en el data pipeline → inspeccionar un batch
  • Labels incorrectos → verificar distribución
  • Probar sobreajustar en 1 batch primero
  • Verificar que optimizer.zero_grad() se llama

🟢 Overfitting (val ≫ train)

  • Aumentar data augmentation
  • Aumentar weight decay
  • Añadir o subir Dropout
  • Activar label smoothing
  • Reducir capacidad del modelo
  • Más datos si es posible

🔵 Underfitting (ambos malos)

  • Modelo muy pequeño → más capas/canales
  • Entrenar más épocas
  • Reducir regularización
  • Subir LR o probar OneCycleLR
  • Quitar augmentation agresivo

Antes de entrenar en todo el dataset, verifica que tu pipeline funciona haciendo overfit en un solo batch. Si no puedes lograr ~100% de accuracy en un batch de, digamos, 32 imágenes, hay un bug.

# Sanity check: overfit one batch
images, targets = next(iter(train_loader))
images, targets = images[:32].to(device), targets[:32].to(device)

model.train()
opt_test = torch.optim.Adam(model.parameters(), lr=1e-3)
for i in range(200):
    out = model(images)
    loss = nn.CrossEntropyLoss()(out, targets)
    opt_test.zero_grad(); loss.backward(); opt_test.step()
    if i % 50 == 0:
        acc = (out.argmax(1) == targets).float().mean()
        print(f"Step {i}: loss={loss.item():.4f}, acc={acc:.2%}")
# Resultado esperado: acc → 100% rápidamente

9.4 Referencias y recursos

📄 Papers fundamentales

TécnicaPaperAño
Kaiming init He et al. — Delving Deep into Rectifiers 2015
Xavier init Glorot & Bengio — Understanding difficulty of training 2010
Adam Kingma & Ba — Adam: A Method for Stochastic Optimization 2015
AdamW Loshchilov & Hutter — Decoupled Weight Decay Regularization 2019
OneCycleLR Smith & Topin — Super-Convergence 2018
Batch Normalization Ioffe & Szegedy — Batch Normalization: Accelerating Training 2015
Dropout Srivastava et al. — Dropout: A Simple Way to Prevent Overfitting 2014
Label smoothing Szegedy et al. — Rethinking the Inception Architecture 2016
CutMix Yun et al. — CutMix: Regularization Strategy 2019
Mixup Zhang et al. — mixup: Beyond Empirical Risk Minimization 2018
TrivialAugment Müller & Hutter — TrivialAugment 2021

📚 Documentación de PyTorch

🛠️ Herramientas recomendadas

🏁 Resumen final: Un entrenamiento efectivo combina inicialización correcta (Kaiming) + optimizer moderno (AdamW) + scheduler (OneCycleLR) + estabilización (BatchNorm + gradient clipping) + regularización (weight decay + Dropout + augmentation) + monitorización continua. No hay una receta mágica universal, pero estas técnicas forman un toolkit robusto que funciona bien en la gran mayoría de escenarios.