Lección 5.4.2 · 70 min

Purged k-fold con embargo y CPCV para modelos de ML

La lección 2.5.3 programó la purga, el embargo y la validación combinatoria para elegir el parámetro de una estrategia. Con un modelo de machine learning sobre un panel cambian tres cosas: cada fecha tiene muchas filas, la purga tiene que salir de las fechas de fin de cada etiqueta y cada división entrena un modelo nuevo cuyas predicciones hay que recomponer en caminos. Esta lección lo programa, lo comprueba contra los datos y lo usa para comparar un random forest con una baseline lineal.

Fig. — IC medio de los 5 caminos de una CPCV purgada (N = 6, k = 2, embargo de 63 sesiones) en el panel del nivel 5: logística sobre rangos, random forest y random forest con la feature con fuga, frente al oráculo
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

Qué cambia respecto a 2.5.3

La lección 2.5.3 trabajó con una serie: una fila por sesión, intervalos de etiqueta por posición (t0, t1) y una CPCV que elegía el mejor de ocho lookbacks. El ML sobre un panel complica las tres piezas:

PiezaEstrategia sobre una serie (2.5.3)Modelo sobre un panel (esta lección)
Filas por fechauna80 (una por acción)
Intervalo de la etiquetaposiciones t + 1 a t + Hfechas fecha y fin_sig_21d del fichero
Qué se entrena en cada divisiónnada: se elige un parámetroun modelo nuevo
Qué se guarda de la pruebarendimientos de la variante elegidapredicciones (probabilidades) por fila
Métrica de cada caminoSharpeacierto e IC de rangos por semana

El panel es el de la lección 5.4.1 (80 acciones, 725 semanas, etiqueta «por encima de la mediana de la semana a 21 sesiones») y el modelo, el mismo random forest. Datos sintéticos con la respuesta conocida: sirven para medir el método.

IN [01]python
import zlib
from itertools import combinations
from math import comb
import numpy as np
import pandas as pd
from scipy.stats import norm
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (TimeSeriesSplit,
                                     cross_val_score)

feat = pd.read_parquet("panel5_features.parquet")
etiq = pd.read_parquet("panel5_etiquetas.parquet")
d = feat.merge(etiq, on=["fecha", "ticker"])
completa = (d.groupby("fecha")[["ret_1s", "ret_sig_21d"]]
            .transform(lambda s: s.notna().all()).all(axis=1))
d = d[completa].copy()
d["med_21d"] = d.groupby("fecha")["ret_sig_21d"].transform("median")
tickers = sorted(d["ticker"].unique())
rng = np.random.default_rng([2029, zlib.crc32(b"n5_panel_cv")])
elegidas = [tickers[i] for i in
            np.sort(rng.choice(400, 80, replace=False))]
d = (d[d["ticker"].isin(elegidas)]
     .sort_values(["fecha", "ticker"]).reset_index(drop=True))

FEATS = [c for c in feat.columns if c not in
         ("fecha", "ticker", "sector", "revision_analistas")]
X = np.nan_to_num(d[FEATS].to_numpy(dtype=float))
X_fuga = np.nan_to_num(
    d[FEATS + ["revision_analistas"]].to_numpy(dtype=float))
y = (d["ret_sig_21d"] > d["med_21d"]).astype(int).to_numpy()

t0 = d["fecha"].to_numpy()        # se conoce la fila
t1 = d["fin_sig_21d"].to_numpy()  # termina su etiqueta
fechas = np.unique(t0)            # las 725 fechas de features
sesiones = pd.DatetimeIndex(pd.read_parquet(
    "panel5_precios.parquet", columns=["fecha"])["fecha"].unique())
sesiones = sesiones.sort_values()

n_ses = sesiones.searchsorted(t1) - sesiones.searchsorted(t0)
n_fechas = (np.searchsorted(fechas, t1, side="right")
            - np.searchsorted(fechas, t0))
print("sesiones por etiqueta:", np.unique(n_ses))
print("fechas de features dentro de cada etiqueta:")
print(pd.Series(n_fechas).value_counts().sort_index().to_string())
seis = np.unique(t0[n_fechas == 6])
print("etiquetas que cubren 6 fechas:", seis.astype("datetime64[D]"))
OUT [01]El panel, con los intervalos de cada etiqueta
sesiones por etiqueta: [21]
fechas de features dentro de cada etiqueta:
1       80
2       80
3       80
4       80
5    57600
6       80
etiquetas que cubren 6 fechas: ['2024-12-20']

