Use shap.TreeExplainer(model) on a boosted or random-forest land-cover classifier, normalise the output to a (n_samples, n_features, n_classes) array, assert that the attributions reconstruct the model margin, and keep the sample identifiers so every row can be joined back to its geometry. The multiclass case is where most implementations quietly go wrong, because the output shape has changed across SHAP releases and an incorrect axis assumption produces attributions that look plausible and describe the wrong class.
This page is the multiclass workflow. For the surrounding methodology — block permutation, collinearity, mapping — see Model Explainability for Spatial Predictions.
Why This Fails in Geospatial ML Pipelines
The output-shape ambiguity is the first trap and it is entirely avoidable. Different SHAP versions have returned a list of per-class arrays and a stacked three-dimensional array; XGBoost, LightGBM and scikit-learn wrappers have each behaved slightly differently. Code written against one combination indexes the wrong axis under another, and because the resulting matrix still has the right shape and plausible magnitudes, nothing complains. The output is a confident explanation of a class the analyst did not intend.
The second trap is losing the join. SHAP takes a matrix and returns a matrix; geometry is not carried through. If the feature frame was filtered, re-sorted or de-duplicated between the model fit and the explanation, row i of the attribution matrix no longer corresponds to row i of the GeoDataFrame — and the resulting map is a spatially scrambled but visually smooth surface, which is worse than no map at all.
Third, memory. Attributions are n_samples × n_features × n_classes in float64. A million pixels, twenty features and eight classes is 1.3 GB before any copies. Teams discover this at the end of a long run, so the sensible move is to explain a stratified sample from the start rather than to retrofit sampling after the first MemoryError.
Core Principles
- Normalise the output shape immediately and assert it.
- Choose the reduction deliberately — fixed class or predicted class — and name the output for it.
- Assert reconstruction. Base value plus attributions must equal the raw margin.
- Carry identifiers, not just arrays. Explain a frame that keeps its index.
- Stratify the sample by predicted class and spatial block.
- Version the attribution matrix as an artifact next to the model.
Production-Ready Code
from __future__ import annotations
import logging
import numpy as np
import pandas as pd
import shap
logger = logging.getLogger(__name__)
def explain_multiclass(model, x: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
"""Return SHAP values normalised to (n_samples, n_features, n_classes).
Handles both the list-of-arrays and stacked-array conventions that different
SHAP releases use, so downstream code can index one known layout.
"""
explainer = shap.TreeExplainer(model)
raw = explainer.shap_values(x)
if isinstance(raw, list): # older convention
sv = np.stack(raw, axis=-1)
else:
sv = np.asarray(raw)
if sv.ndim == 2: # binary model
sv = np.stack([-sv, sv], axis=-1)
n_samples, n_features = x.shape
if sv.shape[:2] != (n_samples, n_features):
raise ValueError(f"unexpected SHAP shape {sv.shape} for input {x.shape}")
base = np.atleast_1d(np.asarray(explainer.expected_value))
if base.shape[0] != sv.shape[2]:
base = np.repeat(base, sv.shape[2])[: sv.shape[2]]
logger.info("SHAP cube %s, %d class base values", sv.shape, base.shape[0])
return sv, base
def assert_reconstructs(model, x: pd.DataFrame, sv: np.ndarray, base: np.ndarray,
atol: float = 1e-2) -> None:
"""Base value plus contributions must equal the model's raw margin, per class."""
margins = model.predict(x, output_margin=True)
margins = np.atleast_2d(margins)
if margins.shape[0] == 1 and sv.shape[0] != 1:
margins = margins.T
recon = base[None, :] + sv.sum(axis=1)
if not np.allclose(recon, margins, atol=atol):
worst = float(np.max(np.abs(recon - margins)))
raise ValueError(
f"SHAP values do not reconstruct the margin (max error {worst:.4f}). "
"The explainer and the model likely disagree on feature order.")
def attributions_for_prediction(sv: np.ndarray, predicted: np.ndarray) -> np.ndarray:
"""Gather each sample's own predicted-class layer -> (n_samples, n_features)."""
idx = np.asarray(predicted, dtype="int64")
if idx.shape[0] != sv.shape[0]:
raise ValueError("predicted class vector does not match the sample count")
return np.take_along_axis(sv, idx[:, None, None], axis=2)[:, :, 0]
def stratified_sample(frame: pd.DataFrame, predicted: np.ndarray, blocks: np.ndarray,
n: int = 20_000, seed: int = 0) -> np.ndarray:
"""Row positions stratified by predicted class and spatial block."""
rng = np.random.default_rng(seed)
key = pd.Series([f"{c}_{b}" for c, b in zip(predicted, blocks)])
per_group = max(1, n // key.nunique())
picks = []
for _, positions in key.groupby(key).groups.items():
pos = np.asarray(positions)
picks.append(rng.choice(pos, size=min(per_group, len(pos)), replace=False))
return np.concatenate(picks)Step-by-Step Walkthrough
Step 1 — Take a stratified sample
import numpy as np
predicted = model.predict(X_test)
blocks = block_id(coords_test, block_m=5000.0)
rows = stratified_sample(X_test, predicted, blocks, n=20_000, seed=0)
X_sample = X_test.iloc[rows]
pred_sample = predicted[rows]
print(f"explaining {len(rows):,} rows over {len(np.unique(blocks[rows]))} blocks")Step 2 — Explain and verify in the same breath
sv, base = explain_multiclass(model, X_sample)
assert_reconstructs(model, X_sample, sv, base)
print("cube:", sv.shape, "base values:", np.round(base, 3))If assert_reconstructs raises, stop. Every downstream map would be wrong, and the usual cause is a feature-order mismatch between the frame passed to fit and the one passed to the explainer.
Step 3 — Reduce to a per-sample matrix
attrib = attributions_for_prediction(sv, pred_sample) # (n, n_features)
CLASSES = ["background", "cropland", "forest", "water", "built"]
per_class = {
name: np.abs(sv[:, :, k]).mean(axis=0)
for k, name in enumerate(CLASSES)
}
import pandas as pd
print(pd.DataFrame(per_class, index=X_sample.columns).round(4))The per-class table is often the most informative single output: it shows that elevation drives forest, that shortwave infrared drives water, and that the built class rests on a feature nobody expected — which is usually where the modelling insight is.
Step 4 — Join back to geometry and write a mappable artifact
import geopandas as gpd
explained = gpd.GeoDataFrame(
pd.DataFrame(attrib, columns=[f"shap_{c}" for c in X_sample.columns],
index=X_sample.index),
geometry=samples.loc[X_sample.index, "geometry"],
crs=samples.crs,
)
explained["predicted"] = [CLASSES[i] for i in pred_sample]
explained.to_parquet("artifacts/shap_attributions.parquet")
assert len(explained) == len(X_sample)Using the frame’s own index for the join — rather than positional alignment — is what makes this safe against any upstream filtering.
Verification
import numpy as np
import pandas as pd
import pytest
def test_output_is_normalised_to_a_cube():
sv, base = explain_multiclass(model, X_sample.head(50))
assert sv.ndim == 3
assert sv.shape[:2] == (50, X_sample.shape[1])
assert base.shape[0] == sv.shape[2]
def test_reconstruction_holds():
sv, base = explain_multiclass(model, X_sample.head(200))
assert_reconstructs(model, X_sample.head(200), sv, base)
def test_feature_order_mismatch_is_detected():
shuffled = X_sample.head(100)[list(reversed(X_sample.columns))]
sv, base = explain_multiclass(model, shuffled)
with pytest.raises(ValueError, match="reconstruct"):
assert_reconstructs(model, shuffled, sv, base)
def test_gather_picks_the_predicted_layer():
sv = np.arange(2 * 3 * 4, dtype="float64").reshape(2, 3, 4)
predicted = np.array([1, 3])
out = attributions_for_prediction(sv, predicted)
assert np.array_equal(out[0], sv[0, :, 1])
assert np.array_equal(out[1], sv[1, :, 3])
def test_join_survives_a_filtered_frame():
"""Filtering the frame before explaining must not scramble the geometry join."""
sub = X_test[X_test["elevation"] > 500]
sv, base = explain_multiclass(model, sub.head(100))
attrib = attributions_for_prediction(sv, model.predict(sub.head(100)))
frame = pd.DataFrame(attrib, index=sub.head(100).index)
assert frame.index.equals(sub.head(100).index)The feature-order test is worth writing once and keeping forever: it is the failure that produces the most convincing wrong answers.
FAQ
What shape does TreeExplainer return for a multiclass model?
It varies by release and model wrapper — a stacked (samples, features, classes) array in recent versions, a list of per-class arrays in older ones. Normalise to one layout immediately, as explain_multiclass does, and assert the shape rather than indexing on assumption.
Which class should I explain?
The predicted class per sample, if the question is “why did the model choose this”. Explaining a fixed class answers “what would push each pixel towards that class” — a legitimate but different question. Name the output after whichever you chose.
How large a sample do I need for stable global rankings?
A few tens of thousands of rows, stratified by predicted class and spatial block. Beyond that the ranking barely moves while memory doubles, because the cube is samples × features × classes in float64.
Related
- Model Explainability for Spatial Predictions — block permutation, collinearity and mapping
- Mapping Permutation Importance Across Space — regional importance surfaces
- Gradient Boosting for Raster Data — the model being explained
- Handling Class Imbalance in Land Cover Classification — why per-class attributions matter more than a global ranking
Part of: Model Explainability for Spatial Predictions Part of: Training Geospatial Predictive Models in Python