Machine Learning · Module 22 of 26 · 10 concepts · about 18 min

Model Interpretability

Getting a defensible answer to 'why did it predict that' — globally, for the model as a whole, and locally, for one row.

At work: Explaining a model's decisions to customers, regulators and managers: why was this loan declined?

Why interpretability matters

What it is

Three distinct reasons, which want different tools: debugging (is the model right for the right reasons?), trust (will a domain expert act on this output?), and compliance (in credit, insurance, hiring and healthcare, an adverse decision often must come with a stated reason).

Why it matters

Conflating the three produces the wrong artifact. A regulator wants a per-decision reason code; a data scientist chasing a bug wants a global ranking to sort features by suspicion; a clinician wants to know whether this particular prediction rests on something clinically plausible.

In the real world: Lenders must tell customers why a loan was declined, and regulators ask how a model makes decisions.

# Debugging: does the global ranking contain anything absurd?
from sklearn.inspection import permutation_importance
r = permutation_importance(pipe, X_val, y_val, n_repeats=10, random_state=0, scoring='roc_auc')
for i in r.importances_mean.argsort()[::-1][:5]:
    print(f'{X_val.columns[i]:<24} {r.importances_mean[i]:+.4f}')

# Compliance: the same model, one row, ranked reasons for THIS decision.
# top_reasons(explain_row(pipe, X_val.iloc[[7]]), k=3)

An accurate model that is right for the wrong reason passes every metric you have. A pneumonia model that learnt the hospital's portable-X-ray marker, or a churn model that learnt a support ticket opened *because* the cancellation was already decided, both score beautifully offline and are useless in the decision they were built for.

Global versus local interpretability

What it is

Global interpretability describes the model's behaviour over the whole input distribution — which features matter, and in what direction, on average. Local interpretability explains one prediction: which feature values pushed *this* row above or below the baseline.

Why it matters

They routinely disagree, and the disagreement is the point. A feature can be unimportant globally and decisive for a small subgroup; a globally monotone effect can reverse locally because of an interaction. Reporting the global story for a local question is the most common interpretability error.

In the real world: “What drives churn overall?” for the strategy team, versus “why did this customer get flagged?” for the support agent.

Visual example

Scope of an explanation

Two different questions, two different objects: one number per feature, or one number per feature per row.

global

(averaged over 8,000 rows)

permutation_importance(...)

ranks by average effect

shap_values[42]

ranks by effect on THIS row

per-feature effect on churn probability
featureglobal effectrow 42's effect
tenure_months-0.18-0.02
support_tickets+0.06+0.31
monthly_spend-0.09-0.04
is_trial+0.02+0.19

Global rank: tenure first. Row 42's rank: support_tickets first

Why the rankings differ

tenure: row 42 has tenure 11 months — near the median, so it moves the prediction barely at all

tickets: row 42 filed 9 tickets against a median of 1 — an extreme value on a mid-importance feature

Global importance is about variance across rows; local attribution is about distance from typical

Why this matters

A global ranking is computed by averaging, and averaging destroys exactly the information a single-case explanation needs: how unusual this row's values are.

Common misconception

Wrong idea: the most important feature globally is the main reason for any individual prediction.

Correct idea: a feature only drives one prediction when that row's value is far from typical. Row 42 was flagged by a feature ranked fourth overall.

# Global: averaged over rows.
from sklearn.inspection import partial_dependence
pd_result = partial_dependence(pipe, X_val, features=['tenure_months'], kind='average')

# Local: one row, no averaging.
row = X_val.iloc[[42]]
base = pipe.predict_proba(X_val)[:, 1].mean()
this = pipe.predict_proba(row)[0, 1]
print(f'baseline {base:.3f} -> this row {this:.3f}')

Answering 'why was my loan refused?' with a global feature importance chart. It says what the model generally cares about, not what happened to this applicant — and if their decisive feature is rare, it may not appear in the global ranking at all.

Impurity-based feature importance, and its bias

What it is

A tree ensemble's feature_importances_ sums the impurity decrease each feature achieved across every split, weighted by the samples reaching that split. It is free — it falls out of the fitted trees with no extra data and no extra predictions.

Why it matters