Todas las etiquetas miden 21 sesiones exactas. En fechas de features, casi todas (57.600) cubren la suya y las cuatro siguientes: la regla de «4 semanas» del README es correcta para ellas. Pero 80 filas, las del 20 de diciembre de 2024 (con Navidad y Año Nuevo en las semanas siguientes), cubren 6; y las últimas filas de la muestra cubren menos porque no hay más fechas de features detrás. Una purga escrita en semanas fallaría en silencio en el primer caso. Escrita con fin_sig_21d, no puede fallar.

Purga y embargo con fechas

La regla es la de 2.5.3, en fechas. Para cada tramo de prueba (un conjunto de fechas de features contiguas), su intervalo va del primer fecha al último fin_sig_21d de sus filas. Fuera del entrenamiento:

  • Purga: toda fila cuyo intervalo [fecha, fin_sig_21d] se solape con el del tramo.
  • Embargo: además, las filas que empiezan dentro de las embargo sesiones posteriores al fin del tramo. Su etiqueta ya no se solapa, pero sus features de ventana larga (ret_1m, ret_3m, mom_12_1) se calcularon con rendimientos que forman parte de las etiquetas de la prueba.
fuerai  ⟺  t0,i≤finb+e  ∧  t1,i≥inibpara alguˊn tramo b\text{fuera}_i \iff t_{0,i} \le \text{fin}_b + e \;\land\; t_{1,i} \ge \text{ini}_b \quad\text{para algún tramo } b

con ee el embargo en sesiones. Un conjunto de prueba puede tener varios tramos (en la CPCV, dos grupos separados), así que la función los separa primero.

IN [02]python
def tramos(prueba):
    """(inicio, fin) de cada tramo de fechas contiguas de `prueba`."""
    f = np.unique(t0[prueba])
    pos = np.searchsorted(fechas, f)
    for bloque in np.split(f, np.flatnonzero(np.diff(pos) > 1) + 1):
        en = (t0[prueba] >= bloque[0]) & (t0[prueba] <= bloque[-1])
        yield t0[prueba][en].min(), t1[prueba][en].max()

def purgar(prueba, embargo=0):
    """Posiciones de entrenamiento: fuera la prueba, las filas que
    se solapan con un tramo y las del embargo tras cada tramo."""
    fuera = np.zeros(len(t0), bool)
    fuera[prueba] = True
    for ini, fin in tramos(prueba):
        j = min(sesiones.searchsorted(fin) + embargo,
                len(sesiones) - 1)
        fuera |= (t0 <= sesiones[j]) & (t1 >= ini)
    return np.flatnonzero(~fuera)

class PurgedKFold:
    """k-fold por bloques de fechas con purga y embargo. Tiene
    split() y get_n_splits(): vale como cv= en scikit-learn."""
    def __init__(self, n_splits=5, embargo=0):
        self.n_splits, self.embargo = n_splits, embargo

    def get_n_splits(self, X=None, y=None, groups=None):
        return self.n_splits

    def split(self, X=None, y=None, groups=None):
        for b in np.array_split(fechas, self.n_splits):
            prueba = np.flatnonzero(np.isin(t0, b))
            yield purgar(prueba, self.embargo), prueba

La clase no guarda los datos: lee t0, t1, fechas y sesiones del entorno, lo justo para la lección. En un proyecto, esos cuatro arrays irían en el constructor (como en el PurgedKFold del capítulo 7 de López de Prado, que recibe la serie de fines de etiqueta).

Comprobar la purga contra los datos

Una función que purga y otra que comprueba, escrita de otra forma, se vigilan entre sí. La comprobación no usa tramos: para cada fecha de prueba toma el fin de etiqueta más tardío de sus filas y busca filas de entrenamiento que se solapen con ese intervalo. Mide también el hueco del embargo: cuántas sesiones hay entre el último fin de etiqueta de la prueba y el inicio de la primera fila de entrenamiento posterior.

