💻 Tutorial paso a paso

Optimización de hiperparámetros con Hyperopt

Guía completa para optimizar los hiperparámetros de una red neuronal con Hyperopt: desde el estudio previo para identificar qué ajustar, pasando por random search como baseline, hasta optimización bayesiana con TPE. Dataset FashionMNIST, CNN configurable y código 100% reproducible.

⏱️ ~50 min 📊 Nivel: intermedio 🔥 PyTorch 2.x · Hyperopt · FashionMNIST

Requisitos previos

  • Python 3.9+ y PyTorch 2.x instalados
  • Conceptos básicos de entrenamiento de redes neuronales (loss, optimizer, épocas)
  • Entender qué es una CNN y un MLP (consulta la teoría de CNNs)
  • Haber leído la teoría de optimización de hiperparámetros
  • Opcional: GPU con CUDA (los ejemplos funcionan en CPU)
1

Visión general: ¿por qué optimizar hiperparámetros?

Los hiperparámetros son todos los valores que elegimos antes de entrenar y que no se aprenden con backpropagation: learning rate, batch size, número de capas, dropout rate, weight decay… Una mala elección puede convertir un modelo excelente en uno mediocre.

El problema: el espacio de hiperparámetros es enorme, las evaluaciones son caras (cada una implica entrenar un modelo) y la relación HP → rendimiento es no lineal, ruidosa y con interacciones. Necesitamos estrategias inteligentes.

Grid Search 16 puntos, patrón fijo Random Search 16 puntos, mejor cobertura Bayesian (TPE) 16 puntos, se concentran en la zona óptima

1.1 Los tres enfoques principales

1.2 ¿Por qué Hyperopt?

Hyperopt es una librería Python para HPO que implementa dos algoritmos:

  • Random Search (rand.suggest) — baseline rápido.
  • TPE (tpe.suggest) — Tree-structured Parzen Estimator, un algoritmo bayesiano particularmente eficiente para espacios condicionales y mixtos.

TPE fue propuesto en Bergstra et al. (2011). En lugar de modelar directamente P(score | HP) como un proceso gaussiano, TPE modela P(HP | score < umbral) y P(HP | score ≥ umbral) por separado, lo que es más escalable y maneja bien espacios condicionales.

LibreríaAlgoritmo principalEspacio condicionalParalelismo
Hyperopt TPE ✅ Nativo MongoDB / Spark
Optuna TPE (propio) ✅ Nativo Multi-thread / DB
Ray Tune Varios (ASHA, BO, PBT) ✅ Via wrappers Distribuido
Scikit-optimize GP-based BO ❌ Limitado Secuencial
💡 ¿Por qué Hyperopt y no Optuna? Ambas son excelentes. Hyperopt es más explícito en la definición del espacio (lo defines completo de una vez), lo que facilita entender el concepto. Optuna usa una API "define-by-run" que es más flexible pero puede oscurecer la estructura del espacio para un tutorial didáctico. Los conceptos que aprendas aquí aplican a cualquier librería.

1.3 Nuestro plan

En este tutorial seguiremos este flujo:

  1. Cargar FashionMNIST y definir un modelo CNN+MLP configurable.
  2. Identificar qué hiperparámetros tienen mayor impacto (estudio previo).
  3. Definir el espacio de búsqueda con las primitivas de Hyperopt.
  4. Implementar la función objetivo que entrena y evalúa un modelo.
  5. Ejecutar random search para obtener un baseline.
  6. Ejecutar TPE (bayesiano) partiendo de los resultados previos.
  7. Analizar resultados y extraer conclusiones.
2

Setup: dataset, modelo base y librerías

2.1 Instalación

Terminal instalar dependencias
pip install torch torchvision hyperopt matplotlib numpy

2.2 Imports

Python imports principales
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
import numpy as np
import matplotlib.pyplot as plt
import time
import warnings
warnings.filterwarnings('ignore')

from hyperopt import fmin, tpe, hp, rand, Trials, STATUS_OK, space_eval

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)

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

2.3 Dataset: FashionMNIST

Usaremos FashionMNIST: 70K imágenes 28×28 en escala de grises, 10 clases de ropa (camisetas, pantalones, zapatillas…). Es más desafiante que MNIST pero lo bastante rápido de entrenar para iterar muchas veces — ideal para HPO.

Python cargar FashionMNIST
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,)),  # media y std de FashionMNIST
])

full_train = datasets.FashionMNIST('./data', train=True,  download=True, transform=transform)
test_set   = datasets.FashionMNIST('./data', train=False, download=True, transform=transform)

# Split train/val (54K / 6K)
train_set, val_set = random_split(
    full_train, [54000, 6000],
    generator=torch.Generator().manual_seed(SEED)
)

print(f"Train: {len(train_set)} | Val: {len(val_set)} | Test: {len(test_set)}")
print(f"Clases: {full_train.classes}")
print(f"Input shape: {full_train[0][0].shape}")  # → [1, 28, 28]
Salida
Train: 54000 | Val: 6000 | Test: 10000
Clases: ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
Input shape: torch.Size([1, 28, 28])
L10El split train/val es imprescindible para HPO. Evaluar en el mismo set que entrenas infla artificialmente todos los candidatos y produce data leakage.
⚠️ El set de test NUNCA se usa durante HPO. Reserva el test set exclusivamente para la evaluación final del mejor modelo. Usar test para seleccionar hiperparámetros invalida tus resultados.

2.4 Modelo base: CNN + MLP

Definimos una CNN con bloques convolucionales variables y un clasificador MLP configurable. Por ahora es la versión fija (valores por defecto) — la parametrizaremos en el paso 4.

