---
title: "5.1.2 Aprendizaje supervisado y pipelines de scikit-learn"
description: "Supervisado, no supervisado y refuerzo; Pipeline, ColumnTransformer, transformadores propios y búsqueda de hiperparámetros con validación temporal."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.1 · Fundamentos de ML aplicados a finanzas
- Duración: 70 min
- Requisitos: [0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-groupby-merge-resample-y-rolling/), [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [2.5.3 · Validación cruzada combinatoria purgada para estrategias](https://pythonparatrading.com/curso-python-trading/nivel-2/validacion-cruzada-combinatoria-purgada-cpcv/), [5.1.1 · Por qué el machine learning financiero es distinto](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-ml-financiero-es-distinto/)

## Al terminar esta lección

- Distinguir aprendizaje supervisado, no supervisado y por refuerzo y situar cada uno en un problema de trading concreto.
- Usar la API de scikit-learn (`fit`, `transform`, `predict`) sabiendo qué aprende cada paso y de qué datos.
- Construir un `Pipeline` con `ColumnTransformer` y transformadores propios con y sin estado, compatibles con `set_output`.
- Escribir un partidor por fechas con hueco y un *scorer* de IC para buscar hiperparámetros con `GridSearchCV` sin mezclar el futuro con el pasado.
- Medir la fuga de seleccionar features con todos los datos antes de validar y evitarla metiendo la selección en el pipeline.

## Tres formas de aprender

El nivel usa tres familias de métodos. Se distinguen por lo que reciben para aprender, no por el algoritmo:

```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 $x$ y una etiqueta $y$ observada, y el modelo aprende
  $f(x) \approx y$. Si $y$ 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 $x$. 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:

```python
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())
```
```output
145600 filas de entrenamiento; 145600 de prueba
2018-12-28 | 2019-01-11
```

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

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

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

```python
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))
```
```output
              bajo   alto
ret_1s      -0.112  0.116
sue         -2.990  3.662
sentimiento -2.344  2.307
```

Con é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:

```python
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}")
```
```output
['winsorizador', 'simpleimputer', 'standardscaler', 'ridge']
['ruido_4' 'ruido_5' 'missingindicator_sentimiento']
IC medio 2019-2025: 0.0181
```

`lineal[:-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:

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

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

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

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

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

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

*Fig. — Particiones del partidor por fechas en 2012-2018: ventana de entrenamiento expansiva, una semana de hueco y un bloque de prueba de unas 60 semanas; aparte, la prueba final de 2019-2025.* Seis filas sobre un eje de 2012 a 2026. En las cinco particiones, una barra gris de entrenamiento que empieza en enero de 2012 y crece hasta marzo de 2013, mayo de 2014, julio de 2015, septiembre de 2016 y octubre de 2017, seguida tras un hueco de una semana por un bloque de prueba azul de unas 60 semanas. La última fila tiene el entrenamiento completo de 2012-2018 y la prueba final de 2019-2025 destacada.

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

```python
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_)
```
```output
                    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}
```

*Fig. — IC de validación de la ridge según su regularización α, media de las cinco particiones con su error típico: sube y se estabiliza cuando α domina.* Línea con puntos sobre un eje logarítmico de α entre 1 y 100 millones: el IC medio pasa de 0,0043 con α = 1 a 0,0070 desde α = 1 millón, con barras de error de unos ±0,005 en cada punto. El punto elegido, α = 10 millones, está destacado.

Dos cosas que no son obvias. La primera: el IC de validación **sube** con α y se estabiliza en valores enormes.
Cuando α domina a $X^\top X$, la ridge da $\hat\beta \approx X^\top y/\alpha$: 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 $\alpha = 10^6$ y $\alpha = 10^8$ 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).

```python
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)
```
```output
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: float64
```

La 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):

```python
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}")
```
```output
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.001
```

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

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

La regla es mecánica: **todo** lo que mira la etiqueta o aprende parámetros de los datos va dentro del pipeline y se
reajusta en cada partición. Eso incluye la selección de features, los imputadores, los escaladores, el PCA, el
equilibrado de clases y cualquier umbral elegido con los datos. Lo que no puede ir en un pipeline (una feature
elegida mirando un gráfico de todo el histórico, una idea sacada de un artículo que usa el mismo periodo) también
cuenta como intento: va al diario de experimentos de la lección 2.2.4 y al Deflated Sharpe de la 5.4.4.