IN [03]python
def comprobar(entreno, prueba):
    """(filas de entreno solapadas con la prueba, sesiones entre el
    último fin de etiqueta de la prueba y el siguiente entreno)."""
    fin_por_fecha = pd.Series(t1[prueba]).groupby(t0[prueba]).max()
    solapes = np.zeros(len(entreno), bool)
    for f, fin in fin_por_fecha.items():
        solapes |= (t0[entreno] <= fin) & (t1[entreno] >= f)
    fin_prueba = t1[prueba].max()
    despues = t0[entreno][t0[entreno] > fin_prueba]
    hueco = (sesiones.searchsorted(despues.min())
             - sesiones.searchsorted(fin_prueba)
             if len(despues) else None)
    return int(solapes.sum()), hueco

# la regla del README: 4 semanas a cada lado del bloque
semana = np.searchsorted(fechas, t0)
regla_4s = []
for b in np.array_split(np.arange(len(fechas)), 5):
    lejos = (semana < b[0] - 4) | (semana > b[-1] + 4)
    regla_4s.append((np.flatnonzero(lejos),
                     np.flatnonzero(np.isin(semana, b))))

filas = []
for nombre, div in [
        ("regla de 4 semanas", regla_4s),
        ("purga por fechas", list(PurgedKFold(5).split())),
        ("purga + embargo 21", list(PurgedKFold(5, 21).split()))]:
    for i, (ent, pru) in enumerate(div):
        sol, hueco = comprobar(ent, pru)
        filas.append((nombre, i, len(ent), sol, hueco))
pd.DataFrame(filas, columns=["esquema", "pliegue", "entreno",
                             "solapes", "hueco (ses.)"]
             ).set_index(["esquema", "pliegue"])
OUT [03]Ningún solape, y el hueco que pide el embargo
                            entreno  solapes  hueco (ses.)
esquema            pliegue
regla de 4 semanas 0          46080        0           4.0
                   1          45760        0           4.0
                   2          45760        0           3.0
                   3          45760        0           3.0
                   4          46080        0           NaN
purga por fechas   0          46080        0           4.0
                   1          45760        0           4.0
                   2          45760        0           3.0
                   3          45760        0           3.0
                   4          46080        0           NaN
purga + embargo 21 0          45760        0          23.0
                   1          45440        0          23.0
                   2          45440        0          22.0
                   3          45440        0          22.0
                   4          46080        0           NaN

La purga por fechas deja cero solapes en los cinco pliegues, y el embargo de 21 sesiones aparece en la última columna como el hueco que debe ser. La regla de 4 semanas del README también da cero aquí, porque ninguna frontera de los cinco bloques cae en la semana de los festivos: es correcta por suerte, no por construcción. Fíjate además en el pliegue 4, el último: no tiene entrenamiento después, así que el hueco no existe.

Fig. — Purga por fechas en la frontera del tercer bloque de prueba (4 de agosto de 2017): salen del entrenamiento las cuatro filas semanales cuya etiqueta de 21 sesiones llega a la prueba

La figura muestra, para una acción, el inicio del tercer bloque de prueba (el pliegue 2, que empieza el 4 de agosto de 2017) con la purga por fechas: las cuatro semanas anteriores al bloque salen del entrenamiento porque su etiqueta llega a la primera fecha de prueba, y las cuatro posteriores, porque empiezan antes de que termine la última etiqueta de la prueba. Lo que se aparta es lo que se solapa, ni más ni menos.

Cuánto cuesta el embargo

El embargo no sale gratis: cada sesión de embargo es una semana de 80 filas menos para entrenar en cada frontera posterior. La ventana más larga de las features es la del momentum, 252 sesiones. Cubrirla entera sería lo más seguro y lo más caro:

IN [04]python
sin_purga = sum(len(t0) - len(p) for _, p in PurgedKFold(5).split())
coste = {}
for e in (0, 5, 21, 63, 252):
    n = sum(len(ent) for ent, _ in PurgedKFold(5, e).split())
    coste[e] = {"filas de entreno": n,
                "perdidas": f"{1 - n / sin_purga:.1%}"}
pd.DataFrame(coste).T.rename_axis("embargo (sesiones)")
OUT [04]Filas de entrenamiento según el embargo
                   filas de entreno perdidas
embargo (sesiones)
0                            229440     1.1%
5                            229120     1.2%
21                           228160     1.7%
63                           225280     2.9%
252                          212800     8.3%
Fig. — Fracción del entrenamiento que se pierde en un purged k-fold de 5 bloques según el embargo, sobre el panel del nivel 5

