12. Tecnicas de Entrenamiento en Deep Learning


Fase 3
Optimizacion Adam Dropout BatchNorm

Por que el entrenamiento importa tanto como la arquitectura

En Deep Learning existe una trampa comun: creer que con una arquitectura mas grande o mas capas los resultados mejoran solos. La realidad es que una red identica entrenada con tecnicas diferentes puede tener diferencias de 10-20% en accuracy final, o no converger del todo.

Las tecnicas de entrenamiento responden preguntas criticas:

  • Como actualizar los pesos de forma eficiente? → Optimizadores
  • A que velocidad aprender y como ajustarla? → Learning rate scheduling
  • Como evitar que la red memorice el train set? → Dropout y regularizacion
  • Como estabilizar y acelerar el entrenamiento? → Batch Normalization
  • Cuando parar de entrenar? → Early stopping
  • Como comenzar con pesos razonables? → Inicializacion
  • Como evitar gradientes que explotan? → Gradient clipping

Este tema te da las herramientas para diagnosticar problemas de entrenamiento y aplicar soluciones precisas, no al azar.


1. Optimizadores

Descenso de gradiente: la base de todo

Todo optimizador de redes neuronales es una variante del descenso de gradiente. La idea central: si conoces la direccion en que la funcion de perdida crece (el gradiente), muevete en la direccion opuesta.

θt+1 = θt - η θ L (θt)

Donde η es el learning rate y ∇L es el gradiente de la perdida respecto a los parametros.

La diferencia entre variantes es como se calcula ese paso de actualizacion.

Trayectorias de optimizadores en superficie de perdida 2D

SGD puro y sus variantes

SGD (Stochastic Gradient Descent) calcula el gradiente sobre un mini-batch:

import torch
import torch.nn as nn

model = nn.Linear(10, 1)

# SGD puro
optimizer_sgd = torch.optim.SGD(model.parameters(), lr=0.01)

# SGD con momentum (mucho mejor)
optimizer_sgd_m = torch.optim.SGD(
    model.parameters(),
    lr=0.01,
    momentum=0.9,       # acumula gradientes pasados
    weight_decay=1e-4   # L2 regularization
)

Momentum acumula un promedio exponencial de gradientes pasados, lo que suaviza la trayectoria y acelera en direcciones consistentes:

vt = μ vt-1 - η θ L
θt = θt-1 + vt

Con μ=0.9 (tipico), la actualizacion efectiva es ~10x el gradiente actual.

Adam: gradiente adaptativo por parametro

Adam (Adaptive Moment Estimation) mantiene tasas de aprendizaje individuales por parametro, basadas en estimaciones del primer y segundo momento del gradiente:

mt = β1 mt-1 + (1-β1) gt
vt = β2 vt-1 + (1-β2) gt2
θt = θt-1 - η m^t v^t+ε
  • m_t: media movil del gradiente (primer momento, “direccion”)
  • v_t: media movil del gradiente al cuadrado (segundo momento, “magnitud”)
  • β₁=0.9, β₂=0.999: hiperparametros estandar
  • ε=1e-8: evita division por cero
# Adam: default moderno para la mayoria de problemas DL
optimizer_adam = torch.optim.Adam(
    model.parameters(),
    lr=1e-3,
    betas=(0.9, 0.999),
    eps=1e-8
)

# AdamW: Adam con weight decay desacoplado (RECOMENDADO)
# En Adam clasico, weight decay interactua mal con la normalizacion adaptativa
# AdamW los desacopla, lo que mejora regularizacion
optimizer_adamw = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-2   # regularizacion L2 desacoplada
)

Comparativa de optimizadores

OptimizadorVelocidad de convergenciaGeneralizacionCuando usar
SGD puroLentaExcelenteRaramente (solo benchmarks)
SGD + MomentumModeradaMuy buenaCuando quieres maxima generalizacion
AdamRapidaBuenaDefault para experimentar
AdamWRapidaMuy buenaDefault recomendado en produccion

Regla practica: usa AdamW para experimentar rapido. Si necesitas exprimir el ultimo punto de accuracy, prueba SGD + momentum con LR schedule cuidadoso.

Bucle de entrenamiento completo con AdamW

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

# Datos de ejemplo
X = torch.randn(1000, 20)
y = (X[:, 0] + X[:, 1] > 0).float()

dataset = TensorDataset(X, y)
loader  = DataLoader(dataset, batch_size=64, shuffle=True)