Python CNN+MLP configurable
class ConfigurableCNN(nn.Module):
    """
    CNN con arquitectura configurable para HPO.
    
    Parámetros configurables:
      n_conv_blocks: número de bloques conv (2-4)
      n_filters:     filtros base por bloque (16, 32, 64)
      activation:    función de activación ('relu', 'leaky_relu', 'gelu')
      fc_dim:        neuronas en la capa FC oculta (64, 128, 256, 512)
      dropout:       dropout rate en el clasificador (0.0 - 0.5)
      use_batchnorm: usar BatchNorm (True/False)
    """
    def __init__(self, n_conv_blocks=3, n_filters=32, activation='relu',
                 fc_dim=128, dropout=0.3, use_batchnorm=True):
        super().__init__()
        
        act_fn = {
            'relu': nn.ReLU, 'leaky_relu': nn.LeakyReLU, 'gelu': nn.GELU
        }[activation]
        
        # ── Bloques convolucionales ──
        layers = []
        in_ch = 1  # FashionMNIST: 1 canal
        for i in range(n_conv_blocks):
            out_ch = n_filters * (2 ** i)  # Doblar filtros cada bloque
            layers.append(nn.Conv2d(in_ch, out_ch, 3, padding=1))
            if use_batchnorm:
                layers.append(nn.BatchNorm2d(out_ch))
            layers.append(act_fn())
            layers.append(nn.Conv2d(out_ch, out_ch, 3, padding=1))
            if use_batchnorm:
                layers.append(nn.BatchNorm2d(out_ch))
            layers.append(act_fn())
            layers.append(nn.MaxPool2d(2))
            in_ch = out_ch
        
        self.features = nn.Sequential(*layers)
        
        # Calcular tamaño del feature map tras las convoluciones
        # 28 → 14 → 7 → 3 → 1 (con cada MaxPool2d(2))
        feat_size = 28 // (2 ** n_conv_blocks)
        flat_dim = in_ch * feat_size * feat_size
        
        # ── Clasificador MLP ──
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(flat_dim, fc_dim),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(fc_dim, 10),
        )
        
        self._init_weights()
    
    def _init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
            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))

# Quick test con valores por defecto
model = ConfigurableCNN().to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"ConfigurableCNN (default): {n_params:,} params")

# Verificar forward pass
x = torch.randn(4, 1, 28, 28).to(device)
out = model(x)
print(f"Output shape: {out.shape}")  # → [4, 10]
Salida
ConfigurableCNN (default): 229,130 params
Output shape: torch.Size([4, 10])
L27out_ch = n_filters * (2 ** i): Si n_filters=32 y n_conv_blocks=3, los bloques tendrán 32, 64, 128 filtros. Arquitectura "piramidal" estándar.
L41Calculamos el tamaño del feature map tras los MaxPool2d sucesivos. Con 3 bloques: 28 → 14 → 7 → 3 (floor). Con 4 bloques: 28 → 14 → 7 → 3 → 1.

Para HPO, el tiempo por evaluación importa mucho. FashionMNIST tiene imágenes más pequeñas (28×28 vs 32×32) y solo 1 canal (vs 3), así que entrenar es ~3× más rápido que CIFAR-10.

Si usamos 50 trials y cada uno entrena 10 épocas, con FashionMNIST en CPU tardamos ~30 min. Con CIFAR-10 serían ~90 min. Para un tutorial interactivo, FashionMNIST es ideal.

Los conceptos que aprendas aquí aplican exactamente igual a CIFAR-10, ImageNet o cualquier dataset: lo que cambia es el espacio de búsqueda y el tiempo por trial.

3

Estudio previo: ¿qué hiperparámetros importan?

Antes de lanzar una búsqueda costosa, conviene dedicar unos minutos a entender qué hiperparámetros tienen mayor impacto en tu problema. No todos los HPs son iguales: algunos dominan el rendimiento y otros apenas importan.

3.1 Jerarquía de impacto

En la práctica (y según estudios empíricos como Bergstra & Bengio 2012), el impacto típico de los HPs sigue esta jerarquía:

Learning rate CRÍTICO Arquitectura MUY ALTO Batch size ALTO Regularización MEDIO Impacto →

3.2 Análisis de sensibilidad rápido

Una forma sencilla de validar esta jerarquía es un one-factor-at-a-time (OFAT): fijar todos los HPs excepto uno, variarlo, y medir el impacto en validación. Hacemos un entrenamiento corto (5 épocas) para cada configuración:

Python análisis de sensibilidad rápido
def quick_train(model, train_loader, val_loader, lr, epochs=5):
    """Entrena rápido y devuelve val accuracy."""
    optimizer = optim.Adam(model.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()
    
    model.train()
    for epoch in range(epochs):
        for images, targets in train_loader:
            images, targets = images.to(device), targets.to(device)
            optimizer.zero_grad()
            loss = criterion(model(images), targets)
            loss.backward()
            optimizer.step()
    
    # Evaluar
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for images, targets in val_loader:
            images, targets = images.to(device), targets.to(device)
            correct += (model(images).argmax(1) == targets).sum().item()
            total += targets.size(0)
    return correct / total

# DataLoaders con batch_size fijo para el estudio
study_train = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2)
study_val   = DataLoader(val_set, batch_size=256, shuffle=False, num_workers=2)

# ── Test 1: Sensibilidad al Learning Rate ──────────────────
print("=== Sensibilidad al Learning Rate ===")
for lr in [1e-1, 1e-2, 1e-3, 3e-4, 1e-4]:
    model = ConfigurableCNN(n_conv_blocks=2, n_filters=32).to(device)
    acc = quick_train(model, study_train, study_val, lr=lr, epochs=5)
    print(f"  lr={lr:.0e}  →  val_acc={acc:.2%}")

# ── Test 2: Sensibilidad a la arquitectura ─────────────────
print("\n=== Sensibilidad a la Arquitectura ===")
for blocks, filters in [(2, 16), (2, 32), (3, 32), (3, 64)]:
    model = ConfigurableCNN(n_conv_blocks=blocks, n_filters=filters).to(device)
    n = sum(p.numel() for p in model.parameters())
    acc = quick_train(model, study_train, study_val, lr=1e-3, epochs=5)
    print(f"  blocks={blocks}, filters={filters} ({n:>7,} params)  →  val_acc={acc:.2%}")