It is the default that everyone reads first, and it is biased in a specific, predictable way: features with many possible split points — continuous columns, high-cardinality IDs — get more chances to reduce impurity by luck, so they rank high even when they carry no signal.

In the real world: Built-in importance can overrate an ID-like column just because it has many values, and mislead the business.

import numpy as np, pandas as pd
from sklearn.ensemble import RandomForestClassifier

rng = np.random.default_rng(0)
n = 2000
X = pd.DataFrame({
    'real_signal': rng.integers(0, 2, n),          # binary, genuinely predictive
    'random_id': rng.random(n),                     # continuous pure noise
    'random_cat': rng.integers(0, 3, n),            # low-cardinality pure noise
})
y = (X['real_signal'] + rng.normal(0, 0.4, n) > 0.5).astype(int)

rf = RandomForestClassifier(random_state=0).fit(X, y)
pd.Series(rf.feature_importances_, index=X.columns).sort_values(ascending=False)
# random_id often outranks random_cat — and sometimes approaches real_signal

Impurity importance is computed on the training data, so it reports what the model used to memorise, not what generalises. A row-id column left in by accident can top the ranking while contributing nothing to held-out accuracy at all.

Practise it: Permutation Importance, Averaged Honestly

Permutation importance

What it is

Shuffle one column of a held-out set, rescore the model, and measure how far the metric drops. Repeat n_repeats times to get a mean and a spread. Because it only needs predict, it works on any model — and because it uses held-out data, it measures reliance on that feature for *generalisation*.

Why it matters

It answers the question people actually mean by 'importance': if this feature became noise, how much worse would the model get? A near-zero drop means the model does not need the column, whatever the impurity ranking says.

In the real world: Shuffling one input to see how much the fraud model's performance drops, to show which signals really matter.

Visual example

Two importance methods

The same forest, the same three features: impurity ranks the noise column second, permutation ranks it last.

training impurity

(many split points = many chances)

RandomForestClassifier, 2,000 rows
featureimpurity (train)permutation AUC drop (val)
real_signal0.46+0.152 ± 0.009
random_id (continuous)0.39+0.001 ± 0.004
random_cat (3 levels)0.15+0.000 ± 0.003

rf.feature_importances_

free, biased toward continuous columns

permutation_importance(..., X_val)

costs n_features x n_repeats predictions

random_id holds 39% of the impurity credit and 0.001 AUC of actual value

Why the continuous noise column scores 0.39

random_id: 2,000 distinct values -> ~1,999 candidate thresholds per node

random_cat: 3 distinct values -> 3 candidate thresholds per node

More thresholds means a better best-of-many split by chance alone, at every node, in every tree

Why this matters

Deleting random_id on the impurity ranking's advice looks dangerous and is free. Keeping it because impurity ranked it second is how an id column ends up in a production model.

Common misconception

Wrong idea: feature_importances_ measures how much each feature contributes to the model's accuracy.

Correct idea: it measures how much impurity each feature happened to reduce while fitting. Only a held-out re-scoring — permutation importance — connects a feature to accuracy.

from sklearn.inspection import permutation_importance

r = permutation_importance(pipe, X_val, y_val, n_repeats=20,
                           random_state=0, scoring='roc_auc')
imp = pd.DataFrame({'mean': r.importances_mean, 'sd': r.importances_std},
                   index=X_val.columns).sort_values('mean', ascending=False)
imp.head()
#                   mean      sd
# tenure_months   0.0840  0.0071
# support_tickets 0.0310  0.0052
# random_id       0.0004  0.0039   <- within noise of zero

Run it on the training set and you measure memorisation, not reliance — the same flaw as impurity importance, with more compute. Run it with n_repeats=1 and you cannot tell a small real effect from a shuffle that happened to land badly: always read the mean beside its standard deviation.

Practise it: Permutation Importance, Averaged Honestly

Partial dependence plots

What it is

For a grid of values of one feature, set *every* row to that value, predict, and average. The resulting curve is the model's average predicted output as a function of that feature, with all other features left at their observed values.

Why it matters

It is the most literal picture of a model's learnt shape. It shows non-linearity, saturation and thresholds that no single importance number can: 'important' says a feature matters, partial dependence says a discount stops helping past 30% and starts hurting past 60%.

In the real world: Showing how predicted demand changes as price rises, to guide pricing decisions.

