Redesign InternalSolver constraint methods

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

@ -76,6 +76,7 @@ class DynamicLazyConstraintsComponent(Component):
instance: Instance, instance: Instance,
model: Any, model: Any,
) -> bool: ) -> bool:
assert solver.internal_solver is not None
logger.debug("Finding violated lazy constraints...") logger.debug("Finding violated lazy constraints...")
cids = instance.find_violated_lazy_constraints(solver.internal_solver, model) cids = instance.find_violated_lazy_constraints(solver.internal_solver, model)
if len(cids) == 0: if len(cids) == 0:

@ -53,8 +53,7 @@ class UserCutsComponent(Component):
cids = self.dynamic.sample_predict(instance, training_data) cids = self.dynamic.sample_predict(instance, training_data)
logger.info("Enforcing %d user cuts ahead-of-time..." % len(cids)) logger.info("Enforcing %d user cuts ahead-of-time..." % len(cids))
for cid in cids: for cid in cids:
cobj = instance.build_user_cut(model, cid) instance.enforce_user_cut(solver.internal_solver, model, cid)
solver.internal_solver.add_constraint(cobj)
stats["UserCuts: Added ahead-of-time"] = len(cids) stats["UserCuts: Added ahead-of-time"] = len(cids)
@overrides @overrides
@ -73,9 +72,7 @@ class UserCutsComponent(Component):
if cid in self.enforced: if cid in self.enforced:
continue continue
assert isinstance(cid, Hashable) assert isinstance(cid, Hashable)
cobj = instance.build_user_cut(model, cid) instance.enforce_user_cut(solver.internal_solver, model, cid)
assert cobj is not None
solver.internal_solver.add_cut(cobj)
self.enforced.add(cid) self.enforced.add(cid)
self.n_added_in_callback += 1 self.n_added_in_callback += 1
if len(cids) > 0: if len(cids) > 0:

