Métodos de Ensemble: Bagging y Random Forest

Quiz

Motivación: el problema de la varianza

En el capítulo anterior vimos que los árboles de decisión son modelos flexibles capaces de capturar relaciones complejas. Sin embargo, tienen una debilidad fundamental: alta varianza. Un árbol entrenado en un conjunto de datos puede verse completamente diferente si le cambias unas pocas observaciones.

Para ilustrarlo, considera lo siguiente:

Código
import numpy as np
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.datasets import make_moons

np.random.seed(42)
X, y = make_moons(n_samples=150, noise=0.3, random_state=42)

# Muestras bootstrap del mismo tamaño (con reemplazo) — como hace Bagging
idx1 = np.random.choice(len(X), size=len(X), replace=True)
idx2 = np.random.choice(len(X), size=len(X), replace=True)

# Sin límite de profundidad: árboles completos tienen máxima varianza
tree1 = DecisionTreeClassifier(random_state=1)
tree2 = DecisionTreeClassifier(random_state=2)
tree1.fit(X[idx1], y[idx1])
tree2.fit(X[idx2], y[idx2])

h = 0.03
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))

fig, axes = plt.subplots(1, 2, figsize=(13, 5))
for ax, tree, title in zip(axes, [tree1, tree2], ['Árbol 1 (muestra A)', 'Árbol 2 (muestra B)']):
    Z = tree.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    ax.contourf(xx, yy, Z, alpha=0.35, cmap='RdBu_r')
    ax.scatter(X[:, 0], X[:, 1], c=y, cmap='RdBu_r', edgecolors='black', s=40)
    ax.set_title(title, fontsize=13)
    ax.set_xlabel('X1'); ax.set_ylabel('X2')
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Diferencia en predicciones
pred1 = tree1.predict(X)
pred2 = tree2.predict(X)
desacuerdo = (pred1 != pred2).mean()
print(f"Porcentaje de desacuerdo entre los dos árboles: {desacuerdo:.1%}")

Alta varianza de los árboles: dos muestras ligeramente distintas generan árboles muy diferentes
Porcentaje de desacuerdo entre los dos árboles: 12.7%

Los dos árboles fueron entrenados con muestras casi idénticas y producen fronteras de decisión muy distintas. Esto es la varianza en acción: el modelo es demasiado sensible a las particularidades del conjunto de entrenamiento.

La solución intuitiva es simple: si un árbol tiene mucha varianza, ¿qué tal si entrenamos muchos árboles y promediamos sus predicciones? Pero para hacerlo necesitamos generar múltiples versiones del conjunto de entrenamiento a partir de los datos que ya tenemos. Ahí entra el bootstrap.


Bootstrap

El bootstrap es una técnica de remuestreo que permite estimar la variabilidad de un estadístico sin asumir una distribución paramétrica, y sin necesidad de recolectar más datos.

La idea es: dado un conjunto de \(n\) observaciones, genera muestras artificiales del mismo tamaño \(n\) tomando observaciones con reemplazo. Cada muestra resultante se llama muestra bootstrap.

Dataset original: [A, B, C, D, E]   (n = 5)

Muestra bootstrap 1: [B, B, A, D, A]   ← B y A aparecen dos veces; C y E ausentes
Muestra bootstrap 2: [C, E, E, A, D]   ← E aparece dos veces; B ausente
Muestra bootstrap 3: [A, C, B, B, C]   ← B y C aparecen dos veces; D y E ausentes

La propiedad del 63.2%

¿Qué fracción de las observaciones originales aparece en una muestra bootstrap? La probabilidad de que una observación no sea seleccionada en ninguno de los \(n\) sorteos es:

\[P(\text{no seleccionada}) = \left(1 - \frac{1}{n}\right)^n \xrightarrow{n \to \infty} e^{-1} \approx 0.368\]

Por lo tanto, cada muestra bootstrap contiene en promedio \(\approx 63.2\%\) de las observaciones originales (distintas), y el restante \(\approx 36.8\%\) queda fuera de la muestra (out-of-bag, OOB).

Código
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

n = 200
B = 500
fracciones_unicas = []
conteo_apariciones = np.zeros(n, dtype=int)

for _ in range(B):
    muestra = rng.integers(0, n, size=n)
    fracciones_unicas.append(len(np.unique(muestra)) / n)
    conteo_apariciones += np.bincount(muestra, minlength=n)

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Panel 1: distribución de fracción de observaciones únicas
axes[0].hist(fracciones_unicas, bins=25, color='steelblue', alpha=0.8, edgecolor='white')
axes[0].axvline(x=np.mean(fracciones_unicas), color='red', linewidth=2,
                label=f'Media = {np.mean(fracciones_unicas):.3f}')
axes[0].axvline(x=1 - 1/np.e, color='black', linewidth=2, linestyle='--',
                label=f'Límite teórico = {1 - 1/np.e:.3f}')