Visual example

Partial dependence

Average predicted churn against tenure: a steep early cliff, then flat. A single importance number cannot say this.

where retention spend payssparse data — extrapolated0102030405000.10.20.30.40.50.6tenure_monthsAverage predicted P(churn)base rate 0.19-0.10 per 3 months

How each point is computed

x = 6: set tenure_months = 6 on all 8,000 val rows, predict, average -> 0.31

x = 24: set tenure_months = 24 on the same 8,000 rows, predict, average -> 0.12

Slope 0 to 12 months: (0.16 - 0.52) / 12 = -0.030 per month

Slope 24 to 48 months: (0.11 - 0.12) / 24 = -0.0004 per month — effectively flat

Why this matters

The actionable content of this model is in the first nine months. 'tenure is the most important feature' would have led a retention team to spend evenly across the whole base.

Common misconception

Wrong idea: a partial dependence curve shows how churn actually varies with tenure in the data.

Correct idea: it shows how the model's prediction varies. It is a portrait of the model, and where the model extrapolates into empty regions the curve is invention, not evidence.

from sklearn.inspection import PartialDependenceDisplay, partial_dependence

res = partial_dependence(pipe, X_val, features=['tenure_months'],
                         grid_resolution=20, kind='average')
res['grid_values'][0][:4]      # the feature values evaluated
res['average'][0][:4]          # the averaged prediction at each

PartialDependenceDisplay.from_estimator(pipe, X_val,
                                        features=['tenure_months', 'monthly_spend'])

Partial dependence evaluates the model at feature combinations that may never occur — tenure of 48 months on a trial account, for instance. Where a region of the plot has no real data behind it, the curve is extrapolation, not a finding. Always read a partial dependence curve next to the feature's own distribution, and treat the sparse tails with suspicion.

ICE plots and averaged-away interactions

What it is

An Individual Conditional Expectation plot draws one line per row instead of averaging them: each line is that row's prediction as the feature of interest is swept across its range. The partial dependence curve is exactly the average of all the ICE lines.

Why it matters

If the ICE lines fan out or cross, the feature interacts with something else, and the averaged curve is hiding it. A flat partial dependence curve is perfectly compatible with a feature that raises the prediction sharply for half the rows and lowers it for the other half.

In the real world: Revealing that a price increase hurts demand for budget customers but not premium ones.

Visual example

ICE vs PDP

A flat average over two opposite subgroup effects — the interaction the PDP averaged away.

Enterprise rowsSelf-serve rowsPartial dependence (average)
0102030400.20.30.40.50.60.70.80.9discount_pctPredicted P(renew)PDP: 'no effect'

The average, at one x value

x = 40: enterprise 0.42, self-serve 0.68 — half the rows each

(0.42 + 0.68) / 2 = 0.55, the same value as at x = 0

Effect size per subgroup: -0.36 and +0.38. Averaged effect: +0.01

Why this matters

Acting on the dashed line — 'discounts do not affect renewal, stop offering them' — would be right for one segment and badly wrong for the other.

Common misconception

Wrong idea: a flat partial dependence curve means the model ignores the feature.

Correct idea: it means the model's average response is flat. Plot the individual lines before concluding anything; a fan or a crossing is an interaction with another feature.

PartialDependenceDisplay.from_estimator(
    pipe, X_val, features=['discount_pct'],
    kind='both',                  # ICE lines plus the averaged curve
    subsample=50, random_state=0,
)

# Two-way partial dependence tells you where to look for the interaction.
res = partial_dependence(pipe, X_val, features=[('discount_pct', 'is_enterprise')])

Reading a flat partial dependence curve as 'this feature does nothing'. Check the ICE spread before dropping the feature: cancellation between subgroups is one of the few ways a genuinely decisive feature can look inert.

SHAP values

What it is

A per-prediction attribution derived from cooperative game theory: each feature's value is assigned the average marginal change it causes to the prediction over all orderings in which features could be added. The attributions are additive — they sum exactly to the gap between this prediction and the baseline (average) prediction.

Why it matters

That additivity is what makes SHAP usable as an explanation rather than a heuristic. A local explanation whose parts do not add up to the decision leaves room for argument; one that does is a complete account of the prediction in terms of its inputs.