# ── Test 3: Sensibilidad al Dropout ────────────────────────
print("\n=== Sensibilidad al Dropout ===")
for drop in [0.0, 0.1, 0.3, 0.5]:
    model = ConfigurableCNN(n_conv_blocks=2, n_filters=32, dropout=drop).to(device)
    acc = quick_train(model, study_train, study_val, lr=1e-3, epochs=5)
    print(f"  dropout={drop}  →  val_acc={acc:.2%}")
Salida (ejemplo)
=== Sensibilidad al Learning Rate ===
  lr=1e-01  →  val_acc=10.02%      ← ¡Diverge!
  lr=1e-02  →  val_acc=87.15%
  lr=1e-03  →  val_acc=89.43%      ← Mejor
  lr=3e-04  →  val_acc=88.12%
  lr=1e-04  →  val_acc=83.57%      ← Demasiado lento

=== Sensibilidad a la Arquitectura ===
  blocks=2, filters=16 ( 14,666 params)  →  val_acc=87.28%
  blocks=2, filters=32 ( 55,178 params)  →  val_acc=89.43%
  blocks=3, filters=32 (229,130 params)  →  val_acc=90.15%
  blocks=3, filters=64 (912,842 params)  →  val_acc=90.87%

=== Sensibilidad al Dropout ===
  dropout=0.0  →  val_acc=89.21%
  dropout=0.1  →  val_acc=89.38%
  dropout=0.3  →  val_acc=89.43%   ← Leve diferencia
  dropout=0.5  →  val_acc=88.90%
💡 Conclusiones del estudio previo:
  • El LR abarca ~80% de rango en 5 épocas (del 10% al 89%). Es el HP más crítico.
  • La arquitectura (bloques × filtros) da un rango de ~4% (87% a 91%). Importante pero secundario.
  • El dropout apenas varía ~0.5%. Menos prioritario, pero afecta con más épocas.

3.3 Seleccionar los hiperparámetros a optimizar

Basándonos en el estudio previo, seleccionamos estos 8 hiperparámetros para la búsqueda:

Activación: Puedes incluirla como hp.choice entre ReLU, LeakyReLU y GELU. En la práctica, ReLU funciona bien para CNNs y el impacto es menor que LR o arquitectura. Lo incluiremos como bonus.

BatchNorm: Casi siempre conviene usarlo. Optimizar un booleano use_batchnorm es viable pero el consenso es "siempre True para CNNs con batch ≥ 16". Lo fijaremos a True.

Regla general: No metas demasiados HPs en la búsqueda. Cada HP adicional aumenta el volumen del espacio exponencialmente. 8 HPs ya es un espacio rico. Más de 12 requiere centenares de trials.

4

Parametrizar el modelo y definir el espacio de búsqueda

La clave de HPO es tener un modelo parametrizable y un espacio de búsqueda bien definido que Hyperopt pueda explorar. En este paso conectaremos ambas cosas.

4.1 Función constructora: build_model(params)

Python construir modelo desde un dict de hiperparámetros
def build_model(params):
    """Construye un ConfigurableCNN a partir de un diccionario de HPs."""
    model = ConfigurableCNN(
        n_conv_blocks=params['n_conv_blocks'],
        n_filters=params['n_filters'],
        activation=params.get('activation', 'relu'),
        fc_dim=params['fc_dim'],
        dropout=params['dropout'],
        use_batchnorm=True,  # Fijo: siempre BatchNorm
    )
    return model.to(device)

# Test
test_params = {
    'n_conv_blocks': 3, 'n_filters': 32, 'activation': 'relu',
    'fc_dim': 128, 'dropout': 0.3,
}
m = build_model(test_params)
print(f"Modelo con {sum(p.numel() for p in m.parameters()):,} params")

4.2 Primitivas de Hyperopt para el espacio

Hyperopt ofrece distintas distribuciones para definir el rango de cada HP. Es fundamental elegir la distribución correcta:

Python primitivas de Hyperopt
# hp.choice(label, options)
#   → Elige un elemento de una lista finita. Para categorías y discretos.
#   Ejemplo: hp.choice('optimizer', ['adam', 'sgd'])

# hp.uniform(label, low, high)
#   → Valor continuo uniforme en [low, high]. Para rangos lineales.
#   Ejemplo: hp.uniform('dropout', 0.0, 0.5)

# hp.loguniform(label, low, high)
#   → exp(uniform(low, high)). Para valores que varían en órdenes de magnitud.
#   Ejemplo: hp.loguniform('lr', np.log(1e-4), np.log(1e-1))
#   ⚠️ low y high son el LOG del rango deseado

# hp.quniform(label, low, high, q)
#   → round(uniform(low, high) / q) * q. Para discretos con paso.
#   Ejemplo: hp.quniform('neurons', 32, 512, 16)

# hp.normal(label, mu, sigma)
#   → Distribución normal. Para centrar la búsqueda alrededor de un valor conocido.

# hp.lognormal(label, mu, sigma)
#   → exp(normal(mu, sigma)). Para LR cuando tienes un prior fuerte.
L10¡Los argumentos de hp.loguniform son el logaritmo! Para buscar entre 1e-4 y 1e-1, usamos np.log(1e-4)=-9.21 y np.log(1e-1)=-2.30.
L1hp.choice devuelve el índice internamente (0, 1, 2...). Usa space_eval(space, best) al final para obtener el valor real.

4.3 Widget interactivo: explora las distribuciones

🧪 Explorador de distribuciones de Hyperopt

Media
Std
Mínimo
Máximo

4.4 Definir el espacio de búsqueda completo

Python espacio de búsqueda
search_space = {
    # ── Optimización ──
    'lr': hp.loguniform('lr', np.log(1e-4), np.log(1e-1)),
    'weight_decay': hp.loguniform('weight_decay', np.log(1e-6), np.log(1e-2)),
    'optimizer': hp.choice('optimizer', ['adam', 'sgd']),
    'batch_size': hp.choice('batch_size', [32, 64, 128, 256]),

    # ── Arquitectura ──
    'n_conv_blocks': hp.choice('n_conv_blocks', [2, 3]),
    'n_filters': hp.choice('n_filters', [16, 32, 64]),
    'fc_dim': hp.choice('fc_dim', [64, 128, 256, 512]),
    'activation': hp.choice('activation', ['relu', 'leaky_relu', 'gelu']),

    # ── Regularización ──
    'dropout': hp.uniform('dropout', 0.0, 0.5),
}

