skills/shap/references/troubleshooting.md
Diagnose SHAP problems from environment and semantics outward. Do not start by disabling checks or changing tolerances.
Run without printing environment variables:
import importlib.metadata
import platform
from pathlib import Path
import numpy as np
import shap
print("Python:", platform.python_version())
print("SHAP:", shap.__version__)
print("NumPy:", np.__version__)
print("SHAP import path:", Path(shap.__file__).resolve())
for package in [
"pandas",
"scikit-learn",
"xgboost",
"lightgbm",
"catboost",
"tensorflow",
"keras",
"torch",
]:
try:
print(f"{package}:", importlib.metadata.version(package))
except importlib.metadata.PackageNotFoundError:
pass
For an explanation:
print("values:", np.shape(explanation.values))
print("base_values:", np.shape(explanation.base_values))
print("data:", np.shape(explanation.data))
print("feature_names:", explanation.feature_names)
print("output_names:", explanation.output_names)
print("model input:", X_eval.shape)
Do not include sensitive row values in a public bug report.
SHAP 0.52.0 requires Python 3.12+ and NumPy 2+.
uv run python --version
uv pip show shap
uv pip tree
If Python 3.11 is required, pin SHAP 0.51.0. For Python 3.9/3.10, 0.49.1 is the final compatible line; upgrading Python is preferable.
Never name a local script or directory:
shap.py;numpy.py;pandas.py;xgboost.py;lightgbm.py;joblib.py;sklearn.py.Local files can shadow installed packages. Verify:
from pathlib import Path
import shap
print(Path(shap.__file__).resolve())
The path should point into the intended environment's installed package, not the project working directory.
After renaming a shadowing module, remove only its corresponding local __pycache__ entries and restart Python.
Since 0.45, one-input multi-output explainers return an array with outputs last:
class_exp = explanation[..., class_index]
Do not use:
class_exp = explanation[class_index] # selects a sample
Select an output:
print(explanation.values.shape)
single_output = explanation[..., output_index]
assert single_output.values.ndim == 2
shap.plots.beeswarm(single_output)
single_output = explanation[..., output_index]
shap.plots.waterfall(single_output[row_index])
Check:
assert explanation.values.shape[1] == X_eval.shape[1]
assert len(explanation.feature_names) == X_eval.shape[1]
Likely causes:
An additivity failure means the selected SHAP values do not reconstruct the selected output within expected tolerance.
Explicit check for one tabular output:
values = np.asarray(explanation.values)
base = np.asarray(explanation.base_values)
reconstructed = base + values.sum(axis=1)
error = reconstructed - expected_output
print("max abs error:", np.max(np.abs(error)))
print("mean abs error:", np.mean(np.abs(error)))
For an XGBoost classifier, default TreeExplainer output may be a raw margin:
raw_margin = model.predict(dmatrix, output_margin=True)
Comparing raw-margin SHAP reconstruction to predict_proba will fail conceptually even if both implementations are correct.
For probability explanations, configure:
explainer = shap.TreeExplainer(
model,
data=background,
feature_perturbation="interventional",
model_output="probability",
)
check_additivity=False is appropriate only after:
It is not a fix for huge, non-finite, or wrong-unit values.
Support differs across XGBoost, LightGBM, CatBoost, and scikit-learn categorical configurations. SHAP 0.49 added categorical-split support in the C++ library, and 0.52 tightened unsupported categorical handling in GPU/sklearn paths.
If an error mentions categorical splits:
TreeExplainer;Do not convert categories to arbitrary integer codes merely to silence the explainer; that can change model semantics.
SHAP 0.52 fixed a TreeExplainer crash with pandas nullable dtypes. If constrained to an older release, convert only after checking that missing values and category semantics remain identical:
print(X_eval.dtypes)
Prefer upgrading within the project's compatibility window.
Verify:
Small or inconsistent path-dependent backgrounds/model counts can cause failures in older versions. SHAP 0.51 included a path-dependent NaN fix. Reproduce on the latest compatible patch before working around it.
A model-agnostic masker may call the model with an array. If the pipeline requires column names:
columns = raw_background.columns.tolist()
def model_fn(array):
frame = pd.DataFrame(array, columns=columns)
return pipeline.predict_proba(frame)
This is safe only when all columns can be reconstructed without losing categorical dtypes. Prefer a masker/callable path that preserves DataFrames.
names = preprocessor.get_feature_names_out()
X_eval_t = preprocessor.transform(X_eval)
assert X_eval_t.shape[1] == len(names)
Inspect the transformer version and fitted categories. Do not borrow names from a separately fitted preprocessor.
Model-agnostic explainers can become expensive or densify data. Test memory on a small batch. Keep sparse input only if the selected explainer and model callable support it end to end.
Likely causes:
PyTorch:
First switch the PyTorch nn.Module to evaluation mode with its standard eval method. This is a framework state change, not Python's built-in code-evaluation function.
with torch.inference_mode():
output = model(test_batch)
print(output.shape)
If gradients are needed by the explainer, do not wrap the explainer call itself in torch.inference_mode().
Try:
GradientExplainer;PartitionExplainer with a model callable.Document changed semantics when switching explainers.
Ensure model, background, and explained tensors use the same device and compatible dtype:
device = next(model.parameters()).device
background = background.to(device)
to_explain = to_explain.to(device)
Move returned values to CPU only after explanation.
ranked_outputs=k returns values and indexes:
values, indexes = explainer.shap_values(
X,
ranked_outputs=3,
)
Use indexes to label each row. Do not assume rank one is the same class across rows.
Use the exact tokenizer revision paired with the model. Check truncation, padding, special tokens, and mask token.
The model callable must apply the same resize, channel order, range, and normalization used in validation:
def model_fn(images):
return model(preprocess(images.copy()))
This may be expected. Blur, inpainting, constants, and token masking define different hidden-feature operations. Report a sensitivity analysis instead of selecting one silently.
For scripts:
ax = shap.plots.beeswarm(explanation, show=False)
ax.figure.savefig("beeswarm.png", dpi=200, bbox_inches="tight")
plt.close(ax.figure)
For JavaScript force plots in notebooks:
shap.plots.initjs()
shap.plots.force(local_exp)
Use matplotlib=True for a static force plot.
Capture and close each figure before creating another:
ax = shap.plots.bar(explanation, show=False)
figure = ax.figure
figure.savefig("bar.png", bbox_inches="tight")
plt.close(figure)
Check explanation.data and display_data. Values-only objects cannot color by original feature values.
Estimate cost before a large run:
evaluation rows
× model evaluations per row
× background rows per masked evaluation
× model latency
Reduce cost in this order:
Do not subset only after viewing which explanations look interesting.
Model and explainer pickle/joblib/cloudpickle artifacts can execute code when loaded. Only load artifacts produced by a trusted pipeline and stored with integrity controls.
For long-lived systems, prefer:
Do not accept an uploaded explainer file from an untrusted user.
A useful issue report contains:
Remove credentials, environment variables, private paths, and sensitive data.