test_sensitivity_analysis.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import numpy as np
  2. from numpy.testing import assert_allclose, assert_array_less
  3. import pytest
  4. from scipy import stats
  5. from scipy.stats import sobol_indices
  6. from scipy.stats._resampling import BootstrapResult
  7. from scipy.stats._sensitivity_analysis import (
  8. BootstrapSobolResult, f_ishigami, sample_AB, sample_A_B
  9. )
  10. @pytest.fixture(scope='session')
  11. def ishigami_ref_indices():
  12. """Reference values for Ishigami from Saltelli2007.
  13. Chapter 4, exercise 5 pages 179-182.
  14. """
  15. a = 7.
  16. b = 0.1
  17. var = 0.5 + a**2/8 + b*np.pi**4/5 + b**2*np.pi**8/18
  18. v1 = 0.5 + b*np.pi**4/5 + b**2*np.pi**8/50
  19. v2 = a**2/8
  20. v3 = 0
  21. v12 = 0
  22. # v13: mistake in the book, see other derivations e.g. in 10.1002/nme.4856
  23. v13 = b**2*np.pi**8*8/225
  24. v23 = 0
  25. s_first = np.array([v1, v2, v3])/var
  26. s_second = np.array([
  27. [0., 0., v13],
  28. [v12, 0., v23],
  29. [v13, v23, 0.]
  30. ])/var
  31. s_total = s_first + s_second.sum(axis=1)
  32. return s_first, s_total
  33. def f_ishigami_vec(x):
  34. """Output of shape (2, n)."""
  35. res = f_ishigami(x)
  36. return res, res
  37. class TestSobolIndices:
  38. dists = [
  39. stats.uniform(loc=-np.pi, scale=2*np.pi) # type: ignore[attr-defined]
  40. ] * 3
  41. def test_sample_AB(self):
  42. # (d, n)
  43. A = np.array(
  44. [[1, 4, 7, 10],
  45. [2, 5, 8, 11],
  46. [3, 6, 9, 12]]
  47. )
  48. B = A + 100
  49. # (d, d, n)
  50. ref = np.array(
  51. [[[101, 104, 107, 110],
  52. [2, 5, 8, 11],
  53. [3, 6, 9, 12]],
  54. [[1, 4, 7, 10],
  55. [102, 105, 108, 111],
  56. [3, 6, 9, 12]],
  57. [[1, 4, 7, 10],
  58. [2, 5, 8, 11],
  59. [103, 106, 109, 112]]]
  60. )
  61. AB = sample_AB(A=A, B=B)
  62. assert_allclose(AB, ref)
  63. @pytest.mark.xslow
  64. @pytest.mark.xfail_on_32bit("Can't create large array for test")
  65. @pytest.mark.parametrize(
  66. 'func',
  67. [f_ishigami, pytest.param(f_ishigami_vec, marks=pytest.mark.slow)],
  68. ids=['scalar', 'vector']
  69. )
  70. def test_ishigami(self, ishigami_ref_indices, func):
  71. rng = np.random.default_rng(28631265345463262246170309650372465332)
  72. res = sobol_indices(
  73. func=func, n=4096,
  74. dists=self.dists,
  75. rng=rng
  76. )
  77. if func.__name__ == 'f_ishigami_vec':
  78. ishigami_ref_indices = [
  79. [ishigami_ref_indices[0], ishigami_ref_indices[0]],
  80. [ishigami_ref_indices[1], ishigami_ref_indices[1]]
  81. ]
  82. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2)
  83. assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2)
  84. assert res._bootstrap_result is None
  85. bootstrap_res = res.bootstrap(n_resamples=99)
  86. assert isinstance(bootstrap_res, BootstrapSobolResult)
  87. assert isinstance(res._bootstrap_result, BootstrapResult)
  88. assert res._bootstrap_result.confidence_interval.low.shape[0] == 2
  89. assert res._bootstrap_result.confidence_interval.low[1].shape \
  90. == res.first_order.shape
  91. assert bootstrap_res.first_order.confidence_interval.low.shape \
  92. == res.first_order.shape
  93. assert bootstrap_res.total_order.confidence_interval.low.shape \
  94. == res.total_order.shape
  95. assert_array_less(
  96. bootstrap_res.first_order.confidence_interval.low, res.first_order
  97. )
  98. assert_array_less(
  99. res.first_order, bootstrap_res.first_order.confidence_interval.high
  100. )
  101. assert_array_less(
  102. bootstrap_res.total_order.confidence_interval.low, res.total_order
  103. )
  104. assert_array_less(
  105. res.total_order, bootstrap_res.total_order.confidence_interval.high
  106. )
  107. # call again to use previous results and change a param
  108. assert isinstance(
  109. res.bootstrap(confidence_level=0.9, n_resamples=99),
  110. BootstrapSobolResult
  111. )
  112. assert isinstance(res._bootstrap_result, BootstrapResult)
  113. def test_func_dict(self, ishigami_ref_indices):
  114. rng = np.random.default_rng(28631265345463262246170309650372465332)
  115. n = 4096
  116. dists = [
  117. stats.uniform(loc=-np.pi, scale=2*np.pi),
  118. stats.uniform(loc=-np.pi, scale=2*np.pi),
  119. stats.uniform(loc=-np.pi, scale=2*np.pi)
  120. ]
  121. A, B = sample_A_B(n=n, dists=dists, rng=rng)
  122. AB = sample_AB(A=A, B=B)
  123. func = {
  124. 'f_A': f_ishigami(A).reshape(1, -1),
  125. 'f_B': f_ishigami(B).reshape(1, -1),
  126. 'f_AB': f_ishigami(AB).reshape((3, 1, -1))
  127. }
  128. # preserve use of old random_state during SPEC 7 transition
  129. res = sobol_indices(
  130. func=func, n=n,
  131. dists=dists,
  132. rng=rng
  133. )
  134. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2)
  135. res = sobol_indices(
  136. func=func, n=n,
  137. rng=rng
  138. )
  139. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2)
  140. # Ideally should be exactly equal but since f_ishigami
  141. # uses floating point operations, so exact equality
  142. # might not be possible (due to flakiness in computation).
  143. # So, assert_allclose is used with default parameters
  144. # Regression test for https://github.com/scipy/scipy/issues/21383
  145. assert_allclose(f_ishigami(A).reshape(1, -1), func['f_A'])
  146. assert_allclose(f_ishigami(B).reshape(1, -1), func['f_B'])
  147. assert_allclose(f_ishigami(AB).reshape((3, 1, -1)), func['f_AB'])
  148. def test_method(self, ishigami_ref_indices):
  149. def jansen_sobol(f_A, f_B, f_AB):
  150. """Jansen for S and Sobol' for St.
  151. From Saltelli2010, table 2 formulations (c) and (e)."""
  152. var = np.var([f_A, f_B], axis=(0, -1))
  153. s = (var - 0.5*np.mean((f_B - f_AB)**2, axis=-1)) / var
  154. st = np.mean(f_A*(f_A - f_AB), axis=-1) / var
  155. return s.T, st.T
  156. rng = np.random.default_rng(28631265345463262246170309650372465332)
  157. res = sobol_indices(
  158. func=f_ishigami, n=4096,
  159. dists=self.dists,
  160. method=jansen_sobol,
  161. rng=rng
  162. )
  163. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2)
  164. assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2)
  165. def jansen_sobol_typed(
  166. f_A: np.ndarray, f_B: np.ndarray, f_AB: np.ndarray
  167. ) -> tuple[np.ndarray, np.ndarray]:
  168. return jansen_sobol(f_A, f_B, f_AB)
  169. _ = sobol_indices(
  170. func=f_ishigami, n=8,
  171. dists=self.dists,
  172. method=jansen_sobol_typed,
  173. rng=rng
  174. )
  175. def test_normalization(self, ishigami_ref_indices):
  176. rng = np.random.default_rng(28631265345463262246170309650372465332)
  177. res = sobol_indices(
  178. func=lambda x: f_ishigami(x) + 1000, n=4096,
  179. dists=self.dists,
  180. rng=rng
  181. )
  182. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-2)
  183. assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-2)
  184. def test_constant_function(self, ishigami_ref_indices):
  185. def f_ishigami_vec_const(x):
  186. """Output of shape (3, n)."""
  187. res = f_ishigami(x)
  188. return res, res * 0 + 10, res
  189. rng = np.random.default_rng(28631265345463262246170309650372465332)
  190. res = sobol_indices(
  191. func=f_ishigami_vec_const, n=4096,
  192. dists=self.dists,
  193. rng=rng
  194. )
  195. ishigami_vec_indices = [
  196. [ishigami_ref_indices[0], [0, 0, 0], ishigami_ref_indices[0]],
  197. [ishigami_ref_indices[1], [0, 0, 0], ishigami_ref_indices[1]]
  198. ]
  199. assert_allclose(res.first_order, ishigami_vec_indices[0], atol=1e-2)
  200. assert_allclose(res.total_order, ishigami_vec_indices[1], atol=1e-2)
  201. @pytest.mark.xfail_on_32bit("Can't create large array for test")
  202. def test_more_converged(self, ishigami_ref_indices):
  203. rng = np.random.default_rng(28631265345463262246170309650372465332)
  204. res = sobol_indices(
  205. func=f_ishigami, n=2**19, # 524288
  206. dists=self.dists,
  207. rng=rng
  208. )
  209. assert_allclose(res.first_order, ishigami_ref_indices[0], atol=1e-4)
  210. assert_allclose(res.total_order, ishigami_ref_indices[1], atol=1e-4)
  211. def test_raises(self):
  212. message = r"Each distribution in `dists` must have method `ppf`"
  213. with pytest.raises(ValueError, match=message):
  214. sobol_indices(n=0, func=f_ishigami, dists="uniform")
  215. with pytest.raises(ValueError, match=message):
  216. sobol_indices(n=0, func=f_ishigami, dists=[lambda x: x])
  217. message = r"The balance properties of Sobol'"
  218. with pytest.raises(ValueError, match=message):
  219. sobol_indices(n=7, func=f_ishigami, dists=[stats.uniform()])
  220. with pytest.raises(ValueError, match=message):
  221. sobol_indices(n=4.1, func=f_ishigami, dists=[stats.uniform()])
  222. message = r"'toto' is not a valid 'method'"
  223. with pytest.raises(ValueError, match=message):
  224. sobol_indices(n=0, func=f_ishigami, method='toto')
  225. message = r"must have the following signature"
  226. with pytest.raises(ValueError, match=message):
  227. sobol_indices(n=0, func=f_ishigami, method=lambda x: x)
  228. message = r"'dists' must be defined when 'func' is a callable"
  229. with pytest.raises(ValueError, match=message):
  230. sobol_indices(n=0, func=f_ishigami)
  231. def func_wrong_shape_output(x):
  232. return x.reshape(-1, 1)
  233. message = r"'func' output should have a shape"
  234. with pytest.raises(ValueError, match=message):
  235. sobol_indices(
  236. n=2, func=func_wrong_shape_output, dists=[stats.uniform()]
  237. )
  238. message = r"When 'func' is a dictionary"
  239. with pytest.raises(ValueError, match=message):
  240. sobol_indices(
  241. n=2, func={'f_A': [], 'f_AB': []}, dists=[stats.uniform()]
  242. )
  243. with pytest.raises(ValueError, match=message):
  244. # f_B malformed
  245. sobol_indices(
  246. n=2,
  247. func={'f_A': [1, 2], 'f_B': [3], 'f_AB': [5, 6, 7, 8]},
  248. )
  249. with pytest.raises(ValueError, match=message):
  250. # f_AB malformed
  251. sobol_indices(
  252. n=2,
  253. func={'f_A': [1, 2], 'f_B': [3, 4], 'f_AB': [5, 6, 7]},
  254. )