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

313 lines
9.9 KiB
Python

import platform
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pytest
import sklearn
import sklearn.ensemble
from numpy.testing import assert_array_equal
import shap
@pytest.mark.mpl_image_compare
def test_summary():
"""Just make sure the summary_plot function doesn't crash."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_with_data():
"""Just make sure the summary_plot function doesn't crash with data."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), np.random.randn(20, 5), show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_multi_class():
"""Check a multiclass run."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot([np.random.randn(20, 5) for i in range(3)], np.random.randn(20, 5), show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_multi_class_legend_decimals():
"""Check the functionality of printing the legend in the plot of a multiclass run when
all the SHAP values are smaller than 1.
"""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(
[np.random.randn(20, 5) for i in range(3)], np.random.randn(20, 5), show=False, show_values_in_legend=True
)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_multi_class_legend():
"""Check the functionality of printing the legend in the plot of a multiclass run when
SHAP values are bigger than 1.
"""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(
[(2 + np.random.randn(20, 5)) for i in range(3)],
2 + np.random.randn(20, 5),
show=False,
show_values_in_legend=True,
)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_bar_with_data():
"""Check a bar chart."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), np.random.randn(20, 5), plot_type="bar", show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_dot_with_data():
"""Check a dot chart."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), np.random.randn(20, 5), plot_type="dot", show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.skipif(platform.system() in ["Windows", "Darwin"], reason="Images not matching on MacOS and Windows.")
@pytest.mark.mpl_image_compare
def test_summary_compact_dot_with_data():
"""Check a bar chart."""
n_samples = 100
n_features = 5
np.random.seed(0) # for reproducibility
X = np.random.randn(n_samples, n_features)
feature_names = [f"Feature {i + 1}" for i in range(n_features)]
shap_values = np.random.randn(n_samples, n_features, n_features)
fig = plt.figure()
shap.summary_plot(shap_values, X, feature_names=feature_names, plot_type="compact_dot", show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_violin_with_data():
"""Check a violin chart."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), np.random.randn(20, 5), plot_type="violin", show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_layered_violin_with_data():
"""Check a layered violin chart."""
rs = np.random.RandomState(0)
fig = plt.figure()
shap_values = rs.randn(200, 5)
feats = rs.randn(200, 5)
shap.summary_plot(
shap_values,
feats,
plot_type="layered_violin",
show=False,
)
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare(tolerance=6)
def test_summary_with_log_scale():
"""Check a with a log scale."""
np.random.seed(0)
fig = plt.figure()
shap.summary_plot(np.random.randn(20, 5), use_log_scale=True, show=False)
fig.set_layout_engine("tight")
return fig
@pytest.mark.parametrize("background", [True, False])
def test_summary_binary_multiclass(background):
# See GH #2893
lightgbm = pytest.importorskip("lightgbm")
num_examples, num_features = 100, 3
rs = np.random.RandomState(0)
X = rs.normal(size=[num_examples, num_features])
y = ((2 * X[:, 0] + X[:, 1]) > 0).astype(int)
train_data = lightgbm.Dataset(X, label=y)
model = lightgbm.train(dict(objective="multiclass", num_classes=2), train_data)
data = X if background else None
explainer = shap.TreeExplainer(model, data=data)
shap_values = explainer.shap_values(X)
shap.summary_plot(shap_values, X, feature_names=["foo", "bar", "baz"], show=False)
@pytest.mark.mpl_image_compare
def test_summary_multiclass_explanation():
"""Check summary plot with multiclass model with explanation as input."""
xgboost = pytest.importorskip("xgboost")
n_samples = 100
n_features = 5
n_classes = 3
np.random.seed(0) # for reproducibility
X = np.random.randn(n_samples, n_features)
y = np.random.randint(0, n_classes, n_samples)
feature_names = [f"Feature {i + 1}" for i in range(n_features)]
model = xgboost.XGBClassifier(n_estimators=10, random_state=0, tree_method="exact", base_score=0.5).fit(X, y)
explainer = shap.TreeExplainer(model)
shap_values = explainer(X)
fig = plt.figure()
shap.summary_plot(shap_values, X, feature_names=feature_names, show=False)
fig = plt.gcf()
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_bar_multiclass():
# GH 3984
X, y = shap.datasets.iris()
model = sklearn.ensemble.RandomForestClassifier(max_depth=2, random_state=0)
model.fit(X, y)
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X)
shap.summary_plot(
shap_values, X, plot_type="bar", class_names=[0, 1, 2], feature_names=np.array(X.columns), show=False
)
fig = plt.gcf()
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_violin_regression():
# GH 4030
X, y = sklearn.datasets.make_regression(n_features=4, n_informative=2, random_state=0, shuffle=False)
regr = sklearn.ensemble.RandomForestRegressor(max_depth=2, random_state=0)
_ = regr.fit(X, y)
explainer = shap.TreeExplainer(regr)
shap_values = explainer.shap_values(X, y=y)
shap.summary_plot(shap_values, features=X, plot_type="violin", show=False)
fig = plt.gcf()
fig.set_layout_engine("tight")
return fig
@pytest.mark.mpl_image_compare
def test_summary_plot_interaction():
"""Checks the summary plot with interaction effects (GH #4081)."""
n_samples = 100
n_features = 5
np.random.seed(0) # for reproducibility
shap_values = np.random.randn(n_samples, n_features, n_features)
feature_names = [f"Feature {i + 1}" for i in range(n_features)]
X = pd.DataFrame(np.random.randn(n_samples, n_features), columns=feature_names)
shap.summary_plot(shap_values, X)
fig = plt.gcf()
fig.set_layout_engine("tight")
return fig
@pytest.mark.xfail(
reason="Currently not supported since this needs an overhaul of the summary plot code. See #3920 for more information."
)
@pytest.mark.mpl_image_compare
def test_summary_plot_twice():
# GH 3920
xgboost = pytest.importorskip("xgboost")
X, y = shap.datasets.california()
model = xgboost.XGBRegressor().fit(X, y)
explainer = shap.TreeExplainer(model)
shapValues = explainer.shap_values(X)
shap.summary_plot(shapValues, X, show=False)
shap.summary_plot(shapValues, X, show=False)
fig = plt.gcf()
fig.set_layout_engine("tight")
return fig
def test_summary_plot_wrong_features_shape():
"""Checks that ValueError is raised if the features data matrix
has an incompatible shape with the shap_values matrix.
"""
rs = np.random.RandomState(42)
emsg = (
r"The shape of the shap_values matrix does not match the shape of the provided data matrix\. "
r"Perhaps the extra column in the shap_values matrix is the constant offset\? Of so just pass shap_values\[:,:-1\]\."
)
with pytest.raises(ValueError, match=emsg):
shap.summary_plot(rs.randn(20, 5), rs.randn(20, 4), show=False)
emsg = "The shape of the shap_values matrix does not match the shape of the provided data matrix."
with pytest.raises(AssertionError, match=emsg):
shap.summary_plot(rs.randn(20, 5), rs.randn(20, 1), show=False)
@pytest.mark.mpl_image_compare
def test_summary_plot(explainer):
"""Check a beeswarm chart renders correctly with shap_values as an Explanation
object (default settings).
"""
fig = plt.figure()
shap_values = explainer(explainer.data)
shap.plots.beeswarm(shap_values, show=False)
plt.tight_layout()
return fig
@pytest.mark.parametrize(
"rng",
[
np.random.default_rng(167089660),
17,
np.random.SeedSequence(entropy=60767),
],
)
def test_summary_plot_seed_insulated(explainer, rng):
# ensure that it is possible for downstream
# projects to avoid mutating global NumPy
# random state
# see i.e., https://scientific-python.org/specs/spec-0007/
shap_values = explainer(explainer.data)
state_before = np.random.get_state()[1] # type: ignore[index]
shap.summary_plot(shap_values, show=False, rng=rng)
state_after = np.random.get_state()[1] # type: ignore[index]
assert_array_equal(state_after, state_before)
def test_summary_plot_warning(explainer):
# enforce FutureWarning for usage of global random
# state as we prepare for SPEC 7 adoption
shap_values = explainer(explainer.data)
with pytest.warns(FutureWarning, match="NumPy global RNG"):
shap.summary_plot(shap_values, show=False)