In the real world: Breaking an individual insurance quote into contributions: +₹2,000 for age, −₹500 for a clean record.

Visual example

SHAP, row 42

One prediction, decomposed: contributions sum exactly to the distance from the baseline.

additivity

(closes on the prediction)

why P(churn) = 0.71 for customer 42
featurethis row's valueSHAPrunning total
baseline (avg output)——0.19
support_tickets9 (median 1)+0.310.50
is_trialTrue+0.190.69
tenure_months11-0.020.67
all other features—+0.040.71

sv.base_values[42]

the average prediction, before any feature

sv.values[42].sum()

+0.52 — the whole deviation, explained

0.19 + 0.31 + 0.19 - 0.02 + 0.04 = 0.71, the model's actual output

The reason code this produces

1: 9 support tickets against a median of 1 (+0.31)

2: still on a trial plan (+0.19)

Two features account for 0.50 of the 0.52 deviation — a defensible two-line explanation

Why this matters

Additivity turns an explanation into an audit. Anyone can check it: add the numbers and you get the decision, with no residual left over to hide a feature nobody mentioned.

Common misconception

Wrong idea: a SHAP value of +0.31 means removing those support tickets would cut churn risk by 0.31.

Correct idea: it means 0.31 of this model's output is attributable to that value being 9 instead of typical. Whether tickets cause churn, or churn causes tickets, is a causal question the model never addressed.

import shap

explainer = shap.TreeExplainer(model)         # exact and fast for tree ensembles
sv = explainer(X_val)

row = 42
base = sv.base_values[row]                    # average model output
contrib = sv.values[row]                       # one value per feature
print(base + contrib.sum(), model.predict(X_val.iloc[[row]])[0])   # equal, by construction

order = abs(contrib).argsort()[::-1][:3]
for i in order:
    print(f'{X_val.columns[i]:<18} value={X_val.iloc[row, i]!r:>8}  shap={contrib[i]:+.3f}')

A SHAP value is not a causal effect and not a sensitivity to changing the feature in the real world. It answers 'how much of this model's output is attributable to this input value', which is a statement about the model. Also note the cost: TreeExplainer is exact for tree ensembles, while KernelExplainer on an arbitrary model is a sampling approximation and can take minutes per row.

LIME and local surrogates

What it is

Perturb the row of interest to generate a cloud of nearby synthetic points, label them with the black-box model's own predictions, weight them by proximity to the original row, and fit a sparse linear model to that weighted cloud. Its coefficients are the explanation.

Why it matters

It formalises the intuition that a complicated surface is approximately simple if you only look at a small enough patch of it. That gives a model-agnostic local explanation on tabular data, text and images, needing nothing but predict_proba.

In the real world: Explaining one surprising prediction, like a flagged transaction, with a simple local model.

Visual example

LIME

A curved decision surface, replaced locally by a straight line that only has to be right nearby.

Black-box model responseLocal linear surrogateWeighted perturbations
neighbourhood LIME fits02468101200.20.40.60.81support_ticketsModel P(churn)row 42: 9 tickets

What the surrogate says, and where it stops being true

slope: inside the band: +0.107 in P(churn) per extra ticket

at x = 0: the dashed line extrapolates to -0.22 — impossible; the surrogate is local only

LIME reports the slope, not the line: 'support_tickets > 4 -> +0.28 toward churn'

Why this matters

It shows both the promise and the limit in one picture: faithful in the shaded band, meaningless outside it. A LIME explanation quoted away from the row it was fitted around is simply a wrong number.

Common misconception

Wrong idea: LIME gives you a simple model that approximates the black box.

Correct idea: it gives you a simple model that approximates the black box around one point. There is no single global surrogate here, and the explanation for the row next door may have a different slope.

from lime.lime_tabular import LimeTabularExplainer

explainer = LimeTabularExplainer(
    X_train.values, feature_names=list(X_train.columns),
    class_names=['stay', 'churn'], discretize_continuous=True, random_state=0,
)
exp = explainer.explain_instance(X_val.values[42], pipe.predict_proba, num_features=5)
exp.as_list()
# [('support_tickets > 4', 0.28), ('is_trial=True', 0.17), ('tenure_months <= 12', 0.05), ...]