axes[0].set_xlabel('Fracción de observaciones únicas por muestra bootstrap', fontsize=11)
axes[0].set_ylabel('Frecuencia (sobre 500 muestras)', fontsize=11)
axes[0].set_title('¿Qué fracción de datos incluye cada bootstrap?', fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

# Panel 2: número de veces que aparece cada observación
axes[1].bar(range(n), np.sort(conteo_apariciones), color='tomato', alpha=0.8, width=1.0)
axes[1].axhline(y=B, color='black', linewidth=1.5, linestyle='--',
                label=f'Aparece en todos los bootstraps (B={B})')
axes[1].axhline(y=0, color='gray', linewidth=1)
oob_count = (conteo_apariciones == 0).sum()
axes[1].set_xlabel('Observaciones (ordenadas por frecuencia)', fontsize=11)
axes[1].set_ylabel(f'Veces que aparece (sobre {B} muestras)', fontsize=11)
axes[1].set_title(f'Frecuencia de aparición de cada observación\n'
                  f'({oob_count} observaciones nunca aparecieron en ningún bootstrap)',
                  fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(True, alpha=0.3, axis='y')

plt.tight_layout()
plt.show()

print(f"Fracción media de obs. únicas por bootstrap: {np.mean(fracciones_unicas):.4f}")
print(f"Límite teórico (1 - 1/e):                    {1 - 1/np.e:.4f}")

Propiedades del muestreo bootstrap: fracción única y distribución OOB
Fracción media de obs. únicas por bootstrap: 0.6319
Límite teórico (1 - 1/e):                    0.6321

Ejemplo: estimación de la media con bootstrap

Un uso clásico del bootstrap es estimar la distribución muestral de un estadístico cuando no conocemos su fórmula analítica (o no queremos asumir normalidad).

El procedimiento es siempre el mismo:

  1. Calcula el estadístico de interés en los datos originales → \(\hat{\theta}\)
  2. Genera \(B\) muestras bootstrap y calcula el estadístico en cada una → \(\hat{\theta}_1^*, \ldots, \hat{\theta}_B^*\)
  3. La distribución de \(\{\hat{\theta}_b^*\}\) aproxima la distribución muestral de \(\hat{\theta}\)
Código
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(0)

# Datos originales: muestra de ingresos mensuales (distribución asimétrica)
n = 80
datos = rng.exponential(scale=15_000, size=n)   # media teórica = 15,000
media_muestral = datos.mean()

# Bootstrap
B = 2000
medias_bootstrap = np.array([
    rng.choice(datos, size=n, replace=True).mean()
    for _ in range(B)
])

# Intervalo de confianza bootstrap (percentil 2.5 y 97.5)
ic_low, ic_high = np.percentile(medias_bootstrap, [2.5, 97.5])

# Distribución teórica del estimador (CLT): Normal(media, std/sqrt(n))
x_range = np.linspace(medias_bootstrap.min(), medias_bootstrap.max(), 300)
clt_density = stats.norm.pdf(x_range, loc=media_muestral, scale=datos.std() / np.sqrt(n))

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Panel 1: distribución bootstrap vs. distribución teórica (CLT)
axes[0].hist(medias_bootstrap, bins=50, density=True,
             color='steelblue', alpha=0.7, label='Distribución bootstrap')
axes[0].plot(x_range, clt_density, color='red', linewidth=2.5,
             label='Normal teórica (CLT)')
axes[0].axvline(x=media_muestral, color='black', linewidth=2,
                label=f'Media muestral = {media_muestral:,.0f}')
axes[0].axvline(x=15_000, color='gray', linewidth=1.5, linestyle=':',
                label='Media verdadera = 15,000')
axes[0].axvspan(ic_low, ic_high, alpha=0.15, color='steelblue',
                label=f'IC 95%: [{ic_low:,.0f}, {ic_high:,.0f}]')
axes[0].set_xlabel('Media bootstrap', fontsize=11)
axes[0].set_ylabel('Densidad', fontsize=11)
axes[0].set_title('Distribución bootstrap de la media\nvs. aproximación normal (CLT)', fontsize=12)
axes[0].legend(fontsize=9)
axes[0].grid(True, alpha=0.3)

# Panel 2: convergencia del error estándar bootstrap con B
B_vals = [10, 25, 50, 100, 250, 500, 1000, 2000]
se_bootstrap = [
    rng.choice(datos, size=(b, n), replace=True).mean(axis=1).std()
    for b in B_vals
]
se_teorico = datos.std() / np.sqrt(n)

axes[1].plot(B_vals, se_bootstrap, marker='o', linewidth=2,
             color='steelblue', label='Error estándar bootstrap')
axes[1].axhline(y=se_teorico, color='red', linewidth=2, linestyle='--',
                label=f'Error estándar teórico = {se_teorico:,.1f}')
axes[1].set_xlabel('Número de muestras bootstrap (B)', fontsize=11)
axes[1].set_ylabel('Error estándar de la media', fontsize=11)
axes[1].set_title('Convergencia del error estándar bootstrap', fontsize=12)
axes[1].legend(fontsize=10)
axes[1].grid(True, alpha=0.3)
axes[1].set_xscale('log')

plt.tight_layout()
plt.show()

print(f"Media muestral:              {media_muestral:>10,.1f}")
print(f"Error estándar teórico:      {se_teorico:>10,.1f}")
print(f"Error estándar bootstrap:    {medias_bootstrap.std():>10,.1f}")
print(f"IC 95% bootstrap:            [{ic_low:,.0f}, {ic_high:,.0f}]")

Distribución bootstrap de la media: converge a la distribución muestral teórica
Media muestral:                16,313.4
Error estándar teórico:         1,857.1
Error estándar bootstrap:       1,861.6
IC 95% bootstrap:            [12,952, 20,134]

Con \(B \approx 500\)\(1000\) muestras el error estándar bootstrap converge al teórico. Más importante: cuando el estadístico es complejo (una mediana, un coeficiente de correlación, un percentil), el bootstrap sigue funcionando igual aunque no exista fórmula analítica.

¿Para qué sirve el bootstrap más allá de Bagging?

El bootstrap es una herramienta general de inferencia estadística. Se usa para:

  • Estimar intervalos de confianza de cualquier estadístico (media, mediana, correlación) sin asumir normalidad
  • Estimar el error estándar de un estimador complejo
  • Validar modelos (bootstrap .632 es una alternativa a la validación cruzada)

En este capítulo lo usamos específicamente para generar diversidad entre árboles, pero su utilidad es mucho más amplia.


Bagging (Bootstrap Aggregating)

La idea central

Bagging (Breiman, 1996) es la primera gran técnica de ensemble para árboles. La idea es:

  1. Genera \(B\) muestras bootstrap del conjunto de entrenamiento (muestreo con reemplazo)
  2. Entrena un árbol completo (sin podar) en cada muestra
  3. Para predecir un punto nuevo, promedia (regresión) o toma la moda (clasificación) de las \(B\) predicciones
Para b = 1 hasta B:
  1. Samplear n observaciones con reemplazo → D*ᵦ  (~63.2% únicas)
  2. Entrenar árbol completo T*ᵦ sobre D*ᵦ

Predicción para x nuevo:
  - Regresión:      ŷ(x) = (1/B) Σ T*ᵦ(x)
  - Clasificación:  ŷ(x) = moda{ T*ᵦ(x) : b = 1...B }

¿Por qué funciona? La matemática del promedio

Si tenemos \(B\) predictores independientes, cada uno con varianza \(\sigma^2(x)\), su promedio tiene varianza:

\[\text{Var}\left[\frac{1}{B}\sum_{b=1}^B \hat{f}_b(x)\right] = \frac{\sigma^2(x)}{B}\]

La varianza cae con \(B\). En el límite, desaparece.

Pero los árboles entrenados con muestras bootstrap del mismo dataset no son independientes: están correlacionados con coeficiente \(\rho(x)\). La varianza real del promedio es:

\[\text{Var}[\bar{T}(x)] = \rho(x)\,\sigma^2(x) + \frac{1 - \rho(x)}{B}\,\sigma^2(x)\]

Cuando \(B \to \infty\), el segundo término desaparece, pero el primero permanece. En otras palabras:

Aumentar \(B\) más allá de cierto punto no ayuda si los árboles están muy correlacionados. El techo de la mejora es \(\rho(x)\,\sigma^2(x)\).

Esto motivará directamente la idea de Random Forest.

Sesgo vs. Varianza en Bagging

Bagging no cambia el sesgo de los árboles individuales. Un árbol completo tiene sesgo bajo y varianza alta; bagging mantiene el sesgo bajo y reduce la varianza. Por eso se usan árboles sin podar: queremos el sesgo mínimo y dejamos que el promedio controle la varianza.

Muestras Bootstrap y la propiedad OOB

Cada muestra bootstrap incluye en promedio el \(1 - (1 - 1/n)^n \approx 1 - 1/e \approx 63.2\%\) de las observaciones originales. El restante \(\approx 36.8\%\) queda fuera de la bolsa (Out-of-Bag, OOB).

Esto es una propiedad muy útil:

Para cada observación \(i\), solo los árboles que no la usaron en entrenamiento emiten una predicción. El error OOB es el error promedio de esas predicciones “honestas”, y es una estimación casi libre de sesgo del error de generalización.

El error OOB equivale aproximadamente a la validación cruzada leave-one-out, pero mucho más barato de calcular.

Código
from sklearn.ensemble import BaggingClassifier
from sklearn.model_selection import train_test_split

np.random.seed(42)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42)

B_values = [5, 10, 15, 20, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200]
train_errors, test_errors, oob_errors = [], [], []

for B in B_values:
    bag = BaggingClassifier(
        estimator=DecisionTreeClassifier(),
        n_estimators=B,
        bootstrap=True,
        oob_score=(B > 1),
        random_state=42,
        n_jobs=-1
    )
    bag.fit(X_tr, y_tr)
    train_errors.append(1 - bag.score(X_tr, y_tr))
    test_errors.append(1 - bag.score(X_te, y_te))
    oob_errors.append(1 - bag.oob_score_ if B > 1 else None)

fig, ax = plt.subplots(figsize=(8, 5))

ax.plot(B_values, train_errors, marker='o', label='Error entrenamiento', linewidth=2)
ax.plot(B_values, test_errors, marker='s', label='Error prueba', linewidth=2)
oob_valid = [(b, e) for b, e in zip(B_values[1:], oob_errors[1:]) if e is not None]
if oob_valid:
    bs, es = zip(*oob_valid)
    ax.plot(bs, es, marker='^', label='Error OOB', linewidth=2, linestyle='--')
ax.axhline(y=1 - DecisionTreeClassifier().fit(X_tr, y_tr).score(X_te, y_te),
           color='gray', linestyle=':', linewidth=1.5, label='Árbol simple (test)')
ax.set_xlabel('Número de árboles (B)', fontsize=11)
ax.set_ylabel('Tasa de error', fontsize=11)
ax.set_title('Bagging: efecto de B en el error', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Bagging reduce la varianza: comparación de errores a medida que crece B

Observaciones:

  • A partir de ~50 árboles el error se estabiliza: más árboles no degradan el desempeño, solo lo estabilizan
  • El error OOB es una buena aproximación del error en test sin necesidad de un conjunto de validación separado

¿Cuántos árboles usar?

Una propiedad clave de Bagging (y de Random Forest) es que más árboles nunca empeoran el modelo: el error es monótonamente no creciente con \(B\). La pregunta práctica es cuándo deja de valer la pena agregar más.

La ganancia marginal de pasar de \(B\) a \(B+1\) árboles cae muy rápido. En la mayoría de los problemas, con \(B \approx 100\)\(200\) se captura el 95% de la reducción de varianza posible. Lo importante es que el error OOB permite monitorear esa convergencia sin reservar datos de validación: cuando la curva OOB se aplana, ya tienes suficientes árboles.

Código
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
X_cnt, y_cnt = make_classification(
    n_samples=600, n_features=15, n_informative=8, n_redundant=3, random_state=42
)
X_tr_cnt, X_te_cnt, y_tr_cnt, y_te_cnt = train_test_split(
    X_cnt, y_cnt, test_size=0.3, random_state=42
)

B_range = list(range(2, 201))
test_errors, oob_errors = [], []

for B in B_range:
    bag = BaggingClassifier(
        estimator=DecisionTreeClassifier(),
        n_estimators=B, bootstrap=True, oob_score=True,
        random_state=42, n_jobs=-1
    )
    bag.fit(X_tr_cnt, y_tr_cnt)
    test_errors.append(1 - bag.score(X_te_cnt, y_te_cnt))
    oob_errors.append(1 - bag.oob_score_)

test_errors = np.array(test_errors)
oob_errors  = np.array(oob_errors)
marginal_oob = -np.diff(oob_errors)

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].plot(B_range, test_errors, linewidth=2, color='steelblue', label='Error test')
axes[0].plot(B_range, oob_errors,  linewidth=2, color='tomato',    label='Error OOB', linestyle='--')
axes[0].axvline(x=50,  color='gray',  linestyle=':', linewidth=1.2, label='B = 50')
axes[0].axvline(x=100, color='black', linestyle=':', linewidth=1.2, label='B = 100')
axes[0].set_xlabel('Número de árboles (B)', fontsize=11)
axes[0].set_ylabel('Tasa de error', fontsize=11)
axes[0].set_title('Error OOB vs. error test', fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

axes[1].fill_between(B_range[1:], marginal_oob, alpha=0.4, color='tomato')
axes[1].plot(B_range[1:], marginal_oob, linewidth=1.5, color='tomato')
axes[1].axhline(y=0, color='black', linewidth=0.8)
axes[1].axvline(x=50, color='gray', linestyle=':', linewidth=1.2, label='B = 50')
axes[1].set_xlabel('Número de árboles (B)', fontsize=11)
axes[1].set_ylabel('Reducción de error OOB al añadir 1 árbol', fontsize=11)
axes[1].set_title('Ganancia marginal (error OOB)', fontsize=12)
axes[1].legend(fontsize=10)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"Error OOB  con B=50:  {oob_errors[48]:.4f}  | Error test: {test_errors[48]:.4f}")
print(f"Error OOB  con B=200: {oob_errors[-1]:.4f}  | Error test: {test_errors[-1]:.4f}")
print(f"Mejora OOB de 50→200: {oob_errors[48] - oob_errors[-1]:.4f}")

Convergencia del error OOB y test: retornos decrecientes a partir de ~50 árboles
Error OOB  con B=50:  0.1762  | Error test: 0.0833
Error OOB  con B=200: 0.1738  | Error test: 0.0778
Mejora OOB de 50→200: 0.0024
Regla práctica para B
  • B = 50–100 es suficiente para la mayoría de los problemas
  • Usa el error OOB para confirmar la convergencia sin necesidad de un conjunto de validación separado
  • Si la latencia de predicción importa (producción en tiempo real), prefiere B más pequeño
  • El número de árboles nunca es el hiperparámetro más crítico — max_features y max_depth tienen mayor impacto

Importancia de variables en Bagging

Cada árbol en el ensemble tiene su propia importancia MDI. Bagging la extiende naturalmente: se promedia sobre todos los árboles del ensemble.

\[\text{Importancia}_{\text{Bag}}(X_j) = \frac{1}{B}\sum_{b=1}^B \text{MDI}_b(X_j)\]

Esto produce una estimación mucho más estable que la de un árbol individual: al promediar sobre cientos de árboles entrenados con distintas muestras, el ruido de estimación se cancela.

Código
import numpy as np
import matplotlib.pyplot as plt
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

np.random.seed(42)
X_fi, y_fi = make_classification(
    n_samples=800, n_features=10, n_informative=5,
    n_redundant=2, random_state=42
)
feat_names = [f'var_{i+1:02d}' for i in range(10)]
X_tr_fi, X_te_fi, y_tr_fi, y_te_fi = train_test_split(
    X_fi, y_fi, test_size=0.25, random_state=42
)

# Árbol individual
single_tree = DecisionTreeClassifier(random_state=42)
single_tree.fit(X_tr_fi, y_tr_fi)
imp_tree = single_tree.feature_importances_

# Bagging: promediar feature_importances_ de cada estimador
bag = BaggingClassifier(
    estimator=DecisionTreeClassifier(),
    n_estimators=200, bootstrap=True, random_state=42, n_jobs=-1
)
bag.fit(X_tr_fi, y_tr_fi)
imp_bag = np.mean([t.feature_importances_ for t in bag.estimators_], axis=0)
std_bag  = np.std( [t.feature_importances_ for t in bag.estimators_], axis=0)

# Orden por importancia en Bagging
ord_bag = np.argsort(imp_bag)[::-1]

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

x = np.arange(len(feat_names))
axes[0].bar(x, imp_tree[ord_bag], color='steelblue', alpha=0.8)
axes[0].set_xticks(x)
axes[0].set_xticklabels([feat_names[i] for i in ord_bag], fontsize=10)
axes[0].set_ylabel('Importancia MDI', fontsize=11)
axes[0].set_title(f'Árbol individual\n(acc. test: {single_tree.score(X_te_fi, y_te_fi):.3f})',
                  fontsize=12)
axes[0].grid(True, alpha=0.3, axis='y')

axes[1].bar(x, imp_bag[ord_bag], yerr=std_bag[ord_bag],
            color='tomato', alpha=0.8, capsize=4)
axes[1].set_xticks(x)
axes[1].set_xticklabels([feat_names[i] for i in ord_bag], fontsize=10)
axes[1].set_ylabel('Importancia MDI (promedio ± std)', fontsize=11)
axes[1].set_title(f'Bagging (200 árboles)\n(acc. test: {bag.score(X_te_fi, y_te_fi):.3f})',
                  fontsize=12)
axes[1].grid(True, alpha=0.3, axis='y')

plt.suptitle('Importancia de variables: árbol individual vs. Bagging', fontsize=13)
plt.tight_layout()
plt.show()

print("Variable  | Árbol individual | Bagging (media ± std)")
print("-" * 52)
for i in ord_bag:
    print(f"{feat_names[i]:9s} | {imp_tree[i]:.4f}           | {imp_bag[i]:.4f} ± {std_bag[i]:.4f}")

Importancia de variables: árbol individual vs. Bagging (promedio de 200 árboles)
Variable  | Árbol individual | Bagging (media ± std)
----------------------------------------------------
var_01    | 0.2543           | 0.2705 ± 0.0433
var_02    | 0.2291           | 0.2222 ± 0.0586
var_06    | 0.1953           | 0.1369 ± 0.0380
var_03    | 0.0594           | 0.0853 ± 0.0605
var_09    | 0.0708           | 0.0822 ± 0.0371
var_04    | 0.0550           | 0.0472 ± 0.0265
var_08    | 0.0165           | 0.0424 ± 0.0210
var_10    | 0.0441           | 0.0409 ± 0.0264
var_05    | 0.0636           | 0.0397 ± 0.0203
var_07    | 0.0119           | 0.0329 ± 0.0188
Estabilidad vs. árbol individual

El árbol individual asigna importancias volátiles: si lo reentrenamos con una semilla diferente, el ranking puede cambiar notablemente. Bagging promedia sobre 200 árboles, reduciendo la varianza de estimación por un factor de \(\approx 1/\sqrt{200}\). Las barras de error en el panel derecho muestran la dispersión entre árboles — las variables realmente informativas tienen error estándar bajo relativo a su importancia.

Comparación de fronteras de decisión: árbol individual vs. Bagging

La reducción de varianza de Bagging es más intuitiva visualmente: promediar muchas fronteras angulares e irregulares produce una frontera más suave y estable.

Código
import numpy as np
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

np.random.seed(42)
X_bd, y_bd = make_moons(n_samples=300, noise=0.30, random_state=42)
X_tr_bd, X_te_bd, y_tr_bd, y_te_bd = train_test_split(
    X_bd, y_bd, test_size=0.3, random_state=42
)

models = {
    'Árbol individual\n(sin límite de profundidad)':
        DecisionTreeClassifier(random_state=42),
    'Bagging\n(B = 10)':
        BaggingClassifier(estimator=DecisionTreeClassifier(),
                          n_estimators=10, bootstrap=True, random_state=42),
    'Bagging\n(B = 200)':
        BaggingClassifier(estimator=DecisionTreeClassifier(),
                          n_estimators=200, bootstrap=True, random_state=42, n_jobs=-1),
}

h = 0.03
x_min, x_max = X_bd[:, 0].min() - 0.5, X_bd[:, 0].max() + 0.5
y_min, y_max = X_bd[:, 1].min() - 0.5, X_bd[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))