# Verificar el espacio
from hyperopt.pyll.stochastic import sample
example = sample(search_space)
print("Ejemplo de punto muestreado:")
for k, v in sorted(example.items()):
    print(f"  {k:>16s}: {v}")
Salida (ejemplo)
Ejemplo de punto muestreado:
      activation: relu
      batch_size: 128
         dropout: 0.2341
          fc_dim: 256
              lr: 0.003217
   n_conv_blocks: 3
       n_filters: 32
       optimizer: adam
    weight_decay: 0.0000842
⚠️ Errores comunes al definir el espacio:
  • LR lineal en vez de log: hp.uniform('lr', 1e-4, 1e-1) concentra el 99.9% de las muestras en [0.01, 0.1]. Usa hp.loguniform.
  • Olvidar np.log(): hp.loguniform('lr', 1e-4, 1e-1) muestrea exp(U(0.0001, 0.1)), no lo que quieres.
  • Espacio demasiado amplio: El rango [1e-6, 1] para LR es enorme. Acota basándote en el estudio previo.

A veces un HP solo tiene sentido si otro tiene cierto valor. Por ejemplo, el momentum solo aplica si el optimizer es SGD. Hyperopt maneja esto naturalmente con hp.choice anidados:

'optimizer_config': hp.choice('opt', [
    {'type': 'adam', 'beta1': hp.uniform('adam_b1', 0.85, 0.95)},
    {'type': 'sgd',  'momentum': hp.uniform('sgd_mom', 0.8, 0.99)},
])

TPE maneja bien estos espacios condicionales — es una de sus ventajas frente a GP-based BO que espera un espacio rectangular fijo. En este tutorial usamos un espacio plano por simplicidad.

HPDistribuciónRangoJustificación
lrloguniform[1e-4, 1e-1]Varía en órdenes de magnitud
weight_decayloguniform[1e-6, 1e-2]Ídem, escala logarítmica
optimizerchoice{adam, sgd}Categórico binario
batch_sizechoice{32, 64, 128, 256}Potencias de 2, discreto
n_conv_blockschoice{2, 3}Discreto, pocos valores
n_filterschoice{16, 32, 64}Potencias de 2, discreto
fc_dimchoice{64, 128, 256, 512}Potencias de 2, discreto
dropoutuniform[0.0, 0.5]Rango lineal acotado
5

La función objetivo

La función objetivo es el corazón de HPO: recibe un diccionario de hiperparámetros, entrena un modelo, evalúa en validación y devuelve una métrica de coste (loss) que Hyperopt intentará minimizar.

5.1 Estructura de la función objetivo

params dict build model train N eps eval val {'loss': -val_acc}
Python función objetivo completa
NUM_EPOCHS_HPO = 10  # Épocas por trial (suficiente para discriminar)

def objective(params):
    """
    Función objetivo para Hyperopt.
    Entrena un modelo con los HPs dados y devuelve -val_accuracy.
    (Hyperopt MINIMIZA, así que negamos la accuracy.)
    """
    try:
        # 1. Construir modelo
        model = build_model(params)

        # 2. DataLoaders con batch_size del HP
        bs = params['batch_size']
        tr_loader = DataLoader(train_set, batch_size=bs, shuffle=True,
                               num_workers=2, pin_memory=True)
        vl_loader = DataLoader(val_set, batch_size=256, shuffle=False,
                               num_workers=2, pin_memory=True)

        # 3. Optimizer
        if params['optimizer'] == 'adam':
            opt = optim.Adam(model.parameters(),
                             lr=params['lr'],
                             weight_decay=params['weight_decay'])
        else:  # sgd
            opt = optim.SGD(model.parameters(),
                            lr=params['lr'],
                            momentum=0.9,
                            weight_decay=params['weight_decay'])

        criterion = nn.CrossEntropyLoss()

        # 4. Entrenar
        model.train()
        for epoch in range(NUM_EPOCHS_HPO):
            for images, targets in tr_loader:
                images, targets = images.to(device), targets.to(device)
                opt.zero_grad()
                loss = criterion(model(images), targets)
                loss.backward()
                nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
                opt.step()

        # 5. Evaluar en validación
        model.eval()
        correct, total = 0, 0
        with torch.no_grad():
            for images, targets in vl_loader:
                images, targets = images.to(device), targets.to(device)
                preds = model(images).argmax(dim=1)
                correct += (preds == targets).sum().item()
                total += targets.size(0)

        val_acc = correct / total

        # 6. Retornar resultado
        return {
            'loss': -val_acc,       # Hyperopt minimiza → negamos la accuracy
            'status': STATUS_OK,
            'val_acc': val_acc,     # Metadata extra
            'n_params': sum(p.numel() for p in model.parameters()),
        }

    except Exception as e:
        # Si algo falla (OOM, dimensiones incompatibles...), retornar loss mala
        print(f"  ❌ Trial fallido: {e}")
        return {'loss': 0.0, 'status': STATUS_OK, 'val_acc': 0.0}

# Quick test con los HPs por defecto
result = objective(test_params | {'lr': 1e-3, 'weight_decay': 1e-4,
                                   'optimizer': 'adam', 'batch_size': 128})
print(f"Test objective: val_acc={result['val_acc']:.2%}, loss={result['loss']:.4f}")
Salida
Test objective: val_acc=89.95%, loss=-0.8995
L56'loss': -val_acc: Hyperopt siempre minimiza. Para maximizar accuracy, negamos el valor. -0.90 es "mejor" que -0.85 (menor).
L58Puedes añadir cualquier metadata al dict de retorno. Hyperopt la almacena en el objeto Trials y puedes consultarla después.
L62Catch de errores: si un HP produce dimensiones incompatibles o OOM, retornamos loss=0.0 (accuracy 0%) en vez de crashear toda la búsqueda.