La purga sola cuesta un 1,1 % del entrenamiento. Un embargo de 21 sesiones (un mes) sube la pérdida al 1,7 %; uno de 63 (tres meses, la ventana de ret_3m), al 2,9 %; cubrir el año del momentum, al 8,3 %. Con más pliegues hay más fronteras y el coste se multiplica: en la CPCV de abajo, cada división tiene hasta dos tramos de prueba con su embargo. La recomendación de López de Prado, un embargo pequeño del orden del 1 % de la muestra, es un compromiso: con 725 semanas, unas 7 semanas. Aquí se usarán 63 sesiones, que cubren todas las ventanas salvo las de 252.

Lo que cambian la purga y el embargo en el acierto

Con los pliegues por bloques, la fuga gruesa del k-fold barajado ya no está (lección 5.4.1). Queda la de las fronteras: las primeras y las últimas semanas de cada bloque de prueba tienen vecinas solapadas en el entrenamiento si no se purga. Por eso, además del acierto de todo el pliegue, se mide el de las filas de frontera (las 4 primeras y las 4 últimas semanas de cada bloque).

IN [05]python
def bosque():
    return RandomForestClassifier(
        n_estimators=60, min_samples_leaf=5, max_features="sqrt",
        max_samples=0.3, n_jobs=4, random_state=0)

bloques = [(np.flatnonzero(~np.isin(t0, b)),
            np.flatnonzero(np.isin(t0, b)))
           for b in np.array_split(fechas, 5)]
res = {}
for nombre, div in [("bloques sin purga", bloques),
                    ("purga", list(PurgedKFold(5).split())),
                    ("purga + embargo 63",
                     list(PurgedKFold(5, 63).split()))]:
    total, borde = [], []
    for ent, pru in div:
        ok = bosque().fit(X[ent], y[ent]).predict(X[pru]) == y[pru]
        s = semana[pru]
        en_borde = (s < s.min() + 4) | (s > s.max() - 4)
        total.append(ok.mean())
        borde.append(ok[en_borde].mean())
    res[nombre] = {"acierto": np.mean(total),
                   "acierto en fronteras": np.mean(borde)}
pd.DataFrame(res).T.map(lambda a: f"{a:.1%}")
OUT [05]Bloques sin purga, con purga y con embargo
                   acierto acierto en fronteras
bloques sin purga    50.0%                50.8%
purga                50.1%                49.2%
purga + embargo 63   50.1%                49.3%

En el conjunto de cada pliegue, las tres versiones dan lo mismo: un 50,0-50,1 %. En las fronteras, sin purga el bosque acierta el 50,8 % y con purga el 49,2 %: es la huella de la fuga que queda en los bordes, que el promedio diluye porque las fronteras son solo 8 de las 145 semanas de cada bloque. La diferencia es del orden del ruido de estas cifras (unas 12.800 filas de frontera, muy solapadas entre sí), así que no se puede leer como una medida precisa; lo que sí es seguro es la dirección, y que crece con el número de pliegues, porque hay más fronteras.

Un splitter propio dentro de scikit-learn

Como PurgedKFold tiene split y get_n_splits, cualquier función de scikit-learn que acepte cv= lo usa sin más: cross_val_score, cross_validate, GridSearchCV. Sirve para presentar la baseline: una regresión logística sobre las features en rango normal dentro de cada semana (el mismo tratamiento que la lección 5.2.4), con mucha regularización. Es rápida y lineal: si el bosque no la supera, no aporta nada.

IN [06]python
rango = d.groupby("fecha")[FEATS].rank()
n_sem = d.groupby("fecha")[FEATS].transform("count")
G = np.nan_to_num(norm.ppf((rango - 0.5) / n_sem))

def logistica():
    return LogisticRegression(C=0.01, max_iter=500)

cv = PurgedKFold(5, embargo=63)
for nombre, modelo, Xm in [("logística (rangos)", logistica(), G),
                           ("random forest", bosque(), X)]:
    a = cross_val_score(modelo, Xm, y, cv=cv, scoring="accuracy")
    print(f"{nombre:<20}acierto {a.mean():.2%} "
          f"(pliegues {np.round(a, 3).tolist()})")
