test__basinhopping.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  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. assert_allclose)
  7. import pytest
  8. from pytest import raises as assert_raises
  9. import numpy as np
  10. from numpy import cos, sin
  11. from scipy.optimize import basinhopping, OptimizeResult
  12. from scipy.optimize._basinhopping import (
  13. Storage, RandomDisplacement, Metropolis, AdaptiveStepsize)
  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. self.kwargs = {"method": "L-BFGS-B", "jac": True}
  90. self.kwargs_nograd = {"method": "L-BFGS-B"}
  91. def test_TypeError(self):
  92. # test the TypeErrors are raised on bad input
  93. i = 1
  94. # if take_step is passed, it must be callable
  95. assert_raises(TypeError, basinhopping, func2d, self.x0[i],
  96. take_step=1)
  97. # if accept_test is passed, it must be callable
  98. assert_raises(TypeError, basinhopping, func2d, self.x0[i],
  99. accept_test=1)
  100. def test_input_validation(self):
  101. msg = 'target_accept_rate has to be in range \\(0, 1\\)'
  102. with assert_raises(ValueError, match=msg):
  103. basinhopping(func1d, self.x0[0], target_accept_rate=0.)
  104. with assert_raises(ValueError, match=msg):
  105. basinhopping(func1d, self.x0[0], target_accept_rate=1.)
  106. msg = 'stepwise_factor has to be in range \\(0, 1\\)'
  107. with assert_raises(ValueError, match=msg):
  108. basinhopping(func1d, self.x0[0], stepwise_factor=0.)
  109. with assert_raises(ValueError, match=msg):
  110. basinhopping(func1d, self.x0[0], stepwise_factor=1.)
  111. def test_1d_grad(self):
  112. # test 1-D minimizations with gradient
  113. i = 0
  114. res = basinhopping(func1d, self.x0[i], minimizer_kwargs=self.kwargs,
  115. niter=self.niter, disp=self.disp)
  116. assert_almost_equal(res.x, self.sol[i], self.tol)
  117. def test_2d(self):
  118. # test 2d minimizations with gradient
  119. i = 1
  120. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  121. niter=self.niter, disp=self.disp)
  122. assert_almost_equal(res.x, self.sol[i], self.tol)
  123. assert_(res.nfev > 0)
  124. def test_njev(self):
  125. # test njev is returned correctly
  126. i = 1
  127. minimizer_kwargs = self.kwargs.copy()
  128. # L-BFGS-B doesn't use njev, but BFGS does
  129. minimizer_kwargs["method"] = "BFGS"
  130. res = basinhopping(func2d, self.x0[i],
  131. minimizer_kwargs=minimizer_kwargs, niter=self.niter,
  132. disp=self.disp)
  133. assert_(res.nfev > 0)
  134. assert_equal(res.nfev, res.njev)
  135. def test_jac(self):
  136. # test Jacobian returned
  137. minimizer_kwargs = self.kwargs.copy()
  138. # BFGS returns a Jacobian
  139. minimizer_kwargs["method"] = "BFGS"
  140. res = basinhopping(func2d_easyderiv, [0.0, 0.0],
  141. minimizer_kwargs=minimizer_kwargs, niter=self.niter,
  142. disp=self.disp)
  143. assert_(hasattr(res.lowest_optimization_result, "jac"))
  144. # in this case, the Jacobian is just [df/dx, df/dy]
  145. _, jacobian = func2d_easyderiv(res.x)
  146. assert_almost_equal(res.lowest_optimization_result.jac, jacobian,
  147. self.tol)
  148. def test_2d_nograd(self):
  149. # test 2-D minimizations without gradient
  150. i = 1
  151. res = basinhopping(func2d_nograd, self.x0[i],
  152. minimizer_kwargs=self.kwargs_nograd,
  153. niter=self.niter, disp=self.disp)
  154. assert_almost_equal(res.x, self.sol[i], self.tol)
  155. @pytest.mark.fail_slow(10)
  156. def test_all_minimizers(self):
  157. # Test 2-D minimizations with gradient. Nelder-Mead, Powell, COBYLA, and
  158. # COBYQA don't accept jac=True, so aren't included here.
  159. i = 1
  160. methods = ['CG', 'BFGS', 'Newton-CG', 'L-BFGS-B', 'TNC', 'SLSQP']
  161. minimizer_kwargs = copy.copy(self.kwargs)
  162. for method in methods:
  163. minimizer_kwargs["method"] = method
  164. res = basinhopping(func2d, self.x0[i],
  165. minimizer_kwargs=minimizer_kwargs,
  166. niter=self.niter, disp=self.disp)
  167. assert_almost_equal(res.x, self.sol[i], self.tol)
  168. @pytest.mark.fail_slow(40)
  169. @pytest.mark.parametrize("method", [
  170. 'CG', 'BFGS', 'L-BFGS-B', 'TNC', 'SLSQP',
  171. 'Nelder-Mead', 'Powell', 'COBYLA', 'COBYQA'])
  172. def test_all_nograd_minimizers(self, method):
  173. # Test 2-D minimizations without gradient. Newton-CG requires jac=True,
  174. # so not included here.
  175. i = 1
  176. minimizer_kwargs = self.kwargs_nograd.copy()
  177. minimizer_kwargs["method"] = method
  178. # These methods take extensive amount of time on this problem
  179. niter = 10 if method in ('COBYLA', 'COBYQA') else self.niter
  180. res = basinhopping(func2d_nograd, self.x0[i],
  181. minimizer_kwargs=minimizer_kwargs,
  182. niter=niter, disp=self.disp, seed=1234)
  183. tol = 2 if method == 'COBYLA' else self.tol
  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. niter = 10
  237. res = basinhopping(func2d, self.x0[i], minimizer_kwargs=self.kwargs,
  238. niter=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_rng_reproducibility(self):
  248. # rng 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, rng=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, rng=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, rng=rng)
  268. rng = np.random.default_rng(1)
  269. res2 = basinhopping(func2d, [1.0, 1.0],
  270. minimizer_kwargs=minimizer_kwargs,
  271. niter=10, rng=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 create_storage(self):
  281. self.x0 = np.array(1)
  282. self.f0 = 0
  283. minres = OptimizeResult(success=True)
  284. minres.x = self.x0
  285. minres.fun = self.f0
  286. return Storage(minres)
  287. def test_higher_f_rejected(self):
  288. storage = self.create_storage()
  289. new_minres = OptimizeResult(success=True)
  290. new_minres.x = self.x0 + 1
  291. new_minres.fun = self.f0 + 1
  292. ret = storage.update(new_minres)
  293. minres = storage.get_lowest()
  294. assert_equal(self.x0, minres.x)
  295. assert_equal(self.f0, minres.fun)
  296. assert_(not ret)
  297. @pytest.mark.parametrize('success', [True, False])
  298. def test_lower_f_accepted(self, success):
  299. storage = self.create_storage()
  300. new_minres = OptimizeResult(success=success)
  301. new_minres.x = self.x0 + 1
  302. new_minres.fun = self.f0 - 1
  303. ret = storage.update(new_minres)
  304. minres = storage.get_lowest()
  305. assert (self.x0 != minres.x) == success # can't use `is`
  306. assert (self.f0 != minres.fun) == success # left side is NumPy bool
  307. assert ret is success
  308. class Test_RandomDisplacement:
  309. def setup_method(self):
  310. self.stepsize = 1.0
  311. self.N = 300000
  312. def test_random(self):
  313. # the mean should be 0
  314. # the variance should be (2*stepsize)**2 / 12
  315. # note these tests are random, they will fail from time to time
  316. rng = np.random.RandomState(0)
  317. x0 = np.zeros([self.N])
  318. displace = RandomDisplacement(stepsize=self.stepsize, rng=rng)
  319. x = displace(x0)
  320. v = (2. * self.stepsize) ** 2 / 12
  321. assert_almost_equal(np.mean(x), 0., 1)
  322. assert_almost_equal(np.var(x), v, 1)
  323. class Test_Metropolis:
  324. def setup_method(self):
  325. self.T = 2.
  326. self.met = Metropolis(self.T)
  327. self.res_new = OptimizeResult(success=True, fun=0.)
  328. self.res_old = OptimizeResult(success=True, fun=1.)
  329. def test_boolean_return(self):
  330. # the return must be a bool, else an error will be raised in
  331. # basinhopping
  332. ret = self.met(res_new=self.res_new, res_old=self.res_old)
  333. assert isinstance(ret, bool)
  334. def test_lower_f_accepted(self):
  335. assert_(self.met(res_new=self.res_new, res_old=self.res_old))
  336. def test_accept(self):
  337. # test that steps are randomly accepted for f_new > f_old
  338. one_accept = False
  339. one_reject = False
  340. for i in range(1000):
  341. if one_accept and one_reject:
  342. break
  343. res_new = OptimizeResult(success=True, fun=1.)
  344. res_old = OptimizeResult(success=True, fun=0.5)
  345. ret = self.met(res_new=res_new, res_old=res_old)
  346. if ret:
  347. one_accept = True
  348. else:
  349. one_reject = True
  350. assert_(one_accept)
  351. assert_(one_reject)
  352. def test_GH7495(self):
  353. # an overflow in exp was producing a RuntimeWarning
  354. # create own object here in case someone changes self.T
  355. met = Metropolis(2)
  356. res_new = OptimizeResult(success=True, fun=0.)
  357. res_old = OptimizeResult(success=True, fun=2000)
  358. with np.errstate(over='raise'):
  359. met.accept_reject(res_new=res_new, res_old=res_old)
  360. def test_gh7799(self):
  361. # gh-7799 reported a problem in which local search was successful but
  362. # basinhopping returned an invalid solution. Show that this is fixed.
  363. def func(x):
  364. return (x**2-8)**2+(x+2)**2
  365. x0 = -4
  366. limit = 50 # Constrain to func value >= 50
  367. con = {'type': 'ineq', 'fun': lambda x: func(x) - limit},
  368. res = basinhopping(
  369. func,
  370. x0,
  371. 30,
  372. seed=np.random.RandomState(1234),
  373. minimizer_kwargs={'constraints': con}
  374. )
  375. assert res.success
  376. assert_allclose(res.fun, limit, rtol=1e-6)
  377. def test_accept_gh7799(self):
  378. # Metropolis should not accept the result of an unsuccessful new local
  379. # search if the old local search was successful
  380. met = Metropolis(0) # monotonic basin hopping
  381. res_new = OptimizeResult(success=True, fun=0.)
  382. res_old = OptimizeResult(success=True, fun=1.)
  383. # if new local search was successful and energy is lower, accept
  384. assert met(res_new=res_new, res_old=res_old)
  385. # if new res is unsuccessful, don't accept - even if energy is lower
  386. res_new.success = False
  387. assert not met(res_new=res_new, res_old=res_old)
  388. # ...unless the old res was unsuccessful, too. In that case, why not?
  389. res_old.success = False
  390. assert met(res_new=res_new, res_old=res_old)
  391. def test_reject_all_gh7799(self):
  392. # Test the behavior when there is no feasible solution
  393. def fun(x):
  394. return x@x
  395. def constraint(x):
  396. return x + 1
  397. kwargs = {'constraints': {'type': 'eq', 'fun': constraint},
  398. 'bounds': [(0, 1), (0, 1)], 'method': 'slsqp'}
  399. res = basinhopping(fun, x0=[2, 3], niter=10, minimizer_kwargs=kwargs)
  400. assert not res.success
  401. @pytest.mark.thread_unsafe(reason="shared state")
  402. class Test_AdaptiveStepsize:
  403. def setup_method(self):
  404. self.stepsize = 1.
  405. self.ts = RandomDisplacement(stepsize=self.stepsize)
  406. self.target_accept_rate = 0.5
  407. self.takestep = AdaptiveStepsize(takestep=self.ts, verbose=False,
  408. accept_rate=self.target_accept_rate)
  409. def test_adaptive_increase(self):
  410. # if few steps are rejected, the stepsize should increase
  411. x = 0.
  412. self.takestep(x)
  413. self.takestep.report(False)
  414. for i in range(self.takestep.interval):
  415. self.takestep(x)
  416. self.takestep.report(True)
  417. assert_(self.ts.stepsize > self.stepsize)
  418. def test_adaptive_decrease(self):
  419. # if few steps are rejected, the stepsize should increase
  420. x = 0.
  421. self.takestep(x)
  422. self.takestep.report(True)
  423. for i in range(self.takestep.interval):
  424. self.takestep(x)
  425. self.takestep.report(False)
  426. assert_(self.ts.stepsize < self.stepsize)
  427. def test_all_accepted(self):
  428. # test that everything works OK if all steps were accepted
  429. x = 0.
  430. for i in range(self.takestep.interval + 1):
  431. self.takestep(x)
  432. self.takestep.report(True)
  433. assert_(self.ts.stepsize > self.stepsize)
  434. def test_all_rejected(self):
  435. # test that everything works OK if all steps were rejected
  436. x = 0.
  437. for i in range(self.takestep.interval + 1):
  438. self.takestep(x)
  439. self.takestep.report(False)
  440. assert_(self.ts.stepsize < self.stepsize)