5.2 Decisiones clave en la función objetivo

DecisiónNuestra elecciónAlternativa
Nº de épocas 10 (rápido, suficiente para discriminar) 20-50 (más preciso pero 2-5× más lento)
Métrica a optimizar Val accuracy Val loss, F1-score, AUC-ROC
Manejo de errores Retornar loss=0.0 (peor posible) STATUS_FAIL (cuidado: Hyperopt puede no manejarlo bien)
Reproducibilidad Seed global fijo Seed por trial (más robusto: media de N seeds)
💡 Trade-off épocas vs. número de trials: Con un presupuesto fijo de tiempo (e.g. 1 hora), ¿prefieres 100 trials × 5 épocas o 20 trials × 25 épocas? La evidencia empírica sugiere que más trials con menos épocas da mejores resultados en HPO. Los primeros epochs ya discriminan bien qué configuraciones son malas. Técnicas como successive halving o Hyperband automatizan esta idea.

Para ahorrar tiempo, puedes implementar early stopping dentro de la función objetivo: si la val_loss no mejora en 3 épocas, parar.

# Dentro del training loop:
best_val_loss = float('inf')
patience_counter = 0
for epoch in range(NUM_EPOCHS_HPO):
    train_one_epoch(...)
    val_loss = evaluate(...)
    if val_loss < best_val_loss - 1e-4:
        best_val_loss = val_loss
        patience_counter = 0
    else:
        patience_counter += 1
        if patience_counter >= 3:
            break  # este trial no mejora, ahorra tiempo

Esto prioriza automáticamente los buenos trials (más épocas) y descarta los malos rápido (menos épocas). Es una forma manual de "successive halving".

6

Random search como baseline

Antes de aplicar optimización bayesiana, establecemos un baseline con random search. Esto nos da un punto de comparación justo y, sorprendentemente, random search suele encontrar buenas configuraciones rápido (Bergstra & Bengio, 2012).

6.1 Ejecutar random search

Python random search con Hyperopt
N_RANDOM_TRIALS = 30  # Número de configuraciones a probar

# Trials almacena todo el historial de la búsqueda
random_trials = Trials()

print(f"🎲 Iniciando Random Search ({N_RANDOM_TRIALS} trials)...")
print("=" * 60)

best_random = fmin(
    fn=objective,
    space=search_space,
    algo=rand.suggest,       # ← Random search
    max_evals=N_RANDOM_TRIALS,
    trials=random_trials,
    rstate=np.random.default_rng(SEED),
    verbose=False,
)

# Convertir índices a valores reales
best_random_params = space_eval(search_space, best_random)

print("\n" + "=" * 60)
print("🏆 Mejor configuración (Random Search):")
for k, v in sorted(best_random_params.items()):
    print(f"  {k:>16s}: {v}")

best_random_acc = -random_trials.best_trial['result']['loss']
print(f"\n  → Val accuracy: {best_random_acc:.2%}")
Salida (ejemplo)
🎲 Iniciando Random Search (30 trials)...
============================================================

============================================================
🏆 Mejor configuración (Random Search):
      activation: relu
      batch_size: 64
         dropout: 0.1823
          fc_dim: 256
              lr: 0.001247
   n_conv_blocks: 3
       n_filters: 32
       optimizer: adam
    weight_decay: 0.000134

  → Val accuracy: 91.13%
L10algo=rand.suggest: Le dice a fmin que use muestreo aleatorio puro, sin modelo surrogate.
L18space_eval: Convierte el resultado de fmin (que usa índices para hp.choice) a los valores reales del espacio.

6.2 Analizar los resultados

Python extraer y visualizar resultados
# Extraer accuracies de todos los trials
random_accs = [-t['result']['loss'] for t in random_trials.trials]

# Estadísticas
print(f"Trials completados: {len(random_accs)}")
print(f"Mejor accuracy:     {max(random_accs):.2%}")
print(f"Peor accuracy:      {min(random_accs):.2%}")
print(f"Media:              {np.mean(random_accs):.2%}")
print(f"Std:                {np.std(random_accs):.2%}")

# Distribución de accuracies
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# Histograma
axes[0].hist(random_accs, bins=15, color='#6c5ce7', alpha=0.7, edgecolor='white')
axes[0].axvline(max(random_accs), color='#00b894', linestyle='--', label=f'Best: {max(random_accs):.2%}')
axes[0].set_xlabel('Val Accuracy'); axes[0].set_ylabel('Nº Trials')
axes[0].set_title('Distribución de Accuracies (Random Search)')
axes[0].legend()

# Accuracy vs trial number (¿mejora con más trials?)
axes[1].scatter(range(1, len(random_accs)+1), random_accs,
                c='#6c5ce7', alpha=0.6, s=30)
best_so_far = [max(random_accs[:i+1]) for i in range(len(random_accs))]
axes[1].plot(range(1, len(random_accs)+1), best_so_far,
             color='#00b894', linewidth=2, label='Best so far')
axes[1].set_xlabel('Trial'); axes[1].set_ylabel('Val Accuracy')
axes[1].set_title('Convergencia (Random Search)')
axes[1].legend()

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

6.3 ¿Qué HPs afectaron más?

Python análisis de HPs vs accuracy
# Extraer HPs de cada trial
lrs = [t['misc']['vals']['lr'][0] for t in random_trials.trials]

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

# LR vs Accuracy (el más revelador)
axes[0].scatter(lrs, random_accs, c=random_accs, cmap='viridis', s=40, alpha=0.8)
axes[0].set_xscale('log')
axes[0].set_xlabel('Learning Rate (log)'); axes[0].set_ylabel('Val Accuracy')
axes[0].set_title('LR vs Accuracy')