OUT [06]cross_val_score con el splitter purgado
logística (rangos)  acierto 50.55% (pliegues [0.51, 0.503, 0.503, 0.505, 0.507])
random forest       acierto 50.07% (pliegues [0.502, 0.505, 0.503, 0.491, 0.503])

La logística acierta algo más que el bosque, con la misma validación. Las dos cifras están cerca del 50 %, como debe ser con un techo del 51,1 % (lección 5.4.1), y la diferencia, de unas décimas, es del orden del ruido. Para saber si es consistente hace falta más de un número: es lo que da la CPCV.

CPCV para un modelo de machine learning

La CPCV de 2.5.3 elegía un parámetro con los datos de entrenamiento y aplicaba la variante elegida a los grupos de prueba. Con un modelo, el procedimiento es:

  1. Partir las fechas en N grupos contiguos.
  2. Para cada una de las (Nk)\binom{N}{k} combinaciones de k grupos de prueba: purgar y embargar alrededor de cada grupo (la función purgar ya separa los tramos), entrenar un modelo nuevo con el resto y guardar sus predicciones para cada grupo de prueba por separado.
  3. Recomponer (N−1k−1)\binom{N-1}{k-1} caminos: para cada grupo, la predicción de su j-ésima aparición en la prueba.
  4. Medir cada camino con las métricas del problema: aquí el acierto y el IC (correlación de rangos entre la probabilidad y el rendimiento a 21 sesiones, semana a semana, y su media).
Fechas en N gruposC N k divisionesPurga y embargoEntrenar un modeloPredecir gruposRecomponer caminosIC por camino
Fuente Mermaid
flowchart TB
  F[Fechas en N grupos] --> C[C N k divisiones]
  C --> P[Purga y embargo]
  P --> E[Entrenar un modelo]
  E --> G[Predecir grupos]
  G --> R[Recomponer caminos]
  R --> M[IC por camino]

Con N = 6 y k = 2 salen 15 divisiones, 15 modelos y 5 caminos; cada modelo entrena con unas 4/6 partes de las fechas, menos la purga y el embargo alrededor de los dos grupos de prueba.

IN [07]python
N, K = 6, 2
grupos = np.array_split(fechas, N)
divisiones = list(combinations(range(N), K))
fila_grupo = [np.flatnonzero(np.isin(t0, g)) for g in grupos]

def cpcv(modelo, Xm, embargo=63):
    """{(división, grupo): probabilidades de las filas del grupo}."""
    pred = {}
    for i, prueba_g in enumerate(divisiones):
        prueba = np.concatenate([fila_grupo[g] for g in prueba_g])
        ent = purgar(prueba, embargo)
        m = modelo().fit(Xm[ent], y[ent])
        for g in prueba_g:
            pred[(i, g)] = m.predict_proba(Xm[fila_grupo[g]])[:, 1]
    return pred

def ic_semanal(p, filas):
    """Media de la correlación de rangos semanal entre p y el
    rendimiento a 21 sesiones."""
    df = pd.DataFrame({"s": semana[filas], "p": p,
                       "r": d["ret_sig_21d"].to_numpy()[filas]})
    rk = df.groupby("s")[["p", "r"]].rank()
    return rk.groupby(df["s"]).corr().xs("p", level=1)["r"].mean()

def caminos(pred):
    filas = []
    for j in range(comb(N - 1, K - 1)):
        p = np.concatenate([
            pred[([i for i, dv in enumerate(divisiones)
                   if g in dv][j], g)] for g in range(N)])
        todas = np.concatenate(fila_grupo)
        filas.append({"acierto": ((p > 0.5) == y[todas]).mean(),
                      "IC": ic_semanal(p, todas)})
    return pd.DataFrame(filas).rename_axis("camino")

tabla = pd.concat({
    "logística": caminos(cpcv(logistica, G)),
    "bosque": caminos(cpcv(bosque, X)),
    "bosque + fuga": caminos(cpcv(bosque, X_fuga))}, axis=1)
tabla.round(4)
OUT [07]CPCV con predicciones guardadas por grupo
       logística          bosque         bosque + fuga
         acierto      IC acierto      IC       acierto      IC
