A flexible model can be probed even when it cannot be read: shuffle a predictor and see how much worse it gets, vary a predictor and watch the prediction, or split a single prediction fairly among the predictors. These explain the model, which is not the same as explaining the world.
6.8.1 Which predictors matter? Permutation importance
A model’s own importance scores (for trees, how much each split reduced the error, Chapter 6.6) are measured on the training data and favour predictors with many possible split points. Permutation importance asks a cleaner question on held-out data: if the values of one predictor were shuffled among the patients, breaking its link with the outcome while keeping everything else, how much worse would the predictions be?
Code
result = permutation_importance( boosted, X_test, y_test, scoring="neg_mean_squared_error", n_repeats=30, random_state=0)order = np.argsort(result.importances_mean)fig, ax = plt.subplots(figsize=(8, 3.6))ax.barh(np.array(NAMES)[order], result.importances_mean[order], color=style.BLUE, alpha=0.7)ax.errorbar( result.importances_mean[order],range(len(NAMES)), xerr=[ result.importances_mean[order] - result.importances.min(axis=1)[order], result.importances.max(axis=1)[order] - result.importances_mean[order], ], fmt="none", color=style.INK, lw=1,)ax.axvline(0, color=style.INK, lw=0.8)ax.set(xlabel="increase in mean squared error when shuffled")plt.show()
Figure 6.8.1: Permutation importance for boosted trees predicting disease progression, on 133 held-out patients: the increase in mean squared error when each predictor is shuffled (average and range over 30 shuffles). s5 (a blood serum measurement) and bmi matter most; several predictors could be shuffled with almost no loss.
A caution: correlated predictors share credit. If two predictors carry the same information, shuffling either one alone hurts little, because the model can lean on the other, and both look unimportant.
Figure 6.8.2: The same model refitted with an exact copy of bmi added as an extra column. The importance that bmi had alone (left) is now split between the two copies (right), and each looks less important than it really is.
6.8.2 How does a predictor act? Partial dependence and ICE
Partial dependence shows the average prediction as one predictor is moved across its range, with every patient’s other values kept as they are. Individual conditional expectation (ICE) curves show the same for each patient separately: where they run parallel, the predictor acts the same way for everyone; where they cross, it interacts with something else.
Code
def ice_curves(feature: str, patients: int=60) ->tuple[np.ndarray, np.ndarray]:"""Predictions for a sample of patients as one predictor moves over a grid of values.""" grid = np.linspace(*np.percentile(X[feature], [2, 98]), 40) sample = X_test.sample(patients, random_state=0) curves = np.empty((patients, len(grid)))for k, value inenumerate(grid): curves[:, k] = boosted.predict(sample.assign(**{feature: value}))return grid, curvesfig, axes = style.subplots(2, height=3.2, width=10)for ax, feature inzip(axes, ("bmi", "s5")): grid, curves = ice_curves(feature) ax.plot(grid, curves.T, color=style.GREY, lw=0.6, alpha=0.6) ax.plot(grid, curves.mean(axis=0), color=style.CORAL, lw=3) ax.set(xlabel=feature, ylabel="predicted progression")fig.tight_layout()plt.show()
Figure 6.8.3: Partial dependence (thick red line) and ICE curves for 60 patients (grey) for bmi and s5 in the boosted model. Both raise the predicted progression, with steps where the trees split. The ICE curves are roughly parallel: in this model, each predictor shifts everyone’s prediction by about the same amount.
6.8.3 Why this prediction? Shapley values
For one patient, the model predicts a progression of, say, 230, against an average prediction of 150. Which predictors account for the difference of 80? Shapley values, from cooperative game theory, split it fairly. Imagine adding the predictors one at a time, in random order, and record how much each one changes the prediction when it joins; a predictor’s Shapley value is its average contribution over all orders:
Here is the prediction when only the predictors in S are known (the others averaged over the data). The values add up exactly: average prediction plus all equals the patient’s prediction. For a linear model, .
Code
background = X_train.sample(100, random_state=1)patient = X_test.iloc[[5]]def value_of(subset: tuple[int, ...]) ->float:"""Average prediction when the predictors in `subset` take the patient's values.""" data = background.copy()for j in subset: data.iloc[:, j] = patient.iloc[0, j]returnfloat(boosted.predict(data).mean())values = {s: value_of(s) for r inrange(11) for s in itertools.combinations(range(10), r)}shapley = np.zeros(10)for j inrange(10):for subset, v in values.items():if j in subset:continue weight = ( math.factorial(len(subset))* math.factorial(10-len(subset) -1)/ math.factorial(10) ) shapley[j] += weight * (values[tuple(sorted(subset + (j,)))] - v)base, final = values[()], values[tuple(range(10))]fig, ax = plt.subplots(figsize=(8, 3.8))order = np.argsort(np.abs(shapley))running = basefor row, j inenumerate(order): color = style.CORAL if shapley[j] >0else style.BLUE ax.barh(row, shapley[j], left=running, color=color, alpha=0.8) ax.text(max(running, running + shapley[j]) +1, row,f"{shapley[j]:+.0f} ({NAMES[j]} = {patient.iloc[0, j]:.4g})", va="center", fontsize=8, ) running += shapley[j]ax.axvline(base, color=style.INK, ls="--", lw=1)ax.axvline(final, color=style.INK, lw=1)ax.set_yticks(range(10), np.array(NAMES)[order])ax.set( xlabel=f"prediction: from the average {base:.0f} to this patient's {final:.0f}", xlim=(min(base, final) -30, max(base, final) +70),)plt.show()
Figure 6.8.4: Exact Shapley values for one held-out patient, computed over all 1,024 subsets of the ten predictors (other predictors averaged over 100 background patients). Starting from the average prediction, each bar adds one predictor’s contribution, red raising and blue lowering the prediction, ending at this patient’s prediction. High s5 and bmi push it up; the rest matter little.
Computing all subsets is only possible with few predictors. With many, Shapley values are estimated by sampling random orders, and for tree models there are fast exact algorithms. Plotting the Shapley values of many patients gives a global picture that also shows direction:
Code
def sampled_shapley(row: np.ndarray, orders: int=60) -> np.ndarray:"""Average contribution of each predictor over random orders of adding them.""" contributions = np.zeros(10) start = background.to_numpy()[:30]for _ inrange(orders): data = start.copy() previous = boosted.predict(data).mean()for j in rng.permutation(10): data[:, j] = row[j] current = boosted.predict(data).mean() contributions[j] += current - previous previous = currentreturn contributions / ordersexplained = X_test.iloc[:150]phis = np.array([sampled_shapley(r) for r in explained.to_numpy()])order = np.argsort(np.abs(phis).mean(axis=0))fig, ax = plt.subplots(figsize=(8, 4))for row, j inenumerate(order): values_j = explained.iloc[:, j].to_numpy() scaled = (values_j - values_j.min()) / (np.ptp(values_j) +1e-9) ax.scatter( phis[:, j], row + rng.uniform(-0.25, 0.25, len(phis)), c=scaled, cmap="coolwarm", s=8 )ax.axvline(0, color=style.INK, lw=0.8)ax.set_yticks(range(10), np.array(NAMES)[order])ax.set(xlabel="Shapley value (contribution to the prediction)")plt.show()
Figure 6.8.5: Estimated Shapley values (60 random orders each) for 150 held-out patients, one dot per patient and predictor, coloured by the predictor’s value (blue low, red high). For s5 and bmi, high values push predictions up and low values down; for most other predictors, the dots stay near zero.
6.8.4 Fairness
When a model’s decisions affect people (loans, hiring, medical triage), it matters how its errors fall across groups. A model can be equally accurate overall and still make one kind of mistake far more often for one group. There are several definitions of fairness, and they conflict:
Calibration within groups: a score of 30% means 30% in every group.
Equal error rates: the same false positive rate and the same false negative rate in every group.
Demographic parity: the same share of positive decisions in every group.
When the groups have different base rates of the outcome, a calibrated score cannot have equal error rates in both groups (unless it is perfect). Choosing among the definitions is a value judgement, not a statistical one; what statistics can do is make the trade-offs visible.
Code
n =20_000group = rng.uniform(size=n) <0.5true_logit = np.where(group, -1.1, -2.2) + rng.normal(0, 1, n)default = rng.uniform(size=n) <1/ (1+ np.exp(-true_logit))signal = true_logit + rng.normal(0, 0.8, n)score_model = LogisticRegression().fit(np.column_stack([signal, group]), default)risk = score_model.predict_proba(np.column_stack([signal, group]))[:, 1]refused = risk >0.2def calibration(ax: Axes) ->None:"""Observed default rate in bins of predicted risk, per group.""" bins = np.linspace(0, 0.8, 9)for g, color, name in ((False, style.BLUE, "group A"), (True, style.CORAL, "group B")): mask = group == g index = np.digitize(risk[mask], bins) centres = [ risk[mask][index == b].mean()for b inrange(1, len(bins))if (index == b).sum() >50 ] observed = [ default[mask][index == b].mean()for b inrange(1, len(bins))if (index == b).sum() >50 ] ax.plot(centres, observed, "o-", color=color, lw=2, label=name) ax.plot([0, 0.8], [0, 0.8], color=style.INK, ls="--", lw=1) ax.xaxis.set_major_formatter(style.PERCENT) ax.yaxis.set_major_formatter(style.PERCENT) ax.legend(fontsize=8) ax.set( xlabel="predicted risk", ylabel="observed default rate", title="Calibrated in both groups", )def error_rates(ax: Axes) ->None:"""False positive rate, false negative rate and refusal share per group.""" metrics = {}for g, name in ((False, "group A"), (True, "group B")): mask = group == g metrics[name] = [ refused[mask &~default].mean(), (~refused)[mask & default].mean(), refused[mask].mean(), ] labels = ["false positive rate", "false negative rate", "share refused"] x = np.arange(3)for offset, (name, values), color inzip( (-0.2, 0.2), metrics.items(), (style.BLUE, style.CORAL) ): ax.bar(x + offset, values, width=0.4, color=color, alpha=0.75, label=name) ax.set_xticks(x, labels, fontsize=8.5) ax.yaxis.set_major_formatter(style.PERCENT) ax.legend(fontsize=8) ax.set(ylabel="rate", title="Threshold 20% for everyone")fig, axes = style.subplots(2, height=3.4, width=10.5)calibration(axes[0])error_rates(axes[1])fig.tight_layout()plt.show()
Figure 6.8.6: A simulated loan-default model applied to two groups with different default rates (10% and 25%). The score is calibrated in both (left: predicted risk matches the observed rate). With one threshold of 20% for both groups, the false positive rate (people refused who would have repaid) is much higher in group B, and the share refused differs (right).
6.8.5 What explanations do not say
They describe the model, not the world. A predictor the model relies on is not thereby a cause; changing it in reality may not change the outcome (Chapter 5.4).
Correlated predictors make any attribution partly arbitrary.
Shuffling and moving one predictor at a time creates combinations that never occur (a tall person with a child’s weight), and the model’s behaviour there is meaningless.
An explanation that looks sensible is not evidence that the model is right; check it on held-out data (Chapter 6.2).
6.8.6 In one picture
Permutation importance: loss increase on held-out data when a predictor is shuffled; correlated predictors share credit.
Partial dependence shows the average effect of moving a predictor; ICE curves show it per case.
Shapley values split one prediction fairly among predictors and add up to it; for a linear model, .
Fairness criteria (calibration, equal error rates, parity) conflict when base rates differ; show the trade-offs.
Explanations describe the model, not causes.
Next: Part 7, unsupervised learning, methods with no outcome to learn from.