Quick Reference · SHapley Additive exPlanations

SHAP cheat sheet

One equation carries the whole library: f(x) = E[f(X)] + Σφᵢ. Start from the average prediction, add each feature's contribution, land exactly on this prediction. Everything else is picking the right explainer for your model and the right plot for your question — plus three traps that silently corrupt the numbers.

foundations & the Explanation object explainers local plots — one prediction global plots — whole dataset text · image · interactions trap most common

Grounded against shap.readthedocs.io (official API) & github.com/shap/shap · Lundberg & Lee, NeurIPS 2017 · TreeSHAP, Nature MI 2020. Every claim and number here was executed against shap 0.52.0 / scikit-learn 1.8.0 / xgboost. Diagram figures are real output.

Model → Explainer → Explanation → plots · and the two gates that decide whether the numbers mean anything
YOUR MODEL ONE OBJECT · ONE IDENTITY TWO QUESTIONS · TWO PLOT FAMILIES tree / GBM TreeExplainer — exact · fast linear LinearExplainer — exact neural net Deep / GradientExplainer anything else Kernel / Permutation slow · APPROXIMATE shap.Explainer(model, X) auto-picks one of the above Explanation .values φ — the SHAP values .base_values E[f(X)] — the starting point .data the input values .feature_names THE ADDITIVITY IDENTITY f(x) = E[f(X)] + Σ φᵢ exact for Tree & Linear · approximate for Kernel GATE 1 · output space XGB/LGBM → LOG-ODDS sklearn RF → probability sigmoid(Σ) to compare GATE 2 · background E[f(X)] is computed FROM the background set. Change it → all φ change. Both gates are silent. Nothing errors — the numbers just quietly mean something else. LOCAL — one row "why THIS prediction?" plots.waterfall(sv[0]) plots.force(sv[0]) plots.decision(...) GLOBAL — whole dataset "what drives the model?" plots.beeswarm(sv) plots.bar(sv) plots.scatter(sv[:, "age"]) plots.heatmap(sv) GLOBAL = mean(|φ|) NOT mean(φ) — that cancels to ≈ 0 and tells you nothing. bar & beeswarm do this for you SHAP is the only method where local values aggregate into a valid global explanation. That's the whole selling point.

Foundations

SHAP has a modern API (shap.ExplainerExplanation object) and a legacy API (.shap_values() → raw arrays). Tutorials mix them freely, which is why the shapes never match. Learn the modern one.

01The Three-Line Workflowthis is the whole library
  • pip install shap
    import shap
    Current: 0.52.0. Works on scikit-learn 1.8, numpy 2, XGBoost, LightGBM, CatBoost, PyTorch, TF/Keras.
  • explainer = shap.Explainer(model, X_background)
    sv = explainer(X) # → Explanation
    shap.plots.waterfall(sv[0])
    The modern API. shap.Explainer auto-dispatches to Tree/Linear/Deep/Permutation based on the model. Calling it returns an Explanation that every plot accepts.
  • sv.values # φ — (n_rows, n_features)
    sv.base_values # E[f(X)] — the baseline
    sv.data # the input values
    sv.feature_names
    The Explanation is sliceable like numpy: sv[0] = one row, sv[:, "age"] = one feature, sv[..., 1] = one class.
  • sv = explainer.shap_values(X) # LEGACYavoid
    Returns bare arrays with no base_values attached — so plots.waterfall won't take them, and you must pass explainer.expected_value by hand. Most Stack Overflow answers use this. It's why nothing lines up.
  • shap.sample(X, 100) · shap.kmeans(X, 50)
    Summarise the background set. Necessary for Kernel/Deep — see card 05.
