test_direct.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """
  2. Unit test for DIRECT optimization algorithm.
  3. """
  4. from numpy.testing import (assert_allclose,
  5. assert_array_less)
  6. import pytest
  7. import numpy as np
  8. from scipy.optimize import direct, Bounds
  9. import threading
  10. class TestDIRECT:
  11. def setup_method(self):
  12. self.fun_calls = threading.local()
  13. self.bounds_sphere = 4*[(-2, 3)]
  14. self.optimum_sphere_pos = np.zeros((4, ))
  15. self.optimum_sphere = 0.0
  16. self.bounds_stylinski_tang = Bounds([-4., -4.], [4., 4.])
  17. self.maxiter = 1000
  18. # test functions
  19. def sphere(self, x):
  20. if not hasattr(self.fun_calls, 'c'):
  21. self.fun_calls.c = 0
  22. self.fun_calls.c += 1
  23. return np.square(x).sum()
  24. def inv(self, x):
  25. if np.sum(x) == 0:
  26. raise ZeroDivisionError()
  27. return 1/np.sum(x)
  28. def nan_fun(self, x):
  29. return np.nan
  30. def inf_fun(self, x):
  31. return np.inf
  32. def styblinski_tang(self, pos):
  33. x, y = pos
  34. return 0.5 * (x**4 - 16 * x**2 + 5 * x + y**4 - 16 * y**2 + 5 * y)
  35. @pytest.mark.parametrize("locally_biased", [True, False])
  36. def test_direct(self, locally_biased):
  37. res = direct(self.sphere, self.bounds_sphere,
  38. locally_biased=locally_biased)
  39. # test accuracy
  40. assert_allclose(res.x, self.optimum_sphere_pos,
  41. rtol=1e-3, atol=1e-3)
  42. assert_allclose(res.fun, self.optimum_sphere, atol=1e-5, rtol=1e-5)
  43. # test that result lies within bounds
  44. _bounds = np.asarray(self.bounds_sphere)
  45. assert_array_less(_bounds[:, 0], res.x)
  46. assert_array_less(res.x, _bounds[:, 1])
  47. # test number of function evaluations. Original DIRECT overshoots by
  48. # up to 500 evaluations in last iteration
  49. assert res.nfev <= 1000 * (len(self.bounds_sphere) + 1)
  50. # test that number of function evaluations is correct
  51. assert res.nfev == self.fun_calls.c
  52. # test that number of iterations is below supplied maximum
  53. assert res.nit <= self.maxiter
  54. @pytest.mark.parametrize("locally_biased", [True, False])
  55. def test_direct_callback(self, locally_biased):
  56. # test that callback does not change the result
  57. res = direct(self.sphere, self.bounds_sphere,
  58. locally_biased=locally_biased)
  59. def callback(x):
  60. x = 2*x
  61. dummy = np.square(x)
  62. print("DIRECT minimization algorithm callback test")
  63. return dummy
  64. res_callback = direct(self.sphere, self.bounds_sphere,
  65. locally_biased=locally_biased,
  66. callback=callback)
  67. assert_allclose(res.x, res_callback.x)
  68. assert res.nit == res_callback.nit
  69. assert res.nfev == res_callback.nfev
  70. assert res.status == res_callback.status
  71. assert res.success == res_callback.success
  72. assert res.fun == res_callback.fun
  73. assert_allclose(res.x, res_callback.x)
  74. assert res.message == res_callback.message
  75. # test accuracy
  76. assert_allclose(res_callback.x, self.optimum_sphere_pos,
  77. rtol=1e-3, atol=1e-3)
  78. assert_allclose(res_callback.fun, self.optimum_sphere,
  79. atol=1e-5, rtol=1e-5)
  80. @pytest.mark.parametrize("locally_biased", [True, False])
  81. def test_exception(self, locally_biased):
  82. bounds = 4*[(-10, 10)]
  83. with pytest.raises(ZeroDivisionError):
  84. direct(self.inv, bounds=bounds,
  85. locally_biased=locally_biased)
  86. @pytest.mark.parametrize("locally_biased", [True, False])
  87. def test_nan(self, locally_biased):
  88. bounds = 4*[(-10, 10)]
  89. direct(self.nan_fun, bounds=bounds,
  90. locally_biased=locally_biased)
  91. @pytest.mark.parametrize("len_tol", [1e-3, 1e-4])
  92. @pytest.mark.parametrize("locally_biased", [True, False])
  93. def test_len_tol(self, len_tol, locally_biased):
  94. bounds = 4*[(-10., 10.)]
  95. res = direct(self.sphere, bounds=bounds, len_tol=len_tol,
  96. vol_tol=1e-30, locally_biased=locally_biased)
  97. assert res.status == 5
  98. assert res.success
  99. assert_allclose(res.x, np.zeros((4, )))
  100. message = ("The side length measure of the hyperrectangle containing "
  101. "the lowest function value found is below "
  102. f"len_tol={len_tol}")
  103. assert res.message == message
  104. @pytest.mark.parametrize("vol_tol", [1e-6, 1e-8])
  105. @pytest.mark.parametrize("locally_biased", [True, False])
  106. def test_vol_tol(self, vol_tol, locally_biased):
  107. bounds = 4*[(-10., 10.)]
  108. res = direct(self.sphere, bounds=bounds, vol_tol=vol_tol,
  109. len_tol=0., locally_biased=locally_biased)
  110. assert res.status == 4
  111. assert res.success
  112. assert_allclose(res.x, np.zeros((4, )))
  113. message = ("The volume of the hyperrectangle containing the lowest "
  114. f"function value found is below vol_tol={vol_tol}")
  115. assert res.message == message
  116. @pytest.mark.parametrize("f_min_rtol", [1e-3, 1e-5, 1e-7])
  117. @pytest.mark.parametrize("locally_biased", [True, False])
  118. def test_f_min(self, f_min_rtol, locally_biased):
  119. # test that desired function value is reached within
  120. # relative tolerance of f_min_rtol
  121. f_min = 1.
  122. bounds = 4*[(-2., 10.)]
  123. res = direct(self.sphere, bounds=bounds, f_min=f_min,
  124. f_min_rtol=f_min_rtol,
  125. locally_biased=locally_biased)
  126. assert res.status == 3
  127. assert res.success
  128. assert res.fun < f_min * (1. + f_min_rtol)
  129. message = ("The best function value found is within a relative "
  130. f"error={f_min_rtol} of the (known) global optimum f_min")
  131. assert res.message == message
  132. def circle_with_args(self, x, a, b):
  133. return np.square(x[0] - a) + np.square(x[1] - b).sum()
  134. @pytest.mark.parametrize("locally_biased", [True, False])
  135. def test_f_circle_with_args(self, locally_biased):
  136. bounds = 2*[(-2.0, 2.0)]
  137. res = direct(self.circle_with_args, bounds, args=(1, 1), maxfun=1250,
  138. locally_biased=locally_biased)
  139. assert_allclose(res.x, np.array([1., 1.]), rtol=1e-5)
  140. @pytest.mark.parametrize("locally_biased", [True, False])
  141. def test_failure_maxfun(self, locally_biased):
  142. # test that if optimization runs for the maximal number of
  143. # evaluations, success = False is returned
  144. maxfun = 100
  145. result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
  146. maxfun=maxfun, locally_biased=locally_biased)
  147. assert result.success is False
  148. assert result.status == 1
  149. assert result.nfev >= maxfun
  150. message = ("Number of function evaluations done is "
  151. f"larger than maxfun={maxfun}")
  152. assert result.message == message
  153. @pytest.mark.parametrize("locally_biased", [True, False])
  154. def test_failure_maxiter(self, locally_biased):
  155. # test that if optimization runs for the maximal number of
  156. # iterations, success = False is returned
  157. maxiter = 10
  158. result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
  159. maxiter=maxiter, locally_biased=locally_biased)
  160. assert result.success is False
  161. assert result.status == 2
  162. assert result.nit >= maxiter
  163. message = f"Number of iterations is larger than maxiter={maxiter}"
  164. assert result.message == message
  165. @pytest.mark.parametrize("locally_biased", [True, False])
  166. def test_bounds_variants(self, locally_biased):
  167. # test that new and old bounds yield same result
  168. lb = [-6., 1., -5.]
  169. ub = [-1., 3., 5.]
  170. x_opt = np.array([-1., 1., 0.])
  171. bounds_old = list(zip(lb, ub))
  172. bounds_new = Bounds(lb, ub)
  173. res_old_bounds = direct(self.sphere, bounds_old,
  174. locally_biased=locally_biased)
  175. res_new_bounds = direct(self.sphere, bounds_new,
  176. locally_biased=locally_biased)
  177. assert res_new_bounds.nfev == res_old_bounds.nfev
  178. assert res_new_bounds.message == res_old_bounds.message
  179. assert res_new_bounds.success == res_old_bounds.success
  180. assert res_new_bounds.nit == res_old_bounds.nit
  181. assert_allclose(res_new_bounds.x, res_old_bounds.x)
  182. assert_allclose(res_new_bounds.x, x_opt, rtol=1e-2)
  183. @pytest.mark.parametrize("locally_biased", [True, False])
  184. @pytest.mark.parametrize("eps", [1e-5, 1e-4, 1e-3])
  185. def test_epsilon(self, eps, locally_biased):
  186. result = direct(self.styblinski_tang, self.bounds_stylinski_tang,
  187. eps=eps, vol_tol=1e-6,
  188. locally_biased=locally_biased)
  189. assert result.status == 4
  190. assert result.success
  191. @pytest.mark.xslow
  192. @pytest.mark.parametrize("locally_biased", [True, False])
  193. def test_no_segmentation_fault(self, locally_biased):
  194. # test that an excessive number of function evaluations
  195. # does not result in segmentation fault
  196. bounds = [(-5., 20.)] * 100
  197. result = direct(self.sphere, bounds, maxfun=10000000,
  198. maxiter=1000000, locally_biased=locally_biased)
  199. assert result is not None
  200. @pytest.mark.parametrize("locally_biased", [True, False])
  201. def test_inf_fun(self, locally_biased):
  202. # test that an objective value of infinity does not crash DIRECT
  203. bounds = [(-5., 5.)] * 2
  204. result = direct(self.inf_fun, bounds,
  205. locally_biased=locally_biased)
  206. assert result is not None
  207. @pytest.mark.parametrize("len_tol", [-1, 2])
  208. def test_len_tol_validation(self, len_tol):
  209. error_msg = "len_tol must be between 0 and 1."
  210. with pytest.raises(ValueError, match=error_msg):
  211. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  212. len_tol=len_tol)
  213. @pytest.mark.parametrize("vol_tol", [-1, 2])
  214. def test_vol_tol_validation(self, vol_tol):
  215. error_msg = "vol_tol must be between 0 and 1."
  216. with pytest.raises(ValueError, match=error_msg):
  217. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  218. vol_tol=vol_tol)
  219. @pytest.mark.parametrize("f_min_rtol", [-1, 2])
  220. def test_fmin_rtol_validation(self, f_min_rtol):
  221. error_msg = "f_min_rtol must be between 0 and 1."
  222. with pytest.raises(ValueError, match=error_msg):
  223. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  224. f_min_rtol=f_min_rtol, f_min=0.)
  225. @pytest.mark.parametrize("maxfun", [1.5, "string", (1, 2)])
  226. def test_maxfun_wrong_type(self, maxfun):
  227. error_msg = "maxfun must be of type int."
  228. with pytest.raises(ValueError, match=error_msg):
  229. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  230. maxfun=maxfun)
  231. @pytest.mark.parametrize("maxiter", [1.5, "string", (1, 2)])
  232. def test_maxiter_wrong_type(self, maxiter):
  233. error_msg = "maxiter must be of type int."
  234. with pytest.raises(ValueError, match=error_msg):
  235. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  236. maxiter=maxiter)
  237. def test_negative_maxiter(self):
  238. error_msg = "maxiter must be > 0."
  239. with pytest.raises(ValueError, match=error_msg):
  240. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  241. maxiter=-1)
  242. def test_negative_maxfun(self):
  243. error_msg = "maxfun must be > 0."
  244. with pytest.raises(ValueError, match=error_msg):
  245. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  246. maxfun=-1)
  247. @pytest.mark.parametrize("bounds", ["bounds", 2., 0])
  248. def test_invalid_bounds_type(self, bounds):
  249. error_msg = ("bounds must be a sequence or "
  250. "instance of Bounds class")
  251. with pytest.raises(ValueError, match=error_msg):
  252. direct(self.styblinski_tang, bounds)
  253. @pytest.mark.parametrize("bounds",
  254. [Bounds([-1., -1], [-2, 1]),
  255. Bounds([-np.nan, -1], [-2, np.nan]),
  256. ]
  257. )
  258. def test_incorrect_bounds(self, bounds):
  259. error_msg = 'Bounds are not consistent min < max'
  260. with pytest.raises(ValueError, match=error_msg):
  261. direct(self.styblinski_tang, bounds)
  262. def test_inf_bounds(self):
  263. error_msg = 'Bounds must not be inf.'
  264. bounds = Bounds([-np.inf, -1], [-2, np.inf])
  265. with pytest.raises(ValueError, match=error_msg):
  266. direct(self.styblinski_tang, bounds)
  267. @pytest.mark.parametrize("locally_biased", ["bias", [0, 0], 2.])
  268. def test_locally_biased_validation(self, locally_biased):
  269. error_msg = 'locally_biased must be True or False.'
  270. with pytest.raises(ValueError, match=error_msg):
  271. direct(self.styblinski_tang, self.bounds_stylinski_tang,
  272. locally_biased=locally_biased)