Lección 5.5.2 · 70 min

Árboles de decisión, Random Forest y bagging

Un árbol corta el espacio de features en rectángulos y predice la media de cada uno; un bosque promedia cientos de árboles distintos para bajar su varianza. Esta lección los ajusta sobre el panel sintético del nivel, mide cuánto sobreajustan con cada hiperparámetro, comprueba si encuentran la interacción plantada entre reversión y volumen y los compara con la baseline lineal de la lección 5.5.1.

Fig. — Un árbol de regresión según su profundidad máxima: IC dentro de muestra (2012-2018, escala log) y fuera de muestra (2019-2025), frente a MCO
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

Qué hace un árbol

Un árbol de regresión es una sucesión de preguntas de sí o no sobre las features. En cada nodo busca, entre todas las features y todos los umbrales posibles, el corte xj≤cx_j \le c que más reduce el error cuadrático de las dos mitades:

min⁡j, c  ∑i∈I(yi−y‾I)2+∑i∈D(yi−y‾D)2\min_{j,\,c}\; \sum_{i \in I} (y_i - \overline{y}_I)^2 + \sum_{i \in D} (y_i - \overline{y}_D)^2

donde II y DD son las filas que quedan a la izquierda y a la derecha. Repite el proceso en cada mitad hasta que se cumple una regla de parada (profundidad máxima, filas mínimas por hoja) y predice la media de la etiqueta en cada hoja. El resultado es una función escalonada: constante en cada rectángulo del espacio de features.

Tres propiedades lo hacen atractivo para datos financieros:

  • Invariante a transformaciones monótonas. Un corte en vol_63d ≤ 0,3 es el mismo que en su logaritmo o en su rango. No hace falta escalar.
  • Captura interacciones y no linealidades sin pedirlas. Un corte en ret_1s seguido de otro en volumen_rel es una interacción; un corte en sue es un umbral. Un modelo lineal necesita que alguien escriba esos términos.
  • Es rápido. Con el algoritmo de histogramas de las librerías de boosting (lección 5.5.3), cientos de miles de filas se ajustan en segundos.

Y un defecto que lo decide todo: varianza altísima. Cada corte se elige con los datos del nodo, y en los nodos profundos quedan pocas filas. Con una etiqueta en la que la señal es el 0,1 % de la varianza (lección 5.5.1), un árbol grande se dedica a ajustar ruido. Bagging y Random Forest existen para corregir eso.

El banco de pruebas

Los datos son los del panel sintético del nivel (400 acciones ficticias, 730 semanas, 21 features semanales, la verdad publicada en public/curso/datos/nivel-5/README.md); nada de lo que sale aquí es evidencia sobre mercados reales. Como en la lección 5.5.1, las features van en rangos normales de cada semana. Aquí también la etiqueta: el rango normal del rendimiento de la semana siguiente. Un árbol que ajusta medias es sensible a las colas (una acción que sube un 40 % arrastra la media de su hoja), y pasar la etiqueta a rangos las neutraliza sin cambiar lo que se evalúa, el IC de rangos semanal.

Para que la lección se ejecute en menos de dos minutos se usa una sola partición: entrenamiento 2012-2018 sin la última semana (su etiqueta termina en 2019: purga de la lección 5.4.2) y prueba 2019-2025, 365 semanas. En producción se haría un walk-forward anual como el de la lección 5.5.1, con más árboles y una rejilla de hiperparámetros validada dentro de cada entrenamiento.

IN [01]python
import numpy as np
import pandas as pd
from scipy.stats import norm
from sklearn.linear_model import LinearRegression

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
v = pd.read_parquet("panel5_verdad.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana", "ret_sig_21d"]],
             on=["fecha", "ticker"])
      .merge(v, on=["fecha", "ticker"])
      .dropna(subset=["ret_sig_semana"])
      .reset_index(drop=True))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]

def g(s):
    """Rango normal de cada semana (lección 5.2.4); sin dato, 0."""
    por = s.groupby(d["fecha"])
    u = (por.rank() - 0.5) / por.transform("count")
    return pd.Series(norm.ppf(u), index=s.index).fillna(0.0)

def ic(score, mask):
    """IC de rangos de cada semana entre score y la etiqueta."""
    t = pd.DataFrame({"s": score, "f": sem[mask],
                      "y": d.loc[mask, "ret_sig_semana"].to_numpy()})
    t[["s", "y"]] = t.groupby("f")[["s", "y"]].rank()
    return t.groupby("f")[["s", "y"]].corr().xs("s", level=1)["y"]