# Dropout vs Accuracy
dropouts = [t['misc']['vals']['dropout'][0] for t in random_trials.trials]
axes[1].scatter(dropouts, random_accs, c=random_accs, cmap='viridis', s=40, alpha=0.8)
axes[1].set_xlabel('Dropout'); axes[1].set_ylabel('Val Accuracy')
axes[1].set_title('Dropout vs Accuracy')

# Weight decay vs Accuracy
wds = [t['misc']['vals']['weight_decay'][0] for t in random_trials.trials]
axes[2].scatter(wds, random_accs, c=random_accs, cmap='viridis', s=40, alpha=0.8)
axes[2].set_xscale('log')
axes[2].set_xlabel('Weight Decay (log)'); axes[2].set_ylabel('Val Accuracy')
axes[2].set_title('Weight Decay vs Accuracy')

plt.tight_layout()
plt.savefig('hp_analysis_random.png', dpi=150)
plt.show()
💡 Interpretación típica de random search:
  • El gráfico LR vs Accuracy muestra una "U invertida" — hay una zona óptima.
  • Los mejores trials suelen tener LR ≈ 1e-3 ± un orden de magnitud.
  • El dropout y weight decay importan menos: trials buenos aparecen con valores variados.
  • Con más trials, la curva "best so far" se estabiliza. 30 trials es un buen inicio.
🎯 Baseline establecido: ~91% con random search y 30 trials. Ahora veamos si la optimización bayesiana puede mejorar esto con el mismo presupuesto o menos.
7

Optimización bayesiana con TPE

El algoritmo TPE (Tree-structured Parzen Estimator) es la joya de Hyperopt. En lugar de muestrear al azar, TPE aprende de los trials anteriores para proponer configuraciones cada vez más prometedoras.

7.1 ¿Cómo funciona TPE?

TPE divide los trials en dos grupos según un umbral γ (el cuantil) de la loss:

  • l(x) = distribución de HPs en los buenos trials (loss < umbral).
  • g(x) = distribución de HPs en los malos trials (loss ≥ umbral).

Luego propone HPs que maximicen l(x) / g(x) — es decir, puntos que parecen buenos y no parecen malos. Esto es equivalente a maximizar la función de adquisición Expected Improvement.

l(x) buenos trials (top 20%) g(x) malos trials (bottom 80%) l(x) / g(x) argmax EI ~ l/g x*

7.2 Ejecutar TPE

Python optimización bayesiana con TPE
N_TPE_TRIALS = 50  # Presupuesto total (incluye warm-start)

# Crear nuevo objeto Trials para TPE
tpe_trials = Trials()

print(f"🧠 Iniciando TPE ({N_TPE_TRIALS} trials)...")
print("=" * 60)

best_tpe = fmin(
    fn=objective,
    space=search_space,
    algo=tpe.suggest,          # ← TPE bayesiano
    max_evals=N_TPE_TRIALS,
    trials=tpe_trials,
    rstate=np.random.default_rng(SEED),
    verbose=False,
)

best_tpe_params = space_eval(search_space, best_tpe)

print("\n" + "=" * 60)
print("🏆 Mejor configuración (TPE):")
for k, v in sorted(best_tpe_params.items()):
    print(f"  {k:>16s}: {v}")

best_tpe_acc = -tpe_trials.best_trial['result']['loss']
print(f"\n  → Val accuracy: {best_tpe_acc:.2%}")
Salida (ejemplo)
🧠 Iniciando TPE (50 trials)...
============================================================

============================================================
🏆 Mejor configuración (TPE):
      activation: gelu
      batch_size: 64
         dropout: 0.1204
          fc_dim: 256
              lr: 0.000891
   n_conv_blocks: 3
       n_filters: 64
       optimizer: adam
    weight_decay: 0.000052

  → Val accuracy: 92.18%
L11algo=tpe.suggest: Una sola línea cambia random por bayesiano. La interfaz es idéntica.
L12max_evals=50: TPE usa los primeros ~20 trials como "random warm-up" antes de empezar a modelar, así que con < 20 trials no hay diferencia con random.

7.3 Comparar convergencia: Random vs TPE

Python comparación de convergencia
random_accs = [-t['result']['loss'] for t in random_trials.trials]
tpe_accs    = [-t['result']['loss'] for t in tpe_trials.trials]

# Best-so-far curves
random_best = [max(random_accs[:i+1]) for i in range(len(random_accs))]
tpe_best    = [max(tpe_accs[:i+1]) for i in range(len(tpe_accs))]

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# ── Convergencia ──
axes[0].plot(range(1, len(random_best)+1), random_best,
             color='#fdcb6e', linewidth=2, label=f'Random ({max(random_accs):.2%})')
axes[0].plot(range(1, len(tpe_best)+1), tpe_best,
             color='#6c5ce7', linewidth=2, label=f'TPE ({max(tpe_accs):.2%})')
axes[0].set_xlabel('Trial')
axes[0].set_ylabel('Best Val Accuracy')
axes[0].set_title('Convergencia: Random Search vs TPE')
axes[0].legend()
axes[0].grid(alpha=0.2)

# ── Distribuciones ──
axes[1].hist(random_accs, bins=12, color='#fdcb6e', alpha=0.5, label='Random', edgecolor='white')
axes[1].hist(tpe_accs, bins=12, color='#6c5ce7', alpha=0.5, label='TPE', edgecolor='white')
axes[1].set_xlabel('Val Accuracy')
axes[1].set_ylabel('Nº Trials')
axes[1].set_title('Distribución de accuracies')
axes[1].legend()

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

# ── Resumen ──
print("\n📊 Resumen comparativo:")
print(f"{'':>20s} {'Random':>10s}  {'TPE':>10s}")
print(f"{'Trials':>20s} {len(random_accs):>10d}  {len(tpe_accs):>10d}")
print(f"{'Mejor accuracy':>20s} {max(random_accs):>10.2%}  {max(tpe_accs):>10.2%}")
print(f"{'Media accuracy':>20s} {np.mean(random_accs):>10.2%}  {np.mean(tpe_accs):>10.2%}")
print(f"{'Std':>20s} {np.std(random_accs):>10.2%}  {np.std(tpe_accs):>10.2%}")
Salida (ejemplo)
📊 Resumen comparativo:
                          Random         TPE
              Trials           30          50
     Mejor accuracy       91.13%      92.18%
     Media accuracy       85.47%      89.32%
                Std        6.12%       3.41%