fig, axes = plt.subplots(1, 3, figsize=(14, 5))

for ax, (name, model) in zip(axes, models.items()):
    model.fit(X_tr_bd, y_tr_bd)
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    ax.contourf(xx, yy, Z, alpha=0.35, cmap='RdBu_r')
    ax.scatter(X_bd[:, 0], X_bd[:, 1], c=y_bd, cmap='RdBu_r',
               edgecolors='black', s=35, alpha=0.8)
    tr_acc = model.score(X_tr_bd, y_tr_bd)
    te_acc = model.score(X_te_bd, y_te_bd)
    ax.set_title(f'{name}\nTrain: {tr_acc:.3f} | Test: {te_acc:.3f}', fontsize=11)
    ax.set_xlabel('X1'); ax.set_ylabel('X2')
    ax.grid(True, alpha=0.2)

plt.tight_layout()
plt.show()

Fronteras de decisión: un árbol completo vs. Bagging con distintos B
¿Qué observar en estas fronteras?
  • Árbol individual: frontera muy irregular y angulosa — captura el ruido de entrenamiento
  • Bagging B=10: ya se suaviza notablemente, pero aún hay irregularidades
  • Bagging B=200: frontera suave y estable — el promedio de muchos árboles ruidosos converge a una frontera limpia

