---
title: "2.5.3 Validación cruzada combinatoria purgada (CPCV)"
description: "Purga, embargo y CPCV programados desde cero: por qué el k-fold filtra información con etiquetas solapadas y cómo obtener varios caminos fuera de muestra."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/validacion-cruzada-combinatoria-purgada-cpcv/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/validacion-cruzada-combinatoria-purgada-cpcv.png"
---

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

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.5 · Sesgos y validación
- Duración: 70 min
- Requisitos: [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [0.7.2 · Probabilidad y distribuciones, correlación frente a causalidad y regresión simple](https://pythonparatrading.com/curso-python-trading/nivel-0/probabilidad-distribuciones-y-regresion/), [0.5.1 · NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios](https://pythonparatrading.com/curso-python-trading/nivel-0/numpy-arrays-y-vectorizacion/)

## Al terminar esta lección

- Explicar por qué el k-fold estándar filtra información cuando las etiquetas abarcan varias sesiones y los rasgos se solapan, y medir esa fuga en datos sin efecto.
- Programar desde cero la purga (quitar del entrenamiento las observaciones cuya etiqueta se solapa con la prueba) y el embargo (quitar las inmediatamente posteriores).
- Construir la validación cruzada combinatoria purgada: divisiones, grupos de prueba y recomposición de caminos, y calcular cuántos caminos salen para N grupos y k de prueba.
- Aplicar CPCV a la elección de un parámetro y obtener la distribución del Sharpe fuera de muestra en una estrategia con efecto y en una de ruido.

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

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

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

Datos sintéticos con la respuesta conocida: la serie no tiene ninguna dependencia temporal después de la ruptura,
por construcción (README del nivel 2). Se usa para medir el método, no para buscar una señal.

</aside>

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:

```python
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))
```
```output
2994 observaciones; proporción de etiquetas 1: 0.586
```

El 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:

```python
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}")
```
```output
k-fold barajado: AUC medio 0.928
k-fold sin barajar: AUC medio 0.532
```

Barajado, 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 $[t_0, t_1]$ 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.

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

```python
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)
```
```output
                 AUC frontera  AUC centro
sin purga               0.558       0.499
purga y embargo         0.497       0.459
```

*Fig. — AUC fuera de muestra de un bosque aleatorio sobre una serie de puro ruido, con etiquetas de 60 sesiones, según cómo se valide.* Barras horizontales que salen de 0,5 (el azar). K-fold barajado: 0,928 (destacado). K-fold sin barajar: 0,532. Sin purga, observaciones de frontera 0,558 y de centro 0,499. Con purga y embargo, frontera 0,497 y centro 0,459.

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

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

`TimeSeriesSplit` de scikit-learn tiene un parámetro `gap` que deja un hueco entre entrenamiento y prueba: es una
purga del lado anterior, suficiente cuando la prueba siempre va después del entrenamiento (como en un walk-forward).
En un k-fold, con entrenamiento a los dos lados de la prueba, hace falta purgar los dos lados y embargar el posterior.

</aside>

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

1. Se parten los datos en N grupos contiguos.
2. Se prueban **todas** las combinaciones de k grupos como conjunto de prueba: $\binom{N}{k}$ divisiones. En cada una
   se entrena con los N − k grupos restantes, purgados y embargados alrededor de cada grupo de prueba.
3. Cada grupo aparece en la prueba en $\binom{N-1}{k-1}$ divisiones. Tomando, para cada grupo, su j-ésima aparición,
   se recomponen caminos completos que cubren toda la historia fuera de muestra.

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

$$
\varphi[N, k] = \frac{k}{N}\binom{N}{k} = \binom{N-1}{k-1}
$$

porque hay $\binom{N}{k}$ divisiones con k grupos de prueba cada una, $k\binom{N}{k}$ 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.

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

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

```python
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 salida
```

## CPCV 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 ($t_0 = t_1 = t + 1$), 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).

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

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

*Fig. — Sharpe fuera de muestra de los 9 caminos de una CPCV (N = 10, k = 2) que elige el mejor parámetro: momentum de futuros sintéticos frente a 1.000 estrategias de ruido.* Dos filas de marcas verticales. Futuros, eligiendo entre ocho lookbacks: nueve caminos entre 0,84 y 1,06, todos positivos, siete de ellos en 1,06, que es también la mediana. Ruido, eligiendo la mejor de 1.000: nueve caminos entre −1,30 y 0,56, mediana −0,27 (destacada).

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

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

La purga y el embargo no protegen de la anticipación en los rasgos. Si un rasgo usa información posterior a su fecha
(lección 2.5.1), ninguna validación cruzada lo detecta: la fuga ya está dentro de cada fila. La prueba del corte de la
lección 2.5.1 va antes que cualquier validación.

</aside>

## Ejercicios

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

```python
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)
```
```output
H
1     0.507
5     0.790
20    0.872
60    0.928
Name: AUC barajado, dtype: float64
```

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

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

```python
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}")
```
```output
divisiones 220, caminos 55, entreno 75%, sesiones por grupo 83
```

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

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

## Autoevaluación

1. 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?

   - A. Porque barajar cambia la distribución de las etiquetas
   - 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
   - C. Porque los árboles de decisión no admiten datos barajados
   - D. Porque reduce el tamaño del entrenamiento

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

2. ¿Qué hace la purga?

   - A. Quita las observaciones atípicas del entrenamiento
   - B. Quita del entrenamiento las observaciones cuya etiqueta se solapa en el tiempo con alguna etiqueta del conjunto de prueba
   - C. Quita del conjunto de prueba las observaciones con etiqueta ambigua
   - D. Normaliza los rasgos de cada pliegue

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

3. ¿Cuántos caminos fuera de muestra completos da una CPCV con N = 6 grupos y k = 2 grupos de prueba?

   - A. 15
   - B. 5
   - C. 6
   - D. 2

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

4. 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?

   - A. Nada: el walk-forward da lo mismo
   - 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
   - C. Un Sharpe más alto
   - D. La seguridad de que no hay sobreajuste

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

5. ¿Qué límite tiene la CPCV frente al walk-forward?

   - A. Que no se puede programar en Python
   - 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
   - C. Que solo vale para modelos de aprendizaje automático
   - D. Que necesita datos diarios

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

## Recursos

### Research

- [Introduccion al Quant Research](https://pythonparatrading.com/quant-research/) · Research-0023 · 8 min

### Documentación

- [scikit-learn: validación cruzada (KFold, TimeSeriesSplit y el supuesto de independencia)](https://scikit-learn.org/stable/modules/cross_validation.html) · scikit-learn.org
- [scikit-learn: TimeSeriesSplit (parámetro gap)](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) · scikit-learn.org

### Libros

- [López de Prado, M. (2018): Advances in Financial Machine Learning. Wiley (capítulos 7 y 12: validación cruzada purgada y CPCV)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Datos

- [Datos del nivel 2: regímenes y ruptura estructural (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/regimenes2.parquet) · Descarga · regimenes2.parquet
- [Datos del nivel 2: futuros continuos, 24 mercados, 2000-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Descarga · futuros2_continuos.parquet

Lección anterior: [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/)

Lección siguiente: [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/)
