Lección 5.4.1 · 60 min

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.

Fig. — Acierto fuera de muestra de un random forest en el panel del nivel 5 (80 acciones, 725 semanas, etiqueta a 21 sesiones) según la validación, frente al techo del oráculo
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

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.

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.

IN [01]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}")
OUT [01]El panel del README: 80 acciones, 725 semanas
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).
IN [02]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%}")
OUT [02]Tres validaciones del mismo bosque
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).

IN [03]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%}")
OUT [03]El acierto del oráculo
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.

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:

IN [04]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})
OUT [04]Cinco etiquetas seguidas de una acción
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

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.

IN [05]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)
OUT [05]Acuerdo de la etiqueta con la de k semanas antes
   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

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:

IN [06]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())
OUT [06]Persistencia de las features
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

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.

Etiqueta solapadaMisma respuestaFeatures lentasVecina reconociblek-fold barajadoAcierto inflado
Fuente 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.
IN [07]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%}")
OUT [07]Quitar un ingrediente cada vez
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%
EtiquetaFeaturesk-fold barajadoQué falta
21 sesioneslas 2159,7 %nada: fuga completa
1 semanalas 2150,0 %el solape de etiquetas
21 sesiones4 lentas sin efecto53,5 %la señal (no hace falta)
21 sesiones3 rápidas49,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.

IN [08]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%}")
OUT [08]k-fold barajado por semanas (GroupKFold)
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.

EsquemaQué protegeAcierto
KFold(shuffle=True)nada59,7 %
GroupKFold(shuffle=True) por semanaacciones de la misma semana59,2 %
KFold por bloques de tiempocasi todo; queda la frontera50,0 %
bloques con purga de 4 semanastambién la frontera50,1 %

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:

IN [09]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%}")
OUT [09]Tamaño mínimo de hoja: k-fold barajado frente a purgado
            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

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.

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
IN [10]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%}")
OUT [10]Una sola feature persistente
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.

  1. 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
IN [11]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")
OUT [11]Error típico con etiquetas solapadas
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.

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

Respuesta plegada en cada pregunta

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?

    • AQue el random forest ha encontrado no linealidades que el oráculo no ve
    • BQue el oráculo está mal calculado
    • CQue con 58.000 filas cualquier acierto es significativo
    • DQue 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
    Ver 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?

    • AQue la etiqueta de una semana no tiene información
    • BQue la fuga necesita etiquetas solapadas: con etiquetas independientes, reconocer a la misma acción una semana antes no dice nada de la respuesta
    • CQue el random forest no funciona con datos semanales
    • DQue barajar no tiene ningún efecto en finanzas
    Ver respuesta

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

    • APorque 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
    • BPorque la fuga venía de las acciones vecinas en la misma semana, y agruparlas no la toca
    • CPorque GroupKFold no baraja
    • DPorque hay pocas semanas
    Ver respuesta

    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?

    • AHay que usar hojas de 1, porque es el que mejor resultado da en alguna validación
    • BLas dos validaciones coinciden
    • CLa 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
    • DEl purged k-fold no sirve para elegir hiperparámetros
    Ver respuesta

    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?

    • AEl solape de las etiquetas entre entrenamiento y prueba
    • BLa memoria de las features persistentes en el k-fold barajado
    • CUna feature fechada antes de conocerse (como revision_analistas, que usa los dos primeros días de la semana siguiente)
    • DLa concentración de la fuga en las fronteras de los pliegues
    Ver respuesta

    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