💡 ¿Qué observamos?
  • Mejor accuracy: TPE supera a random por ~1% — significativo en solo 50 trials.
  • Media más alta: TPE concentra las evaluaciones en zonas prometedoras → menos desperdicio.
  • Menor varianza: TPE converge: los últimos trials tienden a ser mejores que los primeros.
  • Curva "best so far": TPE sigue mejorando tras 30 trials; random se estanca antes.

TPE puede no mejorar cuando:

  • Muy pocos trials (< 20): TPE necesita un warm-up de ~20 observaciones antes de que el modelo sea informativo.
  • Evaluación muy ruidosa: Si la accuracy varía ±2% entre evaluaciones del mismo HP (por randomness del training), TPE no puede distinguir señal de ruido.
  • Espacio muy simple: Con solo 2-3 HPs y pocos valores posibles, random cubre el espacio rápidamente.
  • Espacio enorme + presupuesto pequeño: Si tienes 20 HPs y solo 30 trials, ni TPE puede explorar bien.

Regla de oro: TPE brilla con 5-15 HPs, presupuesto de 50-200 trials y evaluaciones razonablemente deterministas.

7.4 Evaluar el mejor modelo en test

Python evaluación final en test set
# Reentrenar el mejor modelo con más épocas
print("🔄 Reentrenando mejor configuración con 30 épocas...")
best_model = build_model(best_tpe_params)

opt = optim.Adam(best_model.parameters(),
                 lr=best_tpe_params['lr'],
                 weight_decay=best_tpe_params['weight_decay'])
criterion = nn.CrossEntropyLoss()
bs = best_tpe_params['batch_size']
final_train = DataLoader(train_set, batch_size=bs, shuffle=True, num_workers=2)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)

best_model.train()
for epoch in range(30):
    for images, targets in final_train:
        images, targets = images.to(device), targets.to(device)
        opt.zero_grad()
        loss = criterion(best_model(images), targets)
        loss.backward()
        nn.utils.clip_grad_norm_(best_model.parameters(), max_norm=1.0)
        opt.step()

# Evaluar en TEST (la primera y única vez)
best_model.eval()
correct, total = 0, 0
with torch.no_grad():
    for images, targets in test_loader:
        images, targets = images.to(device), targets.to(device)
        correct += (best_model(images).argmax(1) == targets).sum().item()
        total += targets.size(0)

test_acc = correct / total
print(f"\n📊 Test accuracy (mejor modelo, 30 épocas): {test_acc:.2%}")
print(f"   (Val accuracy durante HPO fue: {best_tpe_acc:.2%})")
Salida
🔄 Reentrenando mejor configuración con 30 épocas...

📊 Test accuracy (mejor modelo, 30 épocas): 92.85%
   (Val accuracy durante HPO fue: 92.18%)
🎯 Resultado final: Partimos de un baseline manual (~89%) y con optimización bayesiana llegamos a ~93% test accuracy en FashionMNIST — una mejora de +4 puntos porcentuales solo ajustando HPs, sin cambiar la arquitectura base.
8

Análisis de resultados, buenas prácticas y referencias

En este último paso analizamos en profundidad los resultados de la búsqueda, extraemos lecciones prácticas y recopilamos referencias para seguir aprendiendo.

8.1 Importancia de cada hiperparámetro

Python análisis de importancia de HPs
import pandas as pd

# Construir tabla de todos los trials TPE
rows = []
for t in tpe_trials.trials:
    vals = t['misc']['vals']
    row = {
        'val_acc': -t['result']['loss'],
        'lr': vals['lr'][0],
        'weight_decay': vals['weight_decay'][0],
        'dropout': vals['dropout'][0],
        'optimizer': ['adam', 'sgd'][vals['optimizer'][0]],
        'batch_size': [32, 64, 128, 256][vals['batch_size'][0]],
        'n_conv_blocks': [2, 3][vals['n_conv_blocks'][0]],
        'n_filters': [16, 32, 64][vals['n_filters'][0]],
        'fc_dim': [64, 128, 256, 512][vals['fc_dim'][0]],
        'activation': ['relu', 'leaky_relu', 'gelu'][vals['activation'][0]],
    }
    rows.append(row)

df = pd.DataFrame(rows)

# Correlación con val_acc (para HPs numéricos)
numeric_cols = ['lr', 'weight_decay', 'dropout', 'n_conv_blocks', 'n_filters', 'fc_dim']
correlations = df[numeric_cols + ['val_acc']].corr()['val_acc'].drop('val_acc').abs()
correlations = correlations.sort_values(ascending=False)

print("📊 Correlación absoluta con val_acc:")
for hp_name, corr in correlations.items():
    bar = '█' * int(corr * 40)
    print(f"  {hp_name:>16s}: {corr:.3f} {bar}")

# Top 5 mejores trials
print("\n🏆 Top 5 configuraciones:")
top5 = df.nlargest(5, 'val_acc')
print(top5[['val_acc', 'lr', 'n_filters', 'n_conv_blocks', 'fc_dim',
            'dropout', 'optimizer']].to_string(index=False))
Salida (ejemplo)
📊 Correlación absoluta con val_acc:
              lr: 0.342 █████████████
       n_filters: 0.281 ███████████
   n_conv_blocks: 0.198 ███████
          fc_dim: 0.156 ██████
        dropout: 0.124 ████
    weight_decay: 0.087 ███

🏆 Top 5 configuraciones:
 val_acc       lr  n_filters  n_conv_blocks  fc_dim  dropout optimizer
  0.9218 0.000891         64              3     256   0.1204     adam
  0.9195 0.001123         64              3     256   0.0891     adam
  0.9172 0.000756         32              3     256   0.1532     adam
  0.9163 0.001347         64              3     128   0.1010     adam
  0.9148 0.000634         64              3     512   0.2104     adam