camino
0         0.5041  0.0123  0.5009 -0.0011        0.5126  0.0442
1         0.5023  0.0105  0.5000  0.0008        0.5145  0.0451
2         0.5069  0.0190  0.5026  0.0040        0.5167  0.0503
3         0.5048  0.0148  0.5025  0.0137        0.5162  0.0499
4         0.5037  0.0125  0.5005 -0.0008        0.5123  0.0432

Para leer la tabla falta la referencia: el IC del oráculo (el alfa verdadero frente al mismo rendimiento a 21 sesiones) en las mismas 80 acciones. Como todos los caminos cubren todas las fechas, es el mismo para los cinco.

IN [08]python
verdad = pd.read_parquet("panel5_verdad.parquet",
                         columns=["fecha", "ticker", "alfa_verdadero"])
alfa = d[["fecha", "ticker"]].merge(verdad)["alfa_verdadero"]
ic_oraculo = ic_semanal(alfa.to_numpy(), np.arange(len(d)))
print(f"IC del oráculo: {ic_oraculo:.4f}")
dif = tabla["bosque"]["IC"] - tabla["logística"]["IC"]
print("bosque menos logística, por camino:",
      dif.round(4).tolist())
print(f"caminos en los que gana la logística: {(dif < 0).sum()}/5")
OUT [08]El oráculo y la comparación pareada
IC del oráculo: 0.0270
bosque menos logística, por camino: [-0.0135, -0.0096, -0.015, -0.001, -0.0134]
caminos en los que gana la logística: 5/5

La figura de la cabecera pone los quince números en una escala. Tres lecturas:

  • La baseline gana. La logística sobre rangos tiene un IC de 0,011 a 0,019 por camino y el bosque, de −0,001 a 0,014. La comparación es pareada (mismas divisiones, mismas filas de prueba) y la logística queda por encima en los cinco caminos. Con esta relación señal/ruido, la flexibilidad del bosque se gasta en ruido; la lección 5.5.2 vuelve sobre cómo regularizarlo.
  • Nadie pasa del techo. El oráculo da el IC máximo alcanzable; los dos modelos honestos quedan por debajo, como debe ser. La logística recoge la mitad, más o menos.
  • La fuga gana en todos los caminos. Con revision_analistas, el bosque tiene un IC de 0,043 a 0,050 y un acierto del 51,2-51,7 %, por encima del oráculo en las cinco. La CPCV, con purga y embargo, no lo puede ver: la información del futuro está dentro de la feature (lección 5.2.2).

Hiperparámetros dentro de la CPCV

Si los hiperparámetros del bosque se eligieran mirando los caminos de la CPCV, los caminos dejarían de ser fuera de muestra: serían el conjunto con el que se eligió. Hay dos salidas correctas:

  • Validación anidada: dentro de cada división de la CPCV, un PurgedKFold sobre el entrenamiento elige los hiperparámetros (por ejemplo, con GridSearchCV(cv=PurgedKFold(…))), y el modelo elegido predice los grupos de prueba. Cuesta (número de divisiones) × (combinaciones) × (pliegues internos) entrenamientos.
  • Contar los intentos: si se prueban varias configuraciones y se elige la mejor por los caminos, se registran todas y el resultado se descuenta con el Deflated Sharpe o la PBO (lecciones 2.5.4 y 5.4.4).

Y los pesos por unicidad de la lección 5.3.4 entran en el mismo bucle: fit(X[ent], y[ent], sample_weight=w[ent]), con los pesos calculados solo con las etiquetas de entrenamiento.

Ejercicios

  1. Comprueba TimeSeriesSplit con comprobar: cinco pliegues sin hueco y con gap=4 * 80. ¿Cuántas filas de entrenamiento se solapan con la prueba en cada caso?
Solución
IN [09]python
for gap in (0, 4 * 80):
    tss = TimeSeriesSplit(5, gap=gap)
    sol = [comprobar(a, b)[0] for a, b in tss.split(X)]
    print(f"gap={gap:>3}: solapes por pliegue {sol}")
OUT [09]TimeSeriesSplit frente a la purga
gap=  0: solapes por pliegue [390, 376, 362, 348, 334]
gap=320: solapes por pliegue [70, 56, 42, 28, 14]

