Lección 2.5.3 · 70 min
Validación cruzada combinatoria purgada para estrategias
La validación cruzada de manual supone observaciones independientes, y una serie financiera con etiquetas a varios días no lo es. Esta lección mide la fuga del k-fold con datos de puro ruido, programa la purga y el embargo, y construye la validación combinatoria que da una distribución de Sharpes fuera de muestra en lugar de un solo número.
- Módulo
- 2.5 · Sesgos y validación
- Duración
- 70 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
Lo que supone el k-fold
La validación cruzada de k pliegues (k-fold) es la forma estándar de estimar cómo se comportará un modelo con datos que no ha visto: se parten los datos en k trozos, se entrena con k − 1 y se prueba con el que queda, k veces. Funciona porque supone que las observaciones son independientes y de la misma distribución: da igual qué filas caigan en cada trozo. La documentación de scikit-learn lo dice expresamente y advierte de que en series temporales ese supuesto no se cumple.
En un problema de trading el supuesto falla por dos lados a la vez:
- Etiquetas solapadas. La etiqueta de una observación suele abarcar varias sesiones: «¿subirá el precio en los próximos 60 días?». La etiqueta del lunes y la del martes comparten 59 de sus 60 días.
- Rasgos solapados. Los rasgos (features) son ventanas móviles: la rentabilidad de 120 días del lunes y la del martes comparten 119 días. Dos días seguidos tienen rasgos casi iguales.
Juntas, las dos cosas hacen que una observación de prueba tenga en el entrenamiento vecinas casi idénticas con casi la misma respuesta. Un modelo flexible no necesita predecir: le basta con reconocerlas. El resultado es una precisión que no existe.
Fuente Mermaid
flowchart TB F[Rasgos solapados] --> V[Vecinas iguales] E[Etiquetas largas] --> R[Respuesta común] V --> M[El modelo reconoce] R --> M M --> P[Precisión ficticia]
Medir la fuga en una serie sin nada que predecir
Para medir la fuga sin que un efecto real la tape hace falta una serie en la que se sepa que no hay nada que predecir.
La serie ruptura de regimenes2.parquet es exactamente eso: rendimientos normales independientes con una sola
ruptura estructural el 22 de mayo de 2013. Desde ese día, media diaria 0 y volatilidad 0,013: puro ruido, sin memoria.
Cualquier AUC distinto de 0,5 fuera de muestra es suerte o fuga.
La etiqueta es el signo del rendimiento de las 60 sesiones siguientes; los rasgos, cuatro rentabilidades y dos
volatilidades móviles. Cada observación lleva además el intervalo de su etiqueta, t0 y t1 (posiciones de la
primera y la última sesión que usa), que es lo que la purga necesita:
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import KFold
reg = pd.read_parquet("regimenes2.parquet")
r = (reg[reg["serie"] == "ruptura"].set_index("fecha")
["rendimiento_log"].loc["2013-05-22":])
H = 60 # horizonte de la etiqueta, en sesiones
rasgos = pd.DataFrame({f"r{n}": r.rolling(n).sum()
for n in (5, 20, 60, 120)})
rasgos["v20"] = r.rolling(20).std()
rasgos["v60"] = r.rolling(60).std()
futuro = r[::-1].rolling(H).sum()[::-1].shift(-1) # r de t+1 a t+H
datos = rasgos.assign(futuro=futuro).dropna()
X = datos[rasgos.columns].to_numpy()
y = (datos["futuro"] > 0).astype(int).to_numpy()
pos = np.arange(len(datos))
t0, t1 = pos + 1, pos + H # sesiones que usa cada etiqueta
print(len(datos), "observaciones; proporción de etiquetas 1:",
round(y.mean(), 3))2994 observaciones; proporción de etiquetas 1: 0.586El modelo es un bosque aleatorio, flexible a propósito: es el tipo de modelo que mejor «recuerda» vecinas. La función
auc entrena con unas posiciones y mide el AUC (área bajo la curva ROC: 0,5 es azar, 1 es perfecto) en otras:
def modelo():
return RandomForestClassifier(n_estimators=100, max_features=2,
random_state=0, n_jobs=-1)
def auc(entreno, prueba):
m = modelo().fit(X[entreno], y[entreno])
return roc_auc_score(y[prueba], m.predict_proba(X[prueba])[:, 1])
for barajar in (True, False):
kf = KFold(10, shuffle=barajar,
random_state=0 if barajar else None)
res = [auc(a, b) for a, b in kf.split(X)]
print(f"k-fold {'barajado' if barajar else 'sin barajar'}: "
f"AUC medio {np.mean(res):.3f}")k-fold barajado: AUC medio 0.928
k-fold sin barajar: AUC medio 0.532Barajado, el k-fold «predice» el signo de los próximos 60 días de una serie de ruido con un AUC cercano a 0,9. Es la fuga en estado puro: cada fila de prueba tiene en el entrenamiento a su vecina de ayer y de mañana. Sin barajar, los pliegues son bloques de tiempo contiguos y la mayor parte de la fuga desaparece: el AUC queda en 0,53, cerca del azar. Ese 0,53 tiene mucha dispersión: con 3.000 observaciones solapadas, el número de etiquetas independientes es de unas 50 (3.000 / 60), y con tan pocas el AUC de un modelo sin información puede salir varios puntos por encima o por debajo de 0,5.
Pero sin barajar aún queda fuga, en las fronteras de cada bloque. La última observación de entrenamiento antes de un bloque de prueba tiene una etiqueta que abarca los 60 primeros días del bloque; la primera de después tiene rasgos calculados con los últimos días del bloque. Para eliminarla están la purga y el embargo.
Purga y embargo
- Purga. Se quita del entrenamiento toda observación cuya etiqueta se solape con el intervalo que cubren las etiquetas del bloque de prueba. Antes del bloque, son las últimas H − 1 observaciones; después, las primeras H − 1.
- Embargo. Se quitan además las observaciones que empiezan justo después del bloque de prueba, durante un número de sesiones fijo. Sus etiquetas ya no se solapan, pero sus rasgos (ventanas de hasta 120 sesiones) se calcularon con datos de la prueba. López de Prado propone un embargo pequeño, del orden del 1 % de la muestra; con rasgos largos conviene cubrir la ventana más larga.
El esquema completo de una división, con la prueba en medio de la serie, es el de una fila de la figura de la cabecera: entrenamiento, purga, prueba, purga, embargo, entrenamiento.
def purgar(t0, t1, bloques, embargo=0):
"""Posiciones de entrenamiento: fuera las observaciones cuya
etiqueta [t0, t1] se solapa con la de algún bloque de prueba,
y las que empiezan en los `embargo` días tras cada bloque."""
fuera = np.zeros(len(t0), bool)
for b in bloques:
ini, fin = t0[b].min(), t1[b].max()
fuera |= (t0 <= fin) & (t1 >= ini) # purga
fuera |= (t0 > fin) & (t0 <= fin + embargo) # embargo
return np.flatnonzero(~fuera)
bloque = np.arange(1000, 1300)
entreno = purgar(t0, t1, [bloque], embargo=120)
quitadas = np.setdiff1d(np.setdiff1d(pos, entreno), bloque)
print(f"Prueba: {len(bloque)}; quitadas del entrenamiento: "
f"{len(quitadas)} (de {quitadas.min()} a {quitadas.max()})")Prueba: 300; quitadas del entrenamiento: 238 (de 941 a 1478)Un bloque de prueba de 300 observaciones obliga a quitar 59 antes (purga), 59 después (purga) y 120 más (embargo, la ventana del rasgo más largo). Ahora la comparación: el mismo k-fold sin barajar, con y sin purga y embargo, midiendo el AUC por separado en las observaciones de frontera (las 60 primeras y las 60 últimas de cada bloque, que son las que tienen vecinas filtradas) y en las del centro:
def k_fold(k, embargo, purga):
p_borde, y_borde, p_centro, y_centro = [], [], [], []
for b in np.array_split(pos, k):
if purga:
entreno = purgar(t0, t1, [b], embargo)
else:
entreno = np.setdiff1d(pos, b)
m = modelo().fit(X[entreno], y[entreno])
p = m.predict_proba(X[b])[:, 1]
borde = (b < b[0] + H) | (b > b[-1] - H)
p_borde += list(p[borde]); y_borde += list(y[b][borde])
p_centro += list(p[~borde]); y_centro += list(y[b][~borde])
return (roc_auc_score(y_borde, p_borde),
roc_auc_score(y_centro, p_centro))
filas = {"sin purga": k_fold(10, 0, False),
"purga y embargo": k_fold(10, 120, True)}
pd.DataFrame(filas, index=["AUC frontera", "AUC centro"]).T.round(3) AUC frontera AUC centro
sin purga 0.558 0.499
purga y embargo 0.497 0.459Sin purga, las observaciones de frontera tienen un AUC de 0,56, por encima del 0,50 de las del centro: es la fuga de los solapes. Con purga y embargo, las fronteras bajan a 0,50. La diferencia es del orden de la dispersión por azar de estos datos (unas 50 etiquetas independientes), así que la prueba concluyente es la del k-fold barajado; la de las fronteras enseña dónde está la fuga cuando no se baraja. Parece pequeña en el promedio de todo el pliegue, pero en un problema con señal débil, que es el habitual, una fuga de unos puntos de AUC basta para convertir un modelo inútil en uno aparentemente rentable. Y crece con el horizonte de la etiqueta y con el número de pliegues, porque hay más fronteras.
De k-fold a validación combinatoria
Un k-fold purgado da k estimaciones, una por pliegue, que juntas forman un camino fuera de muestra: cada observación se prueba una vez. El walk-forward de la lección 2.5.2 también da un solo camino. La validación cruzada combinatoria purgada (CPCV, López de Prado, 2018) sale de esa limitación:
- Se parten los datos en N grupos contiguos.
- Se prueban todas las combinaciones de k grupos como conjunto de prueba: divisiones. En cada una se entrena con los N − k grupos restantes, purgados y embargados alrededor de cada grupo de prueba.
- Cada grupo aparece en la prueba en divisiones. Tomando, para cada grupo, su j-ésima aparición, se recomponen caminos completos que cubren toda la historia fuera de muestra.
Fuente Mermaid
flowchart TB D[Datos] --> G[N grupos] G --> C[C N k divisiones] C --> P[Purga y embargo] P --> E[Elegir en el resto] E --> T[Probar k grupos] T --> R[Rehacer caminos]
El número de caminos es
porque hay divisiones con k grupos de prueba cada una, grupos probados en total, y cada camino necesita N de ellos. La figura de la cabecera dibuja el caso N = 6, k = 2: 15 divisiones y 5 caminos, con el número de camino al que va cada grupo de prueba.
from itertools import combinations
from math import comb
tabla = {(N, k): {"divisiones": comb(N, k),
"caminos": comb(N - 1, k - 1),
"entreno (fracción)": round((N - k) / N, 2)}
for N in (6, 8, 10) for k in (1, 2, 3)}
pd.DataFrame(tabla).T.rename_axis(["N", "k"]) divisiones caminos entreno (fracción)
N k
6 1 6.0 1.0 0.83
2 15.0 5.0 0.67
3 20.0 10.0 0.50
8 1 8.0 1.0 0.88
2 28.0 7.0 0.75
3 56.0 21.0 0.62
10 1 10.0 1.0 0.90
2 45.0 9.0 0.80
3 120.0 36.0 0.70Con k = 1, la CPCV es un k-fold purgado: N divisiones, un camino. Subir k da más caminos, pero a costa de entrenar con menos datos (con N = 6 y k = 3 se entrena con la mitad). N = 10 y k = 2 es un compromiso razonable: 45 divisiones, 9 caminos y el 80 % de los datos para entrenar.
Las dos funciones de la CPCV, genéricas: una genera las divisiones con su entrenamiento purgado; la otra recompone los caminos a partir de los rendimientos fuera de muestra de cada (división, grupo).
def cpcv(n, N, k, t0, t1, embargo):
"""Para cada combinación de k grupos de prueba: (índice de la
división, grupos de prueba, posiciones de entrenamiento)."""
grupos = np.array_split(np.arange(n), N)
for d, prueba in enumerate(combinations(range(N), k)):
bloques = [grupos[g] for g in prueba]
yield d, prueba, grupos, purgar(t0, t1, bloques, embargo)
def caminos(N, k, oos):
"""oos[(división, grupo)] = rendimientos de ese grupo cuando se
probó en esa división. Devuelve los caminos completos."""
divisiones = list(combinations(range(N), k))
salida = []
for j in range(comb(N - 1, k - 1)):
partes = []
for g in range(N):
con_g = [d for d, p in enumerate(divisiones) if g in p]
partes.append(oos[(con_g[j], g)])
salida.append(pd.concat(partes))
return salidaCPCV para elegir un parámetro
La aplicación natural para una estrategia no es un clasificador, sino el procedimiento de la lección 2.5.2: elegir el lookback L del momentum de futuros con el Sharpe de los datos de entrenamiento y aplicarlo a los grupos de prueba. La etiqueta de cada observación diaria es el rendimiento del día siguiente (), así que la purga apenas quita nada; el embargo, de 63 sesiones, cubre parte del solape de los rasgos (el lookback más largo es de 378).
fut = pd.read_parquet("futuros2_continuos.parquet")
precio = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
rf = precio.pct_change(fill_method=None)
vol = rf.ewm(span=60, min_periods=60).std() * np.sqrt(252)
def sharpe(x):
return x.mean() / x.std() * np.sqrt(252)
variantes = {}
for n in (10, 21, 42, 63, 126, 189, 252, 378):
senal = np.sign(precio.pct_change(n, fill_method=None))
peso = (senal * 0.40 / vol / precio.shape[1]).shift(1)
variantes[n] = (peso * rf).sum(axis=1, min_count=1)
variantes = pd.DataFrame(variantes).loc["2002":]
def cpcv_eleccion(var, N=10, k=2, embargo=63):
n = len(var)
p = np.arange(n)
oos, elegidos = {}, []
for d, prueba, grupos, entreno in cpcv(n, N, k, p + 1, p + 1,
embargo):
mejor = sharpe(var.iloc[entreno]).idxmax()
elegidos.append(mejor)
for g in prueba:
oos[(d, g)] = var.iloc[grupos[g]][mejor]
sh = pd.Series([sharpe(c) for c in caminos(N, k, oos)])
return sh, pd.Series(elegidos).value_counts()
sh_fut, elegidos = cpcv_eleccion(variantes)
print("Sharpe de los 9 caminos:", sh_fut.round(2).tolist())
print(f"mediana {sh_fut.median():.2f}, mín. {sh_fut.min():.2f}, "
f"máx. {sh_fut.max():.2f}")
print("Elecciones en las 45 divisiones:", elegidos.to_dict())Sharpe de los 9 caminos: [1.06, 1.06, 0.97, 0.84, 1.06, 1.06, 1.06, 1.06, 1.06]
mediana 1.06, mín. 0.84, máx. 1.06
Elecciones en las 45 divisiones: {252: 43, 189: 2}Los nueve caminos son positivos y la elección es muy estable: 252, el valor plantado, gana en 43 de las 45 divisiones, y 189, su vecino, en las otras dos. Por eso siete caminos dan exactamente 1,06, el Sharpe de la variante de 252 en toda la muestra (lección 2.5.2): si todas las divisiones eligen lo mismo, cada camino es la misma serie, cortada en grupos y vuelta a pegar. Solo los dos caminos que pasan por las divisiones que eligieron 189 se apartan, hasta 0,97 y 0,84. La dispersión entre caminos es la información que un walk-forward no da, pero hay que leerla bien: en una CPCV mide cuánto cambia la elección de una división a otra, no qué años caen juntos en la prueba. Aquí es pequeña porque el efecto es real y estable; el coste de elegir se reduce a esas dos divisiones.
El contraste con el ruido, otra vez sobre ruido2_estrategias.parquet: elegir la mejor de 1.000 en el entrenamiento y
probarla en los grupos de prueba. Con 1.003 sesiones, cada grupo tiene unas 100.
est = pd.read_parquet("ruido2_estrategias.parquet").pivot(
index="fecha", columns="estrategia", values="rendimiento")
sh_ruido, eleg_ruido = cpcv_eleccion(est, embargo=5)
print("Sharpe de los 9 caminos:", sh_ruido.round(2).tolist())
print(f"mediana {sh_ruido.median():.2f}; "
f"{len(eleg_ruido)} estrategias distintas en 45 divisiones")Sharpe de los 9 caminos: [-1.3, 0.14, -0.75, -0.35, 0.56, -0.46, -0.2, 0.31, -0.27]
mediana -0.27; 11 estrategias distintas en 45 divisionesEn el ruido, la mediana de los caminos es negativa y el rango va de pérdidas fuertes a ganancias moderadas: una distribución centrada cerca de cero, como corresponde a un procedimiento sin ventaja. La elección cambia de una división a otra (11 estrategias distintas), y por eso los caminos se separan tanto. Un solo camino, el que habría dado un walk-forward, podría haber caído en cualquier punto del rango; si hubiera caído en el mejor, el procedimiento habría parecido prometedor.
Lo que la CPCV es y lo que no
La CPCV no simula la historia. En la mayoría de las divisiones se entrena también con datos posteriores a la prueba: para el grupo de 2005 se elige el parámetro con datos de 2010 o de 2020. Eso es legítimo para estimar el rendimiento de un procedimiento si el proceso que genera los datos es estable, y es optimista si no lo es (un efecto que se descubre en 2015 y se arbitra después ayuda a «predecir» 2005). Por eso las dos herramientas se complementan:
| Herramienta | Qué estima | Ventaja | Límite |
|---|---|---|---|
| Walk-forward (2.5.2) | La historia que se habría vivido eligiendo en tiempo real | Respeta la flecha del tiempo; se parece a la producción | Un solo camino; los primeros años entrenan con poco |
| k-fold purgado | El rendimiento medio del procedimiento | Usa todos los datos para entrenar y probar | Un solo camino; entrena con el futuro |
| CPCV | La distribución del rendimiento del procedimiento | Varios caminos; permite medir dispersión y probabilidad de sobreajuste | Entrena con el futuro; caminos correlacionados entre sí |
Los caminos de la CPCV no son independientes (comparten datos de entrenamiento y de prueba), así que su dispersión no es un intervalo de confianza en sentido estricto. Sirve para comparar procedimientos y para alimentar medidas como la probabilidad de sobreajuste del backtest, que la lección 2.5.4 calcula con una técnica emparentada (CSCV).
Ejercicios
- La fuga del k-fold barajado necesita etiquetas solapadas. Compruébalo: repite el k-fold barajado con etiquetas de 1, 5, 20 y 60 sesiones, con los mismos rasgos. ¿Qué pasa con la etiqueta de una sola sesión?
Solución
def auc_barajado(h):
fut_h = r[::-1].rolling(h).sum()[::-1].shift(-1)
d = rasgos.assign(futuro=fut_h).dropna()
Xh = d[rasgos.columns].to_numpy()
yh = (d["futuro"] > 0).astype(int).to_numpy()
res = []
for a, b in KFold(10, shuffle=True, random_state=0).split(Xh):
m = modelo().fit(Xh[a], yh[a])
res.append(roc_auc_score(yh[b], m.predict_proba(Xh[b])[:, 1]))
return np.mean(res)
pd.Series({h: auc_barajado(h) for h in (1, 5, 20, 60)},
name="AUC barajado").rename_axis("H").round(3)H
1 0.507
5 0.790
20 0.872
60 0.928
Name: AUC barajado, dtype: float64Con una etiqueta de una sola sesión, el k-fold barajado no encuentra nada (AUC 0,51): los rasgos de dos días seguidos siguen
siendo casi iguales, pero sus etiquetas son independientes, así que reconocer a la vecina no dice nada de la
respuesta. La fuga aparece en cuanto las etiquetas se solapan (0,79 con 5 sesiones) y crece con el horizonte (0,87 con 20 y 0,93
con 60), porque las vecinas comparten
una parte cada vez mayor de la respuesta. Es la razón de que la purga se defina sobre los intervalos de las etiquetas,
t0 y t1, y de que importe sobre todo con etiquetas largas (rendimientos a semanas o meses, barreras de varios
días).
- Calcula cuántos caminos y cuántas divisiones da la CPCV con N = 12 y k = 3, y qué fracción de los datos queda para entrenar antes de purgar. ¿Te parece buena configuración para 1.003 sesiones?
Solución
N, k = 12, 3
print(f"divisiones {comb(N, k)}, caminos {comb(N - 1, k - 1)}, "
f"entreno {(N - k) / N:.0%}, sesiones por grupo "
f"{1003 // N}")divisiones 220, caminos 55, entreno 75%, sesiones por grupo 83Salen 220 divisiones y 55 caminos, entrenando con el 75 % de los datos. Con 1.003 sesiones, cada grupo tiene 83 sesiones: el Sharpe de cada grupo es muy ruidoso, y la purga y el embargo alrededor de tres grupos de prueba quitan una parte apreciable del entrenamiento. Más caminos no son más información independiente: los 55 comparten casi todo. Para series cortas, N y k pequeños (N = 6 a 10, k = 2) suelen bastar.
- En la CPCV de los futuros, el embargo de 63 sesiones no cubre el lookback más largo (378). Explica qué fuga queda y cómo comprobarías si importa.
Solución
Las sesiones de entrenamiento entre 63 y 378 días después de un grupo de prueba tienen señales de momentum calculadas
en parte con precios del grupo de prueba. Su rendimiento no se solapa con el de la prueba (la etiqueta es de un día),
pero el Sharpe de entrenamiento de cada variante depende de esas señales. Para comprobar si importa, se repite la CPCV
con embargo=378 y se comparan las elecciones y la distribución de los caminos: si apenas cambian, la fuga es
despreciable para esta elección; si cambian mucho, el embargo corto estaba ayudando a elegir.
Respuesta plegada en cada pregunta
Autoevaluación
Con etiquetas de 60 sesiones (rendimiento de los 60 días siguientes), ¿por qué barajar las observaciones antes de un k-fold infla el resultado?
- APorque barajar cambia la distribución de las etiquetas
- BPorque cada observación de prueba tiene en el entrenamiento vecinas de los días de al lado, con rasgos casi iguales y una etiqueta que comparte 59 de sus 60 días: el modelo la reconoce, no la predice
- CPorque los árboles de decisión no admiten datos barajados
- DPorque reduce el tamaño del entrenamiento
Ver respuesta
Respuesta B. Porque cada observación de prueba tiene en el entrenamiento vecinas de los días de al lado, con rasgos casi iguales y una etiqueta que comparte 59 de sus 60 días: el modelo la reconoce, no la predice
En la serie de ruido, el k-fold barajado da un AUC de 0,93 donde no hay nada que predecir. Sin barajar, la fuga queda en las fronteras de cada pliegue; con purga y embargo desaparece.
¿Qué hace la purga?
- AQuita las observaciones atípicas del entrenamiento
- BQuita del entrenamiento las observaciones cuya etiqueta se solapa en el tiempo con alguna etiqueta del conjunto de prueba
- CQuita del conjunto de prueba las observaciones con etiqueta ambigua
- DNormaliza los rasgos de cada pliegue
Ver respuesta
Respuesta B. Quita del entrenamiento las observaciones cuya etiqueta se solapa en el tiempo con alguna etiqueta del conjunto de prueba
Si una etiqueta de entrenamiento usa rendimientos que también forman parte de una etiqueta de prueba, el modelo ha visto parte de la respuesta. La purga elimina ese solape a los dos lados del bloque de prueba.
¿Cuántos caminos fuera de muestra completos da una CPCV con N = 6 grupos y k = 2 grupos de prueba?
- A15
- B5
- C6
- D2
Ver respuesta
Respuesta B. 5
Hay C(6, 2) = 15 divisiones; cada grupo aparece en la prueba en C(5, 1) = 5 de ellas, así que se pueden recomponer k/N · C(N, k) = 5 caminos completos.
En la CPCV sobre los futuros, el Sharpe fuera de muestra de los 9 caminos va de 0,84 a 1,06; en el ruido, de −1,30 a 0,56. ¿Qué aporta esto frente a un walk-forward?
- ANada: el walk-forward da lo mismo
- BUna distribución en lugar de un número: se ve la dispersión que viene solo de qué datos caen en la prueba, y si el procedimiento es positivo en casi todos los caminos o solo en algunos
- CUn Sharpe más alto
- DLa seguridad de que no hay sobreajuste
Ver respuesta
Respuesta B. Una distribución en lugar de un número: se ve la dispersión que viene solo de qué datos caen en la prueba, y si el procedimiento es positivo en casi todos los caminos o solo en algunos
El walk-forward es un único camino histórico. La CPCV da varios con los mismos datos; en los futuros todos los caminos son positivos, en el ruido la mediana es negativa y la dispersión es enorme.
¿Qué límite tiene la CPCV frente al walk-forward?
- AQue no se puede programar en Python
- BQue entrena también con datos posteriores a la prueba: no simula lo que se habría hecho en tiempo real, sino que estima el rendimiento del procedimiento de elección, y supone que el proceso es estable
- CQue solo vale para modelos de aprendizaje automático
- DQue necesita datos diarios
Ver respuesta
Respuesta B. Que entrena también con datos posteriores a la prueba: no simula lo que se habría hecho en tiempo real, sino que estima el rendimiento del procedimiento de elección, y supone que el proceso es estable
Con un efecto que cambia en el tiempo, entrenar con el futuro puede ser optimista. Por eso conviene usar las dos herramientas: el walk-forward para la historia simulada y la CPCV para la distribución.
Ideas clave
- El k-fold supone observaciones independientes. Con etiquetas a varios días, las vecinas en el tiempo comparten casi toda la respuesta: barajar da un AUC de 0,93 en una serie de puro ruido.
- Sin barajar, la fuga se concentra en las fronteras de cada pliegue (AUC 0,56 en las fronteras sin purga frente a 0,50 con purga y embargo).
- Purga: fuera del entrenamiento toda observación cuya etiqueta se solape con la prueba. Embargo: fuera también las inmediatamente posteriores, cuyos rasgos se calcularon con datos de la prueba.
- La CPCV prueba todas las combinaciones de k grupos de N y recompone k/N · C(N, k) caminos fuera de muestra: una distribución del Sharpe en lugar de un número.
- En los futuros sintéticos (momentum plantado), los 9 caminos van de 0,84 a 1,06; en el ruido, la mediana es −0,27. La CPCV no sustituye al walk-forward: lo complementa.