Add user cut callbacks; begin rewrite of UserCutsComponent

master
Alinson S. Xavier 5 years ago
parent cfb17551f1
commit 9f2d7439dc
No known key found for this signature in database
GPG Key ID: DCA0DAD4D2F58624

@ -198,6 +198,14 @@ class Component:
) -> None: ) -> None:
return return
def user_cut_cb(
self,
solver: "LearningSolver",
instance: Instance,
model: Any,
) -> None:
return
def evaluate(self, instances: List[Instance]) -> List: def evaluate(self, instances: List[Instance]) -> List:
ev = [] ev = []
for instance in instances: for instance in instances:

@ -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")

@ -24,6 +24,7 @@ class TrainingSample:
solution: Optional[Solution] = None solution: Optional[Solution] = None
upper_bound: Optional[float] = None upper_bound: Optional[float] = None
slacks: Optional[Dict[str, float]] = None slacks: Optional[Dict[str, float]] = None
user_cuts_enforced: Optional[Set[Hashable]] = None
@dataclass @dataclass

@ -170,11 +170,14 @@ class Instance(ABC):
""" """
pass 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 [] return []
def build_user_cut(self, model, violation): def build_user_cut(self, model: Any, violation: Hashable) -> Any:
pass return None
def flush(self) -> None: def flush(self) -> None:
""" """

@ -185,9 +185,3 @@ class TravelingSalesmanInstance(Instance):
or (e[0] not in component and e[1] 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) 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)

@ -17,7 +17,7 @@ from miplearn.solvers.internal import (
LazyCallback, LazyCallback,
MIPSolveStats, MIPSolveStats,
) )
from miplearn.types import VarIndex, SolverParams, Solution from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -153,41 +153,49 @@ class GurobiSolver(InternalSolver):
tee: bool = False, tee: bool = False,
iteration_cb: IterationCallback = None, iteration_cb: IterationCallback = None,
lazy_cb: LazyCallback = None, lazy_cb: LazyCallback = None,
user_cut_cb: UserCutCallback = None,
) -> MIPSolveStats: ) -> MIPSolveStats:
self._raise_if_callback() self._raise_if_callback()
assert self.model is not None 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): def cb_wrapper(cb_model, cb_where):
try: try:
self.cb_where = cb_where 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) 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: except:
logger.exception("callback error") logger.exception("callback error")
finally: finally:
self.cb_where = None self.cb_where = None
if lazy_cb: # Configure Gurobi
if lazy_cb is not None:
self.params["LazyConstraints"] = 1 self.params["LazyConstraints"] = 1
if user_cut_cb is not None:
self.params["PreCrush"] = 1
# Solve problem
total_wallclock_time = 0 total_wallclock_time = 0
total_nodes = 0 total_nodes = 0
streams: List[Any] = [StringIO()] streams: List[Any] = [StringIO()]
if tee: if tee:
streams += [sys.stdout] streams += [sys.stdout]
self._apply_params(streams) self._apply_params(streams)
if iteration_cb is None:
iteration_cb = lambda: False
while True: while True:
with _RedirectOutput(streams): 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_wallclock_time += self.model.runtime
total_nodes += int(self.model.nodeCount) total_nodes += int(self.model.nodeCount)
should_repeat = iteration_cb() should_repeat = iteration_cb()
if not should_repeat: if not should_repeat:
break break
# Fetch results and stats
log = streams[0].getvalue() log = streams[0].getvalue()
ub, lb = None, None ub, lb = None, None
sense = "min" if self.model.modelSense == 1 else "max" sense = "min" if self.model.modelSense == 1 else "max"
@ -313,6 +321,11 @@ class GurobiSolver(InternalSolver):
else: else:
self.model.addConstr(constraint, name=name) 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: def _clear_warm_start(self) -> None:
for (varname, vardict) in self._all_vars.items(): for (varname, vardict) in self._all_vars.items():
for (idx, var) in vardict.items(): for (idx, var) in vardict.items():
@ -421,7 +434,6 @@ class GurobiSolver(InternalSolver):
} }
def __setstate__(self, state): def __setstate__(self, state):
self.params = state["params"] self.params = state["params"]
self.lazy_cb_where = state["lazy_cb_where"] self.lazy_cb_where = state["lazy_cb_where"]
self.instance = None self.instance = None

