Lección 5.5.4 · 75 min

Optuna y el riesgo de sobreajustar la validación

Buscar hiperparámetros es ajustar un modelo más, cuyo conjunto de entrenamiento es la validación. Esta lección monta una búsqueda reproducible con Optuna 5.0 y validación purgada sobre el panel sintético del nivel, mide cuánto sube el mejor de N intentos sin mejorar fuera de muestra y enseña las dos defensas: la validación anidada y contar los intentos en el Deflated Sharpe.

Fig. — Un estudio de Optuna de 50 intentos (LightGBM, TPE con semilla 0) sobre 100 acciones del panel: IC de validación purgada de cada intento en 2012-2018 y su IC en el periodo reservado 2019-2025. La escalera es el mejor hasta ese intento y lo que da ese mismo elegido fuera
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Elegir hiperparámetros también es ajustar

Un modelo de boosting (lección 5.5.3) tiene media docena de hiperparámetros que no se aprenden con el gradiente: tasa de aprendizaje, número de hojas, tamaño mínimo de hoja, fracción de features y de filas, regularización, número de rondas. Se eligen probando configuraciones y quedándose con la que mejor puntúa en una validación. Esa elección es un ajuste más, y su conjunto de entrenamiento es la validación: con suficientes intentos, la mejor configuración no es la mejor, es la que mejor encajó con el ruido de esas semanas concretas. Es el mismo fenómeno que el Sharpe del mejor de N backtests (lecciones 2.5.1 y 2.5.4) con otro disfraz.

En un problema con señal abundante (clasificar imágenes, por ejemplo), el sesgo es pequeño al lado de las diferencias reales entre configuraciones. En el panel del nivel, donde el oráculo tiene un IC de 0,03 (lección 5.1.1), las diferencias reales entre configuraciones razonables son del orden del ruido de la validación, y el sesgo de selección puede ser todo lo que se mide. La disciplina es la de siempre: tres papeles para los datos, y el último se mira una vez.

Datos de búsquedaValidación purgadaUn IC por intentoElegir el mejorReentrenarPeriodo reservadoUna sola medida
Fuente Mermaid
flowchart TB
  A[Datos de búsqueda] --> B[Validación purgada]
  B --> C[Un IC por intento]
  C --> D[Elegir el mejor]
  D --> E[Reentrenar]
  E --> F[Periodo reservado]
  F --> G[Una sola medida]

Optuna en cinco piezas

Optuna (licencia MIT; versión 5.0.0, de septiembre de 2026) organiza la búsqueda en cinco piezas:

PiezaQué esEn el código
EstudioLa búsqueda completa y su historialoptuna.create_study(direction=…, sampler=…)
Intento (trial)Una configuración evaluadael argumento trial de la función objetivo
EspacioLo que se puede proponer, definido al ejecutartrial.suggest_float, suggest_int, suggest_categorical
MuestreadorCómo se propone el siguiente intentoTPESampler, RandomSampler, GPSampler, CmaEsSampler
PodadorCuándo abandonar un intento que va malMedianPruner con trial.report y trial.should_prune

El muestreador por defecto es el TPE (Tree-structured Parzen Estimator): tras unos intentos aleatorios (n_startup_trials=10), separa los intentos en buenos y malos, ajusta una densidad a cada grupo y propone donde el cociente entre las dos es mayor. Desde la 5.0, para un solo objetivo usa por defecto la versión multivariante (modela los hiperparámetros juntos, no uno a uno) y el constant liar para búsquedas en paralelo; además, create_study ya no admite argumentos posicionales y las importancias usan PED-ANOVA en lugar de f-ANOVA (notas de la versión en los recursos).

Para que un estudio sea reproducible hacen falta tres cosas: semilla en el muestreador, un solo proceso (n_jobs=1, el valor por defecto de optimize; con varios hilos el orden de llegada de los resultados cambia lo que propone el TPE) y un modelo determinista. La primera celda lo comprueba con una función de respuesta conocida, un cuenco con el mínimo en (2,−1)(2, -1):

IN [01]python
import optuna

optuna.logging.set_verbosity(optuna.logging.WARNING)

def cuenco(trial):
    x = trial.suggest_float("x", -10, 10)
    y = trial.suggest_float("y", -10, 10)
    return (x - 2) ** 2 + (y + 1) ** 2

for nombre, muestreador in [
        ("TPE, semilla 0", optuna.samplers.TPESampler(seed=0)),
        ("TPE, semilla 0", optuna.samplers.TPESampler(seed=0)),
        ("aleatorio, semilla 0",
         optuna.samplers.RandomSampler(seed=0))]:
    estudio = optuna.create_study(direction="minimize",
                                  sampler=muestreador)
    estudio.optimize(cuenco, n_trials=40)
    p = estudio.best_params
    print(f"{nombre:<21} mejor {estudio.best_value:.4f}  "
          f"x={p['x']:.3f} y={p['y']:.3f}")
OUT [01]Un estudio en miniatura
TPE, semilla 0        mejor 0.0097  x=2.049 y=-0.915
TPE, semilla 0        mejor 0.0097  x=2.049 y=-0.915
aleatorio, semilla 0  mejor 0.4073  x=1.404 y=-1.228

Las dos ejecuciones con la misma semilla dan exactamente el mismo resultado, y el TPE llega en 40 intentos mucho más cerca del mínimo (0,0097) que la búsqueda aleatoria (0,4073). Es la situación favorable: la función no tiene ruido y la diferencia entre dos puntos es siempre real. En un modelo de trading, la función objetivo es un IC medido con unos cientos de semanas, y cada evaluación lleva su error.

El banco de pruebas

Los datos son los del panel sintético del nivel (README de public/curso/datos/nivel-5/): features semanales con efectos plantados de unos 5 pb por semana y una relación señal/ruido de uno a setenta. Para que la búsqueda tarde poco en el runner se usan 100 acciones elegidas con semilla fija (en un proyecto se usarían las 400 y más intentos). Las features y la etiqueta se pasan a rango normal de cada semana (lección 5.2.4) y la fuga revision_analistas queda fuera.

  • Búsqueda: 2012-2018, 364 semanas. La validación es un k-fold de 4 bloques de semanas con purga de una semana a cada lado (la etiqueta ret_sig_semana termina al cierre de la semana siguiente) y 4 semanas de embargo tras cada bloque, como en la lección 5.4.2.
  • Reservado: 2019-2025, 365 semanas. En un proyecto real no se tocaría hasta el final; aquí se mide en cada intento solo para enseñar lo que pasa fuera.
  • Referencias: una ridge sin ajuste (α = 1, prácticamente MCO con 27.000 filas) y el oráculo alfa_verdadero.
