import pytest import numpy as np from numpy.testing import assert_array_less, assert_allclose, assert_equal import scipy._lib._elementwise_iterative_method as eim from scipy import stats from scipy.optimize._differentiate import (_differentiate as differentiate, _EERRORINCREASE) class TestDifferentiate: def f(self, x): return stats.norm().cdf(x) @pytest.mark.parametrize('x', [0.6, np.linspace(-0.05, 1.05, 10)]) def test_basic(self, x): # Invert distribution CDF and compare against distribution `ppf` res = differentiate(self.f, x) ref = stats.norm().pdf(x) np.testing.assert_allclose(res.df, ref) # This would be nice, but doesn't always work out. `error` is an # estimate, not a bound. assert_array_less(abs(res.df - ref), res.error) assert res.x.shape == ref.shape @pytest.mark.parametrize('case', stats._distr_params.distcont) def test_accuracy(self, case): distname, params = case dist = getattr(stats, distname)(*params) x = dist.median() + 0.1 res = differentiate(dist.cdf, x) ref = dist.pdf(x) assert_allclose(res.df, ref, atol=1e-10) @pytest.mark.parametrize('order', [1, 6]) @pytest.mark.parametrize('shape', [tuple(), (12,), (3, 4), (3, 2, 2)]) def test_vectorization(self, order, shape): # Test for correct functionality, output shapes, and dtypes for various # input shapes. x = np.linspace(-0.05, 1.05, 12).reshape(shape) if shape else 0.6 n = np.size(x) @np.vectorize def _differentiate_single(x): return differentiate(self.f, x, order=order) def f(x, *args, **kwargs): f.nit += 1 f.feval += 1 if (x.size == n or x.ndim <=1) else x.shape[-1] return self.f(x, *args, **kwargs) f.nit = -1 f.feval = 0 res = differentiate(f, x, order=order) refs = _differentiate_single(x).ravel() ref_x = [ref.x for ref in refs] assert_allclose(res.x.ravel(), ref_x) assert_equal(res.x.shape, shape) ref_df = [ref.df for ref in refs] assert_allclose(res.df.ravel(), ref_df) assert_equal(res.df.shape, shape) ref_error = [ref.error for ref in refs] assert_allclose(res.error.ravel(), ref_error, atol=5e-15) assert_equal(res.error.shape, shape) ref_success = [ref.success for ref in refs] assert_equal(res.success.ravel(), ref_success) assert_equal(res.success.shape, shape) assert np.issubdtype(res.success.dtype, np.bool_) ref_flag = [ref.status for ref in refs] assert_equal(res.status.ravel(), ref_flag) assert_equal(res.status.shape, shape) assert np.issubdtype(res.status.dtype, np.integer) ref_nfev = [ref.nfev for ref in refs] assert_equal(res.nfev.ravel(), ref_nfev) assert_equal(np.max(res.nfev), f.feval) assert_equal(res.nfev.shape, res.x.shape) assert np.issubdtype(res.nfev.dtype, np.integer) ref_nit = [ref.nit for ref in refs] assert_equal(res.nit.ravel(), ref_nit) assert_equal(np.max(res.nit), f.nit) assert_equal(res.nit.shape, res.x.shape) assert np.issubdtype(res.nit.dtype, np.integer) def test_flags(self): # Test cases that should produce different status flags; show that all # can be produced simultaneously. rng = np.random.default_rng(5651219684984213) def f(xs, js): f.nit += 1 funcs = [lambda x: x - 2.5, # converges lambda x: np.exp(x)*rng.random(), # error increases lambda x: np.exp(x), # reaches maxiter due to order=2 lambda x: np.full_like(x, np.nan)[()]] # stops due to NaN res = [funcs[j](x) for x, j in zip(xs, js.ravel())] return res f.nit = 0 args = (np.arange(4, dtype=np.int64),) res = differentiate(f, [1]*4, rtol=1e-14, order=2, args=args) ref_flags = np.array([eim._ECONVERGED, _EERRORINCREASE, eim._ECONVERR, eim._EVALUEERR]) assert_equal(res.status, ref_flags) def test_flags_preserve_shape(self): # Same test as above but using `preserve_shape` option to simplify. rng = np.random.default_rng(5651219684984213) def f(x): return [x - 2.5, # converges np.exp(x)*rng.random(), # error increases np.exp(x), # reaches maxiter due to order=2 np.full_like(x, np.nan)[()]] # stops due to NaN res = differentiate(f, 1, rtol=1e-14, order=2, preserve_shape=True) ref_flags = np.array([eim._ECONVERGED, _EERRORINCREASE, eim._ECONVERR, eim._EVALUEERR]) assert_equal(res.status, ref_flags) def test_preserve_shape(self): # Test `preserve_shape` option def f(x): return [x, np.sin(3*x), x+np.sin(10*x), np.sin(20*x)*(x-1)**2] x = 0 ref = [1, 3*np.cos(3*x), 1+10*np.cos(10*x), 20*np.cos(20*x)*(x-1)**2 + 2*np.sin(20*x)*(x-1)] res = differentiate(f, x, preserve_shape=True) assert_allclose(res.df, ref) def test_convergence(self): # Test that the convergence tolerances behave as expected dist = stats.norm() x = 1 f = dist.cdf ref = dist.pdf(x) kwargs0 = dict(atol=0, rtol=0, order=4) kwargs = kwargs0.copy() kwargs['atol'] = 1e-3 res1 = differentiate(f, x, **kwargs) assert_array_less(abs(res1.df - ref), 1e-3) kwargs['atol'] = 1e-6 res2 = differentiate(f, x, **kwargs) assert_array_less(abs(res2.df - ref), 1e-6) assert_array_less(abs(res2.df - ref), abs(res1.df - ref)) kwargs = kwargs0.copy() kwargs['rtol'] = 1e-3 res1 = differentiate(f, x, **kwargs) assert_array_less(abs(res1.df - ref), 1e-3 * np.abs(ref)) kwargs['rtol'] = 1e-6 res2 = differentiate(f, x, **kwargs) assert_array_less(abs(res2.df - ref), 1e-6 * np.abs(ref)) assert_array_less(abs(res2.df - ref), abs(res1.df - ref)) def test_step_parameters(self): # Test that step factors have the expected effect on accuracy dist = stats.norm() x = 1 f = dist.cdf ref = dist.pdf(x) res1 = differentiate(f, x, initial_step=0.5, maxiter=1) res2 = differentiate(f, x, initial_step=0.05, maxiter=1) assert abs(res2.df - ref) < abs(res1.df - ref) res1 = differentiate(f, x, step_factor=2, maxiter=1) res2 = differentiate(f, x, step_factor=20, maxiter=1) assert abs(res2.df - ref) < abs(res1.df - ref) # `step_factor` can be less than 1: `initial_step` is the minimum step kwargs = dict(order=4, maxiter=1, step_direction=0) res = differentiate(f, x, initial_step=0.5, step_factor=0.5, **kwargs) ref = differentiate(f, x, initial_step=1, step_factor=2, **kwargs) assert_allclose(res.df, ref.df, rtol=5e-15) # This is a similar test for one-sided difference kwargs = dict(order=2, maxiter=1, step_direction=1) res = differentiate(f, x, initial_step=1, step_factor=2, **kwargs) ref = differentiate(f, x, initial_step=1/np.sqrt(2), step_factor=0.5, **kwargs) assert_allclose(res.df, ref.df, rtol=5e-15) kwargs['step_direction'] = -1 res = differentiate(f, x, initial_step=1, step_factor=2, **kwargs) ref = differentiate(f, x, initial_step=1/np.sqrt(2), step_factor=0.5, **kwargs) assert_allclose(res.df, ref.df, rtol=5e-15) def test_step_direction(self): # test that `step_direction` works as expected def f(x): y = np.exp(x) y[(x < 0) + (x > 2)] = np.nan return y x = np.linspace(0, 2, 10) step_direction = np.zeros_like(x) step_direction[x < 0.6], step_direction[x > 1.4] = 1, -1 res = differentiate(f, x, step_direction=step_direction) assert_allclose(res.df, np.exp(x)) assert np.all(res.success) def test_vectorized_step_direction_args(self): # test that `step_direction` and `args` are vectorized properly def f(x, p): return x ** p def df(x, p): return p * x ** (p - 1) x = np.array([1, 2, 3, 4]).reshape(-1, 1, 1) hdir = np.array([-1, 0, 1]).reshape(1, -1, 1) p = np.array([2, 3]).reshape(1, 1, -1) res = differentiate(f, x, step_direction=hdir, args=(p,)) ref = np.broadcast_to(df(x, p), res.df.shape) assert_allclose(res.df, ref) def test_maxiter_callback(self): # Test behavior of `maxiter` parameter and `callback` interface x = 0.612814 dist = stats.norm() maxiter = 3 def f(x): res = dist.cdf(x) return res default_order = 8 res = differentiate(f, x, maxiter=maxiter, rtol=1e-15) assert not np.any(res.success) assert np.all(res.nfev == default_order + 1 + (maxiter - 1)*2) assert np.all(res.nit == maxiter) def callback(res): callback.iter += 1 callback.res = res assert hasattr(res, 'x') assert res.df not in callback.dfs callback.dfs.add(res.df) assert res.status == eim._EINPROGRESS if callback.iter == maxiter: raise StopIteration callback.iter = -1 # callback called once before first iteration callback.res = None callback.dfs = set() res2 = differentiate(f, x, callback=callback, rtol=1e-15) # terminating with callback is identical to terminating due to maxiter # (except for `status`) for key in res.keys(): if key == 'status': assert res[key] == eim._ECONVERR assert callback.res[key] == eim._EINPROGRESS assert res2[key] == eim._ECALLBACK else: assert res2[key] == callback.res[key] == res[key] @pytest.mark.parametrize("hdir", (-1, 0, 1)) @pytest.mark.parametrize("x", (0.65, [0.65, 0.7])) @pytest.mark.parametrize("dtype", (np.float16, np.float32, np.float64)) def test_dtype(self, hdir, x, dtype): # Test that dtypes are preserved x = np.asarray(x, dtype=dtype)[()] def f(x): assert x.dtype == dtype return np.exp(x) def callback(res): assert res.x.dtype == dtype assert res.df.dtype == dtype assert res.error.dtype == dtype res = differentiate(f, x, order=4, step_direction=hdir, callback=callback) assert res.x.dtype == dtype assert res.df.dtype == dtype assert res.error.dtype == dtype eps = np.finfo(dtype).eps assert_allclose(res.df, np.exp(res.x), rtol=np.sqrt(eps)) def test_input_validation(self): # Test input validation for appropriate error messages message = '`func` must be callable.' with pytest.raises(ValueError, match=message): differentiate(None, 1) message = 'Abscissae and function output must be real numbers.' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, -4+1j) message = "When `preserve_shape=False`, the shape of the array..." with pytest.raises(ValueError, match=message): differentiate(lambda x: [1, 2, 3], [-2, -3]) message = 'Tolerances and step parameters must be non-negative...' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, atol=-1) with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, rtol='ekki') with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, initial_step=None) with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, step_factor=object()) message = '`maxiter` must be a positive integer.' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, maxiter=1.5) with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, maxiter=0) message = '`order` must be a positive integer' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, order=1.5) with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, order=0) message = '`preserve_shape` must be True or False.' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, preserve_shape='herring') message = '`callback` must be callable.' with pytest.raises(ValueError, match=message): differentiate(lambda x: x, 1, callback='shrubbery') def test_special_cases(self): # Test edge cases and other special cases # Test that integers are not passed to `f` # (otherwise this would overflow) def f(x): assert np.issubdtype(x.dtype, np.floating) return x ** 99 - 1 res = differentiate(f, 7, rtol=1e-10) assert res.success assert_allclose(res.df, 99*7.**98) # Test that if success is achieved in the correct number # of iterations if function is a polynomial. Ideally, all polynomials # of order 0-2 would get exact result with 0 refinement iterations, # all polynomials of order 3-4 would be differentiated exactly after # 1 iteration, etc. However, it seems that _differentiate needs an # extra iteration to detect convergence based on the error estimate. for n in range(6): x = 1.5 def f(x): return 2*x**n ref = 2*n*x**(n-1) res = differentiate(f, x, maxiter=1, order=max(1, n)) assert_allclose(res.df, ref, rtol=1e-15) assert_equal(res.error, np.nan) res = differentiate(f, x, order=max(1, n)) assert res.success assert res.nit == 2 assert_allclose(res.df, ref, rtol=1e-15) # Test scalar `args` (not in tuple) def f(x, c): return c*x - 1 res = differentiate(f, 2, args=3) assert_allclose(res.df, 3) @pytest.mark.xfail @pytest.mark.parametrize("case", ( # function, evaluation point (lambda x: (x - 1) ** 3, 1), (lambda x: np.where(x > 1, (x - 1) ** 5, (x - 1) ** 3), 1) )) def test_saddle_gh18811(self, case): # With default settings, _differentiate will not always converge when # the true derivative is exactly zero. This tests that specifying a # (tight) `atol` alleviates the problem. See discussion in gh-18811. atol = 1e-16 res = differentiate(*case, step_direction=[-1, 0, 1], atol=atol) assert np.all(res.success) assert_allclose(res.df, 0, atol=atol)