123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- import warnings
- import numpy as np
- from scipy.sparse import csc_array, vstack
- from ._highs._highs_wrapper import _highs_wrapper # type: ignore[import]
- from ._constraints import LinearConstraint, Bounds
- from ._optimize import OptimizeResult
- from ._linprog_highs import _highs_to_scipy_status_message
- def _constraints_to_components(constraints):
- """
- Convert sequence of constraints to a single set of components A, b_l, b_u.
- `constraints` could be
- 1. A LinearConstraint
- 2. A tuple representing a LinearConstraint
- 3. An invalid object
- 4. A sequence of composed entirely of objects of type 1/2
- 5. A sequence containing at least one object of type 3
- We want to accept 1, 2, and 4 and reject 3 and 5.
- """
- message = ("`constraints` (or each element within `constraints`) must be "
- "convertible into an instance of "
- "`scipy.optimize.LinearConstraint`.")
- As = []
- b_ls = []
- b_us = []
- # Accept case 1 by standardizing as case 4
- if isinstance(constraints, LinearConstraint):
- constraints = [constraints]
- else:
- # Reject case 3
- try:
- iter(constraints)
- except TypeError as exc:
- raise ValueError(message) from exc
- # Accept case 2 by standardizing as case 4
- if len(constraints) == 3:
- # argument could be a single tuple representing a LinearConstraint
- try:
- constraints = [LinearConstraint(*constraints)]
- except (TypeError, ValueError, np.VisibleDeprecationWarning):
- # argument was not a tuple representing a LinearConstraint
- pass
- # Address cases 4/5
- for constraint in constraints:
- # if it's not a LinearConstraint or something that represents a
- # LinearConstraint at this point, it's invalid
- if not isinstance(constraint, LinearConstraint):
- try:
- constraint = LinearConstraint(*constraint)
- except TypeError as exc:
- raise ValueError(message) from exc
- As.append(csc_array(constraint.A))
- b_ls.append(np.atleast_1d(constraint.lb).astype(np.double))
- b_us.append(np.atleast_1d(constraint.ub).astype(np.double))
- if len(As) > 1:
- A = vstack(As)
- b_l = np.concatenate(b_ls)
- b_u = np.concatenate(b_us)
- else: # avoid unnecessary copying
- A = As[0]
- b_l = b_ls[0]
- b_u = b_us[0]
- return A, b_l, b_u
- def _milp_iv(c, integrality, bounds, constraints, options):
- # objective IV
- c = np.atleast_1d(c).astype(np.double)
- if c.ndim != 1 or c.size == 0 or not np.all(np.isfinite(c)):
- message = ("`c` must be a one-dimensional array of finite numbers "
- "with at least one element.")
- raise ValueError(message)
- # integrality IV
- message = ("`integrality` must contain integers 0-3 and be broadcastable "
- "to `c.shape`.")
- if integrality is None:
- integrality = 0
- try:
- integrality = np.broadcast_to(integrality, c.shape).astype(np.uint8)
- except ValueError:
- raise ValueError(message)
- if integrality.min() < 0 or integrality.max() > 3:
- raise ValueError(message)
- # bounds IV
- if bounds is None:
- bounds = Bounds(0, np.inf)
- elif not isinstance(bounds, Bounds):
- message = ("`bounds` must be convertible into an instance of "
- "`scipy.optimize.Bounds`.")
- try:
- bounds = Bounds(*bounds)
- except TypeError as exc:
- raise ValueError(message) from exc
- try:
- lb = np.broadcast_to(bounds.lb, c.shape).astype(np.double)
- ub = np.broadcast_to(bounds.ub, c.shape).astype(np.double)
- except (ValueError, TypeError) as exc:
- message = ("`bounds.lb` and `bounds.ub` must contain reals and "
- "be broadcastable to `c.shape`.")
- raise ValueError(message) from exc
- # constraints IV
- if not constraints:
- constraints = [LinearConstraint(np.empty((0, c.size)),
- np.empty((0,)), np.empty((0,)))]
- try:
- A, b_l, b_u = _constraints_to_components(constraints)
- except ValueError as exc:
- message = ("`constraints` (or each element within `constraints`) must "
- "be convertible into an instance of "
- "`scipy.optimize.LinearConstraint`.")
- raise ValueError(message) from exc
- if A.shape != (b_l.size, c.size):
- message = "The shape of `A` must be (len(b_l), len(c))."
- raise ValueError(message)
- indptr, indices, data = A.indptr, A.indices, A.data.astype(np.double)
- # options IV
- options = options or {}
- supported_options = {'disp', 'presolve', 'time_limit', 'node_limit',
- 'mip_rel_gap'}
- unsupported_options = set(options).difference(supported_options)
- if unsupported_options:
- message = (f"Unrecognized options detected: {unsupported_options}. "
- "These will be passed to HiGHS verbatim.")
- warnings.warn(message, RuntimeWarning, stacklevel=3)
- options_iv = {'log_to_console': options.pop("disp", False),
- 'mip_max_nodes': options.pop("node_limit", None)}
- options_iv.update(options)
- return c, integrality, lb, ub, indptr, indices, data, b_l, b_u, options_iv
- def milp(c, *, integrality=None, bounds=None, constraints=None, options=None):
- r"""
- Mixed-integer linear programming
- Solves problems of the following form:
- .. math::
- \min_x \ & c^T x \\
- \mbox{such that} \ & b_l \leq A x \leq b_u,\\
- & l \leq x \leq u, \\
- & x_i \in \mathbb{Z}, i \in X_i
- where :math:`x` is a vector of decision variables;
- :math:`c`, :math:`b_l`, :math:`b_u`, :math:`l`, and :math:`u` are vectors;
- :math:`A` is a matrix, and :math:`X_i` is the set of indices of
- decision variables that must be integral. (In this context, a
- variable that can assume only integer values is said to be "integral";
- it has an "integrality" constraint.)
- Alternatively, that's:
- minimize::
- c @ x
- such that::
- b_l <= A @ x <= b_u
- l <= x <= u
- Specified elements of x must be integers
- By default, ``l = 0`` and ``u = np.inf`` unless specified with
- ``bounds``.
- Parameters
- ----------
- c : 1D array_like
- The coefficients of the linear objective function to be minimized.
- `c` is converted to a double precision array before the problem is
- solved.
- integrality : 1D array_like, optional
- Indicates the type of integrality constraint on each decision variable.
- ``0`` : Continuous variable; no integrality constraint.
- ``1`` : Integer variable; decision variable must be an integer
- within `bounds`.
- ``2`` : Semi-continuous variable; decision variable must be within
- `bounds` or take value ``0``.
- ``3`` : Semi-integer variable; decision variable must be an integer
- within `bounds` or take value ``0``.
- By default, all variables are continuous. `integrality` is converted
- to an array of integers before the problem is solved.
- bounds : scipy.optimize.Bounds, optional
- Bounds on the decision variables. Lower and upper bounds are converted
- to double precision arrays before the problem is solved. The
- ``keep_feasible`` parameter of the `Bounds` object is ignored. If
- not specified, all decision variables are constrained to be
- non-negative.
- constraints : sequence of scipy.optimize.LinearConstraint, optional
- Linear constraints of the optimization problem. Arguments may be
- one of the following:
- 1. A single `LinearConstraint` object
- 2. A single tuple that can be converted to a `LinearConstraint` object
- as ``LinearConstraint(*constraints)``
- 3. A sequence composed entirely of objects of type 1. and 2.
- Before the problem is solved, all values are converted to double
- precision, and the matrices of constraint coefficients are converted to
- instances of `scipy.sparse.csc_array`. The ``keep_feasible`` parameter
- of `LinearConstraint` objects is ignored.
- options : dict, optional
- A dictionary of solver options. The following keys are recognized.
- disp : bool (default: ``False``)
- Set to ``True`` if indicators of optimization status are to be
- printed to the console during optimization.
- node_limit : int, optional
- The maximum number of nodes (linear program relaxations) to solve
- before stopping. Default is no maximum number of nodes.
- presolve : bool (default: ``True``)
- Presolve attempts to identify trivial infeasibilities,
- identify trivial unboundedness, and simplify the problem before
- sending it to the main solver.
- time_limit : float, optional
- The maximum number of seconds allotted to solve the problem.
- Default is no time limit.
- mip_rel_gap : float, optional
- Termination criterion for MIP solver: solver will terminate when
- the gap between the primal objective value and the dual objective
- bound, scaled by the primal objective value, is <= mip_rel_gap.
- Returns
- -------
- res : OptimizeResult
- An instance of :class:`scipy.optimize.OptimizeResult`. The object
- is guaranteed to have the following attributes.
- status : int
- An integer representing the exit status of the algorithm.
- ``0`` : Optimal solution found.
- ``1`` : Iteration or time limit reached.
- ``2`` : Problem is infeasible.
- ``3`` : Problem is unbounded.
- ``4`` : Other; see message for details.
- success : bool
- ``True`` when an optimal solution is found and ``False`` otherwise.
- message : str
- A string descriptor of the exit status of the algorithm.
- The following attributes will also be present, but the values may be
- ``None``, depending on the solution status.
- x : ndarray
- The values of the decision variables that minimize the
- objective function while satisfying the constraints.
- fun : float
- The optimal value of the objective function ``c @ x``.
- mip_node_count : int
- The number of subproblems or "nodes" solved by the MILP solver.
- mip_dual_bound : float
- The MILP solver's final estimate of the lower bound on the optimal
- solution.
- mip_gap : float
- The difference between the primal objective value and the dual
- objective bound, scaled by the primal objective value.
- Notes
- -----
- `milp` is a wrapper of the HiGHS linear optimization software [1]_. The
- algorithm is deterministic, and it typically finds the global optimum of
- moderately challenging mixed-integer linear programs (when it exists).
- References
- ----------
- .. [1] Huangfu, Q., Galabova, I., Feldmeier, M., and Hall, J. A. J.
- "HiGHS - high performance software for linear optimization."
- https://highs.dev/
- .. [2] Huangfu, Q. and Hall, J. A. J. "Parallelizing the dual revised
- simplex method." Mathematical Programming Computation, 10 (1),
- 119-142, 2018. DOI: 10.1007/s12532-017-0130-5
- Examples
- --------
- Consider the problem at
- https://en.wikipedia.org/wiki/Integer_programming#Example, which is
- expressed as a maximization problem of two variables. Since `milp` requires
- that the problem be expressed as a minimization problem, the objective
- function coefficients on the decision variables are:
- >>> import numpy as np
- >>> c = -np.array([0, 1])
- Note the negative sign: we maximize the original objective function
- by minimizing the negative of the objective function.
- We collect the coefficients of the constraints into arrays like:
- >>> A = np.array([[-1, 1], [3, 2], [2, 3]])
- >>> b_u = np.array([1, 12, 12])
- >>> b_l = np.full_like(b_u, -np.inf)
- Because there is no lower limit on these constraints, we have defined a
- variable ``b_l`` full of values representing negative infinity. This may
- be unfamiliar to users of `scipy.optimize.linprog`, which only accepts
- "less than" (or "upper bound") inequality constraints of the form
- ``A_ub @ x <= b_u``. By accepting both ``b_l`` and ``b_u`` of constraints
- ``b_l <= A_ub @ x <= b_u``, `milp` makes it easy to specify "greater than"
- inequality constraints, "less than" inequality constraints, and equality
- constraints concisely.
- These arrays are collected into a single `LinearConstraint` object like:
- >>> from scipy.optimize import LinearConstraint
- >>> constraints = LinearConstraint(A, b_l, b_u)
- The non-negativity bounds on the decision variables are enforced by
- default, so we do not need to provide an argument for `bounds`.
- Finally, the problem states that both decision variables must be integers:
- >>> integrality = np.ones_like(c)
- We solve the problem like:
- >>> from scipy.optimize import milp
- >>> res = milp(c=c, constraints=constraints, integrality=integrality)
- >>> res.x
- [1.0, 2.0]
- Note that had we solved the relaxed problem (without integrality
- constraints):
- >>> res = milp(c=c, constraints=constraints) # OR:
- >>> # from scipy.optimize import linprog; res = linprog(c, A, b_u)
- >>> res.x
- [1.8, 2.8]
- we would not have obtained the correct solution by rounding to the nearest
- integers.
- Other examples are given :ref:`in the tutorial <tutorial-optimize_milp>`.
- """
- args_iv = _milp_iv(c, integrality, bounds, constraints, options)
- c, integrality, lb, ub, indptr, indices, data, b_l, b_u, options = args_iv
- highs_res = _highs_wrapper(c, indptr, indices, data, b_l, b_u,
- lb, ub, integrality, options)
- res = {}
- # Convert to scipy-style status and message
- highs_status = highs_res.get('status', None)
- highs_message = highs_res.get('message', None)
- status, message = _highs_to_scipy_status_message(highs_status,
- highs_message)
- res['status'] = status
- res['message'] = message
- res['success'] = (status == 0)
- x = highs_res.get('x', None)
- res['x'] = np.array(x) if x is not None else None
- res['fun'] = highs_res.get('fun', None)
- res['mip_node_count'] = highs_res.get('mip_node_count', None)
- res['mip_dual_bound'] = highs_res.get('mip_dual_bound', None)
- res['mip_gap'] = highs_res.get('mip_gap', None)
- return OptimizeResult(res)
|