02The Additivity Identitythe one equation
Every SHAP value is a signed contribution measured from a baseline. Start at the average prediction, add each φ, arrive at this prediction. For trees and linear models this holds exactly.
  • # verified on a real RandomForestRegressor:
    base_values 223.60 # E[f(X)]
    rooms=6   +85.42
    age=2     +21.15
    dist_km=2.1 +27.48
    # ───────────────
    sum = 357.65 == model.predict(row)
    exact
    Not a metaphor — machine-precision equality. See the waterfall diagram below.
  • φᵢ = # the Shapley value: the average marginal
        # contribution of feature i across ALL
        # possible orderings of the features
    From cooperative game theory. The averaging over orderings is what makes SHAP fair — and what makes it expensive (O(2ⁿ) naively). TreeSHAP gets it to polynomial time.
  • # the four axioms it uniquely satisfies
    Local accuracy (the identity above) · Missingness (absent feature → φ=0) · Consistency (if a feature matters more in a new model, its φ can't go down). SHAP is the only attribution method satisfying all of them — that's the entire theoretical claim.
03Picking an Explainerexactness vs speed
ExplainerUse for · cost
Tree RF, XGBoost, LightGBM, CatBoost, sklearn trees.
Exact + polynomial time. Always use this if you can.
LinearLinear/logistic regression. Exact, trivial.
DeepTF/Keras/PyTorch nets. DeepLIFT-based approximation.
GradientNets, via expected gradients. More general than Deep.
PermutationModel-agnostic. The sensible default black-box choice — and what shap.Explainer auto-picks.
KernelModel-agnostic (weighted linear regression). Slow & approximate. Last resort.
PartitionHierarchical / correlated features. Powers text & image.
ExactBrute-force O(2ⁿ). Ground truth for <15 features.
Just use shap.Explainer(model, X). It picks Tree for a forest, Linear for a regression, Permutation for a black box. Name an explainer explicitly only to override that choice.

What the numbers actually are

Both figures below are real output from shap 0.52.0. The waterfall is the single most important picture in the library — read it once properly and every other plot follows.

waterfall — E[f(X)] + Σφ = f(x), exactly

A RandomForestRegressor on house prices. base_values = 223.60 is the model's answer knowing nothing. Each feature then pushes it. The arrow lands on the prediction with zero residual — that's the local-accuracy axiom, not an approximation.

row: rooms = 6 · age = 2 · dist_km = 2.1 223.60 E[f(X)] base_values knowing nothing +85.42 rooms = 6 big house +21.15 age = 2 nearly new +27.48 dist_km = 2.1 close to centre 223.60 + 85.42 + 21.15 + 27.48 357.65 == model.predict() to machine precision

✓ TreeExplainer — exact

Polynomial-time TreeSHAP. These are the true Shapley values, not an estimate. 0.005 s.

shap.TreeExplainer(model) roomsagedist_km 85.42 21.15 27.48 exact · 0.005 s · use this whenever you can

≈ KernelExplainer — approximate

Same model, same row. rooms comes out 93.67 instead of 85.42 — a ~10% error, and 5× slower. Model-agnostic is not free.

shap.KernelExplainer(f, background) roomsagedist_km 93.67 21.06 26.01 approximate · 5× slower · a fallback, not a default

The plots

Two families, one rule: local plots take one row (sv[0]), global plots take the whole matrix (sv). Passing the wrong shape is the #1 plotting error.

04Local — one prediction"why THIS row?"
  • shap.plots.waterfall(sv[0])
    The one to reach for. Ranked contributions from E[f(X)] up to f(x). Small features collapse into an "other" row — control with max_display.
  • shap.initjs()
    shap.plots.force(sv[0])
    The same information as a pushed-apart bar. Needs initjs() in a notebook or you get a blank cell. Pass matplotlib=True for a static image.
  • shap.plots.force(sv[:100]) # stacked
    100 force plots rotated and stacked — an interactive way to spot clusters of similar explanations.
  • shap.plots.decision(sv.base_values[0], sv.values[0], X.iloc[0])
    A cumulative path from base to prediction. Best for comparing several rows at once — divergent lines show where the model treats them differently.
  • shap.plots.waterfall(sv) # whole matrixerror
    Waterfall wants one row: sv[0]. Passing the full Explanation raises.
05Global — the whole dataset"what drives the model?"
Global importance is mean(|φ|), never mean(φ). Positive and negative contributions cancel — verified: mean(φ) came out ≈ 0 while mean(|φ|) correctly ranked the features.
  • shap.plots.beeswarm(sv)
    The signature SHAP plot — and the densest. One row per feature, one dot per sample. x = the SHAP value (impact), colour = the feature's actual value (red high, blue low). See the reading guide below.
  • shap.plots.bar(sv)
    Plain mean(|φ|) ranking. Loses all direction and distribution — but it's the one non-specialists can read.
  • shap.plots.scatter(sv[:, "age"], color=sv)
    The dependence plot. How one feature's φ varies with its value — reveals non-linearity and thresholds. color=sv auto-picks the strongest interacting feature and colours by it.
  • shap.plots.heatmap(sv)
    Samples on x, features on y, φ as colour, with a f(x) line on top. Good for spotting subpopulations the model treats differently.
  • shap.plots.bar(sv.cohorts(2).abs.mean(0))
    Auto-split into cohorts and compare importances side by side. Quietly one of the most useful calls in the library.
  • shap.summary_plot(...) · shap.dependence_plot(...)legacy
    The old top-level API. Still works, now deprecated in places. summary_plotplots.beeswarm; dependence_plotplots.scatter.
06Text, Image & Interactionsbeyond tabular
  • expl = shap.Explainer(pipeline, shap.maskers.Text(tokenizer))
    shap.plots.text(expl(docs))
    Token-level highlighting for transformers and text pipelines. Works with a HuggingFace pipeline directly.
  • masker = shap.maskers.Image("inpaint_telea", X[0].shape)
    shap.plots.image(shap.Explainer(model, masker)(X[:2]))
    Pixel/superpixel attributions. The masker defines what "removing" a feature means — for images, that's inpainting or blurring.
  • shap.maskers.Independent · Partition · Text · Image · Impute
    The masker is the background model. Independent = interventional (breaks correlations); Partition = respects a feature hierarchy.
  • iv = shap.TreeExplainer(model).shap_interaction_values(X)
    # shape: (n, features, features)
    Pairwise interactions. The diagonal is the main effect, off-diagonal the interaction, and each row still sums to the prediction. Expensive — O(features²), tree models only.
  • shap.GPUTreeExplainer(model)
    CUDA TreeSHAP. Worth it on large datasets or when computing interaction values.

Reading the beeswarm · and the trap that voids it

The beeswarm packs three variables into one chart, which is why people misread it. And mean(φ) vs mean(|φ|) is the difference between a correct importance ranking and a chart of near-zeroes.

beeswarm — three variables, one chart

Row = feature (ranked by mean|φ|). x = that sample's SHAP value. Colour = the feature's actual value. A red cloud on the right means "high values push the prediction up."

φ = 0 rooms dist_km age tight cluster → low impact ← pushes prediction DOWN pushes prediction UP → low value high value

✗ mean(φ) is not importance

Real numbers from the same model. Positive and negative contributions cancel, so mean(φ) collapses to near-zero and ranks rooms — the dominant feature — as trivial.

mean(φ) — WRONG roomsagedist_km 0.251 −0.032 0.073 all ≈ 0 · ranking is noise mean(|φ|) — CORRECT roomsagedist 49.38 11.66 14.36 rooms dominates — correct

The three silent traps

None of these raise an error. The plots render beautifully. The numbers just quietly mean something other than what you think — which is the worst possible failure mode for an audit tool.

07Trap 1 — Output Spacelog-odds ≠ probability
For XGBoost / LightGBM classifiers, SHAP values live in log-odds (margin) space. They do not sum to predict_proba. For sklearn RandomForest they do sum to probability. Same code, different units.
  • # VERIFIED — XGBClassifier
    base + Σφ = 4.1027
    predict(output_margin=True) = 4.1027 # ✓ match
    predict_proba[1] = 0.9837 # ✗ NOT this
    sigmoid(4.1027) = 0.9837 # ✓ now it matches
    units
    A φ of +2.0 is 2 log-odds, not 200 percentage points. Reporting it as "probability" to a stakeholder is simply wrong.
  • shap.TreeExplainer(model, data=X_bg,
      model_output="probability")
    fix
    Forces probability space. Requires a background dataset, and it's slower — but now φ is in units a human can act on.
  • # sklearn RandomForestClassifier — VERIFIED
    base 0.5166 + Σφ 0.4435 = 0.9600 == predict_proba[1]
    Already in probability space, because sklearn's RF averages leaf probabilities. Don't assume — check which space you're in.
08Trap 2 — The Background SetE[f(X)] is not a constant
"Contribution" is always relative to a baseline, and the baseline is computed from the background data you pass. Change the background and every SHAP value changes — legitimately.
  • # VERIFIED — same model, same rows
    feature_perturbation="tree_path_dependent"
      → E[f(X)] = 223.60 # from tree node weights

    feature_perturbation="interventional", data=bg
      → E[f(X)] = 227.38 # == mean(predict(bg))
    shifts
    Two different baselines, two different sets of φ. Neither is wrong — but you must know which you reported, and keep it fixed across a comparison.
  • bg = shap.sample(X_train, 100, random_state=0)
    bg = shap.kmeans(X_train, 50) # weighted centroids
    Kernel/Deep scale with background size — passing all 100k rows will hang. Summarise to 50–200. SHAP itself warns and silently subsamples to 100 if you don't.
  • # interventional vs conditional
    Interventional (default with data=) breaks feature correlations — it may evaluate the model on impossible rows (a 1-room mansion). tree_path_dependent respects the training distribution but is less "causal". A real, unresolved tension — not a bug.
09Trap 3 — Shapesbinary classification is 3-D
The single most-Googled SHAP error. For a binary classifier the values are 3-dimensional — and the shape has changed across versions, which is why every Stack Overflow answer contradicts the next.
  • # VERIFIED — RandomForestClassifier, shap 0.52
    sv.values.shape    # (400, 3, 2) ← 3-D!
    sv.base_values.shape # (400, 2)
    # last axis = CLASS
    3-D
    Older versions returned a list of two 2-D arrays. Newer ones return one 3-D array. Code written against either breaks on the other.
  • shap.plots.beeswarm(sv[..., 1]) # positive class
    shap.plots.waterfall(sv[0, :, 1])
    Slice the class axis. The Explanation object slices like numpy and carries base_values along with it — which is exactly why the modern API is worth using.
  • # always check before plotting
    print(sv.shape, sv.base_values.shape)
    Two seconds that saves an hour. Binary → 3-D. Multiclass → 3-D with k on the last axis. Regression → 2-D.

Decision map

Model type picks the explainer. Question type picks the plot. Everything else is checking which units you're in.

flowchart LR
  A["I need to explain
a model"] --> B{"Model type?"} B -->|"tree / RF / XGB /
LGBM / CatBoost"| C["TreeExplainer
EXACT · fast"] B -->|"linear / logistic"| D["LinearExplainer
EXACT"] B -->|"neural net"| E["Deep / Gradient
Explainer"] B -->|"anything else"| F["Permutation
(Kernel = last resort)"] B -->|"text / image"| G["Explainer + masker
Text / Image"] C --> H{"Classifier?"} H -->|yes| H1["CHECK UNITS:
XGB → log-odds
sklearn RF → probability"] H -->|no| I H1 --> H2["sv shape is 3-D!
slice sv[..., 1]"] H2 --> I D --> I E --> I F --> I G --> I I["Explanation object
f(x) = E[f(X)] + Σφ"] --> J{"Which question?"} J -->|"why THIS row?"| K["plots.waterfall(sv[0])
plots.force · plots.decision"] J -->|"what drives
the model?"| L["plots.beeswarm(sv)
plots.bar · plots.scatter"] J -->|"how does ONE
feature behave?"| M["plots.scatter(sv[:, 'age'])"] K --> Z(["Ship it"]) L --> Z M --> Z classDef exact fill:#ecf7f2,stroke:#059669,color:#065f46,stroke-width:2px; classDef approx fill:#fdf6ec,stroke:#d97706,color:#92400e,stroke-width:2px; classDef gate fill:#ecf6fa,stroke:#0891b2,color:#155e75,stroke-width:2px; classDef warn fill:#fdf0ef,stroke:#dc2626,color:#991b1b,stroke-width:2px; classDef glob fill:#eef0fd,stroke:#4f46e5,color:#3730a3,stroke-width:2px; classDef ship fill:#1a1d24,stroke:#1a1d24,color:#fbbf24,stroke-width:2px; class B,H,J gate; class C,D,I exact; class E,F,G approx; class H1,H2 warn; class K,L,M glob; class Z ship;

Perspective

SHAP is the most-cited explainability tool in ML — and also the most over-applied. Two things worth saying out loud before you reach for it reflexively.

10SHAP vs eli5 vs permutationpick the right tool
WantUse
Fast global feature ranking, nothing moresklearn.inspection.permutation_importance. Free, robust, honest. SHAP is overkill.
Debug a text model quicklyeli5 — its word highlighting for linear + vectorizer is faster and clearer.
Per-row attributions that sum to the predictionSHAP. This is the unique capability.
An auditable, axiomatic answerSHAP. The consistency guarantee is the reason regulators accept it.
Global ranking that aggregates local truthSHAPmean(|φ|). Impurity importance can't do this.
The honest line: if you only need "which features matter?", permutation importance answers it in one line with fewer assumptions. SHAP earns its cost when you need the per-row decomposition — a loan denial, a flagged transaction, a diagnosis.
11What SHAP Does NOT Tell Youthe limits
  • # SHAP is not causalimportant
    φ says "the model used this feature this way"not "changing this feature would change the outcome". A model that learned a proxy will produce confident, correct-looking SHAP values for the proxy.
  • # correlated features split credit arbitrarily
    Two near-duplicate features share the attribution between them. Neither looks important alone. Cluster correlated features and interpret them as a group — or use Partition masking, which respects a hierarchy.
  • # interventional masking evaluates impossible rows
    Breaking correlations means asking the model about a 1-room mansion. The model answers, and that answer enters your explanation. This is a genuine, acknowledged tension in the method.
  • # a good explanation of a bad model is still bad
    SHAP faithfully describes whatever the model learned — including its biases and its leakage. It's a mirror, not a validator. Validate the model first; explain it second.
12Performancewhen it hangs
  • shap.TreeExplainer(model) # no background
    Fastest path — tree_path_dependent needs no background data at all. Use it when you don't need interventional semantics.
  • shap.KernelExplainer(f, X_train) # all 100k rowshangs
    Kernel cost scales with background size × rows explained × nsamples. Always shap.sample(X, 100).
  • explainer(X[:500]) # beeswarm doesn't need 1M rows
    A few hundred samples give a beeswarm indistinguishable from the full set. Subsample the rows you explain, not just the background.
  • shap.GPUTreeExplainer · PermutationExplainer(…, max_evals=500)
    GPU TreeSHAP for scale; cap max_evals to trade accuracy for time on Permutation.

Worth memorizing

f(x) = E[f(X)] + Σφᵢthe whole library · exact for Tree & Linear, approximate for Kernel
3-line workflowExplainer(model, X)explainer(X)plots.waterfall(sv[0])
use the modern APIexplainer(X) returns an Explanation with base_values attached · .shap_values() doesn't
TreeExplainer is EXACTand fast · Kernel is approximate and slow — verified 85.42 vs 93.67 on the same row
global = mean(|φ|)never mean(φ) — it cancels to ≈0 and ranks your top feature as trivial
binary clf → 3-D values(n, features, 2) · slice sv[..., 1] for the positive class
XGB/LGBM → LOG-ODDSφ does NOT sum to predict_proba · sigmoid(Σ) does · sklearn RF gives probability
E[f(X)] movesit's computed from the BACKGROUND set · change it and every φ changes
shap.sample(X, 100)always summarise the background for Kernel/Deep, or it hangs
waterfall = one rowsv[0] · beeswarm/bar = the whole matrix sv
beeswarm: x=φ, colour=valuered cloud on the right = "high values push the prediction up"
scatter = dependenceplots.scatter(sv[:, "age"], color=sv) reveals non-linearity and interactions
force needs initjs()or you get a blank cell in the notebook
SHAP is not causalit says how the MODEL used a feature, not what would happen if you changed it
correlated features split creditnear-duplicates share attribution — interpret them as a group
just need a ranking?permutation_importance is one line and fewer assumptions · SHAP is for per-row decomposition