test_milp.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. """
  2. Unit test for Mixed Integer Linear Programming
  3. """
  4. import re
  5. import sys
  6. import numpy as np
  7. from numpy.testing import assert_allclose, assert_array_equal
  8. import pytest
  9. from .test_linprog import magic_square
  10. from scipy.optimize import milp, Bounds, LinearConstraint
  11. from scipy import sparse
  12. _IS_32BIT = (sys.maxsize < 2**32)
  13. def test_milp_iv():
  14. message = "`c` must be a dense array"
  15. with pytest.raises(ValueError, match=message):
  16. milp(sparse.coo_array([0, 0]))
  17. message = "`c` must be a one-dimensional array of finite numbers with"
  18. with pytest.raises(ValueError, match=message):
  19. milp(np.zeros((3, 4)))
  20. with pytest.raises(ValueError, match=message):
  21. milp([])
  22. with pytest.raises(ValueError, match=message):
  23. milp(None)
  24. message = "`bounds` must be convertible into an instance of..."
  25. with pytest.raises(ValueError, match=message):
  26. milp(1, bounds=10)
  27. message = "`constraints` (or each element within `constraints`) must be"
  28. with pytest.raises(ValueError, match=re.escape(message)):
  29. milp(1, constraints=10)
  30. with pytest.raises(ValueError, match=re.escape(message)):
  31. milp(np.zeros(3), constraints=([[1, 2, 3]], [2, 3], [2, 3]))
  32. with pytest.raises(ValueError, match=re.escape(message)):
  33. milp(np.zeros(2), constraints=([[1, 2]], [2], sparse.coo_array([2])))
  34. message = "The shape of `A` must be (len(b_l), len(c))."
  35. with pytest.raises(ValueError, match=re.escape(message)):
  36. milp(np.zeros(3), constraints=([[1, 2]], [2], [2]))
  37. message = "`integrality` must be a dense array"
  38. with pytest.raises(ValueError, match=message):
  39. milp([1, 2], integrality=sparse.coo_array([1, 2]))
  40. message = ("`integrality` must contain integers 0-3 and be broadcastable "
  41. "to `c.shape`.")
  42. with pytest.raises(ValueError, match=message):
  43. milp([1, 2, 3], integrality=[1, 2])
  44. with pytest.raises(ValueError, match=message):
  45. milp([1, 2, 3], integrality=[1, 5, 3])
  46. message = "Lower and upper bounds must be dense arrays."
  47. with pytest.raises(ValueError, match=message):
  48. milp([1, 2, 3], bounds=([1, 2], sparse.coo_array([3, 4])))
  49. message = "`lb`, `ub`, and `keep_feasible` must be broadcastable."
  50. with pytest.raises(ValueError, match=message):
  51. milp([1, 2, 3], bounds=([1, 2], [3, 4, 5]))
  52. with pytest.raises(ValueError, match=message):
  53. milp([1, 2, 3], bounds=([1, 2, 3], [4, 5]))
  54. message = "`bounds.lb` and `bounds.ub` must contain reals and..."
  55. with pytest.raises(ValueError, match=message):
  56. milp([1, 2, 3], bounds=([1, 2], [3, 4]))
  57. with pytest.raises(ValueError, match=message):
  58. milp([1, 2, 3], bounds=([1, 2, 3], ["3+4", 4, 5]))
  59. with pytest.raises(ValueError, match=message):
  60. milp([1, 2, 3], bounds=([1, 2, 3], [set(), 4, 5]))
  61. @pytest.mark.xfail(run=False,
  62. reason="Needs to be fixed in `_highs_wrapper`")
  63. def test_milp_options(capsys):
  64. # run=False now because of gh-16347
  65. message = "Unrecognized options detected: {'ekki'}..."
  66. options = {'ekki': True}
  67. with pytest.warns(RuntimeWarning, match=message):
  68. milp(1, options=options)
  69. A, b, c, numbers, M = magic_square(3)
  70. options = {"disp": True, "presolve": False, "time_limit": 0.05}
  71. res = milp(c=c, constraints=(A, b, b), bounds=(0, 1), integrality=1,
  72. options=options)
  73. captured = capsys.readouterr()
  74. assert "Presolve is switched off" in captured.out
  75. assert "Time Limit Reached" in captured.out
  76. assert not res.success
  77. def test_result():
  78. A, b, c, numbers, M = magic_square(3)
  79. res = milp(c=c, constraints=(A, b, b), bounds=(0, 1), integrality=1)
  80. assert res.status == 0
  81. assert res.success
  82. msg = "Optimization terminated successfully. (HiGHS Status 7:"
  83. assert res.message.startswith(msg)
  84. assert isinstance(res.x, np.ndarray)
  85. assert isinstance(res.fun, float)
  86. assert isinstance(res.mip_node_count, int)
  87. assert isinstance(res.mip_dual_bound, float)
  88. assert isinstance(res.mip_gap, float)
  89. A, b, c, numbers, M = magic_square(6)
  90. res = milp(c=c*0, constraints=(A, b, b), bounds=(0, 1), integrality=1,
  91. options={'time_limit': 0.05})
  92. assert res.status == 1
  93. assert not res.success
  94. msg = "Time limit reached. (HiGHS Status 13:"
  95. assert res.message.startswith(msg)
  96. assert (res.fun is res.mip_dual_bound is res.mip_gap
  97. is res.mip_node_count is res.x is None)
  98. res = milp(1, bounds=(1, -1))
  99. assert res.status == 2
  100. assert not res.success
  101. msg = "The problem is infeasible. (HiGHS Status 8:"
  102. assert res.message.startswith(msg)
  103. assert (res.fun is res.mip_dual_bound is res.mip_gap
  104. is res.mip_node_count is res.x is None)
  105. res = milp(-1)
  106. assert res.status == 3
  107. assert not res.success
  108. msg = "The problem is unbounded. (HiGHS Status 10:"
  109. assert res.message.startswith(msg)
  110. assert (res.fun is res.mip_dual_bound is res.mip_gap
  111. is res.mip_node_count is res.x is None)
  112. def test_milp_optional_args():
  113. # check that arguments other than `c` are indeed optional
  114. res = milp(1)
  115. assert res.fun == 0
  116. assert_array_equal(res.x, [0])
  117. def test_milp_1():
  118. # solve magic square problem
  119. n = 3
  120. A, b, c, numbers, M = magic_square(n)
  121. A = sparse.csc_array(A) # confirm that sparse arrays are accepted
  122. res = milp(c=c*0, constraints=(A, b, b), bounds=(0, 1), integrality=1)
  123. # check that solution is a magic square
  124. x = np.round(res.x)
  125. s = (numbers.flatten() * x).reshape(n**2, n, n)
  126. square = np.sum(s, axis=0)
  127. np.testing.assert_allclose(square.sum(axis=0), M)
  128. np.testing.assert_allclose(square.sum(axis=1), M)
  129. np.testing.assert_allclose(np.diag(square).sum(), M)
  130. np.testing.assert_allclose(np.diag(square[:, ::-1]).sum(), M)
  131. def test_milp_2():
  132. # solve MIP with inequality constraints and all integer constraints
  133. # source: slide 5,
  134. # https://www.cs.upc.edu/~erodri/webpage/cps/theory/lp/milp/slides.pdf
  135. # also check that `milp` accepts all valid ways of specifying constraints
  136. c = -np.ones(2)
  137. A = [[-2, 2], [-8, 10]]
  138. b_l = [1, -np.inf]
  139. b_u = [np.inf, 13]
  140. linear_constraint = LinearConstraint(A, b_l, b_u)
  141. # solve original problem
  142. res1 = milp(c=c, constraints=(A, b_l, b_u), integrality=True)
  143. res2 = milp(c=c, constraints=linear_constraint, integrality=True)
  144. res3 = milp(c=c, constraints=[(A, b_l, b_u)], integrality=True)
  145. res4 = milp(c=c, constraints=[linear_constraint], integrality=True)
  146. res5 = milp(c=c, integrality=True,
  147. constraints=[(A[:1], b_l[:1], b_u[:1]),
  148. (A[1:], b_l[1:], b_u[1:])])
  149. res6 = milp(c=c, integrality=True,
  150. constraints=[LinearConstraint(A[:1], b_l[:1], b_u[:1]),
  151. LinearConstraint(A[1:], b_l[1:], b_u[1:])])
  152. res7 = milp(c=c, integrality=True,
  153. constraints=[(A[:1], b_l[:1], b_u[:1]),
  154. LinearConstraint(A[1:], b_l[1:], b_u[1:])])
  155. xs = np.array([res1.x, res2.x, res3.x, res4.x, res5.x, res6.x, res7.x])
  156. funs = np.array([res1.fun, res2.fun, res3.fun,
  157. res4.fun, res5.fun, res6.fun, res7.fun])
  158. np.testing.assert_allclose(xs, np.broadcast_to([1, 2], xs.shape))
  159. np.testing.assert_allclose(funs, -3)
  160. # solve relaxed problem
  161. res = milp(c=c, constraints=(A, b_l, b_u))
  162. np.testing.assert_allclose(res.x, [4, 4.5])
  163. np.testing.assert_allclose(res.fun, -8.5)
  164. def test_milp_3():
  165. # solve MIP with inequality constraints and all integer constraints
  166. # source: https://en.wikipedia.org/wiki/Integer_programming#Example
  167. c = [0, -1]
  168. A = [[-1, 1], [3, 2], [2, 3]]
  169. b_u = [1, 12, 12]
  170. b_l = np.full_like(b_u, -np.inf, dtype=np.float64)
  171. constraints = LinearConstraint(A, b_l, b_u)
  172. integrality = np.ones_like(c)
  173. # solve original problem
  174. res = milp(c=c, constraints=constraints, integrality=integrality)
  175. assert_allclose(res.fun, -2)
  176. # two optimal solutions possible, just need one of them
  177. assert np.allclose(res.x, [1, 2]) or np.allclose(res.x, [2, 2])
  178. # solve relaxed problem
  179. res = milp(c=c, constraints=constraints)
  180. assert_allclose(res.fun, -2.8)
  181. assert_allclose(res.x, [1.8, 2.8])
  182. def test_milp_4():
  183. # solve MIP with inequality constraints and only one integer constraint
  184. # source: https://www.mathworks.com/help/optim/ug/intlinprog.html
  185. c = [8, 1]
  186. integrality = [0, 1]
  187. A = [[1, 2], [-4, -1], [2, 1]]
  188. b_l = [-14, -np.inf, -np.inf]
  189. b_u = [np.inf, -33, 20]
  190. constraints = LinearConstraint(A, b_l, b_u)
  191. bounds = Bounds(-np.inf, np.inf)
  192. res = milp(c, integrality=integrality, bounds=bounds,
  193. constraints=constraints)
  194. assert_allclose(res.fun, 59)
  195. assert_allclose(res.x, [6.5, 7])
  196. def test_milp_5():
  197. # solve MIP with inequality and equality constraints
  198. # source: https://www.mathworks.com/help/optim/ug/intlinprog.html
  199. c = [-3, -2, -1]
  200. integrality = [0, 0, 1]
  201. lb = [0, 0, 0]
  202. ub = [np.inf, np.inf, 1]
  203. bounds = Bounds(lb, ub)
  204. A = [[1, 1, 1], [4, 2, 1]]
  205. b_l = [-np.inf, 12]
  206. b_u = [7, 12]
  207. constraints = LinearConstraint(A, b_l, b_u)
  208. res = milp(c, integrality=integrality, bounds=bounds,
  209. constraints=constraints)
  210. # there are multiple solutions
  211. assert_allclose(res.fun, -12)
  212. @pytest.mark.xslow
  213. def test_milp_6():
  214. # solve a larger MIP with only equality constraints
  215. # source: https://www.mathworks.com/help/optim/ug/intlinprog.html
  216. integrality = 1
  217. A_eq = np.array([[22, 13, 26, 33, 21, 3, 14, 26],
  218. [39, 16, 22, 28, 26, 30, 23, 24],
  219. [18, 14, 29, 27, 30, 38, 26, 26],
  220. [41, 26, 28, 36, 18, 38, 16, 26]])
  221. b_eq = np.array([7872, 10466, 11322, 12058])
  222. c = np.array([2, 10, 13, 17, 7, 5, 7, 3])
  223. res = milp(c=c, constraints=(A_eq, b_eq, b_eq), integrality=integrality)
  224. np.testing.assert_allclose(res.fun, 1854)
  225. def test_infeasible_prob_16609():
  226. # Ensure presolve does not mark trivially infeasible problems
  227. # as Optimal -- see gh-16609
  228. c = [1.0, 0.0]
  229. integrality = [0, 1]
  230. lb = [0, -np.inf]
  231. ub = [np.inf, np.inf]
  232. bounds = Bounds(lb, ub)
  233. A_eq = [[0.0, 1.0]]
  234. b_eq = [0.5]
  235. constraints = LinearConstraint(A_eq, b_eq, b_eq)
  236. res = milp(c, integrality=integrality, bounds=bounds,
  237. constraints=constraints)
  238. np.testing.assert_equal(res.status, 2)
  239. _msg_time = "Time limit reached. (HiGHS Status 13:"
  240. _msg_iter = "Iteration limit reached. (HiGHS Status 14:"
  241. # See https://github.com/scipy/scipy/pull/19255#issuecomment-1778438888
  242. @pytest.mark.xfail(reason="Often buggy, revisit with callbacks, gh-19255")
  243. @pytest.mark.skipif(np.intp(0).itemsize < 8,
  244. reason="Unhandled 32-bit GCC FP bug")
  245. @pytest.mark.slow
  246. @pytest.mark.parametrize(["options", "msg"], [({"time_limit": 0.1}, _msg_time),
  247. ({"node_limit": 1}, _msg_iter)])
  248. def test_milp_timeout_16545(options, msg):
  249. # Ensure solution is not thrown away if MILP solver times out
  250. # -- see gh-16545
  251. rng = np.random.default_rng(5123833489170494244)
  252. A = rng.integers(0, 5, size=(100, 100))
  253. b_lb = np.full(100, fill_value=-np.inf)
  254. b_ub = np.full(100, fill_value=25)
  255. constraints = LinearConstraint(A, b_lb, b_ub)
  256. variable_lb = np.zeros(100)
  257. variable_ub = np.ones(100)
  258. variable_bounds = Bounds(variable_lb, variable_ub)
  259. integrality = np.ones(100)
  260. c_vector = -np.ones(100)
  261. res = milp(
  262. c_vector,
  263. integrality=integrality,
  264. bounds=variable_bounds,
  265. constraints=constraints,
  266. options=options,
  267. )
  268. assert res.message.startswith(msg)
  269. assert res["x"] is not None
  270. # ensure solution is feasible
  271. x = res["x"]
  272. tol = 1e-8 # sometimes needed due to finite numerical precision
  273. assert np.all(b_lb - tol <= A @ x) and np.all(A @ x <= b_ub + tol)
  274. assert np.all(variable_lb - tol <= x) and np.all(x <= variable_ub + tol)
  275. assert np.allclose(x, np.round(x))
  276. def test_three_constraints_16878():
  277. # `milp` failed when exactly three constraints were passed
  278. # Ensure that this is no longer the case.
  279. rng = np.random.default_rng(5123833489170494244)
  280. A = rng.integers(0, 5, size=(6, 6))
  281. bl = np.full(6, fill_value=-np.inf)
  282. bu = np.full(6, fill_value=10)
  283. constraints = [LinearConstraint(A[:2], bl[:2], bu[:2]),
  284. LinearConstraint(A[2:4], bl[2:4], bu[2:4]),
  285. LinearConstraint(A[4:], bl[4:], bu[4:])]
  286. constraints2 = [(A[:2], bl[:2], bu[:2]),
  287. (A[2:4], bl[2:4], bu[2:4]),
  288. (A[4:], bl[4:], bu[4:])]
  289. lb = np.zeros(6)
  290. ub = np.ones(6)
  291. variable_bounds = Bounds(lb, ub)
  292. c = -np.ones(6)
  293. res1 = milp(c, bounds=variable_bounds, constraints=constraints)
  294. res2 = milp(c, bounds=variable_bounds, constraints=constraints2)
  295. ref = milp(c, bounds=variable_bounds, constraints=(A, bl, bu))
  296. assert res1.success and res2.success
  297. assert_allclose(res1.x, ref.x)
  298. assert_allclose(res2.x, ref.x)
  299. @pytest.mark.xslow
  300. def test_mip_rel_gap_passdown():
  301. # Solve problem with decreasing mip_gap to make sure mip_rel_gap decreases
  302. # Adapted from test_linprog::TestLinprogHiGHSMIP::test_mip_rel_gap_passdown
  303. # MIP taken from test_mip_6 above
  304. A_eq = np.array([[22, 13, 26, 33, 21, 3, 14, 26],
  305. [39, 16, 22, 28, 26, 30, 23, 24],
  306. [18, 14, 29, 27, 30, 38, 26, 26],
  307. [41, 26, 28, 36, 18, 38, 16, 26]])
  308. b_eq = np.array([7872, 10466, 11322, 12058])
  309. c = np.array([2, 10, 13, 17, 7, 5, 7, 3])
  310. mip_rel_gaps = [0.25, 0.01, 0.001]
  311. sol_mip_gaps = []
  312. for mip_rel_gap in mip_rel_gaps:
  313. res = milp(c=c, bounds=(0, np.inf), constraints=(A_eq, b_eq, b_eq),
  314. integrality=True, options={"mip_rel_gap": mip_rel_gap})
  315. # assert that the solution actually has mip_gap lower than the
  316. # required mip_rel_gap supplied
  317. assert res.mip_gap <= mip_rel_gap
  318. # check that `res.mip_gap` is as defined in the documentation
  319. assert res.mip_gap == (res.fun - res.mip_dual_bound)/res.fun
  320. sol_mip_gaps.append(res.mip_gap)
  321. # make sure that the mip_rel_gap parameter is actually doing something
  322. # check that differences between solution gaps are declining
  323. # monotonically with the mip_rel_gap parameter.
  324. assert np.all(np.diff(sol_mip_gaps) < 0)
  325. @pytest.mark.xfail(reason='Upstream / Wrapper issue, see gh-20116')
  326. def test_large_numbers_gh20116():
  327. h = 10 ** 12
  328. A = np.array([[100.4534, h], [100.4534, -h]])
  329. b = np.array([h, 0])
  330. constraints = LinearConstraint(A=A, ub=b)
  331. bounds = Bounds([0, 0], [1, 1])
  332. c = np.array([0, 0])
  333. res = milp(c=c, constraints=constraints, bounds=bounds, integrality=1)
  334. assert res.status == 0
  335. assert np.all(A @ res.x < b)
  336. def test_presolve_gh18907():
  337. from scipy.optimize import milp
  338. import numpy as np
  339. inf = np.inf
  340. # set up problem
  341. c = np.array([-0.85850509, -0.82892676, -0.80026454, -0.63015535, -0.5099006,
  342. -0.50077193, -0.4894404, -0.47285865, -0.39867774, -0.38069646,
  343. -0.36733012, -0.36733012, -0.35820411, -0.31576141, -0.20626091,
  344. -0.12466144, -0.10679516, -0.1061887, -0.1061887, -0.1061887,
  345. -0., -0., -0., -0., 0., 0., 0., 0.])
  346. A = np.array([[1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
  347. 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 0.],
  348. [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
  349. 1., 0., 0., 0., 0., 0., 1., 0., 0., 0., -25., -0., -0., -0.],
  350. [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
  351. -1., 0., 0., 0., 0., 0., -1., 0., 0., 0., 2., 0., 0., 0.],
  352. [0., 0., 0., 0., 1., 1., 1., 1., 0., 1., 0., 0., 0., 0., 0.,
  353. 0., 0., 0., 0., 0., 0., 0., 0., 0., -0., -25., -0., -0.],
  354. [0., 0., 0., 0., -1., -1., -1., -1., 0., -1., 0., 0., 0.,
  355. 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 2., 0., 0.],
  356. [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
  357. 0., 0., 1., 1., 1., 0., 0., 0., 0., -0., -0., -25., -0.],
  358. [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
  359. 0., 0., -1., -1., -1., 0., 0., 0., 0., 0., 0., 2., 0.],
  360. [1., 1., 1., 1., 0., 0., 0., 0., 1., 0., 1., 1., 1., 1., 0.,
  361. 1., 1., 0., 0., 0., 0., 1., 1., 1., -0., -0., -0., -25.],
  362. [-1., -1., -1., -1., 0., 0., 0., 0., -1., 0., -1., -1., -1., -1.,
  363. 0., -1., -1., 0., 0., 0., 0., -1., -1., -1., 0., 0., 0., 2.]])
  364. bl = np.array([-inf, -inf, -inf, -inf, -inf, -inf, -inf, -inf, -inf])
  365. bu = np.array([100., 0., 0., 0., 0., 0., 0., 0., 0.])
  366. constraints = LinearConstraint(A, bl, bu)
  367. integrality = 1
  368. bounds = (0, 1)
  369. r1 = milp(c=c, constraints=constraints, integrality=integrality, bounds=bounds,
  370. options={'presolve': True})
  371. r2 = milp(c=c, constraints=constraints, integrality=integrality, bounds=bounds,
  372. options={'presolve': False})
  373. assert r1.status == r2.status
  374. assert_allclose(r1.fun, r2.fun)
  375. # another example from the same issue
  376. bounds = Bounds(lb=0, ub=1)
  377. integrality = [1, 1, 0, 0]
  378. c = [10, 9.52380952, -1000, -952.38095238]
  379. A = [[1, 1, 0, 0], [0, 0, 1, 1], [200, 0, 0, 0], [0, 200, 0, 0],
  380. [0, 0, 2000, 0], [0, 0, 0, 2000], [-1, 0, 1, 0], [-1, -1, 0, 1]]
  381. ub = [1, 1, 200, 200, 1000, 1000, 0, 0]
  382. constraints = LinearConstraint(A, ub=ub)
  383. r1 = milp(c=c, constraints=constraints, bounds=bounds,
  384. integrality=integrality, options={"presolve": False})
  385. r2 = milp(c=c, constraints=constraints, bounds=bounds,
  386. integrality=integrality, options={"presolve": False})
  387. assert r1.status == r2.status
  388. assert_allclose(r1.x, r2.x)
  389. def test_regression_gh24141():
  390. c = np.ones(8, dtype=np.int64)
  391. integrality = c.copy()
  392. b = np.asarray([42, 252, 277, 41, 222, 48], dtype=np.int64)
  393. a = np.asarray([
  394. [0, 1, 1, 0, 1, 0, 0, 0],
  395. [0, 0, 1, 1, 1, 0, 1, 1],
  396. [1, 1, 1, 1, 1, 1, 0, 1],
  397. [0, 1, 0, 1, 1, 1, 0, 0],
  398. [0, 1, 0, 0, 1, 1, 0, 1],
  399. [0, 1, 1, 1, 0, 1, 1, 0],
  400. ], dtype=np.int64)
  401. res = milp(c, integrality=integrality, constraints=(a, b, b))
  402. assert res.success
  403. assert_allclose(a @ res.x, b)