X = np.column_stack([g(d[k]) for k in FE]).astype(np.float32)
yr = g(d["ret_sig_semana"]).to_numpy()       # etiqueta en rangos
sem = d["fecha"].to_numpy()
tr = sem < np.datetime64("2018-12-24")       # 2012-2018, purgado
te = sem >= np.datetime64("2019-01-01")      # 2019-2025
mco = LinearRegression().fit(X[tr], yr[tr])
ic_te = ic(mco.predict(X[te]), te)
se = ic_te.std() / np.sqrt(len(ic_te))
print(f"MCO: IC dentro {ic(mco.predict(X[tr]), tr).mean():.4f}  "
      f"fuera {ic_te.mean():.4f} ± {se:.4f}")
OUT [01]Datos, etiqueta en rangos y baseline lineal
MCO: IC dentro 0.0274  fuera 0.0180 ± 0.0045

La baseline: MCO con las 21 features da un IC de 0,0180 ± 0,0045 fuera de muestra (0,0274 dentro). Es la cifra que tiene que batir cada modelo de esta lección.

Un árbol pequeño, leído de arriba abajo

IN [02]python
from sklearn.tree import DecisionTreeRegressor, export_text

arbol = DecisionTreeRegressor(max_depth=2, random_state=0)
arbol.fit(X[tr], yr[tr])
print(export_text(arbol, feature_names=FE, decimals=3))
filas_hoja = pd.Series(arbol.apply(X[tr])).value_counts().sort_index()
print("filas por hoja:", filas_hoja.tolist())
OUT [02]Un árbol de profundidad 2
|--- ret_1s <= 0.512
|   |--- mom_12_1 <= -0.708
|   |   |--- value: [-0.021]
|   |--- mom_12_1 >  -0.708
|   |   |--- value: [0.020]
|--- ret_1s >  0.512
|   |--- ruido_1 <= -2.304
|   |   |--- value: [0.158]
|   |--- ruido_1 >  -2.304
|   |   |--- value: [-0.025]

filas por hoja: [24016, 76956, 431, 43797]
sínosínosínoret_1s ≤ 0,512mom_12_1 ≤ −0,708ruido_1 ≤ −2,304−0,0210,0200,158−0,025
Fuente Mermaid
flowchart TB
  A{"ret_1s ≤ 0,512"} -->|sí| B{"mom_12_1 ≤ −0,708"}
  A -->|no| C{"ruido_1 ≤ −2,304"}
  B -->|sí| D["−0,021"]
  B -->|no| E["0,020"]
  C -->|sí| F["0,158"]
  C -->|no| G["−0,025"]

El primer corte es el efecto más fuerte del panel: las acciones que más han subido en la semana (rango de ret_1s por encima de 0,512, el 30 % superior) tienden a dar la vuelta. En la rama izquierda, el segundo corte separa por momentum, el segundo efecto. En la derecha, el árbol corta por ruido_1, ruido puro por construcción: 431 filas (el 0,3 % del entrenamiento) con valores extremos de ruido_1 tuvieron, por azar, una media alta en 2012-2018 (0,158 en rangos), y eso basta para que ese corte sea el que más reduce el error en ese nodo. Un árbol no sabe qué features tienen sentido; solo sabe qué corte reduce el error en las filas que tiene delante.

Profundidad y tamaño de hoja

IN [03]python
filas = []
for prof in [1, 2, 3, 4, 6, 8, 12, None]:
    a = DecisionTreeRegressor(max_depth=prof, random_state=0)
    a.fit(X[tr], yr[tr])
    filas.append({"profundidad": prof or "sin límite",
                  "hojas": a.get_n_leaves(),
                  "ic_dentro": ic(a.predict(X[tr]), tr).mean(),
                  "ic_fuera": ic(a.predict(X[te]), te).mean()})
pd.DataFrame(filas).round(4)
OUT [03]Profundidad: dentro y fuera de muestra
  profundidad   hojas  ic_dentro  ic_fuera
0           1       2     0.0156    0.0130
1           2       4     0.0222    0.0121
2           3       8     0.0246    0.0124
3           4      16     0.0300    0.0104
4           6      63     0.0416    0.0081
5           8     215     0.0581    0.0098
6          12    1417     0.1156    0.0084
7  sin límite  142712     1.0000    0.0024

La figura de la cabecera dibuja esta tabla. El IC dentro de muestra sube con cada nivel, de 0,0156 con un solo corte a 1,0000 sin límite, cuando cada una de las 142 712 hojas tiene una sola fila y la predicción es la propia etiqueta. Fuera de muestra pasa lo contrario: el máximo está en profundidad 1 (0,0130) y sin límite queda en 0,0024. Ningún árbol llega a MCO (0,0180). Con esta relación señal/ruido, el árbol óptimo es un tocón: un corte en la feature con más señal.

La profundidad no es la única forma de frenar el árbol. El tamaño mínimo de hoja (min_samples_leaf) deja que el árbol crezca donde hay muchas filas y lo para donde quedan pocas.

