Files
2026-07-13 13:22:52 +08:00

153 lines
4.7 KiB
Python

from contextlib import nullcontext as does_not_raise
import matplotlib.pyplot as plt
import numpy as np
import pytest
from pytest import param
import shap
@pytest.fixture
def data_explainer_shap_values():
RandomForestRegressor = pytest.importorskip("sklearn.ensemble").RandomForestRegressor
# train model
X, y = shap.datasets.california(n_points=500)
model = RandomForestRegressor(n_estimators=100, random_state=0)
model.fit(X, y)
# explain the model's predictions using SHAP values
explainer = shap.TreeExplainer(model)
return X, explainer, explainer.shap_values(X)
@pytest.mark.parametrize(
"cmap, exp_ctx",
[
# Valid cmaps
param("coolwarm", does_not_raise(), id="valid-str"),
param(["#000000", "#ffffff"], does_not_raise(), id="valid-list[str]"),
# Invalid cmaps
param(
777,
pytest.raises(TypeError, match="Plot color map must be string or list!"),
id="invalid-dtype1",
),
param(
[],
pytest.raises(ValueError, match="Color map must be at least two colors"),
id="invalid-insufficient-colors1",
),
param(
["#8834BB"],
pytest.raises(ValueError, match="Color map must be at least two colors"),
id="invalid-insufficient-colors2",
),
param(
["#883488", "#Gg8888"],
pytest.raises(ValueError, match=r"Invalid color .+ found in cmap"),
id="invalid-hexcolor-in-list1",
),
param(
["#883488", "#1111119"],
pytest.raises(ValueError, match=r"Invalid color .+ found in cmap"),
id="invalid-hexcolor-in-list2",
),
],
)
def test_verify_valid_cmap(cmap, exp_ctx):
from shap.plots._force import verify_valid_cmap
with exp_ctx:
verify_valid_cmap(cmap)
def test_random_force_plot_mpl_with_data(data_explainer_shap_values):
"""Test if force plot with matplotlib works."""
X, explainer, shap_values = data_explainer_shap_values
# visualize the first prediction's explanation
shap.force_plot(explainer.expected_value, shap_values[0, :], X.iloc[0, :], matplotlib=True, show=False)
with pytest.raises(TypeError, match="force plot now requires the base value as the first parameter"):
shap.force_plot([1, 1], shap_values, X.iloc[0, :], show=False)
plt.close("all")
def test_random_force_plot_mpl_text_rotation_with_data(data_explainer_shap_values):
"""Test if force plot with matplotlib works when supplied with text_rotation."""
X, explainer, shap_values = data_explainer_shap_values
# visualize the first prediction's explanation
shap.force_plot(
explainer.expected_value, shap_values[0, :], X.iloc[0, :], matplotlib=True, text_rotation=30, show=False
)
plt.close("all")
@pytest.mark.mpl_image_compare(tolerance=3)
def test_force_plot_negative_sign():
np.random.seed(0)
base = 100
contribution = np.r_[-np.random.rand(5)]
names = [f"minus_{i}" for i in range(5)]
shap.force_plot(
base,
contribution,
names,
matplotlib=True,
show=False,
)
return plt.gcf()
@pytest.mark.mpl_image_compare(tolerance=3)
def test_force_plot_positive_sign():
np.random.seed(0)
base = 100
contribution = np.r_[np.random.rand(5)]
names = [f"plus_{i}" for i in range(5)]
shap.force_plot(
base,
contribution,
names,
matplotlib=True,
show=False,
)
return plt.gcf()
def test_flipud_reverses_clust_order():
"""Regression test for GH-4342: np.flipud(clustOrder) was a no-op."""
from shap.plots._force import AdditiveExplanation, AdditiveForceArrayVisualizer
from shap.utils._legacy import DenseData, IdentityLink, Instance, Model
feature_names = ["f0", "f1"]
base_value = 0.0
link = IdentityLink()
data = DenseData(np.zeros((1, 2)), feature_names)
model = Model(lambda x: x, ["f(x)"])
def _make_exp(effects):
effects = np.array(effects, dtype=float)
out_value = base_value + effects.sum()
instance = Instance(np.ones((1, len(feature_names))), np.zeros(len(feature_names)))
return AdditiveExplanation(base_value, out_value, effects, None, instance, link, model, data)
# Sample 0: low total (sum = 1.0)
# Sample 1: high total (sum = 10.0)
exp_low = _make_exp([0.5, 0.5])
exp_high = _make_exp([5.0, 5.0])
viz = AdditiveForceArrayVisualizer([exp_low, exp_high])
sim_low = viz.data["explanations"][0]["simIndex"]
sim_high = viz.data["explanations"][1]["simIndex"]
assert sim_high < sim_low, (
f"Higher-prediction sample should come first (lower simIndex), "
f"got simIndex_high={sim_high}, simIndex_low={sim_low}"
)