IN [02]python
import zlib
import numpy as np
import pandas as pd
from scipy.stats import norm
from sklearn.linear_model import Ridge

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet",
                    columns=["fecha", "ticker", "ret_sig_semana"])
v = pd.read_parquet("panel5_verdad.parquet",
                    columns=["fecha", "ticker", "alfa_verdadero"])
tickers = sorted(f["ticker"].unique())
rng = np.random.default_rng([2029, zlib.crc32(b"n5_optuna")])
elegidas = [tickers[i] for i in
            np.sort(rng.choice(400, 100, replace=False))]
d = (f[f["ticker"].isin(elegidas)]
     .merge(e, on=["fecha", "ticker"])
     .merge(v, on=["fecha", "ticker"])
     .dropna(subset=["ret_sig_semana"])
     .sort_values(["fecha", "ticker"]).reset_index(drop=True))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]

def g(s):
    """Rango normal de cada semana (lección 5.2.4); sin dato, 0."""
    por = s.groupby(d["fecha"])
    u = (por.rank() - 0.5) / por.transform("count")
    return np.nan_to_num(norm.ppf(u.to_numpy()))

X = np.column_stack([g(d[k]) for k in FE]).astype(np.float32)
y = g(d["ret_sig_semana"])                 # etiqueta en rangos
r = d["ret_sig_semana"].to_numpy()
alfa = d["alfa_verdadero"].to_numpy()
fechas = np.unique(d["fecha"])
semana = np.searchsorted(fechas, d["fecha"].to_numpy())
NB = int((fechas < np.datetime64("2019-01-01")).sum())
BUSQ = np.flatnonzero(semana < NB)         # 2012-2018
RES = np.flatnonzero(semana >= NB)         # 2019-2025, reservado
ENT = np.flatnonzero(semana < NB - 1)      # sin la última semana

def ic_sem(p, filas):
    """IC de rangos de cada semana (100 acciones por semana)."""
    P = p.reshape(-1, 100).argsort(1).argsort(1).astype(float)
    R = r[filas].reshape(-1, 100).argsort(1).argsort(1)
    R = R.astype(float)
    P -= P.mean(1, keepdims=True)
    R -= R.mean(1, keepdims=True)
    return (P * R).sum(1) / np.sqrt((P**2).sum(1) * (R**2).sum(1))

def pliegues(s0, s1, k, embargo=4):
    """k bloques de semanas en [s0, s1) con purga de una semana a
    cada lado y embargo tras el bloque (lección 5.4.2)."""
    out = []
    for b in np.array_split(np.arange(s0, s1), k):
        fuera = (semana >= b[0] - 1) & (semana <= b[-1] + 1 + embargo)
        dentro = (semana >= s0) & (semana < s1 - 1)
        out.append((np.flatnonzero(~fuera & dentro),
                    np.flatnonzero((semana >= b[0])
                                   & (semana <= b[-1]))))
    return out

PL = pliegues(0, NB, 4)

def ridge_ic(ent, pru):
    m = Ridge(alpha=1.0).fit(X[ent], y[ent])
    return ic_sem(m.predict(X[pru]), pru)

def resumen(serie):
    se = serie.std() / np.sqrt(len(serie))
    return f"{serie.mean():.4f} ± {se:.4f}"

ridge_val = np.concatenate([ridge_ic(a, b) for a, b in PL])
print(f"filas {len(d)}, semanas de búsqueda {NB}, "
      f"reservadas {len(fechas) - NB}")
print("tamaños (entreno, validación):",
      [(len(a), len(b)) for a, b in PL])
print("IC ridge   búsqueda (CV)", resumen(ridge_val),
      " reservado", resumen(ridge_ic(ENT, RES)))
print("IC oráculo búsqueda     ", resumen(ic_sem(alfa[BUSQ], BUSQ)),
      " reservado", resumen(ic_sem(alfa[RES], RES)))
OUT [02]El panel: 100 acciones, búsqueda y periodo reservado
filas 72900, semanas de búsqueda 364, reservadas 365
tamaños (entreno, validación): [(26700, 9100), (26600, 9100), (26600, 9100), (27200, 9100)]
IC ridge   búsqueda (CV) 0.0138 ± 0.0065  reservado 0.0172 ± 0.0062
IC oráculo búsqueda      0.0322 ± 0.0063  reservado 0.0248 ± 0.0069

Cada pliegue entrena con unas 26.700 filas y valida con 9.100 (91 semanas). La ridge da un IC de 0,0138 en la validación cruzada y 0,0172 en el reservado; el oráculo, 0,0322 y 0,0248. Fíjate en el error típico: ± 0,0065 para un IC medido con 364 semanas de 100 acciones. Cualquier diferencia entre configuraciones menor que eso no se distingue del azar con estos datos, y los niveles cambian entre periodos (el oráculo pierde un cuarto de su IC de un periodo al otro) aunque el modelo sea el mismo.

Un estudio de 50 intentos

La función objetivo entrena un LightGBM en cada pliegue, guarda las predicciones de validación y devuelve el IC medio de todas las semanas de validación. Tres detalles de implementación:

  • lgb.train con un Dataset construido una vez por pliegue: el binning de las features no depende de los hiperparámetros que se buscan, y rehacerlo en cada intento costaría casi tanto como entrenar. feature_pre_filter=False es necesario para poder cambiar min_data_in_leaf sobre el mismo Dataset.
  • trial.set_user_attr guarda información extra por intento. Aquí, el IC del periodo reservado con la media de los cuatro modelos de los pliegues; la variable oof guarda las predicciones de validación para el Deflated Sharpe.
  • Determinismo de LightGBM: seed=0, num_threads=4, deterministic=True, force_row_wise=True.
IN [03]python
import lightgbm as lgb