IN [04]python
filas = []
for hoja in [100, 1000, 3000, 10000]:
    a = DecisionTreeRegressor(min_samples_leaf=hoja, random_state=0)
    a.fit(X[tr], yr[tr])
    filas.append({"min_hoja": hoja, "hojas": a.get_n_leaves(),
                  "profundidad": a.get_depth(),
                  "ic_dentro": ic(a.predict(X[tr]), tr).mean(),
                  "ic_fuera": ic(a.predict(X[te]), te).mean()})
pd.DataFrame(filas).round(4)
OUT [04]Tamaño mínimo de hoja
   min_hoja  hojas  profundidad  ic_dentro  ic_fuera
0       100   1116           42     0.2337   -0.0013
1      1000    114           16     0.0761    0.0092
2      3000     37           13     0.0449    0.0093
3     10000     12            6     0.0305    0.0111

Con hojas de al menos 100 filas el árbol llega a 42 niveles y 1 116 hojas, y fuera de muestra el IC es negativo (−0,0013). Con 10 000 filas por hoja queda en 12 hojas y 0,0111. El tamaño de hoja es el hiperparámetro que más importa en datos ruidosos, porque fija cuántas observaciones promedia cada predicción: con un ruido de desviación 1 (la etiqueta en rangos) y una señal del orden de 0,03, hacen falta miles de filas para que la media de una hoja distinga una de otra. Por eso en finanzas se usan hojas de cientos o miles de filas, no las de 1 a 5 de los ejemplos de los manuales.

Bagging y Random Forest

Un árbol grande tiene sesgo bajo y varianza alta. La idea de Breiman (1996) es reducir la varianza promediando: entrenar BB árboles, cada uno con una muestra bootstrap distinta de las filas, y promediar sus predicciones (bagging, de bootstrap aggregating). Si cada árbol tiene varianza σ2\sigma^2 y la correlación entre dos árboles es ρ\rho, la varianza de la media es

Var⁡(1B∑b=1BTb(x))=ρ σ2+1−ρB σ2.\operatorname{Var}\Big(\frac{1}{B}\sum_{b=1}^{B} T_b(x)\Big) = \rho\,\sigma^2 + \frac{1-\rho}{B}\,\sigma^2 .

Al añadir árboles el segundo término desaparece, pero el primero no: el suelo es ρσ2\rho\sigma^2. Random Forest (Breiman, 2001) baja ρ\rho obligando a cada corte a elegir solo entre max_features features sorteadas. Si la feature más fuerte no está en el sorteo, el árbol corta por otra, y los árboles se parecen menos.

Filas de 2012-2018Muestra 1Muestra 2Muestra BÁrbol 1Árbol 2Árbol BMedia
Fuente Mermaid
flowchart TB
  D[Filas de 2012-2018] --> B1[Muestra 1]
  D --> B2[Muestra 2]
  D --> B3[Muestra B]
  B1 --> T1[Árbol 1]
  B2 --> T2[Árbol 2]
  B3 --> T3[Árbol B]
  T1 --> M[Media]
  T2 --> M
  T3 --> M

En scikit-learn, max_features es el número (o la fracción) de features candidatas en cada corte: con 21 es bagging puro; la regla clásica para regresión era un tercio (7); "sqrt" da 4. max_samples=0.3 hace que cada árbol vea una muestra bootstrap del 30 % de las filas: más rápido y árboles más distintos. La celda entrena cuatro bosques de 100 árboles con hojas de al menos 1 000 filas, guarda la predicción de cada árbol por separado y mide la correlación media entre árboles, ρ\rho, y el IC al promediar los primeros bb árboles.

IN [05]python
from sklearn.ensemble import RandomForestRegressor

def bosque(mf, n=100, hoja=1000):
    return RandomForestRegressor(
        n_estimators=n, min_samples_leaf=hoja, max_features=mf,
        max_samples=0.3, n_jobs=4, random_state=0)

curvas, filas, bosques = {}, [], {}
for mf in [21, 7, 4, 1]:                       # 21 = bagging puro
    rf = bosques[mf] = bosque(mf).fit(X[tr], yr[tr])
    P = np.stack([t.predict(X[te]) for t in rf.estimators_])
    rho = np.corrcoef(P[:30])[np.triu_indices(30, 1)].mean()
    curvas[mf] = [ic(P[:b].mean(0), te).mean()
                  for b in [1, 3, 10, 30, 100]]
    filas.append({"max_features": mf, "rho_arboles": rho,
                  "ic_dentro": ic(rf.predict(X[tr]), tr).mean(),
                  "ic_1_arbol": curvas[mf][0],
                  "ic_100": curvas[mf][-1]})
pd.DataFrame(filas).round(4)
OUT [05]Bagging y Random Forest: max_features
   max_features  rho_arboles  ic_dentro  ic_1_arbol  ic_100
0            21       0.0932     0.0735      0.0031  0.0130
1             7       0.0823     0.0672     -0.0014  0.0137
2             4       0.0770     0.0651      0.0039  0.0136
3             1       0.0519     0.0618      0.0034  0.0123

