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.
- Módulo
- 5.5 · Modelos clásicos
- 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
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 que más reduce el error cuadrático de las dos mitades:
donde y 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,3es 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_1sseguido de otro envolumen_reles una interacción; un corte ensuees 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.
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}")MCO: IC dentro 0.0274 fuera 0.0180 ± 0.0045La 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
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())|--- 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]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
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) 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.0024La 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.
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) 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.0111Con 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 árboles, cada uno con una muestra bootstrap distinta de las filas, y promediar sus predicciones (bagging, de bootstrap aggregating). Si cada árbol tiene varianza y la correlación entre dos árboles es , la varianza de la media es
Al añadir árboles el segundo término desaparece, pero el primero no: el suelo es . Random Forest
(Breiman, 2001) baja 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.
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, , y el IC al promediar los primeros árboles.
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) 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.0123random_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 .
- 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 con
max_featuresayuda poco aquí, porque el suelo 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).
pd.DataFrame(curvas, index=[1, 3, 10, 30, 100]).round(4) 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.0123El 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 ya es pequeño. Y ningún bosque llega a la línea de MCO.
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}")bosque (max_features=7) − MCO: -0.0042 ± 0.0018La 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 . Con volumen relativo bajo () la reversión es 1,6 veces la media; con volumen alto (), 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.
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))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]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_relno 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.
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}")pendiente relativa (bajo, medio, alto): [1.78 1. 0.22]
IC fuera: 0.0192MCO 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.
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} %")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_.
mdi = pd.Series(bosques[7].feature_importances_, index=FE)
mdi.sort_values(ascending=False).round(3).head(10)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: float64Las 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
- Repite la tabla de
max_featurescon hojas de 3 000 filas en vez de 1 000 (solo bagging ymax_features=7, para que tarde poco). ¿Sube o baja el IC fuera de muestra? ¿Y la correlación entre árboles?
Solución
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}")max_features 21: rho 0.170 IC fuera 0.0144
max_features 7: rho 0.159 IC fuera 0.0151Con 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).
- Entrena un bosque con solo tres features:
ret_1s,volumen_relymom_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
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}")pendiente relativa: [0.96 1. 0.54]
IC fuera: 0.0131Con 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.
- 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
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).
¿Por qué un Random Forest usa
max_featuresmenor 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).
Un bosque con solo
ret_1syvolumen_relreproduce 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_relno 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_relno 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 mostrarUn á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_relescrito a mano la recupera mejor (1,78 / 1 / 0,22).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.
En la importancia por impureza (MDI) del bosque,
ruido_2yruido_4aparecen entre las diez primeras yvalor_bpno. ¿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
Documentación
Libros
- Breiman, L. (1996): Bagging Predictors. Machine Learning 24, 123-140
- Breiman, L. (2001): Random Forests. Machine Learning 45, 5-32
- Hastie, T., Tibshirani, R. y Friedman, J. (2009): The Elements of Statistical Learning, 2.ª ed., capítulos 9, 10 y 15 (PDF de los autores)
- Campbell, J. Y., Grossman, S. J. y Wang, J. (1993): Trading Volume and Serial Correlation in Stock Returns. The Quarterly Journal of Economics 108(4), 905-939