ML/AIMLE·AIMachine Learning Engineering
Machine Learning · Ensembles · Artículo completo

Random Forest

Cómo muchos árboles mediocres forman un modelo excelente: intuición, matemáticas, implementación desde cero, scikit-learn y resultados reales.

Resultados obtenidos con scikit-learn 1.8.0 y NumPy 2.4 · random_state = 42 · Laboratorio relacionado

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:

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

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:

(1−1n)n→ n→∞ e−1≈0.368\left(1 - \frac{1}{n}\right)^{n} \xrightarrow{\,n\to\infty\,} e^{-1} \approx 0.368

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:

G(S)=1−∑kpk2,ΔG=G(S)−∣SL∣∣S∣G(SL)−∣SR∣∣S∣G(SR)G(S) = 1 - \sum_{k} p_k^2, \qquad \Delta G = G(S) - \frac{|S_L|}{|S|}G(S_L) - \frac{|S_R|}{|S|}G(S_R)

Predicción

y^clas(x)=moda⁡{Tb(x)}b=1B,y^reg(x)=1B∑b=1BTb(x)\hat{y}_{\text{clas}}(x) = \operatorname{moda}\{T_b(x)\}_{b=1}^{B}, \qquad \hat{y}_{\text{reg}}(x) = \frac{1}{B}\sum_{b=1}^{B} T_b(x)

El valor habitual de m es ⌊p⌋\lfloor\sqrt{p}\rfloor 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

  1. Elige el número de árboles B y el número de variables candidatas por nodo m.
  2. Para cada árbol b = 1…B, toma una muestra bootstrap: n filas elegidas al azar con reemplazo.
  3. Haz crecer un árbol con esa muestra. En cada nodo, escoge m variables al azar y busca el mejor corte solo entre ellas.
  4. Deja crecer el árbol sin podar, hasta que las hojas sean puras o tengan el mínimo de muestras.
  5. 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.

rf_desde_cero.py
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)
Uso
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

Python
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ón

08Dataset 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.

Python
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

Python
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

El 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

Python
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
ModeloAcierto en testValidación cruzada (5 particiones)
Un árbol de decisión92,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

Precisioˊn=TPTP+FP,Recall=TPTP+FN,F1=2⋅P⋅RP+R\text{Precisión} = \frac{TP}{TP+FP}, \quad \text{Recall} = \frac{TP}{TP+FN}, \quad F_1 = \frac{2\cdot P\cdot R}{P+R}
Métrica (clase positiva: benigno)Valor
Acierto0,958
Precisión0,957
Recall0,978
F10,967
ROC-AUC0,995
Real \ PredichoMalignoBenigno
Maligno494
Benigno288

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

worst perimeter0.146
worst area0.144
worst concave points0.115
mean concave points0.098
worst radius0.072
mean radius0.061
mean perimeter0.056
mean concavity0.045
Las 8 variables más importantes según la reducción media de impureza (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.
90 %92 %94 %96 %15102550100200300número de árboles (escala por posición)1 árbol: 92,3 %300 árboles: 95,8 %
Acierto en test según el número de árboles (misma partición, random_state = 42). Más árboles nunca empeoran el modelo; a partir de ~100 se estabiliza.

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 defectoEfecto
n_estimators100Nú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_depthNoneProfundidad máxima. Sin límite, los árboles crecen hasta hojas puras.
min_samples_leaf1Mínimo de muestras por hoja. Subirlo suaviza el modelo.
min_samples_split2Mínimo de muestras para dividir un nodo.
bootstrapTrueUsar muestras bootstrap. Con False, cada árbol ve todos los datos.
max_samplesNoneTamaño de cada muestra bootstrap; útil con datasets grandes.
class_weightNone"balanced" compensa clases desbalanceadas.
oob_scoreFalseCalcula 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.

Plantilla
# 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
    pass

19Có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í:

random_forest_completo.py
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.025

20Referencias y papers

  1. L. Breiman (2001). «Random Forests». Machine Learning, 45(1), 5–32.
  2. L. Breiman (1996). «Bagging Predictors». Machine Learning, 24(2), 123–140.
  3. T. K. Ho (1995). «Random Decision Forests». Proceedings of the 3rd International Conference on Document Analysis and Recognition, 278–282.
  4. T. Hastie, R. Tibshirani, J. Friedman (2009). The Elements of Statistical Learning, 2.ª ed., capítulo 15. Springer.
  5. F. Pedregosa et al. (2011). «Scikit-learn: Machine Learning in Python». JMLR, 12, 2825–2830.