BASE = dict(objective="regression", bagging_freq=1, max_bin=63,
            seed=0, num_threads=4, deterministic=True,
            force_row_wise=True, verbose=-1)

def espacio(trial):
    p = dict(
        learning_rate=trial.suggest_float("learning_rate", 0.01, 0.3,
                                          log=True),
        num_leaves=trial.suggest_int("num_leaves", 4, 48, log=True),
        min_data_in_leaf=trial.suggest_int("min_data_in_leaf", 20,
                                           2000, log=True),
        feature_fraction=trial.suggest_float("feature_fraction",
                                             0.3, 1.0),
        bagging_fraction=trial.suggest_float("bagging_fraction",
                                             0.3, 1.0),
        lambda_l2=trial.suggest_float("lambda_l2", 1e-3, 100,
                                      log=True))
    return p, trial.suggest_int("rondas", 50, 200, step=50)

def datasets(pl):
    """Un Dataset de LightGBM por pliegue, construido una vez."""
    return [lgb.Dataset(X[a], y[a], free_raw_data=False,
                        params=dict(max_bin=63, verbose=-1,
                                    feature_pre_filter=False))
            .construct() for a, _ in pl]

DS = datasets(PL)
oof = {}   # predicciones de validación de cada intento

def objetivo(trial):
    p, n = espacio(trial)
    pred = np.empty(len(BUSQ))
    res = np.zeros(len(RES))
    for (ent, pru), ds in zip(PL, DS):
        b = lgb.train({**BASE, **p}, ds, num_boost_round=n)
        pred[pru] = b.predict(X[pru])
        res += b.predict(X[RES]) / len(PL)
    oof[trial.number] = pred
    # solo para la lección: un proyecto no miraría esto
    trial.set_user_attr("ic_reservado",
                        float(ic_sem(res, RES).mean()))
    return float(ic_sem(pred, BUSQ).mean())

estudio = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=0))
estudio.optimize(objetivo, n_trials=50)
mejor = estudio.best_trial
print(f"mejor intento: n.º {mejor.number}, IC de validación "
      f"{mejor.value:.4f}")
print({k: (round(v, 4) if isinstance(v, float) else v)
       for k, v in mejor.params.items()})
OUT [03]Cincuenta intentos con LightGBM y validación purgada
mejor intento: n.º 49, IC de validación 0.0109
{'learning_rate': 0.0119, 'num_leaves': 6, 'min_data_in_leaf': 92, 'feature_fraction': 0.7374, 'bagging_fraction': 0.5103, 'lambda_l2': 0.0024, 'rondas': 50}

El ganador es el último intento: hojas pequeñas (6), tasa de aprendizaje baja (0,012) y solo 50 rondas, es decir, un modelo muy suave. Su IC de validación es 0,0109, por debajo de la ridge sin ajuste (0,0138) en los mismos pliegues. Cincuenta configuraciones de un modelo flexible no alcanzan a la baseline lineal; la lección 5.5.1 ya había visto que en este panel casi toda la señal es lineal en los rangos.

Cuánto sube el mejor de N

La celda siguiente sigue la búsqueda intento a intento: el mejor IC de validación hasta el intento N, la mediana de los intentos hechos hasta entonces y, para el líder de cada momento, su IC en el periodo reservado.

IN [04]python
t = pd.DataFrame({
    "validacion": [x.value for x in estudio.trials],
    "reservado": [x.user_attrs["ic_reservado"]
                  for x in estudio.trials]})
lider = t["validacion"].expanding().apply(
    lambda s: s.idxmax()).astype(int)
filas = []
for n in (1, 5, 10, 20, 30, 40, 50):
    k = lider.iloc[n - 1]
    filas.append({
        "N": n, "mejor_validacion": t["validacion"].iloc[k],
        "mediana_validacion": t["validacion"].iloc[:n].median(),
        "elegido_reservado": t["reservado"].iloc[k],
        "mediana_reservado": t["reservado"].iloc[:n].median()})
print(pd.DataFrame(filas).set_index("N").round(4))
rho = t[["validacion", "reservado"]].corr(method="spearman").iloc[0, 1]
pos = (t["reservado"] > t["reservado"].iloc[mejor.number]).sum() + 1
print(f"correlación de rangos validación-reservado: {rho:.2f}")
print(f"el elegido queda {pos}.º de 50 en el periodo reservado")
OUT [04]Cuánto sube el mejor de N y qué queda fuera
    mejor_validacion  mediana_validacion  elegido_reservado  mediana_reservado
N
1            -0.0081             -0.0081             0.0120             0.0120
5             0.0021             -0.0038             0.0102             0.0120
10            0.0064              0.0010             0.0153             0.0124
20            0.0092              0.0033             0.0086             0.0128
30            0.0092              0.0044             0.0086             0.0126
40            0.0092              0.0045             0.0086             0.0128
50            0.0109              0.0046             0.0131             0.0129
correlación de rangos validación-reservado: 0.07
el elegido queda 22.º de 50 en el periodo reservado

La figura de la cabecera dibuja la tabla completa. Arriba, la escalera del mejor sube de −0,0081 a 0,0109: cada escalón es un intento que tuvo más suerte que los anteriores con las semanas de validación. Abajo, el mismo elegido en el periodo reservado sube y baja sin tendencia (de 0,0086 a 0,0153). Tres medidas lo resumen:

  • Ventaja del elegido sobre el intento típico: en validación, 0,0109 frente a una mediana de 0,0046, es decir, 0,0063. En el reservado, 0,0131 frente a 0,0129: 0,0002.
  • Correlación de rangos entre el IC de validación y el del reservado en los 50 intentos: 0,07. Ordenar las configuraciones por su validación no dice casi nada de cómo se ordenan fuera.
  • Puesto del elegido en el reservado: 22.º de 50.

El nivel absoluto, en cambio, sube del periodo de búsqueda al reservado (todos los intentos dan más IC fuera que en validación), por la misma razón que la ridge y el oráculo cambian entre periodos. Por eso el sesgo de selección se mide contra los otros intentos del mismo periodo y no restando dos periodos distintos.

El máximo esperado de N intentos

¿Cuánto debería subir el mejor de N si ninguna configuración fuera mejor que otra? La lección 2.5.4 dio la aproximación para el máximo de N normales independientes con la constante de Euler-Mascheroni γ\gamma:

