Refactor StaticLazy; remove old constraint methods

This commit is contained in:
2021-05-15 14:15:48 -05:00
parent 53d3e9d98a
commit 91c8db2225
10 changed files with 343 additions and 583 deletions

View File

@@ -91,26 +91,6 @@ class GurobiSolver(InternalSolver):
self.gp.GRB.Callback.MIPNODE,
]
@overrides
def add_constraint(self, constr: Constraint, name: str) -> None:
assert self.model is not None
assert self._varname_to_var is not None
assert constr.lhs is not None
lhs = self.gp.quicksum(
self._varname_to_var[varname] * coeff
for (varname, coeff) in constr.lhs.items()
)
if constr.sense == "=":
self.model.addConstr(lhs == constr.rhs, name=name)
elif constr.sense == "<":
self.model.addConstr(lhs <= constr.rhs, name=name)
else:
self.model.addConstr(lhs >= constr.rhs, name=name)
self._dirty = True
self._has_lp_solution = False
self._has_mip_solution = False
@overrides
def add_constraints(self, cf: ConstraintFeatures) -> None:
assert cf.names is not None
@@ -143,7 +123,7 @@ class GurobiSolver(InternalSolver):
self,
cf: ConstraintFeatures,
tol: float = 1e-5,
) -> List[bool]:
) -> Tuple[bool, ...]:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
@@ -162,7 +142,7 @@ class GurobiSolver(InternalSolver):
result.append(lhs >= cf.rhs[i] - tol)
else:
result.append(abs(cf.rhs[i] - lhs) <= tol)
return result
return tuple(result)
@overrides
def build_test_instance_infeasible(self) -> Instance:
@@ -289,76 +269,6 @@ class GurobiSolver(InternalSolver):
slacks=slacks,
)
@overrides
def get_constraints_old(self, with_static: bool = True) -> Dict[str, Constraint]:
model = self.model
assert model is not None
self._raise_if_callback()
if self._dirty:
model.update()
self._dirty = False
gp_constrs = model.getConstrs()
constr_names = model.getAttr("constrName", gp_constrs)
lhs: Optional[List[Dict]] = None
rhs = None
sense = None
dual_value = None
sa_rhs_up = None
sa_rhs_down = None
slack = None
basis_status = None
if with_static:
var_names = model.getAttr("varName", model.getVars())
rhs = model.getAttr("rhs", gp_constrs)
sense = model.getAttr("sense", gp_constrs)
lhs = []
for (i, gp_constr) in enumerate(gp_constrs):
expr = model.getRow(gp_constr)
lhsi = {}
for j in range(expr.size()):
lhsi[var_names[expr.getVar(j).index]] = expr.getCoeff(j)
lhs.append(lhsi)
if self._has_lp_solution:
dual_value = model.getAttr("pi", gp_constrs)
sa_rhs_up = model.getAttr("saRhsUp", gp_constrs)
sa_rhs_down = model.getAttr("saRhsLow", gp_constrs)
basis_status = model.getAttr("cbasis", gp_constrs)
if self._has_lp_solution or self._has_mip_solution:
slack = model.getAttr("slack", gp_constrs)
constraints: Dict[str, Constraint] = {}
for (i, gp_constr) in enumerate(gp_constrs):
assert (
constr_names[i] not in constraints
), f"Duplicated constraint name detected: {constr_names[i]}"
constraint = Constraint()
if with_static:
assert lhs is not None
assert rhs is not None
assert sense is not None
constraint.lhs = lhs[i]
constraint.rhs = rhs[i]
constraint.sense = sense[i]
if dual_value is not None:
assert sa_rhs_up is not None
assert sa_rhs_down is not None
assert basis_status is not None
constraint.dual_value = dual_value[i]
constraint.sa_rhs_up = sa_rhs_up[i]
constraint.sa_rhs_down = sa_rhs_down[i]
if gp_constr.cbasis == 0:
constraint.basis_status = "B"
elif gp_constr.cbasis == -1:
constraint.basis_status = "N"
else:
raise Exception(f"unknown cbasis: {gp_constr.cbasis}")
if slack is not None:
constraint.slack = slack[i]
constraints[constr_names[i]] = constraint
return constraints
@overrides
def get_solution(self) -> Optional[Solution]:
assert self.model is not None
@@ -470,42 +380,6 @@ class GurobiSolver(InternalSolver):
values=values,
)
def is_constraint_satisfied(
self,
names: List[str],
tol: float = 1e-6,
) -> List[bool]:
def _check(c: Tuple) -> bool:
lhs, sense, rhs = c
lhs_value = lhs.getValue()
if sense == "=":
return abs(lhs_value - rhs) < tol
elif sense == ">":
return lhs_value > rhs - tol
else:
return lhs_value < rhs - tol
constrs = [self._relaxed_constrs[n] for n in names]
return list(map(_check, constrs))
@overrides
def is_constraint_satisfied_old(
self,
constr: Constraint,
tol: float = 1e-6,
) -> bool:
assert constr.lhs is not None
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)
@overrides
def is_infeasible(self) -> bool:
assert self.model is not None
@@ -521,13 +395,7 @@ class GurobiSolver(InternalSolver):
self.model.update()
@overrides
def remove_constraint(self, name: str) -> None:
assert self.model is not None
constr = self.model.getConstrByName(name)
self.model.remove(constr)
@overrides
def remove_constraints(self, names: List[str]) -> None:
def remove_constraints(self, names: Tuple[str, ...]) -> None:
assert self.model is not None
constrs = [self.model.getConstrByName(n) for n in names]
self.model.remove(constrs)

