Random Forest
Cómo muchos árboles mediocres forman un modelo excelente: intuición, matemáticas, implementación desde cero, scikit-learn y resultados reales.
01Qué es
Random Forest es un método de ensemble para clasificación y regresión. Entrena muchos árboles de decisión, cada uno con una muestra aleatoria de los datos y con un subconjunto aleatorio de variables en cada división, y combina sus predicciones: por votación en clasificación y por media en regresión. Lo propuso Leo Breiman en 2001, a partir de su trabajo anterior sobre bagging (1996).
Es uno de los algoritmos más usados con datos tabulares: funciona bien sin apenas ajustes, no necesita escalar las variables y da una estimación de su propio error sin reservar datos.
02Intuición
Un árbol de decisión profundo se aprende los datos casi de memoria: tiene poco sesgo pero mucha varianza, porque cambiar unos pocos puntos cambia el árbol entero. La idea del bosque es la de la «sabiduría de la multitud»: si muchos estimadores ruidosos cometen errores distintos, al promediarlos los errores se compensan.
La clave está en que los errores sean distintos. Si todos los árboles vieran los mismos datos y las mismas variables serían casi idénticos y promediarlos no serviría de nada. Por eso se introduce aleatoriedad por dos vías: cada árbol ve una muestra bootstrap distinta, y en cada nodo solo puede elegir entre unas pocas variables al azar.
Pruébalo en el laboratorio del árbol de decisión: con el dataset «Ruido», compara un árbol profundo con el botón «Ver Random Forest».
03Fundamento matemático
Por qué promediar reduce el error
Si promediamos B variables idénticamente distribuidas, cada una con varianza σ² y correlación ρ entre pares, la varianza de la media es:
El segundo término desaparece al añadir árboles, pero el primero no: la única forma de bajarlo es reducir la correlación ρ entre árboles. Eso es exactamente lo que hace la selección aleatoria de variables.
Bootstrap y datos out-of-bag
Cada árbol se entrena con n muestras tomadas con reemplazo. La probabilidad de que una muestra concreta no salga nunca es:
Así, cada árbol deja fuera en torno al 36,8 % de los datos. Evaluar cada punto solo con los árboles que no lo vieron da el error out-of-bag, una estimación del error de generalización sin necesidad de un conjunto de validación.
Criterio de división
Cada nodo busca, entre las m variables candidatas, el umbral que más reduce la impureza de Gini:
Predicción
El valor habitual de m es en clasificación, con p el número de variables. scikit-learn, en lugar de contar votos, promedia las probabilidades de clase de los árboles.
04Algoritmo paso a paso
- Elige el número de árboles B y el número de variables candidatas por nodo m.
- Para cada árbol b = 1…B, toma una muestra bootstrap: n filas elegidas al azar con reemplazo.
- Haz crecer un árbol con esa muestra. En cada nodo, escoge m variables al azar y busca el mejor corte solo entre ellas.
- Deja crecer el árbol sin podar, hasta que las hojas sean puras o tengan el mínimo de muestras.
- Para predecir, pasa el nuevo punto por los B árboles y combina las respuestas: voto mayoritario o media.
05Pseudocódigo
función RANDOM_FOREST(D, B, m):
bosque ← []
para b ← 1 hasta B:
D_b ← muestra_con_reemplazo(D, |D|)
T_b ← CRECER_ÁRBOL(D_b, m)
añadir T_b a bosque
devolver bosque
función CRECER_ÁRBOL(D, m):
si D es puro o demasiado pequeño:
devolver HOJA(clase mayoritaria de D)
F ← m variables elegidas al azar
(f, t) ← argmax sobre f ∈ F y umbrales t de ΔGini(D, f, t)
devolver NODO(f, t, CRECER_ÁRBOL(D[f ≤ t], m), CRECER_ÁRBOL(D[f > t], m))
función PREDECIR(bosque, x):
devolver moda{ T(x) para T en bosque }06Implementación desde cero en Python
Una implementación completa y funcional en NumPy, pensada para leerse, no para ser rápida. Sigue el algoritmo tal cual: bootstrap, selección aleatoria de variables en cada nodo y voto mayoritario.
import numpy as np
class Nodo:
def __init__(self, feature=None, umbral=None, izq=None, der=None, valor=None):
self.feature, self.umbral, self.izq, self.der, self.valor = feature, umbral, izq, der, valor
def gini(y):
_, cuentas = np.unique(y, return_counts=True)
p = cuentas / len(y)
return 1.0 - np.sum(p ** 2)
class ArbolDecision:
def __init__(self, max_depth=None, min_samples_split=2, max_features=None, rng=None):
self.max_depth, self.min_samples_split = max_depth, min_samples_split
self.max_features, self.rng = max_features, rng or np.random.default_rng()
def fit(self, X, y):
self.raiz = self._crecer(X, y, profundidad=0)
return self
def _crecer(self, X, y, profundidad):
if (len(np.unique(y)) == 1 or len(y) < self.min_samples_split
or (self.max_depth is not None and profundidad >= self.max_depth)):
return Nodo(valor=np.bincount(y).argmax())
n_feat = X.shape[1]
k = self.max_features or n_feat
candidatas = self.rng.choice(n_feat, k, replace=False) # aleatoriedad por nodo
mejor = (None, None, gini(y))
for f in candidatas:
for t in np.unique(X[:, f])[:-1]:
izq = X[:, f] <= t
g = (izq.sum() * gini(y[izq]) + (~izq).sum() * gini(y[~izq])) / len(y)
if g < mejor[2]:
mejor = (f, t, g)
f, t, _ = mejor
if f is None:
return Nodo(valor=np.bincount(y).argmax())
izq = X[:, f] <= t
return Nodo(f, t, self._crecer(X[izq], y[izq], profundidad + 1),
self._crecer(X[~izq], y[~izq], profundidad + 1))
def _predecir_uno(self, x, nodo):
while nodo.valor is None:
nodo = nodo.izq if x[nodo.feature] <= nodo.umbral else nodo.der
return nodo.valor
def predict(self, X):
return np.array([self._predecir_uno(x, self.raiz) for x in X])
class RandomForest:
def __init__(self, n_estimators=100, max_depth=None, max_features="sqrt", seed=0):
self.n_estimators, self.max_depth = n_estimators, max_depth
self.max_features, self.rng = max_features, np.random.default_rng(seed)
def fit(self, X, y):
n, d = X.shape
k = int(np.sqrt(d)) if self.max_features == "sqrt" else d
self.arboles = []
for _ in range(self.n_estimators):
idx = self.rng.integers(0, n, n) # muestra bootstrap
arbol = ArbolDecision(self.max_depth, max_features=k, rng=self.rng)
self.arboles.append(arbol.fit(X[idx], y[idx]))
return self
def predict(self, X):
votos = np.array([a.predict(X) for a in self.arboles]) # (n_arboles, n_muestras)
return np.apply_along_axis(lambda v: np.bincount(v).argmax(), 0, votos)from sklearn.metrics import accuracy_score
rf = RandomForest(n_estimators=25, max_depth=8, seed=0).fit(X_tr, y_tr)
print(accuracy_score(y_te, rf.predict(X_te))) # 0.965 (unos 7 s: bucles en Python puro)Con 25 árboles de profundidad máxima 8 alcanza un 96,5 % de acierto en el mismo conjunto de test que el modelo de scikit-learn, aunque tarda unos 7 segundos por los bucles de Python frente a menos de uno en scikit-learn.
07Implementación con Scikit-learn
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
clf = RandomForestClassifier(n_estimators=300, max_features="sqrt", random_state=42)
clf.fit(X_tr, y_tr)
clf.predict(X_te) # clases
clf.predict_proba(X_te) # media de las probabilidades de los árboles
clf.feature_importances_ # importancia por reducción media de impureza
reg = RandomForestRegressor(n_estimators=300, max_features=1.0) # para regresión08Dataset de ejemplo
Se usa Breast Cancer Wisconsin (Diagnostic), incluido en scikit-learn: 569 muestras de biopsias con 30 variables numéricas calculadas sobre imágenes de núcleos celulares (10 medidas, como el radio, la textura o la concavidad, cada una en tres versiones: media, error estándar y peor valor). Hay 212 casos malignos y 357 benignos.
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
datos = load_breast_cancer()
X, y = datos.data, datos.target # y: 0 = maligno, 1 = benigno
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)
print(X_tr.shape, X_te.shape) # (426, 30) (143, 30)La partición estratificada mantiene la proporción de clases en entrenamiento (426 muestras) y test (143).
09Entrenamiento
from sklearn.ensemble import RandomForestClassifier
modelo = RandomForestClassifier(
n_estimators=300,
oob_score=True, # error out-of-bag gratis
random_state=42,
n_jobs=-1,
)
modelo.fit(X_tr, y_tr)
print(f"OOB: {modelo.oob_score_:.3f}") # 0.962El error out-of-bag da un 96,2 % de acierto sin haber tocado el conjunto de test, muy cerca de lo que luego se mide en test.
10Evaluación
from sklearn.model_selection import cross_val_score
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, confusion_matrix)
pred = modelo.predict(X_te)
prob = modelo.predict_proba(X_te)[:, 1]
print(accuracy_score(y_te, pred)) # 0.958
print(roc_auc_score(y_te, prob)) # 0.995
print(confusion_matrix(y_te, pred)) # [[49 4]
# [ 2 88]]
cv = cross_val_score(RandomForestClassifier(n_estimators=300, random_state=42), X, y, cv=5)
print(cv.mean(), cv.std()) # 0.961 0.025| Modelo | Acierto en test | Validación cruzada (5 particiones) |
|---|---|---|
| Un árbol de decisión | 92,3 % | — |
| Random Forest desde cero (25 árboles) | 96,5 % | — |
| Random Forest scikit-learn (300 árboles) | 95,8 % | 96,1 % ± 2,5 |
La desviación de la validación cruzada (±2,5 puntos) es mayor que la diferencia entre los dos bosques: con 143 muestras de test, esa diferencia no es significativa.
11Métricas
| Métrica (clase positiva: benigno) | Valor |
|---|---|
| Acierto | 0,958 |
| Precisión | 0,957 |
| Recall | 0,978 |
| F1 | 0,967 |
| ROC-AUC | 0,995 |
| Real \ Predicho | Maligno | Benigno |
|---|---|---|
| Maligno | 49 | 4 |
| Benigno | 2 | 88 |
En diagnóstico, el error grave es no detectar un tumor maligno. El recall de la clase maligna es 49 / 53 = 92,5 %: 4 casos malignos se clasificaron como benignos. En un sistema real se bajaría el umbral de decisión para la clase maligna, aceptando más falsas alarmas a cambio de perder menos casos.
12Visualización
feature_importances_). Las medidas «worst» (el peor valor de los núcleos de cada imagen) dominan. Esta importancia tiende a favorecer variables continuas o con muchos valores; permutation_importance es una alternativa más fiable.Para ver cómo un árbol divide el espacio y cómo el bosque suaviza la frontera, abre el laboratorio del árbol de decisión.
13Ventajas
- Muy buen rendimiento sin apenas ajustar hiperparámetros.
- No necesita escalar ni normalizar las variables, y maneja relaciones no lineales e interacciones.
- Robusto frente al sobreajuste al añadir árboles: más árboles nunca empeoran el error esperado.
- Error out-of-bag gratis y medidas de importancia de variables.
- Entrenamiento trivialmente paralelizable: cada árbol es independiente.
14Limitaciones
- Menos interpretable que un solo árbol: 300 árboles no se pueden leer.
- No extrapola: en regresión nunca predice fuera del rango visto en entrenamiento.
- Modelos grandes en memoria y predicción más lenta que un modelo lineal.
- La importancia por impureza está sesgada hacia variables continuas o con muchas categorías.
- En datos tabulares, el gradient boosting bien ajustado suele superarlo.
15Hiperparámetros
| Parámetro (scikit-learn) | Por defecto | Efecto |
|---|---|---|
n_estimators | 100 | Número de árboles. Más es mejor hasta que se estabiliza; solo cuesta tiempo. |
max_features | "sqrt" | Variables candidatas por nodo (m). Menos variables, árboles menos correlacionados. |
max_depth | None | Profundidad máxima. Sin límite, los árboles crecen hasta hojas puras. |
min_samples_leaf | 1 | Mínimo de muestras por hoja. Subirlo suaviza el modelo. |
min_samples_split | 2 | Mínimo de muestras para dividir un nodo. |
bootstrap | True | Usar muestras bootstrap. Con False, cada árbol ve todos los datos. |
max_samples | None | Tamaño de cada muestra bootstrap; útil con datasets grandes. |
class_weight | None | "balanced" compensa clases desbalanceadas. |
oob_score | False | Calcula el acierto out-of-bag al entrenar. |
criterion | "gini" | También "entropy" o "log_loss". |
16Casos de uso
- Línea base sólida para cualquier problema tabular antes de probar modelos más complejos.
- Scoring de riesgo de crédito y detección de fraude.
- Diagnóstico médico a partir de mediciones clínicas.
- Selección de variables mediante su importancia.
- Imputación de valores faltantes (MissForest).
17Aplicaciones industriales
- Mantenimiento predictivo: predecir fallos de máquina a partir de sensores de temperatura, par y desgaste. Ver el proyecto de mantenimiento predictivo.
- Telecomunicaciones: predicción de bajas de clientes y detección de anomalías en red.
- Energía: previsión de demanda y detección de fallos en aerogeneradores.
- Control de calidad: clasificar piezas defectuosas a partir de mediciones de proceso.
18Ejercicio práctico
Amplía la implementación desde cero para que calcule el error out-of-bag. Debes guardar los índices bootstrap de cada árbol y, para cada muestra, votar solo con los árboles que no la vieron. Comprueba que el resultado se acerca al 96,2 % que da scikit-learn.
# Ejercicio: completa la función para calcular el error out-of-bag
# con la clase RandomForest implementada desde cero.
# Pista: guarda los índices bootstrap de cada árbol en fit().
def error_oob(bosque, X, y):
votos = [[] for _ in range(len(X))]
for arbol, idx in zip(bosque.arboles, bosque.indices):
fuera = np.setdiff1d(np.arange(len(X)), idx)
# ... predice con 'arbol' solo las muestras 'fuera' y guarda el voto
# ... devuelve la proporción de aciertos con el voto mayoritario
pass19Código completo
El script completo con todos los experimentos de esta página está en el repositorio y se puede ejecutar en Google Colab sin instalar nada:
O cópialo desde aquí:
import numpy as np
class Nodo:
def __init__(self, feature=None, umbral=None, izq=None, der=None, valor=None):
self.feature, self.umbral, self.izq, self.der, self.valor = feature, umbral, izq, der, valor
def gini(y):
_, cuentas = np.unique(y, return_counts=True)
p = cuentas / len(y)
return 1.0 - np.sum(p ** 2)
class ArbolDecision:
def __init__(self, max_depth=None, min_samples_split=2, max_features=None, rng=None):
self.max_depth, self.min_samples_split = max_depth, min_samples_split
self.max_features, self.rng = max_features, rng or np.random.default_rng()
def fit(self, X, y):
self.raiz = self._crecer(X, y, profundidad=0)
return self
def _crecer(self, X, y, profundidad):
if (len(np.unique(y)) == 1 or len(y) < self.min_samples_split
or (self.max_depth is not None and profundidad >= self.max_depth)):
return Nodo(valor=np.bincount(y).argmax())
n_feat = X.shape[1]
k = self.max_features or n_feat
candidatas = self.rng.choice(n_feat, k, replace=False) # aleatoriedad por nodo
mejor = (None, None, gini(y))
for f in candidatas:
for t in np.unique(X[:, f])[:-1]:
izq = X[:, f] <= t
g = (izq.sum() * gini(y[izq]) + (~izq).sum() * gini(y[~izq])) / len(y)
if g < mejor[2]:
mejor = (f, t, g)
f, t, _ = mejor
if f is None:
return Nodo(valor=np.bincount(y).argmax())
izq = X[:, f] <= t
return Nodo(f, t, self._crecer(X[izq], y[izq], profundidad + 1),
self._crecer(X[~izq], y[~izq], profundidad + 1))
def _predecir_uno(self, x, nodo):
while nodo.valor is None:
nodo = nodo.izq if x[nodo.feature] <= nodo.umbral else nodo.der
return nodo.valor
def predict(self, X):
return np.array([self._predecir_uno(x, self.raiz) for x in X])
class RandomForest:
def __init__(self, n_estimators=100, max_depth=None, max_features="sqrt", seed=0):
self.n_estimators, self.max_depth = n_estimators, max_depth
self.max_features, self.rng = max_features, np.random.default_rng(seed)
def fit(self, X, y):
n, d = X.shape
k = int(np.sqrt(d)) if self.max_features == "sqrt" else d
self.arboles = []
for _ in range(self.n_estimators):
idx = self.rng.integers(0, n, n) # muestra bootstrap
arbol = ArbolDecision(self.max_depth, max_features=k, rng=self.rng)
self.arboles.append(arbol.fit(X[idx], y[idx]))
return self
def predict(self, X):
votos = np.array([a.predict(X) for a in self.arboles]) # (n_arboles, n_muestras)
return np.apply_along_axis(lambda v: np.bincount(v).argmax(), 0, votos)
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
datos = load_breast_cancer()
X, y = datos.data, datos.target # y: 0 = maligno, 1 = benigno
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)
print(X_tr.shape, X_te.shape) # (426, 30) (143, 30)
from sklearn.metrics import accuracy_score
rf = RandomForest(n_estimators=25, max_depth=8, seed=0).fit(X_tr, y_tr)
print(accuracy_score(y_te, rf.predict(X_te))) # 0.965 (unos 7 s: bucles en Python puro)
from sklearn.ensemble import RandomForestClassifier
modelo = RandomForestClassifier(
n_estimators=300,
oob_score=True, # error out-of-bag gratis
random_state=42,
n_jobs=-1,
)
modelo.fit(X_tr, y_tr)
print(f"OOB: {modelo.oob_score_:.3f}") # 0.962
from sklearn.model_selection import cross_val_score
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, confusion_matrix)
pred = modelo.predict(X_te)
prob = modelo.predict_proba(X_te)[:, 1]
print(accuracy_score(y_te, pred)) # 0.958
print(roc_auc_score(y_te, prob)) # 0.995
print(confusion_matrix(y_te, pred)) # [[49 4]
# [ 2 88]]
cv = cross_val_score(RandomForestClassifier(n_estimators=300, random_state=42), X, y, cv=5)
print(cv.mean(), cv.std()) # 0.961 0.02520Referencias y papers
- L. Breiman (2001). «Random Forests». Machine Learning, 45(1), 5–32.
- L. Breiman (1996). «Bagging Predictors». Machine Learning, 24(2), 123–140.
- T. K. Ho (1995). «Random Decision Forests». Proceedings of the 3rd International Conference on Document Analysis and Recognition, 278–282.
- T. Hastie, R. Tibshirani, J. Friedman (2009). The Elements of Statistical Learning, 2.ª ed., capítulo 15. Springer.
- F. Pedregosa et al. (2011). «Scikit-learn: Machine Learning in Python». JMLR, 12, 2825–2830.