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
+15
View File
@@ -0,0 +1,15 @@
# SHAP Tests
## GPU TreeExplainer Tests
The GPU TreeExplainer tests require a CUDA-capable GPU and the `_cext_gpu` extension
built from source. Since GPU hardware is not available in our CI pipeline, these tests
can be run manually on Google Colab.
### Running on Google Colab
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/shap/shap/blob/gpu-tree-tests-colab/tests/gpu_tree_tests.ipynb)
1. Click the badge above to open the notebook in Google Colab
2. Switch to a GPU runtime: **Runtime > Change runtime type > T4 GPU**
3. Run all cells
+60
View File
@@ -0,0 +1,60 @@
"""Unit tests for SHAP actions."""
import numpy as np
import pandas as pd
import pytest
import shap
def test_create_and_run():
X = pd.DataFrame({"feature1": np.ones(5), "feature2": np.ones(5)})
class IncreaseFeature1(shap.actions.Action):
"""Sample action."""
def __init__(self, amount):
self.amount = amount
self.cost = 5 * amount
def __call__(self, X):
X["feature1"] += self.amount
def __str__(self):
return f"Improve feature1 by {self.amount}."
action = IncreaseFeature1(4)
action.__repr__()
assert not (action < action)
row = X.iloc[0].copy()
action(row)
assert row["feature1"] == 5
def test_action_base_call_raises_not_implemented():
"""Test that the base Action class raises NotImplementedError when called."""
base_action = shap.actions.Action(cost=10)
with pytest.raises(NotImplementedError):
base_action()
def test_action_comparisons_and_properties():
"""Test the less-than operator, string representation, and base properties."""
class MockAction(shap.actions.Action):
def __str__(self):
return "Test action string"
action_cheap = MockAction(cost=5)
action_expensive = MockAction(cost=15)
# Test __lt__ for different costs
assert action_cheap < action_expensive
assert not (action_expensive < action_cheap)
# Test __repr__ to ensure formatting is correct
assert repr(action_cheap) == "<Action 'Test action string'>"
# Test default initialized properties
assert action_cheap._group_index == 0
assert action_cheap._grouped_index == 0
+95
View File
@@ -0,0 +1,95 @@
"""Unit tests for the ActionOptimizer."""
import numpy as np
import pandas as pd
import pytest
import shap
from shap.utils._exceptions import ConvergenceError, InvalidAction
def create_basic_scenario():
X = pd.DataFrame({"feature1": np.ones(5), "feature2": np.ones(5), "feature3": np.ones(5)})
class IncreaseFeature1(shap.actions.Action):
"""Sample action."""
def __init__(self, amount):
self.amount = amount
self.cost = 5 * amount
def __call__(self, X):
X["feature1"] += self.amount
def __str__(self):
return f"Improve feature1 by {self.amount}."
class IncreaseFeature2(shap.actions.Action):
"""Sample action."""
def __init__(self, amount):
self.amount = amount
self.cost = 3 * amount
def __call__(self, X):
X["feature2"] += self.amount
def __str__(self):
return f"Improve feature2 by {self.amount}."
class IncreaseFeature3(shap.actions.Action):
"""Sample action."""
def __init__(self, amount):
self.amount = amount
self.cost = 4 * amount
def __call__(self, X):
X["feature3"] += self.amount
def __str__(self):
return f"Improve feature3 by {self.amount}."
def passed(x):
return np.sum(x) > 10
return X, IncreaseFeature1, IncreaseFeature2, IncreaseFeature3, passed
def test_basic_run():
X, IncreaseFeature1, IncreaseFeature2, IncreaseFeature3, passed = create_basic_scenario()
possible_actions = [
[IncreaseFeature1(i) for i in range(1, 10)],
IncreaseFeature2(5),
[IncreaseFeature3(i) for i in range(1, 20)],
]
optimizer = shap.ActionOptimizer(passed, possible_actions)
actions = optimizer(X.iloc[0])
assert len(actions) == 2
assert sum(a.cost for a in actions) == 27 # ensure we got the optimal answer
def test_too_few_evals():
X, IncreaseFeature1, IncreaseFeature2, IncreaseFeature3, passed = create_basic_scenario()
possible_actions = [
[IncreaseFeature1(i) for i in range(1, 10)],
IncreaseFeature2(5),
[IncreaseFeature3(i) for i in range(1, 20)],
]
optimizer = shap.ActionOptimizer(passed, possible_actions)
with pytest.raises(ConvergenceError):
optimizer(X.iloc[0], max_evals=3)
def test_run_out_of_group():
X, IncreaseFeature1, IncreaseFeature2, IncreaseFeature3, passed = create_basic_scenario()
possible_actions = [[IncreaseFeature1(i) for i in range(1, 10)], IncreaseFeature2(5), [IncreaseFeature3(1)]]
optimizer = shap.ActionOptimizer(passed, possible_actions)
actions = optimizer(X.iloc[0])
print(actions)
assert len(actions) == 3
def test_bad_action():
with pytest.raises(InvalidAction):
shap.ActionOptimizer(None, [None]) # type: ignore
+55
View File
@@ -0,0 +1,55 @@
import numpy as np
import shap
def model(x):
return np.array([np.linalg.norm(x)])
X = np.array([[3, 4], [5, 12], [7, 24]])
y = np.array([5, 13, 25])
explainer = np.array([[-1, 2], [-4, 2], [1, 2]])
masker = X
def test_update():
"""This is to test the update function within benchmark/framework"""
sort_order = "positive"
def score_function(true, pred):
return np.mean(pred)
perturbation = "keep"
scores = {"name": "test", "metrics": list(), "values": dict()}
shap.benchmark.update(model, X, y, explainer, masker, sort_order, score_function, perturbation, scores)
metric = perturbation + " " + sort_order
assert scores["metrics"][0] == metric
assert len(scores["values"][metric]) == 3
def test_get_benchmark():
"""This is to test the get benchmark function within benchmark/framework"""
metrics = {"sort_order": ["positive", "negative"], "perturbation": ["keep"]}
scores = shap.benchmark.get_benchmark(model, X, y, explainer, masker, metrics)
expected_metrics = ["keep positive", "keep negative"]
assert set(expected_metrics) == set(scores["metrics"])
assert len(scores["values"]) == 2
def test_get_metrics():
"""This is to test the get metrics function with respect to different selection method"""
scores1 = {"name": "test1", "metrics": ["keep positive", "keep absolute"], "values": dict()}
scores2 = {"name": "test2", "metrics": ["keep positive", "keep negative"], "values": dict()}
benchmarks = {"test1": scores1, "test2": scores2}
expected_metrics1 = set(["keep positive"])
expected_metrics2 = set(["keep positive", "keep negative", "keep absolute"])
assert set(shap.benchmark.get_metrics(benchmarks, lambda x, y: x.intersection(y))) == expected_metrics1
assert set(shap.benchmark.get_metrics(benchmarks, lambda x, y: x.union(y))) == expected_metrics2
+56
View File
@@ -0,0 +1,56 @@
import numpy as np
from transformers import AutoTokenizer
import shap.benchmark as benchmark
from shap.maskers import FixedComposite, Image, Impute, Independent, Partition, Text
def model(x, y):
return x
sort_order = "positive"
perturbation = "keep"
def test_init(random_seed):
rs = np.random.RandomState(random_seed)
X = rs.random((10, 13))
tabular_masker = Independent(X)
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(
model, tabular_masker, sort_order, perturbation
)
assert sequential_perturbation.data_type == "tabular"
tabular_masker = Partition(X)
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(
model, tabular_masker, sort_order, perturbation
)
assert sequential_perturbation.data_type == "tabular"
tabular_masker = Impute(X)
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(
model, tabular_masker, sort_order, perturbation
)
assert sequential_perturbation.data_type == "tabular"
text_masker = Text(AutoTokenizer.from_pretrained("nateraw/bert-base-uncased-emotion", use_fast=True))
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(
model, text_masker, sort_order, perturbation
)
assert sequential_perturbation.data_type == "text"
image_masker = Image("inpaint_telea", shape=(224, 224, 3))
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(
model, image_masker, sort_order, perturbation
)
assert sequential_perturbation.data_type == "image"
fc_masker = FixedComposite(text_masker)
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(model, fc_masker, sort_order, perturbation)
assert sequential_perturbation.data_type == "text"
fc_masker = FixedComposite(image_masker)
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(model, fc_masker, sort_order, perturbation)
assert sequential_perturbation.data_type == "image"
+4
View File
@@ -0,0 +1,4 @@
def test_import():
# FIXME: Remove this test in the future once the follow-ups from #3076
# are handled.
import shap.benchmark # noqa: F401
+114
View File
@@ -0,0 +1,114 @@
import functools
import sys
from pathlib import Path
# Remove the current working directory from sys.path to ensure tests import the
# installed shap package (with compiled C extensions) rather than the source tree.
# If this line is commented out, run pytest via `python -P -m pytest tests` instead.
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
try:
# On MacOS, the newer libomp versions that comes with Homebrew (version >= 12)
# cause segfaults to occur when pytorch + lightgbm are imported (in that order).
# The error does not occur when we import lightgbm first because lightgbm
# distributes its own libomp which takes precedence.
# cf. GH #3092 for more context.
import lightgbm # noqa: F401
except ImportError:
pass
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import pytest # noqa: E402
def pytest_addoption(parser):
parser.addoption("--random-seed", action="store", help="Fix the random seed")
@pytest.fixture()
def random_seed(request) -> int:
"""Provides a test-specific random seed for reproducible "fuzz testing".
Example use in a test:
def test_thing(random_seed):
# Numpy
rs = np.random.RandomState(seed=random_seed)
values = rs.randint(...)
# Pytorch
torch.manual_seed(random_seed)
# Tensorflow
tf.compat.v1.random.set_random_seed(random_seed)
By default, a new seed is generated on each run of the tests. If a test
fails, the random seed used will be displayed in the pytest logs.
The seed can be fixed by providing a CLI option e.g:
pytest --random-seed 123
For numpy usage, note the legacy `RandomState` has stricter version-to-version
compatibility guarantees than new-style `default_rng`:
https://numpy.org/doc/stable/reference/random/compatibility.html
"""
manual_seed = request.config.getoption("--random-seed")
if manual_seed is not None:
return int(manual_seed)
else:
# Otherwise, create a new seed for each test
rs = np.random.RandomState()
return rs.randint(0, 1000)
@pytest.fixture(autouse=True)
def global_random_seed():
"""Set the global numpy random seed before each test
Nb. Tests that use random numbers should instantiate a local
`np.random.RandomState` rather than use the global numpy random state.
"""
np.random.seed(0)
@pytest.fixture(autouse=True)
def mpl_test_cleanup():
"""Run tests in a mpl context manager and close figures after each test."""
plt.switch_backend("Agg") # Non-interactive backend
with plt.rc_context():
yield
plt.close("all")
def compare_numpy_outputs_against_baseline(*, func_file, baseline_dir=None, rtol=1e-4, atol=1e-6):
if baseline_dir is None:
baseline_dir = Path(__file__).parent / "shap_values_baselines"
elif isinstance(baseline_dir, str):
baseline_dir = Path(baseline_dir)
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
output = func(*args, **kwargs)
base_func_name = f"{Path(func_file).stem}_{func.__name__}"
baseline_file = baseline_dir / f"{base_func_name}_baseline.npz"
if hasattr(output, "values"):
arrays = {"values": output.values, "base_values": np.asarray(output.base_values)}
else:
arrays = {"values": output}
if baseline_file.exists():
baseline = np.load(baseline_file, allow_pickle=False)
for key in arrays:
np.testing.assert_allclose(arrays[key], baseline[key], rtol=rtol, atol=atol)
else:
baseline_dir.mkdir(parents=True, exist_ok=True)
np.savez(baseline_file, **arrays)
return output
return wrapper
return decorator
+84
View File
@@ -0,0 +1,84 @@
"""
Configuration file listing all datasets used in tests.
This file is used by the CI pipeline to pre-download datasets before running tests.
"""
from __future__ import annotations
from typing import TypedDict
class DictKwargs(TypedDict, total=False):
subset: str
categories: list[str]
class Dataset(TypedDict):
name: str
kwargs: DictKwargs
# Shap datasets that download from URLs
SHAP_DATASETS = [
"imagenet50",
"california",
"imdb",
"adult",
"nhanesi",
"a1a",
"rank",
"linnerud",
"diabetes",
"iris",
]
# Sklearn datasets that need to be fetched (download from internet)
SKLEARN_FETCH_DATASETS: list[Dataset] = [
{
"name": "fetch_california_housing",
"kwargs": {},
},
{
"name": "fetch_20newsgroups",
"kwargs": {"subset": "train", "categories": ["alt.atheism", "talk.religion.misc"]},
},
{
"name": "fetch_20newsgroups",
"kwargs": {"subset": "test", "categories": ["alt.atheism", "talk.religion.misc"]},
},
]
def download_all_datasets():
"""Download all datasets used in tests."""
from sklearn import datasets as sklearn_datasets
import shap
print("Downloading shap datasets...")
for dataset_name in SHAP_DATASETS:
try:
dataset_func = getattr(shap.datasets, dataset_name)
print(f" - {dataset_name}...", end=" ")
dataset_func()
print("")
except Exception as e:
print(f"✗ (Error: {e})")
print("\nFetching sklearn datasets...")
for dataset_config in SKLEARN_FETCH_DATASETS:
try:
dataset_func = getattr(sklearn_datasets, dataset_config["name"])
kwargs_str = ", ".join(f"{k}={v}" for k, v in dataset_config["kwargs"].items())
print(f" - {dataset_config['name']}({kwargs_str})...", end=" ")
dataset_func(**dataset_config["kwargs"])
print("")
except Exception as e:
print(f"✗ (Error: {e})")
print("\nAll datasets processed!")
if __name__ == "__main__":
download_all_datasets()
+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
+252
View File
@@ -0,0 +1,252 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {
"id": "EMMJtIPXknKr"
},
"source": [
"# GPU TreeExplainer Tests\n",
"\n",
"This notebook runs the GPU TreeExplainer test suite on Google Colab.\n",
"\n",
"**Requirements:** Select a GPU runtime before running:\n",
"- Go to **Runtime > Change runtime type > T4 GPU**"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "k4Y-UQH_knKt"
},
"source": [
"## 1. Verify GPU availability"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
},
"id": "BfUaPMpUknKu",
"outputId": "6b269fdf-ba81-4d61-a502-dc9aa506c1ee"
},
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"Thu Feb 19 19:44:50 2026 \n",
"+-----------------------------------------------------------------------------------------+\n",
"| NVIDIA-SMI 580.82.07 Driver Version: 580.82.07 CUDA Version: 13.0 |\n",
"+-----------------------------------------+------------------------+----------------------+\n",
"| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |\n",
"| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |\n",
"| | | MIG M. |\n",
"|=========================================+========================+======================|\n",
"| 0 Tesla T4 Off | 00000000:00:04.0 Off | 0 |\n",
"| N/A 39C P8 15W / 70W | 0MiB / 15360MiB | 0% Default |\n",
"| | | N/A |\n",
"+-----------------------------------------+------------------------+----------------------+\n",
"\n",
"+-----------------------------------------------------------------------------------------+\n",
"| Processes: |\n",
"| GPU GI CI PID Type Process name GPU Memory |\n",
"| ID ID Usage |\n",
"|=========================================================================================|\n",
"| No running processes found |\n",
"+-----------------------------------------------------------------------------------------+\n"
]
}
],
"source": [
"!nvidia-smi"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "IT52P6XCknKw"
},
"source": [
"## 2. Clone and install SHAP from source with CUDA support"
]
},
{
"cell_type": "code",
"source": [
"!git clone https://github.com/shap/shap.git"
],
"metadata": {
"id": "8vz1-tzJZVcg",
"outputId": "1e94d76c-9ba7-4d70-8b93-53ce997b5289",
"colab": {
"base_uri": "https://localhost:8080/"
}
},
"execution_count": 2,
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"fatal: destination path 'shap' already exists and is not an empty directory.\n"
]
}
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
},
"id": "J8phKJJnknKy",
"outputId": "f16e6294-f17d-45b4-8d79-f444c904b548"
},
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
" Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n",
" Checking if build backend supports build_editable ... \u001b[?25l\u001b[?25hdone\n",
" Getting requirements to build editable ... \u001b[?25l\u001b[?25hdone\n",
" Preparing editable metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
" Building editable for shap (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n"
]
}
],
"source": [
"!cd shap && CUDA_PATH=/usr/local/cuda pip install -e \".[test-core]\" -q"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {
"id": "lfbrKaqRknKz"
},
"outputs": [],
"source": [
"!pip install xgboost==3.0.5 lightgbm -q"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "xybTgkUPknK3"
},
"source": [
"## 3. Run GPU TreeExplainer tests"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
},
"id": "1Jp71Z5HknK4",
"outputId": "12ad7bca-65b1-448b-ed65-12d705d9a75b"
},
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"\u001b[1m============================= test session starts ==============================\u001b[0m\n",
"platform linux -- Python 3.12.12, pytest-8.4.2, pluggy-1.6.0 -- /usr/bin/python3\n",
"cachedir: .pytest_cache\n",
"Matplotlib: 3.10.0\n",
"Freetype: 2.6.1\n",
"rootdir: /content/shap\n",
"configfile: pyproject.toml\n",
"plugins: mpl-0.18.0, cov-7.0.0, anyio-4.12.1, langsmith-0.7.3, typeguard-4.5.0\n",
"collected 37 items \u001b[0m\n",
"\n",
"tests/explainers/test_gpu_tree.py::test_front_page_xgboost \u001b[32mPASSED\u001b[0m\u001b[33m [ 2%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_xgboost_cat_unsupported \u001b[32mPASSED\u001b[0m\u001b[33m [ 5%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-xgboost.core.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 8%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-xgboost.sklearn.XGBRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 10%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-xgboost.sklearn.XGBClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 13%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-xgboost.sklearn.XGBClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 16%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-lightgbm.basic.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 18%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-lightgbm.sklearn.LGBMRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 21%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-lightgbm.sklearn.LGBMClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 24%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-lightgbm.sklearn.LGBMClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 27%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-sklearn.ensemble._forest.RandomForestClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 29%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-sklearn.ensemble._forest.RandomForestRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 32%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[interventional-sklearn.ensemble._forest.RandomForestClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 35%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-xgboost.core.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 37%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-xgboost.sklearn.XGBRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 40%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-xgboost.sklearn.XGBClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 43%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-xgboost.sklearn.XGBClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 45%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-lightgbm.basic.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 48%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-lightgbm.sklearn.LGBMRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 51%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-lightgbm.sklearn.LGBMClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 54%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-lightgbm.sklearn.LGBMClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 56%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-sklearn.ensemble._forest.RandomForestClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 59%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-sklearn.ensemble._forest.RandomForestRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 62%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap[tree_path_dependent-sklearn.ensemble._forest.RandomForestClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 64%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-xgboost.core.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 67%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-xgboost.sklearn.XGBRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 70%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-xgboost.sklearn.XGBClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 72%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-xgboost.sklearn.XGBClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 75%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-lightgbm.basic.Booster] \u001b[32mPASSED\u001b[0m\u001b[33m [ 78%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-lightgbm.sklearn.LGBMRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 81%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-lightgbm.sklearn.LGBMClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 83%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-lightgbm.sklearn.LGBMClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 86%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-sklearn.ensemble._forest.RandomForestClassifier0] \u001b[32mPASSED\u001b[0m\u001b[33m [ 89%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-sklearn.ensemble._forest.RandomForestRegressor] \u001b[32mPASSED\u001b[0m\u001b[33m [ 91%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_gpu_tree_explainer_shap_interactions[tree_path_dependent-sklearn.ensemble._forest.RandomForestClassifier1] \u001b[32mPASSED\u001b[0m\u001b[33m [ 94%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_lightgbm_categorical_split[False] \u001b[33mXFAIL\u001b[0m\u001b[33m [ 97%]\u001b[0m\n",
"tests/explainers/test_gpu_tree.py::test_lightgbm_categorical_split[True] \u001b[33mXFAIL\u001b[0m\u001b[33m [100%]\u001b[0m\n",
"\n",
"\u001b[33m=============================== warnings summary ===============================\u001b[0m\n",
"../../usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739\n",
"../../usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739\n",
" /usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739: UserWarning: X does not have valid feature names, but LGBMRegressor was fitted with feature names\n",
" warnings.warn(\n",
"\n",
"../../usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739\n",
"../../usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739\n",
" /usr/local/lib/python3.12/dist-packages/sklearn/utils/validation.py:2739: UserWarning: X does not have valid feature names, but LGBMClassifier was fitted with feature names\n",
" warnings.warn(\n",
"\n",
"tests/explainers/test_gpu_tree.py::test_front_page_xgboost\n",
" /content/shap/tests/explainers/test_gpu_tree.py:42: FutureWarning: The NumPy global RNG was seeded by calling `np.random.seed`. In a future version this function will no longer use the global RNG. Pass `rng` explicitly to opt-in to the new behaviour and silence this warning.\n",
" shap.summary_plot(shap_values, X, show=False)\n",
"\n",
"-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n",
"\u001b[33m================== \u001b[32m35 passed\u001b[0m, \u001b[33m\u001b[1m2 xfailed\u001b[0m, \u001b[33m\u001b[1m5 warnings\u001b[0m\u001b[33m in 21.04s\u001b[0m\u001b[33m ==================\u001b[0m\n"
]
}
],
"source": [
"!cd shap && python -m pytest tests/explainers/test_gpu_tree.py -v"
]
}
],
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.0"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
View File
+239
View File
@@ -0,0 +1,239 @@
"""This file contains tests for the Composite masker using only public API."""
import numpy as np
import pytest
import shap
def test_composite_masker_init():
"""Test Composite masker initialization."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
assert len(composite.maskers) == 2
assert composite.maskers[0] is masker1
assert composite.maskers[1] is masker2
assert len(composite.arg_counts) == 2
assert composite.total_args == 2
assert composite.text_data is False
assert composite.image_data is False
def test_composite_masker_with_fixed_maskers():
"""Test Composite masker combining Fixed maskers."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Test shape
shape = composite.shape("arg1", "arg2")
assert shape == (None, 0)
# Note: Calling composite with Fixed maskers where num_rows is None causes a bug
# TODO: check if this code is dead! Line 118 in _composite.py fails when num_rows is None
def test_composite_masker_shape_method():
"""Test Composite masker shape method."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Two Fixed maskers, each with shape (None, 0)
shape = composite.shape("arg1", "arg2")
assert shape[0] is None
assert shape[1] == 0
def test_composite_masker_mask_shapes():
"""Test Composite masker mask_shapes method."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
result = composite.mask_shapes("arg1", "arg2")
assert isinstance(result, list)
assert len(result) == 2
assert result[0] == (0,)
assert result[1] == (0,)
def test_composite_masker_data_transform():
"""Test Composite masker data_transform method."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Fixed maskers don't have data_transform, so args should pass through
result = composite.data_transform("arg1", "arg2")
assert isinstance(result, list)
assert len(result) == 2
assert result[0] == "arg1"
assert result[1] == "arg2"
def test_composite_masker_arg_count_mismatch():
"""Test Composite masker with wrong number of arguments."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Should expect 2 args, but only provide 1
with pytest.raises(AssertionError, match="number of passed args is incorrect"):
composite.shape("arg1")
# Should expect 2 args, but provide 3
with pytest.raises(AssertionError, match="number of passed args is incorrect"):
composite.shape("arg1", "arg2", "arg3")
def test_composite_masker_call_arg_count_mismatch():
"""Test Composite masker __call__ with wrong number of arguments."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
mask = np.array([], dtype=bool)
# Should expect 2 args, but only provide 1
with pytest.raises(AssertionError, match="number of passed args is incorrect"):
composite(mask, "arg1")
def test_composite_masker_clustering():
"""Test that clustering attribute is set when all maskers have clustering."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Both Fixed maskers have clustering attribute
assert hasattr(composite, "clustering")
def test_composite_masker_single_masker():
"""Test Composite masker with a single submasker."""
masker = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker)
assert len(composite.maskers) == 1
assert composite.total_args == 1
shape = composite.shape("arg1")
assert shape == (None, 0)
def test_composite_masker_three_maskers():
"""Test Composite masker with three submaskers."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
masker3 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2, masker3)
assert len(composite.maskers) == 3
assert composite.total_args == 3
# Note: Calling composite with Fixed maskers where num_rows is None causes a bug
# TODO: check if this code is dead! Line 118 in _composite.py fails when num_rows is None
def test_composite_with_output_composite():
"""Test Composite masker combined with OutputComposite."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
def simple_model(x):
return np.sum(x) if isinstance(x, np.ndarray) else 0
output_comp = shap.maskers.OutputComposite(masker1, simple_model)
# Combine OutputComposite with Fixed masker2
# OutputComposite's __call__ signature is (mask, x) - 1 data arg
# Fixed's __call__ signature is (mask, x) - 1 data arg
# Total = 1 + 1 = 2 data args expected
composite = shap.maskers.Composite(output_comp, masker2)
assert len(composite.maskers) == 2
# Note: Composite counts args after removing 'mask' and 'self'
# OutputComposite.__call__(self, mask, *args) has 0 default args
# So arg count = total params (3: self, mask, *args) - 2 (self, mask) = 1 per masker
# But it looks like the arg counting isn't working as expected
# Just verify the composite was created successfully
assert composite.total_args >= 1
def test_composite_masker_with_independent_maskers():
"""Test Composite masker with Independent maskers that have actual data."""
data1 = np.random.randn(5, 2)
data2 = np.random.randn(5, 3)
masker1 = shap.maskers.Independent(data1)
masker2 = shap.maskers.Independent(data2)
composite = shap.maskers.Composite(masker1, masker2)
# Shape should combine both maskers
shape = composite.shape(data1[0], data2[0])
assert shape == (5, 5) # 5 rows, 2+3=5 cols
# Test __call__ with actual data
mask = np.array([True, False, True, True, False])
result = composite(mask, data1[0], data2[0])
assert isinstance(result, tuple)
assert len(result) == 2 # Two maskers return two outputs
def test_composite_masker_compatible_rows():
"""Test Composite masker with maskers that have compatible number of rows."""
data1 = np.random.randn(10, 2)
data2 = np.random.randn(10, 3)
masker1 = shap.maskers.Independent(data1)
masker2 = shap.maskers.Independent(data2)
composite = shap.maskers.Composite(masker1, masker2)
mask = np.array([True, False, True, True, False])
result = composite(mask, data1[0], data2[0])
# Should work with same number of rows
assert isinstance(result, tuple)
def test_composite_masker_text_and_image_defaults():
"""Test Composite masker text_data and image_data defaults."""
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# Should have text_data and image_data attributes with False defaults
assert composite.text_data is False
assert composite.image_data is False
def test_composite_masker_with_kwargs():
"""Test Composite masker with maskers that have default keyword arguments."""
# Use maskers that have kwargs in their __call__ signature
# Independent masker doesn't have kwargs, so test with Fixed
masker1 = shap.maskers.Fixed()
masker2 = shap.maskers.Fixed()
composite = shap.maskers.Composite(masker1, masker2)
# arg_counts should handle kwargs correctly
assert len(composite.arg_counts) == 2
+21
View File
@@ -0,0 +1,21 @@
"""This file contains tests for custom (user supplied) maskers."""
import numpy as np
import shap
def test_raw_function():
"""Make sure passing a simple masking function works."""
X, _ = shap.datasets.california(n_points=500)
def test(X):
return np.sum(X, 1)
def custom_masker(mask, x):
return (x * mask).reshape(1, len(x)) # just zero out the features we are masking
explainer = shap.Explainer(test, custom_masker)
shap_values = explainer(X[:100])
assert np.var(shap_values.values - shap_values.data) < 1e-6 # type: ignore[union-attr, union-attr]
+74
View File
@@ -0,0 +1,74 @@
"""This file contains tests for the Fixed masker."""
import tempfile
import numpy as np
import shap
def test_fixed_masker_init():
"""Test Fixed masker initialization."""
masker = shap.maskers.Fixed()
assert masker.shape == (None, 0)
assert isinstance(masker.clustering, np.ndarray)
assert masker.clustering.shape == (0, 4)
def test_fixed_masker_call():
"""Test that Fixed masker returns input unchanged regardless of mask."""
masker = shap.maskers.Fixed()
mask = np.array([], dtype=bool)
# Test with different input types
inputs = [
42, # scalar
np.array([1, 2, 3]), # 1D array
np.array([[1, 2], [3, 4]]), # 2D array
"label_class_A", # string (label use case)
]
for x in inputs:
result = masker(mask, x)
assert isinstance(result, tuple) and len(result) == 1
if isinstance(x, np.ndarray):
np.testing.assert_array_equal(result[0][0], x)
else:
assert result[0][0] == x
def test_fixed_masker_mask_shapes():
"""Test that mask_shapes always returns [(0,)]."""
masker = shap.maskers.Fixed()
assert masker.mask_shapes(42) == [(0,)]
assert masker.mask_shapes(np.array([1, 2, 3])) == [(0,)]
def test_fixed_masker_mask_shapes_with_various_inputs():
"""Test mask_shapes with different inputs (input should not affect output)."""
masker = shap.maskers.Fixed()
# The input to mask_shapes doesn't matter for Fixed masker
assert masker.mask_shapes(np.array([1, 2, 3])) == [(0,)]
assert masker.mask_shapes([1, 2, 3]) == [(0,)]
assert masker.mask_shapes("test") == [(0,)]
assert masker.mask_shapes(42) == [(0,)]
def test_fixed_masker_serialization():
"""Test that Fixed masker can be serialized and deserialized."""
original = shap.maskers.Fixed()
with tempfile.TemporaryFile() as f:
original.save(f)
f.seek(0)
loaded = shap.maskers.Fixed.load(f)
# Verify behavior is preserved
test_input = np.array([1, 2, 3])
mask = np.array([], dtype=bool)
np.testing.assert_array_equal(
original(mask, test_input)[0][0],
loaded(mask, test_input)[0][0],
)
+57
View File
@@ -0,0 +1,57 @@
"""This file contains tests for the FixedComposite masker."""
import tempfile
import numpy as np
import pytest
import shap
@pytest.mark.skip(
reason="fails on travis and I don't know why yet...Ryan might need to take a look since this API will change soon anyway"
)
def test_fixed_composite_masker_call():
"""Test to make sure the FixedComposite masker works when masking everything."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
args = ("This is a test statement for fixed composite masker",)
tokenizer = AutoTokenizer.from_pretrained("gpt2")
masker = shap.maskers.Text(tokenizer)
mask = np.zeros(masker.shape(*args)[1], dtype=bool)
fixed_composite_masker = shap.maskers.FixedComposite(masker)
expected_fixed_composite_masked_output = (
np.array([""]),
np.array(["This is a test statement for fixed composite masker"]),
)
fixed_composite_masked_output = fixed_composite_masker(mask, *args)
assert fixed_composite_masked_output == expected_fixed_composite_masked_output
def test_serialization_fixedcomposite_masker():
"""Make sure fixedcomposite serialization works."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-cased", use_fast=False)
underlying_masker = shap.maskers.Text(tokenizer)
original_masker = shap.maskers.FixedComposite(underlying_masker)
with tempfile.TemporaryFile() as temp_serialization_file:
original_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_masker = shap.maskers.FixedComposite.load(temp_serialization_file)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
original_masked_output = original_masker(test_input_mask, test_text)
new_masked_output = new_masker(test_input_mask, test_text)
assert original_masked_output == new_masked_output
+183
View File
@@ -0,0 +1,183 @@
"""This file contains tests for the Image masker."""
import tempfile
import numpy as np
import pytest
import shap
from shap.utils import assert_import
from shap.utils._exceptions import DimensionError
try:
assert_import("cv2")
except ImportError:
pytestmark = pytest.mark.skip("opencv not installed")
def test_serialization_image_masker_inpaint_telea():
"""Make sure image serialization works with inpaint telea mask."""
test_image_height = 500
test_image_width = 500
test_data = np.ones((test_image_height, test_image_width, 3)) * 50
test_shape = (test_image_height, test_image_width, 3)
# initialize image masker
original_image_masker = shap.maskers.Image("inpaint_telea", test_shape)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_image_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_image_masker = shap.maskers.Image.load(temp_serialization_file)
mask = np.ones((test_image_height, test_image_width, 3))
mask = mask.astype(int)
mask[0][0] = 0
mask[4][0] = 0
# comparing masked values
assert np.array_equal(original_image_masker(mask, test_data), new_image_masker(mask, test_data))
def test_serialization_image_masker_inpaint_ns():
"""Make sure image serialization works with inpaint ns mask."""
test_image_height = 500
test_image_width = 500
test_data = np.ones((test_image_height, test_image_width, 3)) * 50
test_shape = (test_image_height, test_image_width, 3)
# initialize image masker
original_image_masker = shap.maskers.Image("inpaint_ns", test_shape)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_image_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_image_masker = shap.maskers.Image.load(temp_serialization_file)
mask = np.ones((test_image_height, test_image_width, 3))
mask = mask.astype(int)
mask[0][0] = 0
mask[4][0] = 0
# comparing masked values
assert np.array_equal(original_image_masker(mask, test_data), new_image_masker(mask, test_data))
def test_serialization_image_masker_blur():
"""Make sure image serialization works with blur mask."""
test_image_height = 500
test_image_width = 500
test_data = np.ones((test_image_height, test_image_width, 3)) * 50
test_shape = (test_image_height, test_image_width, 3)
# initialize image masker
original_image_masker = shap.maskers.Image("blur(10,10)", test_shape)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_image_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_image_masker = shap.maskers.Image.load(temp_serialization_file)
mask = np.ones((test_image_height, test_image_width, 3))
mask = mask.astype(int)
mask[0][0] = 0
mask[4][0] = 0
# comparing masked values
assert np.array_equal(original_image_masker(mask, test_data), new_image_masker(mask, test_data))
def test_serialization_image_masker_mask():
"""Make sure image serialization works."""
test_image_height = 500
test_image_width = 500
test_data = np.ones((test_image_height, test_image_width, 3)) * 50
test_shape = (test_image_height, test_image_width, 3)
test_mask = np.ones((test_image_height, test_image_width, 3))
# initialize image masker
original_image_masker = shap.maskers.Image(test_mask, test_shape)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_image_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_image_masker = shap.maskers.Image.load(temp_serialization_file)
mask = np.ones((test_image_height, test_image_width, 3))
mask = mask.astype(int)
mask[0][0] = 0
mask[4][0] = 0
# comparing masked values
assert np.array_equal(original_image_masker(mask, test_data), new_image_masker(mask, test_data))
def test_init_string_mask_without_shape():
"""Make sure masker raises error when initializing with string mask value without shape"""
with pytest.raises(TypeError):
shap.maskers.Image("inpaint_telea")
def test_init_ndarray_mask_without_shape():
"""Make sure that shape is inferred correctly from np.array when no shape is passed"""
mask_value = np.zeros((5, 5, 3))
image_masker = shap.maskers.Image(mask_value) # no shape parameter passed
assert image_masker.input_shape == (5, 5, 3)
assert image_masker.shape == (1, 75) # 5*5*3 = 75, when flattened
def test_init_scalar_mask_with_shape():
"""Make sure mask_value is expanded to a flat array of the scalar when mask_value is an int"""
image_masker = shap.maskers.Image(5, shape=(5, 5, 3))
assert image_masker.input_shape == (5, 5, 3)
assert image_masker.mask_value.shape == (75,)
assert np.all(image_masker.mask_value == 5)
def test_call_with_torch_tensor():
"""x is converted from torch Tensor to numpy array before masking"""
torch = pytest.importorskip("torch")
image_masker = shap.maskers.Image(np.zeros((5, 5, 3)))
x = torch.zeros(5, 5, 3)
mask = np.ones(75, dtype=bool)
result = image_masker(mask, x)
assert isinstance(result[0], np.ndarray)
def test_call_image_masker_shape_mismatch():
"""Make sure DimensionError when image and masker shapes mismatch"""
image_masker = shap.maskers.Image(np.zeros((4, 4, 3)))
image = np.zeros((5, 5, 3))
mask = np.ones(48, dtype=bool)
with pytest.raises(DimensionError):
image_masker(mask, image)
def test_call_image_masker_with_no_mask():
"""Make sure the entire image is masked when mask=None is passed."""
image_masker = shap.maskers.Image(np.zeros((5, 5, 3)))
image = np.ones((5, 5, 3))
result = image_masker(None, image)
assert np.all(result[0] == 0) # entire image masked with mask_value (all 0)
def test_inpaint_fully_masked_image():
"""Make sure mean colour is used when entire image is masked during inpainting"""
image_masker = shap.maskers.Image("inpaint_telea", (5, 5, 3))
image = np.zeros((5, 5, 3))
image[0, 0, :] = 100 # one pixel has 100 for all three channels, the rest are all 0
# mean = 100/ (5*5) = 4.00
result = image_masker(None, image)
assert np.all(result[0] == 4.0)
+66
View File
@@ -0,0 +1,66 @@
"""This file contains tests for the base Masker class behavior through public masker APIs.
Tests verify _standardize_mask behavior by using public maskers like Fixed,
Composite, and OutputComposite with True/False masks.
"""
import numpy as np
import shap
def test_fixed_masker_with_true_mask():
"""Test Fixed masker with True mask (triggers _standardize_mask)."""
masker = shap.maskers.Fixed()
test_input = np.array([1, 2, 3, 4, 5])
# Fixed masker has 0 features, but True mask should still work
result = masker(True, test_input)
assert isinstance(result, tuple)
assert isinstance(result[0], list)
assert np.array_equal(result[0][0], test_input)
def test_fixed_masker_with_false_mask():
"""Test Fixed masker with False mask (triggers _standardize_mask)."""
masker = shap.maskers.Fixed()
test_input = np.array([1, 2, 3, 4, 5])
result = masker(False, test_input)
assert isinstance(result, tuple)
assert isinstance(result[0], list)
assert np.array_equal(result[0][0], test_input)
def test_output_composite_with_true_mask():
"""Test OutputComposite with True mask (triggers _standardize_mask in underlying masker)."""
masker = shap.maskers.Fixed()
def simple_model(x):
return np.sum(x)
output_composite = shap.maskers.OutputComposite(masker, simple_model)
test_input = np.array([1, 2, 3, 4, 5])
result = output_composite(True, test_input)
assert isinstance(result, tuple)
assert len(result) == 2 # masked input + model output
def test_output_composite_with_false_mask():
"""Test OutputComposite with False mask (triggers _standardize_mask in underlying masker)."""
masker = shap.maskers.Fixed()
def simple_model(x):
return np.sum(x)
output_composite = shap.maskers.OutputComposite(masker, simple_model)
test_input = np.array([1, 2, 3, 4, 5])
result = output_composite(False, test_input)
assert isinstance(result, tuple)
assert len(result) == 2
@@ -0,0 +1,207 @@
"""Tests for maskers through public explainer APIs.
These tests exercise masker functionality by using public explainers like
shap.Explainer, shap.KernelExplainer, etc., which internally use maskers.
"""
import numpy as np
import shap
def test_masker_with_kernel_explainer():
"""Test masker functionality through KernelExplainer (public API)."""
# Create simple model
def model(x):
return np.sum(x, axis=1)
# Create background data for masker
background = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]])
# KernelExplainer uses Independent masker internally
explainer = shap.KernelExplainer(model, background)
# Test data
test_data = np.array([[1, 2, 3]])
# This exercises masker's __call__ with various masks
shap_values = explainer.shap_values(test_data, nsamples=10)
assert shap_values is not None
assert shap_values.shape == (1, 3)
def test_independent_masker_with_kernel_explainer():
"""Test Independent masker through KernelExplainer."""
def model(x):
return np.sum(x, axis=1)
background = np.array([[0, 0, 0], [1, 1, 1]])
# KernelExplainer creates Independent masker internally from data
explainer = shap.KernelExplainer(model, background)
test_data = np.array([[1, 2, 3]])
shap_values = explainer.shap_values(test_data, nsamples=10)
assert shap_values is not None
assert shap_values.shape == (1, 3)
def test_independent_masker_basic():
"""Test Independent masker basic functionality."""
background = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]])
masker = shap.maskers.Independent(background)
# Test shape
assert masker.shape == (3, 3)
# Test with True mask (should select all features)
result = masker(True, background[0])
assert isinstance(result, tuple)
def test_independent_masker_with_false_mask():
"""Test Independent masker with False mask."""
background = np.array([[0, 0], [1, 1], [2, 2]])
masker = shap.maskers.Independent(background)
# False mask should select no features
result = masker(False, background[0])
assert isinstance(result, tuple)
def test_independent_masker_with_partial_mask():
"""Test Independent masker with partial mask array."""
background = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]])
masker = shap.maskers.Independent(background)
# Partial mask - select first two features
mask = np.array([True, True, False])
result = masker(mask, np.array([5, 6, 7]))
assert isinstance(result, tuple)
assert len(result) == 1
# Should return background samples with first two features from input
assert result[0].shape[0] == 3 # Same as background rows
def test_partition_masker_basic():
"""Test Partition masker basic functionality."""
background = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]])
masker = shap.maskers.Partition(background)
# Test with True mask
result = masker(True, background[0])
assert isinstance(result, tuple)
def test_partition_masker_with_false_mask():
"""Test Partition masker with False mask."""
background = np.array([[0, 0], [1, 1]])
masker = shap.maskers.Partition(background)
result = masker(False, background[0])
assert isinstance(result, tuple)
def test_masker_with_explainer_auto_detection():
"""Test that Explainer auto-detects and uses appropriate masker."""
def model(x):
return np.sum(x, axis=1)
background = np.array([[0, 0, 0], [1, 1, 1]])
# Explainer should auto-select masker based on model and data
explainer = shap.Explainer(model, background)
test_data = np.array([[1, 2, 3]])
# This internally uses maskers
shap_values = explainer(test_data)
assert shap_values is not None
assert isinstance(shap_values, shap.Explanation)
assert shap_values.values.shape == (1, 3)
def test_independent_masker_clustering():
"""Test that Independent masker has clustering attribute."""
background = np.array([[0, 0, 0], [1, 1, 1]])
masker = shap.maskers.Independent(background)
# Should have clustering attribute
assert hasattr(masker, "clustering")
def test_partition_masker_clustering():
"""Test that Partition masker has clustering attribute."""
background = np.array([[0, 0, 0], [1, 1, 1]])
masker = shap.maskers.Partition(background)
# Should have clustering attribute
assert hasattr(masker, "clustering")
def test_independent_masker_shape_property():
"""Test Independent masker shape property with different data sizes."""
# 5 samples, 4 features
background = np.random.rand(5, 4)
masker = shap.maskers.Independent(background)
assert masker.shape == (5, 4)
def test_partition_masker_with_clustering():
"""Test Partition masker with explicit clustering."""
background = np.array([[0, 0, 0, 0], [1, 1, 1, 1]])
# Partition masker can take clustering parameter
masker = shap.maskers.Partition(background, clustering="correlation")
assert hasattr(masker, "clustering")
def test_composite_masker_with_independent_maskers():
"""Test Composite masker combining Independent maskers."""
background1 = np.array([[0, 0], [1, 1]])
background2 = np.array([[2, 2, 2], [3, 3, 3]])
masker1 = shap.maskers.Independent(background1)
masker2 = shap.maskers.Independent(background2)
composite = shap.maskers.Composite(masker1, masker2)
# Shape should be sum of both
shape = composite.shape(background1[0], background2[0])
assert shape == (2, 5) # 2 rows (min), 2+3 = 5 cols
def test_independent_masker_with_varying_masks():
"""Test Independent masker with different mask patterns."""
background = np.array([[0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]])
masker = shap.maskers.Independent(background)
# All True
result = masker(np.ones(4, dtype=bool), np.array([5, 6, 7, 8]))
assert result[0].shape == (3, 4)
# All False
result = masker(np.zeros(4, dtype=bool), np.array([5, 6, 7, 8]))
assert result[0].shape == (3, 4)
# Alternating
result = masker(np.array([True, False, True, False]), np.array([5, 6, 7, 8]))
assert result[0].shape == (3, 4)
+225
View File
@@ -0,0 +1,225 @@
"""This file contains tests for the OutputComposite masker."""
import tempfile
import numpy as np
import shap
def test_output_composite_init():
"""Test OutputComposite masker initialization."""
masker = shap.maskers.Fixed()
def simple_model(x):
return np.sum(x)
output_composite = shap.maskers.OutputComposite(masker, simple_model)
assert output_composite.masker is masker
assert output_composite.model is simple_model
def test_output_composite_attribute_propagation():
"""Test that attributes from the underlying masker are propagated."""
masker = shap.maskers.Fixed()
def simple_model(x):
return x
output_composite = shap.maskers.OutputComposite(masker, simple_model)
# Check that shape is propagated
assert hasattr(output_composite, "shape")
assert output_composite.shape == masker.shape
def test_output_composite_call_with_tuple_output():
"""Test OutputComposite __call__ when model returns a tuple."""
masker = shap.maskers.Fixed()
def model_with_tuple_output(x):
return (np.sum(x), np.mean(x))
output_composite = shap.maskers.OutputComposite(masker, model_with_tuple_output)
test_input = np.array([1, 2, 3, 4, 5])
mask = np.array([], dtype=bool)
result = output_composite(mask, test_input)
# Result should be masked input + model output
assert isinstance(result, tuple)
# Fixed masker returns 1 element, model returns 2 elements
assert len(result) == 3
def test_output_composite_call_with_scalar_output():
"""Test OutputComposite __call__ when model returns a scalar."""
masker = shap.maskers.Fixed()
def model_with_scalar_output(x):
return np.sum(x)
output_composite = shap.maskers.OutputComposite(masker, model_with_scalar_output)
test_input = np.array([1, 2, 3, 4, 5])
mask = np.array([], dtype=bool)
result = output_composite(mask, test_input)
# Result should be masked input + model output (wrapped in tuple)
assert isinstance(result, tuple)
# Fixed masker returns 1 element, model returns 1 element (wrapped)
assert len(result) == 2
def test_output_composite_call_with_array_output():
"""Test OutputComposite __call__ when model returns an array."""
masker = shap.maskers.Fixed()
def model_with_array_output(x):
return x * 2
output_composite = shap.maskers.OutputComposite(masker, model_with_array_output)
test_input = np.array([1, 2, 3])
mask = np.array([], dtype=bool)
result = output_composite(mask, test_input)
assert isinstance(result, tuple)
assert len(result) == 2
# Check model output is correct
np.testing.assert_array_equal(result[1], test_input * 2)
def test_output_composite_serialization():
"""Test OutputComposite serialization and deserialization."""
masker = shap.maskers.Fixed()
def simple_model(x):
return np.sum(x)
original_composite = shap.maskers.OutputComposite(masker, simple_model)
with tempfile.TemporaryFile() as temp_file:
# Serialize
original_composite.save(temp_file)
temp_file.seek(0)
# Deserialize
loaded_composite = shap.maskers.OutputComposite.load(temp_file)
# Verify the loaded masker works
test_input = np.array([1, 2, 3])
mask = np.array([], dtype=bool)
original_result = original_composite(mask, test_input)
loaded_result = loaded_composite(mask, test_input)
# Check results are the same
assert len(original_result) == len(loaded_result)
def test_output_composite_with_multiple_args():
"""Test OutputComposite with a model that takes multiple arguments."""
# Create a custom masker that takes 2 arguments
class TwoArgMasker(shap.maskers.Masker):
def __init__(self):
self.shape = (None, 0)
def __call__(self, mask, *args): # type: ignore[override]
x, y = args
return ([x], [y])
masker = TwoArgMasker()
def model_two_args(x, y):
return np.sum(x) + np.sum(y)
output_composite = shap.maskers.OutputComposite(masker, model_two_args)
test_input1 = np.array([1, 2, 3])
test_input2 = np.array([4, 5, 6])
mask = np.array([], dtype=bool)
result = output_composite(mask, test_input1, test_input2)
assert isinstance(result, tuple)
# Masker returns 2 elements, model returns 1 (wrapped)
assert len(result) == 3
assert result[2] == np.sum(test_input1) + np.sum(test_input2)
def test_output_composite_attribute_none_handling():
"""Test that OutputComposite handles None attributes correctly."""
# Create a minimal masker without all optional attributes
class MinimalMasker(shap.maskers.Masker):
def __init__(self):
self.shape = (None, 0)
# Don't set optional attributes
def __call__(self, mask, *args): # type: ignore[override]
x = args[0]
return ([x],)
masker = MinimalMasker()
def simple_model(x):
return x
output_composite = shap.maskers.OutputComposite(masker, simple_model)
# Should have shape but not necessarily other attributes
assert hasattr(output_composite, "shape")
# These attributes shouldn't be set if not present in masker
assert not hasattr(output_composite, "invariants") or output_composite.invariants is None
def test_output_composite_text_data_flag():
"""Test that text_data flag propagates from underlying masker."""
class TextMasker(shap.maskers.Masker):
def __init__(self):
self.shape = (None, 0)
self.text_data = True
def __call__(self, mask, *args): # type: ignore[override]
x = args[0]
return ([x],)
masker = TextMasker()
def simple_model(x):
return x
output_composite = shap.maskers.OutputComposite(masker, simple_model)
assert hasattr(output_composite, "text_data")
assert output_composite.text_data is True
def test_output_composite_image_data_flag():
"""Test that image_data flag propagates from underlying masker."""
class ImageMasker(shap.maskers.Masker):
def __init__(self):
self.shape = (None, 0)
self.image_data = True
def __call__(self, mask, *args): # type: ignore[override]
x = args[0]
return ([x],)
masker = ImageMasker()
def simple_model(x):
return x
output_composite = shap.maskers.OutputComposite(masker, simple_model)
assert hasattr(output_composite, "image_data")
assert output_composite.image_data is True
+256
View File
@@ -0,0 +1,256 @@
"""This file contains tests for the Tabular maskers."""
import logging
import tempfile
import numpy as np
import shap
def test_serialization_independent_masker_dataframe():
"""Test the serialization of an Independent masker based on a data frame."""
X, _ = shap.datasets.california(n_points=500)
# initialize independent masker
original_independent_masker = shap.maskers.Independent(X)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_independent_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_independent_masker = shap.maskers.Independent.load(temp_serialization_file)
mask = np.ones(X.shape[1]).astype(int)
mask[0] = 0
mask[4] = 0
# comparing masked values
assert np.array_equal(
original_independent_masker(mask, X[:1].values[0])[1], new_independent_masker(mask, X[:1].values[0])[1]
)
def test_serialization_independent_masker_numpy():
"""Test the serialization of an Independent masker based on a numpy array."""
X, _ = shap.datasets.california(n_points=500)
X = X.values
# initialize independent masker
original_independent_masker = shap.maskers.Independent(X)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize independent masker
original_independent_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_independent_masker = shap.maskers.Masker.load(temp_serialization_file)
mask = np.ones(X.shape[1]).astype(int)
mask[0] = 0
mask[4] = 0
# comparing masked values
assert np.array_equal(original_independent_masker(mask, X[0])[0], new_independent_masker(mask, X[0])[0])
def test_serialization_partion_masker_dataframe():
"""Test the serialization of a Partition masker based on a DataFrame."""
X, _ = shap.datasets.california(n_points=500)
# initialize partition masker
original_partition_masker = shap.maskers.Partition(X)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize partition masker
original_partition_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_partition_masker = shap.maskers.Partition.load(temp_serialization_file)
mask = np.ones(X.shape[1]).astype(int)
mask[0] = 0
mask[4] = 0
# comparing masked values
assert np.array_equal(
original_partition_masker(mask, X[:1].values[0])[1], new_partition_masker(mask, X[:1].values[0])[1]
)
def test_serialization_partion_masker_numpy():
"""Test the serialization of a Partition masker based on a numpy array."""
X, _ = shap.datasets.california(n_points=500)
X = X.values
# initialize partition masker
original_partition_masker = shap.maskers.Partition(X)
with tempfile.TemporaryFile() as temp_serialization_file:
# serialize partition masker
original_partition_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_partition_masker = shap.maskers.Masker.load(temp_serialization_file)
mask = np.ones(X.shape[1]).astype(int)
mask[0] = 0
mask[4] = 0
# comparing masked values
assert np.array_equal(original_partition_masker(mask, X[0])[0], new_partition_masker(mask, X[0])[0])
def test_independent_masker_with_dataframe_init():
"""Test that Independent masker can be initialized with DataFrame."""
import pandas as pd
df = pd.DataFrame(
{"a": [0.0, 1.0, 2.0, 3.0, 4.0], "b": [5.0, 6.0, 7.0, 8.0, 9.0], "c": [10.0, 11.0, 12.0, 13.0, 14.0]}
)
masker = shap.maskers.Independent(df)
# Should preserve feature names
assert hasattr(masker, "feature_names")
assert list(masker.feature_names) == ["a", "b", "c"]
assert masker.shape == (5, 3)
def test_independent_masker_with_dict_mean_cov():
"""Test Independent masker with dictionary containing mean and cov."""
data_dict = {"mean": np.array([1.5, 2.5, 3.5]), "cov": np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])}
masker = shap.maskers.Independent(data_dict)
# Should have mean and cov attributes
assert hasattr(masker, "mean")
assert hasattr(masker, "cov")
assert np.allclose(masker.mean, data_dict["mean"])
assert np.allclose(masker.cov, data_dict["cov"])
# Should work for masking
result = masker(True, np.array([5, 6, 7]))
assert isinstance(result, tuple)
def test_independent_masker_with_sampling():
"""Test Independent masker with large dataset that triggers sampling."""
# Create data larger than max_samples
large_data = np.random.randn(150, 5)
masker = shap.maskers.Independent(large_data, max_samples=50)
# Should have sampled down to max_samples
assert masker.data.shape[0] == 50
assert masker.data.shape[1] == 5
def test_independent_masker_with_no_clustering():
"""Test Independent masker without clustering."""
data = np.random.randn(10, 3)
# Independent masker doesn't take clustering parameter
masker = shap.maskers.Independent(data)
# Should have None clustering since Independent doesn't use it
assert masker.clustering is None
def test_independent_masker_dimension_error():
"""Test that dimension mismatch raises appropriate error."""
import pytest
data = np.array([[0, 0, 0], [1, 1, 1]])
masker = shap.maskers.Independent(data)
# Try to mask with wrong dimension
with pytest.raises(shap.utils._exceptions.DimensionError):
masker(np.array([True, False]), np.array([1, 2])) # Only 2 features instead of 3
def test_independent_masker_invariants_dimension_error():
"""Test invariants method with wrong input shape."""
import pytest
data = np.array([[0, 0, 0], [1, 1, 1]])
masker = shap.maskers.Independent(data)
# Call invariants with wrong shape
with pytest.raises(shap.utils._exceptions.DimensionError):
masker.invariants(np.array([1, 2])) # Only 2 features instead of 3
def test_independent_masker_invariants():
"""Test invariants method for detecting unchanging features."""
data = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]])
masker = shap.maskers.Independent(data)
# Test with matching data
x = np.array([0, 1, 2])
invariants = masker.invariants(x)
# All features should be invariant (match all background samples)
assert invariants.shape == (3, 3)
assert np.all(invariants)
def test_partition_masker_with_dataframe_init():
"""Test Partition masker can be initialized with DataFrame."""
import pandas as pd
df = pd.DataFrame({"x": [0.0, 1.0, 2.0], "y": [3.0, 4.0, 5.0], "z": [6.0, 7.0, 8.0]})
# Use clustering=None to avoid clustering issues with small DataFrame
masker = shap.maskers.Partition(df, clustering=None)
# Should preserve feature names and shape
assert hasattr(masker, "feature_names")
assert masker.shape == (3, 3)
def test_independent_masker_no_clustering_or_partition():
"""Test Independent masker without clustering or partition."""
data = np.random.randn(10, 3)
masker = shap.maskers.Independent(data)
# Both should be None
assert masker.clustering is None
assert masker.partition is None
def test_independent_masker_with_small_data():
"""Test Independent masker with data smaller than max_samples."""
# Create small data that doesn't trigger sampling
small_data = np.random.randn(5, 3)
masker = shap.maskers.Independent(small_data, max_samples=100)
# Should keep all data
assert masker.data.shape[0] == 5
assert masker.data.shape[1] == 3
def test_subsampling_warning_when_data_exceeds_max_samples(caplog):
"""Test that a warning is logged when background data is subsampled."""
data = np.random.randn(200, 5)
with caplog.at_level(logging.WARNING, logger="shap"):
shap.maskers.Independent(data, max_samples=50)
assert len(caplog.records) == 1
assert "200" in caplog.records[0].message
assert "max_samples=50" in caplog.records[0].message
+269
View File
@@ -0,0 +1,269 @@
"""This file contains tests for the Text masker."""
import sys
import tempfile
import numpy as np
import pytest
import shap
pytestmark = pytest.mark.skipif(sys.platform == "darwin", reason="Memory error on macOS")
def test_method_token_segments_pretrained_tokenizer():
"""Check that the Text masker produces the same segments as its non-fast pretrained tokenizer."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-cased", use_fast=False)
masker = shap.maskers.Text(tokenizer)
test_text = "I ate a Cannoli"
output_token_segments, _ = masker.token_segments(test_text)
correct_token_segments = ["", " I", " ate", " a", " Can", "no", "li", ""]
assert output_token_segments == correct_token_segments
def test_method_token_segments_pretrained_tokenizer_fast():
"""Check that the Text masker produces the same segments as its fast pretrained tokenizer."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased", use_fast=True)
masker = shap.maskers.Text(tokenizer)
test_text = "I ate a Cannoli"
output_token_segments, _ = masker.token_segments(test_text)
correct_token_segments = ["", "I ", "ate ", "a ", "Can", "no", "li", ""]
assert output_token_segments == correct_token_segments
def test_masker_call_pretrained_tokenizer():
"""Check that the Text masker with a non-fast pretrained tokenizer masks correctly."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased", use_fast=False)
masker = shap.maskers.Text(tokenizer)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
output_masked_text = masker(test_input_mask, test_text)
correct_masked_text = "[MASK] ate a [MASK]noli"
assert output_masked_text[0] == correct_masked_text
def test_masker_call_pretrained_tokenizer_fast():
"""Check that the Text masker with a fast pretrained tokenizer masks correctly."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased", use_fast=True)
masker = shap.maskers.Text(tokenizer)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
output_masked_text = masker(test_input_mask, test_text)
correct_masked_text = "[MASK]ate a [MASK]noli"
assert output_masked_text[0] == correct_masked_text
@pytest.mark.filterwarnings(r"ignore:Recommended. pip install sacremoses")
def test_sentencepiece_tokenizer_output():
"""Tests for output for sentencepiece tokenizers to not have '_' in output of masker when passed a mask of ones."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
pytest.importorskip("sentencepiece")
tokenizer = AutoTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-es")
masker = shap.maskers.Text(tokenizer)
s = "This is a test statement for sentencepiece tokenizer"
mask = np.ones(masker.shape(s)[1], dtype=bool)
sentencepiece_tokenizer_output_processed = masker(mask, s)
expected_sentencepiece_tokenizer_output_processed = "This is a test statement for sentencepiece tokenizer"
# since we expect output wrapped in a tuple hence the indexing [0][0] to extract the string
assert sentencepiece_tokenizer_output_processed[0][0] == expected_sentencepiece_tokenizer_output_processed
def test_keep_prefix_suffix_tokenizer_parsing():
"""Checks parsed keep prefix and keep suffix for different tokenizers."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer_mt = AutoTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-es")
tokenizer_gpt = AutoTokenizer.from_pretrained("gpt2")
tokenizer_bart = AutoTokenizer.from_pretrained("sshleifer/distilbart-xsum-12-6")
masker_mt = shap.maskers.Text(tokenizer_mt)
masker_gpt = shap.maskers.Text(tokenizer_gpt)
masker_bart = shap.maskers.Text(tokenizer_bart)
masker_mt_expected_keep_prefix, masker_mt_expected_keep_suffix = 0, 1
masker_gpt_expected_keep_prefix, masker_gpt_expected_keep_suffix = 0, 0
masker_bart_expected_keep_prefix, masker_bart_expected_keep_suffix = 1, 1
assert masker_mt.keep_prefix == masker_mt_expected_keep_prefix
assert masker_mt.keep_suffix == masker_mt_expected_keep_suffix
assert masker_gpt.keep_prefix == masker_gpt_expected_keep_prefix
assert masker_gpt.keep_suffix == masker_gpt_expected_keep_suffix
assert masker_bart.keep_prefix == masker_bart_expected_keep_prefix
assert masker_bart.keep_suffix == masker_bart_expected_keep_suffix
@pytest.mark.xfail(reason="gated repository. Find alternative.")
def test_keep_prefix_suffix_tokenizer_parsing_mistralai():
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer_mistral = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
masker_mistral = shap.maskers.Text(tokenizer_mistral)
masker_mistral_expected_keep_prefix, masker_mistral_expected_keep_suffix = 1, 0
assert masker_mistral.keep_prefix == masker_mistral_expected_keep_prefix
assert masker_mistral.keep_suffix == masker_mistral_expected_keep_suffix
@pytest.mark.xfail(reason="gated repository. Find alternative.")
def test_text_infill_with_collapse_mask_token_mistralai():
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer_mistral = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
masker_mistral = shap.maskers.Text(tokenizer_mistral, mask_token="...", collapse_mask_token=True)
s = "This is a test string to be infilled"
# s_masked_with_infill_ex1 = "This is a test string ... ... ..."
mask_ex1 = np.array([True, True, True, True, True, False, False, False, False])
# s_masked_with_infill_ex2 = "This is a ... ... to be infilled"
mask_ex2 = np.array([True, True, True, False, False, True, True, True, True])
# s_masked_with_infill_ex3 = "... ... ... test string to be infilled"
mask_ex3 = np.array([False, False, False, True, True, True, True, True, True])
# s_masked_with_infill_ex4 = "... ... ... ... ... ... ... ..."
mask_ex4 = np.array([False, False, False, False, False, False, False, False, False])
text_infilled_ex1_mist = masker_mistral(np.append(True, mask_ex1), s)[0][0]
expected_text_infilled_ex1 = "This is a test string ..."
text_infilled_ex2_mist = masker_mistral(np.append(True, mask_ex2), s)[0][0]
expected_text_infilled_ex2 = "This is a ... to be infilled"
text_infilled_ex3_mist = masker_mistral(np.append(True, mask_ex3), s)[0][0]
expected_text_infilled_ex3 = "... test string to be infilled"
text_infilled_ex4_mist = masker_mistral(np.append(True, mask_ex4), s)[0][0]
expected_text_infilled_ex4 = "..."
assert text_infilled_ex1_mist == expected_text_infilled_ex1
assert text_infilled_ex2_mist == expected_text_infilled_ex2
assert text_infilled_ex3_mist == expected_text_infilled_ex3
assert text_infilled_ex4_mist == expected_text_infilled_ex4
def test_text_infill_with_collapse_mask_token():
"""Tests for different text infilling output combinations with collapsing mask token."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("gpt2")
masker = shap.maskers.Text(tokenizer, mask_token="...", collapse_mask_token=True)
s = "This is a test string to be infilled"
# s_masked_with_infill_ex1 = "This is a test string ... ... ..."
mask_ex1 = np.array([True, True, True, True, True, False, False, False, False])
# s_masked_with_infill_ex2 = "This is a ... ... to be infilled"
mask_ex2 = np.array([True, True, True, False, False, True, True, True, True])
# s_masked_with_infill_ex3 = "... ... ... test string to be infilled"
mask_ex3 = np.array([False, False, False, True, True, True, True, True, True])
# s_masked_with_infill_ex4 = "... ... ... ... ... ... ... ..."
mask_ex4 = np.array([False, False, False, False, False, False, False, False, False])
text_infilled_ex1 = masker(mask_ex1, s)[0][0]
expected_text_infilled_ex1 = "This is a test string ..."
text_infilled_ex2 = masker(mask_ex2, s)[0][0]
expected_text_infilled_ex2 = "This is a ... to be infilled"
text_infilled_ex3 = masker(mask_ex3, s)[0][0]
expected_text_infilled_ex3 = "... test string to be infilled"
text_infilled_ex4 = masker(mask_ex4, s)[0][0]
expected_text_infilled_ex4 = "..."
assert text_infilled_ex1 == expected_text_infilled_ex1
assert text_infilled_ex2 == expected_text_infilled_ex2
assert text_infilled_ex3 == expected_text_infilled_ex3
assert text_infilled_ex4 == expected_text_infilled_ex4
def test_serialization_text_masker():
"""Make sure text serialization works."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-cased", use_fast=False)
original_masker = shap.maskers.Text(tokenizer)
with tempfile.TemporaryFile() as temp_serialization_file:
original_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_masker = shap.maskers.Text.load(temp_serialization_file)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
original_masked_output = original_masker(test_input_mask, test_text)
new_masked_output = new_masker(test_input_mask, test_text)
assert original_masked_output == new_masked_output
def test_serialization_text_masker_custom_mask():
"""Make sure text serialization works with custom mask."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-cased", use_fast=True)
original_masker = shap.maskers.Text(tokenizer, mask_token="[CUSTOM-MASK]")
with tempfile.TemporaryFile() as temp_serialization_file:
original_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_masker = shap.maskers.Text.load(temp_serialization_file)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
original_masked_output = original_masker(test_input_mask, test_text)
new_masked_output = new_masker(test_input_mask, test_text)
assert original_masked_output == new_masked_output
def test_serialization_text_masker_collapse_mask_token():
"""Make sure text serialization works with collapse mask token."""
AutoTokenizer = pytest.importorskip("transformers").AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-cased", use_fast=True)
original_masker = shap.maskers.Text(tokenizer, collapse_mask_token=True)
with tempfile.TemporaryFile() as temp_serialization_file:
original_masker.save(temp_serialization_file)
temp_serialization_file.seek(0)
# deserialize masker
new_masker = shap.maskers.Text.load(temp_serialization_file)
test_text = "I ate a Cannoli"
test_input_mask = np.array([True, False, True, True, False, True, True, True])
original_masked_output = original_masker(test_input_mask, test_text)
new_masked_output = new_masker(test_input_mask, test_text)
assert original_masked_output == new_masked_output
@@ -0,0 +1,90 @@
"""This file contains tests for the TeacherForcingLogits class."""
import numpy as np
import pytest
import shap
def test_falcon():
pytest.importorskip("torch")
transformers = pytest.importorskip("transformers")
requests = pytest.importorskip("requests")
name = "fxmarty/really-tiny-falcon-testing"
try:
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
model = transformers.AutoModelForCausalLM.from_pretrained(
name, trust_remote_code=True, load_in_8bit=False, low_cpu_mem_usage=False
)
except requests.exceptions.RequestException:
pytest.xfail(reason="Connection error to transformers model")
model = model.eval()
s = ["I enjoy walking with my cute dog"]
gen_dict = dict(
max_new_tokens=100,
num_beams=5,
renormalize_logits=True,
no_repeat_ngram_size=8,
)
model.config.task_specific_params = dict()
model.config.task_specific_params["text-generation"] = gen_dict
shap_model = shap.models.TeacherForcing(model, tokenizer)
explainer = shap.Explainer(shap_model, tokenizer)
shap_values = explainer(s)
assert not np.isnan(np.sum(shap_values.values)) # type: ignore[union-attr]
def test_method_get_teacher_forced_logits_for_encoder_decoder_model():
"""Tests if get_teacher_forced_logits() works for encoder-decoder models."""
pytest.importorskip("torch")
transformers = pytest.importorskip("transformers")
requests = pytest.importorskip("requests")
name = "hf-internal-testing/tiny-random-BartModel"
try:
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
model = transformers.AutoModelForSeq2SeqLM.from_pretrained(name)
except requests.exceptions.RequestException:
pytest.xfail(reason="Connection error to transformers model")
wrapped_model = shap.models.TeacherForcing(model, tokenizer, device="cpu")
source_sentence = np.array(
["This is a test statement for verifying working of teacher forcing logits functionality"]
)
target_sentence = np.array(["Testing teacher forcing logits functionality"])
# call the get teacher forced logits function
logits = wrapped_model.get_teacher_forced_logits(source_sentence, target_sentence)
assert not np.isnan(np.sum(logits))
def test_method_get_teacher_forced_logits_for_decoder_model():
"""Tests if get_teacher_forced_logits() works for decoder only models."""
pytest.importorskip("torch")
transformers = pytest.importorskip("transformers")
requests = pytest.importorskip("requests")
name = "hf-internal-testing/tiny-random-gpt2"
try:
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
model = transformers.AutoModelForCausalLM.from_pretrained(name)
except requests.exceptions.RequestException:
pytest.xfail(reason="Connection error to transformers model")
model.config.is_decoder = True
wrapped_model = shap.models.TeacherForcing(model, tokenizer, device="cpu")
source_sentence = np.array(["This is a test statement for verifying"])
target_sentence = np.array(["working of teacher forcing logits functionality"])
# call the get teacher forced logits function
logits = wrapped_model.get_teacher_forced_logits(source_sentence, target_sentence)
assert not np.isnan(np.sum(logits))
+41
View File
@@ -0,0 +1,41 @@
"""This file contains tests for the TextGeneration class."""
import sys
import pytest
import shap
@pytest.mark.skipif(sys.platform == "win32", reason="Integer division bug in HuggingFace on Windows")
def test_call_function_text_generation():
"""Tests if target sentence from model and model wrapped in a function (mimics model agnostic scenario)
produces the same ids.
"""
torch = pytest.importorskip("torch")
transformers = pytest.importorskip("transformers")
name = "hf-internal-testing/tiny-random-BartModel"
tokenizer = transformers.AutoTokenizer.from_pretrained(name)
model = transformers.AutoModelForSeq2SeqLM.from_pretrained(name)
# Define function
def f(x):
inputs = tokenizer(x.tolist(), return_tensors="pt", padding=True)
with torch.no_grad():
out = model.generate(**inputs)
sentence = [tokenizer.decode(g, skip_special_tokens=True) for g in out]
return sentence
text_generation_for_pretrained_model = shap.models.TextGeneration(model, tokenizer=tokenizer, device="cpu")
text_generation_for_model_agnostic_scenario = shap.models.TextGeneration(f, device="cpu")
s = "This is a test statement for verifying text generation ids"
target_sentence_ids_for_pretrained_model = text_generation_for_pretrained_model(s)
target_sentence_for_pretrained_model = [
tokenizer.decode(g, skip_special_tokens=True) for g in target_sentence_ids_for_pretrained_model
]
target_sentence_for_model_agnostic_scenario = text_generation_for_model_agnostic_scenario(s)
assert target_sentence_for_pretrained_model[0] == target_sentence_for_model_agnostic_scenario[0]
+12
View File
@@ -0,0 +1,12 @@
"""The plotting baseline folder is generated using the pytest-mpl plugin.
If you have made changes to the plots, the baseline folder will need rebuilding
before the tests can run successfully. Run the following in the root directory:
`pytest tests/plots --mpl-generate-path=tests/plots/baseline`
then check that the plot images in the baseline folder are the expected output.
Then tests can be ran using `pytest .`, and the plots generated from the plot
tests will then be compared to the baseline.
"""
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Some files were not shown because too many files have changed in this diff Show More