test_quadrature.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
  1. # mypy: disable-error-code="attr-defined"
  2. import pytest
  3. import numpy as np
  4. from numpy.testing import assert_equal, assert_almost_equal, assert_allclose
  5. from hypothesis import given
  6. import hypothesis.strategies as st
  7. import hypothesis.extra.numpy as hyp_num
  8. from scipy.integrate import (romb, newton_cotes,
  9. cumulative_trapezoid, trapezoid,
  10. quad, simpson, fixed_quad,
  11. qmc_quad, cumulative_simpson)
  12. from scipy.integrate._quadrature import _cumulative_simpson_unequal_intervals
  13. from scipy import stats, special, integrate
  14. from scipy.conftest import skip_xp_invalid_arg
  15. from scipy._lib._array_api import make_xp_test_case, xp_default_dtype, is_numpy
  16. from scipy._lib._array_api_no_0d import xp_assert_close, xp_assert_equal
  17. skip_xp_backends = pytest.mark.skip_xp_backends
  18. @make_xp_test_case(fixed_quad)
  19. class TestFixedQuad:
  20. def test_scalar(self):
  21. n = 4
  22. expected = 1/(2*n)
  23. got, _ = fixed_quad(lambda x: x**(2*n - 1), 0, 1, n=n)
  24. # quadrature exact for this input
  25. assert_allclose(got, expected, rtol=1e-12)
  26. def test_vector(self):
  27. n = 4
  28. p = np.arange(1, 2*n)
  29. expected = 1/(p + 1)
  30. got, _ = fixed_quad(lambda x: x**p[:, None], 0, 1, n=n)
  31. assert_allclose(got, expected, rtol=1e-12)
  32. @make_xp_test_case(romb)
  33. class TestRomb:
  34. def test_romb(self, xp):
  35. xp_assert_equal(romb(xp.arange(17.0)), xp.asarray(128.0, dtype=xp.float64))
  36. def test_romb_gh_3731(self, xp):
  37. # Check that romb makes maximal use of data points
  38. x = np.arange(2**4+1)
  39. y = np.cos(0.2*x)
  40. val = romb(xp.asarray(y))
  41. expected, _ = quad(lambda x: np.cos(np.array(0.2*x)), np.min(x), np.max(x))
  42. xp_assert_close(val, xp.asarray(expected, dtype=xp.float64), rtol=1e-8, atol=0)
  43. @make_xp_test_case(newton_cotes)
  44. class TestNewtonCotes:
  45. def test_newton_cotes(self):
  46. """Test the first few degrees, for evenly spaced points."""
  47. n = 1
  48. wts, errcoff = newton_cotes(n, 1)
  49. assert_equal(wts, n*np.array([0.5, 0.5]))
  50. assert_almost_equal(errcoff, -n**3/12.0)
  51. n = 2
  52. wts, errcoff = newton_cotes(n, 1)
  53. assert_almost_equal(wts, n*np.array([1.0, 4.0, 1.0])/6.0)
  54. assert_almost_equal(errcoff, -n**5/2880.0)
  55. n = 3
  56. wts, errcoff = newton_cotes(n, 1)
  57. assert_almost_equal(wts, n*np.array([1.0, 3.0, 3.0, 1.0])/8.0)
  58. assert_almost_equal(errcoff, -n**5/6480.0)
  59. n = 4
  60. wts, errcoff = newton_cotes(n, 1)
  61. assert_almost_equal(wts, n*np.array([7.0, 32.0, 12.0, 32.0, 7.0])/90.0)
  62. assert_almost_equal(errcoff, -n**7/1935360.0)
  63. def test_newton_cotes2(self):
  64. """Test newton_cotes with points that are not evenly spaced."""
  65. x = np.array([0.0, 1.5, 2.0])
  66. y = x**2
  67. wts, errcoff = newton_cotes(x)
  68. exact_integral = 8.0/3
  69. numeric_integral = np.dot(wts, y)
  70. assert_almost_equal(numeric_integral, exact_integral)
  71. x = np.array([0.0, 1.4, 2.1, 3.0])
  72. y = x**2
  73. wts, errcoff = newton_cotes(x)
  74. exact_integral = 9.0
  75. numeric_integral = np.dot(wts, y)
  76. assert_almost_equal(numeric_integral, exact_integral)
  77. @make_xp_test_case(simpson)
  78. class TestSimpson:
  79. def test_simpson(self):
  80. y = np.arange(17)
  81. assert_equal(simpson(y), 128)
  82. assert_equal(simpson(y, dx=0.5), 64)
  83. assert_equal(simpson(y, x=np.linspace(0, 4, 17)), 32)
  84. # integral should be exactly 21
  85. x = np.linspace(1, 4, 4)
  86. def f(x):
  87. return x**2
  88. assert_allclose(simpson(f(x), x=x), 21.0)
  89. # integral should be exactly 114
  90. x = np.linspace(1, 7, 4)
  91. assert_allclose(simpson(f(x), dx=2.0), 114)
  92. # test multi-axis behaviour
  93. a = np.arange(16).reshape(4, 4)
  94. x = np.arange(64.).reshape(4, 4, 4)
  95. y = f(x)
  96. for i in range(3):
  97. r = simpson(y, x=x, axis=i)
  98. it = np.nditer(a, flags=['multi_index'])
  99. for _ in it:
  100. idx = list(it.multi_index)
  101. idx.insert(i, slice(None))
  102. integral = x[tuple(idx)][-1]**3 / 3 - x[tuple(idx)][0]**3 / 3
  103. assert_allclose(r[it.multi_index], integral)
  104. # test when integration axis only has two points
  105. x = np.arange(16).reshape(8, 2)
  106. y = f(x)
  107. r = simpson(y, x=x, axis=-1)
  108. integral = 0.5 * (y[:, 1] + y[:, 0]) * (x[:, 1] - x[:, 0])
  109. assert_allclose(r, integral)
  110. # odd points, test multi-axis behaviour
  111. a = np.arange(25).reshape(5, 5)
  112. x = np.arange(125).reshape(5, 5, 5)
  113. y = f(x)
  114. for i in range(3):
  115. r = simpson(y, x=x, axis=i)
  116. it = np.nditer(a, flags=['multi_index'])
  117. for _ in it:
  118. idx = list(it.multi_index)
  119. idx.insert(i, slice(None))
  120. integral = x[tuple(idx)][-1]**3 / 3 - x[tuple(idx)][0]**3 / 3
  121. assert_allclose(r[it.multi_index], integral)
  122. # Tests for checking base case
  123. x = np.array([3])
  124. y = np.power(x, 2)
  125. assert_allclose(simpson(y, x=x, axis=0), 0.0)
  126. assert_allclose(simpson(y, x=x, axis=-1), 0.0)
  127. x = np.array([3, 3, 3, 3])
  128. y = np.power(x, 2)
  129. assert_allclose(simpson(y, x=x, axis=0), 0.0)
  130. assert_allclose(simpson(y, x=x, axis=-1), 0.0)
  131. x = np.array([[1, 2, 4, 8], [1, 2, 4, 8], [1, 2, 4, 8]])
  132. y = np.power(x, 2)
  133. zero_axis = [0.0, 0.0, 0.0, 0.0]
  134. default_axis = [170 + 1/3] * 3 # 8**3 / 3 - 1/3
  135. assert_allclose(simpson(y, x=x, axis=0), zero_axis)
  136. # the following should be exact
  137. assert_allclose(simpson(y, x=x, axis=-1), default_axis)
  138. x = np.array([[1, 2, 4, 8], [1, 2, 4, 8], [1, 8, 16, 32]])
  139. y = np.power(x, 2)
  140. zero_axis = [0.0, 136.0, 1088.0, 8704.0]
  141. default_axis = [170 + 1/3, 170 + 1/3, 32**3 / 3 - 1/3]
  142. assert_allclose(simpson(y, x=x, axis=0), zero_axis)
  143. assert_allclose(simpson(y, x=x, axis=-1), default_axis)
  144. @pytest.mark.parametrize('droplast', [False, True])
  145. def test_simpson_2d_integer_no_x(self, droplast):
  146. # The inputs are 2d integer arrays. The results should be
  147. # identical to the results when the inputs are floating point.
  148. y = np.array([[2, 2, 4, 4, 8, 8, -4, 5],
  149. [4, 4, 2, -4, 10, 22, -2, 10]])
  150. if droplast:
  151. y = y[:, :-1]
  152. result = simpson(y, axis=-1)
  153. expected = simpson(np.array(y, dtype=np.float64), axis=-1)
  154. assert_equal(result, expected)
  155. @make_xp_test_case(cumulative_trapezoid)
  156. class TestCumulative_trapezoid:
  157. def test_1d(self, xp):
  158. x = xp.linspace(-2, 2, num=5)
  159. y = x
  160. y_int = cumulative_trapezoid(y, x, initial=0)
  161. y_expected = xp.asarray([0., -1.5, -2., -1.5, 0.])
  162. xp_assert_close(y_int, y_expected)
  163. y_int = cumulative_trapezoid(y, x, initial=None)
  164. xp_assert_close(y_int, y_expected[1:])
  165. def test_y_nd_x_nd(self, xp):
  166. x = xp.reshape(xp.arange(3 * 2 * 4, dtype=xp_default_dtype(xp)), (3, 2, 4))
  167. y = x
  168. y_int = cumulative_trapezoid(y, x, initial=0)
  169. y_expected = xp.asarray([[[0., 0.5, 2., 4.5],
  170. [0., 4.5, 10., 16.5]],
  171. [[0., 8.5, 18., 28.5],
  172. [0., 12.5, 26., 40.5]],
  173. [[0., 16.5, 34., 52.5],
  174. [0., 20.5, 42., 64.5]]])
  175. xp_assert_close(y_int, y_expected)
  176. # Try with all axes
  177. shapes = [(2, 2, 4), (3, 1, 4), (3, 2, 3)]
  178. for axis, shape in zip([0, 1, 2], shapes):
  179. y_int = cumulative_trapezoid(y, x, initial=0, axis=axis)
  180. assert y_int.shape == (3, 2, 4)
  181. y_int = cumulative_trapezoid(y, x, initial=None, axis=axis)
  182. assert y_int.shape == shape
  183. def test_y_nd_x_1d(self, xp):
  184. y = xp.reshape(xp.arange(3 * 2 * 4, dtype=xp_default_dtype(xp)), (3, 2, 4))
  185. x = xp.arange(4, dtype=xp_default_dtype(xp))**2
  186. # Try with all axes
  187. ys_expected = (
  188. xp.asarray([[[4., 5., 6., 7.],
  189. [8., 9., 10., 11.]],
  190. [[40., 44., 48., 52.],
  191. [56., 60., 64., 68.]]]),
  192. xp.asarray([[[2., 3., 4., 5.]],
  193. [[10., 11., 12., 13.]],
  194. [[18., 19., 20., 21.]]]),
  195. xp.asarray([[[0.5, 5., 17.5],
  196. [4.5, 21., 53.5]],
  197. [[8.5, 37., 89.5],
  198. [12.5, 53., 125.5]],
  199. [[16.5, 69., 161.5],
  200. [20.5, 85., 197.5]]]))
  201. for axis, y_expected in zip([0, 1, 2], ys_expected):
  202. y_int = cumulative_trapezoid(y, x=x[:y.shape[axis]], axis=axis,
  203. initial=None)
  204. xp_assert_close(y_int, y_expected)
  205. def test_x_none(self, xp):
  206. y = xp.linspace(-2, 2, num=5)
  207. y_int = cumulative_trapezoid(y)
  208. y_expected = xp.asarray([-1.5, -2., -1.5, 0.])
  209. xp_assert_close(y_int, y_expected)
  210. y_int = cumulative_trapezoid(y, initial=0)
  211. y_expected = xp.asarray([0, -1.5, -2., -1.5, 0.])
  212. xp_assert_close(y_int, y_expected)
  213. y_int = cumulative_trapezoid(y, dx=3)
  214. y_expected = xp.asarray([-4.5, -6., -4.5, 0.])
  215. xp_assert_close(y_int, y_expected)
  216. y_int = cumulative_trapezoid(y, dx=3, initial=0)
  217. y_expected = xp.asarray([0, -4.5, -6., -4.5, 0.])
  218. xp_assert_close(y_int, y_expected)
  219. @pytest.mark.parametrize(
  220. "initial", [1, 0.5]
  221. )
  222. def test_initial_error(self, initial, xp):
  223. """If initial is not None or 0, a ValueError is raised."""
  224. y = xp.linspace(0, 10, num=10)
  225. with pytest.raises(ValueError, match="`initial`"):
  226. cumulative_trapezoid(y, initial=initial)
  227. def test_zero_len_y(self, xp):
  228. with pytest.raises(ValueError, match="At least one point is required"):
  229. cumulative_trapezoid(y=xp.asarray([]))
  230. @make_xp_test_case(trapezoid)
  231. class TestTrapezoid:
  232. def test_simple(self, xp):
  233. x = xp.arange(-10, 10, .1)
  234. r = trapezoid(xp.exp(-.5 * x ** 2) / xp.sqrt(2 * xp.asarray(xp.pi)), dx=0.1)
  235. # check integral of normal equals 1
  236. xp_assert_close(r, xp.asarray(1.0))
  237. def test_ndim(self, xp):
  238. x = xp.linspace(0, 1, 3)
  239. y = xp.linspace(0, 2, 8)
  240. z = xp.linspace(0, 3, 13)
  241. wx = xp.ones_like(x) * (x[1] - x[0])
  242. wx[0] /= 2
  243. wx[-1] /= 2
  244. wy = xp.ones_like(y) * (y[1] - y[0])
  245. wy[0] /= 2
  246. wy[-1] /= 2
  247. wz = xp.ones_like(z) * (z[1] - z[0])
  248. wz[0] /= 2
  249. wz[-1] /= 2
  250. q = x[:, None, None] + y[None,:, None] + z[None, None,:]
  251. qx = xp.sum(q * wx[:, None, None], axis=0)
  252. qy = xp.sum(q * wy[None, :, None], axis=1)
  253. qz = xp.sum(q * wz[None, None, :], axis=2)
  254. # n-d `x`
  255. r = trapezoid(q, x=x[:, None, None], axis=0)
  256. xp_assert_close(r, qx)
  257. r = trapezoid(q, x=y[None,:, None], axis=1)
  258. xp_assert_close(r, qy)
  259. r = trapezoid(q, x=z[None, None,:], axis=2)
  260. xp_assert_close(r, qz)
  261. # 1-d `x`
  262. r = trapezoid(q, x=x, axis=0)
  263. xp_assert_close(r, qx)
  264. r = trapezoid(q, x=y, axis=1)
  265. xp_assert_close(r, qy)
  266. r = trapezoid(q, x=z, axis=2)
  267. xp_assert_close(r, qz)
  268. def test_gh21908(self, xp):
  269. # extended testing for n-dim arrays
  270. x = xp.reshape(xp.linspace(0, 29, 30), (3, 10))
  271. y = xp.reshape(xp.linspace(0, 29, 30), (3, 10))
  272. out0 = xp.linspace(200, 380, 10)
  273. xp_assert_close(trapezoid(y, x=x, axis=0), out0)
  274. xp_assert_close(trapezoid(y, x=xp.asarray([0, 10., 20.]), axis=0), out0)
  275. # x needs to be broadcastable against y
  276. xp_assert_close(
  277. trapezoid(y, x=xp.asarray([0, 10., 20.])[:, None], axis=0),
  278. out0
  279. )
  280. with pytest.raises(Exception):
  281. # x is not broadcastable against y
  282. trapezoid(y, x=xp.asarray([0, 10., 20.])[None, :], axis=0)
  283. out1 = xp.asarray([ 40.5, 130.5, 220.5])
  284. xp_assert_close(trapezoid(y, x=x, axis=1), out1)
  285. xp_assert_close(
  286. trapezoid(y, x=xp.linspace(0, 9, 10), axis=1),
  287. out1
  288. )
  289. @skip_xp_invalid_arg
  290. def test_masked(self, xp):
  291. # Testing that masked arrays behave as if the function is 0 where
  292. # masked
  293. x = np.arange(5)
  294. y = x * x
  295. mask = x == 2
  296. ym = np.ma.array(y, mask=mask)
  297. r = 13.0 # sum(0.5 * (0 + 1) * 1.0 + 0.5 * (9 + 16))
  298. assert_allclose(trapezoid(ym, x), r)
  299. xm = np.ma.array(x, mask=mask)
  300. assert_allclose(trapezoid(ym, xm), r)
  301. xm = np.ma.array(x, mask=mask)
  302. assert_allclose(trapezoid(y, xm), r)
  303. def test_array_like(self):
  304. x = list(range(5))
  305. y = [t * t for t in x]
  306. xarr = np.asarray(x, dtype=np.float64)
  307. yarr = np.asarray(y, dtype=np.float64)
  308. res = trapezoid(y, x)
  309. resarr = trapezoid(yarr, xarr)
  310. xp_assert_close(res, resarr)
  311. @make_xp_test_case(qmc_quad)
  312. class TestQMCQuad:
  313. def test_input_validation(self, xp):
  314. a = xp.asarray([0., 0.])
  315. b = xp.asarray([1., 1.])
  316. message = "`func` must be callable."
  317. with pytest.raises(TypeError, match=message):
  318. qmc_quad("a duck", a, b)
  319. message = "`func` must evaluate the integrand at points..."
  320. with pytest.raises(ValueError, match=message):
  321. qmc_quad(lambda: 1, a, b)
  322. def func(x):
  323. assert x.ndim == 1
  324. return xp.sum(x)
  325. message = "Exception encountered when attempting vectorized call..."
  326. if is_numpy(xp):
  327. with pytest.warns(UserWarning, match=message):
  328. qmc_quad(func, a, b)
  329. else:
  330. with pytest.raises(ValueError, match=message):
  331. qmc_quad(func, a, b)
  332. message = "`n_points` must be an integer."
  333. with pytest.raises(TypeError, match=message):
  334. qmc_quad(lambda x: 1, a, b, n_points=1024.5)
  335. message = "`n_estimates` must be an integer."
  336. with pytest.raises(TypeError, match=message):
  337. qmc_quad(lambda x: 1, a, b, n_estimates=8.5)
  338. message = "`qrng` must be an instance of scipy.stats.qmc.QMCEngine."
  339. with pytest.raises(TypeError, match=message):
  340. qmc_quad(lambda x: 1, a, b, qrng="a duck")
  341. message = "`qrng` must be initialized with dimensionality equal to "
  342. with pytest.raises(ValueError, match=message):
  343. qmc_quad(lambda x: 1, a, b, qrng=stats.qmc.Sobol(1))
  344. message = r"`log` must be boolean \(`True` or `False`\)."
  345. with pytest.raises(TypeError, match=message):
  346. qmc_quad(lambda x: 1, a, b, log=10)
  347. def basic_test(self, n_points=2**8, n_estimates=8, signs=None, xp=None):
  348. dtype = xp_default_dtype(xp)
  349. if signs is None:
  350. signs = np.ones(2)
  351. ndim = 2
  352. mean = np.zeros(ndim)
  353. cov = np.eye(ndim)
  354. def func(x):
  355. # standard multivariate normal PDF in two dimensions
  356. return xp.exp(-0.5 * xp.sum(x*x, axis=0)) / (2 * xp.pi)
  357. rng = np.random.default_rng(2879434385674690281)
  358. qrng = stats.qmc.Sobol(ndim, seed=rng)
  359. a = np.zeros(ndim)
  360. b = np.ones(ndim) * signs
  361. res = qmc_quad(func, xp.asarray(a, dtype=dtype), xp.asarray(b, dtype=dtype),
  362. n_points=n_points, n_estimates=n_estimates, qrng=qrng)
  363. ref = stats.multivariate_normal.cdf(b, mean, cov, lower_limit=a)
  364. atol = special.stdtrit(n_estimates-1, 0.995) * res.standard_error # 99% CI
  365. xp_assert_close(res.integral, xp.asarray(ref, dtype=dtype), atol=atol)
  366. assert np.prod(signs)*res.integral > 0
  367. rng = np.random.default_rng(2879434385674690281)
  368. qrng = stats.qmc.Sobol(ndim, seed=rng)
  369. logres = qmc_quad(lambda *args: xp.log(func(*args)),
  370. xp.asarray(a, dtype=dtype), xp.asarray(b, dtype=dtype),
  371. n_points=n_points, n_estimates=n_estimates,
  372. log=True, qrng=qrng)
  373. rtol = 1e-14 if res.integral.dtype == xp.float64 else 2e-6
  374. xp_assert_close(xp.real(xp.exp(logres.integral)), res.integral, rtol=rtol)
  375. assert xp.imag(logres.integral + 0j) == (xp.pi if np.prod(signs) < 0 else 0)
  376. xp_assert_close(xp.exp(logres.standard_error),
  377. res.standard_error, rtol=rtol, atol=rtol/100)
  378. @pytest.mark.parametrize("n_points", [2**8, 2**12])
  379. @pytest.mark.parametrize("n_estimates", [8, 16])
  380. def test_basic(self, n_points, n_estimates, xp):
  381. self.basic_test(n_points, n_estimates, xp=xp)
  382. @pytest.mark.parametrize("signs", [[1., 1.], [-1., -1.], [-1., 1.], [1., -1.]])
  383. def test_sign(self, signs, xp):
  384. self.basic_test(signs=signs, xp=xp)
  385. @pytest.mark.parametrize("log", [False, True])
  386. def test_zero(self, log, xp):
  387. message = "A lower limit was equal to an upper limit, so"
  388. with pytest.warns(UserWarning, match=message):
  389. res = qmc_quad(lambda x: 1, xp.asarray([0, 0]), xp.asarray([0, 1]), log=log)
  390. assert res.integral == (-xp.inf if log else 0)
  391. assert res.standard_error == 0
  392. def test_flexible_input(self):
  393. # check that qrng is not required
  394. # also checks that for 1d problems, a and b can be scalars
  395. def func(x):
  396. return stats.norm.pdf(x, scale=2)
  397. res = qmc_quad(func, 0, 1)
  398. ref = stats.norm.cdf(1, scale=2) - stats.norm.cdf(0, scale=2)
  399. assert_allclose(res.integral, ref, 1e-2)
  400. def cumulative_simpson_nd_reference(y, *, x=None, dx=None, initial=None, axis=-1):
  401. # Use cumulative_trapezoid if length of y < 3
  402. if y.shape[axis] < 3:
  403. if initial is None:
  404. return cumulative_trapezoid(y, x=x, dx=dx, axis=axis, initial=None)
  405. else:
  406. return initial + cumulative_trapezoid(y, x=x, dx=dx, axis=axis, initial=0)
  407. # Ensure that working axis is last axis
  408. y = np.moveaxis(y, axis, -1)
  409. x = np.moveaxis(x, axis, -1) if np.ndim(x) > 1 else x
  410. dx = np.moveaxis(dx, axis, -1) if np.ndim(dx) > 1 else dx
  411. initial = np.moveaxis(initial, axis, -1) if np.ndim(initial) > 1 else initial
  412. # If `x` is not present, create it from `dx`
  413. n = y.shape[-1]
  414. x = dx * np.arange(n) if dx is not None else x
  415. # Similarly, if `initial` is not present, set it to 0
  416. initial_was_none = initial is None
  417. initial = 0 if initial_was_none else initial
  418. # `np.apply_along_axis` accepts only one array, so concatenate arguments
  419. x = np.broadcast_to(x, y.shape)
  420. initial = np.broadcast_to(initial, y.shape[:-1] + (1,))
  421. z = np.concatenate((y, x, initial), axis=-1)
  422. # Use `np.apply_along_axis` to compute result
  423. def f(z):
  424. return cumulative_simpson(z[:n], x=z[n:2*n], initial=z[2*n:])
  425. res = np.apply_along_axis(f, -1, z)
  426. # Remove `initial` and undo axis move as needed
  427. res = res[..., 1:] if initial_was_none else res
  428. res = np.moveaxis(res, -1, axis)
  429. return res
  430. @make_xp_test_case(cumulative_simpson)
  431. class TestCumulativeSimpson:
  432. x0 = np.arange(4)
  433. y0 = x0**2
  434. @pytest.mark.parametrize('use_dx', (False, True))
  435. @pytest.mark.parametrize('use_initial', (False, True))
  436. def test_1d(self, use_dx, use_initial, xp):
  437. # Test for exact agreement with polynomial of highest
  438. # possible order (3 if `dx` is constant, 2 otherwise).
  439. rng = np.random.default_rng(82456839535679456794)
  440. n = 10
  441. # Generate random polynomials and ground truth
  442. # integral of appropriate order
  443. order = 3 if use_dx else 2
  444. dx = xp.asarray(rng.random())
  445. if order == 2:
  446. x = xp.asarray(np.sort(rng.random(n)))
  447. else:
  448. x = xp.arange(n, dtype=xp.float64)*dx + xp.asarray(rng.random())
  449. i = xp.arange(order + 1, dtype=xp.float64)[:, xp.newaxis]
  450. c = xp.asarray(rng.random(order + 1))[:, xp.newaxis]
  451. y = xp.sum(c*x**i, axis=0)
  452. Y = xp.sum(c*x**(i + 1)/(i + 1), axis=0)
  453. ref = Y if use_initial else (Y-Y[0])[1:]
  454. # Integrate with `cumulative_simpson`
  455. initial = Y[0] if use_initial else None
  456. kwarg = {'dx': dx} if use_dx else {'x': x}
  457. res = cumulative_simpson(y, **kwarg, initial=initial)
  458. # Compare result against reference
  459. if not use_dx:
  460. xp_assert_close(res, ref, rtol=2e-15)
  461. else:
  462. i0 = 0 if use_initial else 1
  463. # all terms are "close"
  464. xp_assert_close(res, ref, rtol=0.0025)
  465. # only even-interval terms are "exact"
  466. xp_assert_close(res[i0::2], ref[i0::2], rtol=2e-15)
  467. @skip_xp_backends(cpu_only=True) # uses np.apply_along_axis
  468. @pytest.mark.parametrize('axis', np.arange(-3, 3))
  469. @pytest.mark.parametrize('x_ndim', (1, 3))
  470. @pytest.mark.parametrize('x_len', (1, 2, 7))
  471. @pytest.mark.parametrize('i_ndim', (None, 0, 3,))
  472. @pytest.mark.parametrize('dx', (None, True))
  473. def test_nd(self, axis, x_ndim, x_len, i_ndim, dx, xp):
  474. # Test behavior of `cumulative_simpson` with N-D `y`
  475. rng = np.random.default_rng(82456839535679456794)
  476. # determine shapes
  477. shape = [5, 6, x_len]
  478. shape[axis], shape[-1] = shape[-1], shape[axis]
  479. shape_len_1 = shape.copy()
  480. shape_len_1[axis] = 1
  481. i_shape = shape_len_1 if i_ndim == 3 else ()
  482. # initialize arguments
  483. y = xp.asarray(rng.random(size=shape))
  484. x, dx = None, None
  485. if dx:
  486. dx = rng.random(size=shape_len_1) if x_ndim > 1 else rng.random()
  487. dx = xp.asarray(dx)
  488. else:
  489. x = (np.sort(rng.random(size=shape), axis=axis) if x_ndim > 1
  490. else np.sort(rng.random(size=shape[axis])))
  491. x = xp.asarray(x)
  492. initial = None if i_ndim is None else xp.asarray(rng.random(size=i_shape))
  493. # compare results
  494. res = cumulative_simpson(y, x=x, dx=dx, initial=initial, axis=axis)
  495. # use np to generate `ref` as `cumulative_simpson_nd_ref`
  496. # uses `apply_along_axis`
  497. ref = cumulative_simpson_nd_reference(
  498. np.asarray(y), x=np.asarray(x), dx=None if dx is None else np.asarray(dx),
  499. initial=None if initial is None else np.asarray(initial), axis=axis
  500. )
  501. xp_assert_close(res, xp.asarray(ref), rtol=1e-15)
  502. @pytest.mark.parametrize(('message', 'kwarg_update'), [
  503. ("x must be strictly increasing", dict(x=[2, 2, 3, 4])),
  504. ("x must be strictly increasing", dict(x=[x0, [2, 2, 4, 8]], y=[y0, y0])),
  505. ("x must be strictly increasing", dict(x=[x0, x0, x0], y=[y0, y0, y0], axis=0)),
  506. ("At least one point is required", dict(x=[], y=[])),
  507. ("`axis=4` is not valid for `y` with `y.ndim=1`", dict(axis=4)),
  508. ("shape of `x` must be the same as `y` or 1-D", dict(x=np.arange(5))),
  509. ("`initial` must either be a scalar or...", dict(initial=np.arange(5))),
  510. ("`dx` must either be a scalar or...", dict(x=None, dx=np.arange(5))),
  511. ])
  512. def test_simpson_exceptions(self, message, kwarg_update, xp):
  513. kwargs0 = dict(y=xp.asarray(self.y0), x=xp.asarray(self.x0), dx=None,
  514. initial=None, axis=-1)
  515. kwarg_update = {k: xp.asarray(np.asarray(v)) if isinstance(v, list) else v
  516. for k, v in kwarg_update.items()}
  517. with pytest.raises(ValueError, match=message):
  518. cumulative_simpson(**dict(kwargs0, **kwarg_update))
  519. def test_special_cases(self, xp):
  520. # Test special cases not checked elsewhere
  521. rng = np.random.default_rng(82456839535679456794)
  522. y = xp.asarray(rng.random(size=10))
  523. res = cumulative_simpson(y, dx=0.)
  524. xp_assert_equal(res, xp.zeros(9, dtype=xp.float64))
  525. # Should add tests of:
  526. # - all elements of `x` identical
  527. # These should work as they do for `simpson`
  528. def _get_theoretical_diff_between_simps_and_cum_simps(self, y, x):
  529. """`cumulative_simpson` and `simpson` can be tested against other to verify
  530. they give consistent results. `simpson` will iteratively be called with
  531. successively higher upper limits of integration. This function calculates
  532. the theoretical correction required to `simpson` at even intervals to match
  533. with `cumulative_simpson`.
  534. """
  535. d = np.diff(x, axis=-1)
  536. sub_integrals_h1 = _cumulative_simpson_unequal_intervals(y, d)
  537. sub_integrals_h2 = _cumulative_simpson_unequal_intervals(
  538. y[..., ::-1], d[..., ::-1]
  539. )[..., ::-1]
  540. # Concatenate to build difference array
  541. zeros_shape = (*y.shape[:-1], 1)
  542. theoretical_difference = np.concatenate(
  543. [
  544. np.zeros(zeros_shape),
  545. (sub_integrals_h1[..., 1:] - sub_integrals_h2[..., :-1]),
  546. np.zeros(zeros_shape),
  547. ],
  548. axis=-1,
  549. )
  550. # Differences only expected at even intervals. Odd intervals will
  551. # match exactly so there is no correction
  552. theoretical_difference[..., 1::2] = 0.0
  553. # Note: the first interval will not match from this correction as
  554. # `simpson` uses the trapezoidal rule
  555. return theoretical_difference
  556. @pytest.mark.fail_slow(10)
  557. @pytest.mark.slow
  558. @given(
  559. y=hyp_num.arrays(
  560. np.float64,
  561. hyp_num.array_shapes(max_dims=4, min_side=3, max_side=10),
  562. elements=st.floats(-10, 10, allow_nan=False).filter(lambda x: abs(x) > 1e-7)
  563. )
  564. )
  565. def test_cumulative_simpson_against_simpson_with_default_dx(
  566. self, y, xp
  567. ):
  568. """Theoretically, the output of `cumulative_simpson` will be identical
  569. to `simpson` at all even indices and in the last index. The first index
  570. will not match as `simpson` uses the trapezoidal rule when there are only two
  571. data points. Odd indices after the first index are shown to match with
  572. a mathematically-derived correction."""
  573. def simpson_reference(y):
  574. return np.stack(
  575. [simpson(y[..., :i], dx=1.0) for i in range(2, y.shape[-1]+1)], axis=-1,
  576. )
  577. res = cumulative_simpson(xp.asarray(y), dx=1.0)
  578. ref = simpson_reference(y)
  579. theoretical_difference = self._get_theoretical_diff_between_simps_and_cum_simps(
  580. y, x=np.arange(y.shape[-1])
  581. )
  582. xp_assert_close(
  583. res[..., 1:], xp.asarray(ref[..., 1:] + theoretical_difference[..., 1:]),
  584. atol=1e-16
  585. )
  586. @pytest.mark.fail_slow(10)
  587. @pytest.mark.slow
  588. @given(
  589. y=hyp_num.arrays(
  590. np.float64,
  591. hyp_num.array_shapes(max_dims=4, min_side=3, max_side=10),
  592. elements=st.floats(-10, 10, allow_nan=False).filter(lambda x: abs(x) > 1e-7)
  593. )
  594. )
  595. def test_cumulative_simpson_against_simpson(
  596. self, y, xp
  597. ):
  598. """Theoretically, the output of `cumulative_simpson` will be identical
  599. to `simpson` at all even indices and in the last index. The first index
  600. will not match as `simpson` uses the trapezoidal rule when there are only two
  601. data points. Odd indices after the first index are shown to match with
  602. a mathematically-derived correction."""
  603. interval = 10/(y.shape[-1] - 1)
  604. x = np.linspace(0, 10, num=y.shape[-1])
  605. x[1:] = x[1:] + 0.2*interval*np.random.uniform(-1, 1, len(x) - 1)
  606. def simpson_reference(y, x):
  607. return np.stack(
  608. [simpson(y[..., :i], x=x[..., :i]) for i in range(2, y.shape[-1]+1)],
  609. axis=-1,
  610. )
  611. res = cumulative_simpson(xp.asarray(y), x=xp.asarray(x))
  612. ref = simpson_reference(y, x)
  613. theoretical_difference = self._get_theoretical_diff_between_simps_and_cum_simps(
  614. y, x
  615. )
  616. xp_assert_close(
  617. res[..., 1:], xp.asarray(ref[..., 1:] + theoretical_difference[..., 1:])
  618. )
  619. @make_xp_test_case(integrate.lebedev_rule)
  620. class TestLebedev:
  621. def test_input_validation(self):
  622. # only certain rules are available
  623. message = "Order n=-1 not available..."
  624. with pytest.raises(NotImplementedError, match=message):
  625. integrate.lebedev_rule(-1)
  626. def test_quadrature(self):
  627. # Test points/weights to integrate an example function
  628. def f(x):
  629. return np.exp(x[0])
  630. x, w = integrate.lebedev_rule(15)
  631. res = w @ f(x)
  632. ref = 14.7680137457653 # lebedev_rule reference [3]
  633. assert_allclose(res, ref, rtol=1e-14)
  634. assert_allclose(np.sum(w), 4 * np.pi)
  635. @pytest.mark.parametrize('order', list(range(3, 32, 2)) + list(range(35, 132, 6)))
  636. def test_properties(self, order):
  637. x, w = integrate.lebedev_rule(order)
  638. # dispersion should be maximal; no clear spherical mean
  639. with np.errstate(divide='ignore', invalid='ignore'):
  640. res = stats.directional_stats(x.T, axis=0)
  641. assert_allclose(res.mean_resultant_length, 0, atol=1e-15)
  642. # weights should sum to 4*pi (surface area of unit sphere)
  643. assert_allclose(np.sum(w), 4*np.pi)