import numpy as np from numpy.testing import assert_allclose, assert_array_less import pytest from scipy import stats from scipy.stats import sobol_indices from scipy.stats._resampling import BootstrapResult from scipy.stats._sensitivity_analysis import ( BootstrapSobolResult, f_ishigami, sample_AB, sample_A_B ) @pytest.fixture(scope='session') def ishigami_ref_indices(): """Reference values for Ishigami from Saltelli2007. Chapter 4, exercise 5 pages 179-182. """ a = 7. b = 0.1 var = 0.5 + a**2/8 + b*np.pi**4/5 + b**2*np.pi**8/18 v1 = 0.5 + b*np.pi**4/5 + b**2*np.pi**8/50 v2 = a**2/8 v3 = 0 v12 = 0 # v13: mistake in the book, see other derivations e.g. in 10.1002/nme.4856 v13 = b**2*np.pi**8*8/225 v23 = 0 s_first = np.array([v1, v2, v3])/var s_second = np.array([ [0., 0., v13], [v12, 0., v23], [v13, v23, 0.] ])/var s_total = s_first + s_second.sum(axis=1) return s_first, s_total def f_ishigami_vec(x): """Output of shape (2, n).""" res = f_ishigami(x) return res, res class TestSobolIndices: dists = [ stats.uniform(loc=-np.pi, scale=2*np.pi) # type: ignore[attr-defined] ] * 3 def test_sample_AB(self): # (d, n) A = np.array( [[1, 4, 7, 10], [2, 5, 8, 11], [3, 6, 9, 12]] ) B = A + 100 # (d, d, n) ref = np.array( [[[101, 104, 107, 110], [2, 5, 8, 11], [3, 6, 9, 12]], [[1, 4, 7, 10], [102, 105, 108, 111], [3, 6, 9, 12]], [[1, 4, 7, 10], [2, 5, 8, 11], [103, 106, 109, 112]]] ) AB = sample_AB(A=A, B=B) assert_allclose(AB, ref) @pytest.mark.xfail_on_32bit("Can't create large array for test") @pytest.mark.parametrize( 'func', [f_ishigami, pytest.param(f_ishigami_vec, marks=pytest.mark.slow)], ids=['scalar', 'vector'] ) def test_ishigami(self, ishigami_ref_indices, func): rng = np.random.default_rng(28631265345463262246170309650372465332) res = sobol_indices( func=func, n=4096, dists=self.dists, random_state=rng ) if func.__name__ == 'f_ishigami_vec': ishigami_ref_indices = [ [ishigami_ref_indices[0], ishigami_ref_indices[0]], [ishigami_ref_indices[1], ishigami_ref_indices[1]] ] assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2) assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2) assert res._bootstrap_result is None bootstrap_res = res.bootstrap(n_resamples=99) assert isinstance(bootstrap_res, BootstrapSobolResult) assert isinstance(res._bootstrap_result, BootstrapResult) assert res._bootstrap_result.confidence_interval.low.shape[0] == 2 assert res._bootstrap_result.confidence_interval.low[1].shape \ == res.first_order.shape assert bootstrap_res.first_order.confidence_interval.low.shape \ == res.first_order.shape assert bootstrap_res.total_order.confidence_interval.low.shape \ == res.total_order.shape assert_array_less( bootstrap_res.first_order.confidence_interval.low, res.first_order ) assert_array_less( res.first_order, bootstrap_res.first_order.confidence_interval.high ) assert_array_less( bootstrap_res.total_order.confidence_interval.low, res.total_order ) assert_array_less( res.total_order, bootstrap_res.total_order.confidence_interval.high ) # call again to use previous results and change a param assert isinstance( res.bootstrap(confidence_level=0.9, n_resamples=99), BootstrapSobolResult ) assert isinstance(res._bootstrap_result, BootstrapResult) def test_func_dict(self, ishigami_ref_indices): rng = np.random.default_rng(28631265345463262246170309650372465332) n = 4096 dists = [ stats.uniform(loc=-np.pi, scale=2*np.pi), stats.uniform(loc=-np.pi, scale=2*np.pi), stats.uniform(loc=-np.pi, scale=2*np.pi) ] A, B = sample_A_B(n=n, dists=dists, random_state=rng) AB = sample_AB(A=A, B=B) func = { 'f_A': f_ishigami(A).reshape(1, -1), 'f_B': f_ishigami(B).reshape(1, -1), 'f_AB': f_ishigami(AB).reshape((3, 1, -1)) } res = sobol_indices( func=func, n=n, dists=dists, random_state=rng ) assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2) res = sobol_indices( func=func, n=n, random_state=rng ) assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2) def test_method(self, ishigami_ref_indices): def jansen_sobol(f_A, f_B, f_AB): """Jansen for S and Sobol' for St. From Saltelli2010, table 2 formulations (c) and (e).""" var = np.var([f_A, f_B], axis=(0, -1)) s = (var - 0.5*np.mean((f_B - f_AB)**2, axis=-1)) / var st = np.mean(f_A*(f_A - f_AB), axis=-1) / var return s.T, st.T rng = np.random.default_rng(28631265345463262246170309650372465332) res = sobol_indices( func=f_ishigami, n=4096, dists=self.dists, method=jansen_sobol, random_state=rng ) assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2) assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2) def jansen_sobol_typed( f_A: np.ndarray, f_B: np.ndarray, f_AB: np.ndarray ) -> tuple[np.ndarray, np.ndarray]: return jansen_sobol(f_A, f_B, f_AB) _ = sobol_indices( func=f_ishigami, n=8, dists=self.dists, method=jansen_sobol_typed, random_state=rng ) def test_normalization(self, ishigami_ref_indices): rng = np.random.default_rng(28631265345463262246170309650372465332) res = sobol_indices( func=lambda x: f_ishigami(x) + 1000, n=4096, dists=self.dists, random_state=rng ) assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2) assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2) def test_constant_function(self, ishigami_ref_indices): def f_ishigami_vec_const(x): """Output of shape (3, n).""" res = f_ishigami(x) return res, res * 0 + 10, res rng = np.random.default_rng(28631265345463262246170309650372465332) res = sobol_indices( func=f_ishigami_vec_const, n=4096, dists=self.dists, random_state=rng ) ishigami_vec_indices = [ [ishigami_ref_indices[0], [0, 0, 0], ishigami_ref_indices[0]], [ishigami_ref_indices[1], [0, 0, 0], ishigami_ref_indices[1]] ] assert_allclose(res.first_order, ishigami_vec_indices[0], atol=1e-2) assert_allclose(res.total_order, ishigami_vec_indices[1], atol=1e-2) @pytest.mark.xfail_on_32bit("Can't create large array for test") def test_more_converged(self, ishigami_ref_indices): rng = np.random.default_rng(28631265345463262246170309650372465332) res = sobol_indices( func=f_ishigami, n=2**19, # 524288 dists=self.dists, random_state=rng ) assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-4) assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-4) def test_raises(self): message = r"Each distribution in `dists` must have method `ppf`" with pytest.raises(ValueError, match=message): sobol_indices(n=0, func=f_ishigami, dists="uniform") with pytest.raises(ValueError, match=message): sobol_indices(n=0, func=f_ishigami, dists=[lambda x: x]) message = r"The balance properties of Sobol'" with pytest.raises(ValueError, match=message): sobol_indices(n=7, func=f_ishigami, dists=[stats.uniform()]) with pytest.raises(ValueError, match=message): sobol_indices(n=4.1, func=f_ishigami, dists=[stats.uniform()]) message = r"'toto' is not a valid 'method'" with pytest.raises(ValueError, match=message): sobol_indices(n=0, func=f_ishigami, method='toto') message = r"must have the following signature" with pytest.raises(ValueError, match=message): sobol_indices(n=0, func=f_ishigami, method=lambda x: x) message = r"'dists' must be defined when 'func' is a callable" with pytest.raises(ValueError, match=message): sobol_indices(n=0, func=f_ishigami) def func_wrong_shape_output(x): return x.reshape(-1, 1) message = r"'func' output should have a shape" with pytest.raises(ValueError, match=message): sobol_indices( n=2, func=func_wrong_shape_output, dists=[stats.uniform()] ) message = r"When 'func' is a dictionary" with pytest.raises(ValueError, match=message): sobol_indices( n=2, func={'f_A': [], 'f_AB': []}, dists=[stats.uniform()] ) with pytest.raises(ValueError, match=message): # f_B malformed sobol_indices( n=2, func={'f_A': [1, 2], 'f_B': [3], 'f_AB': [5, 6, 7, 8]}, ) with pytest.raises(ValueError, match=message): # f_AB malformed sobol_indices( n=2, func={'f_A': [1, 2], 'f_B': [3, 4], 'f_AB': [5, 6, 7]}, )