@ -16,6 +16,7 @@ from miplearn.types import (
Solution, Solution,
BranchPriorities, BranchPriorities,
Constraint, Constraint,
UserCutCallback,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,6 +52,7 @@ class InternalSolver(ABC):
tee: bool = False, tee: bool = False,
iteration_cb: IterationCallback = None, iteration_cb: IterationCallback = None,
lazy_cb: LazyCallback = None, lazy_cb: LazyCallback = None,
user_cut_cb: UserCutCallback = None,
) -> MIPSolveStats: ) -> MIPSolveStats:
""" """
Solves the currently loaded instance. After this method finishes, Solves the currently loaded instance. After this method finishes,
@ -72,6 +74,9 @@ class InternalSolver(ABC):
- Querying if a constraint is satisfied - Querying if a constraint is satisfied
- Adding a new constraint to the problem - Adding a new constraint to the problem
Additional operations may be allowed by specific subclasses. 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 tee: bool
If true, prints the solver log to the screen. If true, prints the solver log to the screen.
""" """
@ -146,7 +151,7 @@ class InternalSolver(ABC):
`get_solution`. Missing values indicate variables whose priorities `get_solution`. Missing values indicate variables whose priorities
should not be modified. should not be modified.
""" """
raise Exception("Not implemented") raise NotImplementedError()
@abstractmethod @abstractmethod
def get_constraint_ids(self) -> List[str]: def get_constraint_ids(self) -> List[str]:
@ -180,6 +185,13 @@ class InternalSolver(ABC):
""" """
pass 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 @abstractmethod
def extract_constraint(self, cid: str) -> Constraint: def extract_constraint(self, cid: str) -> Constraint:
""" """

@ -125,18 +125,22 @@ class LearningSolver:
) -> LearningSolveStats: ) -> LearningSolveStats:
# Generate model # Generate model
# -------------------------------------------------------
if model is None: if model is None:
with _RedirectOutput([]): with _RedirectOutput([]):
model = instance.to_model() model = instance.to_model()
# Initialize training sample # Initialize training sample
# -------------------------------------------------------
training_sample = TrainingSample() training_sample = TrainingSample()
instance.training_data += [training_sample] instance.training_data += [training_sample]
# Initialize stats # Initialize stats
# -------------------------------------------------------
stats: LearningSolveStats = {} stats: LearningSolveStats = {}
# Initialize internal solver # Initialize internal solver
# -------------------------------------------------------
self.tee = tee self.tee = tee
self.internal_solver = self.solver_factory() self.internal_solver = self.solver_factory()
assert self.internal_solver is not None assert self.internal_solver is not None
@ -144,6 +148,7 @@ class LearningSolver:
self.internal_solver.set_instance(instance, model) self.internal_solver.set_instance(instance, model)
# Extract features # Extract features
# -------------------------------------------------------
FeaturesExtractor(self.internal_solver).extract(instance) FeaturesExtractor(self.internal_solver).extract(instance)
callback_args = ( callback_args = (
@ -156,6 +161,7 @@ class LearningSolver:
) )
# Solve root LP relaxation # Solve root LP relaxation
# -------------------------------------------------------
if self.solve_lp: if self.solve_lp:
logger.debug("Running before_solve_lp callbacks...") logger.debug("Running before_solve_lp callbacks...")
for component in self.components.values(): for component in self.components.values():
@ -172,37 +178,50 @@ class LearningSolver:
for component in self.components.values(): for component in self.components.values():
component.after_solve_lp(*callback_args) component.after_solve_lp(*callback_args)
# Define wrappers # Callback wrappers
# -------------------------------------------------------
def iteration_cb_wrapper() -> bool: def iteration_cb_wrapper() -> bool:
should_repeat = False should_repeat = False
assert isinstance(instance, Instance)
for comp in self.components.values(): for comp in self.components.values():
if comp.iteration_cb(self, instance, model): if comp.iteration_cb(self, instance, model):
should_repeat = True should_repeat = True
return should_repeat return should_repeat
def lazy_cb_wrapper( def lazy_cb_wrapper(
cb_solver: LearningSolver, cb_solver: InternalSolver,
cb_model: Any, cb_model: Any,
) -> None: ) -> None:
assert isinstance(instance, Instance)
for comp in self.components.values(): for comp in self.components.values():
comp.lazy_cb(self, instance, model) 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 lazy_cb = None
if self.use_lazy_cb: if self.use_lazy_cb:
lazy_cb = lazy_cb_wrapper lazy_cb = lazy_cb_wrapper
user_cut_cb = None
if instance.has_user_cuts():
user_cut_cb = user_cut_cb_wrapper
# Before-solve callbacks # Before-solve callbacks
# -------------------------------------------------------
logger.debug("Running before_solve_mip callbacks...") logger.debug("Running before_solve_mip callbacks...")
for component in self.components.values(): for component in self.components.values():
component.before_solve_mip(*callback_args) component.before_solve_mip(*callback_args)
# Solve MIP # Solve MIP
# -------------------------------------------------------
logger.info("Solving MIP...") logger.info("Solving MIP...")
mip_stats = self.internal_solver.solve( mip_stats = self.internal_solver.solve(
tee=tee, tee=tee,
iteration_cb=iteration_cb_wrapper, iteration_cb=iteration_cb_wrapper,
user_cut_cb=user_cut_cb,
lazy_cb=lazy_cb, lazy_cb=lazy_cb,
) )
stats.update(cast(LearningSolveStats, mip_stats)) stats.update(cast(LearningSolveStats, mip_stats))
@ -216,17 +235,20 @@ class LearningSolver:
stats["Mode"] = self.mode stats["Mode"] = self.mode
# Add some information to training_sample # Add some information to training_sample
# -------------------------------------------------------
training_sample.lower_bound = stats["Lower bound"] training_sample.lower_bound = stats["Lower bound"]
training_sample.upper_bound = stats["Upper bound"] training_sample.upper_bound = stats["Upper bound"]
training_sample.mip_log = stats["MIP log"] training_sample.mip_log = stats["MIP log"]
training_sample.solution = self.internal_solver.get_solution() training_sample.solution = self.internal_solver.get_solution()
# After-solve callbacks # After-solve callbacks
# -------------------------------------------------------
logger.debug("Calling after_solve_mip callbacks...") logger.debug("Calling after_solve_mip callbacks...")
for component in self.components.values(): for component in self.components.values():
component.after_solve_mip(*callback_args) component.after_solve_mip(*callback_args)
# Write to file, if necessary # Flush
# -------------------------------------------------------
if not discard_output: if not discard_output:
instance.flush() instance.flush()