@ -12,7 +12,7 @@ from miplearn.classifiers import Classifier
from miplearn.classifiers.counting import CountingClassifier from miplearn.classifiers.counting import CountingClassifier
from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold
from miplearn.components.component import Component from miplearn.components.component import Component
from miplearn.features import TrainingSample, Features from miplearn.features import TrainingSample, Features, Constraint
from miplearn.types import LearningSolveStats from miplearn.types import LearningSolveStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,7 +44,7 @@ class StaticLazyConstraintsComponent(Component):
self.threshold_prototype: Threshold = threshold self.threshold_prototype: Threshold = threshold
self.classifiers: Dict[Hashable, Classifier] = {} self.classifiers: Dict[Hashable, Classifier] = {}
self.thresholds: Dict[Hashable, Threshold] = {} self.thresholds: Dict[Hashable, Threshold] = {}
self.pool: Dict[str, LazyConstraint] = {} self.pool: Dict[str, Constraint] = {}
self.violation_tolerance: float = violation_tolerance self.violation_tolerance: float = violation_tolerance
self.enforced_cids: Set[Hashable] = set() self.enforced_cids: Set[Hashable] = set()
self.n_restored: int = 0 self.n_restored: int = 0
@ -72,10 +72,8 @@ class StaticLazyConstraintsComponent(Component):
self.pool = {} self.pool = {}
for (cid, cdict) in features.constraints.items(): for (cid, cdict) in features.constraints.items():
if cdict.lazy and cid not in self.enforced_cids: if cdict.lazy and cid not in self.enforced_cids:
self.pool[cid] = LazyConstraint( self.pool[cid] = cdict
cid=cid, solver.internal_solver.remove_constraint(cid)
obj=solver.internal_solver.extract_constraint(cid),
)
logger.info( logger.info(
f"{len(self.enforced_cids)} lazy constraints kept; " f"{len(self.enforced_cids)} lazy constraints kept; "
f"{len(self.pool)} moved to the pool" f"{len(self.pool)} moved to the pool"
@ -124,18 +122,18 @@ class StaticLazyConstraintsComponent(Component):
def _check_and_add(self, solver: "LearningSolver") -> bool: def _check_and_add(self, solver: "LearningSolver") -> bool:
assert solver.internal_solver is not None assert solver.internal_solver is not None
logger.info("Finding violated lazy constraints...") logger.info("Finding violated lazy constraints...")
enforced: List[LazyConstraint] = [] enforced: Dict[str, Constraint] = {}
for (cid, c) in self.pool.items(): for (cid, c) in self.pool.items():
if not solver.internal_solver.is_constraint_satisfied( if not solver.internal_solver.is_constraint_satisfied(
c.obj, c,
tol=self.violation_tolerance, tol=self.violation_tolerance,
): ):
enforced.append(c) enforced[cid] = c
logger.info(f"{len(enforced)} violations found") logger.info(f"{len(enforced)} violations found")
for c in enforced: for (cid, c) in enforced.items():
del self.pool[c.cid] del self.pool[cid]
solver.internal_solver.add_constraint(c.obj) solver.internal_solver.add_constraint(c, name=cid)
self.enforced_cids.add(c.cid) self.enforced_cids.add(cid)
self.n_restored += 1 self.n_restored += 1
logger.info( logger.info(
f"{len(enforced)} constraints restored; {len(self.pool)} in the pool" f"{len(enforced)} constraints restored; {len(self.pool)} in the pool"

@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver from miplearn.solvers.learning import InternalSolver
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class Instance(ABC, EnforceOverrides): class Instance(ABC, EnforceOverrides):
""" """
@ -170,7 +171,12 @@ class Instance(ABC, EnforceOverrides):
def find_violated_user_cuts(self, model: Any) -> List[Hashable]: def find_violated_user_cuts(self, model: Any) -> List[Hashable]:
return [] return []
def build_user_cut(self, model: Any, violation: Hashable) -> Any: def enforce_user_cut(
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
) -> Any:
return None return None
def load(self) -> None: def load(self) -> None:

@ -106,9 +106,14 @@ class PickleGzInstance(Instance):
return self.instance.find_violated_user_cuts(model) return self.instance.find_violated_user_cuts(model)
@overrides @overrides
def build_user_cut(self, model: Any, violation: Hashable) -> None: def enforce_user_cut(
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
) -> None:
assert self.instance is not None assert self.instance is not None
self.instance.build_user_cut(model, violation) self.instance.enforce_user_cut(solver, model, violation)
@overrides @overrides
def load(self) -> None: def load(self) -> None:

@ -11,7 +11,7 @@ from scipy.spatial.distance import pdist, squareform
from scipy.stats import uniform, randint from scipy.stats import uniform, randint
from scipy.stats.distributions import rv_frozen from scipy.stats.distributions import rv_frozen
from miplearn import InternalSolver from miplearn import InternalSolver, BasePyomoSolver
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.types import VariableName, Category from miplearn.types import VariableName, Category
@ -108,14 +108,15 @@ class TravelingSalesmanInstance(Instance):
model: Any, model: Any,
component: FrozenSet, component: FrozenSet,
) -> None: ) -> None:
assert isinstance(solver, BasePyomoSolver)
cut_edges = [ cut_edges = [
e e
for e in self.edges for e in self.edges
if (e[0] in component and e[1] not in component) if (e[0] in component and e[1] not in component)
or (e[0] not in component and e[1] in component) or (e[0] not in component and e[1] in component)
] ]
constr = model.eq_subtour.add(sum(model.x[e] for e in cut_edges) >= 2) constr = model.eq_subtour.add(expr=sum(model.x[e] for e in cut_edges) >= 2)
solver.add_constraint(constr) solver.add_constraint(constr, name="")
class TravelingSalesmanGenerator: class TravelingSalesmanGenerator:

@ -32,14 +32,6 @@ from miplearn.types import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class ExtractedGurobiConstraint:
lhs: Any
rhs: float
sense: str
name: str
class GurobiSolver(InternalSolver): class GurobiSolver(InternalSolver):
""" """
An InternalSolver backed by Gurobi's Python API (without Pyomo). An InternalSolver backed by Gurobi's Python API (without Pyomo).
@ -72,10 +64,10 @@ class GurobiSolver(InternalSolver):
self.instance: Optional[Instance] = None self.instance: Optional[Instance] = None
self.model: Optional["gurobipy.Model"] = None self.model: Optional["gurobipy.Model"] = None
self.params: SolverParams = params self.params: SolverParams = params
self.varname_to_var: Dict[str, "gurobipy.Var"] = {}
self.bin_vars: List["gurobipy.Var"] = []
self.cb_where: Optional[int] = None self.cb_where: Optional[int] = None
self.lazy_cb_frequency = lazy_cb_frequency self.lazy_cb_frequency = lazy_cb_frequency
self._bin_vars: List["gurobipy.Var"] = []
self._varname_to_var: Dict[str, "gurobipy.Var"] = {}
if self.lazy_cb_frequency == 1: if self.lazy_cb_frequency == 1:
self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL] self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL]
@ -106,20 +98,20 @@ class GurobiSolver(InternalSolver):
def _update_vars(self) -> None: def _update_vars(self) -> None:
assert self.model is not None assert self.model is not None
self.varname_to_var.clear() self._varname_to_var.clear()
self.bin_vars.clear() self._bin_vars.clear()
for var in self.model.getVars(): for var in self.model.getVars():
assert var.varName not in self.varname_to_var, ( assert var.varName not in self._varname_to_var, (
f"Duplicated variable name detected: {var.varName}. " f"Duplicated variable name detected: {var.varName}. "
f"Unique variable names are currently required." f"Unique variable names are currently required."
) )
self.varname_to_var[var.varName] = var self._varname_to_var[var.varName] = var
assert var.vtype in ["B", "C"], ( assert var.vtype in ["B", "C"], (
"Only binary and continuous variables are currently supported. " "Only binary and continuous variables are currently supported. "
"Variable {var.varName} has type {var.vtype}." "Variable {var.varName} has type {var.vtype}."
) )
if var.vtype == "B": if var.vtype == "B":
self.bin_vars.append(var) self._bin_vars.append(var)
def _apply_params(self, streams: List[Any]) -> None: def _apply_params(self, streams: List[Any]) -> None:
assert self.model is not None assert self.model is not None
@ -138,13 +130,13 @@ class GurobiSolver(InternalSolver):
streams += [sys.stdout] streams += [sys.stdout]
self._apply_params(streams) self._apply_params(streams)
assert self.model is not None assert self.model is not None
for var in self.bin_vars: for var in self._bin_vars:
var.vtype = self.gp.GRB.CONTINUOUS var.vtype = self.gp.GRB.CONTINUOUS
var.lb = 0.0 var.lb = 0.0
var.ub = 1.0 var.ub = 1.0
with _RedirectOutput(streams): with _RedirectOutput(streams):
self.model.optimize() self.model.optimize()
for var in self.bin_vars: for var in self._bin_vars:
var.vtype = self.gp.GRB.BINARY var.vtype = self.gp.GRB.BINARY
log = streams[0].getvalue() log = streams[0].getvalue()
opt_value = None opt_value = None
@ -262,7 +254,7 @@ class GurobiSolver(InternalSolver):
self._raise_if_callback() self._raise_if_callback()
self._clear_warm_start() self._clear_warm_start()
for (var_name, value) in solution.items(): for (var_name, value) in solution.items():
var = self.varname_to_var[var_name] var = self._varname_to_var[var_name]
if value is not None: if value is not None:
var.start = value var.start = value
@ -288,52 +280,54 @@ class GurobiSolver(InternalSolver):
else: else:
return c.pi return c.pi
def _get_value(self, var: Any) -> Optional[float]: def _get_value(self, var: Any) -> float:
assert self.model is not None assert self.model is not None
if self.cb_where == self.gp.GRB.Callback.MIPSOL: if self.cb_where == self.gp.GRB.Callback.MIPSOL:
return self.model.cbGetSolution(var) return self.model.cbGetSolution(var)
elif self.cb_where == self.gp.GRB.Callback.MIPNODE: elif self.cb_where == self.gp.GRB.Callback.MIPNODE:
return self.model.cbGetNodeRel(var) return self.model.cbGetNodeRel(var)
elif self.cb_where is None: elif self.cb_where is None:
if self.is_infeasible(): return var.x
return None
else:
return var.x
else: else:
raise Exception( raise Exception(
"get_value cannot be called from cb_where=%s" % self.cb_where "get_value cannot be called from cb_where=%s" % self.cb_where
) )
@overrides @overrides
def add_constraint(self, cobj: Any, name: str = "") -> None: def add_constraint(self, constr: Constraint, name: str) -> None:
assert self.model is not None assert self.model is not None
if isinstance(cobj, ExtractedGurobiConstraint): lhs = self.gp.quicksum(
if self.cb_where in [ self._varname_to_var[varname] * coeff
self.gp.GRB.Callback.MIPSOL, for (varname, coeff) in constr.lhs.items()
self.gp.GRB.Callback.MIPNODE, )
]: if constr.sense == "=":
self.model.cbLazy(cobj.lhs, cobj.sense, cobj.rhs) self.model.addConstr(lhs == constr.rhs, name=name)
else: elif constr.sense == "<":
self.model.addConstr(cobj.lhs, cobj.sense, cobj.rhs, cobj.name) self.model.addConstr(lhs <= constr.rhs, name=name)
elif isinstance(cobj, self.gp.TempConstr):
if self.cb_where in [
self.gp.GRB.Callback.MIPSOL,
self.gp.GRB.Callback.MIPNODE,
]:
self.model.cbLazy(cobj)
else:
self.model.addConstr(cobj, name=name)
else: else:
raise Exception(f"unknown constraint type: {cobj.__class__.__name__}") self.model.addConstr(lhs >= constr.rhs, name=name)
@overrides @overrides
def add_cut(self, cobj: Any) -> None: def remove_constraint(self, name: str) -> None:
assert self.model is not None assert self.model is not None
assert self.cb_where == self.gp.GRB.Callback.MIPNODE constr = self.model.getConstrByName(name)
self.model.cbCut(cobj) self.model.remove(constr)
@overrides
def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool:
lhs = 0.0
for (varname, coeff) in constr.lhs.items():
var = self._varname_to_var[varname]
lhs += self._get_value(var) * coeff
if constr.sense == "<":
return lhs <= constr.rhs + tol
elif constr.sense == ">":
return lhs >= constr.rhs - tol
else:
return abs(constr.rhs - lhs) < abs(tol)
def _clear_warm_start(self) -> None: def _clear_warm_start(self) -> None:
for var in self.varname_to_var.values(): for var in self._varname_to_var.values():
var.start = self.gp.GRB.UNDEFINED var.start = self.gp.GRB.UNDEFINED
@overrides @overrides
@ -342,50 +336,11 @@ class GurobiSolver(InternalSolver):
for (varname, value) in solution.items(): for (varname, value) in solution.items():
if value is None: if value is None:
continue continue
var = self.varname_to_var[varname] var = self._varname_to_var[varname]
var.vtype = self.gp.GRB.CONTINUOUS var.vtype = self.gp.GRB.CONTINUOUS
var.lb = value var.lb = value
var.ub = value var.ub = value
@overrides
def extract_constraint(self, cid: str) -> ExtractedGurobiConstraint:
self._raise_if_callback()
assert self.model is not None
constr = self.model.getConstrByName(cid)
cobj = ExtractedGurobiConstraint(
lhs=self.model.getRow(constr),
sense=constr.sense,
rhs=constr.RHS,
name=constr.ConstrName,
)
self.model.remove(constr)
return cobj
@overrides
def is_constraint_satisfied(
self,
cobj: ExtractedGurobiConstraint,
tol: float = 1e-6,
) -> bool:
assert isinstance(cobj, ExtractedGurobiConstraint)
lhs, sense, rhs, _ = cobj.lhs, cobj.sense, cobj.rhs, cobj.name
if self.cb_where is not None:
lhs_value = lhs.getConstant()
for i in range(lhs.size()):
var = lhs.getVar(i)
coeff = lhs.getCoeff(i)
lhs_value += self._get_value(var) * coeff
else:
lhs_value = lhs.getValue()
if sense == "<":
return lhs_value <= rhs + tol
elif sense == ">":
return lhs_value >= rhs - tol
elif sense == "=":
return abs(rhs - lhs_value) < abs(tol)
else:
raise Exception("Unknown sense: %s" % sense)
@overrides @overrides
def get_inequality_slacks(self) -> Dict[str, float]: def get_inequality_slacks(self) -> Dict[str, float]:
assert self.model is not None assert self.model is not None
@ -545,4 +500,5 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack):
model: Any, model: Any,
violation: Hashable, violation: Hashable,
) -> None: ) -> None:
solver.add_constraint(model.getVarByName("x[0]") <= 0, name="cut") x0 = model.getVarByName("x[0]")
model.cbLazy(x0 <= 0)