E[max⁡n≤NZn]≈(1−γ) Φ−1 ⁣(1−1N)+γ Φ−1 ⁣(1−1Ne)E\left[\max_{n \le N} Z_n\right] \approx (1-\gamma)\,\Phi^{-1}\!\left(1-\tfrac{1}{N}\right) + \gamma\,\Phi^{-1}\!\left(1-\tfrac{1}{N e}\right)

La escala que multiplica a ese factor es la pregunta interesante. Hay dos candidatas: el error típico del IC de un intento (cuánto se movería si se repitiera con otras semanas) y la dispersión de los IC entre intentos.

IN [05]python
def max_esperado(n):
    """E[max] de n normales tipificadas (aprox. de 2.5.4)."""
    if n == 1:
        return 0.0
    gm = np.euler_gamma
    return ((1 - gm) * norm.ppf(1 - 1 / n)
            + gm * norm.ppf(1 - 1 / (n * np.e)))

se_val = np.mean([ic_sem(oof[k], BUSQ).std() for k in oof]) \
    / np.sqrt(NB)
disp = t["validacion"].std()
print(f"error típico del IC de validación de un intento: {se_val:.4f}")
print(f"dispersión del IC de validación entre intentos:  {disp:.4f}")
for n in (5, 10, 50, 200, 1000):
    print(f"N = {n:>4}: el mejor sube {max_esperado(n):.2f} "
          f"dispersiones = {max_esperado(n) * disp:.4f} de IC")
print(f"medido con N = 50: mejor - media = "
      f"{t['validacion'].max() - t['validacion'].mean():.4f}")
OUT [05]El máximo esperado de N intentos sin diferencias
error típico del IC de validación de un intento: 0.0063
dispersión del IC de validación entre intentos:  0.0038
N =    5: el mejor sube 1.19 dispersiones = 0.0046 de IC
N =   10: el mejor sube 1.57 dispersiones = 0.0060 de IC
N =   50: el mejor sube 2.28 dispersiones = 0.0087 de IC
N =  200: el mejor sube 2.77 dispersiones = 0.0106 de IC
N = 1000: el mejor sube 3.26 dispersiones = 0.0125 de IC
medido con N = 50: mejor - media = 0.0071

El error típico de un intento es 0,0063, pero los 50 intentos solo se separan 0,0038 entre sí. La diferencia es que todos se miden con las mismas semanas: si un tramo de la validación favorece a los modelos de momentum, favorece a casi todos los intentos a la vez, y esa parte común no cambia cuál gana. Lo que el máximo aprovecha es la parte que los distingue. Con la dispersión, la fórmula prevé una subida de 0,0087 con 50 intentos; la medida (mejor menos media) es 0,0071. El TPE no propone configuraciones independientes, sino cada vez más parecidas a las buenas, lo que reduce algo el máximo frente al de 50 independientes.

La tabla da la regla práctica: el sesgo crece como 2ln⁡N\sqrt{2 \ln N}, rápido al principio y despacio después. Pasar de 5 a 50 intentos añade 1,1 dispersiones; de 50 a 1.000, 1,0. Un presupuesto de miles de intentos en un problema con esta relación señal/ruido compra sobre todo sesgo.

Importancia de los hiperparámetros

Optuna estima qué hiperparámetros explican el valor de la función objetivo. En la 5.0, el evaluador por defecto es PED-ANOVA, que compara la distribución de cada hiperparámetro en los mejores intentos (el 10 % superior, 5 de 50) con la de todos. El argumento target permite calcularla con otra medida, aquí el IC reservado; como PED-ANOVA busca siempre valores bajos con target, se le pasa con el signo cambiado, y el aviso que lo recuerda se silencia por su mensaje.

IN [06]python
import warnings

# con `target`, PED-ANOVA avisa de que busca valores bajos: el signo
# menos ya lo tiene en cuenta
warnings.filterwarnings("ignore", message=".*importances of params")
imp_val = optuna.importance.get_param_importances(estudio)
imp_res = optuna.importance.get_param_importances(
    estudio, target=lambda x: -x.user_attrs["ic_reservado"])
pd.DataFrame({"validación": imp_val, "reservado": imp_res}).round(2)
OUT [06]¿Qué hiperparámetro importa? Depende del periodo
                  validación  reservado
lambda_l2               0.47       0.62
min_data_in_leaf        0.16       0.06
feature_fraction        0.10       0.01
rondas                  0.08       0.01
num_leaves              0.08       0.03
learning_rate           0.07       0.04
bagging_fraction        0.05       0.23

Las dos columnas coinciden en la primera (lambda_l2) y se separan en el resto: min_data_in_leaf explica el 16 % con la validación y el 6 % con el reservado; bagging_fraction, el 5 % y el 23 %. Con 50 intentos y un objetivo de este ruido, la importancia de un hiperparámetro es otra estimación ruidosa, calculada con los mismos datos que se están sobreajustando. Sirve para acotar el espacio de una búsqueda siguiente; no es un hallazgo sobre el mercado.

Validación anidada

La defensa limpia contra el sesgo de selección es no usar el valor del ganador como estimación. En la validación anidada, la búsqueda completa se repite dentro de cada periodo de prueba, solo con los datos anteriores:

Periodo de pruebaDatos anterioresEstudio purgadoMejores parámetrosReentrenarIC en el periodo kUnir periodos
Fuente Mermaid
flowchart TB
  A[Periodo de prueba] --> B[Datos anteriores]
  B --> C[Estudio purgado]
  C --> D[Mejores parámetros]
  D --> E[Reentrenar]
  E --> F[IC en el periodo k]
  F --> G[Unir periodos]

El IC del ganador interno solo sirve para elegir; lo que se informa es el IC del periodo k, que nunca intervino en la elección. La cifra resultante mide el procedimiento (buscar y luego entrenar), no una configuración concreta, y es la que se compara con la baseline. Aquí, tres periodos (2019-2020, 2021-2022 y 2023-2025), cada uno con un estudio de 10 intentos y 3 pliegues purgados sobre todo lo anterior (en producción serían más intentos y periodos anuales).

IN [07]python
cortes = [np.datetime64(x) for x in
          ("2019-01-01", "2021-01-01", "2023-01-01", "2026-01-01")]