</aside>

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

1. Sustituye la ridge del pipeline por un `HistGradientBoostingRegressor` de scikit-learn (`max_iter=100`,
   `learning_rate=0.05`, `max_leaf_nodes=8`, `min_samples_leaf=2000`, `random_state=0`) y evalúalo con `cross_val_score`
   y el mismo partidor. ¿Mejora a la ridge en validación?

**Solución.**

```python
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))
```
```output
boosting: [ 0.0053 -0.0085  0.0005  0.0099 -0.0118] -0.0009
ridge elegida: 0.007
```

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

2. Comprueba cuánto cambia el IC de la ridge en 2019-2025 si el `StandardScaler` del pipeline lineal se ajusta con
   todo el periodo 2012-2025 (la fuga «pequeña») en lugar de solo con el entrenamiento.

**Solución.**

```python
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}")
```
```output
sin fuga 0.0181 | con fuga 0.0182
```

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

3. 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 $t$ 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).

## Autoevaluación

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

   - A. Supervisado (clasificación): la etiqueta es el resultado de la triple barrera de cada operación
   - B. No supervisado: no hay etiquetas
   - C. Por refuerzo: el agente decide
   - D. Ninguno: es un backtest

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

2. Se ajusta un `StandardScaler` con todo el histórico 2012-2025 y después se valida un modelo con datos de 2019-2025. ¿Qué ha pasado?

   - A. Nada: escalar no usa la etiqueta
   - 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
   - C. El modelo será más preciso
   - D. Se ha usado mal `fit_transform`

   *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 un `Pipeline`, la validación cruzada lo reajusta en cada pliegue y la fuga desaparece.

3. ¿Por qué `TimeSeriesSplit` de scikit-learn no basta para el panel de 400 acciones?

   - A. Porque no admite pandas
   - B. Porque solo hace tres particiones
   - C. Porque baraja las filas
   - 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

   *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 `TimeSeriesSplit` comparten 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.

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

   - A. Un IC cercano a cero, porque son ruido
   - B. Un error de scikit-learn
   - 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
   - D. Un IC negativo

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

5. En la búsqueda, el IC de validación de la ridge sube con α hasta estabilizarse en valores enormes. ¿Por qué?

   - A. Porque α grande siempre es mejor
   - B. Porque hay un error en el partidor
   - C. Porque las features están en rangos
   - 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

   *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 `fit` aprende algo de los datos. Dentro de un `Pipeline`, la validación lo reajusta en cada partición y no ve la prueba.
- Un transformador propio hereda de `BaseEstimator` y `TransformerMixin`, guarda lo aprendido en atributos con `_` y define `get_feature_names_out` para funcionar con `set_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.

## Recursos

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [scikit-learn: Pipelines and composite estimators](https://scikit-learn.org/stable/modules/compose.html) · scikit-learn.org
- [scikit-learn: Common pitfalls and recommended practices (data leakage)](https://scikit-learn.org/stable/common_pitfalls.html) · scikit-learn.org
- [scikit-learn: Developing scikit-learn estimators](https://scikit-learn.org/stable/developers/develop.html) · scikit-learn.org
- [scikit-learn: Tuning the hyper-parameters of an estimator](https://scikit-learn.org/stable/modules/grid_search.html) · scikit-learn.org

### Libros

- [Hastie, T., Tibshirani, R. y Friedman, J. (2009): The Elements of Statistical Learning, 2.ª ed., sección 7.10.2](https://hastie.su.domains/ElemStatLearn/) · hastie.su.domains

### Datos

- [Datos del nivel 5: etiquetas semanales del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_etiquetas.parquet) · Descarga · panel5_etiquetas.parquet

Lección anterior: [5.1.1 · Por qué el machine learning financiero es distinto](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-ml-financiero-es-distinto/)

Lección siguiente: [5.1.3 · Métricas económicas para modelos de trading: más allá del acierto](https://pythonparatrading.com/curso-python-trading/nivel-5/metricas-economicas-para-modelos-de-trading/)
