Behnam Analytics

Writing Machine learning

Explaining models to stakeholders

How to explain a readmission risk model to clinicians and managers. Global and local explanations, permutation importance and partial dependence in scikit-learn, why correlated features mislead, what-if examples, uncertainty, and model cards.

Behnam Ebrahimi 9 min read

A manager asks what drives readmissions. A clinician asks why this patient scored 47%. Those are different questions. The first needs a global explanation, a summary of how the model behaves across everyone. The second needs a local one, about a single prediction. Both describe the model, not the patients. Most explanation mistakes come from mixing those up, or from forgetting that correlated features make any summary ambiguous.

This article explains a synthetic readmission risk model with the tools in sklearn.inspection, shows where each one misleads, and ends with the model card I’d hand over with it.

The example model

The data is 12,000 synthetic adult discharges, generated by explain_risk_model.py with a fixed seed, with 13.0% readmitted as an emergency within 30 days. The features are age, a frailty score from 1 to 9, emergency admissions and ED attendances in the last year, comorbidity and medication counts, length of stay, living alone and weekend discharge. I planted three things that make explanation hard:

  • Correlated pairs. ED attendances mostly repeat emergency admissions (correlation 0.83 across all discharges), and the medication count tracks comorbidities (0.86). In the simulation only admissions and comorbidities affect the outcome. The partners have no effect of their own.
  • Age and frailty move together (correlation 0.71).
  • An interaction. Living alone raises risk mainly for patients with a frailty score of 6 or more. Frail patients who live alone are 2.1% of discharges.

I trained a gradient boosting model and a logistic regression on a random 70%, and tested on the other 3,600 discharges. Both reached an AUROC of 0.696. The true probabilities that generated the outcomes reach 0.703, so neither model is leaving much on the table. Boosting’s mean predicted risk was 13.1% against 13.0% observed. A real model needs a time split and a harder look at calibration, which my 30-day readmission risk model and calibration before AUC cover. I’ll explain the boosting model, because it has no coefficients to fall back on.

Global: permutation importance

Permutation importance shuffles one column in the test set, breaking its link with the outcome, and measures how much the score falls. scikit-learn does it in one call:

result = permutation_importance(
    model, X, y, scoring="roc_auc", n_repeats=N_REPEATS, random_state=SEED
)
Permutation importance on the test setFall in AUROC when a feature is shuffled; whiskers span 90% of 20 shuffles
Data table
FeatureGradient boostingGradient boosting (low)Gradient boosting (high)
Age0.0410.0250.049
Frailty score0.0340.0300.044
Emergency admissions, 12 months0.0250.0190.034
Comorbidities0.0090.0020.015
Length of stay0.004-0.0010.009
ED attendances, 12 months0.0020.0010.004
Lives alone0.001-0.0010.003
Weekend discharge0.000-0.0000.001
Medications-0.001-0.003-0.000

Synthetic data. Source: projects/article-examples/round-two/explain_risk_model.py.

Age, frailty and prior admissions carry most of the model. Two readings are wrong, and both are tempting.

“Living alone doesn’t matter.” Its importance is 0.001. But for the frail patients it applies to, it matters a lot: for Patient A below, it’s the difference between 47% and 30%. Permutation importance averages over everyone, and a strong effect in 2% of patients averages out to almost nothing. Global importance tells you what the model leans on across the population, not what matters for a given patient.

“The model found the true drivers.” It measures how much this model uses a feature, not whether the feature causes anything. The scikit-learn guide says so directly: it “does not reflect the intrinsic predictive value of a feature by itself but how important this feature is for a particular model.”

Correlated features share the credit

Shuffle emergency admissions and the model can still recover some of that information from ED attendances, which mostly repeat it. The scikit-learn guide warns that this lowers the reported importance of both features, “though they might actually be important”. Shuffling a correlated pair together, with the same permutation for both columns, measures what the pair carries:

Pair Correlation (test set) First alone Second alone Both together
Admissions + ED attendances 0.82 0.0254 0.0022 0.0301
Comorbidities + medications 0.86 0.0094 −0.0012 0.0107

Together, admissions and ED attendances are worth 0.030, more than the two single numbers added up. The split within the pair is lopsided here because the model happened to lean on the column that drives the outcome. A different model, or a different seed, could split the credit more evenly, and the individual numbers would then look like two modest features instead of one important one.

In a stakeholder conversation, I report correlated features as a group: “prior hospital use”, not admissions and ED attendances separately. scikit-learn’s permutation_importance shuffles one column at a time, so grouped shuffling is a short loop of your own. The guide’s own suggestion is to cluster correlated features and keep one from each cluster.

Coefficients don’t escape the problem

Logistic regression looks easier to explain, because each feature has a coefficient. With correlated features, a coefficient is the effect of that feature holding all the others fixed, and “ED attendances rising while admissions stay the same” hardly happens in the data.

