chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:52 +08:00
commit 9d4c7d16ba
528 changed files with 740585 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""This modules tests all the explainer types."""
+118
View File
@@ -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
+60
View File
@@ -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
+17
View File
@@ -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,)
+45
View File
@@ -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)
+91
View File
@@ -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]
+89
View File
@@ -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
+756
View File
@@ -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!",
)
+182
View File
@@ -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)
+97
View File
@@ -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]
+385
View File
@@ -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)
+472
View File
@@ -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()
)
+397
View File
@@ -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)
+293
View File
@@ -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]}"
)
+67
View File
@@ -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
)
+104
View File
@@ -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,
)
+54
View File
@@ -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