random_state=0 con n_jobs=4 es determinista: cada árbol recibe su propia semilla derivada de la del bosque y el reparto entre hilos no cambia el resultado. Lo que muestra la tabla:

  • Un árbol solo no vale nada: IC entre −0,0014 y 0,0039 fuera de muestra. La media de 100 llega a 0,0123-0,0137. Es la reducción de varianza del término (1−ρ)σ2/B(1-\rho)\sigma^2/B.
  • La correlación entre árboles es baja en cualquier caso (de 0,093 con bagging a 0,052 con una feature por corte): con muestras del 30 % y una etiqueta tan ruidosa, cada árbol ajusta un ruido distinto. Bajar ρ\rho con max_features ayuda poco aquí, porque el suelo ρσ2\rho\sigma^2 ya es pequeño. En datos con más señal el efecto es mayor.
  • El óptimo está en medio: 7 features por corte (0,0137). Con una sola, cada árbol corta a menudo por una feature sin efecto y es peor (0,0123).
IN [06]python
pd.DataFrame(curvas, index=[1, 3, 10, 30, 100]).round(4)
OUT [06]La curva del número de árboles
         21      7       4       1
1    0.0031 -0.0014  0.0039  0.0034
3    0.0047  0.0034  0.0063  0.0019
10   0.0105  0.0085  0.0090  0.0083
30   0.0114  0.0138  0.0130  0.0096
100  0.0130  0.0137  0.0136  0.0123
Fig. — IC fuera de muestra al promediar 1, 3, 10, 30 y 100 árboles: bagging (las 21 features en cada corte) y Random Forest con 7 y 1 features por corte, frente a MCO

El IC sube rápido hasta 30 árboles y después se aplana: añadir árboles nunca empeora un bosque (no sobreajusta por número de árboles, a diferencia del boosting de la lección 5.5.3), pero deja de ayudar cuando el término 1/B1/B ya es pequeño. Y ningún bosque llega a la línea de MCO.

IN [07]python
dif = ic(bosques[7].predict(X[te]), te) - ic_te
se_d = dif.std() / np.sqrt(len(dif))
print(f"bosque (max_features=7) − MCO: {dif.mean():+.4f} ± {se_d:.4f}")
OUT [07]El mejor bosque frente a MCO, emparejado
bosque (max_features=7) − MCO: -0.0042 ± 0.0018

La diferencia emparejada, semana a semana, es de −0,0042 ± 0,0018 (t ≈ −2,3): el bosque es peor que la regresión lineal, y con una diferencia que no es ruido. La verdad del panel es casi lineal en los rangos (el 61 % de la varianza del alfa, lección 5.5.1), y un bosque aproxima una recta con escalones que cuestan varianza. Es lo esperable con esta relación señal/ruido: un modelo flexible solo gana si la parte no lineal de la señal compensa la varianza que añade.

La interacción plantada: reversión × volumen

El panel tiene una interacción verdadera: la reversión de la semana se multiplica por (1−0,5 g(volumen_rel))(1 - 0{,}5\,g(\text{volumen\_rel})). Con volumen relativo bajo (g=−1,2g = -1{,}2) la reversión es 1,6 veces la media; con volumen alto (g=1,2g = 1{,}2), 0,4 veces. Es un efecto plantado con parámetros conocidos, no un hecho de los mercados: Campbell, Grossman y Wang (1993) encuentran en índices y acciones grandes de Estados Unidos, con datos diarios, la relación contraria (la autocorrelación de los rendimientos baja con el volumen: más reversión cuando hay mucho volumen, que atribuyen a la presión de liquidez). Lo que importa aquí es que la forma verdadera se conoce y se puede comprobar si un modelo la encuentra.

La herramienta para ver si un modelo la ha capturado es la dependencia parcial: se fijan ret_1s y volumen_rel en una rejilla de valores, se dejan las demás features como están en una muestra de filas de prueba y se promedia la predicción. La pendiente de la curva en ret_1s para cada nivel de volumen mide la reversión que ve el modelo. La celda compara tres bosques: uno entrenado con el alfa verdadero (sin ruido) y solo esas dos features, que muestra lo mejor que puede ver un bosque; uno con la etiqueta y las dos features; y uno con la etiqueta y las 21.

IN [08]python
ir, iv = FE.index("ret_1s"), FE.index("volumen_rel")
gr = np.linspace(-2, 2, 9)                   # rangos de ret_1s
gv = [-1.2, 0.0, 1.2]                        # volumen bajo/medio/alto

def dependencia(modelo, Xm, cols):
    """Dependencia parcial: predicción media fijando los dos rangos."""
    a, b = cols.index(ir), cols.index(iv)
    z = np.empty((len(gr), len(gv)))
    for j, v_ in enumerate(gv):
        for i, r_ in enumerate(gr):
            Z = Xm.copy()
            Z[:, a], Z[:, b] = r_, v_
            z[i, j] = modelo.predict(Z).mean()
    return z

