Files
shap--shap/tests/test_explanation.py
2026-07-13 13:22:52 +08:00

248 lines
7.2 KiB
Python

"""This file contains tests for the `shap._explanation` module."""
from textwrap import dedent
import numpy as np
import pandas as pd
import pytest
from pytest import param
from sklearn.datasets import load_wine
from sklearn.ensemble import RandomForestClassifier
import shap
from shap._explanation import OpHistoryItem
def test_explanation_repr():
exp = shap.Explanation(values=np.arange(5))
assert (
exp.__repr__()
== dedent(
"""
.values =
array([0, 1, 2, 3, 4])
"""
).strip()
)
exp = shap.Explanation(values=np.arange(5), base_values=0.5, data=np.ones(5))
assert (
exp.__repr__()
== dedent(
"""
.values =
array([0, 1, 2, 3, 4])
.base_values =
0.5
.data =
array([1., 1., 1., 1., 1.])
"""
).strip()
)
def test_explanation_hstack(random_seed):
"""Checks that `hstack` works as expected with two valid Explanation objects.
And that it returns an Explanation object.
"""
# generate 2 Explanation objects for stacking
rs = np.random.RandomState(random_seed)
base_vals = np.ones(20) * 0.123
exp1 = shap.Explanation(
values=rs.randn(20, 7),
base_values=base_vals,
)
exp2 = shap.Explanation(
values=rs.randn(20, 5),
base_values=base_vals,
)
new_exp = exp1.hstack(exp2)
assert isinstance(new_exp, shap.Explanation)
assert new_exp.values.shape == (20, 12)
def test_explanation_hstack_errors(random_seed):
"""Checks that `hstack` throws errors on invalid input."""
# generate 2 Explanation objects for stacking
rs = np.random.RandomState(random_seed)
base_vals = np.ones(20) * 0.123
base_exp = shap.Explanation(
values=rs.randn(20, 5),
base_values=base_vals,
)
with pytest.raises(
AssertionError,
match="Can't hstack explanations with different numbers of rows",
):
exp2 = shap.Explanation(
values=rs.randn(7, 5),
base_values=np.ones(7),
)
_ = base_exp.hstack(exp2)
with pytest.raises(
ValueError,
match="Can't hstack explanations with different base values",
):
exp2 = shap.Explanation(
values=rs.randn(20, 5),
base_values=np.ones(20) * 0.987,
)
_ = base_exp.hstack(exp2)
@pytest.mark.parametrize("N", [4, 5, 6])
def test_feature_names_slicing_for_square_arrays(random_seed, N):
"""Checks that feature names in Explanations are properly sliced with "square"
arrays (N==k).
For 2D arrays, there is an ambiguity in how to assign the feature names to the
slicer index. E.g. if feature_names is a list of 5 elements, and the shap_values is
a (5,5) array, it's ambiguous whether the axis=0 or axis=1 refers to the "feature
columns".
This test ensures that we give higher priority to axis=1 for the feature_names for
square arrays. Since most of the time, the 2D shap values arrays are assembled as
(# samples, # features).
cf. GH #2722, GH #2699.
"""
rs = np.random.RandomState(random_seed)
featnames = list("abcde")
exp = shap.Explanation(
# an array of this shape typically arises as the shap values of N samples, k=5 features
values=rs.rand(N, 5),
feature_names=featnames,
output_names=featnames,
)
first_sample = exp[0]
# exp[0] used to return "a" incorrectly when N=5 here, instead of ["a","b","c","d","e"]
assert first_sample.feature_names == first_sample.output_names == featnames
column_e = exp[..., "e"]
assert column_e.feature_names == "e"
def test_populating_op_history():
"""Tests whether the Explanation.op_history attribute is populated properly after operations have been applied."""
values = np.arange(-18, 17).reshape(7, 5)
# apply some operations
exp = shap.Explanation(values=values).abs.sample(5, random_state=0).flip[..., :3].mean(axis=0)
exp += 2
expected_op_names = [
"abs",
"sample",
"flip",
"__getitem__",
"mean",
"__add__",
]
op_history = exp.op_history
# sanity check for op_history
assert len(op_history) == 6
assert all(isinstance(op, OpHistoryItem) for op in op_history)
assert [op.name for op in op_history] == expected_op_names
# check that operations have been applied and produce the correct output
assert np.allclose(exp.values, [10.8, 11.0, 11.6])
@pytest.mark.parametrize(
"inp",
[
param(None, id="None"),
param([1, 2, 3], id="list[int]"),
param({"a": 10}, id="dict[int]"),
],
)
def test_cohorts_invalid_input(inp):
with pytest.raises(TypeError):
_ = shap.Cohorts(test_grp=inp)
with pytest.raises(TypeError):
ch = shap.Cohorts()
ch.cohorts = inp
def test_cohorts_magic_methods(random_seed):
rs = np.random.RandomState(random_seed)
e_size = (1_000, 5)
exp = shap.Explanation(
values=rs.uniform(low=-1, high=1, size=e_size),
data=rs.normal(loc=1, scale=3, size=e_size),
feature_names=list("abcde"),
)
exp_neg = exp[exp[:, "a"].data < 0]
exp_pos = exp[exp[:, "a"].data >= 0]
ch = shap.Cohorts(col_a_neg=exp_neg, col_a_pos=exp_pos)
# normal attribute access AND method access -> should be dispatched to Explanation objects
new_ch = ch.abs.mean(axis=0)
assert isinstance(new_ch, shap.Cohorts)
assert np.allclose(
new_ch.cohorts["col_a_neg"].values,
exp_neg.abs.mean(axis=0).values,
)
assert np.allclose(
new_ch.cohorts["col_a_pos"].values,
exp_pos.abs.mean(axis=0).values,
)
# getitem access -> should be dispatched to Explanation objects
new_ch = ch[..., "a"]
assert isinstance(new_ch, shap.Cohorts)
assert np.allclose(
new_ch.cohorts["col_a_neg"].values,
exp_neg[..., "a"].values,
)
assert np.allclose(
new_ch.cohorts["col_a_pos"].values,
exp_pos[..., "a"].values,
)
def test_cohorts_magic_methods_errors():
"""We don't support dispatching __call__ to the Explanation objects in Cohorts.
The only valid use case for cohorts.__call__() is for invoking Explanation methods (see above test).
"""
ch = shap.Cohorts()
with pytest.raises(ValueError, match=r"No methods"):
ch(axis=0)
def test_cohorts_multi_class():
# Load dataset
data = load_wine()
X = pd.DataFrame(data.data, columns=data.feature_names)
Y = data.target
model = RandomForestClassifier(random_state=42)
model.fit(X, Y)
explainer = shap.TreeExplainer(model)
shap_values = explainer(X[:100])
with pytest.raises(ValueError, match="Cohorts cannot be calculated on multiple outputs at once."):
shap_values.cohorts(2)
cohorts = shap_values[..., 0].cohorts(2)
isinstance(cohorts, shap.Cohorts)
def test_cohorts_generation_with_one_feature():
exp = shap.Explanation(
values=np.random.uniform(low=-1, high=1, size=(500, 1)),
data=np.random.normal(loc=1, scale=3, size=(500, 1)),
feature_names=list("a"),
)
cohorts = exp.cohorts(3)
assert isinstance(cohorts, shap.Cohorts)
assert len(cohorts.cohorts) == 3