diff --git a/miplearn/components/dynamic_lazy.py b/miplearn/components/dynamic_lazy.py index fd66dbf..7b7af20 100644 --- a/miplearn/components/dynamic_lazy.py +++ b/miplearn/components/dynamic_lazy.py @@ -51,8 +51,7 @@ class DynamicLazyConstraintsComponent(Component): ) -> None: assert solver.internal_solver is not None for cid in cids: - cobj = instance.build_lazy_constraint(model, cid) - solver.internal_solver.add_constraint(cobj) + instance.enforce_lazy_constraint(solver.internal_solver, model, cid) @overrides def before_solve_mip( @@ -78,7 +77,7 @@ class DynamicLazyConstraintsComponent(Component): model: Any, ) -> bool: logger.debug("Finding violated lazy constraints...") - cids = instance.find_violated_lazy_constraints(model) + cids = instance.find_violated_lazy_constraints(solver.internal_solver, model) if len(cids) == 0: logger.debug("No violations found") return False diff --git a/miplearn/instance/base.py b/miplearn/instance/base.py index 2a963d0..ec1cfd4 100644 --- a/miplearn/instance/base.py +++ b/miplearn/instance/base.py @@ -4,7 +4,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, List, Optional, Hashable +from typing import Any, List, Optional, Hashable, TYPE_CHECKING from overrides import EnforceOverrides @@ -13,9 +13,11 @@ from miplearn.types import VariableName, Category logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from miplearn.solvers.learning import InternalSolver # noinspection PyMethodMayBeStatic -class Instance(ABC): +class Instance(ABC, EnforceOverrides): """ Abstract class holding all the data necessary to generate a concrete model of the proble. @@ -109,28 +111,40 @@ class Instance(ABC): def is_constraint_lazy(self, cid: str) -> bool: return False - def find_violated_lazy_constraints(self, model: Any) -> List[Hashable]: + def find_violated_lazy_constraints( + self, + solver: "InternalSolver", + model: Any, + ) -> List[Hashable]: """ Returns lazy constraint violations found for the current solution. After solving a model, LearningSolver will ask the instance to identify which lazy constraints are violated by the current solution. For each identified - violation, LearningSolver will then call the build_lazy_constraint, add the - generated Pyomo constraint to the model, then resolve the problem. The - process repeats until no further lazy constraint violations are found. + violation, LearningSolver will then call the enforce_lazy_constraint and + resolve the problem. The process repeats until no further lazy constraint + violations are found. Each "violation" is simply a string, a tuple or any other hashable type which allows the instance to identify unambiguously which lazy constraint should be generated. In the Traveling Salesman Problem, for example, a subtour violation could be a frozen set containing the cities in the subtour. + The current solution can be queried with `solver.get_solution()`. If the solver + is configured to use lazy callbacks, this solution may be non-integer. + For a concrete example, see TravelingSalesmanInstance. """ return [] - def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: + def enforce_lazy_constraint( + self, + solver: "InternalSolver", + model: Any, + violation: Hashable, + ) -> None: """ - Returns a Pyomo constraint which fixes a given violation. + Adds constraints to the model to ensure that the given violation is fixed. This method is typically called immediately after find_violated_lazy_constraints. The violation object provided to this method @@ -138,11 +152,13 @@ class Instance(ABC): find_violated_lazy_constraints. After some training, LearningSolver may decide to proactively build some lazy constraints at the beginning of the optimization process, before a solution is even available. In this case, - build_lazy_constraints will be called without a corresponding call to + enforce_lazy_constraints will be called without a corresponding call to find_violated_lazy_constraints. - The implementation should not directly add the constraint to the model. The - constraint will be added by LearningSolver after the method returns. + Note that this method can be called either before the optimization starts or + from within a callback. To ensure that constraints are added correctly in + either case, it is recommended to use `solver.add_constraint`, instead of + modifying the `model` object directly. For a concrete example, see TravelingSalesmanInstance. """ diff --git a/miplearn/instance/picklegz.py b/miplearn/instance/picklegz.py index 245baca..d6599b5 100644 --- a/miplearn/instance/picklegz.py +++ b/miplearn/instance/picklegz.py @@ -6,13 +6,16 @@ import gc import gzip import os import pickle -from typing import Optional, Any, List, Hashable, cast, IO +from typing import Optional, Any, List, Hashable, cast, IO, TYPE_CHECKING from overrides import overrides from miplearn.instance.base import logger, Instance from miplearn.types import VariableName, Category +if TYPE_CHECKING: + from miplearn.solvers.learning import InternalSolver + class PickleGzInstance(Instance): """ @@ -79,14 +82,23 @@ class PickleGzInstance(Instance): return self.instance.is_constraint_lazy(cid) @overrides - def find_violated_lazy_constraints(self, model: Any) -> List[Hashable]: + def find_violated_lazy_constraints( + self, + solver: "InternalSolver", + model: Any, + ) -> List[Hashable]: assert self.instance is not None - return self.instance.find_violated_lazy_constraints(model) + return self.instance.find_violated_lazy_constraints(solver, model) @overrides - def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: + def enforce_lazy_constraint( + self, + solver: "InternalSolver", + model: Any, + violation: Hashable, + ) -> None: assert self.instance is not None - return self.instance.build_lazy_constraint(model, violation) + self.instance.enforce_lazy_constraint(solver, model, violation) @overrides def find_violated_user_cuts(self, model: Any) -> List[Hashable]: @@ -94,9 +106,9 @@ class PickleGzInstance(Instance): return self.instance.find_violated_user_cuts(model) @overrides - def build_user_cut(self, model: Any, violation: Hashable) -> Any: + def build_user_cut(self, model: Any, violation: Hashable) -> None: assert self.instance is not None - return self.instance.build_user_cut(model, violation) + self.instance.build_user_cut(model, violation) @overrides def load(self) -> None: diff --git a/miplearn/problems/tsp.py b/miplearn/problems/tsp.py index 7ae58e1..b8cea97 100644 --- a/miplearn/problems/tsp.py +++ b/miplearn/problems/tsp.py @@ -1,7 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import List, Tuple, FrozenSet, Any, Optional +from typing import List, Tuple, FrozenSet, Any, Optional, Hashable import networkx as nx import numpy as np @@ -11,6 +11,7 @@ from scipy.spatial.distance import pdist, squareform from scipy.stats import uniform, randint from scipy.stats.distributions import rv_frozen +from miplearn import InternalSolver from miplearn.instance.base import Instance from miplearn.types import VariableName, Category @@ -85,7 +86,11 @@ class TravelingSalesmanInstance(Instance): return self.varname_to_index[var_name] @overrides - def find_violated_lazy_constraints(self, model: Any) -> List[FrozenSet]: + def find_violated_lazy_constraints( + self, + solver: InternalSolver, + model: Any, + ) -> List[FrozenSet]: selected_edges = [e for e in self.edges if model.x[e].value > 0.5] graph = nx.Graph() graph.add_edges_from(selected_edges) @@ -97,14 +102,20 @@ class TravelingSalesmanInstance(Instance): return violations @overrides - def build_lazy_constraint(self, model: Any, component: FrozenSet) -> Any: + def enforce_lazy_constraint( + self, + solver: InternalSolver, + model: Any, + component: FrozenSet, + ) -> None: cut_edges = [ e for e in self.edges if (e[0] in component and e[1] not in component) or (e[0] not in component and e[1] in component) ] - return model.eq_subtour.add(sum(model.x[e] for e in cut_edges) >= 2) + constr = model.eq_subtour.add(sum(model.x[e] for e in cut_edges) >= 2) + solver.add_constraint(constr) class TravelingSalesmanGenerator: diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 38a810d..82396b1 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -232,8 +232,21 @@ class GurobiSolver(InternalSolver): @overrides def get_solution(self) -> Optional[Solution]: - self._raise_if_callback() assert self.model is not None + if self.cb_where is not None: + if self.cb_where == self.gp.GRB.Callback.MIPNODE: + return { + v.varName: self.model.cbGetNodeRel(v) for v in self.model.getVars() + } + elif self.cb_where == self.gp.GRB.Callback.MIPSOL: + return { + v.varName: self.model.cbGetSolution(v) for v in self.model.getVars() + } + else: + raise Exception( + f"get_solution can only be called from a callback " + f"when cb_where is either MIPNODE or MIPSOL" + ) if self.model.solCount == 0: return None return {v.varName: v.x for v in self.model.getVars()} @@ -481,6 +494,7 @@ class GurobiTestInstanceInfeasible(Instance): class GurobiTestInstanceRedundancy(Instance): + @overrides def to_model(self) -> Any: import gurobipy as gp from gurobipy import GRB @@ -525,11 +539,10 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack): return model @overrides - def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: - # TODO: Replace by plain constraint - return ExtractedGurobiConstraint( - lhs=1.0 * model.getVarByName("x[0]"), - sense="<", - rhs=0.0, - name="cut", - ) + def enforce_lazy_constraint( + self, + solver: InternalSolver, + model: Any, + violation: Hashable, + ) -> None: + solver.add_constraint(model.getVarByName("x[0]") <= 0, name="cut") diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index a94f6fe..2ea570f 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -426,6 +426,7 @@ class PyomoTestInstanceInfeasible(Instance): class PyomoTestInstanceRedundancy(Instance): + @overrides def to_model(self) -> pe.ConcreteModel: model = pe.ConcreteModel() model.x = pe.Var([0, 1], domain=pe.Binary) @@ -484,6 +485,11 @@ class PyomoTestInstanceKnapsack(Instance): ] @overrides - def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: + def enforce_lazy_constraint( + self, + solver: InternalSolver, + model: Any, + violation: Hashable, + ) -> None: model.cut = pe.Constraint(expr=model.x[0] <= 0.0, name="cut") - return model.cut + solver.add_constraint(model.cut) diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 8e78227..c58186b 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -88,9 +88,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: ) # Add a brand new constraint - cut = instance.build_lazy_constraint(model, "cut") - assert cut is not None - solver.add_constraint(cut, name="cut") + instance.enforce_lazy_constraint(solver, model, "cut") # New constraint should be listed assert_equals( @@ -199,18 +197,16 @@ def run_iteration_cb_tests(solver: InternalSolver) -> None: def run_lazy_cb_tests(solver: InternalSolver) -> None: instance = solver.build_test_instance_knapsack() model = instance.to_model() - lazy_cb_count = 0 def lazy_cb(cb_solver: InternalSolver, cb_model: Any) -> None: - nonlocal lazy_cb_count - lazy_cb_count += 1 - cobj = instance.build_lazy_constraint(model, "cut") - if not cb_solver.is_constraint_satisfied(cobj): - cb_solver.add_constraint(cobj) + relsol = cb_solver.get_solution() + assert relsol is not None + assert relsol["x[0]"] is not None + if relsol["x[0]"] > 0: + instance.enforce_lazy_constraint(cb_solver, cb_model, "cut") solver.set_instance(instance, model) solver.solve(lazy_cb=lazy_cb) - assert lazy_cb_count > 0 solution = solver.get_solution() assert solution is not None assert_equals(solution["x[0]"], 0.0)