diff --git a/miplearn/components/static_lazy.py b/miplearn/components/static_lazy.py index b8dfa29..0d7468a 100644 --- a/miplearn/components/static_lazy.py +++ b/miplearn/components/static_lazy.py @@ -163,7 +163,7 @@ class StaticLazyConstraintsComponent(Component): logger.info("Finding violated lazy constraints...") enforced: Dict[str, Constraint] = {} for (cid, c) in self.pool.items(): - if not solver.internal_solver.is_constraint_satisfied( + if not solver.internal_solver.is_constraint_satisfied_old( c, tol=self.violation_tolerance, ): diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 9a8ffd6..a4ad452 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -72,12 +72,16 @@ class GurobiSolver(InternalSolver): self._has_mip_solution = False self._varname_to_var: Dict[str, "gurobipy.Var"] = {} + self._cname_to_constr: Dict[str, "gurobipy.Constr"] = {} self._gp_vars: Tuple["gurobipy.Var", ...] = tuple() + self._gp_constrs: Tuple["gurobipy.Constr", ...] = tuple() self._var_names: Tuple[str, ...] = tuple() + self._constr_names: Tuple[str, ...] = tuple() self._var_types: Tuple[str, ...] = tuple() self._var_lbs: Tuple[float, ...] = tuple() self._var_ubs: Tuple[float, ...] = tuple() self._var_obj_coeffs: Tuple[float, ...] = tuple() + self._relaxed_constrs: Dict[str, Tuple["gurobipy.LinExpr", str, float]] = {} if self.lazy_cb_frequency == 1: self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL] @@ -134,6 +138,16 @@ class GurobiSolver(InternalSolver): lazy_cb_frequency=self.lazy_cb_frequency, ) + def enforce_constraints(self, names: List[str]) -> None: + constr = [self._relaxed_constrs[n] for n in names] + for (i, (lhs, sense, rhs)) in enumerate(constr): + if sense == "=": + self.model.addConstr(lhs == rhs, name=names[i]) + elif sense == "<": + self.model.addConstr(lhs <= rhs, name=names[i]) + else: + self.model.addConstr(lhs >= rhs, name=names[i]) + @overrides def fix(self, solution: Solution) -> None: self._raise_if_callback() @@ -406,8 +420,30 @@ class GurobiSolver(InternalSolver): values=values, ) + def is_constraint_satisfied( + self, + names: List[str], + tol: float = 1e-6, + ) -> List[bool]: + def _check(c): + lhs, sense, rhs = c + lhs_value = lhs.getValue() + if sense == "=": + return abs(lhs_value - rhs) < tol + elif sense == ">": + return lhs_value > rhs - tol + else: + return lhs_value < rhs - tol + + constrs = [self._relaxed_constrs[n] for n in names] + return list(map(_check, constrs)) + @overrides - def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + def is_constraint_satisfied_old( + self, + constr: Constraint, + tol: float = 1e-6, + ) -> bool: assert constr.lhs is not None lhs = 0.0 for (varname, coeff) in constr.lhs.items(): @@ -425,6 +461,14 @@ class GurobiSolver(InternalSolver): assert self.model is not None return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD] + def relax_constraints(self, names: List[str]) -> None: + constrs = [self._cname_to_constr[n] for n in names] + for (i, name) in enumerate(names): + c = constrs[i] + self._relaxed_constrs[name] = self.model.getRow(c), c.sense, c.rhs + self.model.remove(constrs) + self.model.update() + @overrides def remove_constraint(self, name: str) -> None: assert self.model is not None @@ -444,7 +488,7 @@ class GurobiSolver(InternalSolver): self.instance = instance self.model = model self.model.update() - self._update_vars() + self._update() @overrides def set_warm_start(self, solution: Solution) -> None: @@ -571,7 +615,7 @@ class GurobiSolver(InternalSolver): assert self.model is not None self.model.update() self.model = self.model.relax() - self._update_vars() + self._update() def _apply_params(self, streams: List[Any]) -> None: assert self.model is not None @@ -620,19 +664,22 @@ class GurobiSolver(InternalSolver): if self.cb_where is not None: raise Exception("method cannot be called from a callback") - def _update_vars(self) -> None: + def _update(self) -> None: assert self.model is not None gp_vars: List["gurobipy.Var"] = self.model.getVars() + gp_constrs: List["gurobipy.Constr"] = self.model.getConstrs() var_names: List[str] = self.model.getAttr("varName", gp_vars) var_types: List[str] = self.model.getAttr("vtype", gp_vars) var_ubs: List[float] = self.model.getAttr("ub", gp_vars) var_lbs: List[float] = self.model.getAttr("lb", gp_vars) var_obj_coeffs: List[float] = self.model.getAttr("obj", gp_vars) + constr_names: List[str] = self.model.getAttr("constrName", gp_constrs) varname_to_var: Dict = {} + cname_to_constr: Dict = {} for (i, gp_var) in enumerate(gp_vars): assert var_names[i] not in varname_to_var, ( f"Duplicated variable name detected: {var_names[i]}. " - f"Unique variable var_names are currently required." + f"Unique variable names are currently required." ) if var_types[i] == "I": assert var_ubs[i] == 1.0, ( @@ -649,9 +696,18 @@ class GurobiSolver(InternalSolver): "Variable {var.varName} has type {vtype}." ) varname_to_var[var_names[i]] = gp_var + for (i, gp_constr) in enumerate(gp_constrs): + assert constr_names[i] not in cname_to_constr, ( + f"Duplicated constraint name detected: {constr_names[i]}. " + f"Unique constraint names are currently required." + ) + cname_to_constr[constr_names[i]] = gp_constr self._varname_to_var = varname_to_var + self._cname_to_constr = cname_to_constr self._gp_vars = tuple(gp_vars) + self._gp_constrs = tuple(gp_constrs) self._var_names = tuple(var_names) + self._constr_names = constr_names self._var_types = tuple(var_types) self._var_lbs = tuple(var_lbs) self._var_ubs = tuple(var_ubs) @@ -692,8 +748,8 @@ class GurobiTestInstanceRedundancy(Instance): model = gp.Model() x = model.addVars(2, vtype=GRB.BINARY, name="x") - model.addConstr(x[0] + x[1] <= 1) - model.addConstr(x[0] + x[1] <= 2) + model.addConstr(x[0] + x[1] <= 1, name="c1") + model.addConstr(x[0] + x[1] <= 2, name="c2") model.setObjective(x[0] + x[1], GRB.MAXIMIZE) return model diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index c3d68db..ad3765f 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -196,7 +196,9 @@ class InternalSolver(ABC, EnforceOverrides): pass @abstractmethod - def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + def is_constraint_satisfied_old( + self, constr: Constraint, tol: float = 1e-6 + ) -> bool: """ Returns True if the current solution satisfies the given constraint. """ diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index b64f9cf..05e2e0d 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -386,7 +386,9 @@ class BasePyomoSolver(InternalSolver): ] @overrides - def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + def is_constraint_satisfied_old( + self, constr: Constraint, tol: float = 1e-6 + ) -> bool: lhs = 0.0 assert constr.lhs is not None for (varname, coeff) in constr.lhs.items(): diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index f463499..b20cae7 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -189,7 +189,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Build a new constraint cut = Constraint(lhs={"x[0]": 1.0}, sense="<", rhs=0.0) - assert not solver.is_constraint_satisfied(cut) + assert not solver.is_constraint_satisfied_old(cut) # Add new constraint and verify that it is listed. Modifying the model should # also clear the current solution. @@ -215,7 +215,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # 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(cut) + assert solver.is_constraint_satisfied_old(cut) # Remove the new constraint solver.remove_constraint("cut") diff --git a/tests/components/test_static_lazy.py b/tests/components/test_static_lazy.py index 7d0d7ab..df508fc 100644 --- a/tests/components/test_static_lazy.py +++ b/tests/components/test_static_lazy.py @@ -86,7 +86,7 @@ def test_usage_with_solver(instance: Instance) -> None: solver.gap_tolerance = 1e-4 internal = solver.internal_solver = Mock(spec=InternalSolver) - internal.is_constraint_satisfied = Mock(return_value=False) + internal.is_constraint_satisfied_old = Mock(return_value=False) component = StaticLazyConstraintsComponent(violation_tolerance=1.0) component.thresholds["type-a"] = MinProbabilityThreshold([0.5, 0.5]) @@ -144,8 +144,8 @@ def test_usage_with_solver(instance: Instance) -> None: # Should ask internal solver to verify if constraints in the pool are # satisfied and add the ones that are not c3 = sample.after_load.constraints_old["c3"] - internal.is_constraint_satisfied.assert_called_once_with(c3, tol=1.0) - internal.is_constraint_satisfied.reset_mock() + internal.is_constraint_satisfied_old.assert_called_once_with(c3, tol=1.0) + internal.is_constraint_satisfied_old.reset_mock() internal.add_constraint.assert_called_once_with(c3, name="c3") internal.add_constraint.reset_mock() @@ -154,7 +154,7 @@ def test_usage_with_solver(instance: Instance) -> None: assert not should_repeat # The lazy constraint pool should be empty by now, so no calls should be made - internal.is_constraint_satisfied.assert_not_called() + internal.is_constraint_satisfied_old.assert_not_called() internal.add_constraint.assert_not_called() # LearningSolver calls after_solve_mip diff --git a/tests/solvers/test_internal_solver.py b/tests/solvers/test_internal_solver.py index 4d50a98..0a3a57a 100644 --- a/tests/solvers/test_internal_solver.py +++ b/tests/solvers/test_internal_solver.py @@ -35,3 +35,23 @@ def test_gurobi_pyomo_solver() -> None: def test_gurobi_solver() -> None: run_internal_solver_tests(GurobiSolver()) + + +def test_redundancy() -> None: + solver = GurobiSolver() + instance = solver.build_test_instance_redundancy() + solver.set_instance(instance) + stats = solver.solve_lp() + assert stats.lp_value == 1.0 + constraints = solver.get_constraints() + assert constraints.names[0] == "c1" + assert constraints.slacks[0] == 0.0 + + solver.relax_constraints(["c1"]) + stats = solver.solve_lp() + assert stats.lp_value == 2.0 + assert solver.is_constraint_satisfied(["c1"]) == [False] + + solver.enforce_constraints(["c1"]) + stats = solver.solve_lp() + assert stats.lp_value == 1.0