dos = [ir, iv]
alfa = 1e4 * (d["alfa_verdadero"]
              - d.groupby("fecha")["alfa_verdadero"].transform("mean"))
casos = {"verdad (alfa)": (alfa.to_numpy(), dos, None),
         "bosque, 2 features": (yr, dos, None),
         "bosque, 21 features": (yr, list(range(21)), 7)}
dep = {}
muestra = np.flatnonzero(te)[::50]           # 2 920 filas de prueba
for nombre, (obj, cols, mf) in casos.items():
    rf = bosque(mf, hoja=2000).fit(X[tr][:, cols], obj[tr])
    dep[nombre] = dependencia(rf, X[muestra][:, cols], cols)
    pend = (dep[nombre][-1] - dep[nombre][0]) / 4
    print(f"{nombre:20s} pendiente relativa (bajo, medio, alto):",
          (pend / pend[1]).round(2))
OUT [08]La interacción reversión × volumen
verdad (alfa)        pendiente relativa (bajo, medio, alto): [1.12 1.   0.35]
bosque, 2 features   pendiente relativa (bajo, medio, alto): [1.12 1.   0.39]
bosque, 21 features  pendiente relativa (bajo, medio, alto): [1.   1.   0.91]
Fig. — Dependencia parcial en el rango de ret_1s con volumen relativo bajo, medio y alto: bosque entrenado con el alfa verdadero, con la etiqueta y dos features, y con la etiqueta y las 21

Tres resultados, uno por panel de la figura:

  • El bosque entrenado con la verdad ve una reversión relativa de 1,12 / 1 / 0,35 con volumen bajo, medio y alto. La fórmula dice 1,6 / 1 / 0,4: con hojas de 2 000 filas el bosque suaviza los extremos, pero la forma está.
  • El bosque con la etiqueta y solo las dos features da 1,12 / 1 / 0,39, prácticamente lo mismo. Con 145 000 filas y la oportunidad de cortar por las dos, el bosque encuentra la interacción a pesar del ruido.
  • El bosque con las 21 features da 1 / 1 / 0,91: casi no la ve. volumen_rel no tiene efecto propio, así que un corte en ella no reduce el error por sí solo y casi nunca gana el sorteo de cortes frente a 20 rivales, varias con efecto directo. Sin cortes en volumen no hay interacción.

Esta es la lección incómoda de los árboles en finanzas: pueden representar cualquier interacción, pero solo encuentran las que dejan un rastro en cortes de una sola feature. La alternativa barata es escribirla.

IN [09]python
Xi = np.column_stack([X, X[:, ir] * X[:, iv]])
mi = LinearRegression().fit(Xi[tr], yr[tr])
b_r, b_i = mi.coef_[ir], mi.coef_[-1]
rel = [(b_r - 1.2 * b_i) / b_r, 1, (b_r + 1.2 * b_i) / b_r]
print("pendiente relativa (bajo, medio, alto):", np.round(rel, 2))
print(f"IC fuera: {ic(mi.predict(Xi[te]), te).mean():.4f}")
OUT [09]La interacción escrita a mano
pendiente relativa (bajo, medio, alto): [1.78 1.   0.22]
IC fuera: 0.0192

MCO con un solo término añadido, ret_1s × volumen_rel, ve 1,78 / 1 / 0,22 (la verdad: 1,6 / 1 / 0,4) y da un IC de 0,0192, el mejor de la lección. Un término con una hipótesis detrás vale más que 100 árboles buscando a ciegas. La lección 5.5.1 mostró el otro lado: añadir los 210 productos posibles sin hipótesis le costaba a MCO un tercio del IC.

El error out-of-bag es un k-fold aleatorio

Cada árbol de un bosque deja fuera de su muestra bootstrap una parte de las filas; predecir cada fila solo con los árboles que no la vieron da el error out-of-bag (OOB), una validación gratis. En datos independientes es una buena estimación del error de generalización. En series financieras es un k-fold barajado: las filas que un árbol no vio son vecinas en el tiempo de filas que sí vio. Con etiquetas que se solapan, eso es una fuga. La celda lo reproduce con el montaje del README de los datos: clasificar si el rendimiento de 21 sesiones queda por encima de la mediana de la semana, en 80 acciones, con un bosque de hojas pequeñas.

IN [10]python
from sklearn.ensemble import RandomForestClassifier