filas, ext, rid = [], [], []
for i in range(3):
    s0 = int((fechas < cortes[i]).sum())
    s1 = int((fechas < cortes[i + 1]).sum())
    pl = pliegues(0, s0, 3)
    ds = datasets(pl)

    def interno(trial):
        p, n = espacio(trial)
        ics = [ic_sem(lgb.train({**BASE, **p}, dk, num_boost_round=n)
                      .predict(X[pru]), pru)
               for (_, pru), dk in zip(pl, ds)]
        return float(np.concatenate(ics).mean())

    est_i = optuna.create_study(
        direction="maximize",
        sampler=optuna.samplers.TPESampler(seed=0))
    est_i.optimize(interno, n_trials=10)
    ent = np.flatnonzero(semana < s0 - 1)
    pru = np.flatnonzero((semana >= s0) & (semana < s1))
    p = dict(est_i.best_params)
    n = p.pop("rondas")
    b = lgb.train({**BASE, **p}, lgb.Dataset(X[ent], y[ent]),
                  num_boost_round=n)
    ext.append(ic_sem(b.predict(X[pru]), pru))
    rid.append(ridge_ic(ent, pru))
    anios = pd.DatetimeIndex(fechas[[s0, s1 - 1]]).year
    filas.append({"periodo": f"{anios[0]}-{anios[1]}",
                  "mejor_interno": est_i.best_value,
                  "lightgbm_fuera": ext[-1].mean(),
                  "ridge_fuera": rid[-1].mean(),
                  "oraculo": ic_sem(alfa[pru], pru).mean()})
print(pd.DataFrame(filas).set_index("periodo").round(4))
ext, rid = np.concatenate(ext), np.concatenate(rid)
print("anidada 2019-2025: LightGBM", resumen(ext),
      " ridge", resumen(rid), " diferencia", resumen(ext - rid))
OUT [07]Validación anidada: un estudio dentro de cada periodo
           mejor_interno  lightgbm_fuera  ridge_fuera  oraculo
periodo
2019-2020         0.0068          0.0040       0.0012   0.0251
2021-2022         0.0071          0.0132       0.0138   0.0222
2023-2025         0.0152          0.0122       0.0241   0.0262
anidada 2019-2025: LightGBM 0.0101 ± 0.0063  ridge 0.0146 ± 0.0065  diferencia -0.0045 ± 0.0042
Fig. — Validación anidada en 100 acciones del panel: en cada periodo, un estudio de Optuna de 10 intentos con validación purgada sobre todo lo anterior elige los hiperparámetros de LightGBM; se compara su IC de validación con el que da fuera, con la ridge sin ajuste y con el oráculo

En dos de los tres periodos, el LightGBM elegido da fuera menos de lo que prometía su validación interna (0,0040 frente a 0,0068; 0,0122 frente a 0,0152), y en el otro, más (0,0132 frente a 0,0071): el mejor interno no predice el resultado de su periodo. En conjunto, la búsqueda anidada da un IC de 0,0101 ± 0,0063 en 2019-2025; la media de los tres mejores internos, ponderada por semanas, es 0,0105, así que aquí los errores se compensan. Con 10 intentos por periodo (los 10 de arranque aleatorio del TPE) el sesgo de selección es menor que con 50, y la anidada tiene su propio ruido: lo que importa es que su cifra es honesta por construcción. La ridge sin ajuste da 0,0146 ± 0,0065 en las mismas semanas: la diferencia emparejada, −0,0045 ± 0,0042, no es significativa, pero va en contra del modelo con búsqueda. Nadie pasa del oráculo (0,022-0,026).

Contar los intentos: el Deflated Sharpe

Si se presenta el mejor de una búsqueda, su métrica tiene que descontar cuántas configuraciones se probaron. La herramienta del nivel 2 es el Deflated Sharpe (lección 2.5.4); la lección 5.4.4 lo aplica a las señales de ML. Cada intento del estudio dejó sus predicciones de validación en oof: con ellas se construye, para cada uno, la cartera larga-corta semanal (10 acciones largas y 10 cortas de las 100, pesos de ±0,1, rendimientos simples) de 2012-2018.

IN [08]python
def cartera(p, filas, coste_pb=10):
    """Larga 10, corta 10 de cada semana, pesos ±0,1 (bruto 2).
    Devuelve rendimientos semanales bruto y neto de 10 pb/lado."""
    P, R = p.reshape(-1, 100), r[filas].reshape(-1, 100)
    o = P.argsort(1)
    w = np.zeros_like(R)
    np.put_along_axis(w, o[:, -10:], 0.1, 1)
    np.put_along_axis(w, o[:, :10], -0.1, 1)
    bruto = (w * R).sum(1)
    rot = np.abs(np.diff(w, axis=0, prepend=0)).sum(1)
    return bruto, bruto - coste_pb / 1e4 * rot

def psr(sr, umbral, t, asim, curt):
    """Probabilistic Sharpe Ratio (lección 2.5.4)."""
    den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr**2)
    return norm.cdf((sr - umbral) * np.sqrt(t - 1) / den)

S = pd.DataFrame({k: cartera(p, BUSQ)[0] for k, p in oof.items()})
sr = S.mean() / S.std()                    # Sharpe semanal
k = sr.idxmax()
T, asim, curt = len(S), S[k].skew(), S[k].kurt() + 3
print(f"Sharpe anual bruto de los 50 intentos: mediana "
      f"{sr.median() * np.sqrt(52):.2f}, máximo "
      f"{sr[k] * np.sqrt(52):.2f} (intento {k})")
print(f"PSR(0) del máximo: {psr(sr[k], 0, T, asim, curt):.3f}")
autoval = np.linalg.eigvalsh(S.corr().to_numpy())
n_ef = autoval.sum() ** 2 / (autoval**2).sum()
for n in (50, round(n_ef)):
    sr0 = np.sqrt(sr.var()) * max_esperado(n)
    print(f"N = {n:>2}: SR_0 = {sr0 * np.sqrt(52):.2f} anual, "
          f"DSR = {psr(sr[k], sr0, T, asim, curt):.3f}")