# Modelo
model = nn.Sequential(
    nn.Linear(20, 128), nn.ReLU(),
    nn.Linear(128, 64), nn.ReLU(),
    nn.Linear(64, 1),   nn.Sigmoid()
)

criterion = nn.BCELoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)

history = {"train_loss": [], "val_loss": []}

for epoch in range(50):
    model.train()
    batch_losses = []
    for X_b, y_b in loader:
        optimizer.zero_grad()          # 1. Limpiar gradientes anteriores
        pred = model(X_b).squeeze()    # 2. Forward pass
        loss = criterion(pred, y_b)    # 3. Calcular perdida
        loss.backward()                # 4. Backpropagation
        optimizer.step()               # 5. Actualizar pesos
        batch_losses.append(loss.item())

    history["train_loss"].append(sum(batch_losses)/len(batch_losses))

print(f"Perdida final: {history['train_loss'][-1]:.4f}")

2. Learning Rate Scheduling

El learning rate es el hiperparametro mas critico del entrenamiento. Un LR fijo raramente es optimo: demasiado alto causa inestabilidad, demasiado bajo hace lento el aprendizaje.

Los schedulers adaptan el LR durante el entrenamiento.

Comparativa de Learning Rate Schedules

Tipos principales de schedulers

from torch.optim.lr_scheduler import (
    StepLR, ExponentialLR, CosineAnnealingLR,
    OneCycleLR, ReduceLROnPlateau
)

optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

# 1. Step Decay: reduce LR por factor cada N epocas
# Simple, predecible, funciona bien como baseline
scheduler_step = StepLR(optimizer, step_size=20, gamma=0.5)
# LR: 0.1 → 0.05 (ep 20) → 0.025 (ep 40) → ...

# 2. Exponential Decay: reduccion continua
scheduler_exp = ExponentialLR(optimizer, gamma=0.97)
# Cada epoca: LR *= 0.97

# 3. Cosine Annealing: suaviza convergencia final (MUY USADO)
scheduler_cos = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)

# 4. OneCycleLR: warmup + cosine en un ciclo (estado del arte)
scheduler_one = OneCycleLR(
    optimizer,
    max_lr=0.1,
    steps_per_epoch=len(loader),
    epochs=50,
    pct_start=0.3,        # 30% del tiempo en warmup
    anneal_strategy="cos"
)

# 5. ReduceLROnPlateau: reduce LR cuando val_loss se estanca
scheduler_plateau = ReduceLROnPlateau(
    optimizer,
    mode="min",
    factor=0.5,
    patience=5,
    verbose=True
)

# Como integrar en el bucle:
for epoch in range(n_epochs):
    train_one_epoch(model, loader, optimizer)
    val_loss = evaluate(model, val_loader)

    # Para schedulers basados en metrica:
    scheduler_plateau.step(val_loss)

    # Para schedulers basados en epoca:
    # scheduler_cos.step()

Linear Warmup: la clave en transformers

En modelos grandes (transformers, BERT), comenzar con LR alto es inestable. El warmup incrementa el LR linealmente durante las primeras N epocas:

from torch.optim.lr_scheduler import LinearLR, SequentialLR

# Warmup: LR va de 0 → lr_max en 10 epocas
warmup = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=10)

# Cosine despues del warmup
cosine = CosineAnnealingLR(optimizer, T_max=90, eta_min=1e-6)

# Combinar: warmup primero, luego cosine
scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[10])

Regla practica: para CNNs usa cosine o step. Para transformers, siempre warmup + cosine.


3. Regularizacion: Dropout y Weight Decay

Dropout

Dropout “apaga” aleatoriamente neuronas durante el entrenamiento (las pone a 0 con probabilidad p). Esto fuerza a que la red no dependa de neuronas especificas y aprenda representaciones mas robustas.

Dropout: efecto en overfitting

Durante inferencia, dropout se desactiva y todos los pesos se escalan por (1-p) para compensar.

import torch.nn as nn

# Modelo con dropout
class MLPConDropout(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim, dropout_p=0.3):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=dropout_p),       # apaga 30% de neuronas en train

            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=dropout_p),

            nn.Linear(hidden_dim, output_dim)
        )

    def forward(self, x):
        return self.net(x)

model = MLPConDropout(20, 256, 10, dropout_p=0.4)

# IMPORTANTE: activar/desactivar dropout segun modo
model.train()  # dropout activo
model.eval()   # dropout desactivado (siempre para inferencia/evaluacion)

