---
title: "5.5.2 Árboles, Random Forest y bagging"
description: "Árboles de decisión, bagging y Random Forest sobre un panel con la verdad publicada: profundidad, hojas, max_features, la interacción plantada y el OOB."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/arboles-random-forest-y-bagging/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/arboles-random-forest-y-bagging.png"
---

# 5.5.2 · Á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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.5 · Modelos clásicos
- Duración: 70 min
- Requisitos: [5.5.1 · Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística](https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/), [5.4.1 · Por qué falla el k-fold aleatorio en datos financieros](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-falla-el-k-fold-aleatorio/), [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/)

## Al terminar esta lección

- Explicar cómo crece un árbol de regresión (cortes que reducen el error cuadrático) y leer un árbol pequeño ajustado sobre datos financieros.
- Controlar el sobreajuste de un árbol con la profundidad máxima y el tamaño mínimo de hoja, midiendo el IC dentro y fuera de muestra.
- Explicar por qué promediar árboles reduce la varianza (bagging) y cómo max_features baja la correlación entre ellos (Random Forest).
- Comprobar con dependencia parcial si un modelo captura una interacción conocida, y por qué un bosque con muchas features la diluye.
- Reconocer que el error out-of-bag es un k-fold aleatorio y que la importancia por impureza premia al ruido.

## 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 $x_j \le c$ que más reduce el error cuadrático de las dos mitades:

$$
\min_{j,\,c}\; \sum_{i \in I} (y_i - \overline{y}_I)^2 + \sum_{i \in D} (y_i - \overline{y}_D)^2
$$

donde $I$ y $D$ 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.

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

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

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

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

```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)
```
```output
   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 $B$ árboles, cada uno con una muestra bootstrap distinta de las filas, y promediar sus predicciones
(**bagging**, de *bootstrap aggregating*). Si cada árbol tiene varianza $\sigma^2$ y la correlación entre dos árboles
es $\rho$, la varianza de la media es

$$
\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 $\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.

```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 $b$ árboles.

```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)
```
```output
   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-\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 $\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).

```python
pd.DataFrame(curvas, index=[1, 3, 10, 30, 100]).round(4)
```
```output
         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.* Tres líneas que suben con el número de árboles. Un árbol solo da un IC entre −0,001 y 0,004. Con 100 árboles, bagging llega a 0,0130, Random Forest con 7 features por corte (destacado) a 0,0137 y con 1 feature a 0,0123. Ninguna alcanza la línea discontinua de MCO, 0,0180.

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/B$ ya es
pequeño. Y ningún bosque llega a la línea de MCO.

```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}")
```
```output
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(\text{volumen\_rel}))$.
Con volumen relativo bajo ($g = -1{,}2$) la reversión es 1,6 veces la media; con volumen alto ($g = 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.

```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))
```
```output
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 paneles, cada uno con tres líneas que bajan de izquierda a derecha (reversión): volumen bajo (destacada), medio y alto (discontinua). Con el alfa verdadero, la línea de volumen alto es mucho más plana: la reversión se debilita con volumen. El bosque entrenado con la etiqueta y solo ret_1s y volumen_rel reproduce esa forma, con más ruido. El bosque con las 21 features da tres líneas casi paralelas: no ve la interacción.

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.

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

```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} %")
```
```output
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_`.

```python
mdi = pd.Series(bosques[7].feature_importances_, index=FE)
mdi.sort_values(ascending=False).round(3).head(10)
```
```output
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.**

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

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

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

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

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

   - A. El árbol ha encontrado la señal verdadera
   - B. Hay una fuga en los datos de prueba
   - 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
   - D. El IC dentro de muestra está mal calculado

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

   - 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
   - B. Para que cada árbol se entrene más rápido, sin efecto sobre la predicción
   - C. Para eliminar las features de ruido
   - D. Porque scikit-learn no admite usar todas

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

   - A. Porque las 21 features tienen una fuga
   - 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
   - C. Porque los bosques no pueden representar interacciones
   - D. Porque la dependencia parcial está mal calculada con 21 features

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

   - A. El régimen de mercado cambió en 2019
   - B. El OOB usa menos árboles
   - C. La purga del walk-forward es demasiado larga
   - 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

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

   - A. El ruido es más útil que el value
   - B. Que hay que quitar `valor_bp`
   - 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
   - D. Que el bosque ha encontrado un efecto en el ruido

   *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

- [scikit-learn 1.9: árboles de decisión](https://scikit-learn.org/stable/modules/tree.html) · scikit-learn.org
- [scikit-learn 1.9: métodos de conjunto (bagging, Random Forest)](https://scikit-learn.org/stable/modules/ensemble.html) · scikit-learn.org

### Libros

- [Breiman, L. (1996): Bagging Predictors. Machine Learning 24, 123-140](https://doi.org/10.1007/BF00058655) · doi.org
- [Breiman, L. (2001): Random Forests. Machine Learning 45, 5-32](https://doi.org/10.1023/A:1010933404324) · doi.org
- [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)](https://hastie.su.domains/ElemStatLearn/) · hastie.su.domains
- [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](https://doi.org/10.2307/2118454) · doi.org

### Datos

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

Lección anterior: [5.5.1 · Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística](https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/)

Lección siguiente: [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/)