@ -23,7 +23,7 @@ from miplearn.solvers.internal import (
LazyCallback, LazyCallback,
MIPSolveStats, MIPSolveStats,
) )
from miplearn.types import VarIndex, SolverParams, Solution from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -81,9 +81,12 @@ class BasePyomoSolver(InternalSolver):
tee: bool = False, tee: bool = False,
iteration_cb: IterationCallback = None, iteration_cb: IterationCallback = None,
lazy_cb: LazyCallback = None, lazy_cb: LazyCallback = None,
user_cut_cb: UserCutCallback = None,
) -> MIPSolveStats: ) -> MIPSolveStats:
if lazy_cb is not None: 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 total_wallclock_time = 0
streams: List[Any] = [StringIO()] streams: List[Any] = [StringIO()]
if tee: if tee:
@ -318,19 +321,19 @@ class BasePyomoSolver(InternalSolver):
return {} return {}
def set_constraint_sense(self, cid: str, sense: str) -> None: def set_constraint_sense(self, cid: str, sense: str) -> None:
raise Exception("Not implemented") raise NotImplementedError()
def extract_constraint(self, cid: str) -> Constraint: 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: def is_constraint_satisfied(self, cobj: Constraint, tol: float = 1e-6) -> bool:
raise Exception("Not implemented") raise NotImplementedError()
def is_infeasible(self) -> bool: def is_infeasible(self) -> bool:
return self._termination_condition == TerminationCondition.infeasible return self._termination_condition == TerminationCondition.infeasible
def get_dual(self, cid): def get_dual(self, cid):
raise Exception("Not implemented") raise NotImplementedError()
def get_sense(self) -> str: def get_sense(self) -> str:
return self._obj_sense return self._obj_sense

@ -2,10 +2,13 @@
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # 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 from mypy_extensions import TypedDict
if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver
VarIndex = Union[str, int, Tuple[Union[str, int]]] VarIndex = Union[str, int, Tuple[Union[str, int]]]
Solution = Dict[str, Dict[VarIndex, Optional[float]]] Solution = Dict[str, Dict[VarIndex, Optional[float]]]
@ -64,6 +67,8 @@ IterationCallback = Callable[[], bool]
LazyCallback = Callable[[Any, Any], None] LazyCallback = Callable[[Any, Any], None]
UserCutCallback = Callable[["InternalSolver", Any], None]
SolverParams = Dict[str, Any] SolverParams = Dict[str, Any]
BranchPriorities = Solution BranchPriorities = Solution

@ -1,31 +1,77 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # 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 import pytest
from gurobipy import GRB
from networkx import Graph from networkx import Graph
import networkx as nx
from scipy.stats import randint
from miplearn import Instance from miplearn import Instance, LearningSolver, GurobiSolver
from miplearn.problems.stab import MaxWeightStableSetGenerator from miplearn.components.user_cuts import UserCutsComponentNG
logger = logging.getLogger(__name__)
class GurobiStableSetProblem(Instance): class GurobiStableSetProblem(Instance):
def __init__(self, graph: Graph) -> None: def __init__(self, graph: Graph) -> None:
super().__init__() super().__init__()
self.graph = graph self.graph = graph
self.nodes = list(self.graph.nodes)
def to_model(self) -> Any: 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 @pytest.fixture
def instance() -> Instance: def stab_instance() -> Instance:
graph = nx.generators.random_graphs.binomial_graph(50, 0.5) graph = nx.generators.random_graphs.binomial_graph(50, 0.50, seed=42)
return GurobiStableSetProblem(graph) return GurobiStableSetProblem(graph)
def test_usage(instance: Instance) -> None: @pytest.fixture
pass 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

Loading…
Cancel
Save