La mejora de test de árbol→Bagging viene exclusivamente de reducir la varianza; el sesgo prácticamente no cambia.


Random Forest

La limitación de Bagging y su solución

El análisis de la varianza mostró que el límite de Bagging es \(\rho(x)\,\sigma^2(x)\), donde \(\rho\) es la correlación entre árboles. Si todos los árboles se parecen mucho, la ganancia es pequeña.

¿Por qué se parecen? Porque si hay una variable muy predictiva, todos los árboles la usarán como primera división. Los árboles comparten estructura.

La solución de Random Forest (Breiman, 2001) es simple y brillante:

En cada división de cada árbol, en lugar de considerar todas las \(p\) variables, selecciona aleatoriamente solo \(m < p\) variables candidatas.

Esto decorrelaciona los árboles: si la variable dominante queda fuera del sorteo en un nodo, otro predictor tendrá que tomar su lugar, forzando diversidad en la estructura de los árboles.

Algoritmo

Para b = 1 hasta B:
  1. Generar muestra bootstrap D*ᵦ de tamaño n
  2. Construir árbol T*ᵦ con la siguiente modificación:
     En cada nodo de decisión:
       a) Sortear m variables de las p totales (sin reemplazo)
       b) Encontrar la mejor división usando solo esas m variables
       c) Dividir el nodo
  3. Crecer el árbol sin podar