LIME explanations are unstable: the perturbation cloud is random, so two runs on the same row with different seeds can return different features in a different order. Seed it, run it several times, and only trust the parts that survive — an explanation that changes when nothing changed is not an explanation.

Interpretable model versus black box plus explanation

What it is

Two strategies. Either use a model whose structure is itself the explanation — linear or logistic regression with few features, a shallow decision tree, a scorecard, a generalised additive model — or use the most accurate model available and attach a post-hoc explainer to it.

Why it matters

Post-hoc explanations are approximations of a model, and can be wrong about it; an intrinsically interpretable model cannot misrepresent itself, because there is nothing behind the coefficients to misrepresent. On tabular data with strong features the accuracy gap between the two strategies is often small enough that the choice is genuinely open.

In the real world: Choosing a simple, transparent credit model when regulators demand clarity, even if a complex one is slightly more accurate.

# Intrinsic: the model IS the explanation.
from sklearn.linear_model import LogisticRegression
lin = LogisticRegression(max_iter=1000).fit(X_train_scaled, y_train)
pd.Series(lin.coef_[0], index=X_train.columns).sort_values()

# Intrinsic and non-linear: a short tree, readable end to end.
from sklearn.tree import DecisionTreeClassifier, export_text
tree = DecisionTreeClassifier(max_depth=3, random_state=0).fit(X_train, y_train)
print(export_text(tree, feature_names=list(X_train.columns)))

A linear model is only interpretable under conditions people forget to check: features on comparable scales (otherwise coefficient size means nothing), low collinearity (otherwise the coefficients are unstable and can even flip sign), and no unmodelled interaction. A logistic regression on 400 correlated engineered features is a black box with a misleadingly transparent API.

Correlated features split the credit

What it is

When two features carry nearly the same information, any single-feature importance method divides their shared contribution between them. Shuffle one and the model leans on its twin, so the measured drop is small for each — even though removing both would be ruinous.

Why it matters

This is how a genuinely essential signal gets dropped during 'feature pruning': each of its two representations looks individually negligible. The fix is to group correlated features and permute the group together, or to cluster on the correlation matrix and keep one representative per cluster.

In the real world: Income and salary share importance, so neither looks important alone; teams need to know this before dropping one.

Visual example

Correlated importance

Two copies of one signal: each scores near zero alone, and 0.14 AUC together.

the trap

(two 'droppable' rows)

permutation AUC drop, corr(spend_30d, spend_90d) = 0.96
permutedAUC dropverdict if read alone
spend_30d+0.011drop it
spend_90d+0.009drop it
both together+0.142essential
tenure_months+0.084keep

permutation_importance(X, ['spend_30d'])

the model falls back on spend_90d

permutation_importance(X, ['spend_30d','spend_90d'])

no fallback left

0.011 + 0.009 = 0.020, but the pair is worth 0.142 — importance is not additive

What the substitution does

shuffle 30d: the 90d column still carries the spend signal -> metric barely moves

shuffle 90d: symmetrically, the 30d column covers for it

Shuffle both and the model loses spend entirely: 0.142 AUC, seven times the sum of the parts

Why this matters

Single-feature importance measures *marginal* value given everything else stays. With a near-duplicate present, the marginal value of either copy is genuinely almost nothing — the number is right and the inference drawn from it is wrong.

Common misconception

Wrong idea: a feature with near-zero permutation importance carries no information.

Correct idea: it carries no information the model cannot get elsewhere. Check for a correlated twin before deleting anything.

from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform
from scipy.stats import spearmanr

corr = spearmanr(X_val).correlation
corr = (corr + corr.T) / 2
np.fill_diagonal(corr, 1)
linkage = hierarchy.ward(squareform(1 - np.abs(corr)))
clusters = hierarchy.fcluster(linkage, 0.5, criterion='distance')
keep = [np.flatnonzero(clusters == c)[0] for c in np.unique(clusters)]

# Now permute a whole cluster at once, not one column.
r = permutation_importance(pipe, X_val.iloc[:, keep], y_val, n_repeats=20, random_state=0)

Never drop features one at a time on the strength of a single importance run. Drop the lowest, refit, re-measure, repeat — or permute groups. Dropping the bottom five features simultaneously because each scored near zero is how a model loses an entire signal in one commit.