@ -6,6 +6,8 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from overrides import EnforceOverrides
from miplearn.features import Constraint from miplearn.features import Constraint
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.types import ( from miplearn.types import (
@ -22,7 +24,7 @@ from miplearn.types import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class InternalSolver(ABC): class InternalSolver(ABC, EnforceOverrides):
""" """
Abstract class representing the MIP solver used internally by LearningSolver. Abstract class representing the MIP solver used internally by LearningSolver.
""" """
@ -155,31 +157,21 @@ class InternalSolver(ABC):
pass pass
@abstractmethod @abstractmethod
def add_constraint(self, cobj: Any, name: str = "") -> None: def add_constraint(self, constr: Constraint, name: str) -> None:
""" """
Adds a single constraint to the model. Adds a given constraint to the model.
""" """
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) -> Any: def remove_constraint(self, name: str) -> None:
""" """
Removes a given constraint from the model and returns an object `cobj` which Removes the constraint that has a given name from the model.
can be used to verify if the removed constraint is still satisfied by
the current solution, using `is_constraint_satisfied(cobj)`, and can potentially
be re-added to the model using `add_constraint(cobj)`.
""" """
pass pass
@abstractmethod @abstractmethod
def is_constraint_satisfied(self, cobj: Any, tol: float = 1e-6) -> bool: def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool:
""" """
Returns True if the current solution satisfies the given constraint. Returns True if the current solution satisfies the given constraint.
""" """

@ -6,14 +6,15 @@ import logging
import re import re
import sys import sys
from io import StringIO from io import StringIO
from typing import Any, List, Dict, Optional, Hashable from typing import Any, List, Dict, Optional
import numpy as np
import pyomo import pyomo
from overrides import overrides from overrides import overrides
from pyomo import environ as pe from pyomo import environ as pe
from pyomo.core import Var from pyomo.core import Var
from pyomo.core.base import _GeneralVarData from pyomo.core.base import _GeneralVarData
from pyomo.core.base.constraint import SimpleConstraint, ConstraintList from pyomo.core.base.constraint import ConstraintList
from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression
from pyomo.opt import TerminationCondition from pyomo.opt import TerminationCondition
from pyomo.opt.base.solvers import SolverFactory from pyomo.opt.base.solvers import SolverFactory
@ -35,7 +36,6 @@ from miplearn.types import (
VariableName, VariableName,
Category, Category,
) )
import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -215,7 +215,7 @@ class BasePyomoSolver(InternalSolver):
def _update_constrs(self) -> None: def _update_constrs(self) -> None:
assert self.model is not None assert self.model is not None
self._cname_to_constr = {} self._cname_to_constr.clear()
for constr in self.model.component_objects(pyomo.core.Constraint): for constr in self.model.component_objects(pyomo.core.Constraint):
if isinstance(constr, pe.ConstraintList): if isinstance(constr, pe.ConstraintList):
for idx in constr: for idx in constr:
@ -233,24 +233,50 @@ class BasePyomoSolver(InternalSolver):
self._pyomo_solver.update_var(var) self._pyomo_solver.update_var(var)
@overrides @overrides
def add_constraint(self, cobj: Any, name: str = "") -> Any: def add_constraint(
self,
constr: Any,
name: str,
) -> None:
assert self.model is not None assert self.model is not None
if isinstance(cobj, Constraint): if isinstance(constr, Constraint):
lhs = 0.0 lhs = 0.0
for (varname, coeff) in cobj.lhs.items(): for (varname, coeff) in constr.lhs.items():
var = self._varname_to_var[varname] var = self._varname_to_var[varname]
lhs += var * coeff lhs += var * coeff
if cobj.sense == "=": if constr.sense == "=":
expr = lhs == cobj.rhs expr = lhs == constr.rhs
elif cobj.sense == "<": elif constr.sense == "<":
expr = lhs <= cobj.rhs expr = lhs <= constr.rhs
else: else:
expr = lhs >= cobj.rhs expr = lhs >= constr.rhs
cl = self.model.extra_constraints cl = pe.Constraint(expr=expr, name=name)
self._pyomo_solver.add_constraint(cl.add(expr)) self.model.add_component(name, cl)
self._pyomo_solver.add_constraint(cl)
self._cname_to_constr[name] = cl
else: else:
self._pyomo_solver.add_constraint(cobj) self._pyomo_solver.add_constraint(constr)
self._update_constrs()
@overrides
def remove_constraint(self, name: str) -> None:
assert self.model is not None
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 is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool:
lhs = 0.0
for (varname, coeff) in constr.lhs.items():
var = self._varname_to_var[varname]
lhs += var.value * coeff
if constr.sense == "<":
return lhs <= constr.rhs + tol
elif constr.sense == ">":
return lhs >= constr.rhs - tol
else:
return abs(constr.rhs - lhs) < abs(tol)
@staticmethod @staticmethod
def __extract( def __extract(
@ -304,27 +330,6 @@ class BasePyomoSolver(InternalSolver):
result[cname] = cobj.slack() result[cname] = cobj.slack()
return result return result
@overrides
def extract_constraint(self, cid: str) -> Any:
cobj = self._cname_to_constr[cid]
constr = self._parse_pyomo_constraint(cobj)
self._pyomo_solver.remove_constraint(cobj)
return constr
@overrides
def is_constraint_satisfied(self, cobj: Any, tol: float = 1e-6) -> bool:
assert isinstance(cobj, Constraint)
lhs_value = 0.0
for (varname, coeff) in cobj.lhs.items():
var = self._varname_to_var[varname]
lhs_value += var.value * coeff
if cobj.sense == "=":
return (lhs_value <= cobj.rhs + tol) and (lhs_value >= cobj.rhs - tol)
elif cobj.sense == "<":
return lhs_value <= cobj.rhs + tol
else:
return lhs_value >= cobj.rhs - tol
@overrides @overrides
def is_infeasible(self) -> bool: def is_infeasible(self) -> bool:
return self._termination_condition == TerminationCondition.infeasible return self._termination_condition == TerminationCondition.infeasible
@ -411,6 +416,7 @@ class BasePyomoSolver(InternalSolver):
sense=sense, sense=sense,
) )
@overrides
def are_callbacks_supported(self) -> bool: def are_callbacks_supported(self) -> bool:
return False return False
@ -483,13 +489,3 @@ class PyomoTestInstanceKnapsack(Instance):
self.weights[item], self.weights[item],
self.prices[item], self.prices[item],
] ]
@overrides
def enforce_lazy_constraint(
self,
solver: InternalSolver,
model: Any,
violation: Hashable,
) -> None:
model.cut = pe.Constraint(expr=model.x[0] <= 0.0, name="cut")
solver.add_constraint(model.cut)