Predicción para x nuevo:
  - Regresión:      ŷ(x) = (1/B) Σ T*ᵦ(x)
  - Clasificación:  ŷ(x) = moda{ T*ᵦ(x) : b = 1...B }

El parámetro m: el control de correlación

El parámetro más importante de Random Forest es \(m\), el número de variables candidatas por nodo:

Valor de m Efecto
\(m = p\) Equivale a Bagging (máxima correlación entre árboles)
\(m = \sqrt{p}\) Default para clasificación — buen balance
\(m = p/3\) Default para regresión
\(m = 1\) Máxima decorrelación, pero árboles muy débiles (alto sesgo)

Reducir \(m\) disminuye la correlación entre árboles (baja varianza del ensemble) pero hace que cada árbol individual sea peor (aumenta el sesgo). El óptimo está en el balance.

Regla práctica

Para la mayoría de los problemas, los valores por defecto de scikit-learn funcionan bien:

  • Clasificación: max_features='sqrt'
  • Regresión: max_features=1/3

El número de árboles (n_estimators) rara vez es el hiperparámetro más importante; 100-200 suele ser suficiente.

Bagging vs. Random Forest: la correlación en acción

Código
from sklearn.ensemble import RandomForestClassifier, BaggingClassifier
from sklearn.datasets import make_classification

np.random.seed(42)
X_cl, y_cl = make_classification(
    n_samples=500, n_features=20, n_informative=10,
    n_redundant=5, random_state=42
)
X_tr, X_te, y_tr, y_te = train_test_split(X_cl, y_cl, test_size=0.3, random_state=42)

B_values = [5, 10, 15, 20, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200]

bag_test, rf_test = [], []

for B in B_values:
    bag = BaggingClassifier(
        estimator=DecisionTreeClassifier(), n_estimators=B,
        bootstrap=True, random_state=42, n_jobs=-1
    )
    rf = RandomForestClassifier(n_estimators=B, random_state=42, n_jobs=-1)
    bag.fit(X_tr, y_tr); rf.fit(X_tr, y_tr)
    bag_test.append(1 - bag.score(X_te, y_te))
    rf_test.append(1 - rf.score(X_te, y_te))

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

