chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""This modules tests all the explainer types."""
|
||||
@@ -0,0 +1,118 @@
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
|
||||
import shap
|
||||
|
||||
|
||||
def basic_xgboost_scenario(max_samples=None, dataset=shap.datasets.adult, seed=42):
|
||||
"""Create a basic XGBoost model on a data set."""
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
|
||||
# get a dataset on income prediction
|
||||
X, y = dataset()
|
||||
if max_samples is not None:
|
||||
X = X.iloc[:max_samples]
|
||||
y = y[:max_samples]
|
||||
X = X.values
|
||||
|
||||
# train an XGBoost model (but any other model type would also work)
|
||||
# Specify some hyperparameters for consitency between xgboost v1.X and v2.X
|
||||
model = xgboost.XGBClassifier(tree_method="exact", base_score=0.5, seed=seed)
|
||||
model.fit(X, y)
|
||||
|
||||
return model, X
|
||||
|
||||
|
||||
def basic_sklearn_scenario():
|
||||
"""Creates a basic scikit-learn logistic regression model and data."""
|
||||
X = np.random.randn(20, 5)
|
||||
y = np.zeros(20)
|
||||
y[10:] = 1
|
||||
|
||||
model = LogisticRegression()
|
||||
model.fit(X, y)
|
||||
|
||||
return model, X
|
||||
|
||||
|
||||
def test_additivity(explainer_type, model, masker, data, **kwargs):
|
||||
"""Test explainer and masker for additivity on a single output prediction problem."""
|
||||
explainer = explainer_type(model, masker, **kwargs)
|
||||
shap_values = explainer(data)
|
||||
|
||||
# a multi-output additivity check
|
||||
if len(shap_values.shape) == 3:
|
||||
# this works with ragged arrays and for models that we can't call directly (they get auto-wrapped)
|
||||
for i in range(shap_values.shape[0]):
|
||||
row = shap_values[i]
|
||||
if callable(explainer.masker.shape):
|
||||
all_on_masked = explainer.masker(np.ones(explainer.masker.shape(data[i])[1], dtype=bool), data[i])
|
||||
else:
|
||||
all_on_masked = explainer.masker(np.ones(explainer.masker.shape[1], dtype=bool), data[i])
|
||||
if not isinstance(all_on_masked, tuple):
|
||||
all_on_masked = (all_on_masked,)
|
||||
out = explainer.model(*all_on_masked)
|
||||
assert np.max(np.abs(row.base_values + row.values.sum(0) - out) < 1e6)
|
||||
else:
|
||||
assert np.max(np.abs(shap_values.base_values + shap_values.values.sum(1) - model(data)) < 1e6)
|
||||
return shap_values
|
||||
|
||||
|
||||
def test_interactions_additivity(explainer_type, model, masker, data, **kwargs):
|
||||
"""Test explainer and masker for additivity on a single output prediction problem."""
|
||||
explainer = explainer_type(model, masker, **kwargs)
|
||||
shap_values = explainer(data, interactions=True)
|
||||
|
||||
assert np.max(np.abs(shap_values.base_values + shap_values.values.sum((1, 2)) - model(data)) < 1e6)
|
||||
return shap_values
|
||||
|
||||
|
||||
# def test_multi_class(explainer_type, model, masker, data, **kwargs):
|
||||
# """ Test explainer and masker for additivity on a multi-class prediction problem.
|
||||
# """
|
||||
# explainer_kwargs = {k: kwargs[k] for k in kwargs if k in ["algorithm"]}
|
||||
# explainer = explainer_type(model.predict_proba, masker, **explainer_kwargs)
|
||||
# shap_values = explainer(data)
|
||||
|
||||
# assert np.max(np.abs(shap_values.base_values + shap_values.values.sum(1) - model.predict_proba(data)) < 1e6)
|
||||
|
||||
# def test_interactions(explainer_type):
|
||||
# """ Check that second order interactions have additivity.
|
||||
# """
|
||||
# model, X = basic_xgboost(100)
|
||||
|
||||
# # build an Exact explainer and explain the model predictions on the given dataset
|
||||
# explainer = explainer_type(model.predict, X)
|
||||
# shap_values = explainer(X, interactions=True)
|
||||
|
||||
# assert np.max(np.abs(shap_values.base_values + shap_values.values.sum((1, 2)) - model.predict(X[:100])) < 1e6)
|
||||
|
||||
|
||||
def test_serialization(explainer_type, model, masker, data, rtol=1e-05, atol=1e-8, **kwargs):
|
||||
"""Test serialization with a given explainer algorithm."""
|
||||
explainer_kwargs = {k: v for k, v in kwargs.items() if k in ["algorithm"]}
|
||||
explainer_original = explainer_type(model, masker, **explainer_kwargs)
|
||||
shap_values_original = explainer_original(data[:1])
|
||||
|
||||
# Serialization
|
||||
with tempfile.TemporaryFile() as temp_serialization_file:
|
||||
save_kwargs = {k: v for k, v in kwargs.items() if k in ["model_saver", "masker_saver"]}
|
||||
explainer_original.save(temp_serialization_file, **save_kwargs)
|
||||
|
||||
# Deserialization
|
||||
temp_serialization_file.seek(0)
|
||||
load_kwargs = {k: v for k, v in kwargs.items() if k in ["model_loader", "masker_loader"]}
|
||||
explainer_new = explainer_type.load(temp_serialization_file, **load_kwargs)
|
||||
|
||||
call_kwargs = {k: v for k, v in kwargs.items() if k in ["max_evals"]}
|
||||
shap_values_new = explainer_new(data[:1], **call_kwargs)
|
||||
|
||||
assert np.allclose(shap_values_original.base_values, shap_values_new.base_values, rtol=rtol, atol=atol)
|
||||
assert np.allclose(shap_values_original[0].values, shap_values_new[0].values, rtol=rtol, atol=atol)
|
||||
assert isinstance(explainer_original, type(explainer_new))
|
||||
if hasattr(explainer_original, "masker"):
|
||||
assert isinstance(explainer_original.masker, type(explainer_new.masker))
|
||||
return shap_values_new
|
||||
@@ -0,0 +1,60 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def load_tokenizer_model(name: str, retries: int) -> tuple:
|
||||
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
|
||||
AutoModelForSeq2SeqLM = pytest.importorskip("transformers").AutoModelForSeq2SeqLM
|
||||
|
||||
max_retries = retries # Use the parameter value
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
tokenizer = AutoTokenizer.from_pretrained(name)
|
||||
model = AutoModelForSeq2SeqLM.from_pretrained(name)
|
||||
return tokenizer, model
|
||||
except OSError:
|
||||
time.sleep(2**attempt) # Exponential backoff
|
||||
raise OSError(f"Failed to load model and tokenizer after {max_retries} attempts")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def basic_translation_scenario():
|
||||
"""Create a basic transformers translation model and tokenizer."""
|
||||
pytest.importorskip("torch")
|
||||
# Use a *tiny* tokenizer model, to keep tests running as fast as possible.
|
||||
# Nb. At time of writing, this pretrained model requires "protobuf==3.20.3".
|
||||
# name = "mesolitica/finetune-translation-t5-super-super-tiny-standard-bahasa-cased"
|
||||
# name = "Helsinki-NLP/opus-mt-en-es"
|
||||
name = "hf-internal-testing/tiny-random-BartModel"
|
||||
|
||||
tokenizer, model = load_tokenizer_model(name=name, retries=5)
|
||||
|
||||
# define the input sentences we want to translate
|
||||
data = [
|
||||
"In this picture, there are four persons: my father, my mother, my brother and my sister.",
|
||||
"Transformers have rapidly become the model of choice for NLP problems, replacing older recurrent neural network models",
|
||||
]
|
||||
|
||||
return model, tokenizer, data
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def causalml_synth_data():
|
||||
"""
|
||||
Generates synthetic data for causalml causal tree tests.
|
||||
|
||||
Unlike standard regression trees causal trees in causalml evaluate outcome conditioning on treatments
|
||||
Thus, a causal tree estimates Y_hat|X,T=t, where t={0, 1,..., n}.
|
||||
The simplest case is when T = {0, 1}, 0 - no treatment, 1 - some treatment.
|
||||
"""
|
||||
dataset = pytest.importorskip("causalml.dataset")
|
||||
|
||||
data_mode = 1 # Basic synthetic data mode with a difficult nuisance components and an easy treatment effect
|
||||
sigma = 0.1 # Synthetic standard deviation of the error term
|
||||
n_observations = 100 # The number of samples to generate
|
||||
n_features = 8 # X in (Y_hat|X, T=0, Y_hat|X, T=1)
|
||||
n_outcomes = 2 # Treatment conditioned outcomes: (Y_hat|X,T=0, Y_hat|X,T=1)
|
||||
|
||||
data = dataset.synthetic_data(mode=data_mode, n=n_observations, p=n_features, sigma=sigma)
|
||||
return data, n_outcomes
|
||||
@@ -0,0 +1,17 @@
|
||||
import numpy as np
|
||||
|
||||
from shap.explainers.other._maple import MAPLE
|
||||
|
||||
|
||||
def test_maple_small():
|
||||
"""Test a small MAPLE model."""
|
||||
rs = np.random.RandomState(0)
|
||||
X_train = rs.randn(10, 4)
|
||||
y_train = X_train[:, 0] * 2 + rs.randn(10) * 0.1
|
||||
X_val = rs.randn(5, 4)
|
||||
y_val = X_val[:, 0] * 2 + rs.randn(5) * 0.1
|
||||
|
||||
maple = MAPLE(X_train, y_train, X_val, y_val)
|
||||
|
||||
preds = maple.predict(X_val)
|
||||
assert preds.shape == (5,)
|
||||
@@ -0,0 +1,45 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from shap.explainers.other import TreeGain
|
||||
|
||||
|
||||
def test_treegain_xgbregressor():
|
||||
pytest.importorskip("xgboost")
|
||||
import xgboost
|
||||
|
||||
# Train a simple model
|
||||
X = np.random.randn(10, 3)
|
||||
y = np.random.randn(10)
|
||||
model = xgboost.XGBRegressor(n_estimators=10)
|
||||
model.fit(X, y)
|
||||
|
||||
# Check that TreeGain can explain it
|
||||
explainer = TreeGain(model)
|
||||
attributions = explainer.attributions(X)
|
||||
|
||||
assert isinstance(attributions, np.ndarray)
|
||||
assert attributions.shape == (10, 3)
|
||||
# attributions should be tiled feature_importances_
|
||||
np.testing.assert_allclose(attributions[0], model.feature_importances_)
|
||||
np.testing.assert_allclose(attributions[-1], model.feature_importances_)
|
||||
|
||||
|
||||
def test_treegain_unsupported_model():
|
||||
class UnsupportedModel:
|
||||
pass
|
||||
|
||||
model = UnsupportedModel()
|
||||
with pytest.raises(NotImplementedError, match="The passed model is not yet supported by TreeGainExplainer"):
|
||||
TreeGain(model)
|
||||
|
||||
|
||||
def test_treegain_missing_feature_importances():
|
||||
pytest.importorskip("xgboost")
|
||||
import xgboost
|
||||
|
||||
# Unfitted model lacks feature_importances_
|
||||
model = xgboost.XGBRegressor()
|
||||
|
||||
with pytest.raises(AssertionError, match="The passed model does not have a feature_importances_ attribute"):
|
||||
TreeGain(model)
|
||||
@@ -0,0 +1,91 @@
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from shap.explainers.other._ubjson import _decode_simple_key_value_pair
|
||||
|
||||
|
||||
def test_decode_simple_key_value_pair():
|
||||
# todo: this is not correct, fix this
|
||||
num_class = b"L\x00\x00\x00\x00\x00\x00\x00\tnum_classL\x00\x00\x00\x00\x00\x00\x00\x0b"
|
||||
fp = BytesIO(num_class)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type=key_type)
|
||||
assert key == "num_class" and value == 11
|
||||
|
||||
boost_from_average = b"L\x00\x00\x00\x00\x00\x00\x00\x12boost_from_averageSL\x00\x00\x00\x00\x00\x00\x00\x011"
|
||||
|
||||
fp = BytesIO(boost_from_average)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "boost_from_average" and value == "1"
|
||||
|
||||
num_feature = b"L\x00\x00\x00\x00\x00\x00\x00\x0bnum_featureSL\x00\x00\x00\x00\x00\x00\x00\x013"
|
||||
fp = BytesIO(num_feature)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "num_feature" and value == "3"
|
||||
|
||||
num_class = b"L\x00\x00\x00\x00\x00\x00\x00\tnum_classSL\x00\x00\x00\x00\x00\x00\x00\x010"
|
||||
fp = BytesIO(num_class)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "num_class" and value == "0"
|
||||
|
||||
|
||||
def test_decode_object():
|
||||
expected_value: dict[str, Any]
|
||||
regression_loss = b"L\x00\x00\x00\x00\x00\x00\x00\x0ereg_loss_param{L\x00\x00\x00\x00\x00\x00\x00\x10scale_pos_weightSL\x00\x00\x00\x00\x00\x00\x00\x011}"
|
||||
fp = BytesIO(regression_loss)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
expected_key = "reg_loss_param"
|
||||
expected_value = {"scale_pos_weight": "1"}
|
||||
assert expected_key == key and value == expected_value
|
||||
|
||||
objective_dict = b"L\x00\x00\x00\x00\x00\x00\x00\tobjective{L\x00\x00\x00\x00\x00\x00\x00\x04nameSL\x00\x00\x00\x00\x00\x00\x00\x0fbinary:logisticL\x00\x00\x00\x00\x00\x00\x00\x0ereg_loss_param{L\x00\x00\x00\x00\x00\x00\x00\x10scale_pos_weightSL\x00\x00\x00\x00\x00\x00\x00\x011}}"
|
||||
fp = BytesIO(objective_dict)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
expected_key = "objective"
|
||||
expected_value = {"name": "binary:logistic", "reg_loss_param": {"scale_pos_weight": "1"}}
|
||||
assert expected_key == key and value == expected_value
|
||||
|
||||
objective_reversed_dict = b"L\x00\x00\x00\x00\x00\x00\x00\tobjective{L\x00\x00\x00\x00\x00\x00\x00\x0ereg_loss_param{L\x00\x00\x00\x00\x00\x00\x00\x10scale_pos_weightSL\x00\x00\x00\x00\x00\x00\x00\x011}L\x00\x00\x00\x00\x00\x00\x00\x04nameSL\x00\x00\x00\x00\x00\x00\x00\x0fbinary:logisticL\x00\x00\x00\x00\x00\x00\x00\x0e}"
|
||||
fp = BytesIO(objective_reversed_dict)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
expected_key = "objective"
|
||||
expected_value = {
|
||||
"reg_loss_param": {"scale_pos_weight": "1"},
|
||||
"name": "binary:logistic",
|
||||
}
|
||||
assert expected_key == key and value == expected_value
|
||||
|
||||
empty_object_dict = b"L\x00\x00\x00\x00\x00\x00\x00\nattributes{}"
|
||||
fp = BytesIO(empty_object_dict)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "attributes" and value == {}
|
||||
|
||||
|
||||
def test_decode_array():
|
||||
left_children = b"L\x00\x00\x00\x00\x00\x00\x00\rleft_children[$l#L\x00\x00\x00\x00\x00\x00\x00\x01\xff\xff\xff\xffL\x00\x00\x00\x00\x00\x00\x00\x0c"
|
||||
fp = BytesIO(left_children)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "left_children" and value == np.array([-1], dtype=np.int32)
|
||||
|
||||
# string array
|
||||
feature_names = b"L\x00\x00\x00\x00\x00\x00\x00\rfeature_names[#L\x00\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00\x00\x00\x00\x00\r"
|
||||
fp = BytesIO(feature_names)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "feature_names" and value == []
|
||||
|
||||
base_weights = b"L\x00\x00\x00\x00\x00\x00\x00\x0cbase_weights[$d#L\x00\x00\x00\x00\x00\x00\x00\x01\xba\xa2\xe1&"
|
||||
fp = BytesIO(base_weights)
|
||||
key_type = fp.read(1)
|
||||
key, value = _decode_simple_key_value_pair(fp, key_type)
|
||||
assert key == "base_weights" and value == [-0.0012426718603819609]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""This file contains tests for coalition explainer."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from conftest import compare_numpy_outputs_against_baseline
|
||||
|
||||
import shap
|
||||
from shap.explainers._coalition import create_partition_hierarchy
|
||||
|
||||
from . import common
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_coalition_single_output():
|
||||
coalition_tree = {
|
||||
"Demographics": ["Sex", "Age", "Race", "Marital Status", "Education-Num"],
|
||||
"Work": ["Occupation", "Workclass", "Hours per week"],
|
||||
"Finance": ["Capital Gain", "Capital Loss"],
|
||||
"Residence": ["Country"],
|
||||
}
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
X, _ = shap.datasets.adult()
|
||||
features = X.columns.tolist()
|
||||
masker = shap.maskers.Partition(data)
|
||||
masker.feature_names = features
|
||||
return common.test_additivity(
|
||||
shap.explainers.CoalitionExplainer, model.predict, masker, data, partition_tree=coalition_tree
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_coalition_multiple_output():
|
||||
coalition_tree = {
|
||||
"Demographics": ["Sex", "Age", "Race", "Marital Status", "Education-Num"],
|
||||
"Work": ["Occupation", "Workclass", "Hours per week"],
|
||||
"Finance": ["Capital Gain", "Capital Loss"],
|
||||
"Residence": ["Country"],
|
||||
}
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
X, _ = shap.datasets.adult()
|
||||
features = X.columns.tolist()
|
||||
masker = shap.maskers.Partition(data)
|
||||
masker.feature_names = features
|
||||
return common.test_additivity(
|
||||
shap.explainers.CoalitionExplainer, model.predict_proba, masker, data, partition_tree=coalition_tree
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_coalition_exact_match():
|
||||
model, data = common.basic_xgboost_scenario(50)
|
||||
X, _ = shap.datasets.adult()
|
||||
features = X.columns.tolist()
|
||||
data = pd.DataFrame(data, columns=features)
|
||||
exact_explainer = shap.explainers.ExactExplainer(model.predict, data)
|
||||
shap_values = exact_explainer(data)
|
||||
|
||||
flat_hierarchy = {}
|
||||
for name in features:
|
||||
flat_hierarchy[name] = name
|
||||
|
||||
partition_masker = shap.maskers.Partition(data)
|
||||
partition_masker.feature_names = features
|
||||
partition_explainer_f = shap.CoalitionExplainer(model.predict, partition_masker, partition_tree=flat_hierarchy)
|
||||
flat_winter_values = partition_explainer_f(data)
|
||||
assert np.allclose(shap_values.values, flat_winter_values.values)
|
||||
return shap_values
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_coalition_partition_match():
|
||||
model, data = common.basic_xgboost_scenario(50)
|
||||
X, _ = shap.datasets.adult()
|
||||
features = X.columns.tolist()
|
||||
data = pd.DataFrame(data, columns=features)
|
||||
partition_tree = shap.utils.partition_tree(data)
|
||||
partition_masker = shap.maskers.Partition(data, clustering=partition_tree)
|
||||
partition_masker.feature_names = features
|
||||
partition_explainer = shap.explainers.PartitionExplainer(model.predict, partition_masker)
|
||||
binary_values = partition_explainer(data)
|
||||
|
||||
hierarchy_binary = create_partition_hierarchy(partition_tree, features)
|
||||
|
||||
coalition_masker = shap.maskers.Partition(data)
|
||||
partition_explainer_b = shap.CoalitionExplainer(model.predict, coalition_masker, partition_tree=hierarchy_binary) # type: ignore[arg-type]
|
||||
binary_winter_values = partition_explainer_b(data)
|
||||
|
||||
assert np.allclose(binary_values.values, binary_winter_values.values) # type: ignore[union-attr]
|
||||
return binary_values
|
||||
@@ -0,0 +1,756 @@
|
||||
"""Tests for the Deep explainer."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from packaging import version
|
||||
|
||||
import shap
|
||||
|
||||
# os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
|
||||
|
||||
############################
|
||||
# Tensorflow related tests #
|
||||
############################
|
||||
|
||||
|
||||
def test_tf_eager_call(random_seed):
|
||||
"""This is a basic eager example from keras."""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
if version.parse(tf.__version__) >= version.parse("2.4.0"):
|
||||
pytest.skip("Deep explainer does not work for TF 2.4 in eager mode.")
|
||||
|
||||
x = pd.DataFrame({"B": rs.random(size=(100,))})
|
||||
y = x.B
|
||||
y = y.map(lambda zz: chr(int(zz * 2 + 65))).str.get_dummies()
|
||||
|
||||
model = tf.keras.models.Sequential()
|
||||
model.add(tf.keras.layers.Input(shape=(x.shape[1],)))
|
||||
model.add(tf.keras.layers.Dense(10, activation="relu"))
|
||||
model.add(tf.keras.layers.Dense(y.shape[1], activation="softmax"))
|
||||
model.summary()
|
||||
model.compile(loss="categorical_crossentropy", optimizer="Adam")
|
||||
model.fit(x.values, y.values, epochs=2)
|
||||
|
||||
e = shap.DeepExplainer(model, x.values[:1])
|
||||
sv = e.shap_values(x.values)
|
||||
sv_call = e(x.values)
|
||||
np.testing.assert_array_almost_equal(sv, sv_call.values, decimal=8)
|
||||
assert np.abs(e.expected_value[0] + sv[0].sum(-1) - model(x.values)[:, 0]).max() < 1e-4
|
||||
|
||||
|
||||
def test_tf_keras_mnist_cnn_call(random_seed):
|
||||
"""This is the basic mnist cnn example from keras."""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
config = tf.compat.v1.ConfigProto()
|
||||
config.gpu_options.allow_growth = True
|
||||
|
||||
batch_size = 64
|
||||
num_classes = 10
|
||||
epochs = 1
|
||||
|
||||
# input image dimensions
|
||||
img_rows, img_cols = 28, 28
|
||||
|
||||
# the data, split between train and test sets
|
||||
# (x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()
|
||||
x_train = rs.randn(200, 28, 28)
|
||||
y_train = rs.randint(0, 9, 200)
|
||||
x_test = rs.randn(200, 28, 28)
|
||||
y_test = rs.randint(0, 9, 200)
|
||||
|
||||
if tf.keras.backend.image_data_format() == "channels_first":
|
||||
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
|
||||
x_test = x_test.reshape(x_test.shape[0], 1, img_rows, img_cols)
|
||||
input_shape = (1, img_rows, img_cols)
|
||||
else:
|
||||
x_train = x_train.reshape(x_train.shape[0], img_rows, img_cols, 1)
|
||||
x_test = x_test.reshape(x_test.shape[0], img_rows, img_cols, 1)
|
||||
input_shape = (img_rows, img_cols, 1)
|
||||
|
||||
x_train = x_train.astype("float32")
|
||||
x_test = x_test.astype("float32")
|
||||
x_train /= 255
|
||||
x_test /= 255
|
||||
|
||||
# convert class vectors to binary class matrices
|
||||
y_train = tf.keras.utils.to_categorical(y_train, num_classes)
|
||||
y_test = tf.keras.utils.to_categorical(y_test, num_classes)
|
||||
|
||||
model = tf.keras.models.Sequential()
|
||||
model.add(tf.keras.layers.Input(shape=input_shape))
|
||||
model.add(tf.keras.layers.Conv2D(2, kernel_size=(3, 3), activation="relu"))
|
||||
model.add(tf.keras.layers.Conv2D(4, (3, 3), activation="relu"))
|
||||
model.add(tf.keras.layers.MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(tf.keras.layers.Dropout(0.25))
|
||||
model.add(tf.keras.layers.Flatten())
|
||||
model.add(tf.keras.layers.Dense(16, activation="relu")) # 128
|
||||
model.add(tf.keras.layers.Dropout(0.5))
|
||||
model.add(tf.keras.layers.Dense(num_classes))
|
||||
model.add(tf.keras.layers.Activation("softmax"))
|
||||
|
||||
model.compile(
|
||||
loss=tf.keras.losses.categorical_crossentropy, optimizer=tf.keras.optimizers.Adadelta(), metrics=["accuracy"]
|
||||
)
|
||||
|
||||
model.fit(x_train, y_train, batch_size=batch_size, epochs=epochs, verbose=1, validation_data=(x_test, y_test))
|
||||
|
||||
# explain by passing the tensorflow inputs and outputs
|
||||
inds = rs.choice(x_train.shape[0], 3, replace=False)
|
||||
e = shap.DeepExplainer((model.inputs, model.layers[-1].output), x_train[inds, :, :])
|
||||
shap_values = e.shap_values(x_test[:1])
|
||||
shap_values_call = e(x_test[:1])
|
||||
|
||||
np.testing.assert_array_almost_equal(shap_values, shap_values_call.values, decimal=8)
|
||||
|
||||
predicted = model(x_test[:1])
|
||||
|
||||
sums = shap_values.sum(axis=(1, 2, 3))
|
||||
(
|
||||
np.testing.assert_allclose(sums + e.expected_value, predicted, atol=1e-3),
|
||||
"Sum of SHAP values does not match difference!",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("activation", ["relu", "elu", "selu"])
|
||||
def test_tf_keras_activations(activation):
|
||||
"""Test verifying that a linear model with linear data gives the correct result."""
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 0
|
||||
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
# coefficients relating y with x1 and x2.
|
||||
coef = np.array([1, 2]).T
|
||||
|
||||
# generate data following a linear relationship
|
||||
x = rs.normal(1, 10, size=(1000, len(coef)))
|
||||
y = np.dot(x, coef) + 1 + rs.normal(scale=0.1, size=1000)
|
||||
|
||||
# create a linear model
|
||||
inputs = tf.keras.layers.Input(shape=(2,))
|
||||
preds = tf.keras.layers.Dense(1, activation=activation)(inputs)
|
||||
|
||||
model = tf.keras.models.Model(inputs=inputs, outputs=preds)
|
||||
model.compile(optimizer=tf.keras.optimizers.SGD(), loss="mse", metrics=["mse"])
|
||||
model.fit(x, y, epochs=30, shuffle=False, verbose=0)
|
||||
|
||||
# explain
|
||||
e = shap.DeepExplainer((model.inputs, model.layers[-1].output), x)
|
||||
shap_values = e.shap_values(x)
|
||||
preds = model.predict(x)
|
||||
|
||||
assert shap_values.shape == (1000, 2, 1)
|
||||
np.testing.assert_allclose(shap_values.sum(axis=1) + e.expected_value, preds, atol=1e-5)
|
||||
|
||||
|
||||
def test_tf_keras_linear():
|
||||
"""Test verifying that a linear model with linear data gives the correct result."""
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 0
|
||||
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
|
||||
# tf.compat.v1.disable_eager_execution()
|
||||
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
# coefficients relating y with x1 and x2.
|
||||
coef = np.array([1, 2]).T
|
||||
|
||||
# generate data following a linear relationship
|
||||
x = rs.normal(1, 10, size=(1000, len(coef)))
|
||||
y = np.dot(x, coef) + 1 + rs.normal(scale=0.1, size=1000)
|
||||
|
||||
# create a linear model
|
||||
inputs = tf.keras.layers.Input(shape=(2,))
|
||||
preds = tf.keras.layers.Dense(1, activation="linear")(inputs)
|
||||
|
||||
model = tf.keras.models.Model(inputs=inputs, outputs=preds)
|
||||
model.compile(optimizer=tf.keras.optimizers.SGD(), loss="mse", metrics=["mse"])
|
||||
model.fit(x, y, epochs=30, shuffle=False, verbose=0)
|
||||
|
||||
fit_coef = model.layers[1].get_weights()[0].T[0]
|
||||
|
||||
# explain
|
||||
e = shap.DeepExplainer((model.inputs, model.layers[-1].output), x)
|
||||
shap_values = e.shap_values(x)
|
||||
|
||||
assert shap_values.shape == (1000, 2, 1)
|
||||
|
||||
# verify that the explanation follows the equation in LinearExplainer
|
||||
expected = (x - x.mean(0)) * fit_coef
|
||||
np.testing.assert_allclose(shap_values.sum(-1), expected, atol=1e-5)
|
||||
|
||||
|
||||
def test_tf_keras_imdb_lstm(random_seed):
|
||||
"""Basic LSTM example using the keras API defined in tensorflow"""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
rs = np.random.RandomState(random_seed)
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
|
||||
# this fails right now for new TF versions (there is a warning in the code for this)
|
||||
if version.parse(tf.__version__) >= version.parse("2.5.0"):
|
||||
pytest.skip()
|
||||
|
||||
tf.compat.v1.disable_eager_execution()
|
||||
|
||||
# load the data from keras
|
||||
max_features = 1000
|
||||
try:
|
||||
(X_train, _), (X_test, _) = tf.keras.datasets.imdb.load_data(num_words=max_features)
|
||||
except Exception:
|
||||
return # this hides a bug in the most recent version of keras that prevents data loading
|
||||
X_train = tf.keras.preprocessing.sequence.pad_sequences(X_train, maxlen=100)
|
||||
X_test = tf.keras.preprocessing.sequence.pad_sequences(X_test, maxlen=100)
|
||||
|
||||
# create the model. note that this is model is very small to make the test
|
||||
# run quick and we don't care about accuracy here
|
||||
mod = tf.keras.models.Sequential()
|
||||
mod.add(tf.keras.layers.Embedding(max_features, 8))
|
||||
mod.add(tf.keras.layers.LSTM(10, dropout=0.2, recurrent_dropout=0.2))
|
||||
mod.add(tf.keras.layers.Dense(1, activation="sigmoid"))
|
||||
mod.compile(loss="binary_crossentropy", optimizer="adam", metrics=["accuracy"])
|
||||
|
||||
# select the background and test samples
|
||||
inds = rs.choice(X_train.shape[0], 3, replace=False)
|
||||
background = X_train[inds]
|
||||
testx = X_test[10:11]
|
||||
|
||||
# explain a prediction and make sure it sums to the difference between the average output
|
||||
# over the background samples and the current output
|
||||
sess = tf.compat.v1.keras.backend.get_session()
|
||||
sess.run(tf.compat.v1.global_variables_initializer())
|
||||
# For debugging, can view graph:
|
||||
# writer = tf.compat.v1.summary.FileWriter("c:\\tmp", sess.graph)
|
||||
# writer.close()
|
||||
e = shap.DeepExplainer((mod.layers[0].input, mod.layers[-1].output), background)
|
||||
shap_values = e.shap_values(testx)
|
||||
sums = np.array([shap_values[i].sum() for i in range(len(shap_values))])
|
||||
diff = sess.run(mod.layers[-1].output, feed_dict={mod.layers[0].input: testx})[0, :] - sess.run(
|
||||
mod.layers[-1].output, feed_dict={mod.layers[0].input: background}
|
||||
).mean(0)
|
||||
np.testing.assert_allclose(sums, diff, atol=1e-02), "Sum of SHAP values does not match difference!"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
platform.system() == "Darwin" and os.getenv("GITHUB_ACTIONS") == "true",
|
||||
reason="Skipping on GH MacOS runners due to memory error, see GH #3929",
|
||||
)
|
||||
def test_tf_deep_imbdb_transformers():
|
||||
# GH 3522
|
||||
pytest.importorskip("torch")
|
||||
transformers = pytest.importorskip("transformers")
|
||||
|
||||
from shap import models
|
||||
|
||||
# data from datasets imdb dataset
|
||||
short_data = ["I lov", "Worth", "its a", "STAR ", "First", "I had", "Isaac", "It ac", "Techn", "Hones"]
|
||||
classifier = transformers.pipeline("sentiment-analysis", top_k=None)
|
||||
pmodel = models.TransformersPipeline(classifier, rescale_to_logits=True)
|
||||
explainer3 = shap.Explainer(pmodel, classifier.tokenizer)
|
||||
shap_values3 = explainer3(short_data[:10])
|
||||
shap.plots.text(shap_values3[:, :, 1]) # type: ignore[call-overload]
|
||||
shap.plots.bar(shap_values3[:, :, 1].mean(0)) # type: ignore[call-overload]
|
||||
|
||||
|
||||
def test_tf_deep_multi_inputs_multi_outputs():
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
|
||||
input1 = tf.keras.layers.Input(shape=(3,))
|
||||
input2 = tf.keras.layers.Input(shape=(4,))
|
||||
|
||||
# Concatenate input layers
|
||||
concatenated = tf.keras.layers.concatenate([input1, input2])
|
||||
|
||||
# Dense layers
|
||||
x = tf.keras.layers.Dense(16, activation="relu")(concatenated)
|
||||
|
||||
# Output layer
|
||||
output = tf.keras.layers.Dense(3, activation="softmax")(x)
|
||||
model = tf.keras.models.Model(inputs=[input1, input2], outputs=output)
|
||||
batch_size = 32
|
||||
# Generate random input data for input1 with shape (batch_size, 3)
|
||||
input1_data = np.random.rand(batch_size, 3)
|
||||
|
||||
# Generate random input data for input2 with shape (batch_size, 4)
|
||||
input2_data = np.random.rand(batch_size, 4)
|
||||
|
||||
predicted = model.predict([input1_data, input2_data])
|
||||
explainer = shap.DeepExplainer(model, [input1_data, input2_data])
|
||||
shap_values = explainer.shap_values([input1_data, input2_data])
|
||||
np.testing.assert_allclose(
|
||||
shap_values[0].sum(1) + shap_values[1].sum(1) + explainer.expected_value, predicted, atol=1e-3
|
||||
)
|
||||
|
||||
|
||||
#######################
|
||||
# Torch related tests #
|
||||
#######################
|
||||
|
||||
|
||||
def _torch_cuda_available():
|
||||
"""Checks whether cuda is available. If so, torch-related tests are also tested on gpu."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return torch.cuda.is_available()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
TORCH_DEVICES = [
|
||||
"cpu",
|
||||
pytest.param("cuda", marks=pytest.mark.skipif(not _torch_cuda_available(), reason="cuda unavailable (with torch)")),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("torch_device", TORCH_DEVICES)
|
||||
@pytest.mark.parametrize("interim", [True, False])
|
||||
def test_pytorch_mnist_cnn_call(torch_device, interim):
|
||||
"""The same test as above, but for pytorch"""
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
|
||||
class RandData:
|
||||
"""Random test data."""
|
||||
|
||||
def __init__(self, batch_size):
|
||||
self.current = 0
|
||||
self.batch_size = batch_size
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
self.current += 1
|
||||
if self.current < 10:
|
||||
return torch.randn(self.batch_size, 1, 28, 28), torch.randint(0, 9, (self.batch_size,))
|
||||
raise StopIteration
|
||||
|
||||
class Net(nn.Module):
|
||||
"""Basic conv net."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Testing several different activations
|
||||
self.conv_layers = nn.Sequential(
|
||||
nn.Conv2d(1, 10, kernel_size=5),
|
||||
nn.MaxPool2d(2),
|
||||
nn.Tanh(),
|
||||
nn.Conv2d(10, 20, kernel_size=5),
|
||||
nn.ConvTranspose2d(20, 20, 1),
|
||||
nn.AdaptiveAvgPool2d(output_size=(4, 4)),
|
||||
nn.Softplus(),
|
||||
nn.Flatten(),
|
||||
)
|
||||
self.fc_layers = nn.Sequential(
|
||||
nn.Linear(320, 50), nn.BatchNorm1d(50), nn.ReLU(), nn.Linear(50, 10), nn.ELU(), nn.Softmax(dim=1)
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
"""Run the model."""
|
||||
x = self.conv_layers(x)
|
||||
x = x.view(-1, 320) # Redundant as `Flatten`, left as a test
|
||||
x = self.fc_layers(x)
|
||||
return x
|
||||
|
||||
def train(model, device, train_loader, optimizer, _, cutoff=20):
|
||||
model.train()
|
||||
num_examples = 0
|
||||
for _, (data, target) in enumerate(train_loader):
|
||||
num_examples += target.shape[0]
|
||||
data, target = data.to(device), target.to(device)
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.mse_loss(output, torch.eye(10).to(device)[target])
|
||||
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
if num_examples > cutoff:
|
||||
break
|
||||
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 42
|
||||
|
||||
torch.manual_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
batch_size = 32
|
||||
|
||||
train_loader = RandData(batch_size)
|
||||
test_loader = RandData(batch_size)
|
||||
|
||||
model = Net()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.5)
|
||||
|
||||
device = torch.device(torch_device)
|
||||
|
||||
model.to(device)
|
||||
train(model, device, train_loader, optimizer, 1)
|
||||
|
||||
next_x, _ = next(iter(train_loader))
|
||||
inds = rs.choice(next_x.shape[0], 3, replace=False)
|
||||
|
||||
next_x_random_choices = next_x[inds, :, :, :].to(device)
|
||||
|
||||
if interim:
|
||||
e = shap.DeepExplainer((model, model.conv_layers[0]), next_x_random_choices)
|
||||
else:
|
||||
e = shap.DeepExplainer(model, next_x_random_choices)
|
||||
|
||||
test_x, _ = next(iter(test_loader))
|
||||
input_tensor = test_x[:1].to(device)
|
||||
shap_values = e.shap_values(input_tensor)
|
||||
shap_values_call = e(input_tensor)
|
||||
|
||||
np.testing.assert_array_almost_equal(shap_values, shap_values_call.values, decimal=8)
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(input_tensor).detach().cpu().numpy()
|
||||
|
||||
sums = shap_values.sum((1, 2, 3))
|
||||
(
|
||||
np.testing.assert_allclose(sums + e.expected_value, outputs, atol=1e-3),
|
||||
"Sum of SHAP values does not match difference!",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("torch_device", TORCH_DEVICES)
|
||||
def test_pytorch_custom_nested_models(torch_device):
|
||||
"""Testing single outputs"""
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from sklearn.datasets import fetch_california_housing
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
|
||||
class CustomNet1(nn.Module):
|
||||
"""Model 1."""
|
||||
|
||||
def __init__(self, num_features):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Sequential(
|
||||
nn.Identity(),
|
||||
nn.Conv1d(1, 1, 1),
|
||||
nn.ConvTranspose1d(1, 1, 1),
|
||||
),
|
||||
nn.AdaptiveAvgPool1d(output_size=num_features // 2),
|
||||
)
|
||||
|
||||
def forward(self, X):
|
||||
"""Run the model."""
|
||||
return self.net(X.unsqueeze(1)).squeeze(1)
|
||||
|
||||
class CustomNet2(nn.Module):
|
||||
"""Model 2."""
|
||||
|
||||
def __init__(self, num_features):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(nn.LeakyReLU(), nn.Linear(num_features // 2, 2))
|
||||
|
||||
def forward(self, X):
|
||||
"""Run the model."""
|
||||
return self.net(X).unsqueeze(1)
|
||||
|
||||
class CustomNet(nn.Module):
|
||||
"""Model 3."""
|
||||
|
||||
def __init__(self, num_features):
|
||||
super().__init__()
|
||||
self.net1 = CustomNet1(num_features)
|
||||
self.net2 = CustomNet2(num_features)
|
||||
self.maxpool2 = nn.MaxPool1d(kernel_size=2)
|
||||
|
||||
def forward(self, X):
|
||||
"""Run the model."""
|
||||
x = self.net1(X)
|
||||
return self.maxpool2(self.net2(x)).squeeze(1)
|
||||
|
||||
def train(model, device, train_loader, optimizer, epoch):
|
||||
model.train()
|
||||
num_examples = 0
|
||||
for batch_idx, (data, target) in enumerate(train_loader):
|
||||
num_examples += target.shape[0]
|
||||
data, target = data.to(device), target.to(device)
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.mse_loss(output.squeeze(1), target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if batch_idx % 2 == 0:
|
||||
print(
|
||||
f"Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}"
|
||||
f" ({100.0 * batch_idx / len(train_loader):.0f}%)]"
|
||||
f"\tLoss: {loss.item():.6f}"
|
||||
)
|
||||
|
||||
random_seed = 777 # TODO: #2960
|
||||
|
||||
torch.manual_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
X, y = fetch_california_housing(return_X_y=True)
|
||||
|
||||
num_features = X.shape[1]
|
||||
|
||||
data = TensorDataset(
|
||||
torch.tensor(X).float(),
|
||||
torch.tensor(y).float(),
|
||||
)
|
||||
|
||||
loader = DataLoader(data, batch_size=128)
|
||||
|
||||
model = CustomNet(num_features)
|
||||
optimizer = torch.optim.Adam(model.parameters())
|
||||
|
||||
device = torch.device(torch_device)
|
||||
|
||||
model.to(device)
|
||||
|
||||
train(model, device, loader, optimizer, 1)
|
||||
|
||||
next_x, _ = next(iter(loader))
|
||||
|
||||
inds = rs.choice(next_x.shape[0], 20, replace=False)
|
||||
|
||||
next_x_random_choices = next_x[inds, :].to(device)
|
||||
e = shap.DeepExplainer(model, next_x_random_choices)
|
||||
|
||||
test_x_tmp, _ = next(iter(loader))
|
||||
test_x = test_x_tmp[:1].to(device)
|
||||
|
||||
shap_values = e.shap_values(test_x)
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
|
||||
with torch.no_grad():
|
||||
diff = model(test_x).detach().cpu().numpy()
|
||||
|
||||
sums = shap_values.sum(axis=(1))
|
||||
(
|
||||
np.testing.assert_allclose(sums + e.expected_value, diff, atol=1e-3),
|
||||
"Sum of SHAP values does not match difference!",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("torch_device", TORCH_DEVICES)
|
||||
def test_pytorch_single_output(torch_device):
|
||||
"""Testing single outputs"""
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from sklearn.datasets import fetch_california_housing
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
|
||||
class Net(nn.Module):
|
||||
"""Test model."""
|
||||
|
||||
def __init__(self, num_features):
|
||||
super().__init__()
|
||||
self.linear = nn.Linear(num_features // 2, 2)
|
||||
self.conv1d = nn.Conv1d(1, 1, 1)
|
||||
self.convt1d = nn.ConvTranspose1d(1, 1, 1)
|
||||
self.leaky_relu = nn.LeakyReLU()
|
||||
self.aapool1d = nn.AdaptiveAvgPool1d(output_size=num_features // 2)
|
||||
self.maxpool2 = nn.MaxPool1d(kernel_size=2)
|
||||
|
||||
def forward(self, X):
|
||||
"""Run the model."""
|
||||
x = self.aapool1d(self.convt1d(self.conv1d(X.unsqueeze(1)))).squeeze(1)
|
||||
return self.maxpool2(self.linear(self.leaky_relu(x)).unsqueeze(1)).squeeze(1)
|
||||
|
||||
def train(model, device, train_loader, optimizer, epoch):
|
||||
model.train()
|
||||
num_examples = 0
|
||||
for batch_idx, (data, target) in enumerate(train_loader):
|
||||
num_examples += target.shape[0]
|
||||
data, target = data.to(device), target.to(device)
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.mse_loss(output.squeeze(1), target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if batch_idx % 2 == 0:
|
||||
print(
|
||||
f"Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}"
|
||||
f" ({100.0 * batch_idx / len(train_loader):.0f}%)]"
|
||||
f"\tLoss: {loss.item():.6f}"
|
||||
)
|
||||
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 0
|
||||
torch.manual_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
X, y = fetch_california_housing(return_X_y=True)
|
||||
|
||||
num_features = X.shape[1]
|
||||
|
||||
data = TensorDataset(
|
||||
torch.tensor(X).float(),
|
||||
torch.tensor(y).float(),
|
||||
)
|
||||
|
||||
loader = DataLoader(data, batch_size=128)
|
||||
|
||||
model = Net(num_features)
|
||||
optimizer = torch.optim.Adam(model.parameters())
|
||||
|
||||
device = torch.device(torch_device)
|
||||
|
||||
model.to(device)
|
||||
|
||||
train(model, device, loader, optimizer, 1)
|
||||
|
||||
next_x, _ = next(iter(loader))
|
||||
inds = rs.choice(next_x.shape[0], 20, replace=False)
|
||||
|
||||
next_x_random_choices = next_x[inds, :].to(device)
|
||||
|
||||
e = shap.DeepExplainer(model, next_x_random_choices)
|
||||
test_x_tmp, _ = next(iter(loader))
|
||||
test_x = test_x_tmp[:1].to(device)
|
||||
|
||||
shap_values = e.shap_values(test_x)
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(test_x).detach().cpu().numpy()
|
||||
|
||||
sums = shap_values.sum(axis=(1))
|
||||
(
|
||||
np.testing.assert_allclose(sums + e.expected_value, outputs, atol=1e-3),
|
||||
"Sum of SHAP values does not match difference!",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("activation", ["relu", "selu", "gelu"])
|
||||
@pytest.mark.parametrize("torch_device", TORCH_DEVICES)
|
||||
@pytest.mark.parametrize("disconnected", [True, False])
|
||||
def test_pytorch_multiple_inputs(torch_device, disconnected, activation):
|
||||
"""Check a multi-input scenario."""
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from sklearn.datasets import fetch_california_housing
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
|
||||
activation_func = {"relu": nn.ReLU(), "selu": nn.SELU(), "gelu": nn.GELU()}[activation]
|
||||
|
||||
class Net(nn.Module):
|
||||
"""Testing model."""
|
||||
|
||||
def __init__(self, num_features, disconnected):
|
||||
super().__init__()
|
||||
self.disconnected = disconnected
|
||||
if disconnected:
|
||||
num_features = num_features // 2
|
||||
self.linear = nn.Linear(num_features, 2)
|
||||
self.output = nn.Sequential(nn.MaxPool1d(2), activation_func)
|
||||
|
||||
def forward(self, x1, x2):
|
||||
"""Run the model."""
|
||||
if self.disconnected:
|
||||
x = self.linear(x1).unsqueeze(1)
|
||||
else:
|
||||
x = self.linear(torch.cat((x1, x2), dim=-1)).unsqueeze(1)
|
||||
return self.output(x).squeeze(1)
|
||||
|
||||
def train(model, device, train_loader, optimizer, epoch):
|
||||
model.train()
|
||||
num_examples = 0
|
||||
for batch_idx, (data1, data2, target) in enumerate(train_loader):
|
||||
num_examples += target.shape[0]
|
||||
data1, data2, target = data1.to(device), data2.to(device), target.to(device)
|
||||
optimizer.zero_grad()
|
||||
output = model(data1, data2)
|
||||
loss = F.mse_loss(output.squeeze(1), target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if batch_idx % 2 == 0:
|
||||
print(
|
||||
f"Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}"
|
||||
f" ({100.0 * batch_idx / len(train_loader):.0f}%)]"
|
||||
f"\tLoss: {loss.item():.6f}"
|
||||
)
|
||||
|
||||
random_seed = 42 # TODO: 2960
|
||||
torch.manual_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
X, y = fetch_california_housing(return_X_y=True)
|
||||
|
||||
num_features = X.shape[1]
|
||||
x1 = X[:, num_features // 2 :]
|
||||
x2 = X[:, : num_features // 2]
|
||||
|
||||
data = TensorDataset(
|
||||
torch.tensor(x1).float(),
|
||||
torch.tensor(x2).float(),
|
||||
torch.tensor(y).float(),
|
||||
)
|
||||
|
||||
loader = DataLoader(data, batch_size=128)
|
||||
|
||||
model = Net(num_features, disconnected)
|
||||
optimizer = torch.optim.Adam(model.parameters())
|
||||
|
||||
device = torch.device(torch_device)
|
||||
|
||||
model.to(device)
|
||||
|
||||
train(model, device, loader, optimizer, 1)
|
||||
|
||||
next_x1, next_x2, _ = next(iter(loader))
|
||||
inds = rs.choice(next_x1.shape[0], 20, replace=False)
|
||||
background = [next_x1[inds, :].to(device), next_x2[inds, :].to(device)]
|
||||
e = shap.DeepExplainer(model, background)
|
||||
|
||||
test_x1_tmp, test_x2_tmp, _ = next(iter(loader))
|
||||
test_x1 = test_x1_tmp[:1].to(device)
|
||||
test_x2 = test_x2_tmp[:1].to(device)
|
||||
|
||||
shap_values = e.shap_values([test_x1[:1], test_x2[:1]])
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(test_x1, test_x2[:1]).detach().cpu().numpy()
|
||||
|
||||
# the shap values have the shape (num_samples, num_features, num_inputs, num_outputs)
|
||||
# so since we have just one output, we slice it out
|
||||
sums = shap_values[0].sum(1) + shap_values[1].sum(1)
|
||||
(
|
||||
np.testing.assert_allclose(sums + e.expected_value, outputs, atol=1e-3),
|
||||
"Sum of SHAP values does not match difference!",
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Unit tests for the Exact explainer."""
|
||||
|
||||
import pickle
|
||||
|
||||
import numpy as np
|
||||
from conftest import compare_numpy_outputs_against_baseline
|
||||
|
||||
import shap
|
||||
|
||||
from . import common
|
||||
|
||||
|
||||
def test_tabular_simple_case():
|
||||
import pytest
|
||||
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
sk = pytest.importorskip("sklearn")
|
||||
|
||||
model = xgboost.XGBClassifier(tree_method="exact", base_score=0.5)
|
||||
X, y = sk.datasets.make_classification(n_samples=100, n_features=2, n_informative=2, n_redundant=0, return_X_y=True)
|
||||
|
||||
X_train = X[:80]
|
||||
X_test = X[80:]
|
||||
y_train = y[:80]
|
||||
model.fit(X_train, y_train)
|
||||
ex = shap.explainers.ExactExplainer(model.predict_proba, X_train)
|
||||
shap_values = ex(X_test)
|
||||
|
||||
pred = model.predict_proba(X_test)
|
||||
# check additivity
|
||||
np.testing.assert_allclose(shap_values.base_values + shap_values.values.sum(axis=1), pred, atol=1e-6)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_interactions():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_interactions_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output_auto_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output_auto_masker_single_value():
|
||||
# This currently fails with an MemoryError, I assume due to having a different dimension than required!
|
||||
model, data = common.basic_xgboost_scenario(1)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output_auto_masker_minimal():
|
||||
model, data = common.basic_xgboost_scenario(2)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_multi_output_auto_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict_proba, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output_partition_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, shap.maskers.Partition(data), data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_multi_output_partition_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(
|
||||
shap.explainers.ExactExplainer, model.predict_proba, shap.maskers.Partition(data), data
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output_independent_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, shap.maskers.Independent(data), data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_multi_output_independent_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(
|
||||
shap.explainers.ExactExplainer, model.predict_proba, shap.maskers.Independent(data), data
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
return common.test_serialization(shap.explainers.ExactExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization_no_model_or_masker():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
return common.test_serialization(
|
||||
shap.explainers.ExactExplainer,
|
||||
model.predict,
|
||||
data,
|
||||
data,
|
||||
model_saver=False,
|
||||
masker_saver=False,
|
||||
model_loader=lambda _: model.predict,
|
||||
masker_loader=lambda _: data,
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization_no_model_or_masker_reduced():
|
||||
import pytest
|
||||
|
||||
X, y = shap.datasets.adult()
|
||||
X = X.iloc[:, :3]
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
data = X
|
||||
|
||||
model = xgboost.XGBClassifier(tree_method="exact", base_score=0.5, seed=42)
|
||||
model.fit(X, y)
|
||||
|
||||
return common.test_serialization(
|
||||
shap.explainers.ExactExplainer,
|
||||
model.predict,
|
||||
data,
|
||||
data,
|
||||
model_saver=False,
|
||||
masker_saver=False,
|
||||
model_loader=lambda _: model.predict,
|
||||
masker_loader=lambda _: data,
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization_custom_model_save():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
return common.test_serialization(
|
||||
shap.explainers.ExactExplainer, model.predict, data, data, model_saver=pickle.dump, model_loader=pickle.load
|
||||
)
|
||||
|
||||
|
||||
def test_multi_output_with_non_varying_features():
|
||||
"""Test 2D code path when some features don't vary from background.
|
||||
|
||||
This reproduces a bug in compute_grey_code_row_values_2d where the inner
|
||||
loop iterates over rv.shape(0) and indexes rv(rvi, ...) instead of
|
||||
iterating over inds.shape(0) and indexing rv(inds(rvi), ...).
|
||||
The bug is invisible when all features vary (inds == [0,1,...,M-1]),
|
||||
but causes wrong results when only a subset varies.
|
||||
"""
|
||||
# 4 features, multi-output model
|
||||
# Background: single sample so we can control exactly which features vary
|
||||
background = np.array([[0.0, 1.0, 2.0, 3.0]])
|
||||
|
||||
# Simple linear multi-output model: returns [sum_of_features, 2*sum_of_features]
|
||||
def model(X):
|
||||
s = X.sum(axis=1)
|
||||
return np.column_stack([s, 2 * s])
|
||||
|
||||
# Test sample: features 0 and 2 match the background, features 1 and 3 differ
|
||||
# So inds should be [1, 3] (only 2 of 4 features vary)
|
||||
test_x = np.array([[0.0, 5.0, 2.0, 7.0]])
|
||||
|
||||
explainer = shap.explainers.ExactExplainer(model, background)
|
||||
shap_values = explainer(test_x)
|
||||
|
||||
# Additivity check: base_values + sum(shap_values) == model prediction
|
||||
pred = model(test_x)
|
||||
reconstructed = shap_values.base_values + shap_values.values.sum(axis=1)
|
||||
np.testing.assert_allclose(reconstructed, pred, atol=1e-10)
|
||||
|
||||
# Non-varying features (0 and 2) should have zero SHAP values
|
||||
np.testing.assert_allclose(shap_values.values[0, 0, :], 0.0, atol=1e-10)
|
||||
np.testing.assert_allclose(shap_values.values[0, 2, :], 0.0, atol=1e-10)
|
||||
|
||||
# Varying features (1 and 3) should have non-zero SHAP values
|
||||
assert np.any(np.abs(shap_values.values[0, 1, :]) > 1e-10)
|
||||
assert np.any(np.abs(shap_values.values[0, 3, :]) > 1e-10)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for Explainer class."""
|
||||
|
||||
import pytest
|
||||
import sklearn
|
||||
|
||||
import shap
|
||||
|
||||
|
||||
def test_explainer_to_permutationexplainer():
|
||||
"""Checks that Explainer maps to PermutationExplainer as expected."""
|
||||
X_train, X_test, y_train, _ = sklearn.model_selection.train_test_split(
|
||||
*shap.datasets.adult(), test_size=0.1, random_state=0
|
||||
)
|
||||
lr = sklearn.linear_model.LogisticRegression(solver="liblinear")
|
||||
lr.fit(X_train, y_train)
|
||||
|
||||
explainer = shap.Explainer(lr.predict_proba, masker=X_train)
|
||||
assert isinstance(explainer, shap.PermutationExplainer)
|
||||
|
||||
# ensures a proper error message is raised if a masker is not provided (GH #3310)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"masker cannot be None",
|
||||
):
|
||||
explainer = shap.Explainer(lr.predict_proba)
|
||||
_ = explainer(X_test)
|
||||
|
||||
|
||||
def test_wrapping_for_text_to_text_teacher_forcing_model():
|
||||
"""This tests using the Explainer class to auto wrap a masker in a text to text scenario."""
|
||||
pytest.importorskip("torch")
|
||||
transformers = pytest.importorskip("transformers")
|
||||
|
||||
def f(x):
|
||||
pass
|
||||
|
||||
name = "hf-internal-testing/tiny-random-BartForCausalLM"
|
||||
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
|
||||
model = transformers.AutoModelForCausalLM.from_pretrained(name)
|
||||
wrapped_model = shap.models.TeacherForcing(f, similarity_model=model, similarity_tokenizer=tokenizer)
|
||||
masker = shap.maskers.Text(tokenizer, mask_token="...")
|
||||
|
||||
explainer = shap.Explainer(wrapped_model, masker, seed=1)
|
||||
|
||||
assert shap.utils.safe_isinstance(explainer.masker, "shap.maskers.OutputComposite")
|
||||
|
||||
|
||||
def test_transformers_label_to_id_mapping_enforces_ints():
|
||||
"""This tests that when we construct our TransformersPipeline, we enforce that label2id values are ints."""
|
||||
pytest.importorskip("torch")
|
||||
transformers = pytest.importorskip("transformers")
|
||||
|
||||
name = "distilbert/distilbert-base-uncased-finetuned-sst-2-english"
|
||||
pipe = transformers.pipeline("text-classification", name)
|
||||
|
||||
# Make the model label2id mapping have str values
|
||||
# to test that our TransformersPipeline converts them to int
|
||||
pipe.model.config.label2id = {k: str(v) for k, v in pipe.model.config.label2id.items()}
|
||||
|
||||
# Finish constructing the Explainer
|
||||
explainer = shap.Explainer(pipe, seed=1)
|
||||
|
||||
# Check that the label2id values are all ints after construction
|
||||
assert isinstance(explainer.model, shap.models.TransformersPipeline)
|
||||
assert all(isinstance(v, int) for v in explainer.model.label2id.values())
|
||||
|
||||
|
||||
def test_wrapping_for_topk_lm_model():
|
||||
"""This tests using the Explainer class to auto wrap a masker in a language modelling scenario."""
|
||||
pytest.importorskip("torch")
|
||||
transformers = pytest.importorskip("transformers")
|
||||
|
||||
name = "hf-internal-testing/tiny-random-BartForCausalLM"
|
||||
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
|
||||
model = transformers.AutoModelForCausalLM.from_pretrained(name)
|
||||
wrapped_model = shap.models.TopKLM(model, tokenizer)
|
||||
masker = shap.maskers.Text(tokenizer, mask_token="...")
|
||||
|
||||
explainer = shap.Explainer(wrapped_model, masker, seed=1)
|
||||
|
||||
assert shap.utils.safe_isinstance(explainer.masker, "shap.maskers.FixedComposite")
|
||||
|
||||
|
||||
def test_explainer_xgboost():
|
||||
"""Check the explainer class wraps a TreeExplainer as expected"""
|
||||
# train an XGBoost model
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
X, y = shap.datasets.california(n_points=500)
|
||||
model = xgboost.XGBRegressor().fit(X, y)
|
||||
|
||||
# explain the model's predictions
|
||||
explainer = shap.Explainer(model)
|
||||
explanation = explainer(X)
|
||||
|
||||
# check the properties of Explanation object
|
||||
assert explanation.values.shape == (*X.shape,) # type: ignore[union-attr]
|
||||
assert explanation.base_values.shape == (len(X),) # type: ignore[union-attr]
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Test gpu accelerated tree functions."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import sklearn
|
||||
|
||||
import shap
|
||||
from shap.explainers._tree import SingleTree, TreeEnsemble
|
||||
from shap.utils import assert_import
|
||||
|
||||
try:
|
||||
assert_import("cext_gpu")
|
||||
except ImportError:
|
||||
pytestmark = pytest.mark.skip("cuda module not built")
|
||||
|
||||
|
||||
def test_front_page_xgboost():
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
|
||||
# load JS visualization code to notebook
|
||||
shap.initjs()
|
||||
|
||||
# train XGBoost model
|
||||
X, y = shap.datasets.california(n_points=500)
|
||||
model = xgboost.train({"learning_rate": 0.01}, xgboost.DMatrix(X, label=y), 100)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.GPUTreeExplainer(model)
|
||||
shap_values = explainer.shap_values(X)
|
||||
|
||||
# visualize the first prediction's explanation
|
||||
shap.force_plot(explainer.expected_value, shap_values[0, :], X.iloc[0, :])
|
||||
|
||||
# visualize the training set predictions
|
||||
shap.force_plot(explainer.expected_value, shap_values, X)
|
||||
|
||||
# create a SHAP dependence plot to show the effect of a single feature across the whole dataset
|
||||
shap.dependence_plot(5, shap_values, X, show=False)
|
||||
shap.dependence_plot("Longitude", shap_values, X, show=False)
|
||||
|
||||
# summarize the effects of all the features
|
||||
shap.summary_plot(shap_values, X, show=False)
|
||||
|
||||
|
||||
rs = np.random.RandomState(15921)
|
||||
n = 100
|
||||
m = 4
|
||||
datasets = {
|
||||
"regression": (rs.randn(n, m), rs.randn(n)),
|
||||
"binary": (rs.randn(n, m), rs.binomial(1, 0.5, n)),
|
||||
"multiclass": (rs.randn(n, m), rs.randint(0, 5, n)),
|
||||
}
|
||||
|
||||
|
||||
def task_xfail(func):
|
||||
def inner():
|
||||
return pytest.param(func(), marks=pytest.mark.xfail)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def xgboost_base():
|
||||
try:
|
||||
import xgboost
|
||||
except ImportError:
|
||||
return pytest.param("xgboost.XGBRegressor", marks=pytest.mark.skip)
|
||||
X, y = datasets["regression"]
|
||||
|
||||
model = xgboost.XGBRegressor(tree_method="hist")
|
||||
model.fit(X, y)
|
||||
return model.get_booster(), X, model.predict(X)
|
||||
|
||||
|
||||
def xgboost_regressor():
|
||||
try:
|
||||
import xgboost
|
||||
except ImportError:
|
||||
return pytest.param("xgboost.XGBRegressor", marks=pytest.mark.skip)
|
||||
|
||||
X, y = datasets["regression"]
|
||||
|
||||
model = xgboost.XGBRegressor()
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X)
|
||||
|
||||
|
||||
def xgboost_binary_classifier():
|
||||
try:
|
||||
import xgboost
|
||||
except ImportError:
|
||||
return pytest.param("xgboost.XGBClassifier", marks=pytest.mark.skip)
|
||||
|
||||
X, y = datasets["binary"]
|
||||
|
||||
model = xgboost.XGBClassifier(eval_metric="error")
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X, output_margin=True)
|
||||
|
||||
|
||||
def xgboost_multiclass_classifier():
|
||||
try:
|
||||
import xgboost
|
||||
except ImportError:
|
||||
return pytest.param("xgboost.XGBClassifier", marks=pytest.mark.skip)
|
||||
|
||||
X, y = datasets["multiclass"]
|
||||
|
||||
model = xgboost.XGBClassifier()
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X, output_margin=True)
|
||||
|
||||
|
||||
def test_xgboost_cat_unsupported() -> None:
|
||||
xgboost = pytest.importorskip("xgboost")
|
||||
X, y = shap.datasets.adult()
|
||||
X["Workclass"] = X["Workclass"].astype("category")
|
||||
|
||||
clf = xgboost.XGBClassifier(n_estimators=2, enable_categorical=True, device="cuda")
|
||||
clf.fit(X, y)
|
||||
|
||||
# Tests for both CPU and GPU in one place
|
||||
|
||||
# Prefer an explict error over silent invalid values.
|
||||
gpu_ex = shap.GPUTreeExplainer(clf, X, feature_perturbation="interventional")
|
||||
with pytest.raises(NotImplementedError, match="Categorical"):
|
||||
gpu_ex.shap_values(X)
|
||||
|
||||
ex = shap.TreeExplainer(clf, X, feature_perturbation="interventional")
|
||||
with pytest.raises(NotImplementedError, match="Categorical"):
|
||||
ex.shap_values(X)
|
||||
|
||||
|
||||
def lightgbm_base():
|
||||
try:
|
||||
import lightgbm
|
||||
except ImportError:
|
||||
return pytest.param("lightgbm.LGBMRegressor", marks=pytest.mark.skip)
|
||||
X, y = datasets["regression"]
|
||||
|
||||
model = lightgbm.LGBMRegressor(n_jobs=1)
|
||||
model.fit(X, y)
|
||||
return model.booster_, X, model.predict(X)
|
||||
|
||||
|
||||
def lightgbm_regression():
|
||||
try:
|
||||
import lightgbm
|
||||
except ImportError:
|
||||
return pytest.param("lightgbm.LGBMRegressor", marks=pytest.mark.skip)
|
||||
X, y = datasets["regression"]
|
||||
|
||||
model = lightgbm.LGBMRegressor(n_jobs=1)
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X)
|
||||
|
||||
|
||||
def lightgbm_binary_classifier():
|
||||
try:
|
||||
import lightgbm
|
||||
except ImportError:
|
||||
return pytest.param("lightgbm.LGBMClassifier", marks=pytest.mark.skip)
|
||||
X, y = datasets["binary"]
|
||||
|
||||
model = lightgbm.LGBMClassifier(n_jobs=1)
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X, raw_score=True)
|
||||
|
||||
|
||||
def lightgbm_multiclass_classifier():
|
||||
try:
|
||||
import lightgbm
|
||||
except ImportError:
|
||||
return pytest.param("lightgbm.LGBMClassifier", marks=pytest.mark.skip)
|
||||
X, y = datasets["multiclass"]
|
||||
|
||||
model = lightgbm.LGBMClassifier(n_jobs=1)
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X, raw_score=True)
|
||||
|
||||
|
||||
def rf_regressor():
|
||||
X, y = datasets["regression"]
|
||||
model = sklearn.ensemble.RandomForestRegressor()
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict(X)
|
||||
|
||||
|
||||
def rf_binary_classifier():
|
||||
X, y = datasets["binary"]
|
||||
model = sklearn.ensemble.RandomForestClassifier()
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict_proba(X)
|
||||
|
||||
|
||||
def rf_multiclass_classifier():
|
||||
X, y = datasets["multiclass"]
|
||||
model = sklearn.ensemble.RandomForestClassifier()
|
||||
model.fit(X, y)
|
||||
return model, X, model.predict_proba(X)
|
||||
|
||||
|
||||
tasks = [
|
||||
xgboost_base(),
|
||||
xgboost_regressor(),
|
||||
xgboost_binary_classifier(),
|
||||
xgboost_multiclass_classifier(),
|
||||
lightgbm_base(),
|
||||
lightgbm_regression(),
|
||||
lightgbm_binary_classifier(),
|
||||
lightgbm_multiclass_classifier(),
|
||||
rf_binary_classifier(),
|
||||
rf_regressor(),
|
||||
rf_multiclass_classifier(),
|
||||
]
|
||||
|
||||
|
||||
# pretty print tasks
|
||||
def idfn(task):
|
||||
if isinstance(task, str):
|
||||
return task
|
||||
model, _, _ = task
|
||||
return type(model).__module__ + "." + type(model).__qualname__
|
||||
|
||||
|
||||
def assert_gpu_matches_cpu(task, feature_perturbation, X=None):
|
||||
model, background, _ = task
|
||||
if X is None:
|
||||
X = background
|
||||
|
||||
gpu_ex = shap.GPUTreeExplainer(model, background, feature_perturbation=feature_perturbation)
|
||||
ex = shap.TreeExplainer(model, background, feature_perturbation=feature_perturbation)
|
||||
host_shap = ex.shap_values(X, check_additivity=True)
|
||||
gpu_shap = gpu_ex.shap_values(X, check_additivity=True)
|
||||
|
||||
# todo: this should actually happen in the GPUTreeExplainer
|
||||
if np.array(gpu_shap).ndim == 3:
|
||||
gpu_shap = np.moveaxis(np.array(gpu_shap), [0, 1, 2], [2, 0, 1])
|
||||
else:
|
||||
gpu_shap = np.array(gpu_shap, copy=False)
|
||||
# Check outputs roughly the same as CPU algorithm
|
||||
assert np.allclose(ex.expected_value, gpu_ex.expected_value, 1e-3, 1e-3)
|
||||
assert np.allclose(host_shap, gpu_shap, 1e-3, 1e-3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", tasks, ids=idfn)
|
||||
@pytest.mark.parametrize("feature_perturbation", ["interventional", "tree_path_dependent"])
|
||||
def test_gpu_tree_explainer_shap(task, feature_perturbation):
|
||||
assert_gpu_matches_cpu(task, feature_perturbation)
|
||||
|
||||
|
||||
def test_gpu_tree_explainer_shap_with_missing_values():
|
||||
task = xgboost_base()
|
||||
X = task[1].copy()
|
||||
rows = np.arange(0, X.shape[0], 10)
|
||||
X[rows, 0] = np.nan
|
||||
|
||||
for feature_perturbation in ["interventional", "tree_path_dependent"]:
|
||||
assert_gpu_matches_cpu(task, feature_perturbation, X)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", tasks, ids=idfn)
|
||||
@pytest.mark.parametrize("feature_perturbation", ["tree_path_dependent"])
|
||||
def test_gpu_tree_explainer_shap_interactions(task, feature_perturbation):
|
||||
model, X, margin = task
|
||||
ex = shap.GPUTreeExplainer(model, X, feature_perturbation=feature_perturbation)
|
||||
shap_values = np.array(ex.shap_interaction_values(X), copy=False)
|
||||
|
||||
assert np.allclose(np.sum(shap_values, axis=(1, 2)) + ex.expected_value, margin, atol=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_interactions", [False, True])
|
||||
def test_lightgbm_categorical_split(use_interactions):
|
||||
# GH 480
|
||||
"""Checks that shap interaction values are computed without error when the LightGBM model has categorical splits."""
|
||||
lightgbm = pytest.importorskip("lightgbm")
|
||||
X, y = shap.datasets.california(n_points=10000)
|
||||
# Add HouseAgeGroup categorical variable
|
||||
target_variable = "HouseAge"
|
||||
X["HouseAgeGroup"] = pd.cut(
|
||||
X[target_variable],
|
||||
bins=[-float("inf"), 17, 27, 37, float("inf")],
|
||||
labels=[0, 1, 2, 3],
|
||||
right=False,
|
||||
).astype(int)
|
||||
model = lightgbm.LGBMRegressor(n_estimators=400, max_cat_to_onehot=1)
|
||||
model.fit(
|
||||
X, y, categorical_feature=[X.columns.get_loc("HouseAgeGroup")]
|
||||
) # Set HouseAgeGroup as categorical variable
|
||||
preds = model.predict(X, raw_score=True)
|
||||
|
||||
explainer = shap.GPUTreeExplainer(model)
|
||||
|
||||
if use_interactions:
|
||||
# Check SHAP interaction values sum to model output
|
||||
shap_interaction_values = explainer.shap_interaction_values(X.iloc[:10, :])
|
||||
assert np.allclose(shap_interaction_values.sum(axis=(1, 2)) + explainer.expected_value, preds[:10], atol=1e-4)
|
||||
else:
|
||||
shap_values = explainer.shap_values(X.iloc[:10, :])
|
||||
assert np.allclose(shap_values.sum(axis=1) + explainer.expected_value, preds[:10], atol=1e-4)
|
||||
|
||||
|
||||
def test_categorical_split_cpu_gpu_equivalence():
|
||||
"""
|
||||
Check consistency with a dummy tree that a single categorical split yields the same results on GPU and CPU.
|
||||
"""
|
||||
tree = {
|
||||
"children_left": np.array([1, -1, -1], dtype=np.int32),
|
||||
"children_right": np.array([2, -1, -1], dtype=np.int32),
|
||||
"children_default": np.array([1, -1, -1], dtype=np.int32),
|
||||
"features": np.array([0, -1, -1], dtype=np.int32),
|
||||
"thresholds": np.array([2.0, 0.0, 0.0], dtype=np.float64),
|
||||
"values": np.array([[0.8], [2.0], [-1.0]], dtype=np.float64),
|
||||
"node_sample_weight": np.array([100.0, 60.0, 40.0], dtype=np.float64),
|
||||
}
|
||||
single_tree = SingleTree(tree)
|
||||
single_tree.threshold_types = np.array([1, 0, 0], dtype=np.int32)
|
||||
ensemble = TreeEnsemble([single_tree], model_output="raw")
|
||||
ensemble.tree_output = "raw_value"
|
||||
ensemble.objective = "squared_error"
|
||||
|
||||
X = np.array([[0.0], [1.0], [2.0], [3.0]])
|
||||
cpu_explainer = shap.TreeExplainer(ensemble, feature_perturbation="tree_path_dependent")
|
||||
gpu_explainer = shap.GPUTreeExplainer(ensemble)
|
||||
shap_values_cpu = cpu_explainer.shap_values(X, check_additivity=False)
|
||||
shap_values_gpu = gpu_explainer.shap_values(X, check_additivity=False)
|
||||
np.testing.assert_allclose(shap_values_gpu, shap_values_cpu, atol=1e-5)
|
||||
|
||||
|
||||
def test_categorical_split_matches_binary_feature():
|
||||
"""
|
||||
Tests that using the categorical feature for SHAP value computation gives the same result as using a binary feature that routes the same way. We compare values computed on gpu and cpu here to check consistency.
|
||||
"""
|
||||
children_left = np.array([1, -1, -1], dtype=np.int32)
|
||||
children_right = np.array([2, -1, -1], dtype=np.int32)
|
||||
children_default = np.array([1, -1, -1], dtype=np.int32)
|
||||
features = np.array([0, -1, -1], dtype=np.int32)
|
||||
values = np.array([[0.8], [2.0], [-1.0]], dtype=np.float64)
|
||||
node_sample_weight = np.array([100.0, 60.0, 40.0], dtype=np.float64)
|
||||
|
||||
cat_tree = {
|
||||
"children_left": children_left,
|
||||
"children_right": children_right,
|
||||
"children_default": children_default,
|
||||
"features": features,
|
||||
"thresholds": np.array([1.0, 0.0, 0.0], dtype=np.float64),
|
||||
"values": values,
|
||||
"node_sample_weight": node_sample_weight,
|
||||
}
|
||||
cat_single = SingleTree(cat_tree)
|
||||
cat_single.threshold_types = np.array([1, 0, 0], dtype=np.int32)
|
||||
cat_ensemble = TreeEnsemble([cat_single], model_output="raw")
|
||||
cat_ensemble.tree_output = "raw_value"
|
||||
cat_ensemble.objective = "squared_error"
|
||||
|
||||
bin_tree = {
|
||||
"children_left": children_left,
|
||||
"children_right": children_right,
|
||||
"children_default": children_default,
|
||||
"features": features,
|
||||
"thresholds": np.array([0.5, 0.0, 0.0], dtype=np.float64),
|
||||
"values": values,
|
||||
"node_sample_weight": node_sample_weight,
|
||||
}
|
||||
bin_single = SingleTree(bin_tree)
|
||||
bin_ensemble = TreeEnsemble([bin_single], model_output="raw")
|
||||
bin_ensemble.tree_output = "raw_value"
|
||||
bin_ensemble.objective = "squared_error"
|
||||
|
||||
X_cat = np.array([[1.0], [2.0], [1.0], [2.0]])
|
||||
X_bin = X_cat - 1.0
|
||||
|
||||
cat_cpu = shap.TreeExplainer(cat_ensemble, feature_perturbation="tree_path_dependent").shap_values(
|
||||
X_cat, check_additivity=False
|
||||
)
|
||||
cat_gpu = shap.GPUTreeExplainer(cat_ensemble).shap_values(X_cat, check_additivity=False)
|
||||
np.testing.assert_allclose(cat_gpu, cat_cpu, atol=1e-5)
|
||||
|
||||
bin_cpu = shap.TreeExplainer(bin_ensemble, feature_perturbation="tree_path_dependent").shap_values(
|
||||
X_bin, check_additivity=False
|
||||
)
|
||||
bin_gpu = shap.GPUTreeExplainer(bin_ensemble).shap_values(X_bin, check_additivity=False)
|
||||
np.testing.assert_allclose(bin_gpu, bin_cpu, atol=1e-5)
|
||||
np.testing.assert_allclose(cat_gpu, bin_gpu, atol=1e-5)
|
||||
np.testing.assert_allclose(cat_cpu, cat_gpu, atol=1e-5)
|
||||
@@ -0,0 +1,472 @@
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from packaging import version
|
||||
|
||||
import shap
|
||||
|
||||
|
||||
def test_tf_keras_mnist_cnn_tf216_and_above(random_seed):
|
||||
"""This is the basic mnist cnn example from keras."""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
if version.parse(tf.__version__) < version.parse("2.16.0"):
|
||||
pytest.skip(
|
||||
"This test only works with tensorflow==2.16.1 and and above, see the test test_tf_keras_mnist_cnn_tf215_and_lower for lower tensorflow versions."
|
||||
)
|
||||
|
||||
rs = np.random.RandomState(random_seed)
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
|
||||
from tensorflow.compat.v1 import ConfigProto, InteractiveSession
|
||||
from tensorflow.keras import backend as K
|
||||
from tensorflow.keras.layers import (
|
||||
Activation,
|
||||
Conv2D,
|
||||
Dense,
|
||||
Dropout,
|
||||
Flatten,
|
||||
Input,
|
||||
MaxPooling2D,
|
||||
)
|
||||
from tensorflow.keras.models import Sequential
|
||||
|
||||
config = ConfigProto()
|
||||
config.gpu_options.allow_growth = True
|
||||
sess = InteractiveSession(config=config)
|
||||
|
||||
batch_size = 128
|
||||
num_classes = 10
|
||||
epochs = 1
|
||||
|
||||
# input image dimensions
|
||||
img_rows, img_cols = 28, 28
|
||||
|
||||
# the data, split between train and test sets
|
||||
# (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
|
||||
x_train = rs.randn(200, 28, 28)
|
||||
y_train = rs.randint(0, 9, 200)
|
||||
x_test = rs.randn(200, 28, 28)
|
||||
y_test = rs.randint(0, 9, 200)
|
||||
|
||||
if K.image_data_format() == "channels_first":
|
||||
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
|
||||
x_test = x_test.reshape(x_test.shape[0], 1, img_rows, img_cols)
|
||||
input_shape = (1, img_rows, img_cols)
|
||||
else:
|
||||
x_train = x_train.reshape(x_train.shape[0], img_rows, img_cols, 1)
|
||||
x_test = x_test.reshape(x_test.shape[0], img_rows, img_cols, 1)
|
||||
input_shape = (img_rows, img_cols, 1)
|
||||
|
||||
x_train = x_train.astype("float32")
|
||||
x_test = x_test.astype("float32")
|
||||
x_train /= 255
|
||||
x_test /= 255
|
||||
|
||||
# convert class vectors to binary class matrices
|
||||
y_train = tf.keras.utils.to_categorical(y_train, num_classes)
|
||||
y_test = tf.keras.utils.to_categorical(y_test, num_classes)
|
||||
|
||||
model = Sequential()
|
||||
model.add(Input(shape=input_shape))
|
||||
model.add(Conv2D(32, kernel_size=(3, 3), activation="relu"))
|
||||
model.add(Conv2D(64, (3, 3), activation="relu"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Dropout(0.25))
|
||||
model.add(Flatten())
|
||||
model.add(Dense(32, activation="relu")) # 128
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Dense(num_classes))
|
||||
model.add(Activation("softmax"))
|
||||
|
||||
model.compile(
|
||||
loss=tf.keras.losses.categorical_crossentropy, optimizer=tf.keras.optimizers.Adadelta(), metrics=["accuracy"]
|
||||
)
|
||||
|
||||
model.fit(
|
||||
x_train[:1000, :],
|
||||
y_train[:1000, :],
|
||||
batch_size=batch_size,
|
||||
epochs=epochs,
|
||||
verbose=1,
|
||||
validation_data=(x_test[:1000, :], y_test[:1000, :]),
|
||||
)
|
||||
|
||||
# explain by passing the tensorflow inputs and outputs
|
||||
inds = rs.choice(x_train.shape[0], 20, replace=False)
|
||||
e = shap.GradientExplainer((model.inputs, model.layers[-1].input), x_train[inds, :, :])
|
||||
shap_values = e.shap_values(x_test[:1], nsamples=2000)
|
||||
|
||||
model = tf.keras.Model(inputs=model.inputs, outputs=model.layers[-1].input)
|
||||
outputs = model(x_test[:1]).numpy()
|
||||
background = model(x_train[inds, :, :]).numpy()
|
||||
# outputs = sess.run(model.layers[-1].input, feed_dict={model.layers[0].input: x_test[:1]})
|
||||
# background = sess.run(model.layers[-1].input, feed_dict={model.layers[0].input: x_train[inds, :, :]})
|
||||
expected_value = background.mean(0)
|
||||
|
||||
sums = shap_values.sum((1, 2, 3)) # type: ignore[union-attr, union-attr]
|
||||
np.testing.assert_allclose(sums + expected_value, outputs, atol=1e-4)
|
||||
sess.close()
|
||||
|
||||
|
||||
def test_tf_keras_mnist_cnn_tf215_and_lower(random_seed):
|
||||
"""This is the basic mnist cnn example from keras."""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
if version.parse(tf.__version__) >= version.parse("2.16.0"):
|
||||
pytest.skip(
|
||||
"This test only works with tensorflow==2.15.1 and lower, see the test test_tf_keras_mnist_cnn_tf216_and_above for higher tensorflow versions."
|
||||
)
|
||||
|
||||
rs = np.random.RandomState(random_seed)
|
||||
tf.compat.v1.random.set_random_seed(random_seed)
|
||||
|
||||
from tensorflow.compat.v1 import ConfigProto, InteractiveSession
|
||||
from tensorflow.keras import backend as K
|
||||
from tensorflow.keras.layers import (
|
||||
Activation,
|
||||
Conv2D,
|
||||
Dense,
|
||||
Dropout,
|
||||
Flatten,
|
||||
MaxPooling2D,
|
||||
)
|
||||
from tensorflow.keras.models import Sequential
|
||||
|
||||
config = ConfigProto()
|
||||
config.gpu_options.allow_growth = True
|
||||
sess = InteractiveSession(config=config)
|
||||
|
||||
tf.compat.v1.disable_eager_execution()
|
||||
|
||||
batch_size = 128
|
||||
num_classes = 10
|
||||
epochs = 1
|
||||
|
||||
# input image dimensions
|
||||
img_rows, img_cols = 28, 28
|
||||
|
||||
# the data, split between train and test sets
|
||||
# (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
|
||||
x_train = rs.randn(200, 28, 28)
|
||||
y_train = rs.randint(0, 9, 200)
|
||||
x_test = rs.randn(200, 28, 28)
|
||||
y_test = rs.randint(0, 9, 200)
|
||||
|
||||
if K.image_data_format() == "channels_first":
|
||||
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
|
||||
x_test = x_test.reshape(x_test.shape[0], 1, img_rows, img_cols)
|
||||
input_shape = (1, img_rows, img_cols)
|
||||
else:
|
||||
x_train = x_train.reshape(x_train.shape[0], img_rows, img_cols, 1)
|
||||
x_test = x_test.reshape(x_test.shape[0], img_rows, img_cols, 1)
|
||||
input_shape = (img_rows, img_cols, 1)
|
||||
|
||||
x_train = x_train.astype("float32")
|
||||
x_test = x_test.astype("float32")
|
||||
x_train /= 255
|
||||
x_test /= 255
|
||||
|
||||
# convert class vectors to binary class matrices
|
||||
y_train = tf.keras.utils.to_categorical(y_train, num_classes)
|
||||
y_test = tf.keras.utils.to_categorical(y_test, num_classes)
|
||||
|
||||
model = Sequential()
|
||||
model.add(Conv2D(32, kernel_size=(3, 3), activation="relu", input_shape=input_shape))
|
||||
model.add(Conv2D(64, (3, 3), activation="relu"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Dropout(0.25))
|
||||
model.add(Flatten())
|
||||
model.add(Dense(32, activation="relu")) # 128
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Dense(num_classes))
|
||||
model.add(Activation("softmax"))
|
||||
|
||||
model.compile(
|
||||
loss=tf.keras.losses.categorical_crossentropy,
|
||||
optimizer=tf.keras.optimizers.legacy.Adadelta(),
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
|
||||
model.fit(
|
||||
x_train[:1000, :],
|
||||
y_train[:1000, :],
|
||||
batch_size=batch_size,
|
||||
epochs=epochs,
|
||||
verbose=1,
|
||||
validation_data=(x_test[:1000, :], y_test[:1000, :]),
|
||||
)
|
||||
|
||||
# explain by passing the tensorflow inputs and outputs
|
||||
inds = rs.choice(x_train.shape[0], 20, replace=False)
|
||||
e = shap.GradientExplainer((model.layers[0].input, model.layers[-1].input), x_train[inds, :, :])
|
||||
shap_values = e.shap_values(x_test[:1], nsamples=2000)
|
||||
|
||||
outputs = sess.run(model.layers[-1].input, feed_dict={model.layers[0].input: x_test[:1]})
|
||||
background = sess.run(model.layers[-1].input, feed_dict={model.layers[0].input: x_train[inds, :, :]})
|
||||
expected_value = background.mean(0)
|
||||
|
||||
sums = shap_values.sum((1, 2, 3)) # type: ignore[union-attr, union-attr]
|
||||
np.testing.assert_allclose(sums + expected_value, outputs, atol=1e-4)
|
||||
sess.close()
|
||||
|
||||
|
||||
def test_tf_multi_inputs_multi_outputs():
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
input1 = tf.keras.layers.Input(shape=(3,))
|
||||
input2 = tf.keras.layers.Input(shape=(4,))
|
||||
|
||||
# Concatenate input layers
|
||||
concatenated = tf.keras.layers.concatenate([input1, input2])
|
||||
|
||||
# Dense layers
|
||||
x = tf.keras.layers.Dense(16, activation="relu")(concatenated)
|
||||
|
||||
# Output layer
|
||||
output = tf.keras.layers.Dense(3, activation="softmax")(x)
|
||||
model = tf.keras.models.Model(inputs=[input1, input2], outputs=output)
|
||||
batch_size = 32
|
||||
# Generate random input data for input1 with shape (batch_size, 3)
|
||||
input1_data = np.random.rand(batch_size, 3)
|
||||
|
||||
# Generate random input data for input2 with shape (batch_size, 4)
|
||||
input2_data = np.random.rand(batch_size, 4)
|
||||
|
||||
predicted = model.predict([input1_data, input2_data])
|
||||
explainer = shap.GradientExplainer(model, [input1_data, input2_data])
|
||||
shap_values = explainer.shap_values([input1_data, input2_data])
|
||||
np.testing.assert_allclose(shap_values[0].sum(1) + shap_values[1].sum(1) + predicted.mean(0), predicted, atol=1e-1)
|
||||
|
||||
|
||||
def test_pytorch_mnist_cnn():
|
||||
"""The same test as above, but for pytorch"""
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 0
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
torch.manual_seed(random_seed)
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
|
||||
batch_size = 128
|
||||
|
||||
class RandData:
|
||||
"""Ranomd data for testing."""
|
||||
|
||||
def __init__(self, batch_size):
|
||||
self.current = 0
|
||||
self.batch_size = batch_size
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
self.current += 1
|
||||
if self.current < 10:
|
||||
return torch.randn(self.batch_size, 1, 28, 28), torch.randint(0, 9, (self.batch_size,))
|
||||
raise StopIteration
|
||||
|
||||
try:
|
||||
# train_loader = torch.utils.data.DataLoader(
|
||||
# datasets.MNIST(tmpdir, train=True, download=True,
|
||||
# transform=transforms.Compose([
|
||||
# transforms.ToTensor(),
|
||||
# transforms.Normalize((0.1307,), (0.3081,))
|
||||
# ])),
|
||||
# batch_size=batch_size, shuffle=True)
|
||||
# test_loader = torch.utils.data.DataLoader(
|
||||
# datasets.MNIST(tmpdir, train=False, download=True,
|
||||
# transform=transforms.Compose([
|
||||
# transforms.ToTensor(),
|
||||
# transforms.Normalize((0.1307,), (0.3081,))
|
||||
# ])),
|
||||
# batch_size=batch_size, shuffle=True)
|
||||
train_loader = RandData(batch_size)
|
||||
test_loader = RandData(batch_size)
|
||||
except HTTPError:
|
||||
pytest.skip()
|
||||
|
||||
def run_test(train_loader, test_loader, interim):
|
||||
class Net(nn.Module):
|
||||
"""A test model."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.conv1 = nn.Conv2d(1, 5, kernel_size=5)
|
||||
self.conv2 = nn.Conv2d(5, 10, kernel_size=5)
|
||||
self.conv2_drop = nn.Dropout2d()
|
||||
self.fc1 = nn.Linear(160, 20)
|
||||
self.fc2 = nn.Linear(20, 10)
|
||||
|
||||
def forward(self, x):
|
||||
"""Run the model."""
|
||||
x = F.relu(F.max_pool2d(self.conv1(x), 2))
|
||||
x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
|
||||
x = x.view(-1, 160)
|
||||
x = F.relu(self.fc1(x))
|
||||
x = F.dropout(x, training=self.training)
|
||||
x = self.fc2(x)
|
||||
return F.log_softmax(x, dim=1)
|
||||
|
||||
model = Net()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.5)
|
||||
|
||||
def train(model, device, train_loader, optimizer, _, cutoff=20):
|
||||
model.train()
|
||||
num_examples = 0
|
||||
for _, (data, target) in enumerate(train_loader):
|
||||
num_examples += target.shape[0]
|
||||
data, target = data.to(device), target.to(device)
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.nll_loss(output, target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
# if batch_idx % 10 == 0:
|
||||
# print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
|
||||
# epoch, batch_idx * len(data), len(train_loader.dataset),
|
||||
# 100. * batch_idx / len(train_loader), loss.item()
|
||||
# ))
|
||||
if num_examples > cutoff:
|
||||
break
|
||||
|
||||
device = torch.device("cpu")
|
||||
train(model, device, train_loader, optimizer, 1)
|
||||
|
||||
next_x, _ = next(iter(train_loader))
|
||||
inds = rs.choice(next_x.shape[0], 3, replace=False)
|
||||
if interim:
|
||||
e = shap.GradientExplainer((model, model.conv1), next_x[inds, :, :, :])
|
||||
else:
|
||||
e = shap.GradientExplainer(model, next_x[inds, :, :, :])
|
||||
test_x, _ = next(iter(test_loader))
|
||||
shap_values = e.shap_values(test_x[:1], nsamples=1000)
|
||||
|
||||
if not interim:
|
||||
# unlike deepLIFT, Integrated Gradients aren't necessarily consistent for interim layers
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
with torch.no_grad():
|
||||
outputs = model(test_x[:1]).detach().numpy()
|
||||
expected_value = model(next_x[inds, :, :, :]).detach().numpy().mean(0)
|
||||
sums = shap_values.sum(axis=(1, 2, 3)) # type: ignore[union-attr, union-attr]
|
||||
np.testing.assert_allclose(sums + expected_value, outputs, atol=1e-2)
|
||||
|
||||
print("Running test from interim layer")
|
||||
run_test(train_loader, test_loader, True)
|
||||
print("Running test on whole model")
|
||||
run_test(train_loader, test_loader, False)
|
||||
|
||||
|
||||
def test_pytorch_multiple_inputs(random_seed):
|
||||
"""Test multi-input scenarios."""
|
||||
torch = pytest.importorskip("torch")
|
||||
from torch import nn
|
||||
|
||||
torch.manual_seed(random_seed)
|
||||
batch_size = 10
|
||||
x1 = torch.ones(batch_size, 3)
|
||||
x2 = torch.ones(batch_size, 4)
|
||||
|
||||
background = [torch.zeros(batch_size, 3), torch.zeros(batch_size, 4)]
|
||||
|
||||
class Net(nn.Module):
|
||||
"""A test model."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.linear = nn.Linear(7, 1)
|
||||
|
||||
def forward(self, x1, x2):
|
||||
"""Run the model."""
|
||||
return self.linear(torch.cat((x1, x2), dim=-1))
|
||||
|
||||
model = Net()
|
||||
|
||||
e = shap.GradientExplainer(model, background)
|
||||
shap_values = e.shap_values([x1, x2])
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
with torch.no_grad():
|
||||
outputs = model(x1, x2).detach().numpy()
|
||||
expected_value = model(*background).detach().numpy().mean(0)
|
||||
|
||||
sums = np.sum([shap_values[i].sum(axis=1) for i in range(len(shap_values))], axis=0)
|
||||
np.testing.assert_allclose(sums + expected_value, outputs, atol=1e-2)
|
||||
|
||||
|
||||
def test_pytorch_multiple_inputs_multiple_outputs(random_seed):
|
||||
"""Test multi-input scenarios."""
|
||||
torch = pytest.importorskip("torch")
|
||||
from torch import nn
|
||||
|
||||
torch.manual_seed(random_seed)
|
||||
batch_size = 10
|
||||
|
||||
background = [torch.zeros(batch_size, 3), torch.zeros(batch_size, 4)]
|
||||
|
||||
class Net(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.fc = nn.Linear(7, 6) # Combined fully connected layer for both inputs
|
||||
|
||||
def forward(self, input1, input2):
|
||||
x = torch.cat((input1, input2), dim=1) # Concatenate both inputs
|
||||
x1 = self.fc(x) # Final processing
|
||||
return x1
|
||||
|
||||
model = Net()
|
||||
batch_size = 10
|
||||
input1 = torch.randn(batch_size, 3)
|
||||
input2 = torch.randn(batch_size, 4)
|
||||
model = Net()
|
||||
|
||||
e = shap.GradientExplainer(model, background)
|
||||
shap_values = e.shap_values([input1, input2])
|
||||
|
||||
model.eval()
|
||||
model.zero_grad()
|
||||
with torch.no_grad():
|
||||
outputs = model(input1, input2).detach().numpy()
|
||||
expected_value = model(*background).detach().numpy().mean(0)
|
||||
|
||||
sums = np.sum([shap_values[i].sum(axis=1) for i in range(len(shap_values))], axis=0)
|
||||
np.testing.assert_allclose(sums + expected_value, outputs, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_type", ["numpy", "dataframe"])
|
||||
def test_tf_input(random_seed, input_type):
|
||||
"""Test tabular (batch_size, features) pd.DataFrame and numpy input."""
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
tf.random.set_seed(random_seed)
|
||||
|
||||
batch_size = 10
|
||||
num_features = 5
|
||||
feature_names = [f"TF_pd_test_feature_{i}" for i in range(num_features)]
|
||||
|
||||
background = np.zeros((batch_size, num_features))
|
||||
if input_type == "dataframe":
|
||||
background = pd.DataFrame(background, columns=feature_names)
|
||||
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.Input(shape=(num_features,)),
|
||||
tf.keras.layers.Dense(10, activation="relu"),
|
||||
tf.keras.layers.Dense(1, activation="linear"),
|
||||
]
|
||||
)
|
||||
model.compile(optimizer="adam", loss="mse")
|
||||
|
||||
explainer = shap.GradientExplainer(model, background)
|
||||
example = np.ones((1, num_features))
|
||||
explanation = explainer(example)
|
||||
|
||||
diff = (model.predict(example) - model.predict(background)).mean(0)
|
||||
sums = np.array([values.sum() for values in explanation.values])
|
||||
d = np.abs(sums - diff).sum()
|
||||
assert d / (np.abs(diff).sum() + 0.01) < 0.1, "Sum of SHAP values does not match difference! %f" % (
|
||||
d / np.abs(diff).sum()
|
||||
)
|
||||
@@ -0,0 +1,397 @@
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import scipy.sparse
|
||||
import sklearn
|
||||
from conftest import compare_numpy_outputs_against_baseline
|
||||
|
||||
import shap
|
||||
|
||||
from . import common
|
||||
|
||||
|
||||
def sigm(x):
|
||||
return np.exp(x) / (1 + np.exp(x))
|
||||
|
||||
|
||||
def test_null_model_small():
|
||||
"""Test a small null model."""
|
||||
explainer = shap.KernelExplainer(lambda x: np.zeros(x.shape[0]), np.ones((2, 4)), nsamples=100)
|
||||
e = explainer.explain(np.ones((1, 4)))
|
||||
assert np.sum(np.abs(e)) < 1e-8
|
||||
|
||||
|
||||
def test_null_model():
|
||||
"""Test a larger null model."""
|
||||
explainer = shap.KernelExplainer(lambda x: np.zeros(x.shape[0]), np.ones((2, 10)), nsamples=100)
|
||||
e = explainer.explain(np.ones((1, 10)))
|
||||
assert np.sum(np.abs(e)) < 1e-8
|
||||
|
||||
|
||||
def test_front_page_model_agnostic():
|
||||
"""Test the ReadMe kernel expainer example."""
|
||||
# print the JS visualization code to the notebook
|
||||
shap.initjs()
|
||||
|
||||
# train a SVM classifier
|
||||
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
||||
*shap.datasets.iris(), test_size=0.1, random_state=0
|
||||
)
|
||||
svm = sklearn.svm.SVC(kernel="rbf", probability=True)
|
||||
svm.fit(X_train, Y_train)
|
||||
|
||||
# use Kernel SHAP to explain test set predictions
|
||||
explainer = shap.KernelExplainer(svm.predict_proba, X_train, nsamples=100, link="logit")
|
||||
shap_values = explainer.shap_values(X_test)
|
||||
|
||||
# plot the SHAP values for the Setosa output of the first instance
|
||||
# this is a multi output model so we index to get the zero-th output (Setosa)
|
||||
shap.force_plot(explainer.expected_value[0], shap_values[0, :, 0], X_test.iloc[0, :], link="logit") # type: ignore[index]
|
||||
|
||||
|
||||
def test_front_page_model_agnostic_rank():
|
||||
"""Test the rank regularized explanation of the ReadMe example."""
|
||||
# print the JS visualization code to the notebook
|
||||
shap.initjs()
|
||||
|
||||
# train a SVM classifier
|
||||
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
||||
*shap.datasets.iris(), test_size=0.1, random_state=0
|
||||
)
|
||||
svm = sklearn.svm.SVC(kernel="rbf", probability=True)
|
||||
svm.fit(X_train, Y_train)
|
||||
|
||||
# use Kernel SHAP to explain test set predictions
|
||||
explainer = shap.KernelExplainer(svm.predict_proba, X_train, nsamples=100, link="logit", l1_reg="rank(3)")
|
||||
shap_values = explainer.shap_values(X_test)
|
||||
|
||||
# plot the SHAP values for the Setosa output of the first instance
|
||||
shap.force_plot(explainer.expected_value[0], shap_values[0, :, 0], X_test.iloc[0, :], link="logit") # type: ignore[index]
|
||||
|
||||
|
||||
def test_kernel_shap_with_call_method():
|
||||
"""Test the __call__ method of the Kernel class"""
|
||||
# print the JS visualization code to the notebook
|
||||
shap.initjs()
|
||||
|
||||
# train a SVM classifier
|
||||
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
||||
*shap.datasets.iris(), test_size=0.1, random_state=0
|
||||
)
|
||||
svm = sklearn.svm.SVC(kernel="rbf", probability=True)
|
||||
svm.fit(X_train, Y_train)
|
||||
|
||||
# use Kernel SHAP to explain test set predictions
|
||||
explainer = shap.KernelExplainer(svm.predict_proba, X_train, nsamples=100, link="logit")
|
||||
shap_values = explainer(X_test)
|
||||
|
||||
# plot the SHAP values for the Versicolour output of the first instance
|
||||
shap.force_plot(shap_values[0, :, 1])
|
||||
|
||||
outputs = svm.predict_proba(X_test)
|
||||
# Call sigm since we use logit link
|
||||
np.testing.assert_allclose(sigm(shap_values.values.sum(1) + explainer.expected_value), outputs)
|
||||
|
||||
shap_values = explainer.shap_values(X_test) # type: ignore[assignment]
|
||||
np.testing.assert_allclose(sigm(shap_values.sum(1) + explainer.expected_value), outputs)
|
||||
|
||||
|
||||
def test_kernel_shap_with_dataframe(random_seed):
|
||||
"""Test with a Pandas DataFrame."""
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
df_X = pd.DataFrame(rs.random((10, 3)), columns=list("abc"))
|
||||
df_X.index = pd.date_range("2018-01-01", periods=10, freq="D", tz="UTC")
|
||||
|
||||
df_y = df_X.eval("a - 2 * b + 3 * c")
|
||||
df_y = df_y + rs.normal(0.0, 0.1, df_y.shape)
|
||||
|
||||
linear_model = sklearn.linear_model.LinearRegression()
|
||||
linear_model.fit(df_X, df_y)
|
||||
|
||||
explainer = shap.KernelExplainer(linear_model.predict, df_X, keep_index=True)
|
||||
_ = explainer.shap_values(df_X)
|
||||
|
||||
|
||||
def test_kernel_shap_with_dataframe_explanation(random_seed):
|
||||
"""Test with a Pandas DataFrame with Explanation API.
|
||||
|
||||
The Explanation.data is supposed to be a numpy array in many parts of the code,
|
||||
e.g., scatter plot will fail if it is not converted from pandas df to ndarray.
|
||||
|
||||
cf. GH #1625
|
||||
"""
|
||||
rs = np.random.RandomState(random_seed)
|
||||
|
||||
df_X = pd.DataFrame(rs.random((10, 3)), columns=list("abc"))
|
||||
df_y = df_X.eval("a - 2 * b + 3 * c")
|
||||
df_y = df_y + rs.normal(0.0, 0.1, df_y.shape)
|
||||
|
||||
linear_model = sklearn.linear_model.LinearRegression()
|
||||
linear_model.fit(df_X, df_y)
|
||||
|
||||
explainer = shap.KernelExplainer(linear_model.predict, df_X, keep_index=True)
|
||||
explanation = explainer(df_X)
|
||||
|
||||
# this shouldn't throw an error
|
||||
shap.plots.scatter(explanation[:, "a"], show=False)
|
||||
|
||||
|
||||
def test_kernel_shap_with_a1a_sparse_zero_background():
|
||||
"""Test with a sparse matrix for the background."""
|
||||
X, y = shap.datasets.a1a()
|
||||
x_train, x_test, y_train, _ = sklearn.model_selection.train_test_split(X, y, test_size=0.01, random_state=0)
|
||||
linear_model = sklearn.linear_model.LinearRegression()
|
||||
linear_model.fit(x_train, y_train)
|
||||
|
||||
_, cols = x_train.shape
|
||||
shape = 1, cols
|
||||
background = scipy.sparse.csr_matrix(shape, dtype=x_train.dtype)
|
||||
explainer = shap.KernelExplainer(linear_model.predict, background)
|
||||
explainer.shap_values(x_test)
|
||||
|
||||
|
||||
def test_kernel_shap_with_a1a_sparse_nonzero_background():
|
||||
"""Check with a sparse non zero background matrix."""
|
||||
np.set_printoptions(threshold=100000)
|
||||
|
||||
X, y = shap.datasets.a1a()
|
||||
x_train, x_test, y_train, _ = sklearn.model_selection.train_test_split(X, y, test_size=0.01, random_state=0)
|
||||
linear_model = sklearn.linear_model.LinearRegression()
|
||||
linear_model.fit(x_train, y_train)
|
||||
# Calculate median of background data
|
||||
median_dense = sklearn.utils.sparsefuncs.csc_median_axis_0(x_train.tocsc())
|
||||
median = scipy.sparse.csr_matrix(median_dense)
|
||||
explainer = shap.KernelExplainer(linear_model.predict, median)
|
||||
shap_values = explainer.shap_values(x_test)
|
||||
|
||||
def dense_to_sparse_predict(data):
|
||||
sparse_data = scipy.sparse.csr_matrix(data)
|
||||
return linear_model.predict(sparse_data)
|
||||
|
||||
explainer_dense = shap.KernelExplainer(dense_to_sparse_predict, median_dense.reshape((1, len(median_dense))))
|
||||
x_test_dense = x_test.toarray()
|
||||
shap_values_dense = explainer_dense.shap_values(x_test_dense)
|
||||
# Validate sparse and dense result is the same
|
||||
assert np.allclose(shap_values, shap_values_dense, rtol=1e-02, atol=1e-01)
|
||||
|
||||
|
||||
def test_kernel_shap_with_high_dim_sparse():
|
||||
"""Verifies we can run on very sparse data produced from feature hashing."""
|
||||
# Skip test for Python versions below 3.9.17 and 3.10.12
|
||||
python_version = sys.version_info
|
||||
if python_version.major == 3 and python_version.minor == 9 and (python_version.micro < 17):
|
||||
pytest.skip(
|
||||
"Skipping test for Python 3.9 versions below 3.9.17. Loading the dataset will run into a tarfile error otherwise due to the missing filter keyword. See https://docs.python.org/3.9/library/tarfile.html#tarfile.TarFile.extractall"
|
||||
)
|
||||
elif python_version.major == 3 and python_version.minor == 10 and (python_version.micro < 12):
|
||||
pytest.skip(
|
||||
"Skipping test for Python 3.10 versions below 3.10.12. Loading the dataset will run into a tarfile error otherwise due to missing filter keyword. See https://docs.python.org/3.10/library/tarfile.html#tarfile.TarFile.extractall"
|
||||
)
|
||||
|
||||
remove = ("headers", "footers", "quotes")
|
||||
categories = [
|
||||
"alt.atheism",
|
||||
"talk.religion.misc",
|
||||
"comp.graphics",
|
||||
"sci.space",
|
||||
]
|
||||
ngroups = sklearn.datasets.fetch_20newsgroups(
|
||||
subset="train", categories=categories, shuffle=True, random_state=42, remove=remove
|
||||
)
|
||||
x_train, x_test, y_train, _ = sklearn.model_selection.train_test_split(
|
||||
ngroups.data, ngroups.target, test_size=0.01, random_state=42
|
||||
)
|
||||
vectorizer = sklearn.feature_extraction.text.HashingVectorizer(
|
||||
stop_words="english", alternate_sign=False, n_features=2**16
|
||||
)
|
||||
x_train = vectorizer.transform(x_train)
|
||||
x_test = vectorizer.transform(x_test)
|
||||
# Fit a linear regression model
|
||||
linear_model = sklearn.linear_model.LinearRegression()
|
||||
linear_model.fit(x_train, y_train)
|
||||
_, cols = x_train.shape
|
||||
shape = 1, cols
|
||||
background = scipy.sparse.csr_matrix(shape, dtype=x_train.dtype)
|
||||
explainer = shap.KernelExplainer(linear_model.predict, background)
|
||||
_ = explainer.shap_values(x_test)
|
||||
|
||||
|
||||
def test_kernel_sparse_vs_dense_multirow_background():
|
||||
"""Mix sparse and dense matrix values."""
|
||||
# train a logistic regression classifier
|
||||
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
||||
*shap.datasets.iris(), test_size=0.1, random_state=0
|
||||
)
|
||||
lr = sklearn.linear_model.LogisticRegression(solver="lbfgs")
|
||||
lr.fit(X_train, Y_train)
|
||||
|
||||
# use Kernel SHAP to explain test set predictions with dense data
|
||||
explainer = shap.KernelExplainer(lr.predict_proba, X_train, nsamples=100, link="logit", l1_reg="rank(3)")
|
||||
shap_values = explainer.shap_values(X_test)
|
||||
|
||||
X_sparse_train = scipy.sparse.csr_matrix(X_train)
|
||||
X_sparse_test = scipy.sparse.csr_matrix(X_test)
|
||||
|
||||
lr_sparse = sklearn.linear_model.LogisticRegression(solver="lbfgs")
|
||||
lr_sparse.fit(X_sparse_train, Y_train)
|
||||
|
||||
# use Kernel SHAP again but with sparse data
|
||||
sparse_explainer = shap.KernelExplainer(
|
||||
lr.predict_proba, X_sparse_train, nsamples=100, link="logit", l1_reg="rank(3)"
|
||||
)
|
||||
sparse_shap_values = sparse_explainer.shap_values(X_sparse_test)
|
||||
|
||||
assert np.allclose(shap_values, sparse_shap_values, rtol=1e-05, atol=1e-05)
|
||||
|
||||
# Use sparse evaluation examples with dense background
|
||||
sparse_sv_dense_bg = explainer.shap_values(X_sparse_test)
|
||||
assert np.allclose(shap_values, sparse_sv_dense_bg, rtol=1e-05, atol=1e-05)
|
||||
|
||||
|
||||
def test_linear(random_seed):
|
||||
"""Tests that KernelExplainer returns the correct result when the model is linear.
|
||||
|
||||
(as per corollary 1 of https://arxiv.org/abs/1705.07874)
|
||||
"""
|
||||
rs = np.random.RandomState(random_seed)
|
||||
x = rs.normal(size=(200, 3), scale=1)
|
||||
|
||||
# a linear model
|
||||
def f(x):
|
||||
return x[:, 0] + 2.0 * x[:, 1]
|
||||
|
||||
explainer = shap.KernelExplainer(f, x)
|
||||
explanation = explainer(x, l1_reg="num_features(2)", silent=True)
|
||||
phi = explanation.values
|
||||
assert phi.shape == x.shape
|
||||
|
||||
# corollary 1
|
||||
expected = (x - x.mean(0)) * np.array([1.0, 2.0, 0.0])
|
||||
|
||||
np.testing.assert_allclose(expected, phi, rtol=1e-3)
|
||||
|
||||
|
||||
def test_non_numeric():
|
||||
"""Test using non-numeric data."""
|
||||
# create dummy data
|
||||
X = np.array([["A", "0", "0"], ["A", "1", "0"], ["B", "0", "0"], ["B", "1", "0"], ["A", "1", "0"]])
|
||||
y = np.array([0, 1, 2, 3, 4])
|
||||
|
||||
# build and train the pipeline
|
||||
pipeline = sklearn.pipeline.Pipeline(
|
||||
[("oneHotEncoder", sklearn.preprocessing.OneHotEncoder()), ("linear", sklearn.linear_model.LinearRegression())]
|
||||
)
|
||||
pipeline.fit(X, y)
|
||||
|
||||
# use KernelExplainer
|
||||
explainer = shap.KernelExplainer(pipeline.predict, X, nsamples=100)
|
||||
shap_values = explainer.explain(X[0, :].reshape(1, -1))
|
||||
|
||||
assert np.abs(explainer.expected_value + shap_values.sum(0) - pipeline.predict(X[0, :].reshape(1, -1))[0]) < 1e-4
|
||||
assert shap_values[2] == 0
|
||||
|
||||
# tests for shap.KernelExplainer.not_equal
|
||||
assert shap.KernelExplainer.not_equal(0, 0) == shap.KernelExplainer.not_equal("0", "0")
|
||||
assert shap.KernelExplainer.not_equal(0, 1) == shap.KernelExplainer.not_equal("0", "1")
|
||||
assert shap.KernelExplainer.not_equal(0, np.nan) == shap.KernelExplainer.not_equal("0", np.nan)
|
||||
assert shap.KernelExplainer.not_equal(0, np.nan) == shap.KernelExplainer.not_equal("0", None)
|
||||
assert shap.KernelExplainer.not_equal(np.nan, 0) == shap.KernelExplainer.not_equal(np.nan, "0")
|
||||
assert shap.KernelExplainer.not_equal(np.nan, 0) == shap.KernelExplainer.not_equal(None, "0")
|
||||
assert shap.KernelExplainer.not_equal("ab", "bc")
|
||||
assert not shap.KernelExplainer.not_equal("ab", "ab")
|
||||
assert shap.KernelExplainer.not_equal(pd.Timestamp("2017-01-01T12"), pd.Timestamp("2017-01-01T13"))
|
||||
assert not shap.KernelExplainer.not_equal(pd.Timestamp("2017-01-01T12"), pd.Timestamp("2017-01-01T12"))
|
||||
assert shap.KernelExplainer.not_equal(pd.Timestamp("2017-01-01T12"), pd.Timestamp("2017-01-01T13"))
|
||||
assert shap.KernelExplainer.not_equal(pd.Period("4Q2005"), pd.Period("3Q2005"))
|
||||
assert not shap.KernelExplainer.not_equal(pd.Period("4Q2005"), pd.Period("4Q2005"))
|
||||
|
||||
|
||||
def test_kernel_explainer_with_tensors():
|
||||
# GH 3492
|
||||
tf = pytest.importorskip("tensorflow")
|
||||
tf.compat.v1.disable_eager_execution()
|
||||
|
||||
X, _ = sklearn.datasets.make_classification(100, 6)
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.Dense(10, input_shape=(6,), activation="relu"),
|
||||
tf.keras.layers.Dense(1, activation="sigmoid"),
|
||||
]
|
||||
)
|
||||
model.compile(optimizer="adam", loss="binary_crossentropy")
|
||||
explainer = shap.KernelExplainer(model, X)
|
||||
explainer.shap_values(X[:1])
|
||||
|
||||
|
||||
def test_kernel_multiclass_single_row():
|
||||
"""Check a multi-input scenario."""
|
||||
X, y = shap.datasets.iris()
|
||||
|
||||
lr = sklearn.linear_model.LogisticRegression(solver="lbfgs")
|
||||
lr.fit(X, y)
|
||||
pred = lr.predict_proba(X.iloc[[0], :])
|
||||
|
||||
explainer = shap.KernelExplainer(lr.predict_proba, X)
|
||||
shap_values = explainer(X.iloc[0, :])
|
||||
np.testing.assert_allclose(shap_values.values.sum(0) + explainer.expected_value, pred.squeeze(), atol=1e-04)
|
||||
|
||||
|
||||
def test_kernel_multiclass_multiple_rows():
|
||||
"""Check a multi-input scenario."""
|
||||
X, y = shap.datasets.iris()
|
||||
|
||||
lr = sklearn.linear_model.LogisticRegression(solver="lbfgs")
|
||||
lr.fit(X, y)
|
||||
pred = lr.predict_proba(X.iloc[[0, 1], :])
|
||||
|
||||
explainer = shap.KernelExplainer(lr.predict_proba, X)
|
||||
shap_values = explainer(X.iloc[[0, 1], :])
|
||||
np.testing.assert_allclose(shap_values.values.sum(1) + explainer.expected_value, pred, atol=1e-04)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nsamples", [3, 5, 10, 100])
|
||||
def test_kernel_logits_zeros_ones_probs(nsamples):
|
||||
# GH 3912
|
||||
iris = sklearn.datasets.load_iris(as_frame=True)
|
||||
X_train, X_test, y_train, y_test = sklearn.model_selection.train_test_split(
|
||||
iris.data, iris.target, test_size=0.1, random_state=42
|
||||
)
|
||||
background_data = X_train.sample(10, random_state=42)
|
||||
|
||||
rf = sklearn.ensemble.RandomForestClassifier(random_state=42)
|
||||
rf.fit(X_train, y_train)
|
||||
|
||||
X_test_sampled = X_test[:nsamples]
|
||||
|
||||
explainer = shap.KernelExplainer(
|
||||
model=rf.predict_proba,
|
||||
data=background_data,
|
||||
keep_index=True,
|
||||
link="logit",
|
||||
)
|
||||
shap_values = explainer(X_test_sampled)
|
||||
pred = rf.predict_proba(X_test_sampled)
|
||||
|
||||
np.testing.assert_allclose(sigm(shap_values.values.sum(1) + explainer.expected_value), pred, atol=1e-04)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dt", [bool, object])
|
||||
def test_explainer_non_number_dtype(dt):
|
||||
seed = 45479
|
||||
rng = np.random.default_rng(seed)
|
||||
X = rng.choice([True, False], size=(15, 8)).astype(dt)
|
||||
y = rng.choice([True, False], size=(15,)).astype(float)
|
||||
rf = sklearn.ensemble.RandomForestClassifier(random_state=seed)
|
||||
rf.fit(X, y)
|
||||
explainer = shap.KernelExplainer(model=rf.predict_proba, data=X, random_state=seed)
|
||||
shap_values = explainer(X)
|
||||
np.testing.assert_allclose(shap_values.values.max(), 0.26548, rtol=1e-2)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization():
|
||||
model, data = common.basic_sklearn_scenario()
|
||||
return common.test_serialization(shap.explainers.KernelExplainer, model.predict, data, data)
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Unit tests for the Linear explainer."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import scipy.special
|
||||
from sklearn.datasets import make_multilabel_classification
|
||||
from sklearn.linear_model import LogisticRegression, Ridge
|
||||
|
||||
import shap
|
||||
import shap.maskers
|
||||
from shap import maskers
|
||||
from shap.utils._exceptions import InvalidFeaturePerturbationError
|
||||
|
||||
|
||||
def test_tied_pair():
|
||||
beta = np.array([1, 0, 0])
|
||||
mu = np.zeros(3)
|
||||
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
||||
X = np.ones((1, 3))
|
||||
masker = maskers.Impute({"mean": mu, "cov": Sigma})
|
||||
explainer = shap.LinearExplainer((beta, 0), masker)
|
||||
assert np.abs(explainer.shap_values(X) - np.array([0.5, 0.5, 0])).max() < 0.05
|
||||
|
||||
|
||||
def test_tied_pair_independent():
|
||||
beta = np.array([1, 0, 0])
|
||||
mu = np.zeros(3)
|
||||
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
||||
X = np.ones((1, 3))
|
||||
masker = maskers.Independent({"mean": mu, "cov": Sigma})
|
||||
explainer = shap.LinearExplainer((beta, 0), masker)
|
||||
assert np.abs(explainer.shap_values(X) - np.array([1, 0, 0])).max() < 0.05
|
||||
|
||||
|
||||
def test_tied_pair_new():
|
||||
beta = np.array([1, 0, 0])
|
||||
mu = np.zeros(3)
|
||||
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
||||
X = np.ones((1, 3))
|
||||
explainer = shap.explainers.LinearExplainer((beta, 0), shap.maskers.Impute({"mean": mu, "cov": Sigma}))
|
||||
assert np.abs(explainer.shap_values(X) - np.array([0.5, 0.5, 0])).max() < 0.05
|
||||
|
||||
|
||||
def test_wrong_masker():
|
||||
with pytest.raises(NotImplementedError):
|
||||
shap.explainers.LinearExplainer((0, 0), shap.maskers.Fixed())
|
||||
|
||||
|
||||
def test_tied_triple():
|
||||
beta = np.array([0, 1, 0, 0])
|
||||
mu = 1 * np.ones(4)
|
||||
Sigma = np.array([[1, 0.999999, 0.999999, 0], [0.999999, 1, 0.999999, 0], [0.999999, 0.999999, 1, 0], [0, 0, 0, 1]])
|
||||
X = 2 * np.ones((1, 4))
|
||||
masker = maskers.Impute({"mean": mu, "cov": Sigma})
|
||||
explainer = shap.LinearExplainer((beta, 0), masker)
|
||||
assert explainer.expected_value == 1
|
||||
assert np.abs(explainer.shap_values(X) - np.array([0.33333, 0.33333, 0.33333, 0])).max() < 0.05
|
||||
|
||||
|
||||
def test_sklearn_linear():
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X)
|
||||
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
||||
explainer.shap_values(X)
|
||||
|
||||
|
||||
def test_sklearn_linear_old_style():
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, maskers.Independent(X))
|
||||
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
||||
explainer.shap_values(X)
|
||||
|
||||
|
||||
def test_sklearn_linear_new():
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.explainers.LinearExplainer(model, X)
|
||||
shap_values = explainer(X)
|
||||
assert np.abs(shap_values.values.sum(1) + shap_values.base_values - model.predict(X)).max() < 1e-6 # type: ignore[union-attr, union-attr]
|
||||
assert np.abs(shap_values.base_values[0] - model.predict(X).mean()) < 1e-6 # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_sklearn_multiclass_no_intercept():
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
|
||||
# make y multiclass
|
||||
multiclass_y = np.expand_dims(y, axis=-1)
|
||||
model = Ridge(fit_intercept=False)
|
||||
model.fit(X, multiclass_y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X)
|
||||
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
||||
explainer.shap_values(X)
|
||||
|
||||
|
||||
def test_perfect_colinear():
|
||||
LinearRegression = pytest.importorskip("sklearn.linear_model").LinearRegression
|
||||
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
X.iloc[:, 0] = X.iloc[:, 4] # test duplicated features
|
||||
X.iloc[:, 5] = X.iloc[:, 6] - X.iloc[:, 6] # test multiple colinear features
|
||||
X.iloc[:, 3] = 0 # test null features
|
||||
model = LinearRegression()
|
||||
model.fit(X, y)
|
||||
explainer = shap.LinearExplainer(model, maskers.Impute(X))
|
||||
shap_values = explainer.shap_values(X)
|
||||
assert np.abs(shap_values.sum(1) - model.predict(X) + model.predict(X).mean()).sum() < 1e-7
|
||||
|
||||
|
||||
def test_shape_values_linear_many_features():
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
coef = np.array([1, 2]).T
|
||||
|
||||
# FIXME: this test should ideally pass with any random seed. See #2960
|
||||
random_seed = 0
|
||||
rs = np.random.RandomState(random_seed)
|
||||
# generate linear data
|
||||
X = rs.normal(1, 10, size=(1000, len(coef)))
|
||||
y = np.dot(X, coef) + 1 + rs.normal(scale=0.1, size=1000)
|
||||
|
||||
# train linear model
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X.mean(0).reshape(1, -1))
|
||||
|
||||
values = explainer.shap_values(X)
|
||||
|
||||
assert values.shape == (1000, 2)
|
||||
|
||||
expected = (X - X.mean(0)) * coef
|
||||
np.testing.assert_allclose(expected - values, 0, atol=0.01)
|
||||
|
||||
|
||||
def test_single_feature(random_seed):
|
||||
"""Make sure things work with a univariate linear regression."""
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# generate linear data
|
||||
rs = np.random.RandomState(random_seed)
|
||||
X = rs.normal(1, 10, size=(100, 1))
|
||||
y = 2 * X[:, 0] + 1 + rs.normal(scale=0.1, size=100)
|
||||
|
||||
# train linear model
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X)
|
||||
shap_values = explainer.shap_values(X)
|
||||
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
||||
assert np.max(np.abs(explainer.expected_value + shap_values.sum(1) - model.predict(X))) < 1e-6
|
||||
|
||||
|
||||
def test_sparse():
|
||||
"""Validate running LinearExplainer on scipy sparse data"""
|
||||
n_features = 20
|
||||
X, y = make_multilabel_classification(n_samples=100, sparse=True, n_features=n_features, n_classes=1, n_labels=2)
|
||||
|
||||
# train linear model
|
||||
model = LogisticRegression()
|
||||
model.fit(X, y.squeeze())
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X)
|
||||
shap_values = explainer.shap_values(X)
|
||||
assert (
|
||||
np.max(
|
||||
np.abs(scipy.special.expit(explainer.expected_value + shap_values.sum(1)) - model.predict_proba(X)[:, 1])
|
||||
)
|
||||
< 1e-6
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="This should pass but it doesn't.")
|
||||
def test_sparse_multi_class():
|
||||
"""Validate running LinearExplainer on scipy sparse data"""
|
||||
n_features = 4
|
||||
X, y = make_multilabel_classification(n_samples=100, sparse=False, n_features=n_features, n_classes=3, n_labels=2)
|
||||
y = np.argmax(y, axis=1)
|
||||
|
||||
# train linear model
|
||||
model = LogisticRegression(max_iter=1000)
|
||||
model.fit(X, y)
|
||||
pred = model.predict_proba(X)
|
||||
|
||||
# explain the model's predictions using SHAP values
|
||||
explainer = shap.LinearExplainer(model, X)
|
||||
shap_values = explainer(X)
|
||||
np.testing.assert_allclose(
|
||||
scipy.special.expit(shap_values.values.sum(1) + shap_values.base_values), # type: ignore[union-attr]
|
||||
pred,
|
||||
atol=1e-6,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:The feature_perturbation option is now deprecated")
|
||||
def test_invalid_feature_perturbation_raises():
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
model = Ridge(0.1).fit(X, y)
|
||||
|
||||
with pytest.raises(InvalidFeaturePerturbationError, match="feature_perturbation must be one of "):
|
||||
shap.LinearExplainer(model, X, feature_perturbation="nonsense") # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:The feature_perturbation option is now deprecated")
|
||||
@pytest.mark.parametrize(
|
||||
"feature_pertubation,masker",
|
||||
[
|
||||
(None, shap.maskers.Independent),
|
||||
("interventional", shap.maskers.Independent),
|
||||
("correlation_dependent", shap.maskers.Impute),
|
||||
],
|
||||
)
|
||||
def test_feature_perturbation_sets_correct_masker(feature_pertubation, masker):
|
||||
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.california(n_points=100)
|
||||
model = Ridge(0.1)
|
||||
model.fit(X, y)
|
||||
|
||||
explainer = shap.explainers.LinearExplainer(model, X, feature_perturbation=feature_pertubation)
|
||||
assert isinstance(explainer.masker, masker)
|
||||
|
||||
|
||||
def test_interventional_multi_regression():
|
||||
ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
||||
|
||||
# train linear model
|
||||
X, y = shap.datasets.linnerud(n_points=100)
|
||||
model = ridge(0.1)
|
||||
model.fit(X, y)
|
||||
outputs = model.predict(X)
|
||||
|
||||
explainer = shap.explainers.LinearExplainer(model, maskers.Independent(X))
|
||||
shap_values = explainer.shap_values(X)
|
||||
assert np.allclose(shap_values.sum(1) + explainer.expected_value, outputs, atol=1e-6)
|
||||
|
||||
|
||||
def test_linear_explainer_warns_singular_covariance():
|
||||
"""LinearExplainer should warn when n_samples <= n_features."""
|
||||
import warnings
|
||||
|
||||
from sklearn.linear_model import LinearRegression
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
n_features = 10
|
||||
X_train = rng.normal(size=(8, n_features))
|
||||
y_train = X_train @ np.arange(1, n_features + 1, dtype=float)
|
||||
model = LinearRegression().fit(X_train, y_train)
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
shap.LinearExplainer(
|
||||
model,
|
||||
X_train,
|
||||
feature_perturbation="correlation_dependent",
|
||||
)
|
||||
user_warnings = [
|
||||
x for x in w if issubclass(x.category, UserWarning) and "singular covariance" in str(x.message).lower()
|
||||
]
|
||||
|
||||
assert len(user_warnings) == 1, (
|
||||
f"Expected a UserWarning about singular covariance matrix but got: {[str(x.message) for x in w]}"
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""This file contains tests for partition explainer."""
|
||||
|
||||
import pickle
|
||||
|
||||
from conftest import compare_numpy_outputs_against_baseline
|
||||
|
||||
import shap
|
||||
|
||||
from . import common
|
||||
|
||||
|
||||
def test_translation(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_additivity(shap.explainers.PartitionExplainer, model, tokenizer, data)
|
||||
|
||||
|
||||
def test_translation_auto(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_additivity(shap.Explainer, model, tokenizer, data)
|
||||
|
||||
|
||||
def test_translation_algorithm_arg(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_additivity(shap.Explainer, model, tokenizer, data, algorithm="partition")
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_single_output():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(shap.explainers.PartitionExplainer, model.predict, shap.maskers.Partition(data), data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_tabular_multi_output():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
return common.test_additivity(
|
||||
shap.explainers.PartitionExplainer, model.predict_proba, shap.maskers.Partition(data), data
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_serialization(shap.explainers.PartitionExplainer, model, tokenizer, data)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization_no_model_or_masker(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_serialization(
|
||||
shap.explainers.Partition,
|
||||
model,
|
||||
tokenizer,
|
||||
data,
|
||||
model_saver=None,
|
||||
masker_saver=None,
|
||||
model_loader=lambda _: model,
|
||||
masker_loader=lambda _: tokenizer,
|
||||
)
|
||||
|
||||
|
||||
@compare_numpy_outputs_against_baseline(func_file=__file__)
|
||||
def test_serialization_custom_model_save(basic_translation_scenario):
|
||||
model, tokenizer, data = basic_translation_scenario
|
||||
return common.test_serialization(
|
||||
shap.explainers.PartitionExplainer, model, tokenizer, data, model_saver=pickle.dump, model_loader=pickle.load
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Unit tests for the Permutation explainer."""
|
||||
|
||||
import pickle
|
||||
|
||||
import numpy as np
|
||||
|
||||
import shap
|
||||
|
||||
from . import common
|
||||
|
||||
|
||||
def test_exact_second_order():
|
||||
"""This tests that the Perumtation explain gives exact answers for second order functions."""
|
||||
rs = np.random.RandomState(42)
|
||||
data = rs.randint(0, 2, size=(100, 5))
|
||||
|
||||
def model(data):
|
||||
return data[:, 0] * data[:, 2] + data[:, 1] + data[:, 2] + data[:, 2] * data[:, 3]
|
||||
|
||||
right_answer = np.zeros(data.shape)
|
||||
right_answer[:, 0] += (data[:, 0] * data[:, 2]) / 2
|
||||
right_answer[:, 2] += (data[:, 0] * data[:, 2]) / 2
|
||||
right_answer[:, 1] += data[:, 1]
|
||||
right_answer[:, 2] += data[:, 2]
|
||||
right_answer[:, 2] += (data[:, 2] * data[:, 3]) / 2
|
||||
right_answer[:, 3] += (data[:, 2] * data[:, 3]) / 2
|
||||
shap_values = shap.explainers.PermutationExplainer(model, np.zeros((1, 5)))(data)
|
||||
|
||||
assert np.allclose(right_answer, shap_values.values) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# TODO: add baseline comparison once PermutationExplainer supports passing a numpy.random.Generator
|
||||
# for reproducible results (currently uses global np.random state)
|
||||
def test_tabular_single_output_auto_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(shap.explainers.PermutationExplainer, model.predict, data, data)
|
||||
|
||||
|
||||
def test_tabular_multi_output_auto_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(shap.explainers.PermutationExplainer, model.predict_proba, data, data)
|
||||
|
||||
|
||||
def test_tabular_single_output_partition_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(shap.explainers.PermutationExplainer, model.predict, shap.maskers.Partition(data), data)
|
||||
|
||||
|
||||
def test_tabular_multi_output_partition_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(
|
||||
shap.explainers.PermutationExplainer, model.predict_proba, shap.maskers.Partition(data), data
|
||||
)
|
||||
|
||||
|
||||
def test_tabular_single_output_independent_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(shap.explainers.PermutationExplainer, model.predict, shap.maskers.Independent(data), data)
|
||||
|
||||
|
||||
def test_tabular_multi_output_independent_masker():
|
||||
model, data = common.basic_xgboost_scenario(100)
|
||||
common.test_additivity(
|
||||
shap.explainers.PermutationExplainer, model.predict_proba, shap.maskers.Independent(data), data
|
||||
)
|
||||
|
||||
|
||||
def test_serialization():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
common.test_serialization(
|
||||
shap.explainers.PermutationExplainer, model.predict, data, data, rtol=0.1, atol=0.05, max_evals=100000
|
||||
)
|
||||
|
||||
|
||||
def test_serialization_no_model_or_masker():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
common.test_serialization(
|
||||
shap.explainers.PermutationExplainer,
|
||||
model.predict,
|
||||
data,
|
||||
data,
|
||||
model_saver=False,
|
||||
masker_saver=False,
|
||||
model_loader=lambda _: model.predict,
|
||||
masker_loader=lambda _: data,
|
||||
rtol=0.1,
|
||||
atol=0.05,
|
||||
max_evals=100000,
|
||||
)
|
||||
|
||||
|
||||
def test_serialization_custom_model_save():
|
||||
model, data = common.basic_xgboost_scenario()
|
||||
common.test_serialization(
|
||||
shap.explainers.PermutationExplainer,
|
||||
model.predict,
|
||||
data,
|
||||
data,
|
||||
model_saver=pickle.dump,
|
||||
model_loader=pickle.load,
|
||||
rtol=0.1,
|
||||
atol=0.05,
|
||||
max_evals=100000,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for the Sampling explainer."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import shap
|
||||
|
||||
|
||||
def test_null_model_small():
|
||||
explainer = shap.SamplingExplainer(lambda x: np.zeros(x.shape[0]), np.ones((2, 4)), nsamples=100)
|
||||
shap_values = explainer.shap_values(np.ones((1, 4)))
|
||||
assert np.sum(np.abs(shap_values)) < 1e-8
|
||||
|
||||
|
||||
def test_null_model_small_pandas_dataframe():
|
||||
explainer = shap.SamplingExplainer(lambda x: pd.DataFrame(np.zeros(x.shape[0])), np.ones((2, 4)), nsamples=100)
|
||||
shap_values = explainer.shap_values(np.ones((1, 4)))
|
||||
assert np.sum(np.abs(shap_values)) < 1e-8
|
||||
|
||||
|
||||
def test_null_model_small_pandas_series():
|
||||
explainer = shap.SamplingExplainer(lambda x: pd.Series(np.zeros(x.shape[0])), np.ones((2, 4)), nsamples=100)
|
||||
shap_values = explainer.shap_values(np.ones((1, 4)))
|
||||
assert np.sum(np.abs(shap_values)) < 1e-8
|
||||
|
||||
|
||||
def test_null_model_small_new():
|
||||
explainer = shap.explainers.SamplingExplainer(lambda x: np.zeros(x.shape[0]), np.ones((2, 4)), nsamples=100)
|
||||
shap_values = explainer(np.ones((1, 4)))
|
||||
assert np.sum(np.abs(shap_values.values)) < 1e-8
|
||||
|
||||
|
||||
def test_null_model():
|
||||
explainer = shap.SamplingExplainer(lambda x: np.zeros(x.shape[0]), np.ones((2, 10)), nsamples=100)
|
||||
shap_values = explainer.shap_values(np.ones((1, 10)))
|
||||
assert np.sum(np.abs(shap_values)) < 1e-8
|
||||
|
||||
|
||||
def test_front_page_model_agnostic():
|
||||
sklearn = pytest.importorskip("sklearn")
|
||||
train_test_split = pytest.importorskip("sklearn.model_selection").train_test_split
|
||||
|
||||
# print the JS visualization code to the notebook
|
||||
shap.initjs()
|
||||
|
||||
# train a SVM classifier
|
||||
X_train, X_test, Y_train, _ = train_test_split(*shap.datasets.iris(), test_size=0.2, random_state=0)
|
||||
svm = sklearn.svm.SVC(kernel="rbf", probability=True)
|
||||
svm.fit(X_train, Y_train)
|
||||
|
||||
# use Kernel SHAP to explain test set predictions
|
||||
explainer = shap.SamplingExplainer(svm.predict_proba, X_train, nsamples=100)
|
||||
explainer.shap_values(X_test)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user