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.
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)
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.
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ía | Algoritmo principal | Espacio condicional | Paralelismo |
|---|---|---|---|
| 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 |
1.3 Nuestro plan
En este tutorial seguiremos este flujo:
- Cargar FashionMNIST y definir un modelo CNN+MLP configurable.
- Identificar qué hiperparámetros tienen mayor impacto (estudio previo).
- Definir el espacio de búsqueda con las primitivas de Hyperopt.
- Implementar la función objetivo que entrena y evalúa un modelo.
- Ejecutar random search para obtener un baseline.
- Ejecutar TPE (bayesiano) partiendo de los resultados previos.
- Analizar resultados y extraer conclusiones.
Setup: dataset, modelo base y librerías
2.1 Instalación
pip install torch torchvision hyperopt matplotlib numpy
2.2 Imports
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}")
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.
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]
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])
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.
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]
ConfigurableCNN (default): 229,130 params Output shape: torch.Size([4, 10])
out_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.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.
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:
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:
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%}")
=== 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%
- 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.
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)
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:
# 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.
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.hp.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
4.4 Definir el espacio de búsqueda completo
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}")
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
- 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]. Usahp.loguniform. - Olvidar np.log():
hp.loguniform('lr', 1e-4, 1e-1)muestreaexp(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.
| HP | Distribución | Rango | Justificación |
|---|---|---|---|
| lr | loguniform | [1e-4, 1e-1] | Varía en órdenes de magnitud |
| weight_decay | loguniform | [1e-6, 1e-2] | Ídem, escala logarítmica |
| optimizer | choice | {adam, sgd} | Categórico binario |
| batch_size | choice | {32, 64, 128, 256} | Potencias de 2, discreto |
| n_conv_blocks | choice | {2, 3} | Discreto, pocos valores |
| n_filters | choice | {16, 32, 64} | Potencias de 2, discreto |
| fc_dim | choice | {64, 128, 256, 512} | Potencias de 2, discreto |
| dropout | uniform | [0.0, 0.5] | Rango lineal acotado |
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
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}")
Test objective: val_acc=89.95%, loss=-0.8995
'loss': -val_acc: Hyperopt siempre minimiza. Para maximizar accuracy, negamos el valor. -0.90 es "mejor" que -0.85 (menor).Trials y puedes consultarla después.loss=0.0 (accuracy 0%) en vez de crashear toda la búsqueda.5.2 Decisiones clave en la función objetivo
| Decisión | Nuestra elección | Alternativa |
|---|---|---|
| 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) |
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".
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
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%}")
🎲 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%
algo=rand.suggest: Le dice a fmin que use muestreo aleatorio puro, sin modelo surrogate.space_eval: Convierte el resultado de fmin (que usa índices para hp.choice) a los valores reales del espacio.6.2 Analizar los 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?
# 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()
- 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.
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.
7.2 Ejecutar 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%}")
🧠 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%
algo=tpe.suggest: Una sola línea cambia random por bayesiano. La interfaz es idéntica.max_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
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%}")
📊 Resumen comparativo:
Random TPE
Trials 30 50
Mejor accuracy 91.13% 92.18%
Media accuracy 85.47% 89.32%
Std 6.12% 3.41%
- 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
# 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%})")
🔄 Reentrenando mejor configuración con 30 épocas... 📊 Test accuracy (mejor modelo, 30 épocas): 92.85% (Val accuracy durante HPO fue: 92.18%)
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
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))
📊 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
- 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)
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
loguniformpara 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.
| Criterio | Hyperopt | Optuna | Ray Tune |
|---|---|---|---|
| API | Define-and-run (espacio completo) | Define-by-run (dentro de trial) | Define-and-run con wrappers |
| Algoritmos | TPE, Random | TPE, CMA-ES, Random, GP | ASHA, PBT, BayesOpt, etc. |
| Pruning | Manual | ✅ Integrado (MedianPruner, Hyperband) | ✅ ASHA, Hyperband |
| Paralelismo | MongoDB / SparkTrials | Multi-thread, RDB storage | Distribuido nativo (Ray) |
| Madurez | Estable, menos activo | Muy activo, bien documentado | Muy activo, más complejo |
| Ideal para | Aprender HPO, proyectos simples | Producción, investigación | Clú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
| Paso | Qué hicimos | Resultado |
|---|---|---|
| 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
| Tema | Paper | Añ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
- Hyperopt (GitHub) — Repositorio oficial
- Hyperopt docs — Documentación oficial
- Optuna docs — Alternativa moderna con pruning integrado
- Ray Tune docs — HPO distribuido
- Keras Tuner — HPO para modelos Keras
- PyTorch Optimizers — Documentación de optimizadores
🛠️ Herramientas complementarias
- W&B Sweeps — HPO integrado con tracking de experimentos
- Ax (Meta) — Plataforma de optimización adaptativa
- Nevergrad (Meta) — Optimización gradient-free