248 lines
7.2 KiB
Python
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
|