diff --git a/miplearn/components/component.py b/miplearn/components/component.py index e73f329..7542fe0 100644 --- a/miplearn/components/component.py +++ b/miplearn/components/component.py @@ -198,6 +198,14 @@ class Component: ) -> None: return + def user_cut_cb( + self, + solver: "LearningSolver", + instance: Instance, + model: Any, + ) -> None: + return + def evaluate(self, instances: List[Instance]) -> List: ev = [] for instance in instances: diff --git a/miplearn/components/user_cuts.py b/miplearn/components/user_cuts.py new file mode 100644 index 0000000..3790e3d --- /dev/null +++ b/miplearn/components/user_cuts.py @@ -0,0 +1,64 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from typing import Any, TYPE_CHECKING, Hashable, Set + +from miplearn import Component, Instance + +import logging + +from miplearn.features import Features, TrainingSample +from miplearn.types import LearningSolveStats + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from miplearn.solvers.learning import LearningSolver + + +class UserCutsComponentNG(Component): + def __init__(self) -> None: + self.enforced: Set[Hashable] = set() + + def before_solve_mip( + self, + solver: "LearningSolver", + instance: Instance, + model: Any, + stats: LearningSolveStats, + features: Features, + training_data: TrainingSample, + ) -> None: + self.enforced.clear() + + def after_solve_mip( + self, + solver: "LearningSolver", + instance: Instance, + model: Any, + stats: LearningSolveStats, + features: Features, + training_data: TrainingSample, + ) -> None: + training_data.user_cuts_enforced = set(self.enforced) + + def user_cut_cb( + self, + solver: "LearningSolver", + instance: Instance, + model: Any, + ) -> None: + assert solver.internal_solver is not None + logger.debug("Finding violated user cuts...") + cids = instance.find_violated_user_cuts(model) + logger.debug(f"Found {len(cids)} violated user cuts") + logger.debug("Building violated user cuts...") + for cid in cids: + assert isinstance(cid, Hashable) + cobj = instance.build_user_cut(model, cid) + assert cobj is not None + solver.internal_solver.add_cut(cobj) + self.enforced.add(cid) + if len(cids) > 0: + logger.info(f"Added {len(cids)} violated user cuts") diff --git a/miplearn/features.py b/miplearn/features.py index bc1f4d7..f78ddb3 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -24,6 +24,7 @@ class TrainingSample: solution: Optional[Solution] = None upper_bound: Optional[float] = None slacks: Optional[Dict[str, float]] = None + user_cuts_enforced: Optional[Set[Hashable]] = None @dataclass diff --git a/miplearn/instance.py b/miplearn/instance.py index 5a156fd..17d3c79 100644 --- a/miplearn/instance.py +++ b/miplearn/instance.py @@ -170,11 +170,14 @@ class Instance(ABC): """ pass - def find_violated_user_cuts(self, model): + def has_user_cuts(self) -> bool: + return False + + def find_violated_user_cuts(self, model: Any) -> List[Hashable]: return [] - def build_user_cut(self, model, violation): - pass + def build_user_cut(self, model: Any, violation: Hashable) -> Any: + return None def flush(self) -> None: """ diff --git a/miplearn/problems/tsp.py b/miplearn/problems/tsp.py index e6d0cb8..23da230 100644 --- a/miplearn/problems/tsp.py +++ b/miplearn/problems/tsp.py @@ -185,9 +185,3 @@ class TravelingSalesmanInstance(Instance): 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) - - def find_violated_user_cuts(self, model): - return self.find_violated_lazy_constraints(model) - - def build_user_cut(self, model, violation): - return self.build_lazy_constraint(model, violation) diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index d333d2d..de0bca5 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -17,7 +17,7 @@ from miplearn.solvers.internal import ( LazyCallback, MIPSolveStats, ) -from miplearn.types import VarIndex, SolverParams, Solution +from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback logger = logging.getLogger(__name__) @@ -153,41 +153,49 @@ class GurobiSolver(InternalSolver): tee: bool = False, iteration_cb: IterationCallback = None, lazy_cb: LazyCallback = None, + user_cut_cb: UserCutCallback = None, ) -> MIPSolveStats: self._raise_if_callback() assert self.model is not None + if iteration_cb is None: + iteration_cb = lambda: False + # Create callback wrapper def cb_wrapper(cb_model, cb_where): try: self.cb_where = cb_where - if cb_where in self.lazy_cb_where: + if lazy_cb is not None and cb_where in self.lazy_cb_where: lazy_cb(self, self.model) + if user_cut_cb is not None and cb_where == self.gp.GRB.Callback.MIPNODE: + user_cut_cb(self, self.model) except: logger.exception("callback error") finally: self.cb_where = None - if lazy_cb: + # Configure Gurobi + if lazy_cb is not None: self.params["LazyConstraints"] = 1 + if user_cut_cb is not None: + self.params["PreCrush"] = 1 + + # Solve problem total_wallclock_time = 0 total_nodes = 0 streams: List[Any] = [StringIO()] if tee: streams += [sys.stdout] self._apply_params(streams) - if iteration_cb is None: - iteration_cb = lambda: False while True: with _RedirectOutput(streams): - if lazy_cb is None: - self.model.optimize() - else: - self.model.optimize(cb_wrapper) + self.model.optimize(cb_wrapper) total_wallclock_time += self.model.runtime total_nodes += int(self.model.nodeCount) should_repeat = iteration_cb() if not should_repeat: break + + # Fetch results and stats log = streams[0].getvalue() ub, lb = None, None sense = "min" if self.model.modelSense == 1 else "max" @@ -313,6 +321,11 @@ class GurobiSolver(InternalSolver): else: self.model.addConstr(constraint, name=name) + def add_cut(self, cobj: Any) -> None: + assert self.model is not None + assert self.cb_where == self.gp.GRB.Callback.MIPNODE + self.model.cbCut(cobj) + def _clear_warm_start(self) -> None: for (varname, vardict) in self._all_vars.items(): for (idx, var) in vardict.items(): @@ -421,7 +434,6 @@ class GurobiSolver(InternalSolver): } def __setstate__(self, state): - self.params = state["params"] self.lazy_cb_where = state["lazy_cb_where"] self.instance = None diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 20c798f..620e62d 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -16,6 +16,7 @@ from miplearn.types import ( Solution, BranchPriorities, Constraint, + UserCutCallback, ) logger = logging.getLogger(__name__) @@ -51,6 +52,7 @@ class InternalSolver(ABC): tee: bool = False, iteration_cb: IterationCallback = None, lazy_cb: LazyCallback = None, + user_cut_cb: UserCutCallback = None, ) -> MIPSolveStats: """ Solves the currently loaded instance. After this method finishes, @@ -72,6 +74,9 @@ class InternalSolver(ABC): - Querying if a constraint is satisfied - Adding a new constraint to the problem Additional operations may be allowed by specific subclasses. + user_cut_cb: UserCutCallback + This function is called whenever the solver found a new integer-infeasible + solution and needs to generate cutting planes to cut it off. tee: bool If true, prints the solver log to the screen. """ @@ -146,7 +151,7 @@ class InternalSolver(ABC): `get_solution`. Missing values indicate variables whose priorities should not be modified. """ - raise Exception("Not implemented") + raise NotImplementedError() @abstractmethod def get_constraint_ids(self) -> List[str]: @@ -180,6 +185,13 @@ class InternalSolver(ABC): """ pass + def add_cut(self, cobj: Any) -> None: + """ + Adds a cutting plane to the model. This function can only be called from a user + cut callback. + """ + raise NotImplementedError() + @abstractmethod def extract_constraint(self, cid: str) -> Constraint: """ diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index b7c13ca..a005abc 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -125,18 +125,22 @@ class LearningSolver: ) -> LearningSolveStats: # Generate model + # ------------------------------------------------------- if model is None: with _RedirectOutput([]): model = instance.to_model() # Initialize training sample + # ------------------------------------------------------- training_sample = TrainingSample() instance.training_data += [training_sample] # Initialize stats + # ------------------------------------------------------- stats: LearningSolveStats = {} # Initialize internal solver + # ------------------------------------------------------- self.tee = tee self.internal_solver = self.solver_factory() assert self.internal_solver is not None @@ -144,6 +148,7 @@ class LearningSolver: self.internal_solver.set_instance(instance, model) # Extract features + # ------------------------------------------------------- FeaturesExtractor(self.internal_solver).extract(instance) callback_args = ( @@ -156,6 +161,7 @@ class LearningSolver: ) # Solve root LP relaxation + # ------------------------------------------------------- if self.solve_lp: logger.debug("Running before_solve_lp callbacks...") for component in self.components.values(): @@ -172,37 +178,50 @@ class LearningSolver: for component in self.components.values(): component.after_solve_lp(*callback_args) - # Define wrappers + # Callback wrappers + # ------------------------------------------------------- def iteration_cb_wrapper() -> bool: should_repeat = False - assert isinstance(instance, Instance) for comp in self.components.values(): if comp.iteration_cb(self, instance, model): should_repeat = True return should_repeat def lazy_cb_wrapper( - cb_solver: LearningSolver, + cb_solver: InternalSolver, cb_model: Any, ) -> None: - assert isinstance(instance, Instance) for comp in self.components.values(): comp.lazy_cb(self, instance, model) + def user_cut_cb_wrapper( + cb_solver: InternalSolver, + cb_model: Any, + ) -> None: + for comp in self.components.values(): + comp.user_cut_cb(self, instance, model) + lazy_cb = None if self.use_lazy_cb: lazy_cb = lazy_cb_wrapper + user_cut_cb = None + if instance.has_user_cuts(): + user_cut_cb = user_cut_cb_wrapper + # Before-solve callbacks + # ------------------------------------------------------- logger.debug("Running before_solve_mip callbacks...") for component in self.components.values(): component.before_solve_mip(*callback_args) # Solve MIP + # ------------------------------------------------------- logger.info("Solving MIP...") mip_stats = self.internal_solver.solve( tee=tee, iteration_cb=iteration_cb_wrapper, + user_cut_cb=user_cut_cb, lazy_cb=lazy_cb, ) stats.update(cast(LearningSolveStats, mip_stats)) @@ -216,17 +235,20 @@ class LearningSolver: stats["Mode"] = self.mode # Add some information to training_sample + # ------------------------------------------------------- training_sample.lower_bound = stats["Lower bound"] training_sample.upper_bound = stats["Upper bound"] training_sample.mip_log = stats["MIP log"] training_sample.solution = self.internal_solver.get_solution() # After-solve callbacks + # ------------------------------------------------------- logger.debug("Calling after_solve_mip callbacks...") for component in self.components.values(): component.after_solve_mip(*callback_args) - # Write to file, if necessary + # Flush + # ------------------------------------------------------- if not discard_output: instance.flush() diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 2791a84..9d04e30 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -23,7 +23,7 @@ from miplearn.solvers.internal import ( LazyCallback, MIPSolveStats, ) -from miplearn.types import VarIndex, SolverParams, Solution +from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback logger = logging.getLogger(__name__) @@ -81,9 +81,12 @@ class BasePyomoSolver(InternalSolver): tee: bool = False, iteration_cb: IterationCallback = None, lazy_cb: LazyCallback = None, + user_cut_cb: UserCutCallback = None, ) -> MIPSolveStats: if lazy_cb is not None: - raise Exception("lazy callback not supported") + raise Exception("lazy callback not currently supported") + if user_cut_cb is not None: + raise Exception("user cut callback not currently supported") total_wallclock_time = 0 streams: List[Any] = [StringIO()] if tee: @@ -318,19 +321,19 @@ class BasePyomoSolver(InternalSolver): return {} def set_constraint_sense(self, cid: str, sense: str) -> None: - raise Exception("Not implemented") + raise NotImplementedError() def extract_constraint(self, cid: str) -> Constraint: - raise Exception("Not implemented") + raise NotImplementedError() def is_constraint_satisfied(self, cobj: Constraint, tol: float = 1e-6) -> bool: - raise Exception("Not implemented") + raise NotImplementedError() def is_infeasible(self) -> bool: return self._termination_condition == TerminationCondition.infeasible def get_dual(self, cid): - raise Exception("Not implemented") + raise NotImplementedError() def get_sense(self) -> str: return self._obj_sense diff --git a/miplearn/types.py b/miplearn/types.py index 6ad7dcf..688e520 100644 --- a/miplearn/types.py +++ b/miplearn/types.py @@ -2,10 +2,13 @@ # 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, Union, Tuple +from typing import Optional, Dict, Callable, Any, Union, Tuple, TYPE_CHECKING from mypy_extensions import TypedDict +if TYPE_CHECKING: + from miplearn.solvers.learning import InternalSolver + VarIndex = Union[str, int, Tuple[Union[str, int]]] Solution = Dict[str, Dict[VarIndex, Optional[float]]] @@ -64,6 +67,8 @@ IterationCallback = Callable[[], bool] LazyCallback = Callable[[Any, Any], None] +UserCutCallback = Callable[["InternalSolver", Any], None] + SolverParams = Dict[str, Any] BranchPriorities = Solution diff --git a/tests/components/test_user_cuts.py b/tests/components/test_user_cuts.py index 885e510..cbd6084 100644 --- a/tests/components/test_user_cuts.py +++ b/tests/components/test_user_cuts.py @@ -1,31 +1,77 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import Any, List +import logging +from typing import Any, FrozenSet, Hashable + +import gurobipy as gp +import networkx as nx import pytest +from gurobipy import GRB from networkx import Graph -import networkx as nx -from scipy.stats import randint -from miplearn import Instance -from miplearn.problems.stab import MaxWeightStableSetGenerator +from miplearn import Instance, LearningSolver, GurobiSolver +from miplearn.components.user_cuts import UserCutsComponentNG + +logger = logging.getLogger(__name__) class GurobiStableSetProblem(Instance): def __init__(self, graph: Graph) -> None: super().__init__() self.graph = graph + self.nodes = list(self.graph.nodes) def to_model(self) -> Any: - pass + model = gp.Model() + x = [model.addVar(vtype=GRB.BINARY) for _ in range(len(self.nodes))] + model.setObjective(gp.quicksum(x), GRB.MAXIMIZE) + for e in list(self.graph.edges): + model.addConstr(x[e[0]] + x[e[1]] <= 1) + return model + + def has_user_cuts(self) -> bool: + return True + + def find_violated_user_cuts(self, model): + assert isinstance(model, gp.Model) + vals = model.cbGetNodeRel(model.getVars()) + violations = [] + for clique in nx.find_cliques(self.graph): + lhs = sum(vals[i] for i in clique) + if lhs > 1: + violations += [frozenset(clique)] + return violations + + def build_user_cut(self, model: Any, violation: Hashable) -> Any: + assert isinstance(violation, FrozenSet) + x = model.getVars() + cut = gp.quicksum([x[i] for i in violation]) <= 1 + return cut @pytest.fixture -def instance() -> Instance: - graph = nx.generators.random_graphs.binomial_graph(50, 0.5) +def stab_instance() -> Instance: + graph = nx.generators.random_graphs.binomial_graph(50, 0.50, seed=42) return GurobiStableSetProblem(graph) -def test_usage(instance: Instance) -> None: - pass +@pytest.fixture +def solver() -> LearningSolver: + return LearningSolver( + solver=lambda: GurobiSolver(), + components=[ + UserCutsComponentNG(), + ], + ) + + +def test_usage( + stab_instance: Instance, + solver: LearningSolver, +) -> None: + solver.solve(stab_instance) + sample = stab_instance.training_data[0] + assert sample.user_cuts_enforced is not None + assert len(sample.user_cuts_enforced) > 0