tick = np.sort(d["ticker"].unique())[::5]       # 80 acciones
m = d["ticker"].isin(tick).to_numpy() & d["ret_sig_21d"].notna()
m = m.to_numpy()
med = d.groupby("fecha")["ret_sig_21d"].transform("median")
z21 = (d["ret_sig_21d"] > med).to_numpy()[m].astype(int)
X80, s80 = X[m], sem[m]
corte = np.datetime64("2019-01-01")
a_tr = s80 < corte - np.timedelta64(28, "D")    # purga de 4 semanas
rfc = RandomForestClassifier(
    n_estimators=60, min_samples_leaf=5, max_samples=0.3,
    oob_score=True, n_jobs=4, random_state=0).fit(X80[a_tr], z21[a_tr])
acierto = (rfc.predict(X80[s80 >= corte]) == z21[s80 >= corte]).mean()
print(f"acierto OOB: {100 * rfc.oob_score_:.1f} %   "
      f"walk-forward 2019-2025: {100 * acierto:.1f} %")
OUT [10]El error out-of-bag es un k-fold aleatorio
acierto OOB: 61.3 %   walk-forward 2019-2025: 50.2 %

El OOB promete un 61,3 % de acierto; entrenando hasta 2018 con cuatro semanas de purga y prediciendo 2019-2025 queda en un 50,2 %, el nivel del oráculo de este panel (el README da un 50,5 % para el signo del alfa verdadero en ese periodo). Los once puntos de diferencia son memoria: la etiqueta de 21 sesiones de una semana comparte cuatro semanas con la de la siguiente, y las features lentas apenas cambian. Es la fuga de la lección 5.4.1 con otro nombre. Las soluciones son las de esa lección y la 5.4.2 (validación purgada), o el bootstrap secuencial de la lección 5.3.4, que sortea las filas con probabilidad inversa a su solapamiento.

Importancia por impureza: lo que no dice

Cada corte reduce la impureza (el error cuadrático) del nodo; sumar esas reducciones por feature y normalizar da la importancia por impureza (MDI) que scikit-learn guarda en feature_importances_.

IN [11]python
mdi = pd.Series(bosques[7].feature_importances_, index=FE)
mdi.sort_values(ascending=False).round(3).head(10)
OUT [11]Importancia por impureza
ret_1s      0.074
mom_12_1    0.067
rsi_14      0.059
ret_3m      0.054
sue         0.053
vol_63d     0.050
ruido_2     0.049
ruido_4     0.049
vol_21d     0.048
ret_1m      0.047
dtype: float64

Las dos primeras son los efectos más fuertes, pero el resto es una lista casi plana entre 0,047 y 0,059 en la que ruido_2 y ruido_4 empatan con vol_63d y superan a valor_bp, que tiene un efecto verdadero de 3,6 pb y no aparece entre las diez primeras. La MDI se calcula dentro de muestra y premia a las features continuas que ofrecen muchos umbrales para ajustar ruido; en un problema ruidoso mide sobre todo eso. Para la importancia hay que usar datos fuera de muestra y métodos como MDA, SFI o SHAP (lección 5.2.5).

Errores típicos

  • Hojas pequeñas. Los valores por defecto de scikit-learn (min_samples_leaf=1, sin límite de profundidad) están pensados para problemas con señal. En rendimientos, hojas de cientos o miles de filas.
  • Validar con el OOB o con un k-fold barajado. Con etiquetas solapadas promete aciertos que no existen (61,3 % frente a 50,2 %). Validación purgada, siempre.
  • Esperar que el bosque encuentre todas las interacciones. Encuentra las que dejan rastro en cortes de una sola feature. Las que tienen una hipótesis detrás se escriben.
  • Leer la importancia por impureza como evidencia. Es una medida dentro de muestra y sesgada hacia features con muchos valores; el ruido sale entre las diez primeras.
  • No comparar con la baseline lineal. Un bosque con un IC de 0,0137 parece un buen resultado hasta que se pone al lado de un MCO de 0,0180 sobre las mismas semanas.
  • Olvidar el coste. Un bosque de 100 árboles con hojas de 1 000 filas tarda decenas de segundos donde MCO tarda milisegundos, y su predicción no se explica con una tabla de coeficientes.

Ejercicios

  1. Repite la tabla de max_features con hojas de 3 000 filas en vez de 1 000 (solo bagging y max_features=7, para que tarde poco). ¿Sube o baja el IC fuera de muestra? ¿Y la correlación entre árboles?
Solución
IN [12]python
for mf in [21, 7]:
    rf = bosque(mf, hoja=3000).fit(X[tr], yr[tr])
    P = np.stack([t.predict(X[te]) for t in rf.estimators_[:30]])
    rho = np.corrcoef(P)[np.triu_indices(30, 1)].mean()
    print(f"max_features {mf:2d}: rho {rho:.3f}  "
          f"IC fuera {ic(rf.predict(X[te]), te).mean():.4f}")
OUT [12]Hojas de 3 000 filas
max_features 21: rho 0.170  IC fuera 0.0144
max_features  7: rho 0.159  IC fuera 0.0151

