Medical AI · Machine learning · Lesson 4 of 9
Gradient-boosted trees for readmission risk
Reviewed by Dr. Ram Kumar G, MBBS, MD (Internal Medicine), MPH (USA), on · Last updated
- Intermediate
- Technical focus
- About 35 minutes
- Includes exercise
Before this lesson: A logistic-regression risk score, Building a cohort and avoiding data leakage
In short
Gradient boosting builds many small decision trees in sequence, each correcting the errors of the ones before, and often performs very well on tabular data. It can find non-linear relationships and interactions without being told about them. For hospital readmission, as for many clinical problems, the gain over a well-specified logistic regression is often small, so compare them fairly, tune with early stopping, and check calibration as well as AUROC.
You will learn to
- Explain how gradient boosting builds a model from many small trees
- Train a histogram gradient-boosting classifier with early stopping in scikit-learn
- Compare boosting fairly with logistic regression on AUROC and Brier score
- Interpret permutation importance and its limits
How gradient boosting works
A single decision tree splits patients into groups by asking yes/no questions ('more than two admissions last year?'). Alone, a tree is unstable and rough. Gradient boosting adds trees one at a time: each new small tree is fitted to the errors left by the trees so far, and its contribution is scaled down by a learning rate. After tens or hundreds of trees, the sum is a flexible, accurate model.
Because trees split on thresholds, boosting naturally captures U-shaped risks (both low and high sodium) and interactions (heart failure is worse for someone who lives alone) without anyone specifying them. Popular implementations include scikit-learn's HistGradientBoosting, XGBoost and LightGBM; they differ mainly in speed and features.
| Setting | What it controls | Typical starting point |
|---|---|---|
| Learning rate | How much each tree contributes | 0.05–0.1 |
| Number of trees | How long boosting continues | Set high and use early stopping |
| Tree size (leaves or depth) | How complex each tree can be | 15–31 leaves, or depth 3–6 |
| Minimum samples per leaf | Prevents tiny, overfitted groups | 20 or more for clinical data |
A synthetic readmission dataset
Readmission risk is a common target for hospital models, and a systematic review found most published readmission models had only modest discrimination. The simulation below builds 12,000 synthetic discharges with deliberately non-linear risk: a U-shaped sodium effect, age mattering only after 65, and an interaction between heart failure and living alone.
import numpy as np
import pandas as pd
rng = np.random.default_rng(8)
n = 12000
df = pd.DataFrame({
"age": rng.integers(20, 95, n),
"length_of_stay": rng.gamma(2.0, 2.5, n).round(1),
"prior_admissions": rng.poisson(0.8, n),
"sodium": rng.normal(138, 4, n).round(),
"heart_failure": rng.binomial(1, 0.15, n),
"lives_alone": rng.binomial(1, 0.25, n),
})
logit = (-2.6 + 0.35 * np.minimum(df.prior_admissions, 4)
+ 0.04 * np.maximum(df.age - 65, 0)
+ 0.9 * (np.abs(df.sodium - 139) > 6) # risk at both low and high sodium
+ 0.6 * df.heart_failure
+ 0.8 * df.heart_failure * df.lives_alone # interaction
+ 0.05 * df.length_of_stay)
df["readmit_30d"] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
print(f"{n} synthetic discharges, 30-day readmission {df.readmit_30d.mean():.1%}")
# 12000 synthetic discharges, 30-day readmission 18.8%Boosting versus logistic regression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, roc_auc_score
from sklearn.model_selection import train_test_split
features = ["age", "length_of_stay", "prior_admissions", "sodium",
"heart_failure", "lives_alone"]
train, test = train_test_split(df, test_size=0.3, random_state=0, stratify=df.readmit_30d)
models = {
"Logistic regression": LogisticRegression(max_iter=2000),
"Gradient boosting": HistGradientBoostingClassifier(
learning_rate=0.05, max_iter=500, max_leaf_nodes=15,
early_stopping=True, validation_fraction=0.2, random_state=0),
}
for name, model in models.items():
model.fit(train[features], train.readmit_30d)
p = model.predict_proba(test[features])[:, 1]
print(f"{name:<20} AUROC {roc_auc_score(test.readmit_30d, p):.3f} "
f"Brier {brier_score_loss(test.readmit_30d, p):.4f}")
print(f"Boosting stopped after {models['Gradient boosting'].n_iter_} trees")
# Logistic regression AUROC 0.646 Brier 0.1446
# Gradient boosting AUROC 0.655 Brier 0.1438
# Boosting stopped after 58 trees- Boosting does better than a plain logistic regression, because it picks up the U-shaped sodium effect and the interaction without being told.
- Early stopping held back 20% of the training data and stopped at 58 trees, when validation performance stopped improving. Without it, boosting easily overfits.
- The gain is small. Readmission depends heavily on things rarely in the record, such as social support and access to follow-up, which limits every model.
A fair comparison includes a well-specified regression
Logistic regression only looks weak when it is given straight-line terms for relationships that are curved. Adding clinically sensible terms, such as abnormal sodium, age above 65 and the heart-failure-by-living-alone interaction, is a fairer comparison.
def add_terms(d):
return d.assign(
sodium_abnormal=(np.abs(d.sodium - 139) > 6).astype(int),
age_over_65=np.maximum(d.age - 65, 0),
hf_alone=d.heart_failure * d.lives_alone,
)
engineered = features + ["sodium_abnormal", "age_over_65", "hf_alone"]
lr2 = LogisticRegression(max_iter=2000).fit(add_terms(train)[engineered], train.readmit_30d)
p = lr2.predict_proba(add_terms(test)[engineered])[:, 1]
print(f"Logistic + clinical terms AUROC {roc_auc_score(test.readmit_30d, p):.3f} "
f"Brier {brier_score_loss(test.readmit_30d, p):.4f}")
# Logistic + clinical terms AUROC 0.674 Brier 0.1408- With the right terms, logistic regression beats boosting here. This is partly an unfair advantage: we wrote the simulation, so we know the true shapes. In real data, splines and clinical knowledge get you some of the way, and boosting can reveal shapes you had not thought of.
- A fair comparison gives both methods the same data, the same split and comparable tuning effort, and reports calibration as well as discrimination.
What the model relies on: permutation importance
Permutation importance shuffles one feature at a time in the test set and measures how much performance drops. A large drop means the model depends on that feature.
from sklearn.inspection import permutation_importance
gb = models["Gradient boosting"]
result = permutation_importance(gb, test[features], test.readmit_30d,
scoring="roc_auc", n_repeats=10, random_state=0)
importance = pd.Series(result.importances_mean, index=features)
print(importance.sort_values(ascending=False).round(3))
# age 0.063
# sodium 0.023
# heart_failure 0.023
# prior_admissions 0.020
# length_of_stay 0.006
# lives_alone 0.002- Living alone appears almost unimportant, yet in the simulation it more than doubles the effect of heart failure. It matters only for the 15% with heart failure, so shuffling it barely changes overall AUROC. Importance scores can hide interactions.
- Importance describes the model, not causes. A feature can matter to the model because it is a proxy for something else, as the 'Prediction or causation?' lesson showed.
Practical checklist
- Start with a logistic-regression baseline, including sensible non-linear terms or splines.
- Tune boosting with early stopping on a validation set taken from the training data only, never the test set.
- Compare AUROC, Brier score and calibration plots; boosting can be poorly calibrated and may need recalibration.
- Prefer the simpler model when performance is similar: it is easier to validate, explain and maintain.
- Check performance in subgroups (age, sex, ethnicity, site), not just overall.
Exercise
Compare gradient boosting with logistic regression for 30-day readmission, fairly and on several metrics.
Data: Synthetic discharges generated in the notebook (no real patient data) (Generated in the notebook)
- Reproduce the three models and their AUROC and Brier scores.
- Add natural cubic splines (sklearn SplineTransformer) for age and sodium to the plain logistic regression and compare.
- Tune the boosting model's learning rate and leaf count with cross-validation on the training set only.
- Draw calibration plots for all models; recalibrate the boosted model with isotonic regression if needed.
- Compute SHAP values for the boosted model and check whether they reveal the heart-failure-by-living-alone interaction that permutation importance missed.
The runnable notebook and worked solution will be published soon. You can work through the steps with the code on this page in the meantime.
Key takeaways
- Gradient boosting adds small trees in sequence and captures non-linearity and interactions automatically.
- Use early stopping on training-set validation data to avoid overfitting.
- Compare against a well-specified logistic regression, not a straw man.
- Importance scores describe the model and can hide interactions.
Terms used: AUROC (area under the ROC curve), Brier score, Gradient boosting, Logistic regression
Related: A logistic-regression risk score, Missing data and class imbalance, Building a cohort and avoiding data leakage
FAQ
Is gradient boosting better than logistic regression?
Sometimes, especially with many predictors, strong interactions or large datasets. For many clinical problems the difference is small once the regression includes sensible non-linear terms.
What is the difference between XGBoost, LightGBM and scikit-learn's HistGradientBoosting?
They are implementations of the same idea. They differ in speed, memory use, handling of categorical and missing values, and extra features. Results are usually similar with comparable tuning.
What is early stopping?
Stopping the addition of trees when performance on a held-out validation set stops improving. It prevents overfitting and removes the need to guess the number of trees.
Can gradient boosting handle missing values?
scikit-learn's HistGradientBoosting, XGBoost and LightGBM handle missing values natively by learning which branch to send them down. That can exploit informative missingness, which has its own risks.
Why are readmission models usually only moderately accurate?
Readmission depends on many factors that are rarely recorded, such as social support, access to follow-up care and adherence, as well as on care received after discharge.
Sources
- Friedman JH. Greedy function approximation: a gradient boosting machine. Annals of Statistics 2001;29(5):1189–1232. doi:10.1214/aos/1013203451
- Chen T, Guestrin C. XGBoost: a scalable tree boosting system. KDD 2016:785–794. doi:10.1145/2939672.2939785
- Kansagara D, et al. Risk prediction models for hospital readmission: a systematic review. JAMA 2011;306(15):1688–1698. doi:10.1001/jama.2011.1515
- Christodoulou E, et al. A systematic review shows no performance benefit of machine learning over logistic regression for clinical prediction models. J Clin Epidemiol 2019;110:12–22. doi:10.1016/j.jclinepi.2019.02.004
- Breiman L. Random forests. Machine Learning 2001;45:5–32. doi:10.1023/A:1010933404324
- scikit-learn documentation. Histogram-based gradient boosting and permutation feature importance
Educational content only. This material teaches methods; it is not clinical advice. Code, models and examples here are not validated medical devices and must not be used to make decisions about real patients without proper clinical validation, governance and any required regulatory approval. Examples use synthetic or openly licensed de-identified data only. Never paste identifiable patient information into public AI tools. Mentions of models, tools or vendors are for identification, not endorsement.