Guia de valores de p:

Contextop recomendado
Capas densas en CNN0.3 – 0.5
Transformers (attention dropout)0.1
Redes pequeñas / datasets pequenos0.1 – 0.2
Dropout espacial (CNNs, DropBlock)0.1 – 0.3

Truco de diagnostico: si accuracy de train baja mucho al agregar dropout pero val no mejora, el dropout es demasiado agresivo.

Weight Decay (L2 Regularization)

Penaliza pesos grandes añadiendo un termino a la perdida:

L = Ltask + λ i θi2
# En AdamW, weight_decay esta desacoplado (correcto)
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-2   # λ = 0.01, tipico
)

# En SGD, se implementa igual pero es equivalente a L2 en este caso
optimizer_sgd = torch.optim.SGD(
    model.parameters(),
    lr=0.01,
    momentum=0.9,
    weight_decay=1e-4
)

4. Batch Normalization

Batch Normalization (BN) normaliza las activaciones de cada capa dentro de un mini-batch, manteniendolas con media ~0 y varianza ~1. Esto resuelve el problema de internal covariate shift: el cambio en la distribucion de activaciones a medida que los pesos se actualizan.

Batch Normalization: distribuciones de activaciones por capa

La matematica de BN

Para un mini-batch de activaciones {x₁, …, xₘ}:

μB = 1m i=1m xi σB2 = 1m i=1m (xi-μB)2
x^i = xi-μB σB2+ε yi = γx^i + β

γ y β son parametros aprendibles que permiten que la red “deshaga” la normalizacion si es necesario.

class MLPConBN(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),  # ANTES de la activacion (debate: antes o despues)
            nn.ReLU(),
            nn.Dropout(p=0.2),

            nn.Linear(hidden_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=0.2),

            nn.Linear(hidden_dim, output_dim)
        )

    def forward(self, x):
        return self.net(x)

# Para CNNs: BatchNorm2d
cnn_block = nn.Sequential(
    nn.Conv2d(64, 128, kernel_size=3, padding=1),
    nn.BatchNorm2d(128),
    nn.ReLU(),
)

Cuando usar BN:

  • CNNs: casi siempre despues de cada convolucion
  • MLPs profundos: si hay mas de 3-4 capas
  • No usar con batch size < 8 (la estimacion de media/varianza es mala)
  • Alternativa para batch sizes pequenos: Layer Normalization (usada en transformers)

5. Early Stopping

Entrenar demasiadas epocas lleva a overfitting. Early stopping monitorea la perdida de validacion y detiene el entrenamiento cuando deja de mejorar.

Early Stopping: detectar punto optimo

class EarlyStopping:
    """
    Para entrenamiento cuando val_loss no mejora en `patience` epocas.
    Guarda el mejor modelo automaticamente.
    """
    def __init__(self, patience=10, min_delta=1e-4, path="best_model.pt"):
        self.patience  = patience
        self.min_delta = min_delta
        self.path      = path
        self.best_loss = float("inf")
        self.counter   = 0
        self.stopped   = False

    def __call__(self, val_loss, model):
        if val_loss < self.best_loss - self.min_delta:
            self.best_loss = val_loss
            self.counter   = 0
            torch.save(model.state_dict(), self.path)  # guarda mejor checkpoint
        else:
            self.counter += 1
            if self.counter >= self.patience:
                self.stopped = True

# Uso en el bucle de entrenamiento
early_stop = EarlyStopping(patience=15, path="mejor_modelo.pt")

for epoch in range(200):
    train_loss = train_epoch(model, train_loader, optimizer)
    val_loss   = evaluate(model, val_loader)

    scheduler.step(val_loss)  # opcional: ReduceLROnPlateau
    early_stop(val_loss, model)

    if early_stop.stopped:
        print(f"Early stop en epoca {epoch+1}. Mejor val_loss: {early_stop.best_loss:.4f}")
        break

# Cargar el mejor checkpoint al final
model.load_state_dict(torch.load("mejor_modelo.pt"))

Regla practica: patience=10-20 epocas suele ser adecuado. Con LR scheduling, patience puede ser mayor porque el LR puede bajar antes de que mejore.


6. Inicializacion de pesos

Si los pesos iniciales son muy grandes, las activaciones saturan desde el inicio. Si son muy pequenos, los gradientes desaparecen. Una buena inicializacion es critica para que la red empiece a aprender desde el principio.