OUT [08]Contar los intentos: Sharpe y Deflated Sharpe
Sharpe anual bruto de los 50 intentos: mediana -0.06, máximo 0.55 (intento 11)
PSR(0) del máximo: 0.927
N = 50: SR_0 = 0.55 anual, DSR = 0.491
N =  3: SR_0 = 0.21 anual, DSR = 0.816
Fig. — Deflated Sharpe del mejor de los 50 intentos del estudio (Sharpe anual bruto 0,55 en la validación de 2012-2018) según cuántos intentos se cuenten: con N = 1 es el PSR(0); con los 50 que se hicieron, 0,49

Los 50 intentos tienen un Sharpe anual bruto mediano de −0,06; el mejor, el intento 11, de 0,55. Aislado, su PSR(0) es 0,927: no llega al 0,95 habitual ni siquiera sin descontar nada. Con N = 50, el umbral SR0SR_0 es justo 0,55 anual y el Deflated Sharpe cae a 0,491: el mejor Sharpe de la búsqueda es exactamente lo que se espera del mejor de 50 intentos sin ventaja.

¿Son 50 intentos? Las 50 carteras están muy correlacionadas entre sí (son variantes del mismo modelo con las mismas features), así que equivalen a menos intentos independientes. Una aproximación sencilla es el número efectivo por la razón de participación de los autovalores de su matriz de correlación, Nef=(∑λi)2/∑λi2N_{ef} = (\sum \lambda_i)^2 / \sum \lambda_i^2, que aquí da unos 3. Con N = 3 el DSR sube a 0,816, y sigue por debajo de 0,95. La figura muestra el recorrido completo. López de Prado propone una estimación más cuidadosa, agrupando los intentos por clústeres y contando los clústeres; la regla que no cambia es que se cuentan todos los intentos ejecutados, también los de estudios anteriores con los mismos datos y los podados.

La celda siguiente cierra el círculo en el periodo reservado: el elegido por IC, el elegido por Sharpe, la ridge y el oráculo, cada uno reentrenado con toda la búsqueda (sin la última semana, cuya etiqueta llega a 2019) y medido una vez, con 10 pb por lado sobre la rotación.

IN [09]python
def modelo_final(params):
    p = dict(params)
    n = p.pop("rondas")
    return lgb.train({**BASE, **p}, lgb.Dataset(X[ENT], y[ENT]),
                     num_boost_round=n)

ridge_fin = Ridge(alpha=1.0).fit(X[ENT], y[ENT])
candidatos = {
    f"elegido por IC (n.º {mejor.number})":
        modelo_final(mejor.params).predict(X[RES]),
    f"elegido por Sharpe (n.º {k})":
        modelo_final(estudio.trials[k].params).predict(X[RES]),
    "ridge sin ajuste": ridge_fin.predict(X[RES]),
    "oráculo": alfa[RES]}
filas = {}
for nombre, p in candidatos.items():
    bruto, neto = cartera(p, RES)
    filas[nombre] = {"IC": ic_sem(p, RES).mean(),
                     "sharpe_bruto": bruto.mean() / bruto.std()
                     * np.sqrt(52),
                     "sharpe_neto": neto.mean() / neto.std()
                     * np.sqrt(52)}
pd.DataFrame(filas).T.round(3)
OUT [09]El elegido, el de mejor Sharpe y la ridge en 2019-2025
                                IC  sharpe_bruto  sharpe_neto
elegido por IC (n.º 49)      0.017         0.830       -0.087
elegido por Sharpe (n.º 11)  0.017         0.502       -0.463
ridge sin ajuste             0.017         0.828       -0.010
oráculo                      0.025         1.216        0.220

El elegido por IC, reentrenado con todo 2012-2018, da un IC de 0,017 en el reservado (más que la media de sus cuatro modelos de pliegue, 0,0131, porque entrena con más datos) y un Sharpe bruto de 0,83, el mismo que la ridge sin ajuste. El elegido por Sharpe, el intento 11, da 0,50 bruto. Netos de costes, los tres pierden (−0,09, −0,46 y −0,01): una cartera de 10 y 10 acciones que se rehace cada semana rota demasiado para una señal de este tamaño (la lección 5.1.3 lo mide con el panel completo). Ni el oráculo pasa de 0,22 neto.

Poda: menos cómputo, los mismos intentos

Un podador abandona los intentos que van peor que los demás antes de terminarlos. Con validación cruzada, el paso natural es el pliegue: tras cada uno, trial.report comunica el IC acumulado y trial.should_prune decide. El MedianPruner poda si el valor intermedio queda por debajo de la mediana de los intentos anteriores en ese paso (n_warmup_steps=1: nunca tras el primer pliegue). Con boosting también se puede informar por rondas, pero entonces la decisión se toma mirando la validación durante el entrenamiento, que es otra forma de usarla para elegir.

IN [10]python
def objetivo_poda(trial):
    p, n = espacio(trial)
    ics = []
    for i, ((_, pru), ds) in enumerate(zip(PL, DS)):
        b = lgb.train({**BASE, **p}, ds, num_boost_round=n)
        ics.append(ic_sem(b.predict(X[pru]), pru))
        trial.report(float(np.concatenate(ics).mean()), i)
        if trial.should_prune():
            raise optuna.TrialPruned()
    return float(np.concatenate(ics).mean())

est_p = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=1),
    pruner=optuna.pruners.MedianPruner(n_startup_trials=5,
                                       n_warmup_steps=1))
est_p.optimize(objetivo_poda, n_trials=20)
estados = pd.Series([x.state.name for x in est_p.trials])
print(estados.value_counts().to_string())
print(f"pliegues entrenados: "
      f"{sum(len(x.intermediate_values) for x in est_p.trials)}"
      f" de {20 * len(PL)}")
print(f"mejor IC de validación: {est_p.best_value:.4f}")
OUT [10]Poda: menos cómputo, los mismos intentos
COMPLETE    14
PRUNED       6
pliegues entrenados: 69 de 80
mejor IC de validación: 0.0097

Se podaron 6 de 20 intentos y se entrenaron 69 pliegues de 80: un ahorro modesto, porque con cuatro pliegues hay poco que cortar (el ahorro es mayor con modelos lentos y pasos por épocas o rondas). Dos lecturas más. Los podados son intentos: se miraron, y cuentan para el Deflated Sharpe. Y este segundo estudio, con otra semilla del muestreador y 20 intentos, se queda en un mejor IC de validación de 0,0097, frente al 0,0109 del primero con 50: el valor del ganador depende del presupuesto y hasta de la semilla con la que se buscó.

