---
title: "5.5.4 Optuna y el sobreajuste de la validación"
description: "Optuna 5.0 con semilla fija y validación purgada sobre un panel sintético: cuánto infla el mejor de N intentos, validación anidada y Deflated Sharpe."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/optuna-y-el-sobreajuste-de-la-validacion/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/optuna-y-el-sobreajuste-de-la-validacion.png"
---

# 5.5.4 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.5 · Modelos clásicos
- Duración: 75 min
- Requisitos: [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/), [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/), [5.5.1 · Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística](https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/), [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/), [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/)

## Al terminar esta lección

- Escribir un estudio de Optuna reproducible: espacio de búsqueda con `suggest_*`, `TPESampler(seed=…)`, una función objetivo con validación purgada y atributos por intento.
- Medir el sesgo de selección de una búsqueda: cuánto sube el mejor IC de validación con el número de intentos y qué da ese mismo elegido en un periodo reservado.
- Separar el error de muestreo de un intento de la dispersión entre intentos, y usarla para prever el máximo esperado de N intentos sin diferencias reales.
- Montar una validación anidada (un estudio dentro de cada periodo de prueba) y compararla con una baseline sin ajuste.
- Registrar todos los intentos, incluidos los podados, y descontar la selección con el Deflated Sharpe.

## 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.

```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](https://optuna.org/) (licencia MIT; versión 5.0.0, de septiembre de 2026) organiza la búsqueda en cinco
piezas:

| Pieza | Qué es | En el código |
|---|---|---|
| Estudio | La búsqueda completa y su historial | `optuna.create_study(direction=…, sampler=…)` |
| Intento (*trial*) | Una configuración evaluada | el argumento `trial` de la función objetivo |
| Espacio | Lo que se puede proponer, definido al ejecutar | `trial.suggest_float`, `suggest_int`, `suggest_categorical` |
| Muestreador | Cómo se propone el siguiente intento | `TPESampler`, `RandomSampler`, `GPSampler`, `CmaEsSampler` |
| Podador | Cuándo abandonar un intento que va mal | `MedianPruner` 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)$:

```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}")
```
```output
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`.

```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)))
```
```output
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`.

```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()})
```
```output
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.

```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")
```
```output
    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.

<aside class="callout" data-kind="aviso">

Medir el periodo reservado en cada intento solo es legítimo en una lección. En un proyecto, en cuanto alguien mira el
reservado para decidir (por ejemplo, para cambiar el espacio de búsqueda porque «fuera no funciona»), el reservado pasa
a ser parte de la validación y hace falta otro.

</aside>

## 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\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.

```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}")
```
```output
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 $\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.

```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)
```
```output
                  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:

```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).

```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))
```
```output
           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.* Tres filas, una por periodo, con cuatro marcas. 2019-2020: mejor interno 0,0068, LightGBM fuera 0,0040 (destacado), ridge 0,0012, oráculo 0,0251. 2021-2022: 0,0071, 0,0132, 0,0138 y 0,0222. 2023-2025: 0,0152, 0,0122, 0,0241 y 0,0262. LightGBM queda por debajo de su validación en dos de los tres periodos y por debajo de la ridge en dos.

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).

<aside class="callout" data-kind="nota">

La validación anidada multiplica el coste: (periodos de prueba) × (intentos) × (pliegues internos) entrenamientos, aquí
3 × 10 × 3 = 90. Es la razón habitual para saltársela, y la razón por la que la alternativa barata, contar los intentos
y descontarlos, tiene que hacerse siempre.

</aside>

## 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.

```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}")
```
```output
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.* Una línea que baja con el número de intentos contados, en escala logarítmica: 0,927 con N = 1, 0,868 con 2, 0,816 con 3, 0,752 con 5, 0,667 con 10, 0,587 con 20 y 0,491 con 50 (destacado). Una línea discontinua marca el umbral habitual de 0,95, que no se alcanza ni con un solo intento.

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 $SR_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, $N_{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.

```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)
```
```output
                                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.

```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}")
```
```output
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:

```python skip
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.**

```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})")
```
```output
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.

2. 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.**

```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}")
```
```output
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 $SR_0$ de 0,55, o más años.

3. 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.

## 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?

   - A. Que la búsqueda funcionó: el elegido mejora fuera de muestra respecto a su validación
   - B. Que hay que hacer más intentos
   - 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
   - D. Que el periodo reservado está mal elegido

   *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?

   - A. Porque Optuna promedia los intentos; la primera
   - B. Porque el TPE descarta los peores; ninguna
   - C. Porque el error típico está mal calculado; la segunda
   - 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

   *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?

   - A. Usa más intentos
   - 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
   - C. Elimina la necesidad de la purga
   - D. Garantiza que LightGBM supere a la ridge

   *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?

   - 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
   - B. N = 1, porque solo se presenta uno
   - C. Solo los intentos que terminaron
   - D. N = 50 dividido entre el número de hiperparámetros

   *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?

   - A. Que la semilla es un hiperparámetro importante
   - B. Que hay que usar siempre la semilla 0
   - C. Que el bagging no funciona en finanzas
   - 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

   *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

### Research

- [Estacionalidad Intradiaria IV - Busqueda de campo](https://pythonparatrading.com/estacionalidad-intradia-optimizacion/) · Research-0027 · 7 min

### Documentación

- [Optuna: documentación (estudios, muestreadores, podadores, importancias)](https://optuna.readthedocs.io/en/stable/) · optuna.readthedocs.io
- [Optuna 5.0.0: notas de la versión (TPE multivariante por defecto, PED-ANOVA, API de restricciones)](https://github.com/optuna/optuna/releases/tag/v5.0.0) · github.com
- [LightGBM: parámetros (determinismo, bagging, regularización)](https://lightgbm.readthedocs.io/en/latest/Parameters.html) · lightgbm.readthedocs.io

### Libros

- [Akiba, T. et al. (2019): Optuna: A Next-generation Hyperparameter Optimization Framework. KDD 2019](https://doi.org/10.1145/3292500.3330701) · doi.org
- [Cawley, G. C. y Talbot, N. L. C. (2010): On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation. JMLR 11, 2079-2107](https://www.jmlr.org/papers/v11/cawley10a.html) · jmlr.org
- [Bailey, D. y López de Prado, M. (2014): The Deflated Sharpe Ratio. Journal of Portfolio Management 40(5), 94-107](https://doi.org/10.3905/jpm.2014.40.5.094) · doi.org

### Datos

- [Datos del nivel 5: features del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/)

Lección siguiente: [5.5.5 · Aprendizaje no supervisado: PCA, clustering y autoencoders](https://pythonparatrading.com/curso-python-trading/nivel-5/pca-clustering-y-autoencoders/)
