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