Skip to content

Commit c1a2264

Browse files
authored
CHORE: Explicit random seed for tests (#3048)
* Fix random seed for tests * Set pytorch random seed * Use changing random seed * Better docs * Cleanup docstring * Fix typo * Handle global random seed * More consistent test random seeding * Tensorflow seed * Typo * Fix typo * Allow CLI setup of random seed * Change from Generator to RandomState * Revert changes to plot tests * Pin seeds for identified flaky tests * Fix typo * Flaky xgboost * Changelog entry * Flaky test_several_trees
1 parent 974d996 commit c1a2264

13 files changed

+204
-118
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ into the main shap repository. PRs from this origin are labelled here as `fork#1
108108
by @connortann)
109109
- Reduced unit test time by ~5 mins
110110
([#3046](https://github.com/slundberg/shap/pull/3046) by @connortann).
111+
- Introduced fixtures for reproducible fuzz testing
112+
([#3048](https://github.com/slundberg/shap/pull/3048) by @connortann).
113+
111114

112115
## [0.41.0] - 2022-06-16
113116

tests/benchmark/perturbation.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ def model(x, y):
1010

1111
sort_order = 'positive'
1212
perturbation = 'keep'
13-
X = np.random.random((10,13))
1413

15-
def test_init():
14+
def test_init(random_seed):
15+
16+
rs = np.random.RandomState(random_seed)
17+
X = rs.random((10,13))
18+
1619
tabular_masker = Independent(X)
1720
sequential_perturbation = benchmark.perturbation.SequentialPerturbation(model, tabular_masker, sort_order, perturbation)
1821
assert sequential_perturbation.data_type == "tabular"

tests/conftest.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import numpy as np
2+
import pytest
3+
4+
5+
def pytest_addoption(parser):
6+
parser.addoption("--random-seed", action="store", help="Fix the random seed")
7+
8+
9+
@pytest.fixture()
10+
def random_seed(request) -> int:
11+
"""Provides a test-specific random seed for reproducible "fuzz testing".
12+
13+
Example use in a test:
14+
15+
def test_thing(random_seed):
16+
17+
# Numpy
18+
rs = np.random.RandomState(seed=random_seed)
19+
values = rs.randint(...)
20+
21+
# Pytorch
22+
torch.manual_seed(random_seed)
23+
24+
# Tensorflow
25+
tf.compat.v1.random.set_random_seed(random_seed)
26+
27+
By default, a new seed is generated on each run of the tests. If a test
28+
fails, the random seed used will be displayed in the pytest logs.
29+
30+
The seed can be fixed by providing a CLI option e.g:
31+
32+
pytest --random-seed 123
33+
34+
For numpy usage, note the legacy `RandomState` has stricter version-to-version
35+
compatibility guarantees than new-style `default_rng`:
36+
https://numpy.org/doc/stable/reference/random/compatibility.html
37+
38+
"""
39+
manual_seed = request.config.getoption("--random-seed")
40+
if manual_seed is not None:
41+
return int(manual_seed)
42+
else:
43+
# Otherwise, create a new seed for each test
44+
rs = np.random.RandomState()
45+
return rs.randint(0, 1000)
46+
47+
48+
@pytest.fixture(autouse=True)
49+
def global_random_seed():
50+
"""Set the global numpy random seed before each test
51+
52+
Nb. Tests that use random numbers should instantiate a local
53+
`np.random.RandomState` rather than use the global numpy random state.
54+
"""
55+
np.random.seed(0)

tests/explainers/test_deep.py

+48-26
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@
1515

1616
# pylint: disable=import-outside-toplevel, no-name-in-module, import-error
1717

18-
def test_tf_eager():
18+
def test_tf_eager(random_seed):
1919
""" This is a basic eager example from keras.
2020
"""
21-
2221
tf = pytest.importorskip('tensorflow')
22+
23+
tf.compat.v1.random.set_random_seed(random_seed)
24+
rs = np.random.RandomState(random_seed)
25+
2326
if version.parse(tf.__version__) >= version.parse("2.4.0"):
2427
pytest.skip("Deep explainer does not work for TF 2.4 in eager mode.")
2528

26-
x = pd.DataFrame({"B": np.random.random(size=(100,))})
29+
x = pd.DataFrame({"B": rs.random(size=(100,))})
2730
y = x.B
2831
y = y.map(lambda zz: chr(int(zz * 2 + 65))).str.get_dummies()
2932

@@ -39,10 +42,12 @@ def test_tf_eager():
3942
assert np.abs(e.expected_value[0] + sv[0].sum(-1) - model(x.values)[:, 0]).max() < 1e-4
4043

4144

42-
def test_tf_keras_mnist_cnn(): # pylint: disable=too-many-locals
45+
def test_tf_keras_mnist_cnn(random_seed):
4346
""" This is the basic mnist cnn example from keras.
4447
"""
4548
tf = pytest.importorskip('tensorflow')
49+
rs = np.random.RandomState(random_seed)
50+
tf.compat.v1.random.set_random_seed(random_seed)
4651

4752
from tensorflow import keras
4853
from tensorflow.compat.v1 import ConfigProto, InteractiveSession
@@ -72,10 +77,10 @@ def test_tf_keras_mnist_cnn(): # pylint: disable=too-many-locals
7277

7378
# the data, split between train and test sets
7479
# (x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()
75-
x_train = np.random.randn(200, 28, 28)
76-
y_train = np.random.randint(0, 9, 200)
77-
x_test = np.random.randn(200, 28, 28)
78-
y_test = np.random.randint(0, 9, 200)
80+
x_train = rs.randn(200, 28, 28)
81+
y_train = rs.randint(0, 9, 200)
82+
x_test = rs.randn(200, 28, 28)
83+
y_test = rs.randint(0, 9, 200)
7984

8085
if K.image_data_format() == 'channels_first':
8186
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
@@ -119,8 +124,7 @@ def test_tf_keras_mnist_cnn(): # pylint: disable=too-many-locals
119124
validation_data=(x_test[:10, :], y_test[:10, :]))
120125

121126
# explain by passing the tensorflow inputs and outputs
122-
np.random.seed(0)
123-
inds = np.random.choice(x_train.shape[0], 3, replace=False)
127+
inds = rs.choice(x_train.shape[0], 3, replace=False)
124128
e = shap.DeepExplainer((model.layers[0].input, model.layers[-1].input), x_train[inds, :, :])
125129
shap_values = e.shap_values(x_test[:1])
126130

@@ -136,6 +140,10 @@ def test_tf_keras_mnist_cnn(): # pylint: disable=too-many-locals
136140
def test_tf_keras_linear():
137141
"""Test verifying that a linear model with linear data gives the correct result.
138142
"""
143+
144+
# FIXME: this test should ideally pass with any random seed. See #2960
145+
random_seed = 0
146+
139147
tf = pytest.importorskip('tensorflow')
140148

141149
from tensorflow.keras.layers import Dense, Input
@@ -144,14 +152,15 @@ def test_tf_keras_linear():
144152

145153
tf.compat.v1.disable_eager_execution()
146154

147-
np.random.seed(0)
155+
tf.compat.v1.random.set_random_seed(random_seed)
156+
rs = np.random.RandomState(random_seed)
148157

149158
# coefficients relating y with x1 and x2.
150159
coef = np.array([1, 2]).T
151160

152161
# generate data following a linear relationship
153-
x = np.random.normal(1, 10, size=(1000, len(coef)))
154-
y = np.dot(x, coef) + 1 + np.random.normal(scale=0.1, size=1000)
162+
x = rs.normal(1, 10, size=(1000, len(coef)))
163+
y = np.dot(x, coef) + 1 + rs.normal(scale=0.1, size=1000)
155164

156165
# create a linear model
157166
inputs = Input(shape=(2,))
@@ -176,10 +185,12 @@ def test_tf_keras_linear():
176185
np.testing.assert_allclose(expected - values, 0, atol=1e-5)
177186

178187

179-
def test_tf_keras_imdb_lstm():
188+
def test_tf_keras_imdb_lstm(random_seed):
180189
""" Basic LSTM example using the keras API defined in tensorflow
181190
"""
182191
tf = pytest.importorskip('tensorflow')
192+
rs = np.random.RandomState(random_seed)
193+
tf.compat.v1.random.set_random_seed(random_seed)
183194

184195
# this fails right now for new TF versions (there is a warning in the code for this)
185196
if version.parse(tf.__version__) >= version.parse("2.5.0"):
@@ -193,7 +204,6 @@ def test_tf_keras_imdb_lstm():
193204
tf.compat.v1.disable_eager_execution()
194205

195206
# load the data from keras
196-
np.random.seed(7)
197207
max_features = 1000
198208
try:
199209
(X_train, _), (X_test, _) = imdb.load_data(num_words=max_features)
@@ -211,7 +221,7 @@ def test_tf_keras_imdb_lstm():
211221
mod.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
212222

213223
# select the background and test samples
214-
inds = np.random.choice(X_train.shape[0], 3, replace=False)
224+
inds = rs.choice(X_train.shape[0], 3, replace=False)
215225
background = X_train[inds]
216226
testx = X_test[10:11]
217227

@@ -238,6 +248,12 @@ def test_pytorch_mnist_cnn():
238248
from torch import nn
239249
from torch.nn import functional as F
240250

251+
# FIXME: this test should ideally pass with any random seed. See #2960
252+
random_seed = 0
253+
254+
torch.manual_seed(random_seed)
255+
rs = np.random.RandomState(random_seed)
256+
241257
class RandData:
242258
""" Random test data.
243259
"""
@@ -315,8 +331,7 @@ def train(model, device, train_loader, optimizer, _, cutoff=20):
315331
train(model, device, train_loader, optimizer, 1)
316332

317333
next_x, _ = next(iter(train_loader))
318-
np.random.seed(0)
319-
inds = np.random.choice(next_x.shape[0], 3, replace=False)
334+
inds = rs.choice(next_x.shape[0], 3, replace=False)
320335
if interim:
321336
e = shap.DeepExplainer((model, model.conv_layers[0]), next_x[inds, :, :, :])
322337
else:
@@ -349,7 +364,7 @@ def train(model, device, train_loader, optimizer, _, cutoff=20):
349364
run_test(train_loader, test_loader, interim=False)
350365

351366

352-
def test_pytorch_custom_nested_models():
367+
def test_pytorch_custom_nested_models(random_seed):
353368
"""Testing single outputs
354369
"""
355370
torch = pytest.importorskip('torch')
@@ -359,6 +374,9 @@ def test_pytorch_custom_nested_models():
359374
from torch.nn import functional as F
360375
from torch.utils.data import DataLoader, TensorDataset
361376

377+
torch.manual_seed(random_seed)
378+
rs = np.random.RandomState(random_seed)
379+
362380
X, y = fetch_california_housing(return_X_y=True)
363381
num_features = X.shape[1]
364382
data = TensorDataset(torch.tensor(X).float(),
@@ -436,8 +454,7 @@ def train(model, device, train_loader, optimizer, epoch):
436454
train(model, device, loader, optimizer, 1)
437455

438456
next_x, _ = next(iter(loader))
439-
np.random.seed(0)
440-
inds = np.random.choice(next_x.shape[0], 20, replace=False)
457+
inds = rs.choice(next_x.shape[0], 20, replace=False)
441458
e = shap.DeepExplainer(model, next_x[inds, :])
442459
test_x, _ = next(iter(loader))
443460
shap_values = e.shap_values(test_x[:1])
@@ -461,6 +478,11 @@ def test_pytorch_single_output():
461478
from torch.nn import functional as F
462479
from torch.utils.data import DataLoader, TensorDataset
463480

481+
# FIXME: this test should ideally pass with any random seed. See #2960
482+
random_seed=0
483+
torch.manual_seed(random_seed)
484+
rs = np.random.RandomState(random_seed)
485+
464486
X, y = fetch_california_housing(return_X_y=True)
465487
num_features = X.shape[1]
466488
data = TensorDataset(torch.tensor(X).float(),
@@ -507,8 +529,7 @@ def train(model, device, train_loader, optimizer, epoch):
507529
train(model, device, loader, optimizer, 1)
508530

509531
next_x, _ = next(iter(loader))
510-
np.random.seed(0)
511-
inds = np.random.choice(next_x.shape[0], 20, replace=False)
532+
inds = rs.choice(next_x.shape[0], 20, replace=False)
512533
e = shap.DeepExplainer(model, next_x[inds, :])
513534
test_x, _ = next(iter(loader))
514535
shap_values = e.shap_values(test_x[:1])
@@ -522,10 +543,12 @@ def train(model, device, train_loader, optimizer, epoch):
522543
assert d / np.abs(diff).sum() < 0.001, "Sum of SHAP values does not match difference! %f" % (d / np.abs(diff).sum())
523544

524545

525-
def test_pytorch_multiple_inputs():
546+
def test_pytorch_multiple_inputs(random_seed):
526547
""" Check a multi-input scenario.
527548
"""
528549
torch = pytest.importorskip('torch')
550+
torch.manual_seed(random_seed)
551+
rs = np.random.RandomState(random_seed)
529552

530553
def _run_pytorch_multiple_inputs_test(disconnected):
531554
""" Testing multiple inputs
@@ -590,8 +613,7 @@ def train(model, device, train_loader, optimizer, epoch):
590613
train(model, device, loader, optimizer, 1)
591614

592615
next_x1, next_x2, _ = next(iter(loader))
593-
np.random.seed(0)
594-
inds = np.random.choice(next_x1.shape[0], 20, replace=False)
616+
inds = rs.choice(next_x1.shape[0], 20, replace=False)
595617
background = [next_x1[inds, :], next_x2[inds, :]]
596618
e = shap.DeepExplainer(model, background)
597619
test_x1, test_x2, _ = next(iter(loader))

tests/explainers/test_gradient.py

+22-16
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77

88
# pylint: disable=import-error, import-outside-toplevel, no-name-in-module, import-error
99

10-
def test_tf_keras_mnist_cnn():
10+
def test_tf_keras_mnist_cnn(random_seed):
1111
""" This is the basic mnist cnn example from keras.
1212
"""
13+
1314
tf = pytest.importorskip('tensorflow')
15+
16+
rs = np.random.RandomState(random_seed)
17+
tf.compat.v1.random.set_random_seed(random_seed)
18+
1419
from tensorflow.compat.v1 import ConfigProto, InteractiveSession
1520
from tensorflow.keras import backend as K
1621
from tensorflow.keras.layers import (
@@ -38,10 +43,10 @@ def test_tf_keras_mnist_cnn():
3843

3944
# the data, split between train and test sets
4045
#(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
41-
x_train = np.random.randn(200, 28, 28)
42-
y_train = np.random.randint(0, 9, 200)
43-
x_test = np.random.randn(200, 28, 28)
44-
y_test = np.random.randint(0, 9, 200)
46+
x_train = rs.randn(200, 28, 28)
47+
y_train = rs.randint(0, 9, 200)
48+
x_test = rs.randn(200, 28, 28)
49+
y_test = rs.randint(0, 9, 200)
4550

4651
if K.image_data_format() == 'channels_first':
4752
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
@@ -88,8 +93,7 @@ def test_tf_keras_mnist_cnn():
8893
)
8994

9095
# explain by passing the tensorflow inputs and outputs
91-
np.random.seed(0)
92-
inds = np.random.choice(x_train.shape[0], 20, replace=False)
96+
inds = rs.choice(x_train.shape[0], 20, replace=False)
9397
e = shap.GradientExplainer((model.layers[0].input, model.layers[-1].input), x_train[inds, :, :])
9498
shap_values = e.shap_values(x_test[:1], nsamples=2000)
9599

@@ -102,14 +106,17 @@ def test_tf_keras_mnist_cnn():
102106
sess.close()
103107

104108

105-
def test_pytorch_mnist_cnn():
109+
def test_pytorch_mnist_cnn(random_seed):
106110
"""The same test as above, but for pytorch
107111
"""
112+
108113
torch = pytest.importorskip('torch')
114+
torch.manual_seed(random_seed)
115+
rs = np.random.RandomState(random_seed)
109116

110117
from torch import nn
111118
from torch.nn import functional as F
112-
torch.manual_seed(0)
119+
113120

114121
batch_size = 128
115122

@@ -199,8 +206,7 @@ def train(model, device, train_loader, optimizer, _, cutoff=20):
199206
train(model, device, train_loader, optimizer, 1)
200207

201208
next_x, _ = next(iter(train_loader))
202-
np.random.seed(0)
203-
inds = np.random.choice(next_x.shape[0], 3, replace=False)
209+
inds = rs.choice(next_x.shape[0], 3, replace=False)
204210
if interim:
205211
e = shap.GradientExplainer((model, model.conv1), next_x[inds, :, :, :])
206212
else:
@@ -225,13 +231,13 @@ def train(model, device, train_loader, optimizer, _, cutoff=20):
225231
run_test(train_loader, test_loader, False)
226232

227233

228-
def test_pytorch_multiple_inputs():
229-
""" Test multi-input scenarios.
230-
"""
231-
# pylint: disable=no-member
234+
def test_pytorch_multiple_inputs(random_seed):
235+
""" Test multi-input scenarios."""
236+
232237
torch = pytest.importorskip('torch')
233238
from torch import nn
234-
torch.manual_seed(1)
239+
240+
torch.manual_seed(random_seed)
235241
batch_size = 10
236242
x1 = torch.ones(batch_size, 3)
237243
x2 = torch.ones(batch_size, 4)

0 commit comments

Comments
 (0)