_milp.py 15 KB

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