Lección 5.1.2 · 70 min
Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
Un modelo de trading es una cadena de pasos —limpiar, transformar, codificar, ajustar— y cada paso que aprende algo de los datos puede filtrar información del futuro. Esta lección sitúa los tres tipos de aprendizaje en el trading y construye con scikit-learn una cadena completa sobre el panel del nivel: transformadores propios, partición por fechas, búsqueda de hiperparámetros con una métrica de trading y la fuga que aparece al seleccionar features fuera de la validación.
- Módulo
- 5.1 · Fundamentos de ML aplicados a finanzas
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
Tres formas de aprender
El nivel usa tres familias de métodos. Se distinguen por lo que reciben para aprender, no por el algoritmo:
Fuente Mermaid
flowchart LR ML[Aprendizaje] --> S[Supervisado] ML --> U[No supervisado] ML --> R[Por refuerzo] S --> SR[Regresión] S --> SC[Clasificación] U --> UD[Reducir dimensión] U --> UG[Agrupar] R --> RP[Política]
- Supervisado. Cada ejemplo trae sus features y una etiqueta observada, y el modelo aprende . Si es un número (el rendimiento de la semana siguiente, la volatilidad de mañana) es regresión; si es una clase (sube o baja, qué barrera se toca primero), clasificación.
- No supervisado. Solo hay . El modelo busca estructura: pocas direcciones que resumen muchas columnas (PCA), grupos de activos que se mueven juntos, estados del mercado que se repiten.
- Por refuerzo. Un agente toma decisiones en secuencia, el entorno responde y devuelve una recompensa; el agente aprende una política (qué hacer en cada estado) que maximiza la recompensa acumulada. No hay etiqueta de «la acción correcta», solo consecuencias.
| Familia | Pregunta de trading | Etiqueta o recompensa | Lecciones |
|---|---|---|---|
| Supervisado, regresión | ¿Qué acciones rendirán más la semana que viene? | Rendimiento futuro (menos la media) | 5.5.1, 5.5.3, 5.5.6 |
| Supervisado, regresión | ¿Qué volatilidad tendrá mañana este activo? | Varianza realizada | 5.7.3, 5.8.5 |
| Supervisado, clasificación | ¿Merece la pena esta operación del sistema primario? | Barrera tocada primero | 5.3.2, 5.3.3 |
| No supervisado | ¿En qué régimen está el mercado? ¿Qué activos son parecidos? | Ninguna | 5.5.5 |
| Por refuerzo | ¿Cómo vendo 200.000 acciones en una hora? ¿Cómo cubro una opción con costes? | Coste o P&L de cada paso | 5.9.1, 5.9.2 |
La mayor parte del nivel es aprendizaje supervisado, y la mayor parte de los errores también: la etiqueta es información del futuro por definición, y cada paso del proceso que la toque fuera de su sitio la filtra hacia el pasado. El resto de la lección construye la cadena de scikit-learn que evita esos errores por diseño.
La API de scikit-learn: qué aprende cada paso
scikit-learn tiene una API uniforme. Un estimador aprende de los datos con fit y guarda lo aprendido en
atributos que terminan en guion bajo (mean_, coef_). Un transformador además tiene transform, que aplica lo
aprendido a datos nuevos; un predictor, predict. La regla que lo ordena todo: fit se llama solo con datos de
entrenamiento, transform y predict con cualquiera.
Los datos son el panel panel5 de la lección 5.1.1: 21 features semanales (sin la fuga revision_analistas), el
sector y como etiqueta el rendimiento de la semana siguiente menos su media transversal (lo que importa para ordenar
acciones). Entrenamiento hasta 2018 y prueba desde 2019, con una semana de margen:
import numpy as np
import pandas as pd
f = pd.read_parquet("panel5_features.parquet")
et = pd.read_parquet("panel5_etiquetas.parquet")
d = (f.merge(et[["fecha", "ticker", "ret_sig_semana"]],
on=["fecha", "ticker"])
.dropna(subset=["ret_sig_semana"]).reset_index(drop=True))
d["y"] = d["ret_sig_semana"] - d.groupby("fecha")[
"ret_sig_semana"].transform("mean")
num = [c for c in f.columns
if c not in ("fecha", "ticker", "sector", "revision_analistas")]
cols = ["fecha", "sector"] + num
ent = d[d["fecha"] < "2019-01-01"].reset_index(drop=True)
pru = d[d["fecha"] >= "2019-01-08"].reset_index(drop=True)
print(len(ent), "filas de entrenamiento;", len(pru), "de prueba")
print(ent["fecha"].max().date(), "|", pru["fecha"].min().date())145600 filas de entrenamiento; 145600 de prueba
2018-12-28 | 2019-01-11Un StandardScaler aprende la media y la desviación de cada columna en fit y las usa en transform. Aplicado a
la prueba, usa las del entrenamiento: así es como un sistema en vivo vería los datos, porque el lunes no conoce las
medias de los próximos siete años.
from sklearn.preprocessing import StandardScaler
esc = StandardScaler().fit(ent[["log_capitalizacion", "vol_63d"]])
print("medias aprendidas:", esc.mean_.round(3))
z = esc.transform(pru[["log_capitalizacion", "vol_63d"]])
print("media en prueba tras transformar:", z.mean(axis=0).round(3))medias aprendidas: [8.932 0.29 ]
media en prueba tras transformar: [0.131 0.197]Las features transformadas de la prueba no tienen media cero: la capitalización de las acciones creció entre los dos periodos (0,13 desviaciones por encima de la media de entrenamiento) y la volatilidad de 2019-2025 fue mayor (0,20 desviaciones). Es lo correcto. Si la media saliera exactamente cero, el escalado se habría aprendido con los datos de prueba.
Pipeline: la cadena como un solo estimador
Un Pipeline encadena transformadores y termina en un predictor, y se comporta como un único estimador: fit
ajusta cada paso con la salida del anterior y predict aplica la cadena entera. La ventaja no es la comodidad: es
que cualquier herramienta de validación de scikit-learn reajusta todos los pasos con los datos de entrenamiento
de cada partición. Para que un paso con estado no vea la prueba basta con que esté dentro.
Antes del pipeline hacen falta la métrica y el primer transformador propio. La métrica es el IC semanal medio (lección
5.1.3); la función puntuacion_ic tiene la firma de un scorer de scikit-learn, (estimador, X, y), y toma las
fechas de la propia X:
def ic_semanal(fecha, pred, y):
"""Correlación de rangos semana a semana."""
s = pd.DataFrame({"fecha": np.asarray(fecha),
"p": np.asarray(pred), "y": np.asarray(y)})
r = s.groupby("fecha")[["p", "y"]].rank()
r = r - r.groupby(s["fecha"]).transform("mean")
num_ = (r["p"] * r["y"]).groupby(s["fecha"]).sum()
den = ((r["p"] ** 2).groupby(s["fecha"]).sum()
* (r["y"] ** 2).groupby(s["fecha"]).sum()) ** 0.5
return num_ / den
def puntuacion_ic(estimador, X, y):
return ic_semanal(X["fecha"], estimador.predict(X), y).mean()El transformador propio es un winsorizador: recorta cada columna a sus cuantiles 1 % y 99 %. Tiene estado: los
cuantiles se aprenden en fit y se aplican en transform. Un transformador de scikit-learn hereda de
BaseEstimator (que le da get_params y set_params, necesarios para la búsqueda de hiperparámetros) y de
TransformerMixin (que le da fit_transform); los parámetros se guardan en __init__ sin tocarlos, lo aprendido en
atributos con _, y get_feature_names_out dice cómo se llaman las columnas de salida:
from sklearn.base import BaseEstimator, TransformerMixin
class Winsorizador(BaseEstimator, TransformerMixin):
"""Recorta cada columna a los cuantiles q y 1 - q del fit."""
def __init__(self, q=0.01):
self.q = q
def fit(self, X, y=None):
X = pd.DataFrame(X)
self.bajo_ = X.quantile(self.q)
self.alto_ = X.quantile(1 - self.q)
self.feature_names_in_ = np.asarray(X.columns, dtype=object)
return self
def transform(self, X):
return pd.DataFrame(X).clip(self.bajo_, self.alto_, axis=1)
def get_feature_names_out(self, input_features=None):
return self.feature_names_in_
w = Winsorizador(0.01).fit(ent[num])
print(pd.DataFrame({"bajo": w.bajo_, "alto": w.alto_})
.loc[["ret_1s", "sue", "sentimiento"]].round(3)) bajo alto
ret_1s -0.112 0.116
sue -2.990 3.662
sentimiento -2.344 2.307Con él, un pipeline lineal completo: winsorizar, imputar los huecos de sentimiento (el 32 % de las filas no tiene
cobertura) con la mediana y una columna indicadora de dato ausente, tipificar y ajustar una ridge:
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
lineal = make_pipeline(Winsorizador(0.01),
SimpleImputer(strategy="median",
add_indicator=True),
StandardScaler(), Ridge(alpha=1000.0))
lineal.set_output(transform="pandas")
lineal.fit(ent[num], ent["y"])
print(list(lineal.named_steps))
print(lineal[:-1].get_feature_names_out()[-3:])
ic = ic_semanal(pru["fecha"], lineal.predict(pru[num]), pru["y"])
print(f"IC medio 2019-2025: {ic.mean():.4f}")['winsorizador', 'simpleimputer', 'standardscaler', 'ridge']
['ruido_4' 'ruido_5' 'missingindicator_sentimiento']
IC medio 2019-2025: 0.0181lineal[:-1] es el pipeline sin el último paso: sirve para ver qué columnas recibe la ridge, incluida la indicadora
missingindicator_sentimiento que añade el imputador. El IC de 2019-2025 es 0,018, cerca de la ridge de la lección
5.1.1 (0,019) y por debajo del techo del oráculo en ese periodo (0,025).
ColumnTransformer y un transformador sin estado
Las features no se tratan todas igual. El sector es una categoría y se codifica en columnas binarias; las numéricas
se pasan a rango transversal: el percentil de cada acción entre las 400 de su semana. El rango quita los valores
extremos y el nivel de cada época de un golpe (una volatilidad del 30 % es alta en 2017 y normal en 2020). La lección
5.2.4 compara este y otros escalados; aquí interesa su forma: es un transformador sin estado, porque cada semana
se transforma solo con los datos de esa semana. Su fit no aprende nada de las features, así que no puede filtrar
nada del futuro.
ColumnTransformer aplica un transformador distinto a cada grupo de columnas y concatena el resultado:
Fuente Mermaid
flowchart TB X[fecha, sector, features] --> RF[RangoPorFecha] RF --> CT[ColumnTransformer] CT --> IM[Numéricas: imputar] CT --> OH[Sector: one-hot] IM --> M[Ridge] OH --> M
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
class RangoPorFecha(BaseEstimator, TransformerMixin):
"""Percentil de cada columna dentro de su fecha, menos 0,5."""
def __init__(self, columnas):
self.columnas = columnas
def fit(self, X, y=None):
self.resto_ = [c for c in X.columns
if c not in self.columnas + ["fecha"]]
return self
def transform(self, X):
g = X.groupby("fecha")[self.columnas]
return (g.rank(pct=True) - 0.5).join(X[self.resto_])
def get_feature_names_out(self, input_features=None):
return np.asarray(self.columnas + self.resto_, dtype=object)
prep = ColumnTransformer(
[("num", SimpleImputer(strategy="constant", fill_value=0.0), num),
("sec", OneHotEncoder(handle_unknown="ignore",
sparse_output=False), ["sector"])],
verbose_feature_names_out=False)
modelo = Pipeline([("rango", RangoPorFecha(num)), ("prep", prep),
("ridge", Ridge())])
modelo.set_output(transform="pandas")
Xt = modelo[:-1].fit_transform(ent[cols])
print(Xt.shape)
Xt.iloc[:2, [0, 3, 20, 21, 22]].round(3)(145600, 31)
ret_1s mom_12_1 ruido_5 sector_consumo_basico sector_consumo_ciclico
0 -0.305 -0.350 -0.440 0.0 0.0
1 -0.285 0.047 -0.107 0.0 0.0El rango deja cada feature entre −0,5 y 0,5; el imputador pone a 0 (la mediana de su semana) las acciones sin dato
de sentimiento, que el rango deja vacías. El
one-hot añade diez columnas, una por sector. handle_unknown="ignore" es una decisión de producción: si aparece un
sector nuevo, se codifica como ceros en lugar de romper el sistema.
Partir por fechas: la validación de un panel
Para elegir hiperparámetros hace falta validar dentro del periodo de entrenamiento. La validación cruzada estándar
(KFold) baraja las filas y en series financieras promete resultados que no existen; la lección 5.4.1 lo mide con
detalle. La alternativa de scikit-learn para series, TimeSeriesSplit, entrena con el pasado y prueba con el
futuro, pero parte por filas. En un panel, cada fecha tiene 400 filas:
from sklearn.model_selection import TimeSeriesSplit
for ent_i, pru_i in TimeSeriesSplit(n_splits=5).split(ent):
comunes = set(ent.loc[ent_i, "fecha"]) & set(
ent.loc[pru_i, "fecha"])
print(f"{len(ent_i):6d} filas de entrenamiento; "
f"semanas compartidas: {len(comunes)}") 24270 filas de entrenamiento; semanas compartidas: 1
48536 filas de entrenamiento; semanas compartidas: 1
72802 filas de entrenamiento; semanas compartidas: 1
97068 filas de entrenamiento; semanas compartidas: 1
121334 filas de entrenamiento; semanas compartidas: 1En las cinco particiones la frontera cae a mitad de una semana: unas acciones de esa semana entrenan y otras se
prueban, con el mismo mercado y el mismo rendimiento común. Además, la etiqueta de la última semana de entrenamiento
termina un viernes después, dentro del periodo de prueba. Las dos cosas se arreglan con un partidor por fechas con un
hueco de al menos el horizonte de la etiqueta (una semana para ret_sig_semana; para ret_sig_21d, cinco).
Un partidor de scikit-learn es cualquier objeto con split y get_n_splits:
class SplitPorFechas:
"""Ventana expansiva por fechas: entrena con el pasado, deja
`hueco` fechas sin usar y prueba con el bloque siguiente."""
def __init__(self, n_splits=5, hueco=1):
self.n_splits = n_splits
self.hueco = hueco
def get_n_splits(self, X=None, y=None, groups=None):
return self.n_splits
def split(self, X, y=None, groups=None):
fechas = np.asarray(X["fecha"])
unicas = np.unique(fechas)
bloques = np.array_split(unicas, self.n_splits + 1)
for b in bloques[1:]:
i = np.searchsorted(unicas, b[0]) - 1 - self.hueco
yield (np.flatnonzero(fechas <= unicas[i]),
np.flatnonzero(np.isin(fechas, b)))
cv = SplitPorFechas(n_splits=5, hueco=1)
for i_ent, i_pru in cv.split(ent):
fe, fp = ent.loc[i_ent, "fecha"], ent.loc[i_pru, "fecha"]
print(fe.min().date(), "a", fe.max().date(), "| prueba",
fp.min().date(), "a", fp.max().date())2012-01-13 a 2013-03-01 | prueba 2013-03-15 a 2014-05-09
2012-01-13 a 2014-05-02 | prueba 2014-05-16 a 2015-07-10
2012-01-13 a 2015-07-02 | prueba 2015-07-17 a 2016-09-09
2012-01-13 a 2016-09-02 | prueba 2016-09-16 a 2017-11-03
2012-01-13 a 2017-10-27 | prueba 2017-11-10 a 2018-12-28Cada partición entrena con todo lo anterior (ventana expansiva), deja una semana sin usar y prueba con el bloque siguiente de unas 60 semanas. La figura dibuja las cinco y, aparte, el periodo de prueba final de 2019-2025, que la búsqueda no toca. Es un walk-forward sencillo; las lecciones 5.4.2 y 5.4.3 añaden la purga, el embargo y las ventanas móviles.
Búsqueda de hiperparámetros con una métrica de trading
GridSearchCV prueba cada combinación de una rejilla con el partidor que se le dé y la puntúa con el scorer. Los
hiperparámetros de un paso del pipeline se nombran paso__parámetro. Aquí se busca la regularización de la ridge, α,
en nueve órdenes de magnitud:
from sklearn.model_selection import GridSearchCV
rejilla = {"ridge__alpha": [1, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7,
1e8]}
busqueda = GridSearchCV(modelo, rejilla, scoring=puntuacion_ic,
cv=cv, n_jobs=1)
busqueda.fit(ent[cols], ent["y"])
res = pd.DataFrame(busqueda.cv_results_)
res = res.set_index("param_ridge__alpha")[
["mean_test_score", "std_test_score"]]
print(res.round(4))
print("elegido:", busqueda.best_params_) mean_test_score std_test_score
param_ridge__alpha
1.0 0.0043 0.0135
10.0 0.0043 0.0135
100.0 0.0045 0.0134
1000.0 0.0052 0.0128
10000.0 0.0063 0.0115
100000.0 0.0068 0.0104
1000000.0 0.0070 0.0102
10000000.0 0.0070 0.0102
100000000.0 0.0070 0.0102
elegido: {'ridge__alpha': 10000000.0}Dos cosas que no son obvias. La primera: el IC de validación sube con α y se estabiliza en valores enormes. Cuando α domina a , la ridge da : los coeficientes tienden a cero, pero su dirección deja de cambiar, y el IC solo mira el orden de las predicciones. Con tan poca señal, encoger mucho es lo que mejor generaliza: la ridge casi se convierte en una media de las features ponderada por su covarianza con la etiqueta. La segunda: la desviación entre particiones (0,010 en la mejor) es mayor que las diferencias entre valores de α. Elegir entre y con estos datos es elegir entre ruidos; la lección 5.5.4 mide cuánto se puede sobreajustar la propia validación cuando la rejilla es grande.
GridSearchCV reentrena el mejor modelo con todo el entrenamiento (refit=True, por defecto). Se evalúa una sola
vez en 2019-2025 y se compara con una baseline sin ajuste: la suma de los rangos de cuatro features con el signo
que da la literatura (momentum, value y SUE positivos; reversión de una semana negativa).
mejor = busqueda.best_estimator_
ic_mod = ic_semanal(pru["fecha"], mejor.predict(pru[cols]),
pru["y"])
rk = pru.groupby("fecha")[["mom_12_1", "ret_1s", "valor_bp",
"sue"]].rank(pct=True)
base = rk["mom_12_1"] - rk["ret_1s"] + rk["valor_bp"] + rk["sue"]
ic_base = ic_semanal(pru["fecha"], base, pru["y"])
for nombre, ic in [("ridge elegida", ic_mod), ("baseline", ic_base)]:
t = ic.mean() / ic.std() * np.sqrt(len(ic))
print(f"{nombre:14s} IC {ic.mean():.4f} t {t:.1f}")
coef = pd.Series(mejor["ridge"].coef_,
index=mejor[:-1].get_feature_names_out())
(coef / coef.abs().max()).sort_values().iloc[[0, 1, 2, -3, -2, -1]
].round(2)ridge elegida IC 0.0189 t 3.1
baseline IC 0.0178 t 4.1
ret_1s -1.00
rsi_14 -0.71
sector_telecomunicaciones -0.62
sector_energia 0.53
mom_12_1 0.55
sector_industria 0.65
dtype: float64La ridge elegida da un IC de 0,019 en 2019-2025 y la baseline de cuatro features, 0,018, con un estadístico t más alto (4,1 frente a 3,1: es más estable de una semana a otra). La diferencia de medias es menor que el error de cualquiera de las dos. Es la comparación que hay que hacer siempre: si un pipeline con 21 features, imputación, codificación y búsqueda no mejora claramente a una suma de cuatro rangos, su complejidad no aporta.
Los coeficientes más grandes (relativos al mayor) cuentan qué aprendió. La reversión de una semana con signo negativo y el momentum con signo positivo son efectos del generador; el RSI negativo hereda la reversión. Pero tres de los seis son sectores: telecomunicaciones en negativo, energía e industria en positivo. El generador no pone ningún efecto de sector en el rendimiento esperado; esos coeficientes son los sectores que, por azar, rindieron más o menos que la media en 2012-2018. Con una regularización tan fuerte, la ridge apuesta en proporción a la covarianza de cada columna con la etiqueta, y la de una columna binaria de sector es simplemente su rendimiento pasado. Un modelo puede mejorar en validación aprendiendo el ruido de un periodo; quitar el sector o neutralizar la cartera por sectores (lección 5.2.4) es una decisión que se toma por razonamiento económico, no por lo que diga la búsqueda.
La fuga dentro de la validación: seleccionar con todos los datos
El error de pipeline más caro no es escalar con los datos de prueba, que aquí movería poco. Es seleccionar con ellos. El experimento: 500 columnas de ruido puro, sin ninguna relación con la etiqueta, en dos años de datos (2012-2013, 41.200 filas). La forma incorrecta elige las 10 más correlacionadas con la etiqueta usando todas las filas y después valida una ridge con ellas. La correcta mete la selección dentro del pipeline, así que en cada partición se elige solo con las filas de entrenamiento. Como control, las 10 columnas elegidas de la forma incorrecta se evalúan también con datos que la selección no vio (2014-2018):
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.model_selection import cross_val_score
class SinFecha(BaseEstimator, TransformerMixin):
def fit(self, X, y=None):
return self
def transform(self, X):
return X.drop(columns="fecha")
rng = np.random.default_rng(0)
ruido = [f"r{i:03d}" for i in range(500)]
datos = d[d["fecha"] < "2019-01-01"].reset_index(drop=True)
Z = pd.DataFrame(rng.standard_normal((len(datos), 500)),
columns=ruido).assign(fecha=datos["fecha"])
dos = (datos["fecha"] < "2014-01-01").to_numpy()
Z2, y2 = Z[dos].reset_index(drop=True), datos.loc[dos, "y"]
elegidas = SelectKBest(f_regression, k=10).fit(
Z2[ruido], y2).get_feature_names_out()
mal = cross_val_score(make_pipeline(SinFecha(), Ridge(1.0)),
Z2[list(elegidas) + ["fecha"]], y2, cv=cv,
scoring=puntuacion_ic)
bien = cross_val_score(
make_pipeline(SinFecha(), SelectKBest(f_regression, k=10),
Ridge(1.0)),
Z2, y2, cv=cv, scoring=puntuacion_ic)
despues = Ridge(1.0).fit(Z2[elegidas], y2).predict(Z[~dos][elegidas])
ic_desp = ic_semanal(Z.loc[~dos, "fecha"], despues,
datos.loc[~dos, "y"]).mean()
for nombre, v in [("fuera del pipeline", mal),
("dentro del pipeline", bien)]:
print(f"{nombre:20s} IC {v.mean():.3f} ± "
f"{v.std(ddof=1) / np.sqrt(len(v)):.3f}", v.round(3))
print(f"elegidas, 2014-2018 IC {ic_desp:.3f}")fuera del pipeline IC 0.028 ± 0.009 [0.035 0.02 0.022 0.006 0.058]
dentro del pipeline IC 0.008 ± 0.004 [ 0.004 0.018 -0.002 0. 0.019]
elegidas, 2014-2018 IC -0.001La figura de la cabecera resume la celda. La forma incorrecta da un IC de validación medio de 0,028 con 500 columnas de ruido: el mismo nivel que el techo del oráculo en el panel. No hay ninguna señal; la selección ya había visto las etiquetas de las semanas de prueba de cada partición y eligió las columnas que, por azar, se parecían a ellas. Con la selección dentro del pipeline, el IC es 0,008 con un error de 0,004, compatible con cero. Y las columnas «elegidas» no predicen nada en los cinco años siguientes. Hastie, Tibshirani y Friedman lo llaman «la forma incorrecta de hacer validación cruzada» (sección 7.10.2 de su libro); en finanzas es más peligrosa que en otros campos porque el ruido es casi todo y cualquier selección con la etiqueta encuentra algo.
Hay fugas que el pipeline no puede evitar, porque no están en el proceso sino en los datos: una feature que el
proveedor fecha antes de conocerse (revision_analistas, lección 5.2.2) o etiquetas que se solapan con la prueba
(lecciones 5.3.4 y 5.4.2). El pipeline protege de los errores de procedimiento; los de datos se buscan auditando
fechas.
Ejercicios
- Sustituye la ridge del pipeline por un
HistGradientBoostingRegressorde scikit-learn (max_iter=100,learning_rate=0.05,max_leaf_nodes=8,min_samples_leaf=2000,random_state=0) y evalúalo concross_val_scorey el mismo partidor. ¿Mejora a la ridge en validación?
Solución
from sklearn.ensemble import HistGradientBoostingRegressor
hgb = Pipeline([
("rango", RangoPorFecha(num)), ("prep", prep),
("hgb", HistGradientBoostingRegressor(
max_iter=100, learning_rate=0.05, max_leaf_nodes=8,
min_samples_leaf=2000, random_state=0))])
hgb.set_output(transform="pandas")
v = cross_val_score(hgb, ent[cols], ent["y"], cv=cv,
scoring=puntuacion_ic)
print("boosting:", v.round(4), round(v.mean(), 4))
print("ridge elegida:", round(busqueda.best_score_, 4))boosting: [ 0.0053 -0.0085 0.0005 0.0099 -0.0118] -0.0009
ridge elegida: 0.007Basta con cambiar el último paso: el partidor, el scorer y los transformadores son los mismos, y el boosting se reajusta en cada partición como la ridge. No mejora: su IC medio de validación es −0,001 frente a 0,007 de la ridge, con dos particiones claramente negativas. La desviación entre particiones es de unos 0,009, así que el error típico de la media de cinco es de unos 0,004 y la diferencia, de unos dos errores: un indicio, no una prueba. Con tan poca señal, el modelo lineal muy regularizado es una baseline difícil de batir; la lección 5.5.3 ajusta el boosting con más cuidado.
- Comprueba cuánto cambia el IC de la ridge en 2019-2025 si el
StandardScalerdel pipeline lineal se ajusta con todo el periodo 2012-2025 (la fuga «pequeña») en lugar de solo con el entrenamiento.
Solución
todo = pd.concat([ent, pru])
w = Winsorizador(0.01).fit(ent[num])
imp = SimpleImputer(strategy="median", add_indicator=True)
imp.set_output(transform="pandas")
imp.fit(w.transform(ent[num]))
fuga = StandardScaler().fit(imp.transform(w.transform(todo[num])))
r = Ridge(1000.0).fit(fuga.transform(
imp.transform(w.transform(ent[num]))), ent["y"])
p = r.predict(fuga.transform(imp.transform(w.transform(pru[num]))))
ic_fuga = ic_semanal(pru["fecha"], p, pru["y"]).mean()
ic_ok = ic_semanal(pru["fecha"], lineal.predict(pru[num]),
pru["y"]).mean()
print(f"sin fuga {ic_ok:.4f} | con fuga {ic_fuga:.4f}")sin fuga 0.0181 | con fuga 0.0182La diferencia es pequeña: tipificar con medias de todo el periodo apenas cambia el orden de las predicciones dentro de cada semana. Por eso esta fuga pasa desapercibida, y por eso es peligrosa como costumbre: el mismo gesto con un selector de features o con un umbral elegido mirando la etiqueta convierte ruido en señal, como en la última celda de la lección.
- Vas a predecir
ret_sig_21d(rendimiento de 21 sesiones, que se solapa entre semanas consecutivas) con el mismo pipeline. ¿Qué cambiarías en el partidor y por qué?
Solución
El hueco. La etiqueta de una fila del viernes termina 21 sesiones después, unas cuatro semanas más tarde. Con un hueco de una semana, las etiquetas de las últimas tres o cuatro semanas de entrenamiento cubren rendimientos que están dentro del bloque de prueba, y el modelo aprendería de ellos. El hueco tiene que ser de al menos cinco fechas (las cuatro semanas del solape más una de margen). Si además las particiones se hicieran con bloques de prueba intercalados (no solo hacia delante), habría que purgar también las filas de entrenamiento posteriores a la prueba cuya información se solape y añadir un embargo: es el purged k-fold de la lección 5.4.2. Y en la evaluación, con etiquetas solapadas, los IC de semanas consecutivas no son independientes: el estadístico t hay que corregirlo (Newey-West, lección 2.1.1).
Respuesta plegada en cada pregunta
Autoevaluación
¿Qué tipo de aprendizaje es entrenar un modelo para predecir si una operación de un sistema primario acabará tocando el take profit antes que el stop?
- ASupervisado (clasificación): la etiqueta es el resultado de la triple barrera de cada operación
- BNo supervisado: no hay etiquetas
- CPor refuerzo: el agente decide
- DNinguno: es un backtest
Ver respuesta
Respuesta A. Supervisado (clasificación): la etiqueta es el resultado de la triple barrera de cada operación
Hay una etiqueta observada para cada ejemplo (qué barrera se tocó primero), así que es clasificación supervisada. Es el meta-labeling de las lecciones 5.3.2 y 5.3.3.
Se ajusta un
StandardScalercon todo el histórico 2012-2025 y después se valida un modelo con datos de 2019-2025. ¿Qué ha pasado?- ANada: escalar no usa la etiqueta
- BEl escalado de entrenamiento usa medias y desviaciones calculadas con datos del periodo de prueba: una fuga, pequeña en este caso pero del mismo tipo que las graves
- CEl modelo será más preciso
- DSe ha usado mal
fit_transform
Ver respuesta
Respuesta B. El escalado de entrenamiento usa medias y desviaciones calculadas con datos del periodo de prueba: una fuga, pequeña en este caso pero del mismo tipo que las graves
Todo paso con estado (
fit) tiene que aprender solo de los datos de entrenamiento de cada partición. Metido en unPipeline, la validación cruzada lo reajusta en cada pliegue y la fuga desaparece.¿Por qué
TimeSeriesSplitde scikit-learn no basta para el panel de 400 acciones?- APorque no admite pandas
- BPorque solo hace tres particiones
- CPorque baraja las filas
- DPorque parte por filas: con las filas ordenadas por fecha, la frontera cae dentro de una semana y esa semana queda a la vez en entrenamiento y en prueba; además, sin hueco, la etiqueta de la última semana de entrenamiento se solapa con la prueba
Ver respuesta
Respuesta D. Porque parte por filas: con las filas ordenadas por fecha, la frontera cae dentro de una semana y esa semana queda a la vez en entrenamiento y en prueba; además, sin hueco, la etiqueta de la última semana de entrenamiento se solapa con la prueba
En la celda, las cinco particiones de
TimeSeriesSplitcomparten una semana entre entrenamiento y prueba. Un partidor por fechas con hueco lo evita; la purga y el embargo completos son la lección 5.4.2.Se eligen las 10 features más correlacionadas con la etiqueta entre 500 columnas de ruido usando todo el periodo, y luego se valida una ridge con ellas. ¿Qué sale?
- AUn IC cercano a cero, porque son ruido
- BUn error de scikit-learn
- CUn IC medio de validación de 0,028, parecido al del oráculo, que desaparece con datos nuevos: la selección ya había visto las etiquetas de prueba
- DUn IC negativo
Ver respuesta
Respuesta C. Un IC medio de validación de 0,028, parecido al del oráculo, que desaparece con datos nuevos: la selección ya había visto las etiquetas de prueba
Es «la forma incorrecta de hacer validación cruzada» de Hastie, Tibshirani y Friedman. Con la selección dentro del pipeline, el IC de validación queda en 0,008 ± 0,004, y las features «elegidas» dan −0,001 en 2014-2018.
En la búsqueda, el IC de validación de la ridge sube con α hasta estabilizarse en valores enormes. ¿Por qué?
- APorque α grande siempre es mejor
- BPorque hay un error en el partidor
- CPorque las features están en rangos
- DPorque el IC solo mira el orden de las predicciones: con α muy grande, los coeficientes son proporcionales a la covarianza de cada feature con la etiqueta y el orden ya no cambia. Con tan poca señal, encoger mucho es lo que mejor generaliza
Ver respuesta
Respuesta D. Porque el IC solo mira el orden de las predicciones: con α muy grande, los coeficientes son proporcionales a la covarianza de cada feature con la etiqueta y el orden ya no cambia. Con tan poca señal, encoger mucho es lo que mejor generaliza
Cuando α domina a X′X, la ridge da β ≈ X′y/α: la escala tiende a cero, pero la dirección es fija y el IC, que es invariante a la escala, deja de cambiar.
Ideas clave
- Supervisado: hay etiqueta (rendimiento, signo, barrera). No supervisado: estructura sin etiqueta (PCA, regímenes, grupos de activos). Refuerzo: decisiones secuenciales con recompensa (ejecución, cobertura, market making).
- Cada paso con
fitaprende algo de los datos. Dentro de unPipeline, la validación lo reajusta en cada partición y no ve la prueba. - Un transformador propio hereda de
BaseEstimatoryTransformerMixin, guarda lo aprendido en atributos con_y defineget_feature_names_outpara funcionar conset_output. - En un panel se parte por fechas, no por filas, y con un hueco al menos igual al horizonte de la etiqueta.
- Seleccionar features con todos los datos y validar después convierte 500 columnas de ruido en un IC de validación de 0,028, igual al techo del oráculo.
- La búsqueda de hiperparámetros se puntúa con una métrica de trading (IC semanal) y el modelo elegido se compara con una baseline simple: aquí, 0,019 frente a 0,018.