mirror of
https://github.com/ANL-CEEESA/MIPLearn.git
synced 2025-12-06 09:28:51 -06:00
Redesign InternalSolver constraint methods
This commit is contained in:
@@ -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,16 +280,13 @@ 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 None
|
|
||||||
else:
|
|
||||||
return var.x
|
return var.x
|
||||||
else:
|
else:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
@@ -305,35 +294,40 @@ class GurobiSolver(InternalSolver):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@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)
|
||||||
|
elif constr.sense == "<":
|
||||||
|
self.model.addConstr(lhs <= constr.rhs, name=name)
|
||||||
else:
|
else:
|
||||||
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:
|
|
||||||
raise Exception(f"unknown constraint type: {cobj.__class__.__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
|
|
||||||
|
|
||||||
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) -> Any:
|
|
||||||
"""
|
|
||||||
Removes a given constraint from the model and returns an object `cobj` which
|
|
||||||
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 remove_constraint(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Removes the constraint that has a given name from the model.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user