Logistic regression coefficients depend on the company they keepChange in log-odds per standard deviation; 90% bootstrap intervals
Data table
All nine features
FeatureLogistic regressionLogistic regression (low)Logistic regression (high)
Age0.190.110.27
Frailty score0.350.250.42
Emergency admissions, 12 months0.170.090.25
ED attendances, 12 months-0.00-0.070.09
Comorbidities0.130.030.25
Medications0.01-0.100.10
Length of stay0.140.090.18
Lives alone0.110.050.15
Weekend discharge0.02-0.030.08
Without emergency admissions
FeatureLogistic regressionLogistic regression (low)Logistic regression (high)
Age0.190.110.27
Frailty score0.350.250.42
ED attendances, 12 months0.140.100.19
Comorbidities0.130.030.25
Medications0.01-0.090.10
Length of stay0.140.090.19
Lives alone0.110.050.16
Weekend discharge0.02-0.030.08

Synthetic data. Source: projects/article-examples/round-two/explain_risk_model.py.

With all nine features, the coefficient for ED attendances is −0.003 per standard deviation, with a 90% bootstrap interval from −0.073 to 0.091: no effect. Remove emergency admissions and it becomes 0.143 (0.095 to 0.192). Nothing about ED attendances changed. Only the company it keeps did. The medication count sits near zero both times, with an interval as wide as the comorbidity coefficient’s, because the two are hard to tell apart.

So “the model says ED attendances don’t matter” and “the model says they do” can both be true statements about two reasonable models. Say which model, and group the features.

Partial dependence: the shape of an effect

Partial dependence shows how the average prediction changes as one feature moves, with every other feature left as it was. Individual conditional expectation (ICE) curves show the same thing for single patients. sklearn.inspection.partial_dependence returns both with kind="both":

res = partial_dependence(
    model,
    X,
    [feature],
    method="brute",
    kind="both",
    response_method="predict_proba",
    custom_values={feature: grid},
)
return res["grid_values"][0], res["average"][0], res["individual"][0][ice_rows]

method="brute" is needed for the individual curves, and response_method="predict_proba" keeps the output on the probability scale that people understand.

Predicted risk as one feature changes, all else held fixedPartial dependence (solid) and five individual patients (dashed)
Data table
Age
Feature valueAverage (partial dependence)Patient 1Patient 2Patient 3Patient 4Patient 5
209%13%5%7%4%4%
259%13%5%7%4%4%
309%13%5%7%4%4%
358%11%5%6%4%4%
409%12%8%6%4%6%
459%13%8%7%4%6%
509%12%7%7%4%6%
5510%14%8%8%5%7%
6010%13%8%8%5%6%
6510%13%8%7%5%6%
7011%14%8%8%6%8%
7511%14%9%8%6%8%
8014%20%12%13%7%10%
8515%22%14%13%8%11%
9015%22%14%13%7%10%
9516%22%14%14%7%10%
10015%22%13%14%7%10%
Length of stay
Feature valueAverage (partial dependence)Patient 1Patient 2Patient 3Patient 4Patient 5
013%13%17%14%5%7%
113%13%17%14%5%7%
213%12%14%13%4%7%
312%11%14%13%4%6%
412%11%13%13%4%6%
513%12%14%13%4%7%
613%12%14%13%4%7%
713%11%14%13%4%7%
813%11%14%13%4%7%
913%12%14%14%5%7%
1013%12%14%14%5%7%
1114%12%15%14%5%7%
1214%12%15%14%5%7%
1314%12%15%14%5%7%
1413%11%15%14%4%7%
1515%16%15%14%6%9%
1615%16%15%14%6%9%
1717%17%16%17%8%11%
1819%20%19%19%9%13%
1922%24%25%22%10%15%
2022%24%25%22%10%15%
2122%24%25%22%10%15%
2226%29%30%26%14%19%
2326%29%30%26%14%19%
2426%29%30%26%14%19%
2526%29%30%26%14%19%
2626%29%30%26%14%19%
2726%29%30%26%14%19%
2826%29%30%26%14%19%
2926%29%30%26%14%19%
3026%29%30%26%14%19%
Frailty score
Feature valueAverage (partial dependence)Patient 1Patient 2Patient 3Patient 4Patient 5
19%7%10%13%4%6%
29%7%10%13%5%6%
311%10%12%18%5%6%
414%13%13%23%6%7%
515%13%20%30%6%8%
619%15%31%40%7%9%
719%15%31%40%7%9%
819%15%31%40%7%9%
919%15%31%40%7%9%

Synthetic data. Source: projects/article-examples/round-two/explain_risk_model.py.

For frailty, the average curve rises from 9.2% at a score of 1 to 19.1% at 6 and above. The individual lines tell a different story at a frailty score of 6: they run from 7.4% to 40.1%, and the two steepest belong to patients in their 80s who live alone. That’s the planted interaction, invisible in the average and in the importance chart.

Partial dependence has a catch, and it’s the correlated-features problem again. The scikit-learn guide states that PDPs and ICE curves “assume that the input features of interest are independent from the complement features”, and that with correlated features they “create absurd data points”. The age curve is an example. At age 40 it shows 8.9%. To get that number, the model scored every test patient as a 40-year-old while keeping their own frailty, including the frail ones. But none of the 328 test patients under 45 had a frailty score of 6 or more, and their mean score was 1.4 against 3.1 overall. Their observed readmission rate was 5.5%. The curve’s 8.9% describes a population that doesn’t exist.

