_milp.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import warnings
  2. import numpy as np
  3. from scipy.sparse import csc_array, vstack
  4. from ._highs._highs_wrapper import _highs_wrapper # type: ignore[import]
  5. from ._constraints import LinearConstraint, Bounds
  6. from ._optimize import OptimizeResult
  7. from ._linprog_highs import _highs_to_scipy_status_message
  8. def _constraints_to_components(constraints):
  9. """
  10. Convert sequence of constraints to a single set of components A, b_l, b_u.
  11. `constraints` could be
  12. 1. A LinearConstraint
  13. 2. A tuple representing a LinearConstraint
  14. 3. An invalid object
  15. 4. A sequence of composed entirely of objects of type 1/2
  16. 5. A sequence containing at least one object of type 3
  17. We want to accept 1, 2, and 4 and reject 3 and 5.
  18. """
  19. message = ("`constraints` (or each element within `constraints`) must be "
  20. "convertible into an instance of "
  21. "`scipy.optimize.LinearConstraint`.")
  22. As = []
  23. b_ls = []
  24. b_us = []
  25. # Accept case 1 by standardizing as case 4
  26. if isinstance(constraints, LinearConstraint):
  27. constraints = [constraints]
  28. else:
  29. # Reject case 3
  30. try:
  31. iter(constraints)
  32. except TypeError as exc:
  33. raise ValueError(message) from exc
  34. # Accept case 2 by standardizing as case 4
  35. if len(constraints) == 3:
  36. # argument could be a single tuple representing a LinearConstraint
  37. try:
  38. constraints = [LinearConstraint(*constraints)]
  39. except (TypeError, ValueError, np.VisibleDeprecationWarning):
  40. # argument was not a tuple representing a LinearConstraint
  41. pass
  42. # Address cases 4/5
  43. for constraint in constraints:
  44. # if it's not a LinearConstraint or something that represents a
  45. # LinearConstraint at this point, it's invalid
  46. if not isinstance(constraint, LinearConstraint):
  47. try:
  48. constraint = LinearConstraint(*constraint)
  49. except TypeError as exc:
  50. raise ValueError(message) from exc
  51. As.append(csc_array(constraint.A))
  52. b_ls.append(np.atleast_1d(constraint.lb).astype(np.double))
  53. b_us.append(np.atleast_1d(constraint.ub).astype(np.double))
  54. if len(As) > 1:
  55. A = vstack(As)
  56. b_l = np.concatenate(b_ls)
  57. b_u = np.concatenate(b_us)
  58. else: # avoid unnecessary copying
  59. A = As[0]
  60. b_l = b_ls[0]
  61. b_u = b_us[0]
  62. return A, b_l, b_u
  63. def _milp_iv(c, integrality, bounds, constraints, options):
  64. # objective IV
  65. c = np.atleast_1d(c).astype(np.double)
  66. if c.ndim != 1 or c.size == 0 or not np.all(np.isfinite(c)):
  67. message = ("`c` must be a one-dimensional array of finite numbers "
  68. "with at least one element.")
  69. raise ValueError(message)
  70. # integrality IV
  71. message = ("`integrality` must contain integers 0-3 and be broadcastable "
  72. "to `c.shape`.")
  73. if integrality is None:
  74. integrality = 0
  75. try:
  76. integrality = np.broadcast_to(integrality, c.shape).astype(np.uint8)
  77. except ValueError:
  78. raise ValueError(message)
  79. if integrality.min() < 0 or integrality.max() > 3:
  80. raise ValueError(message)
  81. # bounds IV
  82. if bounds is None:
  83. bounds = Bounds(0, np.inf)
  84. elif not isinstance(bounds, Bounds):
  85. message = ("`bounds` must be convertible into an instance of "
  86. "`scipy.optimize.Bounds`.")
  87. try:
  88. bounds = Bounds(*bounds)
  89. except TypeError as exc:
  90. raise ValueError(message) from exc
  91. try:
  92. lb = np.broadcast_to(bounds.lb, c.shape).astype(np.double)
  93. ub = np.broadcast_to(bounds.ub, c.shape).astype(np.double)
  94. except (ValueError, TypeError) as exc:
  95. message = ("`bounds.lb` and `bounds.ub` must contain reals and "
  96. "be broadcastable to `c.shape`.")
  97. raise ValueError(message) from exc
  98. # constraints IV
  99. if not constraints:
  100. constraints = [LinearConstraint(np.empty((0, c.size)),
  101. np.empty((0,)), np.empty((0,)))]
  102. try:
  103. A, b_l, b_u = _constraints_to_components(constraints)
  104. except ValueError as exc:
  105. message = ("`constraints` (or each element within `constraints`) must "
  106. "be convertible into an instance of "
  107. "`scipy.optimize.LinearConstraint`.")
  108. raise ValueError(message) from exc
  109. if A.shape != (b_l.size, c.size):
  110. message = "The shape of `A` must be (len(b_l), len(c))."
  111. raise ValueError(message)
  112. indptr, indices, data = A.indptr, A.indices, A.data.astype(np.double)
  113. # options IV
  114. options = options or {}
  115. supported_options = {'disp', 'presolve', 'time_limit', 'node_limit',
  116. 'mip_rel_gap'}
  117. unsupported_options = set(options).difference(supported_options)
  118. if unsupported_options:
  119. message = (f"Unrecognized options detected: {unsupported_options}. "
  120. "These will be passed to HiGHS verbatim.")
  121. warnings.warn(message, RuntimeWarning, stacklevel=3)
  122. options_iv = {'log_to_console': options.pop("disp", False),
  123. 'mip_max_nodes': options.pop("node_limit", None)}
  124. options_iv.update(options)
  125. return c, integrality, lb, ub, indptr, indices, data, b_l, b_u, options_iv
  126. def milp(c, *, integrality=None, bounds=None, constraints=None, options=None):
  127. r"""
  128. Mixed-integer linear programming
  129. Solves problems of the following form:
  130. .. math::
  131. \min_x \ & c^T x \\
  132. \mbox{such that} \ & b_l \leq A x \leq b_u,\\
  133. & l \leq x \leq u, \\
  134. & x_i \in \mathbb{Z}, i \in X_i
  135. where :math:`x` is a vector of decision variables;
  136. :math:`c`, :math:`b_l`, :math:`b_u`, :math:`l`, and :math:`u` are vectors;
  137. :math:`A` is a matrix, and :math:`X_i` is the set of indices of
  138. decision variables that must be integral. (In this context, a
  139. variable that can assume only integer values is said to be "integral";
  140. it has an "integrality" constraint.)
  141. Alternatively, that's:
  142. minimize::
  143. c @ x
  144. such that::
  145. b_l <= A @ x <= b_u
  146. l <= x <= u
  147. Specified elements of x must be integers
  148. By default, ``l = 0`` and ``u = np.inf`` unless specified with
  149. ``bounds``.
  150. Parameters
  151. ----------
  152. c : 1D array_like
  153. The coefficients of the linear objective function to be minimized.
  154. `c` is converted to a double precision array before the problem is
  155. solved.
  156. integrality : 1D array_like, optional
  157. Indicates the type of integrality constraint on each decision variable.
  158. ``0`` : Continuous variable; no integrality constraint.
  159. ``1`` : Integer variable; decision variable must be an integer
  160. within `bounds`.
  161. ``2`` : Semi-continuous variable; decision variable must be within
  162. `bounds` or take value ``0``.
  163. ``3`` : Semi-integer variable; decision variable must be an integer
  164. within `bounds` or take value ``0``.
  165. By default, all variables are continuous. `integrality` is converted
  166. to an array of integers before the problem is solved.
  167. bounds : scipy.optimize.Bounds, optional
  168. Bounds on the decision variables. Lower and upper bounds are converted
  169. to double precision arrays before the problem is solved. The
  170. ``keep_feasible`` parameter of the `Bounds` object is ignored. If
  171. not specified, all decision variables are constrained to be
  172. non-negative.
  173. constraints : sequence of scipy.optimize.LinearConstraint, optional
  174. Linear constraints of the optimization problem. Arguments may be
  175. one of the following:
  176. 1. A single `LinearConstraint` object
  177. 2. A single tuple that can be converted to a `LinearConstraint` object
  178. as ``LinearConstraint(*constraints)``
  179. 3. A sequence composed entirely of objects of type 1. and 2.
  180. Before the problem is solved, all values are converted to double
  181. precision, and the matrices of constraint coefficients are converted to
  182. instances of `scipy.sparse.csc_array`. The ``keep_feasible`` parameter
  183. of `LinearConstraint` objects is ignored.
  184. options : dict, optional
  185. A dictionary of solver options. The following keys are recognized.
  186. disp : bool (default: ``False``)
  187. Set to ``True`` if indicators of optimization status are to be
  188. printed to the console during optimization.
  189. node_limit : int, optional
  190. The maximum number of nodes (linear program relaxations) to solve
  191. before stopping. Default is no maximum number of nodes.
  192. presolve : bool (default: ``True``)
  193. Presolve attempts to identify trivial infeasibilities,
  194. identify trivial unboundedness, and simplify the problem before
  195. sending it to the main solver.
  196. time_limit : float, optional
  197. The maximum number of seconds allotted to solve the problem.
  198. Default is no time limit.
  199. mip_rel_gap : float, optional
  200. Termination criterion for MIP solver: solver will terminate when
  201. the gap between the primal objective value and the dual objective
  202. bound, scaled by the primal objective value, is <= mip_rel_gap.
  203. Returns
  204. -------
  205. res : OptimizeResult
  206. An instance of :class:`scipy.optimize.OptimizeResult`. The object
  207. is guaranteed to have the following attributes.
  208. status : int
  209. An integer representing the exit status of the algorithm.
  210. ``0`` : Optimal solution found.
  211. ``1`` : Iteration or time limit reached.
  212. ``2`` : Problem is infeasible.
  213. ``3`` : Problem is unbounded.
  214. ``4`` : Other; see message for details.
  215. success : bool
  216. ``True`` when an optimal solution is found and ``False`` otherwise.
  217. message : str
  218. A string descriptor of the exit status of the algorithm.
  219. The following attributes will also be present, but the values may be
  220. ``None``, depending on the solution status.
  221. x : ndarray
  222. The values of the decision variables that minimize the
  223. objective function while satisfying the constraints.
  224. fun : float
  225. The optimal value of the objective function ``c @ x``.
  226. mip_node_count : int
  227. The number of subproblems or "nodes" solved by the MILP solver.
  228. mip_dual_bound : float
  229. The MILP solver's final estimate of the lower bound on the optimal
  230. solution.
  231. mip_gap : float
  232. The difference between the primal objective value and the dual
  233. objective bound, scaled by the primal objective value.
  234. Notes
  235. -----
  236. `milp` is a wrapper of the HiGHS linear optimization software [1]_. The
  237. algorithm is deterministic, and it typically finds the global optimum of
  238. moderately challenging mixed-integer linear programs (when it exists).
  239. References
  240. ----------
  241. .. [1] Huangfu, Q., Galabova, I., Feldmeier, M., and Hall, J. A. J.
  242. "HiGHS - high performance software for linear optimization."
  243. https://highs.dev/
  244. .. [2] Huangfu, Q. and Hall, J. A. J. "Parallelizing the dual revised
  245. simplex method." Mathematical Programming Computation, 10 (1),
  246. 119-142, 2018. DOI: 10.1007/s12532-017-0130-5
  247. Examples
  248. --------
  249. Consider the problem at
  250. https://en.wikipedia.org/wiki/Integer_programming#Example, which is
  251. expressed as a maximization problem of two variables. Since `milp` requires
  252. that the problem be expressed as a minimization problem, the objective
  253. function coefficients on the decision variables are:
  254. >>> import numpy as np
  255. >>> c = -np.array([0, 1])
  256. Note the negative sign: we maximize the original objective function
  257. by minimizing the negative of the objective function.
  258. We collect the coefficients of the constraints into arrays like:
  259. >>> A = np.array([[-1, 1], [3, 2], [2, 3]])
  260. >>> b_u = np.array([1, 12, 12])
  261. >>> b_l = np.full_like(b_u, -np.inf)
  262. Because there is no lower limit on these constraints, we have defined a
  263. variable ``b_l`` full of values representing negative infinity. This may
  264. be unfamiliar to users of `scipy.optimize.linprog`, which only accepts
  265. "less than" (or "upper bound") inequality constraints of the form
  266. ``A_ub @ x <= b_u``. By accepting both ``b_l`` and ``b_u`` of constraints
  267. ``b_l <= A_ub @ x <= b_u``, `milp` makes it easy to specify "greater than"
  268. inequality constraints, "less than" inequality constraints, and equality
  269. constraints concisely.
  270. These arrays are collected into a single `LinearConstraint` object like:
  271. >>> from scipy.optimize import LinearConstraint
  272. >>> constraints = LinearConstraint(A, b_l, b_u)
  273. The non-negativity bounds on the decision variables are enforced by
  274. default, so we do not need to provide an argument for `bounds`.
  275. Finally, the problem states that both decision variables must be integers:
  276. >>> integrality = np.ones_like(c)
  277. We solve the problem like:
  278. >>> from scipy.optimize import milp
  279. >>> res = milp(c=c, constraints=constraints, integrality=integrality)
  280. >>> res.x
  281. [1.0, 2.0]
  282. Note that had we solved the relaxed problem (without integrality
  283. constraints):
  284. >>> res = milp(c=c, constraints=constraints) # OR:
  285. >>> # from scipy.optimize import linprog; res = linprog(c, A, b_u)
  286. >>> res.x
  287. [1.8, 2.8]
  288. we would not have obtained the correct solution by rounding to the nearest
  289. integers.
  290. Other examples are given :ref:`in the tutorial <tutorial-optimize_milp>`.
  291. """
  292. args_iv = _milp_iv(c, integrality, bounds, constraints, options)
  293. c, integrality, lb, ub, indptr, indices, data, b_l, b_u, options = args_iv
  294. highs_res = _highs_wrapper(c, indptr, indices, data, b_l, b_u,
  295. lb, ub, integrality, options)
  296. res = {}
  297. # Convert to scipy-style status and message
  298. highs_status = highs_res.get('status', None)
  299. highs_message = highs_res.get('message', None)
  300. status, message = _highs_to_scipy_status_message(highs_status,
  301. highs_message)
  302. res['status'] = status
  303. res['message'] = message
  304. res['success'] = (status == 0)
  305. x = highs_res.get('x', None)
  306. res['x'] = np.array(x) if x is not None else None
  307. res['fun'] = highs_res.get('fun', None)
  308. res['mip_node_count'] = highs_res.get('mip_node_count', None)
  309. res['mip_dual_bound'] = highs_res.get('mip_dual_bound', None)
  310. res['mip_gap'] = highs_res.get('mip_gap', None)
  311. return OptimizeResult(res)