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() )