Two more things to point out when you show these curves. Tree models go flat where the data runs thin: the length-of-stay curve stops rising at 22 days, while the simulated risk keeps climbing. And a curve is the model’s response to a change, not the effect of making that change.

Local: one patient, and what-ifs

Clinicians usually want the local question answered. A what-if changes one input and reports the new prediction. Patient A is 84, frailty score 7, lives alone, with two emergency admissions and three ED attendances in the last year, and a nine-day stay:

Scenario Predicted risk
As recorded 47.3%
Not living alone 30.0%
Length of stay 4 days, not 9 42.8%
No emergency admissions in the last year 41.9%
No ED attendances in the last year 42.2%
Frailty score 4, not 7 27.8%

This is useful for showing what the model is paying attention to for this patient: frailty and living alone. It’s also easy to misread, in two ways.

First, the ED attendance scenario is impossible. In this data every emergency admission comes with an ED attendance, so a patient can’t have two admissions and no attendances. A what-if can create a patient who couldn’t exist, and the model will still give an answer.

Second, a what-if says what the model would predict, not what would happen. In the simulation, ED attendances have no effect on readmission at all, yet removing them lowers the prediction by five points, because the model uses them as a stand-in for admissions. Say “the model would score her lower”, never “her risk would fall”. In real data this matters most for things you could change. A long stay is partly a marker of how ill someone was, and the model can’t tell you what shortening it would do.

Uncertainty and limits

A single number like 47.3% sounds more precise than it is. One way to show how much it depends on the training sample is to refit the model on resampled data and look at the spread of predictions for the same patient:

Predicted risk for four example patientsWhiskers: 5th to 95th percentile over 30 models refitted on resampled data
Data table
PatientGradient boostingGradient boosting (low)Gradient boosting (high)
Patient A47%35%64%
Patient B8%7%11%
Patient C19%16%34%
Patient D58%47%72%

Synthetic data. Source: projects/article-examples/round-two/explain_risk_model.py.

Across 30 refits, Patient A’s risk ranged from 35.4% to 63.6% (5th to 95th percentile). Patient B, a 58-year-old with no prior admissions, stayed between 6.7% and 11.4%. The spread is much wider for Patients A, C and D, whose combinations of age, frailty and history are rarer in the training data.

When I present a risk, I use these habits:

  • Say it as a frequency. “About five in ten patients like her are readmitted within 30 days” is clearer than 47.3%, and it’s honest that the outcome is uncertain even when the risk is known.
  • Give a range for unusual patients, and say the range comes from the model, not from the patient.
  • Say what the model can’t see: social support beyond living alone, what happens after discharge, anything not in the record.
  • Say what it wasn’t built for. A score built to rank patients for a phone call isn’t a basis for treatment decisions.

Model cards

A model card is a short document that travels with a model. Mitchell and colleagues proposed the format in Model Cards for Model Reporting (2019), with nine sections: model details, intended use, factors, metrics, evaluation data, training data, quantitative analyses, ethical considerations, and caveats and recommendations. To my mind, the intended-use section, especially the out-of-scope uses, does the most to prevent misuse. Here’s the skeleton I’d start from for this model:

model_details: Gradient boosted trees (scikit-learn HistGradientBoostingClassifier),
  nine features, trained on synthetic discharges. Owner, version and date go here.
intended_use:
  primary: Rank adult discharges for a follow-up phone call.
  users: Discharge team, who review the list before calling.
  out_of_scope: Treatment decisions; patients under 18; any use without the list review.
factors: Age band, sex, deprivation quintile, frailty.
metrics: AUROC, calibration (mean predicted against observed, and by risk decile),
  readmissions reached by the top 10% of the list.
evaluation_data: 3,600 held-out discharges; test AUROC 0.696, mean predicted 13.1%
  against 13.0% observed.
training_data: 8,400 synthetic discharges.
quantitative_analyses: The metrics above for each factor, with intervals.
ethical_considerations: Ranking by risk can send most calls to the oldest patients;
  say who is left out.
caveats_and_recommendations: Correlated features, so report importance by group.
  Check calibration monthly. Retrain if it drifts.

The 30-day readmission risk model shows what the quantitative analyses look like, including who ends up on the call list.

Before the meeting

  1. Decide whether the question is global or local, and pick the tool to match.
  2. Group correlated features before reporting importance.
  3. Show ICE curves next to any partial dependence plot, and point out where the grid runs into combinations that don’t exist.
  4. Phrase what-ifs as statements about the model.
  5. Give a range and a frequency, not a bare percentage.
  6. Bring the model card, and read out the out-of-scope uses.

Reproduce

The data, both models, every table and the four charts come from one script, which runs in under a minute:

uv run python projects/article-examples/round-two/explain_risk_model.py