Inicializacion de pesos: distribuciones

Xavier / Glorot: para sigmoid y tanh

Diseñada para mantener la varianza de activaciones y gradientes constante a traves de capas:

W ~ 𝒩 ( 0 , 2 nin+nout )

He / Kaiming: para ReLU

ReLU descarta la mitad de las neuronas (las negativas), por lo que necesita mas varianza inicial:

W ~ 𝒩 ( 0 , 2 nin )
import torch.nn as nn

def init_weights(module):
    if isinstance(module, nn.Linear):
        # He (Kaiming): para activaciones ReLU
        nn.init.kaiming_normal_(module.weight, mode="fan_in", nonlinearity="relu")
        nn.init.zeros_(module.bias)

    elif isinstance(module, nn.Conv2d):
        nn.init.kaiming_normal_(module.weight, mode="fan_out", nonlinearity="relu")
        if module.bias is not None:
            nn.init.zeros_(module.bias)

    elif isinstance(module, nn.BatchNorm1d) or isinstance(module, nn.BatchNorm2d):
        nn.init.ones_(module.weight)   # gamma = 1
        nn.init.zeros_(module.bias)    # beta = 0

# Aplicar inicializacion al modelo
model = MLPConBN(20, 256, 10)
model.apply(init_weights)

# Verificar: las activaciones iniciales deben tener varianza ~1
with torch.no_grad():
    x = torch.randn(64, 20)
    h = model.net[:3](x)   # salida de primera capa + BN + ReLU
    print(f"Media: {h.mean():.3f}, Std: {h.std():.3f}")  # esperar: ~0, ~1

Regla rapida:

ActivacionInicializacion recomendada
ReLU, Leaky ReLUHe (Kaiming) normal
Sigmoid, TanhXavier (Glorot)
Ninguna (salida lineal)Xavier o He segun contexto
TransformersNormal(0, 0.02) tipicamente

7. Gradient Clipping

En RNNs, LSTMs y transformers, los gradientes pueden crecer exponencialmente (problema de gradientes explosivos), causando que los pesos se actualicen con pasos enormes y desestabilicen el entrenamiento.

Gradient Clipping: control de gradientes explosivos

Gradient clipping limita la norma L2 del vector de gradientes:

g max_norm g g si g > max_norm
for epoch in range(n_epochs):
    for X_b, y_b in loader:
        optimizer.zero_grad()
        loss = criterion(model(X_b), y_b)
        loss.backward()

        # Aplicar gradient clipping ANTES de optimizer.step()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        optimizer.step()

# Monitorear norma de gradientes (util para diagnostico)
def get_grad_norm(model):
    total_norm = 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

# En el bucle:
grad_norm = get_grad_norm(model)
print(f"Norma del gradiente: {grad_norm:.4f}")

Cuando usar: siempre en RNNs/LSTMs. En CNNs y MLPs es menos critico pero no hace daño. En transformers, max_norm=1.0 es el valor estandar.


8. Diagnostico de problemas de entrenamiento

Saber que tecnica aplicar requiere primero identificar el problema. Las curvas de entrenamiento son tu principal herramienta de diagnostico.

Impacto combinado de tecnicas de entrenamiento

El mapa de diagnostico

Curva train      Curva validacion    Diagnostico              Solucion
──────────────   ─────────────────   ────────────────────     ─────────────────────
↓ baja           ↓ baja              Entrenamiento correcto   Seguir o afinar
↓ baja           ↑ sube              Overfitting              Dropout / weight decay / menos epocas
↑ alta           ↑ alta              Underfitting             Red mas grande / mas epocas / mejor LR
↕ oscila         ↕ oscila            LR muy alto              Bajar LR / gradient clipping
→ plana rapido   → plana rapido      LR muy bajo              Subir LR / cambiar scheduler
↓ muy lenta      ↓ muy lenta         Mala inicializacion      He/Xavier / revisar BN
import matplotlib.pyplot as plt

