From 83c46d70a383dbbf89f3fa048595fbe9f3d9ec88 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 15 May 2021 09:26:55 -0500 Subject: [PATCH] Implement bulk constraint methods --- miplearn/solvers/gurobi.py | 56 ++++++++++++++++++++++++++ miplearn/solvers/internal.py | 52 +++++++++++++++++++++++- miplearn/solvers/pyomo/base.py | 59 +++++++++++++++++++++++++++ miplearn/solvers/tests/__init__.py | 64 ++++++++++++++++++------------ 4 files changed, 203 insertions(+), 28 deletions(-) diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 5eb04a1..b31a6db 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -111,10 +111,59 @@ class GurobiSolver(InternalSolver): self._has_lp_solution = False self._has_mip_solution = False + @overrides + def add_constraints(self, cf: ConstraintFeatures) -> None: + assert cf.names is not None + assert cf.senses is not None + assert cf.lhs is not None + assert cf.rhs is not None + assert self.model is not None + for i in range(len(cf.names)): + sense = cf.senses[i] + lhs = self.gp.quicksum( + self._varname_to_var[varname] * coeff for (varname, coeff) in cf.lhs[i] + ) + if sense == "=": + self.model.addConstr(lhs == cf.rhs[i], name=cf.names[i]) + elif sense == "<": + self.model.addConstr(lhs <= cf.rhs[i], name=cf.names[i]) + else: + self.model.addConstr(lhs >= cf.rhs[i], name=cf.names[i]) + self.model.update() + self._dirty = True + self._has_lp_solution = False + self._has_mip_solution = False + @overrides def are_callbacks_supported(self) -> bool: return True + @overrides + def are_constraints_satisfied( + self, + cf: ConstraintFeatures, + tol: float = 1e-5, + ) -> List[bool]: + assert cf.names is not None + assert cf.senses is not None + assert cf.lhs is not None + assert cf.rhs is not None + assert self.model is not None + result = [] + for i in range(len(cf.names)): + sense = cf.senses[i] + lhs = sum( + self._varname_to_var[varname].x * coeff + for (varname, coeff) in cf.lhs[i] + ) + if sense == "<": + result.append(lhs <= cf.rhs[i] + tol) + elif sense == ">": + result.append(lhs >= cf.rhs[i] - tol) + else: + result.append(abs(cf.rhs[i] - lhs) <= tol) + return result + @overrides def build_test_instance_infeasible(self) -> Instance: return GurobiTestInstanceInfeasible() @@ -477,6 +526,13 @@ class GurobiSolver(InternalSolver): constr = self.model.getConstrByName(name) self.model.remove(constr) + @overrides + def remove_constraints(self, names: List[str]) -> None: + assert self.model is not None + constrs = [self.model.getConstrByName(n) for n in names] + self.model.remove(constrs) + self.model.update() + @overrides def set_instance( self, diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index ef3e9fe..9f88554 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -186,6 +186,22 @@ class InternalSolver(ABC): """ pass + @abstractmethod + def add_constraints(self, cf: ConstraintFeatures) -> None: + """Adds the given constraints to the model.""" + pass + + @abstractmethod + def are_constraints_satisfied( + self, + cf: ConstraintFeatures, + tol: float = 1e-5, + ) -> List[bool]: + """ + Checks whether the current solution satisfies the given constraints. + """ + pass + @abstractmethod def remove_constraint(self, name: str) -> None: """ @@ -193,6 +209,13 @@ class InternalSolver(ABC): """ pass + @abstractmethod + def remove_constraints(self, names: List[str]) -> None: + """ + Removes the given constraints from the model. + """ + pass + @abstractmethod def is_constraint_satisfied_old( self, constr: Constraint, tol: float = 1e-6 @@ -235,6 +258,14 @@ class InternalSolver(ABC): @abstractmethod def build_test_instance_knapsack(self) -> Instance: + """ + Returns an instance corresponding to the following MIP, for testing purposes: + + maximize 505 x0 + 352 x1 + 458 x2 + 220 x3 + s.t. eq_capacity: z = 23 x0 + 26 x1 + 20 x2 + 18 x3 + x0, x1, x2, x3 binary + 0 <= z <= 67 continuous + """ pass def are_callbacks_supported(self) -> bool: @@ -250,12 +281,28 @@ class InternalSolver(ABC): with_static: bool = True, with_sa: bool = True, ) -> VariableFeatures: + """ + Returns a description of the decision variables in the problem. + + Parameters + ---------- + with_static: bool + If True, include features that do not change during the solution process, + such as variable types and names. This parameter is used to reduce the + amount of duplicated data collected by LearningSolver. Features that do + not change are only collected once. + with_sa: bool + If True, collect sensitivity analysis information. For large models, + collecting this information may be expensive, so this parameter is useful + for reducing running times. + """ pass @abstractmethod def get_constraint_attrs(self) -> List[str]: """ - Returns a list of constraint attributes supported by this solver. + Returns a list of constraint attributes supported by this solver. Used for + testing purposes only. """ pass @@ -263,6 +310,7 @@ class InternalSolver(ABC): @abstractmethod def get_variable_attrs(self) -> List[str]: """ - Returns a list of variable attributes supported by this solver. + Returns a list of variable attributes supported by this solver. Used for + testing purposes only. """ pass diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 05e2e0d..c46ae58 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -98,10 +98,60 @@ class BasePyomoSolver(InternalSolver): self._has_lp_solution = False self._has_mip_solution = False + @overrides + def add_constraints(self, cf: ConstraintFeatures) -> None: + assert cf.names is not None + assert cf.senses is not None + assert cf.lhs is not None + assert cf.rhs is not None + assert self.model is not None + for (i, name) in enumerate(cf.names): + lhs = 0.0 + for (varname, coeff) in cf.lhs[i]: + var = self._varname_to_var[varname] + lhs += var * coeff + if cf.senses[i] == "=": + expr = lhs == cf.rhs[i] + elif cf.senses[i] == "<": + expr = lhs <= cf.rhs[i] + else: + expr = lhs >= cf.rhs[i] + cl = pe.Constraint(expr=expr, name=name) + self.model.add_component(name, cl) + self._pyomo_solver.add_constraint(cl) + self._cname_to_constr[name] = cl + self._termination_condition = "" + self._has_lp_solution = False + self._has_mip_solution = False + @overrides def are_callbacks_supported(self) -> bool: return False + @overrides + def are_constraints_satisfied( + self, + cf: ConstraintFeatures, + tol: float = 1e-5, + ) -> List[bool]: + assert cf.names is not None + assert cf.lhs is not None + assert cf.rhs is not None + assert cf.senses is not None + result = [] + for (i, name) in enumerate(cf.names): + lhs = 0.0 + for (varname, coeff) in cf.lhs[i]: + var = self._varname_to_var[varname] + lhs += var.value * coeff + if cf.senses[i] == "<": + result.append(lhs <= cf.rhs[i] + tol) + elif cf.senses[i] == ">": + result.append(lhs >= cf.rhs[i] - tol) + else: + result.append(abs(cf.rhs[i] - lhs) < tol) + return result + @overrides def build_test_instance_infeasible(self) -> Instance: return PyomoTestInstanceInfeasible() @@ -413,6 +463,15 @@ class BasePyomoSolver(InternalSolver): self.model.del_component(constr) self._pyomo_solver.remove_constraint(constr) + @overrides + def remove_constraints(self, names: List[str]) -> None: + assert self.model is not None + for name in names: + constr = self._cname_to_constr[name] + del self._cname_to_constr[name] + self.model.del_component(constr) + self._pyomo_solver.remove_constraint(constr) + @overrides def set_instance( self, diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index b20cae7..5468703 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -183,42 +183,54 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Fetch constraints (after-mip) assert_equals( - _round_constraints(solver.get_constraints_old(with_static=False)), - {"eq_capacity": Constraint(slack=0.0)}, + _round(solver.get_constraints(with_static=False)), + _filter_attrs( + solver.get_constraint_attrs(), + ConstraintFeatures( + names=("eq_capacity",), + slacks=(0.0,), + ), + ), ) - # Build a new constraint - cut = Constraint(lhs={"x[0]": 1.0}, sense="<", rhs=0.0) - assert not solver.is_constraint_satisfied_old(cut) + # Build new constraint and verify that it is violated + cf = ConstraintFeatures( + names=("cut",), + lhs=((("x[0]", 1.0),),), + rhs=(0.0,), + senses=("<",), + ) + assert_equals(solver.are_constraints_satisfied(cf), [False]) - # Add new constraint and verify that it is listed. Modifying the model should - # also clear the current solution. - solver.add_constraint(cut, "cut") + # Add constraint and verify it affects solution + solver.add_constraints(cf) assert_equals( - _round_constraints(solver.get_constraints_old()), - { - "eq_capacity": Constraint( - lazy=False, - lhs={"x[0]": 23.0, "x[1]": 26.0, "x[2]": 20.0, "x[3]": 18.0, "z": -1.0}, - rhs=0.0, - sense="=", - ), - "cut": Constraint( - lazy=False, - lhs={"x[0]": 1.0}, - rhs=0.0, - sense="<", + _round(solver.get_constraints(with_static=True)), + _filter_attrs( + solver.get_constraint_attrs(), + ConstraintFeatures( + names=("eq_capacity", "cut"), + rhs=(0.0, 0.0), + lhs=( + ( + ("x[0]", 23.0), + ("x[1]", 26.0), + ("x[2]", 20.0), + ("x[3]", 18.0), + ("z", -1.0), + ), + (("x[0]", 1.0),), + ), + senses=("=", "<"), ), - }, + ), ) - - # Re-solve MIP and verify that constraint affects the solution stats = solver.solve() assert_equals(stats.mip_lower_bound, 1030.0) - assert solver.is_constraint_satisfied_old(cut) + assert_equals(solver.are_constraints_satisfied(cf), [True]) # Remove the new constraint - solver.remove_constraint("cut") + solver.remove_constraints(["cut"]) # New constraint should no longer affect solution stats = solver.solve()