test__basinhopping.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. """
  2. Unit tests for the basin hopping global minimization algorithm.
  3. """
  4. import copy
  5. from numpy.testing import assert_almost_equal, assert_equal, assert_
  6. import pytest
  7. from pytest import raises as assert_raises
  8. import numpy as np
  9. from numpy import cos, sin
  10. from scipy.optimize import basinhopping, OptimizeResult
  11. from scipy.optimize._basinhopping import (
  12. Storage, RandomDisplacement, Metropolis, AdaptiveStepsize)
  13. from scipy._lib._pep440 import Version
  14. def func1d(x):
  15. f = cos(14.5 * x - 0.3) + (x + 0.2) * x
  16. df = np.array(-14.5 * sin(14.5 * x - 0.3) + 2. * x + 0.2)
  17. return f, df
  18. def func2d_nograd(x):
  19. f = cos(14.5 * x[0] - 0.3) + (x[1] + 0.2) * x[1] + (x[0] + 0.2) * x[0]
  20. return f
  21. def func2d(x):
  22. f = cos(14.5 * x[0] - 0.3) + (x[1] + 0.2) * x[1] + (x[0] + 0.2) * x[0]
  23. df = np.zeros(2)
  24. df[0] = -14.5 * sin(14.5 * x[0] - 0.3) + 2. * x[0] + 0.2
  25. df[1] = 2. * x[1] + 0.2
  26. return f, df
  27. def func2d_easyderiv(x):
  28. f = 2.0*x[0]**2 + 2.0*x[0]*x[1] + 2.0*x[1]**2 - 6.0*x[0]
  29. df = np.zeros(2)
  30. df[0] = 4.0*x[0] + 2.0*x[1] - 6.0
  31. df[1] = 2.0*x[0] + 4.0*x[1]
  32. return f, df
  33. class MyTakeStep1(RandomDisplacement):
  34. """use a copy of displace, but have it set a special parameter to
  35. make sure it's actually being used."""
  36. def __init__(self):
  37. self.been_called = False
  38. super().__init__()
  39. def __call__(self, x):
  40. self.been_called = True
  41. return super().__call__(x)
  42. def myTakeStep2(x):
  43. """redo RandomDisplacement in function form without the attribute stepsize
  44. to make sure everything still works ok
  45. """
  46. s = 0.5
  47. x += np.random.uniform(-s, s, np.shape(x))
  48. return x
  49. class MyAcceptTest:
  50. """pass a custom accept test
  51. This does nothing but make sure it's being used and ensure all the
  52. possible return values are accepted
  53. """
  54. def __init__(self):
  55. self.been_called = False
  56. self.ncalls = 0
  57. self.testres = [False, 'force accept', True, np.bool_(True),
  58. np.bool_(False), [], {}, 0, 1]
  59. def __call__(self, **kwargs):
  60. self.been_called = True
  61. self.ncalls += 1
  62. if self.ncalls - 1 < len(self.testres):
  63. return self.testres[self.ncalls - 1]
  64. else:
  65. return True
  66. class MyCallBack:
  67. """pass a custom callback function
  68. This makes sure it's being used. It also returns True after 10
  69. steps to ensure that it's stopping early.
  70. """
  71. def __init__(self):
  72. self.been_called = False
  73. self.ncalls = 0
  74. def __call__(self, x, f, accepted):
  75. self.been_called = True
  76. self.ncalls += 1
  77. if self.ncalls == 10:
  78. return True
  79. class TestBasinHopping:
  80. def setup_method(self):
  81. """ Tests setup.
  82. Run tests based on the 1-D and 2-D functions described above.
  83. """
  84. self.x0 = (1.0, [1.0, 1.0])
  85. self.sol = (-0.195, np.array([-0.195, -0.1]))
  86. self.tol = 3 # number of decimal places
  87. self.niter = 100
  88. self.disp = False
  89. # fix random seed
  90. np.random.seed(1234)
  91. self.kwargs = {"method": "L-BFGS-B", "jac": True}
  92. self.kwargs_nograd = {"method": "L-BFGS-B"}
  93. def test_TypeError(self):
  94. # test the TypeErrors are raised on bad input
  95. i = 1
  96. # if take_step is passed, it must be callable
  97. assert_raises(TypeError, basinhopping, func2d, self.x0[i],
  98. take_step=1)
  99. # if accept_test is passed, it must be callable
  100. assert_raises(TypeError, basinhopping, func2d, self.x0[i],
  101. accept_test=1)
  102. def test_input_validation(self):
  103. msg = 'target_accept_rate has to be in range \\(0, 1\\)'
  104. with assert_raises(ValueError, match=msg):
  105. basinhopping(func1d, self.x0[0], target_accept_rate=0.)
  106. with assert_raises(ValueError, match=msg):
  107. basinhopping(func1d, self.x0[0], target_accept_rate=1.)
  108. msg = 'stepwise_factor has to be in range \\(0, 1\\)'
  109. with assert_raises(ValueError, match=msg):
  110. basinhopping(func1d, self.x0[0], stepwise_factor=0.)
  111. with assert_raises(ValueError, match=msg):
  112. basinhopping(func1d, self.x0[0], stepwise_factor=1.)
  113. def test_1d_grad(self):
  114. # test 1-D minimizations with gradient
  115. i = 0
  116. res = basinhopping(func1d, self.x0[i], minimizer_kwargs=self.kwargs,
  117. niter=self.niter, disp=self.disp)
  118. assert_almost_equal(res.x, self.sol[i], self.tol)
  119. def test_2d(self):
  120. # test 2d minimizations with gradient
  121. i = 1
  122. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  123. niter=self.niter, disp=self.disp)
  124. assert_almost_equal(res.x, self.sol[i], self.tol)
  125. assert_(res.nfev > 0)
  126. def test_njev(self):
  127. # test njev is returned correctly
  128. i = 1
  129. minimizer_kwargs = self.kwargs.copy()
  130. # L-BFGS-B doesn't use njev, but BFGS does
  131. minimizer_kwargs["method"] = "BFGS"
  132. res = basinhopping(func2d, self.x0[i],
  133. minimizer_kwargs=minimizer_kwargs, niter=self.niter,
  134. disp=self.disp)
  135. assert_(res.nfev > 0)
  136. assert_equal(res.nfev, res.njev)
  137. def test_jac(self):
  138. # test Jacobian returned
  139. minimizer_kwargs = self.kwargs.copy()
  140. # BFGS returns a Jacobian
  141. minimizer_kwargs["method"] = "BFGS"
  142. res = basinhopping(func2d_easyderiv, [0.0, 0.0],
  143. minimizer_kwargs=minimizer_kwargs, niter=self.niter,
  144. disp=self.disp)
  145. assert_(hasattr(res.lowest_optimization_result, "jac"))
  146. # in this case, the Jacobian is just [df/dx, df/dy]
  147. _, jacobian = func2d_easyderiv(res.x)
  148. assert_almost_equal(res.lowest_optimization_result.jac, jacobian,
  149. self.tol)
  150. def test_2d_nograd(self):
  151. # test 2-D minimizations without gradient
  152. i = 1
  153. res = basinhopping(func2d_nograd, self.x0[i],
  154. minimizer_kwargs=self.kwargs_nograd,
  155. niter=self.niter, disp=self.disp)
  156. assert_almost_equal(res.x, self.sol[i], self.tol)
  157. def test_all_minimizers(self):
  158. # Test 2-D minimizations with gradient. Nelder-Mead, Powell, and COBYLA
  159. # don't accept jac=True, so aren't included here.
  160. i = 1
  161. methods = ['CG', 'BFGS', 'Newton-CG', 'L-BFGS-B', 'TNC', 'SLSQP']
  162. minimizer_kwargs = copy.copy(self.kwargs)
  163. for method in methods:
  164. minimizer_kwargs["method"] = method
  165. res = basinhopping(func2d, self.x0[i],
  166. minimizer_kwargs=minimizer_kwargs,
  167. niter=self.niter, disp=self.disp)
  168. assert_almost_equal(res.x, self.sol[i], self.tol)
  169. def test_all_nograd_minimizers(self):
  170. # Test 2-D minimizations without gradient. Newton-CG requires jac=True,
  171. # so not included here.
  172. i = 1
  173. methods = ['CG', 'BFGS', 'L-BFGS-B', 'TNC', 'SLSQP',
  174. 'Nelder-Mead', 'Powell', 'COBYLA']
  175. minimizer_kwargs = copy.copy(self.kwargs_nograd)
  176. for method in methods:
  177. minimizer_kwargs["method"] = method
  178. res = basinhopping(func2d_nograd, self.x0[i],
  179. minimizer_kwargs=minimizer_kwargs,
  180. niter=self.niter, disp=self.disp)
  181. tol = self.tol
  182. if method == 'COBYLA':
  183. tol = 2
  184. assert_almost_equal(res.x, self.sol[i], decimal=tol)
  185. def test_pass_takestep(self):
  186. # test that passing a custom takestep works
  187. # also test that the stepsize is being adjusted
  188. takestep = MyTakeStep1()
  189. initial_step_size = takestep.stepsize
  190. i = 1
  191. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  192. niter=self.niter, disp=self.disp,
  193. take_step=takestep)
  194. assert_almost_equal(res.x, self.sol[i], self.tol)
  195. assert_(takestep.been_called)
  196. # make sure that the build in adaptive step size has been used
  197. assert_(initial_step_size != takestep.stepsize)
  198. def test_pass_simple_takestep(self):
  199. # test that passing a custom takestep without attribute stepsize
  200. takestep = myTakeStep2
  201. i = 1
  202. res = basinhopping(func2d_nograd, self.x0[i],
  203. minimizer_kwargs=self.kwargs_nograd,
  204. niter=self.niter, disp=self.disp,
  205. take_step=takestep)
  206. assert_almost_equal(res.x, self.sol[i], self.tol)
  207. def test_pass_accept_test(self):
  208. # test passing a custom accept test
  209. # makes sure it's being used and ensures all the possible return values
  210. # are accepted.
  211. accept_test = MyAcceptTest()
  212. i = 1
  213. # there's no point in running it more than a few steps.
  214. basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  215. niter=10, disp=self.disp, accept_test=accept_test)
  216. assert_(accept_test.been_called)
  217. def test_pass_callback(self):
  218. # test passing a custom callback function
  219. # This makes sure it's being used. It also returns True after 10 steps
  220. # to ensure that it's stopping early.
  221. callback = MyCallBack()
  222. i = 1
  223. # there's no point in running it more than a few steps.
  224. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  225. niter=30, disp=self.disp, callback=callback)
  226. assert_(callback.been_called)
  227. assert_("callback" in res.message[0])
  228. # One of the calls of MyCallBack is during BasinHoppingRunner
  229. # construction, so there are only 9 remaining before MyCallBack stops
  230. # the minimization.
  231. assert_equal(res.nit, 9)
  232. def test_minimizer_fail(self):
  233. # test if a minimizer fails
  234. i = 1
  235. self.kwargs["options"] = dict(maxiter=0)
  236. self.niter = 10
  237. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  238. niter=self.niter, disp=self.disp)
  239. # the number of failed minimizations should be the number of
  240. # iterations + 1
  241. assert_equal(res.nit + 1, res.minimization_failures)
  242. def test_niter_zero(self):
  243. # gh5915, what happens if you call basinhopping with niter=0
  244. i = 0
  245. basinhopping(func1d, self.x0[i], minimizer_kwargs=self.kwargs,
  246. niter=0, disp=self.disp)
  247. def test_seed_reproducibility(self):
  248. # seed should ensure reproducibility between runs
  249. minimizer_kwargs = {"method": "L-BFGS-B", "jac": True}
  250. f_1 = []
  251. def callback(x, f, accepted):
  252. f_1.append(f)
  253. basinhopping(func2d, [1.0, 1.0], minimizer_kwargs=minimizer_kwargs,
  254. niter=10, callback=callback, seed=10)
  255. f_2 = []
  256. def callback2(x, f, accepted):
  257. f_2.append(f)
  258. basinhopping(func2d, [1.0, 1.0], minimizer_kwargs=minimizer_kwargs,
  259. niter=10, callback=callback2, seed=10)
  260. assert_equal(np.array(f_1), np.array(f_2))
  261. def test_random_gen(self):
  262. # check that np.random.Generator can be used (numpy >= 1.17)
  263. rng = np.random.default_rng(1)
  264. minimizer_kwargs = {"method": "L-BFGS-B", "jac": True}
  265. res1 = basinhopping(func2d, [1.0, 1.0],
  266. minimizer_kwargs=minimizer_kwargs,
  267. niter=10, seed=rng)
  268. rng = np.random.default_rng(1)
  269. res2 = basinhopping(func2d, [1.0, 1.0],
  270. minimizer_kwargs=minimizer_kwargs,
  271. niter=10, seed=rng)
  272. assert_equal(res1.x, res2.x)
  273. def test_monotonic_basin_hopping(self):
  274. # test 1-D minimizations with gradient and T=0
  275. i = 0
  276. res = basinhopping(func1d, self.x0[i], minimizer_kwargs=self.kwargs,
  277. niter=self.niter, disp=self.disp, T=0)
  278. assert_almost_equal(res.x, self.sol[i], self.tol)
  279. class Test_Storage:
  280. def setup_method(self):
  281. self.x0 = np.array(1)
  282. self.f0 = 0
  283. minres = OptimizeResult()
  284. minres.x = self.x0
  285. minres.fun = self.f0
  286. self.storage = Storage(minres)
  287. def test_higher_f_rejected(self):
  288. new_minres = OptimizeResult()
  289. new_minres.x = self.x0 + 1
  290. new_minres.fun = self.f0 + 1
  291. ret = self.storage.update(new_minres)
  292. minres = self.storage.get_lowest()
  293. assert_equal(self.x0, minres.x)
  294. assert_equal(self.f0, minres.fun)
  295. assert_(not ret)
  296. def test_lower_f_accepted(self):
  297. new_minres = OptimizeResult()
  298. new_minres.x = self.x0 + 1
  299. new_minres.fun = self.f0 - 1
  300. ret = self.storage.update(new_minres)
  301. minres = self.storage.get_lowest()
  302. assert_(self.x0 != minres.x)
  303. assert_(self.f0 != minres.fun)
  304. assert_(ret)
  305. class Test_RandomDisplacement:
  306. def setup_method(self):
  307. self.stepsize = 1.0
  308. self.displace = RandomDisplacement(stepsize=self.stepsize)
  309. self.N = 300000
  310. self.x0 = np.zeros([self.N])
  311. def test_random(self):
  312. # the mean should be 0
  313. # the variance should be (2*stepsize)**2 / 12
  314. # note these tests are random, they will fail from time to time
  315. x = self.displace(self.x0)
  316. v = (2. * self.stepsize) ** 2 / 12
  317. assert_almost_equal(np.mean(x), 0., 1)
  318. assert_almost_equal(np.var(x), v, 1)
  319. class Test_Metropolis:
  320. def setup_method(self):
  321. self.T = 2.
  322. self.met = Metropolis(self.T)
  323. def test_boolean_return(self):
  324. # the return must be a bool, else an error will be raised in
  325. # basinhopping
  326. ret = self.met(f_new=0., f_old=1.)
  327. assert isinstance(ret, bool)
  328. def test_lower_f_accepted(self):
  329. assert_(self.met(f_new=0., f_old=1.))
  330. def test_KeyError(self):
  331. # should raise KeyError if kwargs f_old or f_new is not passed
  332. assert_raises(KeyError, self.met, f_old=1.)
  333. assert_raises(KeyError, self.met, f_new=1.)
  334. def test_accept(self):
  335. # test that steps are randomly accepted for f_new > f_old
  336. one_accept = False
  337. one_reject = False
  338. for i in range(1000):
  339. if one_accept and one_reject:
  340. break
  341. ret = self.met(f_new=1., f_old=0.5)
  342. if ret:
  343. one_accept = True
  344. else:
  345. one_reject = True
  346. assert_(one_accept)
  347. assert_(one_reject)
  348. def test_GH7495(self):
  349. # an overflow in exp was producing a RuntimeWarning
  350. # create own object here in case someone changes self.T
  351. met = Metropolis(2)
  352. with np.errstate(over='raise'):
  353. met.accept_reject(0, 2000)
  354. class Test_AdaptiveStepsize:
  355. def setup_method(self):
  356. self.stepsize = 1.
  357. self.ts = RandomDisplacement(stepsize=self.stepsize)
  358. self.target_accept_rate = 0.5
  359. self.takestep = AdaptiveStepsize(takestep=self.ts, verbose=False,
  360. accept_rate=self.target_accept_rate)
  361. def test_adaptive_increase(self):
  362. # if few steps are rejected, the stepsize should increase
  363. x = 0.
  364. self.takestep(x)
  365. self.takestep.report(False)
  366. for i in range(self.takestep.interval):
  367. self.takestep(x)
  368. self.takestep.report(True)
  369. assert_(self.ts.stepsize > self.stepsize)
  370. def test_adaptive_decrease(self):
  371. # if few steps are rejected, the stepsize should increase
  372. x = 0.
  373. self.takestep(x)
  374. self.takestep.report(True)
  375. for i in range(self.takestep.interval):
  376. self.takestep(x)
  377. self.takestep.report(False)
  378. assert_(self.ts.stepsize < self.stepsize)
  379. def test_all_accepted(self):
  380. # test that everything works OK if all steps were accepted
  381. x = 0.
  382. for i in range(self.takestep.interval + 1):
  383. self.takestep(x)
  384. self.takestep.report(True)
  385. assert_(self.ts.stepsize > self.stepsize)
  386. def test_all_rejected(self):
  387. # test that everything works OK if all steps were rejected
  388. x = 0.
  389. for i in range(self.takestep.interval + 1):
  390. self.takestep(x)
  391. self.takestep.report(False)
  392. assert_(self.ts.stepsize < self.stepsize)