axes[0].plot(B_values, bag_test, marker='o', label='Bagging', linewidth=2, color='steelblue')
axes[0].plot(B_values, rf_test, marker='s', label='Random Forest', linewidth=2, color='tomato')
axes[0].set_xlabel('Número de árboles (B)', fontsize=11)
axes[0].set_ylabel('Tasa de error en prueba', fontsize=11)
axes[0].set_title('Bagging vs. Random Forest', fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

# Panel 2: correlación entre predicciones de árboles (200 árboles)
rf_200 = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
bag_200 = BaggingClassifier(
    estimator=DecisionTreeClassifier(), n_estimators=200,
    bootstrap=True, random_state=42, n_jobs=-1
)
rf_200.fit(X_tr, y_tr); bag_200.fit(X_tr, y_tr)

rf_preds = np.array([t.predict(X_te) for t in rf_200.estimators_])
bag_preds = np.array([t.predict(X_te) for t in bag_200.estimators_])

corr_rf  = np.corrcoef(rf_preds)[np.triu_indices(200, k=1)].mean()
corr_bag = np.corrcoef(bag_preds)[np.triu_indices(200, k=1)].mean()

methods = ['Bagging', 'Random Forest']
corrs = [corr_bag, corr_rf]
colors = ['steelblue', 'tomato']
bars = axes[1].bar(methods, corrs, color=colors, alpha=0.8, width=0.4)
for bar, val in zip(bars, corrs):
    axes[1].text(bar.get_x() + bar.get_width()/2, val + 0.005,
                 f'ρ = {val:.3f}', ha='center', fontsize=12, fontweight='bold')
axes[1].set_ylabel('Correlación media entre árboles (ρ)', fontsize=11)
axes[1].set_title('Decorrelación entre árboles', fontsize=12)
axes[1].set_ylim(0, max(corrs) * 1.2)
axes[1].grid(True, alpha=0.3, axis='y')

plt.tight_layout()
plt.show()

print(f"Correlación media entre árboles — Bagging: {corr_bag:.3f} | Random Forest: {corr_rf:.3f}")
print(f"Error en test              — Bagging: {bag_test[-1]:.3f} | Random Forest: {rf_test[-1]:.3f}")

Random Forest vs. Bagging: comparación de errores y correlación entre árboles
Correlación media entre árboles — Bagging: 0.503 | Random Forest: 0.368
Error en test              — Bagging: 0.100 | Random Forest: 0.087

La diferencia es clara: Random Forest produce árboles menos correlacionados entre sí, y esa decorrelación se traduce directamente en mejor desempeño.

Efecto de max_features sobre la correlación y el error

Bagging y Random Forest son dos extremos del mismo dial: max_features=p (todas las variables) es Bagging, y valores menores van decorrelacionando los árboles. ¿Qué pasa en los puntos intermedios?

Código
from sklearn.ensemble import RandomForestClassifier
import numpy as np
import matplotlib.pyplot as plt

p = 20
m_values = [1, 2, 3, 4, 5, 7, 10, 14, 20]
labels = [str(m) for m in m_values]
labels[m_values.index(4)]  = '4 ≈ √p\n(default clf)'
labels[m_values.index(7)]  = '7 ≈ p/3\n(default reg)'
labels[m_values.index(20)] = '20 = p\n(Bagging)'

correlaciones, test_errors_mf = [], []

for m in m_values:
    rf_m = RandomForestClassifier(
        n_estimators=200, max_features=m, random_state=42, n_jobs=-1
    )
    rf_m.fit(X_tr, y_tr)
    preds = np.array([t.predict(X_te) for t in rf_m.estimators_])
    correlaciones.append(np.corrcoef(preds)[np.triu_indices(200, k=1)].mean())
    test_errors_mf.append(1 - rf_m.score(X_te, y_te))

x = np.arange(len(m_values))
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Panel 1: correlación entre árboles
color_corr = 'steelblue'
axes[0].plot(x, correlaciones, marker='o', linewidth=2.5, color=color_corr)
axes[0].fill_between(x, correlaciones, alpha=0.15, color=color_corr)
for xi, (corr, label) in enumerate(zip(correlaciones, labels)):
    axes[0].annotate(f'{corr:.3f}', (xi, corr), textcoords='offset points',
                     xytext=(0, 8), ha='center', fontsize=9)
axes[0].set_xticks(x)
axes[0].set_xticklabels(labels, fontsize=9)
axes[0].set_xlabel('max_features (m)', fontsize=11)
axes[0].set_ylabel('Correlación media entre árboles (ρ)', fontsize=11)
axes[0].set_title('Decorrelación al reducir m', fontsize=12)
axes[0].grid(True, alpha=0.3)

# Panel 2: error de test
color_err = 'tomato'
axes[1].plot(x, test_errors_mf, marker='s', linewidth=2.5, color=color_err)
axes[1].fill_between(x, test_errors_mf, alpha=0.15, color=color_err)
for xi, (err, label) in enumerate(zip(test_errors_mf, labels)):
    axes[1].annotate(f'{err:.3f}', (xi, err), textcoords='offset points',
                     xytext=(0, 8), ha='center', fontsize=9)
axes[1].set_xticks(x)
axes[1].set_xticklabels(labels, fontsize=9)
axes[1].set_xlabel('max_features (m)', fontsize=11)
axes[1].set_ylabel('Error en test', fontsize=11)
axes[1].set_title('Error de test al reducir m', fontsize=12)
axes[1].grid(True, alpha=0.3)

plt.suptitle('Efecto de max_features: menos variables → menos correlación, pero árboles más débiles',
             fontsize=12)
plt.tight_layout()
plt.show()

Correlación entre árboles y error de test en función de max_features (p=20)
El trade-off de max_features
  • m grande (→ Bagging): cada árbol es fuerte individualmente, pero todos se parecen mucho → correlación alta → el promedio no gana tanto
  • m pequeño: árboles muy distintos entre sí (correlación baja), pero cada uno es un predictor más débil → sesgo crece
  • El mínimo de error está en el punto intermedio, típicamente cerca de \(\sqrt{p}\) para clasificación

Este trade-off sesgo–varianza entre árboles es el que max_features controla directamente.


Importancia de Variables en Random Forest

Los árboles individuales ya tenían importancia de variables (MDI). En Random Forest se extiende naturalmente: se promedia la importancia MDI sobre todos los árboles del ensemble.

\[\text{Importancia}(X_j) = \frac{1}{B}\sum_{b=1}^B \sum_{t \in T_b: \text{usa } X_j} \frac{n_t}{n}\,\Delta I(t)\]

Al promediar sobre cientos de árboles, la estimación se vuelve mucho más estable que la de un árbol individual.

Permutation Importance (variante OOB)

Random Forest habilita una variante especialmente conveniente de la permutation importance: en lugar de usar un conjunto de test separado, se usan las muestras OOB de cada árbol. Para medir la importancia de la variable \(X_j\):

  1. Para cada árbol \(b\), toma sus muestras OOB y calcula el error OOB normal
  2. Permuta (baraja) los valores de \(X_j\) en esas muestras OOB y recalcula el error
  3. La importancia es el incremento promedio en error al permutar

Esto no requiere reservar datos adicionales y es computacionalmente eficiente.

Código
from sklearn.inspection import permutation_importance

np.random.seed(42)
X_fi, y_fi = make_classification(
    n_samples=800, n_features=10, n_informative=5,
    n_redundant=2, random_state=42
)
feat_names = [f'var_{i+1:02d}' for i in range(10)]

X_tr_fi, X_te_fi, y_tr_fi, y_te_fi = train_test_split(
    X_fi, y_fi, test_size=0.25, random_state=42
)

rf = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1, oob_score=True)
rf.fit(X_tr_fi, y_tr_fi)

# MDI
mdi = rf.feature_importances_
mdi_ord = np.argsort(mdi)[::-1]

# Permutation importance (sobre test)
perm = permutation_importance(rf, X_te_fi, y_te_fi, n_repeats=20, random_state=42, n_jobs=-1)
perm_mean = perm.importances_mean
perm_ord = np.argsort(perm_mean)[::-1]

fig, axes = plt.subplots(1, 2, figsize=(13, 5))
n_feat = len(feat_names)

axes[0].barh(range(n_feat), mdi[mdi_ord], color='steelblue', alpha=0.8)
axes[0].set_yticks(range(n_feat))
axes[0].set_yticklabels([feat_names[i] for i in mdi_ord], fontsize=10)
axes[0].set_xlabel('Importancia MDI (promedio entre árboles)', fontsize=10)
axes[0].set_title('Importancia MDI\n(Random Forest)', fontsize=11)
axes[0].grid(True, alpha=0.3, axis='x')
for i, v in enumerate(mdi[mdi_ord]):
    axes[0].text(v + 0.002, i, f'{v:.3f}', va='center', fontsize=9)