💡 Patrones en el top 5:
  • LR ≈ 6e-4 a 1.3e-3 — zona estrecha, confirma que es el HP más sensible.
  • n_conv_blocks = 3 en todos — 3 bloques son suficientes para FashionMNIST.
  • n_filters = 64 preferido — más capacidad → mejor, hasta cierto punto.
  • optimizer = Adam en todos — SGD probablemente necesita más épocas o un scheduler.
  • dropout bajo (0.08-0.21) — poca regularización con datos abundantes y épocas cortas.

8.2 Visualización del espacio explorado (LR vs Accuracy)

Python scatter plot 2D del espacio
fig, ax = plt.subplots(figsize=(8, 5))

scatter = ax.scatter(
    df['lr'], df['val_acc'],
    c=range(len(df)), cmap='plasma',
    s=60, alpha=0.7, edgecolors='white', linewidth=0.5,
)
ax.set_xscale('log')
ax.set_xlabel('Learning Rate (log scale)', fontsize=12)
ax.set_ylabel('Val Accuracy', fontsize=12)
ax.set_title('TPE Exploration: LR vs Accuracy (color = trial order)', fontsize=13)
cbar = plt.colorbar(scatter, ax=ax, label='Trial #')

# Marcar el mejor
best_idx = df['val_acc'].idxmax()
ax.scatter(df.loc[best_idx, 'lr'], df.loc[best_idx, 'val_acc'],
           s=200, facecolors='none', edgecolors='#00b894', linewidth=3,
           label=f'Best: {df.loc[best_idx, "val_acc"]:.2%}')
ax.legend(fontsize=10)
plt.tight_layout()
plt.savefig('tpe_exploration.png', dpi=150)
plt.show()

8.3 Checklist de buenas prácticas

🟣 Antes de buscar

  • Haz un estudio previo (OFAT) para acotar rangos
  • Asegura train/val split correcto
  • Verifica que el pipeline funciona con 1 trial
  • Fija los HPs que sabes que no variarán
  • Usa loguniform para LR y weight decay

🟡 Durante la búsqueda

  • Empieza con random search (20-30 trials)
  • Sigue con TPE (30-50 trials más)
  • Monitoriza la curva "best so far"
  • Si la curva se estanca, ¿espacio muy amplio?
  • Captura errores en la función objetivo

🟢 Después de buscar

  • Reentrena el mejor modelo con más épocas
  • Evalúa en test una sola vez
  • Guarda los Trials para reproducibilidad
  • Documenta los rangos óptimos encontrados
  • Analiza qué HPs importaron más
import pickle

# Guardar
with open('tpe_trials.pkl', 'wb') as f:
    pickle.dump(tpe_trials, f)

# Cargar (para continuar o analizar después)
with open('tpe_trials.pkl', 'rb') as f:
    loaded_trials = pickle.load(f)

# Continuar la búsqueda desde donde se dejó
best = fmin(
    fn=objective,
    space=search_space,
    algo=tpe.suggest,
    max_evals=100,          # extender de 50 a 100
    trials=loaded_trials,   # warm-start con los 50 anteriores
)

Guardar los Trials también permite warm-starting: continuar una búsqueda que se interrumpió o extender el presupuesto cuando los resultados son prometedores.

CriterioHyperoptOptunaRay Tune
APIDefine-and-run (espacio completo)Define-by-run (dentro de trial)Define-and-run con wrappers
AlgoritmosTPE, RandomTPE, CMA-ES, Random, GPASHA, PBT, BayesOpt, etc.
PruningManual✅ Integrado (MedianPruner, Hyperband)✅ ASHA, Hyperband
ParalelismoMongoDB / SparkTrialsMulti-thread, RDB storageDistribuido nativo (Ray)
MadurezEstable, menos activoMuy activo, bien documentadoMuy activo, más complejo
Ideal paraAprender HPO, proyectos simplesProducción, investigaciónClústeres, escalabilidad

Recomendación: Hyperopt para aprender y proyectos pequeños. Optuna para producción e investigación seria. Ray Tune si necesitas escalar a múltiples GPUs/nodos.

8.4 Resumen del flujo completo

PasoQué hicimosResultado
1. Estudio previo OFAT sobre LR, arq, dropout LR es el HP más crítico
2. Espacio de búsqueda 8 HPs con distribuciones adecuadas Espacio rico pero acotado
3. Función objetivo Entrenar 10 épocas, eval en val ~30 seg/trial (GPU)
4. Random search 30 trials ~91.1% val accuracy
5. TPE bayesiano 50 trials ~92.2% val accuracy (+1.1%)
6. Reentrenamiento Mejor config, 30 épocas, eval en test ~92.9% test accuracy

8.5 Referencias y recursos

📄 Papers fundamentales

TemaPaperAño
TPE Bergstra et al. — Algorithms for Hyper-Parameter Optimization 2011
Random > Grid Bergstra & Bengio — Random Search for Hyper-Parameter Optimization 2012
Hyperopt library Bergstra et al. — Making a Science of Model Search 2013
Bayesian Optimization Frazier — A Tutorial on Bayesian Optimization 2018
BOHB Falkner et al. — BOHB: Robust and Efficient HPO at Scale 2018
Hyperband Li et al. — Hyperband: A Novel Bandit-Based Approach 2017
Optuna Akiba et al. — Optuna: A Next-generation HPO Framework 2019

📚 Documentación y repositorios

🛠️ Herramientas complementarias

🏁 Resumen final: Optimizar hiperparámetros no es magia — es un proceso sistemático: (1) estudiar qué HPs importan, (2) definir un espacio acotado y bien distribuido, (3) partir de random search como baseline, (4) aplicar bayesiano (TPE) para exprimir el presupuesto. Con 50 trials de TPE en FashionMNIST pasamos de ~89% (configuración manual) a ~93% (configuración optimizada) — una mejora de +4 puntos sin cambiar la arquitectura. Los conceptos que aprendiste aquí aplican a cualquier modelo, dataset y framework.