Sin hueco, cada pliegue deja en el entrenamiento entre 334 y 390 filas cuyas etiquetas llegan a la prueba: algo más de cuatro semanas de 80 acciones. Con gap=320 (cuatro semanas en filas) los solapes bajan, pero no desaparecen: quedan entre 14 y 70. El motivo es que TimeSeriesSplit corta por filas: cada bloque de prueba tiene 58.000 // 6 = 9.666 filas, que no es múltiplo de 80, así que el corte cae a mitad de una fecha y parte de las acciones de la semana frontera quedan en el entrenamiento con una etiqueta que llega a la prueba. Con un panel desequilibrado (acciones que entran y salen) sería peor. Un hueco en filas no es un hueco en fechas; la purga por fin_sig_21d sí lo es.

  1. Calcula, sin entrenar nada, cuántos modelos y caminos salen con N = 8 y k = 2, y qué fracción media de las filas queda para entrenar tras purgar y embargar 63 sesiones alrededor de los dos grupos de prueba.
Solución
IN [10]python
N8, K8 = 8, 2
grupos8 = np.array_split(fechas, N8)
fracs = []
for dv in combinations(range(N8), K8):
    prueba = np.flatnonzero(np.isin(t0, np.concatenate(
        [grupos8[g] for g in dv])))
    fracs.append(len(purgar(prueba, 63)) / len(t0))
print(f"modelos {comb(N8, K8)}, caminos {comb(N8 - 1, K8 - 1)}, "
      f"entreno medio {np.mean(fracs):.1%} "
      f"(sin purga {(N8 - K8) / N8:.1%})")
OUT [10]CPCV con N = 8 y k = 2
modelos 28, caminos 7, entreno medio 70.7% (sin purga 75.0%)

Con N = 8 hacen falta 28 modelos para 7 caminos, casi el doble de entrenamientos que con N = 6 para dos caminos más. Cada modelo entrena con unas tres cuartas partes de las filas, algo menos por la purga y el embargo alrededor de cada grupo de prueba (dos tramos, o uno si los grupos son contiguos). Con modelos caros, como las redes de los módulos 5.6 y 5.7, N y k pequeños son la opción práctica.

  1. Un compañero elige el número de árboles y el tamaño de hoja del bosque con los cinco caminos de la CPCV (prueba doce combinaciones y se queda con la de mejor IC medio) y presenta ese IC como resultado fuera de muestra. ¿Qué está mal y cómo lo arreglarías?
Solución