axes[1].barh(range(n_feat), perm_mean[perm_ord],
             xerr=perm.importances_std[perm_ord],
             color='tomato', alpha=0.8, capsize=4)
axes[1].set_yticks(range(n_feat))
axes[1].set_yticklabels([feat_names[i] for i in perm_ord], fontsize=10)
axes[1].set_xlabel('Caída en accuracy al permutar', fontsize=10)
axes[1].set_title('Permutation Importance\n(sobre test)', fontsize=11)
axes[1].axvline(x=0, color='black', linewidth=0.8)
axes[1].grid(True, alpha=0.3, axis='x')

plt.suptitle(f'Random Forest (200 árboles) — Accuracy test: {rf.score(X_te_fi, y_te_fi):.3f} | OOB: {rf.oob_score_:.3f}',
             fontsize=12)
plt.tight_layout()
plt.show()

Importancia de variables en Random Forest: MDI vs. Permutation Importance

Hiperparámetros de Random Forest

Los principales hiperparámetros a considerar son:

Parámetro Descripción Default Impacto
n_estimators Número de árboles \(B\) 100 Más → más estable, jamás empeora
max_features Variables por nodo \(m\) 'sqrt' Controla la decorrelación
max_depth Profundidad máxima None None = árboles completos (recomendado)
min_samples_leaf Min. muestras en hoja 1 (clf) / 5 (reg) Regulariza los árboles individuales
bootstrap Usar muestras bootstrap True False = Pasting (sin reemplazo)

Comparación de desempeño: árbol simple vs. Bagging vs. Random Forest

Código
from sklearn.ensemble import BaggingClassifier, RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split

np.random.seed(42)
X_mc, y_mc = make_moons(n_samples=300, noise=0.3, random_state=42)
X_tr_mc, X_te_mc, y_tr_mc, y_te_mc = train_test_split(
    X_mc, y_mc, test_size=0.3, random_state=42
)

models = {
    'Árbol de Decisión\n(max_depth=5)':
        DecisionTreeClassifier(max_depth=5, random_state=42),
    'Bagging\n(100 árboles)':
        BaggingClassifier(estimator=DecisionTreeClassifier(),
                          n_estimators=100, random_state=42, n_jobs=-1),
    'Random Forest\n(100 árboles)':
        RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1),
}

h = 0.03
x_min, x_max = X_mc[:, 0].min() - 0.5, X_mc[:, 0].max() + 0.5
y_min, y_max = X_mc[:, 1].min() - 0.5, X_mc[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))

fig, axes = plt.subplots(1, 3, figsize=(14, 5))

for ax, (name, model) in zip(axes, models.items()):
    model.fit(X_tr_mc, y_tr_mc)
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    ax.contourf(xx, yy, Z, alpha=0.35, cmap='RdBu_r')
    ax.scatter(X_mc[:, 0], X_mc[:, 1], c=y_mc, cmap='RdBu_r',
               edgecolors='black', s=35, alpha=0.8)
    test_acc = model.score(X_te_mc, y_te_mc)
    train_acc = model.score(X_tr_mc, y_tr_mc)
    ax.set_title(f'{name}\nTrain: {train_acc:.3f} | Test: {test_acc:.3f}', fontsize=11)
    ax.set_xlabel('X1'); ax.set_ylabel('X2')
    ax.grid(True, alpha=0.2)

plt.tight_layout()
plt.show()

Fronteras de decisión: árbol simple, Bagging y Random Forest
¿Qué observar en estas fronteras?
  • Árbol individual: frontera angular, irregular, posiblemente sobreajustada
  • Bagging: frontera más suave, promedio de muchas fronteras angulares
  • Random Forest: frontera más suave aún, gracias a la decorrelación entre árboles

La mejora de Bagging a Random Forest suele ser notable cuando hay muchas variables y algunas dominantes.


Ventajas y Limitaciones

Ventajas

  1. Robusto al sobreajuste: agregar más árboles nunca empeora el desempeño
  2. Sin ajuste fino requerido: los defaults suelen funcionar bien directamente
  3. OOB estima la generalización sin reservar datos de validación
  4. Paralelizable: cada árbol es independiente, se puede entrenar en paralelo
  5. Importancia de variables robusta y estable (promediada sobre cientos de árboles)
  6. Maneja variables mixtas y datos con ruido

Limitaciones

  1. No extrapola más allá del rango de entrenamiento
  2. Menos interpretable que un árbol individual (no hay una regla IF-THEN legible)
  3. Costoso en predicción si \(B\) es grande y la latencia importa
  4. Subóptimo con relaciones lineales puras — la regresión lineal las captura mejor con menos datos
  5. Importancia MDI sesgada hacia variables de alta cardinalidad (aplica el mismo sesgo que en árboles simples)

¿Cuándo usar cada método?

Situación Recomendación
Interpretabilidad crítica Árbol de decisión (podado)
Máximo desempeño, baja latencia Random Forest
Muchas variables irrelevantes Random Forest (m pequeño)
Pocas variables, todas relevantes Bagging puede ser suficiente
Relaciones principalmente lineales Regresión regularizada (Ridge/Lasso)

Discusión: ¿por qué funciona el promedio?

La siguiente secuencia de preguntas está diseñada para que redescubras la lógica detrás de Bagging y Random Forest en lugar de solo recibirla. Cada pregunta lleva a la siguiente.

Un clasificador se equivoca el 30% de las veces de forma independiente a los demás. Si promedias 2 clasificadores así, ¿cuál es la probabilidad de que el ensemble se equivoque? ¿Y con 10? ¿Y con 100?

Pista: el ensemble comete error solo si la mayoría vota mal. ¿Qué distribución describe eso?

Para discutir: ¿Qué condición es necesaria para que el promedio funcione tan bien? ¿Es realista en la práctica?

En Bagging, todos los árboles se entrenan con datos del mismo dataset original. Si existe una variable muy predictiva (digamos, la más importante con diferencia), ¿qué harán casi todos los árboles en su primera división?

Para discutir: Si 100 árboles hacen casi la misma primera división, ¿cuánto se parece esto a tener un solo árbol? ¿Qué ganamos realmente al promediarlos?

La varianza del promedio de \(B\) árboles con correlación \(\rho\) entre sí es: \[\text{Var}[\bar{T}] = \rho\,\sigma^2 + \frac{1-\rho}{B}\,\sigma^2\] Cuando \(B \to \infty\), ¿qué término desaparece y cuál queda?

