diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index f60f3d0..8deba25 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -17,6 +17,7 @@ from miplearn.solvers.internal import ( LazyCallback, MIPSolveStats, ) +from miplearn.types import VarIndex logger = logging.getLogger(__name__) @@ -230,7 +231,7 @@ class GurobiSolver(InternalSolver): else: return "max" - def get_value(self, var_name, index): + def get_value(self, var_name: str, index: VarIndex) -> Optional[float]: var = self._all_vars[var_name][index] return self._get_value(var) @@ -244,26 +245,29 @@ class GurobiSolver(InternalSolver): else: return c.pi - def _get_value(self, var): + def _get_value(self, var: Any) -> Optional[float]: if self.cb_where == self.GRB.Callback.MIPSOL: return self.model.cbGetSolution(var) elif self.cb_where == self.GRB.Callback.MIPNODE: return self.model.cbGetNodeRel(var) elif self.cb_where is None: - return var.x + if self.is_infeasible(): + return None + else: + return var.x else: raise Exception( "get_value cannot be called from cb_where=%s" % self.cb_where ) - def get_variables(self): + def get_empty_solution(self) -> Dict: self._raise_if_callback() - variables = {} + solution: Dict = {} for (varname, vardict) in self._all_vars.items(): - variables[varname] = [] + solution[varname] = {} for (idx, var) in vardict.items(): - variables[varname] += [idx] - return variables + solution[varname][idx] = None + return solution def add_constraint(self, constraint, name=""): if type(constraint) is tuple: @@ -325,7 +329,7 @@ class GurobiSolver(InternalSolver): else: raise Exception("Unknown sense: %s" % sense) - def get_inequality_slacks(self): + def get_inequality_slacks(self) -> Dict[str, float]: ineqs = [c for c in self.model.getConstrs() if c.sense != "="] return {c.ConstrName: c.Slack for c in ineqs} @@ -341,7 +345,7 @@ class GurobiSolver(InternalSolver): c = self.model.getConstrByName(cid) c.RHS = rhs - def relax(self): + def relax(self) -> None: self.model = self.model.relax() self._update_vars() diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index b6d0103..792ac5e 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -12,6 +12,7 @@ from miplearn.types import ( IterationCallback, LazyCallback, MIPSolveStats, + VarIndex, ) logger = logging.getLogger(__name__) @@ -196,21 +197,22 @@ class InternalSolver(ABC): pass @abstractmethod - def get_value(self, var_name, index): + def get_value(self, var_name: str, index: VarIndex) -> Optional[float]: """ - Returns the current value of a decision variable. + Returns the value of a given variable in the current solution. If no + solution is available, returns None. """ pass @abstractmethod - def relax(self): + def relax(self) -> None: """ Drops all integrality constraints from the model. """ pass @abstractmethod - def get_inequality_slacks(self): + def get_inequality_slacks(self) -> Dict[str, float]: """ Returns a dictionary mapping constraint name to the constraint slack in the current solution. @@ -218,7 +220,7 @@ class InternalSolver(ABC): pass @abstractmethod - def is_infeasible(self): + def is_infeasible(self) -> bool: """ Returns True if the model has been proved to be infeasible. Must be called after solve. @@ -226,31 +228,30 @@ class InternalSolver(ABC): pass @abstractmethod - def get_dual(self, cid): + def get_dual(self, cid: str) -> float: """ - If the model is feasible and has been solved to optimality, returns the optimal - value of the dual variable associated with this constraint. If the model is infeasible, - returns a portion of the infeasibility certificate corresponding to the given constraint. + If the model is feasible and has been solved to optimality, returns the + optimal value of the dual variable associated with this constraint. If the + model is infeasible, returns a portion of the infeasibility certificate + corresponding to the given constraint. - Must be called after solve. + Only available for relaxed problems. Must be called after solve. """ pass @abstractmethod - def get_sense(self): + def get_sense(self) -> str: """ Returns the sense of the problem (either "min" or "max"). """ pass @abstractmethod - def get_variables(self): + def get_empty_solution(self) -> Dict: + """ + Returns a dictionary with the same shape as the one produced by + `get_solution`, but with all values set to None. This method is + used by the ML components to query what variables are there in + the model before a solution is available. + """ pass - - def get_empty_solution(self): - solution = {} - for (var, indices) in self.get_variables().items(): - solution[var] = {} - for idx in indices: - solution[var][idx] = 0.0 - return solution diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index b19409a..defea3d 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -22,6 +22,7 @@ from miplearn.solvers.internal import ( LazyCallback, MIPSolveStats, ) +from miplearn.types import VarIndex logger = logging.getLogger(__name__) @@ -53,20 +54,13 @@ class BasePyomoSolver(InternalSolver): self, tee: bool = False, ) -> LPSolveStats: - for var in self._bin_vars: - lb, ub = var.bounds - var.setlb(lb) - var.setub(ub) - var.domain = pyomo.core.base.set_types.Reals - self._pyomo_solver.update_var(var) + self.relax() streams: List[Any] = [StringIO()] if tee: streams += [sys.stdout] with RedirectOutput(streams): results = self._pyomo_solver.solve(tee=True) - for var in self._bin_vars: - var.domain = pyomo.core.base.set_types.Binary - self._pyomo_solver.update_var(var) + self._restore_integrality() opt_value = None if not self.is_infeasible(): opt_value = results["Problem"][0]["Lower bound"] @@ -75,6 +69,11 @@ class BasePyomoSolver(InternalSolver): "Log": streams[0].getvalue(), } + def _restore_integrality(self) -> None: + for var in self._bin_vars: + var.domain = pyomo.core.base.set_types.Binary + self._pyomo_solver.update_var(var) + def solve( self, tee: bool = False, @@ -166,19 +165,23 @@ class BasePyomoSolver(InternalSolver): self._update_vars() self._update_constrs() - def get_value(self, var_name, index): - var = self._varname_to_var[var_name] - return var[index].value + def get_value(self, var_name: str, index: VarIndex) -> Optional[float]: + if self.is_infeasible(): + return None + else: + var = self._varname_to_var[var_name] + return var[index].value - def get_variables(self): - variables = {} + def get_empty_solution(self) -> Dict: + solution: Dict = {} for var in self.model.component_objects(Var): - variables[str(var)] = [] + svar = str(var) + solution[svar] = {} for index in var: if var[index].fixed: continue - variables[str(var)] += [index] - return variables + solution[svar][index] = None + return solution def _clear_warm_start(self) -> None: for var in self._all_vars: @@ -273,10 +276,15 @@ class BasePyomoSolver(InternalSolver): def is_constraint_satisfied(self, cobj): raise Exception("Not implemented") - def relax(self): - raise Exception("not implemented") + def relax(self) -> None: + for var in self._bin_vars: + lb, ub = var.bounds + var.setlb(lb) + var.setub(ub) + var.domain = pyomo.core.base.set_types.Reals + self._pyomo_solver.update_var(var) - def get_inequality_slacks(self): + def get_inequality_slacks(self) -> Dict[str, float]: raise Exception("not implemented") def set_constraint_sense(self, cid, sense): diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index c101c0c..0cd1d10 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -19,9 +19,9 @@ from miplearn.solvers.pyomo.xpress import XpressPyomoSolver class InfeasiblePyomoInstance(PyomoInstance): def to_model(self) -> pe.ConcreteModel: model = pe.ConcreteModel() - model.x = pe.Var(domain=pe.Binary) - model.OBJ = pe.Objective(expr=model.x, sense=pe.maximize) - model.eq = pe.Constraint(expr=model.x >= 2) + model.x = pe.Var([0], domain=pe.Binary) + model.OBJ = pe.Objective(expr=model.x[0], sense=pe.maximize) + model.eq = pe.Constraint(expr=model.x[0] >= 2) return model diff --git a/miplearn/solvers/tests/test_internal_solver.py b/miplearn/solvers/tests/test_internal_solver.py index ee64f64..4356e4e 100644 --- a/miplearn/solvers/tests/test_internal_solver.py +++ b/miplearn/solvers/tests/test_internal_solver.py @@ -7,6 +7,7 @@ from io import StringIO from warnings import warn import pyomo.environ as pe +from pytest import raises from miplearn.solvers import RedirectOutput from miplearn.solvers.gurobi import GurobiSolver @@ -92,6 +93,7 @@ def test_internal_solver(): solver.set_instance(instance, model) stats = solver.solve_lp() + assert not solver.is_infeasible() assert round(stats["Optimal value"], 3) == 1287.923 assert len(stats["Log"]) > 100 @@ -102,6 +104,7 @@ def test_internal_solver(): assert round(solution["x"][3], 3) == 0.000 stats = solver.solve(tee=True) + assert not solver.is_infeasible() assert len(stats["Log"]) > 100 assert stats["Lower bound"] == 1183.0 assert stats["Upper bound"] == 1183.0 @@ -131,6 +134,17 @@ def test_internal_solver(): assert stats["Lower bound"] == 1030.0 if isinstance(solver, GurobiSolver): + + assert solver.get_sense() == "max" + assert solver.get_constraint_sense("cut") == "<" + assert solver.get_constraint_sense("eq_capacity") == "<" + + # Verify slacks + assert solver.get_inequality_slacks() == { + "cut": 0.0, + "eq_capacity": 3.0, + } + # Extract the new constraint cobj = solver.extract_constraint("cut") @@ -160,6 +174,17 @@ def test_internal_solver(): solver.set_constraint_sense("cut", "=") stats = solver.solve() assert round(stats["Lower bound"]) == 1179.0 + assert round(solver.get_dual("eq_capacity")) == 12.0 + + +def test_relax(): + for solver_class in _get_internal_solvers(): + instance = _get_knapsack_instance(solver_class) + solver = solver_class() + solver.set_instance(instance) + solver.relax() + stats = solver.solve() + assert round(stats["Lower bound"]) == 1288.0 def test_infeasible_instance(): @@ -169,6 +194,7 @@ def test_infeasible_instance(): solver.set_instance(instance) stats = solver.solve() + assert solver.is_infeasible() assert solver.get_solution() is None assert stats["Upper bound"] is None assert stats["Lower bound"] is None @@ -176,6 +202,7 @@ def test_infeasible_instance(): stats = solver.solve_lp() assert solver.get_solution() is None assert stats["Optimal value"] is None + assert solver.get_value("x", 0) is None def test_iteration_cb(): diff --git a/miplearn/types.py b/miplearn/types.py index 5223743..f3bbd2d 100644 --- a/miplearn/types.py +++ b/miplearn/types.py @@ -2,7 +2,7 @@ # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import Optional, Dict, Callable, Any +from typing import Optional, Dict, Callable, Any, Union, List from mypy_extensions import TypedDict @@ -46,3 +46,5 @@ MIPSolveStats = TypedDict( IterationCallback = Callable[[], bool] LazyCallback = Callable[[Any, Any], None] + +VarIndex = Union[str, int, List[Union[str, int]]]