@ -75,39 +75,29 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_constraints(), solver.get_constraints(),
{ {
"eq_capacity": Constraint( "eq_capacity": Constraint(
lhs={ lhs={"x[0]": 23.0, "x[1]": 26.0, "x[2]": 20.0, "x[3]": 18.0},
"x[0]": 23.0,
"x[1]": 26.0,
"x[2]": 20.0,
"x[3]": 18.0,
},
rhs=67.0, rhs=67.0,
sense="<", sense="<",
), ),
}, },
) )
# Add a brand new constraint # Build a new constraint
instance.enforce_lazy_constraint(solver, model, "cut") cut = Constraint(lhs={"x[0]": 1.0}, sense="<", rhs=0.0)
assert not solver.is_constraint_satisfied(cut)
# New constraint should be listed # Add new constraint and verify that it is listed
solver.add_constraint(cut, "cut")
assert_equals( assert_equals(
solver.get_constraints(), solver.get_constraints(),
{ {
"eq_capacity": Constraint( "eq_capacity": Constraint(
lhs={ lhs={"x[0]": 23.0, "x[1]": 26.0, "x[2]": 20.0, "x[3]": 18.0},
"x[0]": 23.0,
"x[1]": 26.0,
"x[2]": 20.0,
"x[3]": 18.0,
},
rhs=67.0, rhs=67.0,
sense="<", sense="<",
), ),
"cut": Constraint( "cut": Constraint(
lhs={ lhs={"x[0]": 1.0},
"x[0]": 1.0,
},
rhs=0.0, rhs=0.0,
sense="<", sense="<",
), ),
@ -117,35 +107,23 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
# New constraint should affect the solution # New constraint should affect the solution
stats = solver.solve() stats = solver.solve()
assert_equals(stats["Lower bound"], 1030.0) assert_equals(stats["Lower bound"], 1030.0)
assert solver.is_constraint_satisfied(cut)
# Verify slacks # Verify slacks
assert_equals( assert_equals(
solver.get_inequality_slacks(), solver.get_inequality_slacks(),
{ {"cut": 0.0, "eq_capacity": 3.0},
"cut": 0.0,
"eq_capacity": 3.0,
},
) )
# # Extract the new constraint # Remove the new constraint
cobj = solver.extract_constraint("cut") solver.remove_constraint("cut")
# New constraint should no longer affect solution # New constraint should no longer affect solution
stats = solver.solve() stats = solver.solve()
assert_equals(stats["Lower bound"], 1183.0) assert_equals(stats["Lower bound"], 1183.0)
# New constraint should not be satisfied by current solution # Constraint should not be satisfied by current solution
assert not solver.is_constraint_satisfied(cobj) assert not solver.is_constraint_satisfied(cut)
# Re-add constraint
solver.add_constraint(cobj)
# Constraint should affect solution again
stats = solver.solve()
assert_equals(stats["Lower bound"], 1030.0)
# New constraint should now be satisfied
assert solver.is_constraint_satisfied(cobj)
def run_warm_start_tests(solver: InternalSolver) -> None: def run_warm_start_tests(solver: InternalSolver) -> None:

@ -12,6 +12,7 @@ from gurobipy import GRB
from networkx import Graph from networkx import Graph
from overrides import overrides from overrides import overrides
from miplearn import InternalSolver
from miplearn.components.dynamic_user_cuts import UserCutsComponent from miplearn.components.dynamic_user_cuts import UserCutsComponent
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.gurobi import GurobiSolver
@ -49,10 +50,15 @@ class GurobiStableSetProblem(Instance):
return violations return violations
@overrides @overrides
def build_user_cut(self, model: Any, cid: Hashable) -> Any: def enforce_user_cut(
self,
solver: InternalSolver,
model: Any,
cid: Hashable,
) -> Any:
assert isinstance(cid, FrozenSet) assert isinstance(cid, FrozenSet)
x = model.getVars() x = model.getVars()
return gp.quicksum([x[i] for i in cid]) <= 1 model.addConstr(gp.quicksum([x[i] for i in cid]) <= 1)
@pytest.fixture @pytest.fixture

@ -78,12 +78,14 @@ def features() -> Features:
def test_usage_with_solver(instance: Instance) -> None: def test_usage_with_solver(instance: Instance) -> None:
assert instance.features is not None
assert instance.features.constraints is not None
solver = Mock(spec=LearningSolver) solver = Mock(spec=LearningSolver)
solver.use_lazy_cb = False solver.use_lazy_cb = False
solver.gap_tolerance = 1e-4 solver.gap_tolerance = 1e-4
internal = solver.internal_solver = Mock(spec=InternalSolver) internal = solver.internal_solver = Mock(spec=InternalSolver)
internal.extract_constraint = Mock(side_effect=lambda cid: "<%s>" % cid)
internal.is_constraint_satisfied = Mock(return_value=False) internal.is_constraint_satisfied = Mock(return_value=False)
component = StaticLazyConstraintsComponent(violation_tolerance=1.0) component = StaticLazyConstraintsComponent(violation_tolerance=1.0)
@ -128,8 +130,8 @@ def test_usage_with_solver(instance: Instance) -> None:
component.classifiers["type-b"].predict_proba.assert_called_once() component.classifiers["type-b"].predict_proba.assert_called_once()
# Should ask internal solver to remove some constraints # Should ask internal solver to remove some constraints
assert internal.extract_constraint.call_count == 1 assert internal.remove_constraint.call_count == 1
internal.extract_constraint.assert_has_calls([call("c3")]) internal.remove_constraint.assert_has_calls([call("c3")])
# LearningSolver calls after_iteration (first time) # LearningSolver calls after_iteration (first time)
should_repeat = component.iteration_cb(solver, instance, None) should_repeat = component.iteration_cb(solver, instance, None)
@ -137,9 +139,10 @@ def test_usage_with_solver(instance: Instance) -> None:
# Should ask internal solver to verify if constraints in the pool are # Should ask internal solver to verify if constraints in the pool are
# satisfied and add the ones that are not # satisfied and add the ones that are not
internal.is_constraint_satisfied.assert_called_once_with("<c3>", tol=1.0) c3 = instance.features.constraints["c3"]
internal.is_constraint_satisfied.assert_called_once_with(c3, tol=1.0)
internal.is_constraint_satisfied.reset_mock() internal.is_constraint_satisfied.reset_mock()
internal.add_constraint.assert_called_once_with("<c3>") internal.add_constraint.assert_called_once_with(c3, name="c3")
internal.add_constraint.reset_mock() internal.add_constraint.reset_mock()
# LearningSolver calls after_iteration (second time) # LearningSolver calls after_iteration (second time)

Loading…
Cancel
Save