4.2 - Bosque aleatorio para el triaje de urgencias¶
Un solo árbol de decisión es fácil de leer, pero se sobreajusta y es inestable. Un bosque aleatorio entrena muchos árboles distintos y los deja votar: "¡Es como pedir una segunda, tercera... centésima opinión!", dice Teresa en el libro. Es el modelo que el equipo de Minermont elige finalmente para el triaje. Sus ideas clave:
- Bagging: cada árbol se entrena con una muestra bootstrap distinta de los datos.
- Aleatoriedad en las características: en cada nodo, un árbol solo considera un subconjunto aleatorio de $m$ características ($m \approx \sqrt{d}$).
- El bosque predice la clase más votada (la moda de las predicciones de los árboles).
- Los pacientes que un árbol no ha visto dan una estimación gratuita del error: el error out-of-bag (OOB).
- El bosque puede estimar la importancia de cada característica.
En este cuaderno:
- Importar las bibliotecas.
- Los datos de triaje.
- Las muestras bootstrap, a mano.
- Un bosque a mano.
- El bosque en scikit-learn: error OOB, número de árboles y aleatoriedad en las características.
- La estabilidad.
- Evaluar un modelo multiclase: métricas por clase.
- La importancia de las características.
- Los niveles críticos: pesos de las clases y umbrales de decisión.
- Comparación con los demás modelos del capítulo.
Paso 1: Importar las bibliotecas¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_predict
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.multiclass import OneVsRestClassifier, OneVsOneClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.inspection import permutation_importance
from sklearn.metrics import (accuracy_score, recall_score, precision_score, classification_report,
ConfusionMatrixDisplay)
Paso 2: Los datos de triaje¶
Los registros de urgencias de un hospital real son confidenciales, así que generamos pacientes sintéticos que se comportan como los del libro. La siguiente celda contiene el generador. No hace falta seguirlo línea a línea: lo importante es que el nivel de urgencia sale de un protocolo oculto (una puntuación de alerta temprana basada en los signos vitales, más algunas señales de alarma clínicas) y del criterio de la propia enfermera, que a veces mueve el nivel un escalón. Los modelos nunca ven esa regla: tienen que descubrirla a partir de los ejemplos.
| Columna | Significado |
|---|---|
edad |
Edad en años (1–97; aproximadamente uno de cada ocho pacientes es un niño) |
hombre |
1 si el paciente es hombre, 0 si es mujer |
presion_sistolica |
Presión arterial sistólica (mmHg) |
frec_cardiaca |
Frecuencia cardiaca (latidos por minuto) |
frec_respiratoria |
Frecuencia respiratoria (respiraciones por minuto) |
spo2 |
Saturación de oxígeno (%) |
temperatura |
Temperatura (°C) |
consciencia |
Nivel de consciencia: 0 alerta, 1 responde a la voz, 2 responde solo al dolor, 3 no responde |
dolor |
Dolor referido por el paciente (0–10) |
dolor_toracico, disnea, traumatismo, vomitos |
Síntomas de consulta: dolor torácico, dificultad para respirar, traumatismo, vómitos (1 = sí) |
nivel |
Objetivo: nivel de urgencia, de 1 (rojo, inmediato) a 5 (azul, no urgente) |
def generate_patients(n, seed):
"""Pacientes sintéticos de urgencias de Minermont: una fila por paciente, con el nivel de urgencia asignado en el triaje."""
rng = np.random.default_rng(seed)
age = np.clip(np.round(np.where(rng.random(n) < 0.12, rng.uniform(1, 17, n), rng.normal(52, 20, n))), 1, 97)
male = rng.binomial(1, 0.5, n)
s = rng.gamma(0.9, 0.55, n) # gravedad oculta del episodio (el modelo nunca la ve)
infection = rng.binomial(1, 0.12 + 0.1 * (s > 1), n)
trauma = rng.binomial(1, 0.2, n)
chest_pain = rng.binomial(1, np.clip(0.04 + 0.0025 * age, 0, 0.35), n)
dyspnea = rng.binomial(1, np.clip(0.06 + 0.25 * s, 0, 0.9), n)
vomiting = rng.binomial(1, 0.12, n)
shock = (rng.random(n) < 0.35) & (s > 1.2)
hr = 76 + 16 * s + 12 * infection + rng.normal(0, 10, n)
brady = (rng.random(n) < 0.08) & (s > 1.3) # algunos pacientes graves tienen el pulso muy lento
hr = np.where(brady, rng.normal(42, 6, n), hr)
rr = 15 + 4.5 * s + 3 * dyspnea + rng.normal(0, 2.2, n)
spo2 = np.clip(98.2 - 2.2 * s * (0.5 + dyspnea) + rng.normal(0, 1.1, n), 70, 100)
sbp = np.clip(128 + 0.25 * (age - 50) + rng.normal(0, 16, n) - 28 * s * shock, 60, 230)
temp = 36.8 + rng.normal(0, 0.35, n) + infection * rng.normal(1.6, 0.6, n)
p_consc = np.clip(0.02 * s ** 2.2, 0, 0.9)
consc = np.where(rng.random(n) < p_consc, rng.choice([1, 2, 3], n, p=[0.55, 0.3, 0.15]), 0)
pain = np.clip(np.round(rng.normal(2.5 + 3.5 * trauma + 3 * chest_pain + 0.8 * s, 1.8, n)), 0, 10).astype(int)
hr, rr, sbp = np.round(hr).astype(int), np.round(rr).astype(int), np.round(sbp).astype(int)
temp, spo2 = np.round(temp, 1), np.round(spo2).astype(int)
# El protocolo oculto detrás de las etiquetas, que solo usamos para generar los datos:
# una puntuación de alerta temprana en la que un signo vital suma puntos si es demasiado bajo O demasiado alto...
score = (np.select([rr <= 8, rr <= 11, rr <= 20, rr <= 24], [3, 1, 0, 2], 3)
+ np.select([spo2 <= 91, spo2 <= 93, spo2 <= 95], [3, 2, 1], 0)
+ np.select([sbp <= 90, sbp <= 100, sbp <= 110, sbp <= 219], [3, 2, 1, 0], 3)
+ np.select([hr <= 40, hr <= 50, hr <= 90, hr <= 110, hr <= 130], [3, 1, 0, 1, 2], 3)
+ np.where(consc > 0, 3, 0)
+ np.select([temp <= 35, temp <= 36, temp <= 38, temp <= 39], [3, 1, 0, 1], 2))
# ...más discriminadores clínicos, del nivel menos urgente al más urgente
level = np.full(n, 5)
level = np.where((score >= 1) | (pain >= 4) | (trauma == 1), 4, level)
level = np.where((score >= 4) | (pain >= 7) | (temp >= 39) | ((chest_pain == 1) & (age < 45)), 3, level)
level = np.where((score >= 6) | ((chest_pain == 1) & (age >= 45)) | (spo2 <= 91), 2, level)
level = np.where((score >= 9) | (consc >= 2) | (sbp <= 80), 1, level)
# y el criterio de la propia enfermera, que a veces mueve el nivel un escalón
level = np.clip(level + rng.choice([-1, 0, 1], n, p=[0.05, 0.9, 0.05]), 1, 5)
return pd.DataFrame({
'edad': age.astype(int), 'hombre': male, 'presion_sistolica': sbp, 'frec_cardiaca': hr, 'frec_respiratoria': rr,
'spo2': spo2, 'temperatura': temp, 'consciencia': consc, 'dolor': pain, 'dolor_toracico': chest_pain,
'disnea': dyspnea, 'traumatismo': trauma, 'vomitos': vomiting, 'nivel': level,
})
df = generate_patients(4000, seed=42)
feature_names = [c for c in df.columns if c != 'nivel']
level_names = ['1 Rojo', '2 Naranja', '3 Amarillo', '4 Verde', '5 Azul']
X = df[feature_names]
y = df['nivel']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
print(f"Entrenamiento: {len(y_train)} pacientes, prueba: {len(y_test)} pacientes\n")
print("Pacientes por nivel de urgencia:")
print(pd.DataFrame({'pacientes': y.value_counts().sort_index(),
'proporción': y.value_counts(normalize=True).sort_index().round(3)},).set_axis(level_names).to_string())
Entrenamiento: 3000 pacientes, prueba: 1000 pacientes
Pacientes por nivel de urgencia:
pacientes proporción
1 Rojo 158 0.040
2 Naranja 530 0.132
3 Amarillo 780 0.195
4 Verde 1671 0.418
5 Azul 861 0.215
Paso 3: Las muestras bootstrap¶
Una muestra bootstrap tiene el mismo tamaño que el conjunto de entrenamiento, pero se extrae con reemplazo: algunos pacientes aparecen varias veces y otros no aparecen. La probabilidad de que un paciente concreto no salga nunca en $n$ extracciones es $(1 - 1/n)^n$, que para $n$ grande vale aproximadamente $1/e \approx 0.368$. Así, cada árbol ve alrededor del 63% de los pacientes distintos, y el 37% restante son sus pacientes out-of-bag (fuera de la bolsa).
rng = np.random.default_rng(0)
n = len(X_train)
sample = rng.integers(0, n, n) # n extracciones con reemplazo
counts = np.bincount(sample, minlength=n)
print(f"Pacientes distintos en la muestra: {np.mean(counts > 0):.1%} (teoría: {1 - (1 - 1 / n) ** n:.1%})")
print(f"Pacientes elegidos más de una vez: {np.mean(counts > 1):.1%}")
print(f"Pacientes out-of-bag: {np.mean(counts == 0):.1%}")
Pacientes distintos en la muestra: 63.9% (teoría: 63.2%) Pacientes elegidos más de una vez: 25.8% Pacientes out-of-bag: 36.1%
Paso 4: Un bosque a mano¶
Con una muestra bootstrap para cada árbol y max_features='sqrt' (en cada nodo, el árbol solo mira un subconjunto aleatorio de $\sqrt{13} \approx 3$ características), construir un bosque son pocas líneas. La predicción es la moda de los votos:
$$\hat{C}(x_q) = \text{moda}\{C_1(x_q), C_2(x_q), \dots, C_N(x_q)\}$$
def train_forest(X, y, n_trees, seed=0):
rng = np.random.default_rng(seed)
trees = []
for b in range(n_trees):
idx = rng.integers(0, len(X), len(X)) # muestra bootstrap
tree = DecisionTreeClassifier(max_features='sqrt', random_state=b) # características aleatorias en cada nodo
trees.append(tree.fit(X.iloc[idx], y.iloc[idx]))
return trees
def forest_predict(trees, X):
votes = np.array([tree.predict(X) for tree in trees]) # árboles x pacientes
return np.array([np.bincount(v, minlength=6).argmax() for v in votes.T]) # nivel más votado
single_tree = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
print(f"Un solo árbol (todos los datos, todas las características): exactitud en prueba {single_tree.score(X_test, y_test):.3f}")
trees = train_forest(X_train, y_train, n_trees=100)
for k in [1, 10, 100]:
print(f"Bosque de {k:>3} árbol(es): exactitud en prueba {accuracy_score(y_test, forest_predict(trees[:k], X_test)):.3f}")
Un solo árbol (todos los datos, todas las características): exactitud en prueba 0.730
Bosque de 1 árbol(es): exactitud en prueba 0.692 Bosque de 10 árbol(es): exactitud en prueba 0.848 Bosque de 100 árbol(es): exactitud en prueba 0.875
Un árbol del bosque es peor que el árbol completo (0,692 frente a 0,730): solo ha visto parte de los pacientes, y solo unas pocas características en cada nodo. Pero combinarlos funciona: 10 árboles llegan a 0,848 y 100 árboles a 0,875. Cada árbol comete errores distintos y, en la votación, muchos se compensan.
Paso 5: El bosque en scikit-learn¶
RandomForestClassifier hace lo mismo, más deprisa, y con oob_score=True calcula además la exactitud OOB: cada paciente de entrenamiento se predice solo con los árboles que no lo vieron, así que obtenemos una estimación del rendimiento con datos nuevos sin tocar el conjunto de prueba.
rf = RandomForestClassifier(n_estimators=300, oob_score=True, random_state=0, n_jobs=-1)
rf.fit(X_train, y_train)
print(f"Exactitud OOB (solo datos de entrenamiento): {rf.oob_score_:.3f}")
print(f"Exactitud en prueba: {rf.score(X_test, y_test):.3f}")
Exactitud OOB (solo datos de entrenamiento): 0.867 Exactitud en prueba: 0.878
La estimación OOB (0,867) está muy cerca de la exactitud en prueba (0,878), y la hemos obtenido sin usar el conjunto de prueba. Es la validación "gratuita" que destaca Marta en el libro.
El número de árboles y el número de características por nodo son los hiperparámetros clave. La exactitud OOB nos permite explorarlos sin gastar datos:
n_trees_list = [25, 50, 100, 200, 300, 500]
oob_by_trees = [RandomForestClassifier(n_estimators=t, oob_score=True, random_state=0, n_jobs=-1)
.fit(X_train, y_train).oob_score_ for t in n_trees_list]
print("Número de árboles:", " ".join(f"{t}: {s:.3f}" for t, s in zip(n_trees_list, oob_by_trees)))
max_features_list = [1, 2, 'sqrt', 6, 9, None]
oob_by_features = [RandomForestClassifier(n_estimators=300, max_features=m, oob_score=True, random_state=0, n_jobs=-1)
.fit(X_train, y_train).oob_score_ for m in max_features_list]
print("Características por nodo:", " ".join(f"{m if m else 'las 13'}: {s:.3f}" for m, s in zip(max_features_list, oob_by_features)))
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(n_trees_list, oob_by_trees, 'o-')
axes[0].set_xlabel('Número de árboles')
axes[0].set_ylabel('Exactitud OOB')
axes[0].set_title('Más árboles: mejor, hasta que se estabiliza')
axes[1].plot([str(m) if m else 'todas (13)' for m in max_features_list], oob_by_features, 's-')
axes[1].set_xlabel('Características consideradas en cada nodo (max_features)')
axes[1].set_ylabel('Exactitud OOB')
axes[1].set_title('Aleatoriedad en las características')
for ax in axes:
ax.grid(True)
plt.show()
Número de árboles: 25: 0.840 50: 0.857 100: 0.863 200: 0.868 300: 0.867 500: 0.869
Características por nodo: 1: 0.840 2: 0.860 sqrt: 0.867 6: 0.869 9: 0.864 las 13: 0.858
La exactitud OOB crece deprisa hasta los 100–200 árboles y después apenas cambia: más árboles no perjudican, solo cuestan tiempo. Con una sola característica por nodo los árboles son demasiado aleatorios (0,840). Considerar las 13 características (0,858) elimina la aleatoriedad en las características y deja solo el bagging, que es peor que el valor por defecto 'sqrt' (3 características, 0,867). Los mejores valores (6 características, o 500 árboles) solo mejoran un par de milésimas los valores por defecto, así que nos quedamos con estos.
Paso 6: La estabilidad¶
En el cuaderno del árbol de decisión, dos árboles entrenados con dos muestras aleatorias del 90% de los pacientes daban niveles distintos a muchos pacientes de prueba. Repitamos el experimento con bosques:
samples = [np.random.default_rng(seed).choice(len(X_train), int(0.9 * len(X_train)), replace=False) for seed in (0, 1)]
for name, make in [('árboles', lambda: DecisionTreeClassifier(random_state=0)),
('bosques', lambda: RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1))]:
p1, p2 = [make().fit(X_train.iloc[s], y_train.iloc[s]).predict(X_test) for s in samples]
print(f"Dos {name:<8} nivel distinto para el {np.mean(p1 != p2):.1%} de los pacientes de prueba")
Dos árboles nivel distinto para el 18.1% de los pacientes de prueba
Dos bosques nivel distinto para el 0.9% de los pacientes de prueba
Los árboles discrepan en el 18,1% de los pacientes de prueba; los bosques, solo en el 0,9%. Promediar cientos de votos hace estables las decisiones del bosque, algo muy importante en triaje: el nivel de un paciente no debería depender de qué registros se usaron por casualidad para entrenar el modelo.
Paso 7: Evaluar un modelo multiclase¶
Marta insiste en el libro en que la exactitud global no basta: necesitamos saber cómo se comporta el modelo con cada nivel, sobre todo con los críticos. En la matriz de confusión multiclase las filas son las clases reales y las columnas las predichas. Para cada clase $k$:
- $VP_k$: la celda $(k, k)$.
- $FP_k$: el resto de la columna $k$ (pacientes de otras clases predichos como $k$).
- $FN_k$: el resto de la fila $k$ (pacientes de la clase $k$ predichos como otra cosa).
- $VN_k$: todas las celdas fuera de la fila $k$ y de la columna $k$.
$$\text{Precisión}_k = \frac{VP_k}{VP_k + FP_k} \qquad \text{Recall}_k = \frac{VP_k}{VP_k + FN_k} \qquad F1_k = 2\,\frac{\text{Precisión}_k \cdot \text{Recall}_k}{\text{Precisión}_k + \text{Recall}_k}$$
Apliquemos las fórmulas a la tabla de ejemplo con 3 niveles del libro:
book_matrix = np.array([[50, 5, 0], # nivel real 1 (alto)
[8, 200, 15], # nivel real 2 (medio)
[1, 25, 500]]) # nivel real 3 (bajo)
def per_class_metrics(cm, names):
rows = []
for k, name in enumerate(names):
tp = cm[k, k]
fp = cm[:, k].sum() - tp
fn = cm[k, :].sum() - tp
tn = cm.sum() - tp - fp - fn
precision, recall = tp / (tp + fp), tp / (tp + fn)
rows.append({'clase': name, 'VP': tp, 'FP': fp, 'FN': fn, 'VN': tn, 'precisión': precision,
'recall': recall, 'F1': 2 * precision * recall / (precision + recall)})
return pd.DataFrame(rows).set_index('clase').round(3)
print(per_class_metrics(book_matrix, ['Nivel 1 (alto)', 'Nivel 2 (medio)', 'Nivel 3 (bajo)']).to_string())
VP FP FN VN precisión recall F1 clase Nivel 1 (alto) 50 9 5 740 0.847 0.909 0.877 Nivel 2 (medio) 200 30 23 551 0.870 0.897 0.883 Nivel 3 (bajo) 500 15 26 263 0.971 0.951 0.961
El nivel 1 tiene el recall más alto (0,909) pero la precisión más baja (0,847): de los 59 pacientes clasificados como de urgencia alta, 9 no lo eran. La mayoría de los errores son confusiones entre los niveles 2 y 3 (15 y 25 pacientes), justo como señala el libro.
Ahora el mismo análisis para nuestro bosque en el conjunto de prueba:
y_pred = rf.predict(X_test)
ConfusionMatrixDisplay.from_predictions(y_test, y_pred, display_labels=level_names, cmap='Blues', xticks_rotation=30)
plt.xlabel('Nivel predicho')
plt.ylabel('Nivel real')
plt.title('Bosque aleatorio en el conjunto de prueba')
plt.show()
print(classification_report(y_test, y_pred, target_names=level_names, digits=3))
precision recall f1-score support
1 Rojo 0.897 0.650 0.754 40
2 Naranja 0.871 0.818 0.844 132
3 Amarillo 0.846 0.815 0.830 195
4 Verde 0.861 0.967 0.911 418
5 Azul 0.953 0.842 0.894 215
accuracy 0.878 1000
macro avg 0.885 0.818 0.846 1000
weighted avg 0.881 0.878 0.876 1000
El bosque es el modelo más exacto hasta ahora (0,878) y muy fiable cuando dice nivel 1 (precisión de 0,897). Pero todavía encuentra solo a 26 de los 40 pacientes críticos (recall de 0,650): 14 pacientes de nivel 1 quedan clasificados como menos urgentes.
Paso 8: La importancia de las características¶
Víctor señala que el bosque no dirá exactamente por qué eligió el nivel 2, pero sí qué variables son las más decisivas. Hay dos medidas habituales:
- Importancia por impureza: cuánto reduce cada característica la impureza de Gini en todas las divisiones de todos los árboles. Sale gratis, pero tiende a favorecer las características con muchos valores distintos.
- Importancia por permutación, el método que describe el libro: barajar los valores de una característica entre los pacientes y medir cuánto baja la exactitud. Si el modelo no necesitaba esa característica, la exactitud no cambia. La calculamos en el conjunto de prueba, repitiendo cada barajado 10 veces.
perm = permutation_importance(rf, X_test, y_test, n_repeats=10, random_state=0, n_jobs=-1)
importance = pd.DataFrame({'impureza': rf.feature_importances_, 'permutación': perm.importances_mean},
index=feature_names).sort_values('permutación')
print(importance.sort_values('permutación', ascending=False).round(3).to_string())
importance.plot.barh(figsize=(9, 5), subplots=True, layout=(1, 2), sharey=True, legend=False,
title=['Importancia por impureza', 'Importancia por permutación (caída de exactitud)'])
plt.tight_layout()
plt.show()
impureza permutación dolor 0.213 0.251 dolor_toracico 0.124 0.187 presion_sistolica 0.137 0.118 frec_respiratoria 0.099 0.117 frec_cardiaca 0.120 0.104 edad 0.095 0.057 spo2 0.056 0.047 traumatismo 0.027 0.046 temperatura 0.087 0.035 disnea 0.009 0.006 consciencia 0.011 0.006 hombre 0.013 -0.001 vomitos 0.009 -0.002
Las dos medidas coinciden en las variables principales: el dolor, el dolor torácico, la presión arterial y las frecuencias respiratoria y cardiaca. Las diferencias son instructivas. La temperatura, con muchos valores distintos, obtiene una importancia por impureza mucho mayor (0,087) que por permutación (0,035). El sexo y los vómitos, que no tienen ningún papel en el protocolo oculto, tienen una importancia por permutación prácticamente nula (ligeramente negativa, por azar). Y el nivel de consciencia parece poco importante solo porque muy pocos pacientes lo tienen alterado: para ellos es decisivo, pero la importancia mide el efecto sobre todos los pacientes.
Paso 9: Los niveles críticos¶
"¡Esto es inaceptable!", exclama Carmen en el libro al ver el recall de los niveles más urgentes. Como explica Marta, la causa es el desbalanceo de clases: el modelo está optimizado para acertar los niveles frecuentes. El libro menciona dos remedios, y probamos los dos sin tocar el conjunto de prueba.
Pesos de las clases. Con class_weight='balanced' cada error en un nivel poco frecuente cuenta más durante el entrenamiento. Lo comparamos con validación cruzada de 5 grupos sobre el conjunto de entrenamiento. Los árboles que crecen del todo terminan en hojas puras, donde los pesos apenas importan, así que probamos también hojas de al menos 10 pacientes (min_samples_leaf=10):
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
options = {
'sin pesos': RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1),
"class_weight='balanced'": RandomForestClassifier(n_estimators=300, class_weight='balanced', random_state=0, n_jobs=-1),
"'balanced' + min_samples_leaf=10": RandomForestClassifier(n_estimators=300, class_weight='balanced',
min_samples_leaf=10, random_state=0, n_jobs=-1),
}
rows = []
for name, model in options.items():
p = cross_val_predict(model, X_train, y_train, cv=cv)
recalls = recall_score(y_train, p, average=None)
rows.append({'opción': name, 'exactitud': accuracy_score(y_train, p), 'recall nivel 1': recalls[0], 'recall nivel 2': recalls[1]})
print("Validación cruzada de 5 grupos sobre el conjunto de entrenamiento:")
print(pd.DataFrame(rows).set_index('opción').round(3).to_string())
Validación cruzada de 5 grupos sobre el conjunto de entrenamiento:
exactitud recall nivel 1 recall nivel 2
opción
sin pesos 0.863 0.602 0.804
class_weight='balanced' 0.861 0.661 0.812
'balanced' + min_samples_leaf=10 0.833 0.695 0.756
Los pesos equilibrados suben el recall del nivel 1 de 0,602 a 0,661 casi sin coste en exactitud. Con hojas más grandes los pesos tienen más efecto (0,695), pero la exactitud global baja a 0,833 y el nivel 2 empeora. Es el compromiso que describe el libro: mejor recall en los niveles críticos a costa de algo de exactitud global.
Un umbral de decisión. El bosque no solo vota: predict_proba da la fracción de árboles que votan por cada nivel. Por defecto gana el nivel más votado, pero con el nivel crítico podemos ser más prudentes: si al menos una fracción $t$ de los árboles dice nivel 1, marcar al paciente como nivel 1. Elegimos $t$ con las probabilidades OOB, que, igual que la exactitud OOB, solo usan árboles que no vieron a cada paciente:
def with_threshold(proba, t):
pred = rf.classes_[proba.argmax(axis=1)]
return np.where(proba[:, 0] >= t, 1, pred) # columna 0 = probabilidad del nivel 1
oob_proba = rf.oob_decision_function_
rows = []
for t in [0.5, 0.4, 0.3, 0.2, 0.15, 0.1]:
p = with_threshold(oob_proba, t)
rows.append({'umbral t': t, 'exactitud OOB': accuracy_score(y_train, p),
'recall nivel 1': recall_score(y_train, p, average=None)[0],
'precisión nivel 1': precision_score(y_train, p, average=None)[0]})
print(pd.DataFrame(rows).set_index('umbral t').round(3).to_string())
exactitud OOB recall nivel 1 precisión nivel 1 umbral t 0.50 0.867 0.627 0.949 0.40 0.868 0.661 0.940 0.30 0.871 0.763 0.891 0.20 0.866 0.797 0.746 0.15 0.853 0.797 0.534 0.10 0.833 0.805 0.377
Bajar el umbral atrapa más pacientes de nivel 1: el recall OOB pasa de 0,627 con $t = 0.5$ a 0,797 con $t = 0.2$, mientras la exactitud apenas se mueve. Por debajo de 0,2 el recall deja de mejorar y la precisión se hunde (0,534 con 0,15): marcaríamos a muchos pacientes que no son críticos. Elegimos $t = 0.2$ y lo comprobamos, una sola vez, en el conjunto de prueba:
t = 0.2
default_pred = rf.predict(X_test)
cautious_pred = with_threshold(rf.predict_proba(X_test), t)
real_1 = y_test.to_numpy() == 1
for name, p in [('nivel más votado', default_pred), (f'umbral t = {t}', cautious_pred)]:
print(f"{name:<18} exactitud {accuracy_score(y_test, p):.3f} | pacientes de nivel 1 encontrados: {np.sum((p == 1) & real_1)} de {real_1.sum()} "
f"| marcados como nivel 1 sin serlo: {np.sum((p == 1) & ~real_1)}")
nivel más votado exactitud 0.878 | pacientes de nivel 1 encontrados: 26 de 40 | marcados como nivel 1 sin serlo: 3 umbral t = 0.2 exactitud 0.876 | pacientes de nivel 1 encontrados: 31 de 40 | marcados como nivel 1 sin serlo: 13
En el conjunto de prueba la regla prudente encuentra a 31 de los 40 pacientes de nivel 1 en lugar de 26, y la exactitud solo pasa de 0,878 a 0,876. El precio son 13 pacientes marcados como nivel 1 sin serlo (3 con la regla por defecto), un error en la dirección segura. Como dice Víctor en el libro, prefiere que el sistema sea algo más cauto y marque los casos de urgencia alta para que la enfermera los confirme.
Paso 10: Comparación con los demás modelos del capítulo¶
Por último, ponemos el bosque junto a las demás opciones que estudió el equipo: KNN y el árbol de decisión de los cuadernos anteriores, y las dos estrategias de la sección 4.1 que convierten un clasificador binario en uno multiclase: One-vs-Rest con regresión logística (un clasificador por nivel) y One-vs-One con SVM (un clasificador por cada par de niveles).
ovr = make_pipeline(StandardScaler(), OneVsRestClassifier(LogisticRegression(max_iter=2000))).fit(X_train, y_train)
ovo = make_pipeline(StandardScaler(), OneVsOneClassifier(SVC())).fit(X_train, y_train)
print(f"One-vs-Rest entrena {len(ovr[-1].estimators_)} clasificadores binarios; One-vs-One entrena {len(ovo[-1].estimators_)}\n")
candidates = {
'KNN (K = 9, Manhattan)': make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=9, metric='manhattan')).fit(X_train, y_train).predict(X_test),
'Árbol de decisión (profundidad 10)': DecisionTreeClassifier(max_depth=10, random_state=0).fit(X_train, y_train).predict(X_test),
'Regresión logística, One-vs-Rest': ovr.predict(X_test),
'SVM, One-vs-One': ovo.predict(X_test),
'Bosque aleatorio': default_pred,
f'Bosque aleatorio + umbral {t} para el nivel 1': cautious_pred,
}
rows = []
for name, p in candidates.items():
recalls = recall_score(y_test, p, average=None)
rows.append({'modelo': name, 'exactitud': accuracy_score(y_test, p), 'recall nivel 1': recalls[0], 'recall nivel 2': recalls[1]})
print(pd.DataFrame(rows).set_index('modelo').round(3).to_string())
One-vs-Rest entrena 5 clasificadores binarios; One-vs-One entrena 10
exactitud recall nivel 1 recall nivel 2 modelo KNN (K = 9, Manhattan) 0.739 0.575 0.750 Árbol de decisión (profundidad 10) 0.827 0.550 0.773 Regresión logística, One-vs-Rest 0.625 0.700 0.735 SVM, One-vs-One 0.778 0.625 0.826 Bosque aleatorio 0.878 0.650 0.818 Bosque aleatorio + umbral 0.2 para el nivel 1 0.876 0.775 0.773
El bosque es el modelo más exacto y, con el umbral prudente, tiene además el mejor recall de nivel 1 de la tabla. El árbol de decisión queda segundo en exactitud y la SVM con One-vs-One (10 clasificadores), tercera. La regresión logística con One-vs-Rest encuentra a muchos pacientes críticos (0,700), pero solo acierta el 62,5% de todos los niveles: sus fronteras lineales no pueden captar signos vitales que son peligrosos tanto si son demasiado altos como demasiado bajos. Naive Bayes, en su propio cuaderno, llegó a 0,761.
Conclusión¶
- Un bosque aleatorio combina muchos árboles de decisión entrenados con muestras bootstrap y con características aleatorias en cada nodo, y predice la clase más votada. Combinar muchos árboles imperfectos y distintos da un modelo más exacto y mucho más estable que un solo árbol.
- La exactitud OOB estima el rendimiento con datos nuevos durante el propio entrenamiento, y es una forma barata de elegir el número de árboles o de características por nodo.
- La importancia de las características, sobre todo por permutación, da a los clínicos una pista interpretable de qué variables pesan más, y pueden compararla con su propio conocimiento.
- Con los niveles críticos no basta la exactitud global: hay que mirar el recall por clase y usar pesos de las clases o un umbral de decisión más prudente, elegidos sin tocar el conjunto de prueba.
- Como en el libro, el bosque es la opción más pragmática para el triaje: exacto, robusto y razonablemente interpretable, siempre como apoyo al criterio de la enfermera y nunca como sustituto.