chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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!",
|
||||
)
|
||||
Reference in New Issue
Block a user