Los caminos se usaron para elegir: el IC de la mejor de doce configuraciones está sesgado hacia arriba, igual que el Sharpe del mejor de N backtests (lección 2.5.1). Dos arreglos. El limpio es anidar: dentro de cada una de las 15 divisiones, elegir la configuración con un PurgedKFold interno sobre el entrenamiento de esa división, y predecir los grupos de prueba con la elegida; los caminos vuelven a ser fuera de muestra. El barato es registrar las doce configuraciones con sus caminos y descontar la selección: con las series de rendimientos de las doce, el Deflated Sharpe (lección 5.4.4) o la PBO por CSCV (lección 2.5.4). En los dos casos, el resultado se compara con la logística, que no necesita ajuste.

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿Por qué la purga se calcula con fin_sig_21d y no con una regla fija de «4 semanas a cada lado»?

    • APorque es más rápido
    • BPorque el horizonte es de 21 sesiones, no de semanas: con festivos una etiqueta puede abarcar las fechas de 6 filas semanales en lugar de 5, y la regla fija dejaría ese solape en el entrenamiento
    • CPorque scikit-learn lo exige
    • DPorque la regla fija quita demasiadas filas siempre
    Ver respuesta

    Respuesta B. Porque el horizonte es de 21 sesiones, no de semanas: con festivos una etiqueta puede abarcar las fechas de 6 filas semanales en lugar de 5, y la regla fija dejaría ese solape en el entrenamiento

    En el panel, 57.600 de las 58.000 etiquetas cubren 5 fechas de features y 80 cubren 6. La fecha de fin es la verdad; cualquier regla en semanas es una aproximación que puede fallar justo en una frontera.

  2. ¿Qué protege el embargo que no proteja ya la purga?

    • ALas filas de entrenamiento justo después de la prueba cuyas features se calcularon con rendimientos de la prueba (por ejemplo ret_1m o mom_12_1), aunque su etiqueta ya no se solape
    • BEl solape de las etiquetas
    • CLas filas de prueba con etiqueta ambigua
    • DLa fuga de revision_analistas
    Ver respuesta

    Respuesta A. Las filas de entrenamiento justo después de la prueba cuyas features se calcularon con rendimientos de la prueba (por ejemplo ret_1m o mom_12_1), aunque su etiqueta ya no se solape

    La purga mira las etiquetas. Las features de ventana larga miran hacia atrás y, después de un bloque de prueba, incluyen rendimientos que forman parte de sus etiquetas. El embargo aparta esas filas; cubrir la ventana más larga (252 sesiones) costaría muchos datos.

  3. En la CPCV con N = 6 y k = 2, la logística sobre rangos tiene un IC medio por camino de 0,011 a 0,019 y el random forest, de −0,001 a 0,014. ¿Qué se concluye?

    • AQue el random forest es mejor porque tiene más parámetros
    • BQue los cinco caminos son cinco muestras independientes y la diferencia es concluyente sin más
    • CQue la baseline lineal gana en los cinco caminos: con esta relación señal/ruido el bosque no aprovecha la señal, y la comparación por caminos lo muestra con los mismos datos de prueba
    • DQue ninguno de los dos supera al azar
    Ver respuesta

    Respuesta C. Que la baseline lineal gana en los cinco caminos: con esta relación señal/ruido el bosque no aprovecha la señal, y la comparación por caminos lo muestra con los mismos datos de prueba

    Los caminos comparten datos, así que no son evidencia independiente; pero la comparación es pareada (mismas filas, mismas divisiones) y la logística queda por encima en todos. Es la baseline que cualquier modelo complejo tiene que batir.

  4. Con revision_analistas el bosque mejora en todos los caminos de la CPCV (IC ≈ 0,045 frente a ≈ 0,004). ¿Qué dice esto de la feature?

    • AQue es la mejor feature del panel
    • BQue la purga no funciona
    • CQue hay que subir el embargo
    • DNada sobre si es legítima: la CPCV purga el solape de las etiquetas, pero una feature que ya contiene información posterior a su fecha pasa todas las validaciones. Hay que auditar cuándo se conoce
    Ver respuesta

    Respuesta D. Nada sobre si es legítima: la CPCV purga el solape de las etiquetas, pero una feature que ya contiene información posterior a su fecha pasa todas las validaciones. Hay que auditar cuándo se conoce

    La fuga está dentro de cada fila: usa los dos primeros días de la semana siguiente. Ni la purga ni el embargo pueden verla. Se detecta con la auditoría point-in-time (lección 5.2.2).

  5. ¿Cuántos caminos y cuántos modelos hay que entrenar en una CPCV con N = 8 grupos y k = 2?

    • A8 caminos y 8 modelos
    • B28 caminos y 7 modelos
    • C7 caminos y 28 modelos
    • D2 caminos y 8 modelos
    Ver respuesta

    Respuesta C. 7 caminos y 28 modelos

    Hay C(8, 2) = 28 divisiones, cada una con su modelo, y C(7, 1) = 7 caminos. Con modelos caros, el número de entrenamientos crece mucho más rápido que el de caminos.

Ideas clave

  • En un panel la purga se define por fechas: fuera del entrenamiento toda fila cuya etiqueta [fecha, fin_sig_21d] se solape con un tramo de prueba. Una regla en semanas es solo una aproximación.
  • Una función de comprobación independiente (0 solapes, hueco del embargo medido en sesiones) vale más que la confianza en el código que purga.
  • El embargo cuesta datos: con 5 bloques, la purga sola aparta el 1,1 % del entrenamiento, un embargo de 63 sesiones el 2,9 % y uno que cubra el momentum de 252 sesiones, el 8,3 %. Se elige sabiendo qué features tienen ventanas largas.
  • Sin barajar, purga y embargo cambian poco el acierto medio de este bosque (50,0 % frente a 50,1 %): la fuga que quedaba estaba en las fronteras.
  • La CPCV para modelos entrena uno por división, guarda las predicciones de cada grupo y recompone caminos. Con N = 6 y k = 2: 15 modelos y 5 caminos.
  • La logística sobre rangos supera al random forest en los cinco caminos y el bosque con revision_analistas gana en todos: la CPCV compara modelos, pero no detecta una feature mal fechada.

Recursos

Datos