Con hojas más grandes cada árbol promedia más filas y ajusta menos ruido, así que se parece más a los demás: la correlación entre árboles sube de 0,093 a 0,170 (bagging) y de 0,082 a 0,159 (7 features). El IC fuera de muestra mejora algo, de 0,0130 a 0,0144 y de 0,0137 a 0,0151, y sigue por debajo de MCO (0,0180). El tamaño de hoja y el número de árboles hacen el mismo trabajo por dos caminos (promediar dentro de cada árbol o entre árboles), y ninguno de los dos lleva el bosque hasta MCO, porque lo que le falta no es solo varianza sino una forma funcional mejor. Probar varios tamaños de hoja y quedarse con el mejor en 2019-2025 sería, además, elegir con los datos de prueba: la rejilla se valida dentro del entrenamiento (lección 5.5.4).

  1. Entrena un bosque con solo tres features: ret_1s, volumen_rel y mom_12_1, y mide su IC fuera de muestra y la pendiente relativa de la reversión con volumen bajo, medio y alto. ¿Qué ganas y qué pierdes frente al bosque de 21?
Solución
IN [13]python
tres = [ir, iv, FE.index("mom_12_1")]
rf3 = bosque(None, hoja=2000).fit(X[tr][:, tres], yr[tr])
z3 = dependencia(rf3, X[muestra][:, tres], tres)
pend = (z3[-1] - z3[0]) / 4
print("pendiente relativa:", (pend / pend[1]).round(2))
print(f"IC fuera: {ic(rf3.predict(X[te][:, tres]), te).mean():.4f}")
OUT [13]Un bosque con tres features
pendiente relativa: [0.96 1.   0.54]
IC fuera: 0.0131

Con tres features el bosque ve parte de la interacción: con volumen alto la reversión baja a 0,54 de la media, aunque con volumen bajo no sube (0,96), porque volumen_rel ya solo compite con dos rivales y los árboles cortan alguna vez por ella. Lo que pierde es la señal del resto de efectos verdaderos (value, SUE, sentimiento, baja volatilidad): su IC, 0,0131, queda por debajo del bosque de 21 (0,0137) y de MCO (0,0180). Seleccionar features con una hipótesis antes de entrenar un bosque es una forma de regularizarlo; hacerlo mirando el resultado fuera de muestra sería sobreajustar la selección.

  1. Sin código: un proveedor de señales presenta un random forest sobre 150 features diarias con «un acierto out-of-bag del 58 %» y una tabla de importancias en la que la primera es una feature de sentimiento de redes sociales. ¿Qué dos números pedirías y por qué la tabla de importancias no te convence?
Solución

