"""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!", )