def diagnostico_entrenamiento(history):
    """
    Grafica curvas de entrenamiento y muestra diagnostico automatico.
    history: dict con listas "train_loss" y "val_loss"
    """
    train = history["train_loss"]
    val   = history["val_loss"]
    ep    = range(1, len(train)+1)

    fig, axes = plt.subplots(1, 2, figsize=(12, 4))

    # Curvas de perdida
    axes[0].plot(ep, train, label="Train", color="#4C72B0")
    axes[0].plot(ep, val, label="Val", color="#DD8452")
    axes[0].set_title("Curvas de Perdida")
    axes[0].set_xlabel("Epoca")
    axes[0].set_ylabel("Loss")
    axes[0].legend()

    # Gap train-val (diagnostico de overfitting)
    gap = [t - v for t, v in zip(train, val)]
    axes[1].plot(ep, gap, color="#C44E52")
    axes[1].axhline(0, ls="--", color="gray", alpha=0.5)
    axes[1].fill_between(ep, gap, 0,
                         where=[g > 0 for g in gap],
                         alpha=0.2, color="#C44E52", label="Overfitting")
    axes[1].fill_between(ep, gap, 0,
                         where=[g <= 0 for g in gap],
                         alpha=0.2, color="#55A868", label="Underfitting")
    axes[1].set_title("Gap Train - Val (positivo = overfitting)")
    axes[1].set_xlabel("Epoca")
    axes[1].set_ylabel("Train Loss - Val Loss")
    axes[1].legend()

    plt.tight_layout()
    plt.savefig("diagnostico.png", dpi=120, bbox_inches="tight")
    plt.show()

    # Diagnostico automatico simple
    final_gap = train[-1] - val[-1]
    if val[-1] > val[max(0, len(val)-10)]:
        print("ALERTA: val loss aumenta en las ultimas epocas → posible overfitting")
    elif final_gap > 0.1:
        print(f"ALERTA: gap train-val = {final_gap:.3f} → considera mas regularizacion")
    else:
        print(f"OK: gap train-val = {final_gap:.3f}, entrenamiento estable")

9. Receta completa de entrenamiento

Integrando todo en un pipeline reproducible:

import torch
import torch.nn as nn
from torch.utils.data import DataLoader

def entrenar_modelo(
    model, train_loader, val_loader,
    n_epochs=100,
    lr=1e-3,
    weight_decay=1e-2,
    patience=15,
    clip_norm=1.0,
    device="cpu"
):
    """
    Bucle de entrenamiento completo con:
    - AdamW + Cosine LR Scheduler
    - Gradient Clipping
    - Early Stopping con checkpoint del mejor modelo
    - Registro de historial
    """
    model = model.to(device)
    criterion = nn.CrossEntropyLoss()

    optimizer = torch.optim.AdamW(
        model.parameters(), lr=lr, weight_decay=weight_decay
    )
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
        optimizer, T_max=n_epochs, eta_min=lr*0.01
    )
    early_stop = EarlyStopping(patience=patience, path="checkpoint.pt")

    history = {"train_loss": [], "val_loss": [], "lr": []}

    for epoch in range(n_epochs):
        # ── Entrenamiento ──────────────────────────────────────────
        model.train()
        train_losses = []
        for X_b, y_b in train_loader:
            X_b, y_b = X_b.to(device), y_b.to(device)
            optimizer.zero_grad()
            loss = criterion(model(X_b), y_b)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
            optimizer.step()
            train_losses.append(loss.item())

        # ── Validacion ─────────────────────────────────────────────
        model.eval()
        val_losses = []
        with torch.no_grad():
            for X_b, y_b in val_loader:
                X_b, y_b = X_b.to(device), y_b.to(device)
                val_losses.append(criterion(model(X_b), y_b).item())

        train_loss = sum(train_losses) / len(train_losses)
        val_loss   = sum(val_losses)   / len(val_losses)
        current_lr = optimizer.param_groups[0]["lr"]

        history["train_loss"].append(train_loss)
        history["val_loss"].append(val_loss)
        history["lr"].append(current_lr)

        scheduler.step()
        early_stop(val_loss, model)

        if (epoch + 1) % 10 == 0:
            print(f"Ep {epoch+1:3d} | train={train_loss:.4f} | val={val_loss:.4f} | lr={current_lr:.2e}")

        if early_stop.stopped:
            print(f"\nEarly stop en epoca {epoch+1}")
            break

    # Restaurar el mejor modelo
    model.load_state_dict(torch.load("checkpoint.pt"))
    return model, history

Dashboard resumen

Dashboard: Tecnicas de Entrenamiento en Deep Learning


Recursos recomendados


← 11. Fundamentos de PyTorch | 13. Redes Convolucionales (CNNs) →

© 2026 OBIA
Olimpiada Boliviana de Inteligencia Artificial - OBIA