4.2 - Árbol de decisión para el triaje de urgencias¶
De todos los algoritmos que estudia el equipo de Minermont en AIA, el árbol de decisión es el que más entusiasma a Teresa, la enfermera jefe de triaje: "Realmente se parece a cómo pensamos en triaje". Un árbol hace una pregunta cada vez sobre el paciente, como en el juego ¿Quién es quién?:
- El nodo raíz contiene a todos los pacientes.
- Cada nodo interno hace una pregunta sobre una característica ("¿dolor torácico?", "¿edad ≤ 44,5?").
- Cada hoja da el nivel predicho.
En este cuaderno:
- Importar las bibliotecas.
- Los datos de triaje.
- Cómo elige el árbol cada pregunta: la impureza y la mejor división, a mano.
- Un árbol sin límites: el sobreajuste.
- Elegir la profundidad con validación cruzada.
- Leer el árbol.
- Dos debilidades: umbrales rígidos e inestabilidad.
- Evaluación final.
Paso 1: Importar las bibliotecas¶
Los árboles comparan cada característica con un umbral, así que, a diferencia de KNN, no necesitan que las características estén escaladas.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_validate
from sklearn.tree import DecisionTreeClassifier, plot_tree, export_text
from sklearn.metrics import 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: Cómo elige el árbol cada pregunta¶
El libro describe la construcción como "divide y vencerás": en cada nodo, buscar la característica y el punto de corte que dividen a los pacientes en grupos lo más puros posible, es decir, en los que la mayoría de los pacientes tienen el mismo nivel. La pureza se mide con la impureza de Gini de un grupo, donde $p_k$ es la proporción de pacientes de nivel $k$:
$$G = 1 - \sum_{k=1}^{5} p_k^2$$
$G = 0$ si todos los pacientes tienen el mismo nivel, y crece cuando los niveles están mezclados. La calidad de una división es la impureza de Gini de los dos grupos resultantes, ponderada por su tamaño. Buscamos la mejor primera pregunta probando todas las características y todos los umbrales, a medio camino entre dos valores consecutivos:
def gini(levels):
p = np.bincount(levels, minlength=6)[1:] / len(levels)
return 1 - np.sum(p ** 2)
y_arr = y_train.to_numpy()
print(f"Impureza de Gini de la raíz (todos los pacientes de entrenamiento): {gini(y_arr):.3f}\n")
best = {'impurity': np.inf}
for col in feature_names:
x = X_train[col].to_numpy()
values = np.unique(x)
for threshold in (values[:-1] + values[1:]) / 2:
left, right = y_arr[x <= threshold], y_arr[x > threshold]
weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(y_arr)
if weighted < best['impurity']:
best = {'impurity': weighted, 'feature': col, 'threshold': threshold}
print(f"Mejor primera pregunta: {best['feature']} <= {best['threshold']} "
f"(impureza ponderada {best['impurity']:.3f})")
stump = DecisionTreeClassifier(max_depth=1).fit(X_train, y_train)
print(f"Raíz de scikit-learn: {feature_names[stump.tree_.feature[0]]} <= {stump.tree_.threshold[0]}")
Impureza de Gini de la raíz (todos los pacientes de entrenamiento): 0.722 Mejor primera pregunta: dolor_toracico <= 0.5 (impureza ponderada 0.633) Raíz de scikit-learn: dolor_toracico <= 0.5
Nuestra búsqueda encuentra la misma primera pregunta que scikit-learn: ¿tiene el paciente dolor torácico? Baja la impureza de 0,722 a 0,633, más que cualquier otra característica o umbral. Después el árbol repite la misma búsqueda dentro de cada uno de los dos grupos, y así sucesivamente.
Paso 4: Un árbol sin límites¶
La construcción se repite en cada nodo hijo hasta que las hojas son puras. ¿Qué pasa si no la detenemos?
full_tree = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
print(f"Profundidad: {full_tree.get_depth()}, hojas: {full_tree.get_n_leaves()}")
print(f"Exactitud en entrenamiento: {full_tree.score(X_train, y_train):.3f}")
print(f"Exactitud en prueba: {full_tree.score(X_test, y_test):.3f}")
Profundidad: 25, hojas: 545 Exactitud en entrenamiento: 1.000 Exactitud en prueba: 0.730
Una puntuación perfecta en entrenamiento y 0,730 con pacientes nuevos. Con 25 niveles de preguntas y 545 hojas, muchas hojas contienen solo uno o dos pacientes: el árbol ha memorizado los datos de entrenamiento, incluidos los casos en los que el criterio de la enfermera movió el nivel. Es lo que Alma le enseña al equipo en el libro: un 100% de exactitud con los datos con los que se entrenó y resultados mucho peores con datos nuevos.
Paso 5: Elegir la profundidad¶
La forma más sencilla de controlar un árbol es limitar su profundidad máxima, el número de preguntas desde la raíz hasta una hoja. La elegimos con validación cruzada estratificada de 5 grupos sobre el conjunto de entrenamiento, y registramos también la exactitud de entrenamiento para ver la distancia entre las dos:
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
depths = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25]
train_scores, val_scores = [], []
for d in depths:
scores = cross_validate(DecisionTreeClassifier(max_depth=d, random_state=0), X_train, y_train, cv=cv, return_train_score=True)
train_scores.append(scores['train_score'].mean())
val_scores.append(scores['test_score'].mean())
best_depth = depths[int(np.argmax(val_scores))]
plt.figure(figsize=(8, 4))
plt.plot(depths, train_scores, 'o-', label='entrenamiento')
plt.plot(depths, val_scores, 's-', label='validación')
plt.axvline(best_depth, color='gray', linestyle='--')
plt.xlabel('Profundidad máxima')
plt.ylabel('Exactitud media (5 grupos)')
plt.title('Elección de la profundidad del árbol')
plt.legend()
plt.grid(True)
plt.show()
print(" ".join(f"profundidad {d}: {v:.3f}" for d, v in zip(depths, val_scores)))
print(f"Mejor profundidad: {best_depth}")
profundidad 1: 0.516 profundidad 2: 0.613 profundidad 3: 0.644 profundidad 4: 0.725 profundidad 5: 0.779 profundidad 6: 0.805 profundidad 8: 0.823 profundidad 10: 0.828 profundidad 12: 0.819 profundidad 15: 0.794 profundidad 20: 0.770 profundidad 25: 0.761 Mejor profundidad: 10
Con pocas preguntas el árbol es demasiado simple (subajuste). A partir de la profundidad 10 la exactitud de entrenamiento sigue subiendo mientras la de validación baja (sobreajuste). La mejor profundidad es 10, con una exactitud media de validación de 0,828, claramente mejor que dejar crecer el árbol hasta su profundidad completa de 25 (0,761).
Paso 6: Leer el árbol¶
Un árbol de profundidad 10 ya es demasiado grande para leerlo. Para ver su lógica, dibujemos un árbol de profundidad 3: solo tres preguntas desde la raíz hasta cada hoja.
small_tree = DecisionTreeClassifier(max_depth=3, random_state=0).fit(X_train, y_train)
plt.figure(figsize=(26, 9))
plot_tree(small_tree, feature_names=feature_names, class_names=level_names, filled=True, rounded=True,
impurity=False, fontsize=9)
plt.title('Árbol de decisión de profundidad 3')
plt.show()
print(export_text(small_tree, feature_names=feature_names, class_names=level_names))
print(f"Exactitud en prueba del árbol de profundidad 3: {small_tree.score(X_test, y_test):.3f}")
|--- dolor_toracico <= 0.50 | |--- dolor <= 6.50 | | |--- dolor <= 3.50 | | | |--- class: 5 Azul | | |--- dolor > 3.50 | | | |--- class: 4 Verde | |--- dolor > 6.50 | | |--- presion_sistolica <= 81.00 | | | |--- class: 1 Rojo | | |--- presion_sistolica > 81.00 | | | |--- class: 3 Amarillo |--- dolor_toracico > 0.50 | |--- edad <= 44.50 | | |--- presion_sistolica <= 81.50 | | | |--- class: 1 Rojo | | |--- presion_sistolica > 81.50 | | | |--- class: 3 Amarillo | |--- edad > 44.50 | | |--- presion_sistolica <= 81.00 | | | |--- class: 1 Rojo | | |--- presion_sistolica > 81.00 | | | |--- class: 2 Naranja Exactitud en prueba del árbol de profundidad 3: 0.641
El árbol se lee casi como un protocolo de triaje. La primera pregunta es el dolor torácico. Con dolor torácico, la siguiente es la edad (44,5 años); sin él, la intensidad del dolor (6,5 y 3,5). Y en todas las ramas una presión arterial muy baja (unos 81 mmHg) significa nivel 1. Como espera Javier en el libro, los clínicos podrían comprobar cada regla con su propio conocimiento. El precio de esta claridad es la exactitud: con solo tres preguntas, el árbol clasifica bien al 64,1% de los pacientes de prueba.
Paso 7: Dos debilidades¶
Umbrales rígidos. Marta señala en el libro que, si la mejor división de la edad es 65,1 años, alguien de 65 queda al otro lado. Nuestro árbol ha aprendido un umbral de 44,5 años para los pacientes con dolor torácico. Comparemos dos pacientes idénticos salvo por un año de edad:
tree = DecisionTreeClassifier(max_depth=best_depth, random_state=0).fit(X_train, y_train)
patient = {'edad': 44, 'hombre': 1, 'presion_sistolica': 135, 'frec_cardiaca': 88, 'frec_respiratoria': 17, 'spo2': 97, 'temperatura': 36.8,
'consciencia': 0, 'dolor': 6, 'dolor_toracico': 1, 'disnea': 0, 'traumatismo': 0, 'vomitos': 0}
two = pd.DataFrame([patient, {**patient, 'edad': 45}])
for age, level in zip(two['edad'], tree.predict(two)):
print(f"Paciente de {age} años con dolor torácico -> {level_names[level - 1]}")
Paciente de 44 años con dolor torácico -> 3 Amarillo Paciente de 45 años con dolor torácico -> 2 Naranja
Un año de edad cambia el nivel de amarillo a naranja. Aquí el umbral no es casualidad: el protocolo oculto que generó los datos usa 45 años para el dolor torácico, y el árbol lo ha encontrado. En los datos reales, sin embargo, el riesgo suele crecer poco a poco con la edad, y un umbral brusco trata a una persona de 44 años y a otra de 45 como pacientes completamente distintos.
Inestabilidad. Alma advierte al equipo de que un pequeño cambio en los datos puede producir un árbol muy distinto. Entrenamos árboles con dos muestras aleatorias del 90% de los pacientes de entrenamiento, que comparten la mayoría de sus pacientes, y contamos cuántos pacientes de prueba reciben un nivel distinto:
samples = [np.random.default_rng(seed).choice(len(X_train), int(0.9 * len(X_train)), replace=False) for seed in (0, 1)]
for d, name in [(None, 'sin límites'), (best_depth, f'profundidad {best_depth}')]:
trees = [DecisionTreeClassifier(max_depth=d, random_state=0).fit(X_train.iloc[s], y_train.iloc[s]) for s in samples]
p1, p2 = trees[0].predict(X_test), trees[1].predict(X_test)
print(f"Árboles {name:<15} hojas {trees[0].get_n_leaves()} y {trees[1].get_n_leaves()}: "
f"nivel distinto para el {np.mean(p1 != p2):.1%} de los pacientes de prueba")
Árboles sin límites hojas 494 y 485: nivel distinto para el 18.1% de los pacientes de prueba Árboles profundidad 10 hojas 155 y 152: nivel distinto para el 7.0% de los pacientes de prueba
Dos árboles entrenados con casi los mismos pacientes dan un nivel distinto al 18,1% de los pacientes de prueba cuando crecen sin límites, y todavía al 7,0% con profundidad 10. Un bosque aleatorio convierte esta debilidad en una virtud: entrena cientos de árboles distintos y los deja votar.
Paso 8: Evaluación final¶
Evaluamos, una sola vez, el árbol con la profundidad elegida por validación cruzada:
y_pred = tree.predict(X_test)
print(classification_report(y_test, y_pred, target_names=level_names, digits=3))
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(f'Árbol de decisión (profundidad {best_depth}) en el conjunto de prueba')
plt.show()
precision recall f1-score support
1 Rojo 0.667 0.550 0.603 40
2 Naranja 0.761 0.773 0.767 132
3 Amarillo 0.736 0.744 0.740 195
4 Verde 0.859 0.902 0.880 418
5 Azul 0.919 0.842 0.879 215
accuracy 0.827 1000
macro avg 0.788 0.762 0.774 1000
weighted avg 0.827 0.827 0.826 1000
El árbol de profundidad 10 alcanza 0,827 en el conjunto de prueba, mejor que KNN y que Naive Bayes, pero todavía encuentra solo a 22 de los 40 pacientes de nivel 1 (recall de 0,550).
Conclusión¶
- Un árbol de decisión hace una pregunta cada vez, eligiendo en cada nodo la división que deja los grupos más puros (menor impureza de Gini). La búsqueda es voraz: escoge la mejor pregunta ahora, sin mirar hacia delante.
- Maneja características numéricas y categóricas sin escalado, capta interacciones (aquí la edad solo importa cuando hay dolor torácico) y un árbol pequeño puede ser leído y revisado por los clínicos.
- Sin límites memoriza los datos de entrenamiento. Hay que controlar su profundidad y elegirla con validación cruzada.
- Sus umbrales son rígidos, y pequeños cambios en los datos producen árboles distintos: es inestable. En el libro, Alma presenta los bosques aleatorios precisamente para resolver el sobreajuste y la inestabilidad. Son el tema del siguiente cuaderno.