El OOB es un k-fold aleatorio: con features diarias lentas y etiquetas que suelen abarcar varios días, las filas que un árbol no vio son casi copias de las que vio. Pediría el acierto (o el IC) en un walk-forward con purga y embargo del tamaño del horizonte de la etiqueta, y la comparación emparejada con una baseline lineal sobre las mismas fechas (lecciones 5.4.2, 5.4.3 y 5.5.1). En el panel de este nivel el OOB da un 61,3 % y el walk-forward un 50,2 %. La tabla de importancias, si es la de feature_importances_, es una MDI calculada dentro de muestra que premia a las features continuas con muchos valores, ruido incluido; que una feature salga la primera no dice que prediga fuera de muestra. Pediría la importancia por permutación (MDA) sobre el periodo de validación o SHAP (lección 5.2.5), y el resultado del modelo sin esa feature.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un árbol sin límite de profundidad da un IC de 1,00 dentro de muestra y 0,0024 fuera. ¿Qué ha pasado?

    • AEl árbol ha encontrado la señal verdadera
    • BHay una fuga en los datos de prueba
    • CCada una de las 142 712 hojas contiene una sola fila: el árbol memoriza la etiqueta de entrenamiento, ruido incluido, y fuera de muestra predice casi al azar
    • DEl IC dentro de muestra está mal calculado
    Ver respuesta

    Respuesta C. Cada una de las 142 712 hojas contiene una sola fila: el árbol memoriza la etiqueta de entrenamiento, ruido incluido, y fuera de muestra predice casi al azar

    Con hojas de una fila, la predicción dentro de muestra es la propia etiqueta. Con una relación señal/ruido de uno a setenta, casi todo lo memorizado es ruido; el mejor árbol fuera de muestra es el más pequeño (profundidad 1, IC 0,0130).

  2. ¿Por qué un Random Forest usa max_features menor que el número de features?

    • APara que los árboles sean distintos entre sí: la varianza de una media de B árboles es ρσ² + (1 − ρ)σ²/B, y bajar la correlación ρ baja el suelo que no se elimina añadiendo árboles
    • BPara que cada árbol se entrene más rápido, sin efecto sobre la predicción
    • CPara eliminar las features de ruido
    • DPorque scikit-learn no admite usar todas
    Ver respuesta

    Respuesta A. Para que los árboles sean distintos entre sí: la varianza de una media de B árboles es ρσ² + (1 − ρ)σ²/B, y bajar la correlación ρ baja el suelo que no se elimina añadiendo árboles

    Con todas las features en cada corte (bagging), los árboles eligen casi siempre los mismos cortes y se parecen (ρ = 0,093 en el panel). Con 1 feature por corte, ρ baja a 0,052. El precio es que cada árbol es peor; el óptimo está en medio (7 features: IC 0,0137).

  3. Un bosque con solo ret_1s y volumen_rel reproduce la interacción plantada (pendiente relativa 1,12 / 1 / 0,39), y uno con las 21 features no (1 / 1 / 0,91). ¿Por qué?

    • APorque las 21 features tienen una fuga
    • BPorque volumen_rel no tiene efecto propio: con 21 candidatas y una etiqueta ruidosa, los árboles casi nunca cortan por ella, y sin cortes en volumen no hay interacción que mostrar
    • CPorque los bosques no pueden representar interacciones
    • DPorque la dependencia parcial está mal calculada con 21 features
    Ver respuesta

    Respuesta B. Porque volumen_rel no tiene efecto propio: con 21 candidatas y una etiqueta ruidosa, los árboles casi nunca cortan por ella, y sin cortes en volumen no hay interacción que mostrar

    Un árbol solo usa una feature si un corte en ella reduce el error en ese nodo. Una feature cuyo efecto existe solo en interacción con otra no reduce el error por sí sola y queda enterrada entre 20 rivales; un modelo lineal con el término ret_1s × volumen_rel escrito a mano la recupera mejor (1,78 / 1 / 0,22).

  4. El random forest de clasificación con etiquetas de 21 sesiones da un acierto out-of-bag del 61,3 % y un 50,2 % en el walk-forward. ¿Qué explica la diferencia?

    • AEl régimen de mercado cambió en 2019
    • BEl OOB usa menos árboles
    • CLa purga del walk-forward es demasiado larga
    • DLas filas fuera de la muestra bootstrap de un árbol son vecinas en el tiempo de filas que sí están dentro, con etiquetas que se solapan: el OOB es un k-fold aleatorio y hereda su fuga
    Ver respuesta

    Respuesta D. Las filas fuera de la muestra bootstrap de un árbol son vecinas en el tiempo de filas que sí están dentro, con etiquetas que se solapan: el OOB es un k-fold aleatorio y hereda su fuga

    Cada etiqueta de 21 sesiones comparte cuatro de sus cinco semanas con la de la semana siguiente. El árbol que vio la semana t acierta la t + 1 por memoria, no por señal. Es la misma fuga de la lección 5.4.1.

  5. En la importancia por impureza (MDI) del bosque, ruido_2 y ruido_4 aparecen entre las diez primeras y valor_bp no. ¿Cómo se interpreta?

    • AEl ruido es más útil que el value
    • BQue hay que quitar valor_bp
    • CLa MDI mide cuánto se usa una feature para cortar dentro de muestra; las features continuas de ruido ofrecen muchos cortes posibles y absorben ruido. No dice nada de su valor fuera de muestra
    • DQue el bosque ha encontrado un efecto en el ruido
    Ver respuesta

    Respuesta C. La MDI mide cuánto se usa una feature para cortar dentro de muestra; las features continuas de ruido ofrecen muchos cortes posibles y absorben ruido. No dice nada de su valor fuera de muestra

    Las importancias por impureza están sesgadas hacia features con muchos valores y se calculan sobre el entrenamiento. MDA, SFI y SHAP sobre datos de validación (lección 5.2.5) son las herramientas para la importancia.

Ideas clave

  • Un árbol de regresión parte las filas con el corte que más reduce el error cuadrático y predice la media de cada hoja. Sin límites memoriza: IC 1,00 dentro de muestra y 0,0024 fuera.
  • Con esta relación señal/ruido, el mejor árbol es diminuto (profundidad 1: IC 0,0130) y ninguno llega a la baseline lineal (0,0180). Limitar la hoja a 10 000 filas da 0,0111.
  • Promediar árboles reduce la varianza: un árbol solo da un IC de entre −0,001 y 0,004 y la media de 100, de 0,012 a 0,014. max_features baja la correlación entre árboles (de 0,093 a 0,052) y el óptimo queda en medio (7 features: 0,0137).
  • Un bosque encuentra la interacción reversión × volumen si puede cortar por las dos features, pero con 21 candidatas la diluye. Escrita a mano en un modelo lineal se recupera mejor y da el mejor IC (0,0192).
  • En el panel, el mejor bosque queda por debajo de MCO (−0,0042 ± 0,0018). El OOB promete un 61,3 % de acierto con etiquetas solapadas que el walk-forward deja en 50,2 %, y la importancia por impureza pone ruido entre las diez primeras.

Recursos

Datos