473 lines
17 KiB
Python
473 lines
17 KiB
Python
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()
|
|
)
|