Para estudios largos, Optuna guarda el historial en una base de datos y permite retomarlo o repartirlo entre procesos (con n_jobs > 1 o varios procesos, el resultado deja de ser reproducible intento a intento). El código, sin ejecutar porque escribe un fichero:

IN [11]python
estudio = optuna.create_study(
    study_name="lgbm-panel5",
    storage="sqlite:///busquedas.db",   # o JournalStorage
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=0),
    load_if_exists=True)                # retoma si ya existe
estudio.optimize(objetivo, n_trials=200, timeout=3600)
# en otra terminal: optuna-dashboard sqlite:///busquedas.db

La base de datos tiene una ventaja que no es técnica: es el registro de intentos que el Deflated Sharpe necesita. Si un estudio se borra y se empieza otro con un espacio corregido, los intentos del primero siguen contando.

Errores típicos

  • Informar del mejor valor del estudio como rendimiento esperado. Es el máximo de N estimaciones ruidosas: sesgado al alza en unas 2,3 dispersiones con 50 intentos.
  • Validación sin purga dentro de la función objetivo. Un k-fold barajado (lección 5.4.1) no solo infla la validación: premia a las configuraciones que mejor memorizan, como hojas pequeñas y muchas rondas.
  • Early stopping con el pliegue de validación. Elegir el número de rondas mirando la validación es un hiperparámetro más elegido con los mismos datos; hace falta una partición interna o contarlo como intento.
  • Paralelizar sin saberlo. n_jobs=-1 hace el estudio irreproducible. Con un solo proceso y semilla, dos ejecuciones dan lo mismo.
  • Ajustar sin baseline. 50 configuraciones de LightGBM no alcanzan a una ridge sin ajuste en este panel; sin la ridge al lado, el 0,0109 parecería un resultado.
  • Olvidar los intentos que no salen en el informe. Estudios borrados, espacios cambiados, intentos podados: todos cuentan para descontar la selección.

Ejercicios

  1. Busca solo la semilla de LightGBM (de 0 a 10.000) con 20 intentos de un RandomSampler, con los demás hiperparámetros fijos y bagging y fracción de features del 50 %. ¿Cuánto se mueve el IC de validación? ¿Qué da en el reservado la mejor semilla?
Solución
IN [12]python
fijos = dict(learning_rate=0.05, num_leaves=8, min_data_in_leaf=200,
             feature_fraction=0.5, bagging_fraction=0.5,
             lambda_l2=1.0)

def objetivo_semilla(trial):
    s = trial.suggest_int("semilla", 0, 10_000)
    pred = np.empty(len(BUSQ))
    res = np.zeros(len(RES))
    for (_, pru), ds in zip(PL, DS):
        b = lgb.train({**BASE, **fijos, "seed": s}, ds,
                      num_boost_round=100)
        pred[pru] = b.predict(X[pru])
        res += b.predict(X[RES]) / len(PL)
    trial.set_user_attr("res", float(ic_sem(res, RES).mean()))
    return float(ic_sem(pred, BUSQ).mean())

est_s = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.RandomSampler(seed=0))
est_s.optimize(objetivo_semilla, n_trials=20)
val = pd.Series([x.value for x in est_s.trials])
res = pd.Series([x.user_attrs["res"] for x in est_s.trials])
print(f"validación: de {val.min():.4f} a {val.max():.4f}; "
      f"el mejor, en el reservado: {res[val.idxmax()]:.4f} "
      f"(mediana {res.median():.4f})")
OUT [12]La semilla como hiperparámetro
validación: de -0.0091 a 0.0033; el mejor, en el reservado: 0.0131 (mediana 0.0131)

La semilla no tiene ningún contenido y aun así mueve el IC de validación de −0,0091 a 0,0033, un rango de 0,0124, tres veces la dispersión de los 50 intentos del estudio principal (0,0038). La «mejor semilla» da 0,0131 en el reservado, exactamente la mediana de las 20: lo que cabe esperar de una semilla cualquiera. Es una buena vara para cualquier búsqueda: si la diferencia entre dos configuraciones es menor que lo que mueve la semilla, no es una diferencia.

  1. Calcula el Deflated Sharpe del mejor intento para N = 2, 3, 5, 10, 20 y 50. ¿Con cuántos intentos dejaría de pasar un umbral de 0,95?
Solución
IN [13]python
for n in (2, 3, 5, 10, 20, 50):
    sr0 = np.sqrt(sr.var()) * max_esperado(n)
    print(f"N = {n:>2}: DSR = {psr(sr[k], sr0, T, asim, curt):.3f}")
OUT [13]¿Cuántos intentos aguanta este Sharpe?
N =  2: DSR = 0.868
N =  3: DSR = 0.816
N =  5: DSR = 0.752
N = 10: DSR = 0.667
N = 20: DSR = 0.587
N = 50: DSR = 0.491

Ni con N = 2 pasa (0,868): ya el PSR(0) del intento aislado, 0,927, quedaba por debajo. Con 0,55 de Sharpe anual y siete años de datos semanales, la estimación es demasiado imprecisa para distinguirla del cero, y cada intento más la aleja. Para pasar el 0,95 con N = 50 haría falta un Sharpe bastante mayor que el umbral SR0SR_0 de 0,55, o más años.

  1. Un compañero ajusta los hiperparámetros con Optuna sobre 2012-2025 con un purged k-fold de 5 pliegues, obtiene un IC de 0,021 con el mejor intento y lo presenta como lo que dará el modelo en 2026. ¿Qué está mal y cómo lo arreglarías sin datos nuevos?
Solución

