---
title: "5.4.1 Por qué falla el k-fold aleatorio"
description: "Un random forest acierta el 59,7 % con k-fold barajado y el 50,1 % con purga, en un panel donde el techo es 51 %: de dónde sale la fuga."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-falla-el-k-fold-aleatorio/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/por-que-falla-el-k-fold-aleatorio.png"
---

# 5.4.1 · Por qué falla el k-fold aleatorio en datos financieros

> Barajar las filas antes de validar es lo primero que enseña cualquier curso de machine learning, y en un panel de acciones con etiquetas a 21 sesiones es la forma más rápida de fabricar un modelo que no existe. Esta lección reproduce el caso del README del nivel, mide sus dos ingredientes y enseña que la validación equivocada no solo infla el resultado: también elige el modelo equivocado.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.4 · Validación para series financieras
- Duración: 60 min
- Requisitos: [2.5.3 · Validación cruzada combinatoria purgada para estrategias](https://pythonparatrading.com/curso-python-trading/nivel-2/validacion-cruzada-combinatoria-purgada-cpcv/), [5.3.1 · Etiquetado de horizonte fijo y sus problemas](https://pythonparatrading.com/curso-python-trading/nivel-5/etiquetado-de-horizonte-fijo/), [5.1.2 · Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn](https://pythonparatrading.com/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn/)

## Al terminar esta lección

- Reproducir el contraste del panel del nivel 5: k-fold barajado frente a k-fold por bloques y purged k-fold, y situarlo frente al techo del oráculo.
- Medir los dos ingredientes de la fuga: el solape de las etiquetas (acuerdo de la etiqueta con la de la misma acción k semanas antes) y la persistencia de las features.
- Comprobar que agrupar por fecha no basta en un panel, porque la fuga viene de las semanas vecinas, no de las acciones vecinas.
- Ver cómo una validación con fuga premia al modelo que más memoriza y por qué una validación honesta no distingue entre hiperparámetros cuando no hay señal que aprender.

## Un acierto que no puede existir

La lección 2.5.3 midió la fuga del k-fold barajado en una serie de puro ruido: un AUC de 0,93 donde no había nada que
predecir. Aquí el problema es el de un modelo de machine learning de verdad: un panel de 400 acciones con 22 features
semanales, una señal pequeña plantada y una pregunta de clasificación como las de 5.3.1, «¿quedará esta acción por
encima de la mediana de su semana en las próximas 21 sesiones?». El README de los datos del nivel publica el resultado
de un random forest con tres validaciones; esta lección lo reproduce y lo desmonta.

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

`panel5_*` son datos sintéticos con la respuesta conocida: el alfa de cada acción y semana está en
`panel5_verdad.parquet`, y el README del nivel describe el modelo que lo genera. Sirven para medir métodos, no para
buscar estrategias.

</aside>

El conjunto es el del README: 80 acciones fijas (elegidas con la semilla del generador) en las 725 semanas que tienen
features y etiqueta de 21 sesiones completas, 21 features sin `revision_analistas` (la fuga plantada, que se deja para
el final) y una etiqueta binaria relativa a la mediana de las 400 acciones de esa semana. Al ser relativa, el
movimiento común del mercado desaparece de la respuesta: lo que se pide es ordenar acciones, no adivinar el índice.

```python
import zlib
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, KFold

feat = pd.read_parquet("panel5_features.parquet")
etiq = pd.read_parquet("panel5_etiquetas.parquet")
d = feat.merge(etiq, on=["fecha", "ticker"])

# semanas completas: features y etiqueta de 21 sesiones en las 400
completa = (d.groupby("fecha")[["ret_1s", "ret_sig_21d"]]
            .transform(lambda s: s.notna().all()).all(axis=1))
d = d[completa].copy()
for col, med in (("ret_sig_21d", "med_21d"),
                 ("ret_sig_semana", "med_sem")):
    d[med] = d.groupby("fecha")[col].transform("median")

# las 80 acciones del README (la semilla del generador)
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))
y = (d["ret_sig_21d"] > d["med_21d"]).astype(int).to_numpy()
semana = d["fecha"].rank(method="dense").astype(int).to_numpy() - 1
print(f"{len(d)} filas = {d['ticker'].nunique()} acciones x "
      f"{semana.max() + 1} semanas; {len(FEATS)} features; "
      f"proporción de unos {y.mean():.3f}")
```
```output
58000 filas = 80 acciones x 725 semanas; 21 features; proporción de unos 0.498
```

El modelo es el del README: un bosque de 60 árboles con hojas de al menos 5 observaciones, cada árbol entrenado con
un 30 % de las filas. Fija `random_state` y el número de hilos, así que el resultado es el mismo en cada ejecución.
Las tres validaciones, con 5 pliegues cada una:

- **k-fold barajado**: `KFold(5, shuffle=True)`, el de cualquier tutorial.
- **k-fold por bloques**: `KFold(5)` sin barajar; como las filas van ordenadas por fecha, cada pliegue son 145 semanas
  seguidas.
- **purged k-fold**: los mismos bloques, quitando del entrenamiento las 4 semanas de cada lado del bloque de prueba
  (la regla del README; la lección 5.4.2 la programa bien, con las fechas de fin de cada etiqueta).

```python
def bosque(hoja=5):
    return RandomForestClassifier(
        n_estimators=60, min_samples_leaf=hoja, max_features="sqrt",
        max_samples=0.3, n_jobs=4, random_state=0)

def acierto(divisiones, Xv=X, yv=y, hoja=5):
    """Acierto medio fuera de muestra de los pliegues."""
    res = []
    for ent, pru in divisiones:
        m = bosque(hoja).fit(Xv[ent], yv[ent])
        res.append((m.predict(Xv[pru]) == yv[pru]).mean())
    return float(np.mean(res))

barajado = list(KFold(5, shuffle=True, random_state=0).split(X))
bloques = list(KFold(5).split(X))
purgado = []
for b in np.array_split(np.arange(semana.max() + 1), 5):
    lejos = (semana < b[0] - 4) | (semana > b[-1] + 4)
    purgado.append((np.flatnonzero(lejos),
                    np.flatnonzero(np.isin(semana, b))))

res = {"k-fold barajado": acierto(barajado),
       "k-fold por bloques": acierto(bloques),
       "purged k-fold": acierto(purgado)}
for nombre, a in res.items():
    print(f"{nombre:<20}{a:.1%}")
```
```output
k-fold barajado     59.7%
k-fold por bloques  50.0%
purged k-fold       50.1%
```

Barajado, el bosque acierta casi seis de cada diez; por bloques, la mitad, como una moneda. La pregunta es cuál de
las dos cifras es la verdad, y en este panel no hace falta fiarse de ninguna: se puede calcular el techo.

## El techo: lo que acertaría el oráculo

`panel5_verdad.parquet` trae `alfa_verdadero`, el rendimiento esperado extra de cada acción y semana por los efectos
plantados. Es la mejor previsión posible de la parte predecible del rendimiento: un modelo perfecto la recuperaría
entera y no podría hacer nada más, porque el resto es ruido. Si se clasifica cada acción por encima o por debajo de
la mediana del alfa de su semana, el acierto frente a la etiqueta es el máximo alcanzable por cualquier modelo fuera de
muestra (con la misma precisión de etiqueta: la de 21 sesiones recoge además alfas de semanas posteriores, que el
oráculo de esta semana no ve, así que es un techo aproximado).

```python
verdad = pd.read_parquet("panel5_verdad.parquet",
                         columns=["fecha", "ticker", "alfa_verdadero"])
verdad["med_alfa"] = (verdad.groupby("fecha")["alfa_verdadero"]
                      .transform("median"))
o = d[["fecha", "ticker"]].merge(verdad, on=["fecha", "ticker"])
acierta = ((o["alfa_verdadero"] > o["med_alfa"]).to_numpy() == y)
reciente = (d["fecha"] >= "2019-01-01").to_numpy()
print(f"oráculo: {acierta.mean():.1%} "
      f"(2019-2025: {acierta[reciente].mean():.1%})")
error_iid = np.sqrt(0.25 / len(y))
print(f"error típico si las filas fueran independientes: "
      f"{error_iid:.2%}")
```
```output
oráculo: 51.1% (2019-2025: 50.5%)
error típico si las filas fueran independientes: 0.21%
```

El oráculo acierta el 51,1 %. Es la cifra que pone todo en su sitio: el 59,7 % del k-fold barajado está ocho puntos
por encima de lo que permite la información que existe, con un error típico (si las filas fueran independientes, que
no lo son) de dos décimas. No es un modelo excelente: es una validación rota. El 50,0 % y el 50,1 % de los bloques,
por debajo del techo, son lo que el bosque consigue de verdad, que es casi nada.

La figura de la cabecera resume las cuatro cifras, con el techo como referencia.

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

Con datos reales no hay oráculo, pero sí un orden de magnitud: aciertos de 51-53 % e IC de unas centésimas son lo
habitual en predicción transversal de rendimientos (lección 5.1.1). Un 60 % de acierto semanal en acciones es una
alarma, no un resultado. Antes de celebrarlo hay que buscar la fuga.

</aside>

## Anatomía del solape: cinco semanas por etiqueta

La etiqueta de una fila es el rendimiento desde el cierre de `fecha` hasta el de 21 sesiones después, `fin_sig_21d`.
Las features son semanales. Por tanto, la etiqueta de un viernes abarca esa semana y las cuatro siguientes, y la de la
semana siguiente comparte con ella cuatro de sus cinco semanas:

```python
una = d[d["ticker"] == elegidas[0]].set_index("fecha")
tabla = una.loc["2019-03-01":"2019-03-29",
                ["fin_sig_21d", "ret_sig_21d", "med_21d"]].copy()
tabla["etiqueta"] = (tabla["ret_sig_21d"]
                     > tabla.pop("med_21d")).astype(int)
print(elegidas[0])
tabla.round({"ret_sig_21d": 4})
```
```output
BAKU
           fin_sig_21d  ret_sig_21d  etiqueta
fecha
2019-03-01  2019-04-01      -0.0860         0
2019-03-08  2019-04-08      -0.0042         1
2019-03-15  2019-04-15      -0.0771         0
2019-03-22  2019-04-23      -0.0905         0
2019-03-29  2019-04-30      -0.0156         1
```

*Fig. — Cinco etiquetas de 21 sesiones seguidas de una acción del panel (marzo de 2019): cada una comparte cuatro de sus cinco semanas con la siguiente.* Cinco barras horizontales, una por fecha de fila del 1 al 29 de marzo de 2019, que van de la fecha de la fila al fin de su etiqueta, 21 sesiones después (del 1 de abril al 30 de abril). La del 15 de marzo, destacada, es la de prueba; las demás, de entrenamiento, se solapan con ella casi entera. Etiquetas: 0, 1, 0, 0, 1.

El horizonte se cuenta en sesiones, no en días: la etiqueta del 22 de marzo termina el 23 de abril porque el 19 de
abril de 2019 (Viernes Santo) la NYSE no abrió. La figura dibuja esas cinco etiquetas como intervalos. Si la del 15 de
marzo cae en el conjunto de prueba y las de las dos semanas anteriores y posteriores en el de entrenamiento, el bosque
tiene para entrenar cuatro filas de la misma acción cuya respuesta se calcula en su mayor parte con los mismos
rendimientos. En este ejemplo dos de las cuatro tienen la misma etiqueta que la del 15; en todo el panel, la
proporción es mucho mayor para las semanas contiguas. Cuánto se parecen esas respuestas se
mide directamente: el acuerdo de la etiqueta de cada fila con la de la misma acción k semanas antes.

```python
y_sem = (d["ret_sig_semana"] > d["med_sem"]).astype(int).to_numpy()
etiquetas = pd.DataFrame({"ticker": d["ticker"],
                          "21 sesiones": y, "1 semana": y_sem})
acuerdo = {}
for k in range(1, 7):
    antes = etiquetas.groupby("ticker")[["21 sesiones",
                                         "1 semana"]].shift(k)
    hay = antes.notna().all(axis=1)
    acuerdo[k] = (antes[hay] == etiquetas.loc[hay, ["21 sesiones",
                                                    "1 semana"]]
                  ).mean()
pd.DataFrame(acuerdo).T.rename_axis("k").round(3)
```
```output
   21 sesiones  1 semana
k
1        0.777     0.496
2        0.680     0.503
3        0.601     0.501
4        0.528     0.502
5        0.502     0.501
6        0.504     0.498
```

*Fig. — Proporción de filas del panel cuya etiqueta coincide con la de la misma acción k semanas antes: etiqueta de 21 sesiones (solapada) frente a la de una semana.* Dos curvas sobre k de 1 a 6 semanas. Con la etiqueta de 21 sesiones, el acuerdo es 77,7 % con la semana anterior (destacado), 68,0 % con k = 2, 60,1 % con k = 3, 52,8 % con k = 4 y 50 % desde k = 5, cuando las etiquetas dejan de solaparse. Con la etiqueta de una semana, el acuerdo está en el 50 % desde k = 1.

Con la etiqueta de 21 sesiones, la fila de la semana anterior tiene la misma respuesta el 77,7 % de las veces; dos
semanas antes, el 68 %; el acuerdo cae a la mitad en la quinta semana, cuando las dos etiquetas ya no comparten ningún
rendimiento. Con la etiqueta de una semana, que no se solapa, el acuerdo es del 50 % desde la primera. Es la misma
acción y las mismas features: lo único que cambia es si la respuesta se comparte.

## El segundo ingrediente: features que cambian poco

Para que el modelo aproveche ese acuerdo tiene que **reconocer** que la fila de prueba es la vecina de una de
entrenamiento. Lo consigue si las features de una semana se parecen mucho a las de la anterior. La autocorrelación
semanal de cada feature, dentro de cada acción, lo mide:

```python
persist = {c: d.groupby("ticker")[c]
           .apply(lambda s: s.autocorr(1)).mean() for c in FEATS}
persist = pd.Series(persist, name="autocorr. semanal")
print(persist.sort_values(ascending=False).round(2).to_string())
```
```output
valor_bp              0.99
log_capitalizacion    0.99
roe                   0.99
ey                    0.99
ruido_3               0.97
mom_12_1              0.97
vol_63d               0.97
dist_max_52s          0.96
sue                   0.94
ret_3m                0.92
ruido_2               0.89
vol_21d               0.88
ruido_5               0.77
ret_1m                0.77
rsi_14                0.63
dias_desde_informe    0.63
sentimiento           0.59
volumen_rel           0.46
ruido_4               0.33
ret_1s                0.03
ruido_1               0.00
```

*Fig. — Persistencia de las 21 features del panel: autocorrelación de una semana a la siguiente dentro de cada acción.* Barras horizontales ordenadas de mayor a menor. valor_bp, log_capitalizacion, roe y ey: 0,99. ruido_3 (un ruido AR(1) sin efecto, destacado), mom_12_1 y vol_63d: 0,97. dist_max_52s 0,96, sue 0,94, ret_3m 0,92, ruido_2 0,89, vol_21d 0,88, ruido_5 y ret_1m 0,77, rsi_14 y dias_desde_informe 0,63, sentimiento 0,59, volumen_rel 0,46, ruido_4 0,33, ret_1s 0,03 y ruido_1 0,00.

Casi todas las features son lentas: el value, el tamaño, los ratios contables y el momentum de 12 meses tienen
autocorrelación semanal de 0,94 a 0,99, y `ruido_3`, un ruido AR(1) sin ningún efecto, 0,97. Juntas forman una huella
que identifica a cada acción en cada tramo de tiempo. Solo `ret_1s` (0,03) y `ruido_1` (0,00) se renuevan por
completo cada semana; `ruido_4` y `volumen_rel`, a medias. Para un bosque con hojas pequeñas, encontrar a la vecina es tan fácil como para un humano reconocer una
cara.

```mermaid
flowchart TB
  A[Etiqueta solapada] --> C[Misma respuesta]
  B[Features lentas] --> D[Vecina reconocible]
  C --> E[k-fold barajado]
  D --> E
  E --> F[Acierto inflado]
```

## Los dos ingredientes, por separado

Si la explicación es correcta, quitando cualquiera de los dos ingredientes la fuga desaparece, y con solo el segundo
basta para fabricar acierto con columnas que no tienen ningún efecto. Tres experimentos, siempre con el k-fold
barajado:

- la etiqueta de **una semana** (sin solape) con las mismas 21 features;
- la etiqueta de 21 sesiones con **cuatro features persistentes y sin efecto**: `ruido_2`, `ruido_3`, `ruido_5` y
  `log_capitalizacion`;
- la etiqueta de 21 sesiones con **tres features rápidas**: `ret_1s`, `ruido_1` y `volumen_rel`.

```python
def columnas(cols):
    return np.nan_to_num(d[cols].to_numpy(dtype=float))

lentas = ["ruido_2", "ruido_3", "ruido_5", "log_capitalizacion"]
rapidas = ["ret_1s", "ruido_1", "volumen_rel"]
exp = {
    "21 sesiones, 21 features": res["k-fold barajado"],
    "1 semana, 21 features": acierto(barajado, yv=y_sem),
    "21 sesiones, 4 lentas sin efecto":
        acierto(barajado, Xv=columnas(lentas)),
    "21 sesiones, 3 rápidas": acierto(barajado, Xv=columnas(rapidas)),
}
for nombre, a in exp.items():
    print(f"{nombre:<34}{a:.1%}")
```
```output
21 sesiones, 21 features          59.7%
1 semana, 21 features             50.0%
21 sesiones, 4 lentas sin efecto  53.5%
21 sesiones, 3 rápidas            49.8%
```

| Etiqueta | Features | k-fold barajado | Qué falta |
|---|---|---|---|
| 21 sesiones | las 21 | 59,7 % | nada: fuga completa |
| 1 semana | las 21 | 50,0 % | el solape de etiquetas |
| 21 sesiones | 4 lentas sin efecto | 53,5 % | la señal (no hace falta) |
| 21 sesiones | 3 rápidas | 49,8 % | la persistencia |

Con la etiqueta de una semana el k-fold barajado se vuelve honesto: 50,0 %, el mismo resultado que por bloques. Con
features rápidas, también. Y con cuatro columnas que no tienen ningún efecto en los rendimientos (tres ruidos y el
tamaño, que en este panel no paga prima), el bosque llega al 53,5 %: por encima del oráculo, sin ninguna información
sobre el futuro. La fuga no necesita señal; necesita una huella y una respuesta compartida.

## Agrupar por fecha no basta

Un panel tiene dos dimensiones, y la intuición habitual es proteger la transversal: que las 80 acciones de una misma
semana no se repartan entre entrenamiento y prueba, porque comparten los movimientos del mercado y del sector de esa
semana. Es lo que hace `GroupKFold` con la semana como grupo: cada semana entera cae en un solo pliegue. Con
`shuffle=True`, las semanas se reparten al azar entre los pliegues.

```python
gkf = GroupKFold(5, shuffle=True, random_state=0)
por_semana = list(gkf.split(X, y, groups=semana))
comunes = [np.intersect1d(semana[a], semana[b]).size
           for a, b in por_semana]
print(f"semanas a los dos lados, por pliegue: {comunes}")
print(f"GroupKFold por semana, barajado: {acierto(por_semana):.1%}")
```
```output
semanas a los dos lados, por pliegue: [0, 0, 0, 0, 0]
GroupKFold por semana, barajado: 59.2%
```

Ninguna semana está a los dos lados, y el acierto apenas se mueve: 59,2 %. La fuga no era transversal. La etiqueta es
relativa a la mediana de la semana, así que el componente común ya estaba fuera de la respuesta; lo que el bosque
explota es la **misma acción** en las **semanas de alrededor**, que siguen cayendo en el entrenamiento cuando las
semanas se barajan. En un panel financiero el grupo que importa es el tiempo, y no como etiqueta de grupo sino como
orden: los pliegues tienen que ser bloques contiguos, con un margen alrededor.

| Esquema | Qué protege | Acierto |
|---|---|---|
| `KFold(shuffle=True)` | nada | 59,7 % |
| `GroupKFold(shuffle=True)` por semana | acciones de la misma semana | 59,2 % |
| `KFold` por bloques de tiempo | casi todo; queda la frontera | 50,0 % |
| bloques con purga de 4 semanas | también la frontera | 50,1 % |

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

La dimensión transversal también puede filtrar, en otros diseños: si la etiqueta fuera el rendimiento absoluto (sin
restar la mediana), las acciones de una misma semana compartirían el movimiento del mercado, y repartirlas entre
entrenamiento y prueba enseñaría al modelo el índice de esa semana. Los bloques de tiempo resuelven las dos cosas a la
vez, porque cada semana entera queda a un lado.

</aside>

## La validación equivocada elige el modelo equivocado

La fuga no solo infla una cifra. Si la validación se usa para elegir hiperparámetros (lección 5.5.4), elige lo que
mejor aprovecha la fuga. El hiperparámetro que controla cuánto puede memorizar un bosque es el tamaño mínimo de hoja:
con hojas de una observación, cada árbol puede aislar a una acción en un tramo de semanas concreto. Cuatro tamaños,
con las dos validaciones:

```python
hojas = {}
for hoja in (1, 5, 50, 500):
    if hoja == 5:  # ya calculado arriba
        hojas[hoja] = (res["k-fold barajado"], res["purged k-fold"])
    else:
        hojas[hoja] = (acierto(barajado, hoja=hoja),
                       acierto(purgado, hoja=hoja))
pd.DataFrame(hojas, index=["barajado", "purgado"]).T.rename_axis(
    "hoja mínima").map(lambda a: f"{a:.1%}")
```
```output
            barajado purgado
hoja mínima
1              60.4%   50.3%
5              59.7%   50.1%
50             55.7%   50.2%
500            52.3%   50.2%
```

*Fig. — Acierto del random forest del panel según el tamaño mínimo de hoja: la validación barajada premia memorizar; la purgada no distingue entre tamaños.* Dos curvas sobre el tamaño mínimo de hoja en escala logarítmica (1, 5, 50 y 500). K-fold barajado: 60,4 % con hojas de 1 (destacado), 59,7 % con 5, 55,7 % con 50 y 52,3 % con 500. Purged k-fold: entre 50,1 % y 50,3 %, plano, por debajo de la línea del oráculo en 51,1 %.

El k-fold barajado tiene una preferencia clara y monótona: cuanto más pequeña la hoja, más acierto, del 52,3 % con
hojas de 500 al 60,4 % con hojas de 1. Un ajuste de hiperparámetros con esa validación elegiría el bosque que más
memoriza y lo presentaría con un 60 %. El purged k-fold da entre el 50,1 % y el 50,3 % con los cuatro tamaños:
ninguno destaca, porque el bosque no encuentra en estas features una señal que pueda aprovechar fuera de muestra. Es
la respuesta correcta, aunque decepcione, y la que hay que tomar en serio: la lección 5.5.1 enseña que una regresión
lineal con las features en rangos sí recoge parte de la señal (IC de 0,0185 en el README), y la 5.4.2 lo comprueba con
la validación purgada.

## Qué hacer en la práctica

1. **Nada de barajar filas** en un problema con etiquetas que abarcan varios periodos o con features persistentes,
   que en finanzas son casi todos. Pliegues contiguos en el tiempo.
2. **Purgar y embargar** alrededor de cada bloque de prueba con las fechas reales de inicio y fin de cada etiqueta, no
   con una regla aproximada (lección 5.4.2).
3. **Walk-forward** para la historia que se habría vivido en tiempo real, con reentrenamiento (lección 5.4.3).
4. **Comparar con un techo y con una baseline**: en datos sintéticos, el oráculo; en datos reales, el orden de
   magnitud de la literatura y un modelo lineal simple. Un resultado muy por encima de los dos es una fuga mientras no
   se demuestre lo contrario.
5. **Auditar cuándo se conoce cada dato** antes de validar nada. La purga protege del solape de las etiquetas, no de
   una feature mal fechada.

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

El README del nivel lo mide con `revision_analistas`, una feature que el proveedor fecha al inicio de la semana pero
que contiene los dos primeros días de la siguiente: con ella, el mismo bosque da 51,5 % en el purged k-fold y 51,8 % en
el walk-forward, por encima del oráculo en las dos validaciones honestas. Ninguna validación la detecta, porque la fuga
está dentro de cada fila. Se descubre con la auditoría point-in-time de la lección 5.2.2.

</aside>

## Ejercicios

1. Con la etiqueta de 21 sesiones, el k-fold barajado da 53,5 % con cuatro features lentas sin efecto. ¿Basta con una
   sola? Repítelo solo con `log_capitalizacion` y solo con `ruido_3`, y explica la diferencia con su persistencia.

**Solución.**

```python
for col in ("log_capitalizacion", "ruido_3"):
    a = acierto(barajado, Xv=columnas([col]))
    print(f"{col:<20}autocorr. {persist[col]:.2f}  "
          f"barajado {a:.1%}")
```
```output
log_capitalizacion  autocorr. 0.99  barajado 51.1%
ruido_3             autocorr. 0.97  barajado 50.4%
```

Con una sola columna la huella es mucho más pobre: 51,1 % con el tamaño y 50,4 % con `ruido_3`. Muchas acciones tienen valores parecidos en una dimensión y el
bosque no puede distinguir a la vecina de otras filas con la respuesta contraria. La fuga crece con el número de
features persistentes porque cada una añade una coordenada a la huella; con cuatro ya supera al oráculo. Es la razón
de que los paneles con muchas features contables y de riesgo, que cambian poco, sean los más expuestos.

2. Las 58.000 filas no son 58.000 observaciones independientes. Estima el número efectivo de etiquetas independientes
   (cada acción tiene una etiqueta nueva cada cinco semanas, aproximadamente) y el error típico del acierto que
   correspondería. ¿Cambia la conclusión sobre el 59,7 %?

**Solución.**

```python
n_ef = len(y) / 5  # una etiqueta independiente cada ~5 semanas
for nombre, n in (("filas", len(y)), ("efectivas", n_ef)):
    print(f"{nombre:<10} n = {n:>8,.0f}  error típico "
          f"{np.sqrt(0.25 / n):.2%}")
print(f"59,7 % frente al techo 51,1 %: "
      f"{(0.597 - 0.511) / np.sqrt(0.25 / n_ef):.0f} errores típicos")
```
```output
filas      n =   58,000  error típico 0.21%
efectivas  n =   11,600  error típico 0.46%
59,7 % frente al techo 51,1 %: 19 errores típicos
```

Con unas 11.600 etiquetas efectivas, el error típico del acierto sube de 0,21 % a 0,46 %, y aún falta la correlación
transversal (las acciones de una semana no son independientes entre sí), que lo agranda más. Aun así, 59,7 % frente a
un techo de 51,1 % son casi veinte errores típicos: no es mala suerte. La lección práctica es la inversa: en una
validación honesta, diferencias de unas décimas entre modelos (50,1 % frente a 50,3 %) son ruido.

3. Un compañero presenta un modelo de clasificación semanal de acciones con un 57 % de acierto validado con
   `TimeSeriesSplit` sin `gap` y etiquetas a un mes. ¿Qué tres preguntas le harías antes de creerle?

**Solución.**

Primero, si las features están bien fechadas: cuándo se conoce cada dato (fecha de publicación frente a fecha del
periodo, revisiones, proveedores que fechan al inicio de la semana). Es la fuga que ninguna validación detecta.
Segundo, cómo se trata el solape: `TimeSeriesSplit` sin `gap` deja en el entrenamiento las últimas filas antes de cada
prueba, cuyas etiquetas de un mes se solapan con las primeras de la prueba; con pliegues pequeños, esa frontera pesa.
Hace falta un hueco de al menos el horizonte de la etiqueta (lecciones 5.4.2 y 5.4.3). Tercero, con qué se compara:
el acierto de una baseline lineal y el de un modelo de solo ruido (columnas permutadas) con la misma validación. Un
57 % semanal en acciones está muy por encima de lo que se publica con datos reales; sin esas tres respuestas, lo
razonable es suponer una fuga.

## Autoevaluación

1. En el panel del nivel, el oráculo (el signo verdadero del alfa) acierta el 51,1 % y un random forest con k-fold barajado, el 59,7 %. ¿Qué se concluye?

   - A. Que el random forest ha encontrado no linealidades que el oráculo no ve
   - B. Que el oráculo está mal calculado
   - C. Que con 58.000 filas cualquier acierto es significativo
   - D. Que la validación tiene una fuga: ningún modelo puede superar fuera de muestra a la esperanza verdadera, así que 8 puntos por encima del techo son información de la respuesta

   *Respuesta D: Que la validación tiene una fuga: ningún modelo puede superar fuera de muestra a la esperanza verdadera, así que 8 puntos por encima del techo son información de la respuesta.* El oráculo usa el alfa verdadero, la mejor previsión posible de la parte predecible. Superarlo fuera de muestra es imposible; con purga el mismo bosque queda en el 50,1 %, por debajo del techo, como debe ser.

2. Con la etiqueta de una semana (`ret_sig_semana`, sin solape) y las mismas features, el k-fold barajado da un 50,0 %. ¿Qué demuestra?

   - A. Que la etiqueta de una semana no tiene información
   - B. Que la fuga necesita etiquetas solapadas: con etiquetas independientes, reconocer a la misma acción una semana antes no dice nada de la respuesta
   - C. Que el random forest no funciona con datos semanales
   - D. Que barajar no tiene ningún efecto en finanzas

   *Respuesta B: Que la fuga necesita etiquetas solapadas: con etiquetas independientes, reconocer a la misma acción una semana antes no dice nada de la respuesta.* La persistencia de las features es la misma en los dos casos. Lo que cambia es que la etiqueta de 21 sesiones coincide con la de la semana anterior el 78 % de las veces y la de una semana, el 50 %.

3. Se cambia el k-fold barajado por filas por uno barajado por semanas (todas las acciones de una semana van juntas al mismo pliegue). El acierto apenas baja, del 59,7 % al 59,2 %. ¿Por qué?

   - A. Porque la fuga viene de la misma acción en las semanas vecinas, que siguen cayendo en el entrenamiento: agrupar por fecha quita la fuga transversal, no la temporal
   - B. Porque la fuga venía de las acciones vecinas en la misma semana, y agruparlas no la toca
   - C. Porque GroupKFold no baraja
   - D. Porque hay pocas semanas

   *Respuesta A: Porque la fuga viene de la misma acción en las semanas vecinas, que siguen cayendo en el entrenamiento: agrupar por fecha quita la fuga transversal, no la temporal.* La etiqueta es relativa a la mediana de la semana, así que el componente común de mercado ya no está. Lo que el bosque reconoce es la huella de cada acción en las semanas de alrededor, y esas siguen al otro lado de la frontera.

4. Con k-fold barajado, el mejor tamaño mínimo de hoja es 1 (60,4 %); con purged k-fold, todos los tamaños dan entre 50,1 % y 50,3 %. ¿Qué lectura es correcta?

   - A. Hay que usar hojas de 1, porque es el que mejor resultado da en alguna validación
   - B. Las dos validaciones coinciden
   - C. La validación barajada premia memorizar; la honesta dice que, sin señal aprovechable por el bosque, el hiperparámetro da igual. Elegir con la primera lleva al modelo más sobreajustado
   - D. El purged k-fold no sirve para elegir hiperparámetros

   *Respuesta C: La validación barajada premia memorizar; la honesta dice que, sin señal aprovechable por el bosque, el hiperparámetro da igual. Elegir con la primera lleva al modelo más sobreajustado.* Hojas de una observación permiten aislar a cada acción en cada tramo de tiempo, que es justo lo que explota la fuga. Fuera del k-fold barajado esa capacidad no vale nada.

5. ¿Cuál de estas fugas NO detecta ni el k-fold por bloques ni el purged k-fold?

   - A. El solape de las etiquetas entre entrenamiento y prueba
   - B. La memoria de las features persistentes en el k-fold barajado
   - C. Una feature fechada antes de conocerse (como `revision_analistas`, que usa los dos primeros días de la semana siguiente)
   - D. La concentración de la fuga en las fronteras de los pliegues

   *Respuesta C: Una feature fechada antes de conocerse (como `revision_analistas`, que usa los dos primeros días de la semana siguiente).* Si una feature ya lleva dentro información posterior a su fecha, cada fila la arrastra a cualquier pliegue. Solo se descubre auditando cuándo se conoce cada dato (lección 5.2.2); la validación no puede verlo.

## Ideas clave

- En el panel del nivel (80 acciones, 725 semanas, etiqueta a 21 sesiones), un random forest da 59,7 % con k-fold barajado, 50,0 % por bloques y 50,1 % con purga. El oráculo acierta el 51,1 %.
- Un acierto por encima del oráculo es la firma de una fuga. En ML financiero el techo es bajo, y conocerlo, aunque sea de forma aproximada, protege más que cualquier métrica.
- La fuga necesita dos cosas a la vez: etiquetas solapadas (la de 21 sesiones coincide con la de la semana anterior el 78 % de las veces) y features persistentes (autocorrelación semanal de 0,97 a 0,99 en momentum, tamaño o value).
- Con cuatro columnas sin ningún efecto pero persistentes, el k-fold barajado ya da 53,5 %. Con la etiqueta de una semana, 50,0 %.
- Agrupar por fecha no arregla nada (59,2 %): en un panel la fuga es temporal, de la misma acción en las semanas vecinas.
- La validación barajada elige las hojas más pequeñas, el modelo que más memoriza. Con purga, ningún tamaño de hoja destaca: es la respuesta correcta cuando no hay señal que aprender.

## Recursos

### Research

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

### Documentación

- [scikit-learn: validación cruzada (supuesto i.i.d., datos agrupados y series temporales)](https://scikit-learn.org/stable/modules/cross_validation.html) · scikit-learn.org
- [scikit-learn: GroupKFold](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.GroupKFold.html) · scikit-learn.org
- [scikit-learn: RandomForestClassifier](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) · scikit-learn.org

### Libros

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

### Datos

- [Datos del nivel 5: features semanales del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet
- [Datos del nivel 5: etiquetas con su fecha de fin (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_etiquetas.parquet) · Descarga · panel5_etiquetas.parquet

Lección anterior: [5.3.5 · Muestreo por eventos: el filtro CUSUM simétrico](https://pythonparatrading.com/curso-python-trading/nivel-5/muestreo-por-eventos-filtro-cusum/)

Lección siguiente: [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/)