Para discutir: ¿Qué implica eso sobre agregar árboles indefinidamente? ¿Cuál es la única forma de seguir reduciendo la varianza más allá de ese límite?

Quieres reducir \(\rho\) — la correlación entre árboles — sin cambiar el dataset ni el tipo de modelo. ¿Qué modificación le harías al proceso de entrenamiento de cada árbol?

No hay una única respuesta correcta. Piensa qué es lo que hace que dos árboles se parezcan.

Para discutir: Compara tu propuesta con la de tus compañeros. ¿Alguna de las ideas coincide con lo que hace Random Forest? ¿Hay propuestas distintas que también podrían funcionar?

Random Forest logra reducir \(\rho\) limitando las variables disponibles en cada nodo. Pero si en un nodo solo puedes usar \(m = 1\) variable (elegida al azar), ¿qué le pasa a la calidad de cada árbol individual?

Para discutir: ¿Qué trade-off estás aceptando al reducir \(m\)? ¿Por qué \(m = \sqrt{p}\) suele ser un buen balance y no \(m = 1\)?

Imagina que tienes un dataset con 100 variables pero solo 3 son verdaderamente informativas y las 97 restantes son ruido puro. ¿Qué le pasaría a Bagging? ¿Y a Random Forest con \(m = \sqrt{100} = 10\)? ¿Qué valor de \(m\) usarías tú y por qué?

Esta pregunta no tiene una respuesta única — el objetivo es que justifiques tu razonamiento conectando varianza, correlación y sesgo.


Tarea: ensemble sobre datos reales

En esta tarea aplicarás Bagging y Random Forest al dataset de cáncer de mama (breast_cancer de scikit-learn), que ya conoces del capítulo anterior. El reto: tomar todas tus decisiones de modelado usando únicamente el error OOB, sin mirar el conjunto de test hasta el final.

El dataset tiene 569 observaciones, 30 variables numéricas (características del núcleo celular) y dos clases: tumor maligno (1) o benigno (0).

Código
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

data = load_breast_cancer()
X_bc, y_bc = data.data, data.target
feature_names = data.feature_names

X_tr_bc, X_te_bc, y_tr_bc, y_te_bc = train_test_split(
    X_bc, y_bc, test_size=0.2, random_state=42, stratify=y_bc
)
print(f"Entrenamiento: {X_tr_bc.shape} | Test: {X_te_bc.shape}")
print(f"Variables: {feature_names.tolist()}")
Entrenamiento: (455, 30) | Test: (114, 30)
Variables: ['mean radius', 'mean texture', 'mean perimeter', 'mean area', 'mean smoothness', 'mean compactness', 'mean concavity', 'mean concave points', 'mean symmetry', 'mean fractal dimension', 'radius error', 'texture error', 'perimeter error', 'area error', 'smoothness error', 'compactness error', 'concavity error', 'concave points error', 'symmetry error', 'fractal dimension error', 'worst radius', 'worst texture', 'worst perimeter', 'worst area', 'worst smoothness', 'worst compactness', 'worst concavity', 'worst concave points', 'worst symmetry', 'worst fractal dimension']

Parte 1 — ¿Cuántos árboles necesitas?

Entrena un BaggingClassifier y un RandomForestClassifier variando n_estimators entre 2 y 200. Para cada valor y cada modelo registra el error OOB. Grafica ambas curvas en la misma figura y determina a partir de qué \(B\) cada una se estabiliza. Usa ese valor en el resto de la tarea.

Código
# Tu código aquí

Preguntas:

  • ¿A partir de qué \(B\) el error OOB de cada modelo ya no mejora más de 0.5 puntos porcentuales?
  • ¿Convergen al mismo valor de error o uno de los dos es consistentemente mejor? ¿Por qué?
  • ¿Coincide ese umbral con lo que viste en los ejemplos del capítulo, o este dataset requiere más/menos árboles?

Parte 2 — Ajustando max_features (solo Random Forest)

Con el \(B\) fijado en la parte anterior, barre max_features desde 1 hasta \(p = 30\) en el Random Forest. Para cada valor registra el error OOB. Grafica la curva.

Bagging no tiene este dial — siempre usa las \(p = 30\) variables en cada nodo, lo que equivale a max_features=p en la gráfica.

Código
# Tu código aquí

Preguntas:

  • ¿Dónde está el mínimo OOB? ¿Coincide con el default \(\sqrt{30} \approx 5\) o el óptimo está en otro valor?
  • ¿Qué dice eso sobre la estructura de este dataset — hay pocas variables muy dominantes o la información está distribuida?
  • Marca en la gráfica el punto que corresponde a Bagging (\(m = 30\)). ¿Cuánto mejora Random Forest sobre Bagging en este dataset?
  • Elige tu max_features óptimo y justifícalo.

Parte 3 — ¿En qué variables confiar?

Obtén el ranking de importancia MDI para Bagging (promediando feature_importances_ sobre los estimadores manualmente) y para Random Forest (directamente de feature_importances_). Grafícalos lado a lado junto con la Permutation Importance del Random Forest.

Código
# Pista para la importancia MDI de Bagging:
# import numpy as np
# imp_bagging = np.mean([t.feature_importances_ for t in bag.estimators_], axis=0)

Preguntas:

  • ¿Coinciden los rankings de Bagging y Random Forest? Si difieren, ¿en qué variables y por qué crees que ocurre?
  • Compara el ranking MDI de Random Forest contra su Permutation Importance. ¿Hay variables donde difieran notablemente?
  • Si tuvieras que reducir el modelo a las 5 variables más importantes para una aplicación clínica, ¿qué ranking usarías y por qué?

Parte 4 — El veredicto

Ahora sí, evalúa los tres modelos sobre el conjunto de test: árbol individual, Bagging y Random Forest (con los hiperparámetros elegidos en las partes anteriores). Reporta accuracy, precisión, recall y F1 para cada uno en una tabla comparativa.

Código
# Tu código aquí

Preguntas:

  • ¿Qué tan bien aproximó el error OOB de cada modelo al error real en test? ¿Fue conservador o optimista?
  • ¿Cuánto mejora Bagging sobre el árbol individual? ¿Y Random Forest sobre Bagging? ¿En cuál salto hay mayor ganancia?
  • Considerando que en oncología un falso negativo (predecir benigno cuando es maligno) es mucho más costoso que un falso positivo, ¿cuál de los tres modelos elegirías y por qué? ¿Cambiaría tu respuesta si el criterio fuera minimizar intervenciones innecesarias?
  • ¿Cambiarías algo de tus decisiones de las partes 1 y 2 si hubieras podido mirar el test desde el principio?