El 0,021 es el máximo de N estimaciones con los mismos datos que sirvieron para elegir: está sesgado al alza, tanto más cuantos más intentos, y no hay periodo reservado que lo corrija. Dos arreglos con los mismos datos. El completo es la validación anidada: repetir el estudio dentro de cada periodo de prueba (por ejemplo, años de 2019 a 2025) usando solo lo anterior, e informar del IC de esos periodos. El barato es contar los intentos (todos, también los de estudios anteriores y los podados) y descontarlos: el Deflated Sharpe de la cartera del mejor intento con N igual al número de intentos, o el máximo esperado de N intentos con la dispersión entre ellos para el IC. En los dos casos, la cifra se pone al lado de una baseline sin ajuste medida igual; y el número de 2026 seguirá teniendo el error típico de un año de datos.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Tras 50 intentos, el mejor IC de validación es 0,0109 y la mediana de los intentos, 0,0046. En el periodo reservado, el elegido da 0,0131 y la mediana de los 50, 0,0129. ¿Qué se concluye?

    • AQue la búsqueda funcionó: el elegido mejora fuera de muestra respecto a su validación
    • BQue hay que hacer más intentos
    • CQue la ventaja del elegido sobre el intento típico (0,0063 en validación) desaparece fuera (0,0002): era selección del ruido de la validación, no una configuración mejor
    • DQue el periodo reservado está mal elegido
    Ver respuesta

    Respuesta C. Que la ventaja del elegido sobre el intento típico (0,0063 en validación) desaparece fuera (0,0002): era selección del ruido de la validación, no una configuración mejor

    Los niveles absolutos cambian entre periodos (el reservado tiene más datos de entrenamiento y otros regímenes), así que se compara contra el intento típico del mismo periodo. Dentro de la validación el elegido destaca; fuera es uno más (puesto 22 de 50).

  2. El IC de validación de un intento tiene un error típico de 0,0063, pero los 50 intentos solo se dispersan 0,0038 entre sí. ¿Por qué, y cuál de las dos cifras predice cuánto sube el mejor de N?

    • APorque Optuna promedia los intentos; la primera
    • BPorque el TPE descarta los peores; ninguna
    • CPorque el error típico está mal calculado; la segunda
    • DPorque todos los intentos se evalúan con las mismas semanas y su error de muestreo es en buena parte común; para el máximo importa la dispersión entre intentos, la segunda
    Ver respuesta

    Respuesta D. Porque todos los intentos se evalúan con las mismas semanas y su error de muestreo es en buena parte común; para el máximo importa la dispersión entre intentos, la segunda

    La parte común del error mueve a todos los intentos a la vez y no cambia cuál es el mejor. Lo que el máximo explota es la parte que los distingue. Con 50 intentos se esperan unas 2,3 dispersiones de subida (0,0087); se midió 0,0071.

  3. ¿Qué hace la validación anidada que no haga un estudio con validación purgada?

    • AUsa más intentos
    • BElige los hiperparámetros con los datos anteriores a cada periodo de prueba y mide el modelo elegido en ese periodo, que nunca intervino en la elección: la cifra resultante incluye el coste de elegir
    • CElimina la necesidad de la purga
    • DGarantiza que LightGBM supere a la ridge
    Ver respuesta

    Respuesta B. Elige los hiperparámetros con los datos anteriores a cada periodo de prueba y mide el modelo elegido en ese periodo, que nunca intervino en la elección: la cifra resultante incluye el coste de elegir

    El mejor valor de un estudio es una estimación sesgada al alza. En la anidada, ese valor solo sirve para elegir; lo que se reporta es el IC fuera. Aquí, el mejor interno no predice el resultado de su periodo (dos veces por encima, una por debajo) y el procedimiento completo da 0,0101, 0,0045 por debajo de la ridge sin ajuste.

  4. El mejor Sharpe de 50 intentos tiene un PSR(0) de 0,927 y un Deflated Sharpe de 0,491 con N = 50. Si alguien solo informa del intento ganador, ¿qué N habría que usar?

    • ATodos los intentos que se ejecutaron, podados incluidos, y los de estudios anteriores con los mismos datos; si están muy correlacionados, un N efectivo menor, justificado
    • BN = 1, porque solo se presenta uno
    • CSolo los intentos que terminaron
    • DN = 50 dividido entre el número de hiperparámetros
    Ver respuesta

    Respuesta A. Todos los intentos que se ejecutaron, podados incluidos, y los de estudios anteriores con los mismos datos; si están muy correlacionados, un N efectivo menor, justificado

    El DSR descuenta la selección: lo que cuenta es cuántas veces se miró. Un N efectivo (aquí ≈ 3 por la correlación entre intentos) sube el DSR a 0,82, pero ni así llega a 0,95.

  5. Un estudio busca solo la semilla de LightGBM (bagging del 50 %) y el IC de validación va de −0,0091 a 0,0033. ¿Qué indica?

    • AQue la semilla es un hiperparámetro importante
    • BQue hay que usar siempre la semilla 0
    • CQue el bagging no funciona en finanzas
    • DQue el azar del propio entrenamiento mueve la validación más que muchos hiperparámetros: cualquier diferencia de ese tamaño entre configuraciones es indistinguible del ruido
    Ver respuesta

    Respuesta D. Que el azar del propio entrenamiento mueve la validación más que muchos hiperparámetros: cualquier diferencia de ese tamaño entre configuraciones es indistinguible del ruido

    La semilla no tiene contenido: el mejor de 20 semillas da 0,0131 en el reservado, exactamente la mediana de las 20. Es la vara de medir del ruido de la búsqueda.

Ideas clave

  • Optuna es determinista con TPESampler(seed=…), n_jobs=1 y un modelo determinista. En la 5.0, create_study solo admite argumentos con nombre y el TPE es multivariante por defecto.
  • El IC de validación del mejor de N intentos sube con N aunque ninguna configuración sea mejor: en el panel, de −0,0081 a 0,0109 en 50 intentos, mientras el mismo elegido queda 22.º de 50 en el periodo reservado.
  • El máximo esperado de N intentos sin diferencias es la dispersión entre intentos por el factor de la lección 2.5.4 (2,28 con N = 50): 0,0087 previsto frente a 0,0071 medido.
  • La validación anidada mide el procedimiento completo, elección incluida. LightGBM ajustado da 0,0101 fuera y la ridge sin ajuste, 0,0146: la búsqueda no compensa la baseline.
  • Todos los intentos cuentan, también los podados. El mejor Sharpe de la búsqueda (0,55 anual) pasa de un PSR(0) de 0,93 a un Deflated Sharpe de 0,49 con N = 50.
  • Las importancias de hiperparámetros y la poda tampoco escapan al ruido: se calculan con la misma validación que se está sobreajustando.

Recursos

Datos