4.2 - Decision tree for emergency triage¶
Of all the algorithms the Minermont team studies at AIA, the decision tree is the one that excites Claire, the head triage nurse, the most: "It really resembles how we think in triage". A tree asks one question at a time about the patient, as in the game Guess Who?:
- The root node contains all the patients.
- Each internal node asks a question about one feature ("chest pain?", "age ≤ 44.5?").
- Each leaf gives the predicted level.
In this notebook:
- Import the libraries.
- The triage data.
- How the tree chooses each question: impurity and the best split, by hand.
- A tree without limits: overfitting.
- Choosing the depth with cross-validation.
- Reading the tree.
- Two weaknesses: rigid thresholds and instability.
- Final evaluation.
Step 1: Import the libraries¶
Trees compare each feature with a threshold, so, unlike KNN, they do not need the features to be scaled.
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
Step 2: The triage data¶
The emergency records of a real hospital are confidential, so we generate synthetic patients that behave like the ones in the book. The next cell contains the generator. You do not need to follow it line by line: what matters is that the urgency level comes from a hidden protocol (an early-warning score based on the vital signs, plus some clinical warning signs) and from the nurse's own judgement, which sometimes moves the level one step. The models never see that rule: they have to discover it from the examples.
| Column | Meaning |
|---|---|
age |
Age in years (1–97; about one patient in eight is a child) |
male |
1 if the patient is a man, 0 if a woman |
systolic_bp |
Systolic blood pressure (mmHg) |
heart_rate |
Heart rate (beats per minute) |
resp_rate |
Respiratory rate (breaths per minute) |
spo2 |
Oxygen saturation (%) |
temperature |
Temperature (°C) |
consciousness |
Level of consciousness: 0 alert, 1 responds to voice, 2 responds only to pain, 3 unresponsive |
pain |
Pain reported by the patient (0–10) |
chest_pain, dyspnea, trauma, vomiting |
Presenting symptoms: chest pain, difficulty breathing, injury, vomiting (1 = yes) |
level |
Target: urgency level, from 1 (red, immediate) to 5 (blue, non-urgent) |
def generate_patients(n, seed):
"""Synthetic emergency patients at Minermont: one row per patient, with the urgency level assigned at triage."""
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) # hidden severity of the episode (never shown to the model)
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) # a few severe patients have a very slow heart rate
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)
# The hidden protocol behind the labels, used only to generate the data:
# an early-warning score in which a vital sign scores points when it is too low OR too high...
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))
# ...plus clinical discriminators, from the least to the most urgent level
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)
# and the nurse's own judgement, which sometimes moves the level one step
level = np.clip(level + rng.choice([-1, 0, 1], n, p=[0.05, 0.9, 0.05]), 1, 5)
return pd.DataFrame({
'age': age.astype(int), 'male': male, 'systolic_bp': sbp, 'heart_rate': hr, 'resp_rate': rr,
'spo2': spo2, 'temperature': temp, 'consciousness': consc, 'pain': pain, 'chest_pain': chest_pain,
'dyspnea': dyspnea, 'trauma': trauma, 'vomiting': vomiting, 'level': level,
})
df = generate_patients(4000, seed=42)
feature_names = [c for c in df.columns if c != 'level']
level_names = ['1 Red', '2 Orange', '3 Yellow', '4 Green', '5 Blue']
X = df[feature_names]
y = df['level']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
print(f"Training set: {len(y_train)} patients, test set: {len(y_test)} patients\n")
print("Patients per urgency level:")
print(pd.DataFrame({'patients': y.value_counts().sort_index(),
'share': y.value_counts(normalize=True).sort_index().round(3)},).set_axis(level_names).to_string())
Training set: 3000 patients, test set: 1000 patients
Patients per urgency level:
patients share
1 Red 158 0.040
2 Orange 530 0.132
3 Yellow 780 0.195
4 Green 1671 0.418
5 Blue 861 0.215
Step 3: How the tree chooses each question¶
The book describes the construction as "divide and conquer": at each node, find the feature and the cut-off point that split the patients into groups that are as pure as possible, that is, where most patients have the same level. Purity is measured with the Gini impurity of a group, where $p_k$ is the proportion of patients of level $k$:
$$G = 1 - \sum_{k=1}^{5} p_k^2$$
$G = 0$ if all the patients have the same level, and it grows when the levels are mixed. The quality of a split is the Gini impurity of the two resulting groups, weighted by their size. We look for the best first question by trying every feature and every threshold, halfway between two consecutive values:
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"Gini impurity of the root (all training patients): {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"Best first question: {best['feature']} <= {best['threshold']} "
f"(weighted impurity {best['impurity']:.3f})")
stump = DecisionTreeClassifier(max_depth=1).fit(X_train, y_train)
print(f"scikit-learn's root: {feature_names[stump.tree_.feature[0]]} <= {stump.tree_.threshold[0]}")
Gini impurity of the root (all training patients): 0.722 Best first question: chest_pain <= 0.5 (weighted impurity 0.633) scikit-learn's root: chest_pain <= 0.5
Our search finds the same first question as scikit-learn: does the patient have chest pain? It lowers the impurity from 0.722 to 0.633, more than any other feature or threshold. The tree then repeats the same search inside each of the two groups, and so on.
Step 4: A tree without limits¶
The construction is repeated in each child node until the leaves are pure. What happens if we do not stop it?
full_tree = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
print(f"Depth: {full_tree.get_depth()}, leaves: {full_tree.get_n_leaves()}")
print(f"Accuracy on the training set: {full_tree.score(X_train, y_train):.3f}")
print(f"Accuracy on the test set: {full_tree.score(X_test, y_test):.3f}")
Depth: 25, leaves: 545 Accuracy on the training set: 1.000 Accuracy on the test set: 0.730
A perfect score on the training set and 0.730 on new patients. With 25 levels of questions and 545 leaves, many leaves contain just one or two patients: the tree has memorized the training data, including the cases in which the nurse's judgement moved the level. It is what Hazel shows the team in the book: 100% accuracy on the data it was trained with, and much worse results on new data.
Step 5: Choosing the depth¶
The simplest way to control a tree is to limit its maximum depth, the number of questions from the root to a leaf. We choose it with stratified 5-fold cross-validation on the training set, and we also record the training accuracy to see the gap between the two:
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='training')
plt.plot(depths, val_scores, 's-', label='validation')
plt.axvline(best_depth, color='gray', linestyle='--')
plt.xlabel('Maximum depth')
plt.ylabel('Mean accuracy (5 folds)')
plt.title('Choosing the depth of the tree')
plt.legend()
plt.grid(True)
plt.show()
print(" ".join(f"depth {d}: {v:.3f}" for d, v in zip(depths, val_scores)))
print(f"Best depth: {best_depth}")
depth 1: 0.516 depth 2: 0.613 depth 3: 0.644 depth 4: 0.725 depth 5: 0.779 depth 6: 0.805 depth 8: 0.823 depth 10: 0.828 depth 12: 0.819 depth 15: 0.794 depth 20: 0.770 depth 25: 0.761 Best depth: 10
With few questions the tree is too simple (underfitting). Beyond depth 10 the training accuracy keeps rising while the validation accuracy falls (overfitting). The best depth is 10, with a mean validation accuracy of 0.828, clearly better than letting the tree grow to its full depth of 25 (0.761).
Step 6: Reading the tree¶
A tree of depth 10 is already too big to read. To see the logic, let's draw a tree of depth 3: only three questions from the root to each leaf.
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('Decision tree of depth 3')
plt.show()
print(export_text(small_tree, feature_names=feature_names, class_names=level_names))
print(f"Test accuracy of the depth-3 tree: {small_tree.score(X_test, y_test):.3f}")
|--- chest_pain <= 0.50 | |--- pain <= 6.50 | | |--- pain <= 3.50 | | | |--- class: 5 Blue | | |--- pain > 3.50 | | | |--- class: 4 Green | |--- pain > 6.50 | | |--- systolic_bp <= 81.00 | | | |--- class: 1 Red | | |--- systolic_bp > 81.00 | | | |--- class: 3 Yellow |--- chest_pain > 0.50 | |--- age <= 44.50 | | |--- systolic_bp <= 81.50 | | | |--- class: 1 Red | | |--- systolic_bp > 81.50 | | | |--- class: 3 Yellow | |--- age > 44.50 | | |--- systolic_bp <= 81.00 | | | |--- class: 1 Red | | |--- systolic_bp > 81.00 | | | |--- class: 2 Orange Test accuracy of the depth-3 tree: 0.641
The tree reads almost like a triage protocol. The first question is chest pain. With chest pain the next one is the age (44.5 years); without it, the pain score (6.5 and 3.5). And in every branch a very low blood pressure (about 81 mmHg) means Level 1. As Owen hopes in the book, clinicians could check each rule against their own knowledge. The price of this clarity is accuracy: with only three questions, the tree classifies 64.1% of the test patients correctly.
Step 7: Two weaknesses¶
Rigid thresholds. Martha points out in the book that if the best split for age is 65.1 years, a 65-year-old ends up on the other side. Our tree has learned a threshold of 44.5 years for patients with chest pain. Let's compare two patients who are identical except for one year of age:
tree = DecisionTreeClassifier(max_depth=best_depth, random_state=0).fit(X_train, y_train)
patient = {'age': 44, 'male': 1, 'systolic_bp': 135, 'heart_rate': 88, 'resp_rate': 17, 'spo2': 97, 'temperature': 36.8,
'consciousness': 0, 'pain': 6, 'chest_pain': 1, 'dyspnea': 0, 'trauma': 0, 'vomiting': 0}
two = pd.DataFrame([patient, {**patient, 'age': 45}])
for age, level in zip(two['age'], tree.predict(two)):
print(f"Patient aged {age} with chest pain -> {level_names[level - 1]}")
Patient aged 44 with chest pain -> 3 Yellow Patient aged 45 with chest pain -> 2 Orange
One year of age changes the level from yellow to orange. Here the threshold is not an accident: the hidden protocol that generated the data uses 45 years for chest pain, and the tree has found it. In real data, however, risk usually grows gradually with age, and a sharp threshold treats a 44-year-old and a 45-year-old as completely different patients.
Instability. Hazel warns the team that a small change in the data can produce a very different tree. We train trees on two random samples of 90% of the training patients, which share most of their patients, and count how many test patients receive a different level:
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, 'without limits'), (best_depth, f'depth {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"Trees {name:<15} leaves {trees[0].get_n_leaves()} and {trees[1].get_n_leaves()}: "
f"different level for {np.mean(p1 != p2):.1%} of the test patients")
Trees without limits leaves 494 and 485: different level for 18.1% of the test patients Trees depth 10 leaves 155 and 152: different level for 7.0% of the test patients
Two trees trained on almost the same patients give a different level to 18.1% of the test patients when they grow without limits, and still to 7.0% with depth 10. A random forest turns this weakness into a strength: it trains hundreds of different trees and lets them vote.
Step 8: Final evaluation¶
We evaluate, once, the tree with the depth chosen by cross-validation:
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('Predicted level')
plt.ylabel('Real level')
plt.title(f'Decision tree (depth {best_depth}) on the test set')
plt.show()
precision recall f1-score support
1 Red 0.667 0.550 0.603 40
2 Orange 0.761 0.773 0.767 132
3 Yellow 0.736 0.744 0.740 195
4 Green 0.859 0.902 0.880 418
5 Blue 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
The tree of depth 10 reaches 0.827 on the test set, better than KNN and Naive Bayes, but it still finds only 22 of the 40 Level 1 patients (recall 0.550).
Conclusion¶
- A decision tree asks one question at a time, choosing at each node the split that leaves the purest groups (lowest Gini impurity). The search is greedy: it picks the best question now, without looking ahead.
- It handles numerical and categorical features without scaling, it captures interactions (the age only matters here when there is chest pain), and a small tree can be read and checked by clinicians.
- Without limits it memorizes the training data. Its depth has to be controlled, and chosen with cross-validation.
- Its thresholds are rigid, and small changes in the data produce different trees: it is unstable. In the book, Hazel introduces random forests precisely to address overfitting and instability. They are the subject of the next notebook.