View File

@@ -5,7 +5,7 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from miplearn.features import Constraint, VariableFeatures, ConstraintFeatures
from miplearn.instance.base import Instance
@@ -51,21 +51,185 @@ class InternalSolver(ABC):
"""
@abstractmethod
def solve_lp(
self,
tee: bool = False,
) -> LPSolveStats:
"""
Solves the LP relaxation of the currently loaded instance. After this
method finishes, the solution can be retrieved by calling `get_solution`.
def add_constraints(self, cf: ConstraintFeatures) -> None:
"""Adds the given constraints to the model."""
pass
This method should not permanently modify the problem. That is, subsequent
calls to `solve` should solve the original MIP, not the LP relaxation.
@abstractmethod
def are_constraints_satisfied(
self,
cf: ConstraintFeatures,
tol: float = 1e-5,
) -> Tuple[bool, ...]:
"""
Checks whether the current solution satisfies the given constraints.
"""
pass
def are_callbacks_supported(self) -> bool:
"""
Returns True if this solver supports native callbacks, such as lazy constraints
callback or user cuts callback.
"""
return False
@abstractmethod
def build_test_instance_infeasible(self) -> Instance:
pass
@abstractmethod
def build_test_instance_knapsack(self) -> Instance:
"""
Returns an instance corresponding to the following MIP, for testing purposes:
maximize 505 x0 + 352 x1 + 458 x2 + 220 x3
s.t. eq_capacity: z = 23 x0 + 26 x1 + 20 x2 + 18 x3
x0, x1, x2, x3 binary
0 <= z <= 67 continuous
"""
pass
@abstractmethod
def build_test_instance_redundancy(self) -> Instance:
pass
@abstractmethod
def clone(self) -> "InternalSolver":
"""
Returns a new copy of this solver with identical parameters, but otherwise
completely unitialized.
"""
pass
@abstractmethod
def fix(self, solution: Solution) -> None:
"""
Fixes the values of a subset of decision variables. Missing values in the
solution indicate variables that should be left free.
"""
pass
@abstractmethod
def get_solution(self) -> Optional[Solution]:
"""
Returns current solution found by the solver.
If called after `solve`, returns the best primal solution found during
the search. If called after `solve_lp`, returns the optimal solution
to the LP relaxation. If no primal solution is available, return None.
"""
pass
@abstractmethod
def get_constraint_attrs(self) -> List[str]:
"""
Returns a list of constraint attributes supported by this solver. Used for
testing purposes only.
"""
pass
@abstractmethod
def get_constraints(
self,
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> ConstraintFeatures:
pass
@abstractmethod
def get_variable_attrs(self) -> List[str]:
"""
Returns a list of variable attributes supported by this solver. Used for
testing purposes only.
"""
pass
@abstractmethod
def get_variables(
self,
with_static: bool = True,
with_sa: bool = True,
) -> VariableFeatures:
"""
Returns a description of the decision variables in the problem.
Parameters
----------
tee
If true, prints the solver log to the screen.
with_static: bool
If True, include features that do not change during the solution process,
such as variable types and names. This parameter is used to reduce the
amount of duplicated data collected by LearningSolver. Features that do
not change are only collected once.
with_sa: bool
If True, collect sensitivity analysis information. For large models,
collecting this information may be expensive, so this parameter is useful
for reducing running times.
"""
pass
@abstractmethod
def is_infeasible(self) -> bool:
"""
Returns True if the model has been proved to be infeasible.
Must be called after solve.
"""
pass
@abstractmethod
def remove_constraints(self, names: Tuple[str, ...]) -> None:
"""
Removes the given constraints from the model.
"""
pass
@abstractmethod
def relax(self) -> None:
"""
Drops all integrality constraints from the model.
"""
pass
def set_branching_priorities(self, priorities: BranchPriorities) -> None:
"""
Sets the branching priorities for the given decision variables.
When the MIP solver needs to decide on which variable to branch, variables
with higher priority are picked first, given that they are fractional.
Ties are solved arbitrarily. By default, all variables have priority zero.
Missing values indicate variables whose priorities should not be modified.
"""
raise NotImplementedError()
@abstractmethod
def set_instance(
self,
instance: Instance,
model: Any = None,
) -> None:
"""
Loads the given instance into the solver.
Parameters
----------
instance: Instance
The instance to be loaded.
model: Any
The concrete optimization model corresponding to this instance
(e.g. JuMP.Model or pyomo.core.ConcreteModel). If not provided,
it will be generated by calling `instance.to_model()`.
"""
pass
@abstractmethod
def set_warm_start(self, solution: Solution) -> None:
"""
Sets the warm start to be used by the solver.
Only one warm start is supported. Calling this function when a warm start
already exists will remove the previous warm start.
"""
pass
@@ -106,211 +270,20 @@ class InternalSolver(ABC):
pass
@abstractmethod
def get_solution(self) -> Optional[Solution]:
"""
Returns current solution found by the solver.
If called after `solve`, returns the best primal solution found during
the search. If called after `solve_lp`, returns the optimal solution
to the LP relaxation. If no primal solution is available, return None.
"""
pass
@abstractmethod
def set_warm_start(self, solution: Solution) -> None:
"""
Sets the warm start to be used by the solver.
Only one warm start is supported. Calling this function when a warm start
already exists will remove the previous warm start.
"""
pass
@abstractmethod
def set_instance(
def solve_lp(
self,
instance: Instance,
model: Any = None,
) -> None:
tee: bool = False,
) -> LPSolveStats:
"""
Loads the given instance into the solver.
Solves the LP relaxation of the currently loaded instance. After this
method finishes, the solution can be retrieved by calling `get_solution`.
This method should not permanently modify the problem. That is, subsequent
calls to `solve` should solve the original MIP, not the LP relaxation.
Parameters
----------
instance: Instance
The instance to be loaded.
model: Any
The concrete optimization model corresponding to this instance
(e.g. JuMP.Model or pyomo.core.ConcreteModel). If not provided,
it will be generated by calling `instance.to_model()`.
"""
pass
@abstractmethod
def fix(self, solution: Solution) -> None:
"""
Fixes the values of a subset of decision variables. Missing values in the
solution indicate variables that should be left free.
"""
pass
def set_branching_priorities(self, priorities: BranchPriorities) -> None:
"""
Sets the branching priorities for the given decision variables.
When the MIP solver needs to decide on which variable to branch, variables
with higher priority are picked first, given that they are fractional.
Ties are solved arbitrarily. By default, all variables have priority zero.
Missing values indicate variables whose priorities should not be modified.
"""
raise NotImplementedError()
@abstractmethod
def get_constraints(
self,
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> ConstraintFeatures:
pass
@abstractmethod
def get_constraints_old(self, with_static: bool = True) -> Dict[str, Constraint]:
pass
@abstractmethod
def add_constraint(self, constr: Constraint, name: str) -> None:
"""
Adds a given constraint to the model.
"""
pass
@abstractmethod
def add_constraints(self, cf: ConstraintFeatures) -> None:
"""Adds the given constraints to the model."""
pass
@abstractmethod
def are_constraints_satisfied(
self,
cf: ConstraintFeatures,
tol: float = 1e-5,
) -> List[bool]:
"""
Checks whether the current solution satisfies the given constraints.
"""
pass
@abstractmethod
def remove_constraint(self, name: str) -> None:
"""
Removes the constraint that has a given name from the model.
"""
pass
@abstractmethod
def remove_constraints(self, names: List[str]) -> None:
"""
Removes the given constraints from the model.
"""
pass
@abstractmethod
def is_constraint_satisfied_old(
self, constr: Constraint, tol: float = 1e-6
) -> bool:
"""
Returns True if the current solution satisfies the given constraint.
"""
pass
@abstractmethod
def relax(self) -> None:
"""
Drops all integrality constraints from the model.
"""
pass
@abstractmethod
def is_infeasible(self) -> bool:
"""
Returns True if the model has been proved to be infeasible.
Must be called after solve.
"""
pass
@abstractmethod
def clone(self) -> "InternalSolver":
"""
Returns a new copy of this solver with identical parameters, but otherwise
completely unitialized.
"""
pass
@abstractmethod
def build_test_instance_infeasible(self) -> Instance:
pass
@abstractmethod
def build_test_instance_redundancy(self) -> Instance:
pass
@abstractmethod
def build_test_instance_knapsack(self) -> Instance:
"""
Returns an instance corresponding to the following MIP, for testing purposes:
maximize 505 x0 + 352 x1 + 458 x2 + 220 x3
s.t. eq_capacity: z = 23 x0 + 26 x1 + 20 x2 + 18 x3
x0, x1, x2, x3 binary
0 <= z <= 67 continuous
"""
pass
def are_callbacks_supported(self) -> bool:
"""
Returns True if this solver supports native callbacks, such as lazy constraints
callback or user cuts callback.
"""
return False
@abstractmethod
def get_variables(
self,
with_static: bool = True,
with_sa: bool = True,
) -> VariableFeatures:
"""
Returns a description of the decision variables in the problem.
Parameters
----------
with_static: bool
If True, include features that do not change during the solution process,
such as variable types and names. This parameter is used to reduce the
amount of duplicated data collected by LearningSolver. Features that do
not change are only collected once.
with_sa: bool
If True, collect sensitivity analysis information. For large models,
collecting this information may be expensive, so this parameter is useful
for reducing running times.
"""
pass
@abstractmethod
def get_constraint_attrs(self) -> List[str]:
"""
Returns a list of constraint attributes supported by this solver. Used for
testing purposes only.
"""
pass
@abstractmethod
def get_variable_attrs(self) -> List[str]:
"""
Returns a list of variable attributes supported by this solver. Used for
testing purposes only.
tee
If true, prints the solver log to the screen.
"""
pass

View File

@@ -28,7 +28,6 @@ from miplearn.solvers.internal import (
IterationCallback,
LazyCallback,
MIPSolveStats,
Constraint,
)
from miplearn.types import (
SolverParams,
@@ -69,31 +68,12 @@ class BasePyomoSolver(InternalSolver):
for (key, value) in params.items():
self._pyomo_solver.options[key] = value
@overrides
def add_constraint(
self,
constr: Any,
name: str,
) -> None:
assert self.model is not None
if isinstance(constr, Constraint):
assert constr.lhs is not None
lhs = 0.0
for (varname, coeff) in constr.lhs.items():
var = self._varname_to_var[varname]
lhs += var * coeff
if constr.sense == "=":
expr = lhs == constr.rhs
elif constr.sense == "<":
expr = lhs <= constr.rhs
else:
expr = lhs >= constr.rhs
cl = pe.Constraint(expr=expr, name=name)
self.model.add_component(name, cl)
self._pyomo_solver.add_constraint(cl)
self._cname_to_constr[name] = cl
else:
self._pyomo_solver.add_constraint(constr)
self._pyomo_solver.add_constraint(constr)
self._termination_condition = ""
self._has_lp_solution = False
self._has_mip_solution = False
@@ -133,7 +113,7 @@ class BasePyomoSolver(InternalSolver):
self,
cf: ConstraintFeatures,
tol: float = 1e-5,
) -> List[bool]:
) -> Tuple[bool, ...]:
assert cf.names is not None
assert cf.lhs is not None
assert cf.rhs is not None
@@ -150,7 +130,7 @@ class BasePyomoSolver(InternalSolver):
result.append(lhs >= cf.rhs[i] - tol)
else:
result.append(abs(cf.rhs[i] - lhs) < tol)
return result
return tuple(result)
@overrides
def build_test_instance_infeasible(self) -> Instance:
@@ -277,30 +257,6 @@ class BasePyomoSolver(InternalSolver):
dual_values=dual_values_t,
)
@overrides
def get_constraints_old(self, with_static: bool = True) -> Dict[str, Constraint]:
assert self.model is not None
constraints = {}
for constr in self.model.component_objects(pyomo.core.Constraint):
if isinstance(constr, pe.ConstraintList):
for idx in constr:
name = f"{constr.name}[{idx}]"
assert name not in constraints
constraints[name] = self._parse_pyomo_constraint(
constr[idx],
with_static=with_static,
)
else:
name = constr.name
assert name not in constraints
constraints[name] = self._parse_pyomo_constraint(
constr,
with_static=with_static,
)
return constraints
@overrides
def get_constraint_attrs(self) -> List[str]:
return [
@@ -435,36 +391,12 @@ class BasePyomoSolver(InternalSolver):
"values",
]
@overrides
def is_constraint_satisfied_old(
self, constr: Constraint, tol: float = 1e-6
) -> bool:
lhs = 0.0
assert constr.lhs is not None
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)
@overrides
def is_infeasible(self) -> bool:
return self._termination_condition == TerminationCondition.infeasible
@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 remove_constraints(self, names: List[str]) -> None:
def remove_constraints(self, names: Tuple[str, ...]) -> None:
assert self.model is not None
for name in names:
constr = self._cname_to_constr[name]
@@ -627,46 +559,6 @@ class BasePyomoSolver(InternalSolver):
def _get_warm_start_regexp(self) -> Optional[str]:
return None
def _parse_pyomo_constraint(
self,
pyomo_constr: pyomo.core.Constraint,
with_static: bool = True,
) -> Constraint:
assert self.model is not None
constr = Constraint()
if with_static:
# Extract RHS and sense
has_ub = pyomo_constr.has_ub()
has_lb = pyomo_constr.has_lb()
assert (
(not has_lb)
or (not has_ub)
or pyomo_constr.upper() == pyomo_constr.lower()
), "range constraints not supported"
if not has_ub:
constr.sense = ">"
constr.rhs = pyomo_constr.lower()
elif not has_lb:
constr.sense = "<"
constr.rhs = pyomo_constr.upper()
else:
constr.sense = "="
constr.rhs = pyomo_constr.upper()
# Extract LHS
constr.lhs = self._parse_pyomo_expr(pyomo_constr.body)
# Extract solution attributes
if self._has_lp_solution:
constr.dual_value = self.model.dual[pyomo_constr]
if self._has_mip_solution or self._has_lp_solution:
constr.slack = self.model.slack[pyomo_constr]
# Build constraint
return constr
def _parse_pyomo_expr(self, expr: Any) -> Dict[str, float]:
lhs = {}
if isinstance(expr, SumExpression):

View File

@@ -200,7 +200,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
rhs=(0.0,),
senses=("<",),
)
assert_equals(solver.are_constraints_satisfied(cf), [False])
assert_equals(solver.are_constraints_satisfied(cf), (False,))
# Add constraint and verify it affects solution
solver.add_constraints(cf)
@@ -227,10 +227,10 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
)
stats = solver.solve()
assert_equals(stats.mip_lower_bound, 1030.0)
assert_equals(solver.are_constraints_satisfied(cf), [True])
assert_equals(solver.are_constraints_satisfied(cf), (True,))
# Remove the new constraint
solver.remove_constraints(["cut"])
solver.remove_constraints(("cut",))
# New constraint should no longer affect solution
stats = solver.solve()