MIPLearn v0.3

This commit is contained in:
2023-06-08 11:25:39 -05:00
parent 6cc253a903
commit 1ea989d48a
172 changed files with 10495 additions and 24812 deletions

View File

@@ -1,48 +1,3 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import sys
from typing import Any, List, TextIO, cast, TypeVar, Optional, Sized
logger = logging.getLogger(__name__)
class _RedirectOutput:
def __init__(self, streams: List[Any]) -> None:
self.streams = streams
def write(self, data: Any) -> None:
for stream in self.streams:
stream.write(data)
def flush(self) -> None:
for stream in self.streams:
stream.flush()
def __enter__(self) -> Any:
self._original_stdout = sys.stdout
self._original_stderr = sys.stderr
sys.stdout = cast(TextIO, self)
sys.stderr = cast(TextIO, self)
return self
def __exit__(
self,
_type: Any,
_value: Any,
_traceback: Any,
) -> None:
sys.stdout = self._original_stdout
sys.stderr = self._original_stderr
T = TypeVar("T", bound=Sized)
def _none_if_empty(obj: T) -> Optional[T]:
if len(obj) == 0:
return None
else:
return obj

View File

@@ -0,0 +1,70 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from abc import ABC, abstractmethod
from typing import Optional, Dict
import numpy as np
from miplearn.h5 import H5File
class AbstractModel(ABC):
_supports_basis_status = False
_supports_sensitivity_analysis = False
_supports_node_count = False
_supports_solution_pool = False
@abstractmethod
def add_constrs(
self,
var_names: np.ndarray,
constrs_lhs: np.ndarray,
constrs_sense: np.ndarray,
constrs_rhs: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
pass
@abstractmethod
def extract_after_load(self, h5: H5File) -> None:
pass
@abstractmethod
def extract_after_lp(self, h5: H5File) -> None:
pass
@abstractmethod
def extract_after_mip(self, h5: H5File) -> None:
pass
@abstractmethod
def fix_variables(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
pass
@abstractmethod
def optimize(self) -> None:
pass
@abstractmethod
def relax(self) -> "AbstractModel":
pass
@abstractmethod
def set_warm_starts(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
pass
@abstractmethod
def write(self, filename: str) -> None:
pass

View File

@@ -1,319 +1,216 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import re
import sys
from io import StringIO
from random import randint
from typing import List, Any, Dict, Optional, TYPE_CHECKING
from typing import Dict, Optional, Callable, Any, List
import gurobipy as gp
from gurobipy import GRB, GurobiError
import numpy as np
from overrides import overrides
from scipy.sparse import coo_matrix, lil_matrix
from scipy.sparse import lil_matrix
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput
from miplearn.solvers.internal import (
InternalSolver,
LPSolveStats,
IterationCallback,
LazyCallback,
MIPSolveStats,
Variables,
Constraints,
)
from miplearn.solvers.pyomo.base import PyomoTestInstanceKnapsack
from miplearn.types import (
SolverParams,
UserCutCallback,
Solution,
)
if TYPE_CHECKING:
import gurobipy
logger = logging.getLogger(__name__)
from miplearn.h5 import H5File
class GurobiSolver(InternalSolver):
"""
An InternalSolver backed by Gurobi's Python API (without Pyomo).
Parameters
----------
params: Optional[SolverParams]
Parameters to pass to Gurobi. For example, `params={"MIPGap": 1e-3}`
sets the gap tolerance to 1e-3.
lazy_cb_frequency: int
If 1, calls lazy constraint callbacks whenever an integer solution
is found. If 2, calls it also at every node, after solving the
LP relaxation of that node.
"""
class GurobiModel:
_supports_basis_status = True
_supports_sensitivity_analysis = True
_supports_node_count = True
_supports_solution_pool = True
def __init__(
self,
params: Optional[SolverParams] = None,
lazy_cb_frequency: int = 1,
inner: gp.Model,
find_violations: Optional[Callable] = None,
fix_violations: Optional[Callable] = None,
) -> None:
import gurobipy
self.fix_violations = fix_violations
self.find_violations = find_violations
self.inner = inner
self.violations_: Optional[List[Any]] = None
assert lazy_cb_frequency in [1, 2]
if params is None:
params = {}
params["InfUnbdInfo"] = True
params["Seed"] = randint(0, 1_000_000)
def add_constrs(
self,
var_names: np.ndarray,
constrs_lhs: np.ndarray,
constrs_sense: np.ndarray,
constrs_rhs: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
assert len(var_names.shape) == 1
nvars = len(var_names)
assert len(constrs_lhs.shape) == 2
nconstrs = constrs_lhs.shape[0]
assert constrs_lhs.shape[1] == nvars
assert constrs_sense.shape == (nconstrs,)
assert constrs_rhs.shape == (nconstrs,)
self.gp = gurobipy
self.instance: Optional[Instance] = None
self.model: Optional["gurobipy.Model"] = None
self.params: SolverParams = params
self.cb_where: Optional[int] = None
self.lazy_cb_frequency = lazy_cb_frequency
self._dirty = True
self._has_lp_solution = False
self._has_mip_solution = False
gp_vars = [self.inner.getVarByName(var_name.decode()) for var_name in var_names]
self.inner.addMConstr(constrs_lhs, gp_vars, constrs_sense, constrs_rhs)
self._varname_to_var: Dict[bytes, "gurobipy.Var"] = {}
self._cname_to_constr: Dict[str, "gurobipy.Constr"] = {}
self._gp_vars: List["gurobipy.Var"] = []
self._gp_constrs: List["gurobipy.Constr"] = []
self._var_names: np.ndarray = np.empty(0)
self._constr_names: List[str] = []
self._var_types: np.ndarray = np.empty(0)
self._var_lbs: np.ndarray = np.empty(0)
self._var_ubs: np.ndarray = np.empty(0)
self._var_obj_coeffs: np.ndarray = np.empty(0)
if stats is not None:
if "Added constraints" not in stats:
stats["Added constraints"] = 0
stats["Added constraints"] += nconstrs
if self.lazy_cb_frequency == 1:
self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL]
def extract_after_load(self, h5: H5File) -> None:
"""
Given a model that has just been loaded, extracts static problem
features, such as variable names and types, objective coefficients, etc.
"""
self.inner.update()
self._extract_after_load_vars(h5)
self._extract_after_load_constrs(h5)
h5.put_scalar("static_sense", "min" if self.inner.modelSense > 0 else "max")
h5.put_scalar("static_obj_offset", self.inner.objCon)
def extract_after_lp(self, h5: H5File) -> None:
"""
Given a linear programming model that has just been solved, extracts
dynamic problem features, such as optimal LP solution, basis status,
etc.
"""
self._extract_after_lp_vars(h5)
self._extract_after_lp_constrs(h5)
h5.put_scalar("lp_obj_value", self.inner.objVal)
h5.put_scalar("lp_wallclock_time", self.inner.runtime)
def extract_after_mip(self, h5: H5File) -> None:
"""
Given a mixed-integer linear programming model that has just been
solved, extracts dynamic problem features, such as optimal MIP solution.
"""
h5.put_scalar("mip_wallclock_time", self.inner.runtime)
h5.put_scalar("mip_node_count", self.inner.nodeCount)
if self.inner.status == GRB.INFEASIBLE:
return
gp_vars = self.inner.getVars()
gp_constrs = self.inner.getConstrs()
h5.put_array(
"mip_var_values",
np.array(self.inner.getAttr("x", gp_vars), dtype=float),
)
h5.put_array(
"mip_constr_slacks",
np.abs(np.array(self.inner.getAttr("slack", gp_constrs), dtype=float)),
)
h5.put_scalar("mip_obj_value", self.inner.objVal)
h5.put_scalar("mip_obj_bound", self.inner.objBound)
try:
h5.put_scalar("mip_gap", self.inner.mipGap)
except AttributeError:
pass
self._extract_after_mip_solution_pool(h5)
def fix_variables(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
assert len(var_values.shape) == 1
assert len(var_values.shape) == 1
assert var_names.shape == var_values.shape
n_fixed = 0
for (var_idx, var_name) in enumerate(var_names):
var_val = var_values[var_idx]
if np.isfinite(var_val):
var = self.inner.getVarByName(var_name.decode())
var.vtype = "C"
var.lb = var_val
var.ub = var_val
n_fixed += 1
if stats is not None:
stats["Fixed variables"] = n_fixed
def optimize(self) -> None:
self.violations_ = []
def callback(m: gp.Model, where: int) -> None:
assert self.find_violations is not None
assert self.violations_ is not None
assert self.fix_violations is not None
if where == GRB.Callback.MIPSOL:
violations = self.find_violations(self)
self.violations_.extend(violations)
self.fix_violations(self, violations, "cb")
if self.fix_violations is not None:
self.inner.Params.lazyConstraints = 1
self.inner.optimize(callback)
else:
self.lazy_cb_where = [
self.gp.GRB.Callback.MIPSOL,
self.gp.GRB.Callback.MIPNODE,
]
self.inner.optimize()
@overrides
def add_constraints(self, cf: Constraints) -> None:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert self.model is not None
lhs = cf.lhs.tocsr()
for i in range(len(cf.names)):
sense = cf.senses[i]
row = lhs[i, :]
row_expr = self.gp.quicksum(
self._gp_vars[row.indices[j]] * row.data[j] for j in range(row.getnnz())
def relax(self) -> "GurobiModel":
return GurobiModel(self.inner.relax())
def set_time_limit(self, time_limit_sec: float) -> None:
self.inner.params.timeLimit = time_limit_sec
def set_warm_starts(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
assert len(var_values.shape) == 2
(n_starts, n_vars) = var_values.shape
assert len(var_names.shape) == 1
assert var_names.shape[0] == n_vars
self.inner.numStart = n_starts
for start_idx in range(n_starts):
self.inner.params.startNumber = start_idx
for (var_idx, var_name) in enumerate(var_names):
var_val = var_values[start_idx, var_idx]
if np.isfinite(var_val):
var = self.inner.getVarByName(var_name.decode())
var.start = var_val
if stats is not None:
stats["WS: Count"] = n_starts
stats["WS: Number of variables set"] = (
np.isfinite(var_values).mean(axis=0).sum()
)
if sense == b"=":
self.model.addConstr(row_expr == cf.rhs[i], name=cf.names[i])
elif sense == b"<":
self.model.addConstr(row_expr <= cf.rhs[i], name=cf.names[i])
elif sense == b">":
self.model.addConstr(row_expr >= cf.rhs[i], name=cf.names[i])
else:
raise Exception(f"Unknown sense: {sense}")
self.model.update()
self._dirty = True
self._has_lp_solution = False
self._has_mip_solution = False
@overrides
def are_callbacks_supported(self) -> bool:
return True
@overrides
def are_constraints_satisfied(
self,
cf: Constraints,
tol: float = 1e-5,
) -> List[bool]:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert self.model is not None
result = []
x = np.array(self.model.getAttr("x", self.model.getVars()))
lhs = cf.lhs.tocsr() * x
for i in range(len(cf.names)):
sense = cf.senses[i]
if sense == b"<":
result.append(lhs[i] <= cf.rhs[i] + tol)
elif sense == b">":
result.append(lhs[i] >= cf.rhs[i] - tol)
elif sense == b"<":
result.append(abs(cf.rhs[i] - lhs[i]) <= tol)
else:
raise Exception(f"unknown sense: {sense}")
return result
@overrides
def build_test_instance_infeasible(self) -> Instance:
return GurobiTestInstanceInfeasible()
@overrides
def build_test_instance_knapsack(self) -> Instance:
return GurobiTestInstanceKnapsack(
weights=[23.0, 26.0, 20.0, 18.0],
prices=[505.0, 352.0, 458.0, 220.0],
capacity=67.0,
)
@overrides
def clone(self) -> "GurobiSolver":
return GurobiSolver(
params=self.params,
lazy_cb_frequency=self.lazy_cb_frequency,
)
@overrides
def fix(self, solution: Solution) -> None:
self._raise_if_callback()
for (varname, value) in solution.items():
if value is None:
continue
var = self._varname_to_var[varname]
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = value
var.ub = value
@overrides
def get_constraint_attrs(self) -> List[str]:
return [
"basis_status",
"categories",
"dual_values",
"lazy",
"lhs",
"names",
"rhs",
"sa_rhs_down",
"sa_rhs_up",
"senses",
"slacks",
"user_features",
]
@overrides
def get_constraints(
self,
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> Constraints:
model = self.model
assert model is not None
assert model.numVars == len(self._gp_vars)
def _parse_gurobi_cbasis(v: int) -> str:
if v == 0:
return "B"
if v == -1:
return "N"
raise Exception(f"unknown cbasis: {v}")
gp_constrs = model.getConstrs()
constr_names = np.array(model.getAttr("constrName", gp_constrs), dtype="S")
lhs: Optional[coo_matrix] = None
rhs, senses, slacks, basis_status = None, None, None, None
dual_value, basis_status, sa_rhs_up, sa_rhs_down = None, None, None, None
if with_static:
rhs = np.array(model.getAttr("rhs", gp_constrs), dtype=float)
senses = np.array(model.getAttr("sense", gp_constrs), dtype="S")
if with_lhs:
nrows = len(gp_constrs)
ncols = len(self._var_names)
tmp = lil_matrix((nrows, ncols), dtype=float)
for (i, gp_constr) in enumerate(gp_constrs):
expr = model.getRow(gp_constr)
for j in range(expr.size()):
tmp[i, expr.getVar(j).index] = expr.getCoeff(j)
lhs = tmp.tocoo()
if self._has_lp_solution:
dual_value = np.array(model.getAttr("pi", gp_constrs), dtype=float)
basis_status = np.array(
[_parse_gurobi_cbasis(c) for c in model.getAttr("cbasis", gp_constrs)],
dtype="S",
def _extract_after_load_vars(self, h5: H5File) -> None:
gp_vars = self.inner.getVars()
for (h5_field, gp_field) in {
"static_var_names": "varName",
"static_var_types": "vtype",
}.items():
h5.put_array(
h5_field, np.array(self.inner.getAttr(gp_field, gp_vars), dtype="S")
)
for (h5_field, gp_field) in {
"static_var_upper_bounds": "ub",
"static_var_lower_bounds": "lb",
"static_var_obj_coeffs": "obj",
}.items():
h5.put_array(
h5_field, np.array(self.inner.getAttr(gp_field, gp_vars), dtype=float)
)
if with_sa:
sa_rhs_up = np.array(model.getAttr("saRhsUp", gp_constrs), dtype=float)
sa_rhs_down = np.array(
model.getAttr("saRhsLow", gp_constrs), dtype=float
)
if self._has_lp_solution or self._has_mip_solution:
slacks = np.array(model.getAttr("slack", gp_constrs), dtype=float)
def _extract_after_load_constrs(self, h5: H5File) -> None:
gp_constrs = self.inner.getConstrs()
gp_vars = self.inner.getVars()
rhs = np.array(self.inner.getAttr("rhs", gp_constrs), dtype=float)
senses = np.array(self.inner.getAttr("sense", gp_constrs), dtype="S")
names = np.array(self.inner.getAttr("constrName", gp_constrs), dtype="S")
nrows, ncols = len(gp_constrs), len(gp_vars)
tmp = lil_matrix((nrows, ncols), dtype=float)
for (i, gp_constr) in enumerate(gp_constrs):
expr = self.inner.getRow(gp_constr)
for j in range(expr.size()):
tmp[i, expr.getVar(j).index] = expr.getCoeff(j)
lhs = tmp.tocoo()
return Constraints(
basis_status=basis_status,
dual_values=dual_value,
lhs=lhs,
names=constr_names,
rhs=rhs,
sa_rhs_down=sa_rhs_down,
sa_rhs_up=sa_rhs_up,
senses=senses,
slacks=slacks,
)
@overrides
def get_solution(self) -> Optional[Solution]:
assert self.model is not None
if self.cb_where is not None:
if self.cb_where == self.gp.GRB.Callback.MIPNODE:
return {
v.varName.encode(): self.model.cbGetNodeRel(v)
for v in self.model.getVars()
}
elif self.cb_where == self.gp.GRB.Callback.MIPSOL:
return {
v.varName.encode(): self.model.cbGetSolution(v)
for v in self.model.getVars()
}
else:
raise Exception(
f"get_solution can only be called from a callback "
f"when cb_where is either MIPNODE or MIPSOL"
)
if self.model.solCount == 0:
return None
return {v.varName.encode(): v.x for v in self.model.getVars()}
@overrides
def get_variable_attrs(self) -> List[str]:
return [
"names",
"basis_status",
"categories",
"lower_bounds",
"obj_coeffs",
"reduced_costs",
"sa_lb_down",
"sa_lb_up",
"sa_obj_down",
"sa_obj_up",
"sa_ub_down",
"sa_ub_up",
"types",
"upper_bounds",
"user_features",
"values",
]
@overrides
def get_variables(
self,
with_static: bool = True,
with_sa: bool = True,
) -> Variables:
model = self.model
assert model is not None
h5.put_array("static_constr_names", names)
h5.put_array("static_constr_rhs", rhs)
h5.put_array("static_constr_sense", senses)
h5.put_sparse("static_constr_lhs", lhs)
def _extract_after_lp_vars(self, h5: H5File) -> None:
def _parse_gurobi_vbasis(b: int) -> str:
if b == 0:
return "B"
@@ -324,393 +221,81 @@ class GurobiSolver(InternalSolver):
elif b == -3:
return "S"
else:
raise Exception(f"unknown vbasis: {basis_status}")
raise Exception(f"unknown vbasis: {b}")
basis_status: Optional[np.ndarray] = None
upper_bounds, lower_bounds, types, values = None, None, None, None
obj_coeffs, reduced_costs = None, None
sa_obj_up, sa_ub_up, sa_lb_up = None, None, None
sa_obj_down, sa_ub_down, sa_lb_down = None, None, None
if with_static:
upper_bounds = self._var_ubs
lower_bounds = self._var_lbs
types = self._var_types
obj_coeffs = self._var_obj_coeffs
if self._has_lp_solution:
reduced_costs = np.array(model.getAttr("rc", self._gp_vars), dtype=float)
basis_status = np.array(
gp_vars = self.inner.getVars()
h5.put_array(
"lp_var_basis_status",
np.array(
[
_parse_gurobi_vbasis(b)
for b in model.getAttr("vbasis", self._gp_vars)
for b in self.inner.getAttr("vbasis", gp_vars)
],
dtype="S",
),
)
for (h5_field, gp_field) in {
"lp_var_reduced_costs": "rc",
"lp_var_sa_obj_up": "saobjUp",
"lp_var_sa_obj_down": "saobjLow",
"lp_var_sa_ub_up": "saubUp",
"lp_var_sa_ub_down": "saubLow",
"lp_var_sa_lb_up": "salbUp",
"lp_var_sa_lb_down": "salbLow",
"lp_var_values": "x",
}.items():
h5.put_array(
h5_field,
np.array(self.inner.getAttr(gp_field, gp_vars), dtype=float),
)
if with_sa:
sa_obj_up = np.array(
model.getAttr("saobjUp", self._gp_vars),
dtype=float,
)
sa_obj_down = np.array(
model.getAttr("saobjLow", self._gp_vars),
dtype=float,
)
sa_ub_up = np.array(
model.getAttr("saubUp", self._gp_vars),
dtype=float,
)
sa_ub_down = np.array(
model.getAttr("saubLow", self._gp_vars),
dtype=float,
)
sa_lb_up = np.array(
model.getAttr("salbUp", self._gp_vars),
dtype=float,
)
sa_lb_down = np.array(
model.getAttr("salbLow", self._gp_vars),
dtype=float,
)
def _extract_after_lp_constrs(self, h5: H5File) -> None:
def _parse_gurobi_cbasis(v: int) -> str:
if v == 0:
return "B"
if v == -1:
return "N"
raise Exception(f"unknown cbasis: {v}")
if model.solCount > 0:
values = np.array(model.getAttr("x", self._gp_vars), dtype=float)
return Variables(
names=self._var_names,
upper_bounds=upper_bounds,
lower_bounds=lower_bounds,
types=types,
obj_coeffs=obj_coeffs,
reduced_costs=reduced_costs,
basis_status=basis_status,
sa_obj_up=sa_obj_up,
sa_obj_down=sa_obj_down,
sa_ub_up=sa_ub_up,
sa_ub_down=sa_ub_down,
sa_lb_up=sa_lb_up,
sa_lb_down=sa_lb_down,
values=values,
gp_constrs = self.inner.getConstrs()
h5.put_array(
"lp_constr_basis_status",
np.array(
[
_parse_gurobi_cbasis(c)
for c in self.inner.getAttr("cbasis", gp_constrs)
],
dtype="S",
),
)
for (h5_field, gp_field) in {
"lp_constr_dual_values": "pi",
"lp_constr_sa_rhs_up": "saRhsUp",
"lp_constr_sa_rhs_down": "saRhsLow",
}.items():
h5.put_array(
h5_field,
np.array(self.inner.getAttr(gp_field, gp_constrs), dtype=float),
)
h5.put_array(
"lp_constr_slacks",
np.abs(np.array(self.inner.getAttr("slack", gp_constrs), dtype=float)),
)
@overrides
def is_infeasible(self) -> bool:
assert self.model is not None
return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD]
@overrides
def remove_constraints(self, names: List[str]) -> None:
assert self.model is not None
constrs = [self.model.getConstrByName(n) for n in names]
self.model.remove(constrs)
self.model.update()
@overrides
def set_instance(
self,
instance: Instance,
model: Any = None,
) -> None:
self._raise_if_callback()
if model is None:
model = instance.to_model()
assert isinstance(model, self.gp.Model)
self.instance = instance
self.model = model
self.model.update()
self._update()
@overrides
def set_warm_start(self, solution: Solution) -> None:
self._raise_if_callback()
self._clear_warm_start()
for (var_name, value) in solution.items():
var = self._varname_to_var[var_name]
if value is not None:
var.start = value
@overrides
def solve(
self,
tee: bool = False,
iteration_cb: Optional[IterationCallback] = None,
lazy_cb: Optional[LazyCallback] = None,
user_cut_cb: Optional[UserCutCallback] = None,
) -> MIPSolveStats:
self._raise_if_callback()
assert self.model is not None
if iteration_cb is None:
iteration_cb = lambda: False
callback_exceptions = []
# Create callback wrapper
def cb_wrapper(cb_model: Any, cb_where: int) -> None:
def _extract_after_mip_solution_pool(self, h5: H5File) -> None:
gp_vars = self.inner.getVars()
pool_var_values = []
pool_obj_values = []
for i in range(self.inner.SolCount):
self.inner.params.SolutionNumber = i
try:
self.cb_where = cb_where
if lazy_cb is not None and cb_where in self.lazy_cb_where:
lazy_cb(self, self.model)
if user_cut_cb is not None and cb_where == self.gp.GRB.Callback.MIPNODE:
user_cut_cb(self, self.model)
except Exception as e:
logger.exception("callback error")
callback_exceptions.append(e)
finally:
self.cb_where = None
pool_var_values.append(self.inner.getAttr("Xn", gp_vars))
pool_obj_values.append(self.inner.PoolObjVal)
except GurobiError:
pass
h5.put_array("pool_var_values", np.array(pool_var_values))
h5.put_array("pool_obj_values", np.array(pool_obj_values))
# Configure Gurobi
if lazy_cb is not None:
self.params["LazyConstraints"] = 1
if user_cut_cb is not None:
self.params["PreCrush"] = 1
# Solve problem
total_wallclock_time = 0
total_nodes = 0
streams: List[Any] = [StringIO()]
if tee:
streams += [sys.stdout]
self._apply_params(streams)
while True:
with _RedirectOutput(streams):
self.model.optimize(cb_wrapper)
self._dirty = False
if len(callback_exceptions) > 0:
raise callback_exceptions[0]
total_wallclock_time += self.model.runtime
total_nodes += int(self.model.nodeCount)
should_repeat = iteration_cb()
if not should_repeat:
break
self._has_lp_solution = False
self._has_mip_solution = self.model.solCount > 0
# Fetch results and stats
log = streams[0].getvalue()
ub, lb = None, None
sense = "min" if self.model.modelSense == 1 else "max"
if self.model.solCount > 0:
if self.model.modelSense == 1:
lb = self.model.objBound
ub = self.model.objVal
else:
lb = self.model.objVal
ub = self.model.objBound
ws_value = self._extract_warm_start_value(log)
return MIPSolveStats(
mip_lower_bound=lb,
mip_upper_bound=ub,
mip_wallclock_time=total_wallclock_time,
mip_nodes=total_nodes,
mip_sense=sense,
mip_log=log,
mip_warm_start_value=ws_value,
)
@overrides
def solve_lp(
self,
tee: bool = False,
) -> LPSolveStats:
self._raise_if_callback()
streams: List[Any] = [StringIO()]
if tee:
streams += [sys.stdout]
self._apply_params(streams)
assert self.model is not None
for (i, var) in enumerate(self._gp_vars):
if self._var_types[i] == b"B":
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = 0.0
var.ub = 1.0
elif self._var_types[i] == b"I":
var.vtype = self.gp.GRB.CONTINUOUS
with _RedirectOutput(streams):
self.model.optimize()
self._dirty = False
for (i, var) in enumerate(self._gp_vars):
if self._var_types[i] == b"B":
var.vtype = self.gp.GRB.BINARY
elif self._var_types[i] == b"I":
var.vtype = self.gp.GRB.INTEGER
log = streams[0].getvalue()
self._has_lp_solution = self.model.solCount > 0
self._has_mip_solution = False
opt_value = None
if not self.is_infeasible():
opt_value = self.model.objVal
return LPSolveStats(
lp_value=opt_value,
lp_log=log,
lp_wallclock_time=self.model.runtime,
)
def _apply_params(self, streams: List[Any]) -> None:
assert self.model is not None
with _RedirectOutput(streams):
for (name, value) in self.params.items():
self.model.setParam(name, value)
def _clear_warm_start(self) -> None:
for var in self._varname_to_var.values():
var.start = self.gp.GRB.UNDEFINED
@staticmethod
def _extract(
log: str,
regexp: str,
default: Optional[str] = None,
) -> Optional[str]:
value = default
for line in log.splitlines():
matches = re.findall(regexp, line)
if len(matches) == 0:
continue
value = matches[0]
return value
def _extract_warm_start_value(self, log: str) -> Optional[float]:
ws = self._extract(log, "MIP start with objective ([0-9.e+-]*)")
if ws is None:
return None
return float(ws)
def _get_value(self, var: Any) -> float:
assert self.model is not None
if self.cb_where == self.gp.GRB.Callback.MIPSOL:
return self.model.cbGetSolution(var)
elif self.cb_where == self.gp.GRB.Callback.MIPNODE:
return self.model.cbGetNodeRel(var)
elif self.cb_where is None:
return var.x
else:
raise Exception(
"get_value cannot be called from cb_where=%s" % self.cb_where
)
def _raise_if_callback(self) -> None:
if self.cb_where is not None:
raise Exception("method cannot be called from a callback")
def _update(self) -> None:
assert self.model is not None
gp_vars: List["gurobipy.Var"] = self.model.getVars()
gp_constrs: List["gurobipy.Constr"] = self.model.getConstrs()
var_names: np.ndarray = np.array(
self.model.getAttr("varName", gp_vars),
dtype="S",
)
var_types: np.ndarray = np.array(
self.model.getAttr("vtype", gp_vars),
dtype="S",
)
var_ubs: np.ndarray = np.array(
self.model.getAttr("ub", gp_vars),
dtype=float,
)
var_lbs: np.ndarray = np.array(
self.model.getAttr("lb", gp_vars),
dtype=float,
)
var_obj_coeffs: np.ndarray = np.array(
self.model.getAttr("obj", gp_vars),
dtype=float,
)
constr_names: List[str] = self.model.getAttr("constrName", gp_constrs)
varname_to_var: Dict[bytes, "gurobipy.Var"] = {}
cname_to_constr: Dict = {}
for (i, gp_var) in enumerate(gp_vars):
assert var_names[i] not in varname_to_var, (
f"Duplicated variable name detected: {var_names[i]}. "
f"Unique variable names are currently required."
)
assert var_types[i] in [b"B", b"C", b"I"], (
"Only binary and continuous variables are currently supported. "
f"Variable {var_names[i]} has type {var_types[i]}."
)
varname_to_var[var_names[i]] = gp_var
for (i, gp_constr) in enumerate(gp_constrs):
assert constr_names[i] not in cname_to_constr, (
f"Duplicated constraint name detected: {constr_names[i]}. "
f"Unique constraint names are currently required."
)
cname_to_constr[constr_names[i]] = gp_constr
self._varname_to_var = varname_to_var
self._cname_to_constr = cname_to_constr
self._gp_vars = gp_vars
self._gp_constrs = gp_constrs
self._var_names = var_names
self._constr_names = constr_names
self._var_types = var_types
self._var_lbs = var_lbs
self._var_ubs = var_ubs
self._var_obj_coeffs = var_obj_coeffs
def __getstate__(self) -> Dict:
return {
"params": self.params,
"lazy_cb_frequency": self.lazy_cb_frequency,
}
def __setstate__(self, state: Dict) -> None:
self.params = state["params"]
self.lazy_cb_frequency = state["lazy_cb_frequency"]
self.instance = None
self.model = None
self.cb_where = None
class GurobiTestInstanceInfeasible(Instance):
@overrides
def to_model(self) -> Any:
import gurobipy as gp
from gurobipy import GRB
model = gp.Model()
x = model.addVars(1, vtype=GRB.BINARY, name="x")
model.addConstr(x[0] >= 2)
model.setObjective(x[0])
return model
class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack):
"""
Simpler (one-dimensional) knapsack instance, implemented directly in Gurobi
instead of Pyomo, used for testing.
"""
def __init__(
self,
weights: List[float],
prices: List[float],
capacity: float,
) -> None:
super().__init__(weights, prices, capacity)
@overrides
def to_model(self) -> Any:
import gurobipy as gp
from gurobipy import GRB
model = gp.Model("Knapsack")
n = len(self.weights)
x = model.addVars(n, vtype=GRB.BINARY, name="x")
z = model.addVar(vtype=GRB.CONTINUOUS, name="z", ub=self.capacity)
model.addConstr(
gp.quicksum(x[i] * self.weights[i] for i in range(n)) == z,
"eq_capacity",
)
model.setObjective(
gp.quicksum(x[i] * self.prices[i] for i in range(n)), GRB.MAXIMIZE
)
return model
@overrides
def enforce_lazy_constraint(
self,
solver: InternalSolver,
model: Any,
violation_data: Any,
) -> None:
x0 = model.getVarByName("x[0]")
model.cbLazy(x0 <= 0)
def write(self, filename: str) -> None:
self.inner.update()
self.inner.write(filename)

View File

@@ -1,340 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Optional, List, TYPE_CHECKING
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.instance.base import Instance
from miplearn.types import (
IterationCallback,
LazyCallback,
UserCutCallback,
Solution,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from miplearn.features.sample import Sample
@dataclass
class LPSolveStats:
lp_log: Optional[str] = None
lp_value: Optional[float] = None
lp_wallclock_time: Optional[float] = None
def to_list(self) -> List[float]:
features: List[float] = []
for attr in ["lp_value", "lp_wallclock_time"]:
if getattr(self, attr) is not None:
features.append(getattr(self, attr))
return features
@dataclass
class MIPSolveStats:
mip_lower_bound: Optional[float] = None
mip_log: Optional[str] = None
mip_nodes: Optional[int] = None
mip_sense: Optional[str] = None
mip_upper_bound: Optional[float] = None
mip_wallclock_time: Optional[float] = None
mip_warm_start_value: Optional[float] = None
@dataclass
class Variables:
names: Optional[np.ndarray] = None
basis_status: Optional[np.ndarray] = None
lower_bounds: Optional[np.ndarray] = None
obj_coeffs: Optional[np.ndarray] = None
reduced_costs: Optional[np.ndarray] = None
sa_lb_down: Optional[np.ndarray] = None
sa_lb_up: Optional[np.ndarray] = None
sa_obj_down: Optional[np.ndarray] = None
sa_obj_up: Optional[np.ndarray] = None
sa_ub_down: Optional[np.ndarray] = None
sa_ub_up: Optional[np.ndarray] = None
types: Optional[np.ndarray] = None
upper_bounds: Optional[np.ndarray] = None
values: Optional[np.ndarray] = None
@dataclass
class Constraints:
basis_status: Optional[np.ndarray] = None
dual_values: Optional[np.ndarray] = None
lazy: Optional[np.ndarray] = None
lhs: Optional[coo_matrix] = None
names: Optional[np.ndarray] = None
rhs: Optional[np.ndarray] = None
sa_rhs_down: Optional[np.ndarray] = None
sa_rhs_up: Optional[np.ndarray] = None
senses: Optional[np.ndarray] = None
slacks: Optional[np.ndarray] = None
@staticmethod
def from_sample(sample: "Sample") -> "Constraints":
return Constraints(
basis_status=sample.get_array("lp_constr_basis_status"),
dual_values=sample.get_array("lp_constr_dual_values"),
lazy=sample.get_array("static_constr_lazy"),
# lhs=sample.get_vector("static_constr_lhs"),
names=sample.get_array("static_constr_names"),
rhs=sample.get_array("static_constr_rhs"),
sa_rhs_down=sample.get_array("lp_constr_sa_rhs_down"),
sa_rhs_up=sample.get_array("lp_constr_sa_rhs_up"),
senses=sample.get_array("static_constr_senses"),
slacks=sample.get_array("lp_constr_slacks"),
)
def __getitem__(self, selected: List[bool]) -> "Constraints":
return Constraints(
basis_status=(
None if self.basis_status is None else self.basis_status[selected]
),
dual_values=(
None if self.dual_values is None else self.dual_values[selected]
),
names=(None if self.names is None else self.names[selected]),
lazy=(None if self.lazy is None else self.lazy[selected]),
lhs=(None if self.lhs is None else self.lhs.tocsr()[selected].tocoo()),
rhs=(None if self.rhs is None else self.rhs[selected]),
sa_rhs_down=(
None if self.sa_rhs_down is None else self.sa_rhs_down[selected]
),
sa_rhs_up=(None if self.sa_rhs_up is None else self.sa_rhs_up[selected]),
senses=(None if self.senses is None else self.senses[selected]),
slacks=(None if self.slacks is None else self.slacks[selected]),
)
class InternalSolver(ABC):
"""
Abstract class representing the MIP solver used internally by LearningSolver.
"""
@abstractmethod
def add_constraints(self, cf: Constraints) -> None:
"""Adds the given constraints to the model."""
pass
@abstractmethod
def are_constraints_satisfied(
self,
cf: Constraints,
tol: float = 1e-5,
) -> List[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:
"""
Returns an infeasible instance, for testing purposes.
"""
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 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,
) -> Constraints:
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,
) -> Variables:
"""
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 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: np.ndarray) -> None:
"""
Removes the given constraints from the model.
"""
pass
@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
@abstractmethod
def solve(
self,
tee: bool = False,
iteration_cb: Optional[IterationCallback] = None,
lazy_cb: Optional[LazyCallback] = None,
user_cut_cb: Optional[UserCutCallback] = None,
) -> MIPSolveStats:
"""
Solves the currently loaded instance. After this method finishes,
the best solution found can be retrieved by calling `get_solution`.
Parameters
----------
iteration_cb: IterationCallback
By default, InternalSolver makes a single call to the native `solve`
method and returns the result. If an iteration callback is provided
instead, InternalSolver enters a loop, where `solve` and `iteration_cb`
are called alternatively. To stop the loop, `iteration_cb` should return
False. Any other result causes the solver to loop again.
lazy_cb: LazyCallback
This function is called whenever the solver finds a new candidate
solution and can be used to add lazy constraints to the model. Only the
following operations within the callback are allowed:
- Querying the value of a variable
- Querying if a constraint is satisfied
- Adding a new constraint to the problem
Additional operations may be allowed by specific subclasses.
user_cut_cb: UserCutCallback
This function is called whenever the solver found a new integer-infeasible
solution and needs to generate cutting planes to cut it off.
tee: bool
If true, prints the solver log to the screen.
"""
pass
@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`.
This method should not permanently modify the problem. That is, subsequent
calls to `solve` should solve the original MIP, not the LP relaxation.
Parameters
----------
tee
If true, prints the solver log to the screen.
"""
pass

View File

@@ -1,591 +1,43 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import time
import traceback
from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO, Union
from overrides import overrides
from p_tqdm import p_map, p_umap
from tqdm.auto import tqdm
from miplearn.features.sample import Hdf5Sample, Sample
from miplearn.components.component import Component
from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.components.dynamic_user_cuts import UserCutsComponent
from miplearn.components.objective import ObjectiveValueComponent
from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features.extractor import FeaturesExtractor
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput
from miplearn.solvers.internal import InternalSolver
from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver
from miplearn.types import LearningSolveStats, ConstraintName
import gzip
import pickle
import miplearn
import json
from os.path import exists
from os import remove
import pyomo.environ as pe
from tempfile import NamedTemporaryFile
from typing import List, Any, Union
logger = logging.getLogger(__name__)
class PyomoFindLazyCutCallbackHandler:
def __init__(self):
pass
def value(self, var):
return var.value
class PyomoEnforceLazyCutsCallbackHandler:
def __init__(self, opt, model):
self.model = model
self.opt = opt
if not hasattr(model, "miplearn_lazy_cb"):
model.miplearn_lazy_cb = pe.ConstraintList()
def enforce(self, expr):
constr = self.model.miplearn_lazy_cb.add(expr=expr)
self.opt.add_constraint(constr)
class FileInstanceWrapper(Instance):
def __init__(
self, data_filename: Any, build_model: Callable, mode: Optional[str] = None
):
super().__init__()
assert data_filename.endswith(".pkl.gz")
self.filename = data_filename
self.sample_filename = data_filename.replace(".pkl.gz", ".h5")
self.build_model = build_model
self.mode = mode
self.sample = None
self.model = None
@overrides
def to_model(self) -> Any:
if self.model is None:
self.model = miplearn.load(self.filename, self.build_model)
return self.model
@overrides
def create_sample(self) -> Sample:
return self.sample
@overrides
def get_samples(self) -> List[Sample]:
return [self.sample]
@overrides
def free(self) -> None:
self.sample.file.close()
@overrides
def load(self) -> None:
if self.mode is None:
self.mode = "r+" if exists(self.sample_filename) else "w"
self.sample = Hdf5Sample(self.sample_filename, mode=self.mode)
@overrides
def has_dynamic_lazy_constraints(self) -> bool:
assert hasattr(self, "model")
return hasattr(self.model, "_miplearn_find_lazy_cuts")
@overrides
def find_violated_lazy_constraints(
self,
solver: "InternalSolver",
model: Any,
) -> Dict[ConstraintName, Any]:
if not hasattr(self.model, "_miplearn_find_lazy_cuts"):
return {}
cb = PyomoFindLazyCutCallbackHandler()
violations = model._miplearn_find_lazy_cuts(cb)
return {json.dumps(v).encode(): v for v in violations}
@overrides
def enforce_lazy_constraint(
self,
solver: "InternalSolver",
model: Any,
violation: Any,
) -> None:
assert isinstance(solver, GurobiPyomoSolver)
cb = PyomoEnforceLazyCutsCallbackHandler(solver._pyomo_solver, model)
model._miplearn_enforce_lazy_cuts(cb, violation)
class MemoryInstanceWrapper(Instance):
def __init__(self, model: Any) -> None:
super().__init__()
assert model is not None
self.model = model
@overrides
def to_model(self) -> Any:
return self.model
@overrides
def has_dynamic_lazy_constraints(self) -> bool:
assert hasattr(self, "model")
return hasattr(self.model, "_miplearn_find_lazy_cuts")
@overrides
def find_violated_lazy_constraints(
self,
solver: "InternalSolver",
model: Any,
) -> Dict[ConstraintName, Any]:
cb = PyomoFindLazyCutCallbackHandler()
violations = model._miplearn_find_lazy_cuts(cb)
return {json.dumps(v).encode(): v for v in violations}
@overrides
def enforce_lazy_constraint(
self,
solver: "InternalSolver",
model: Any,
violation: Any,
) -> None:
assert isinstance(solver, GurobiPyomoSolver)
cb = PyomoEnforceLazyCutsCallbackHandler(solver._pyomo_solver, model)
model._miplearn_enforce_lazy_cuts(cb, violation)
class _GlobalVariables:
def __init__(self) -> None:
self.solver: Optional[LearningSolver] = None
self.build_model: Optional[Callable] = None
self.filenames: Optional[List[str]] = None
self.skip = False
# Global variables used for multiprocessing. Global variables are copied by the
# operating system when the process forks. Local variables are copied through
# serialization, which is a much slower process.
_GLOBAL = [_GlobalVariables()]
def _parallel_solve(
idx: int,
) -> Tuple[Optional[int], Optional[LearningSolveStats]]:
solver = _GLOBAL[0].solver
filenames = _GLOBAL[0].filenames
build_model = _GLOBAL[0].build_model
skip = _GLOBAL[0].skip
assert solver is not None
try:
stats = solver.solve([filenames[idx]], build_model, skip=skip)
return idx, stats[0]
except Exception as e:
traceback.print_exc()
logger.exception(f"Exception while solving {filenames[idx]}. Ignoring.")
return idx, None
from miplearn.h5 import H5File
from miplearn.io import _to_h5_filename
from miplearn.solvers.abstract import AbstractModel
class LearningSolver:
"""
Mixed-Integer Linear Programming (MIP) solver that extracts information
from previous runs and uses Machine Learning methods to accelerate the
solution of new (yet unseen) instances.
def __init__(self, components: List[Any], skip_lp=False):
self.components = components
self.skip_lp = skip_lp
Parameters
----------
components: List[Component]
Set of components in the solver. By default, includes
`ObjectiveValueComponent`, `PrimalSolutionComponent`,
`DynamicLazyConstraintsComponent` and `UserCutsComponent`.
mode: str
If "exact", solves problem to optimality, keeping all optimality
guarantees provided by the MIP solver. If "heuristic", uses machine
learning more aggressively, and may return suboptimal solutions.
solver: Callable[[], InternalSolver]
A callable that constructs the internal solver. If None is provided,
use GurobiPyomoSolver.
use_lazy_cb: bool
If true, use native solver callbacks for enforcing lazy constraints,
instead of a simple loop. May not be supported by all solvers.
solve_lp: bool
If true, solve the root LP relaxation before solving the MIP. This
option should be activated if the LP relaxation is not very
expensive to solve and if it provides good hints for the integer
solution.
"""
def fit(self, data_filenames):
h5_filenames = [_to_h5_filename(f) for f in data_filenames]
for comp in self.components:
comp.fit(h5_filenames)
def __init__(
self,
components: Optional[List[Component]] = None,
mode: str = "exact",
solver: Optional[InternalSolver] = None,
use_lazy_cb: bool = False,
solve_lp: bool = True,
extractor: Optional[FeaturesExtractor] = None,
extract_lhs: bool = True,
extract_sa: bool = True,
) -> None:
if solver is None:
solver = GurobiPyomoSolver()
if extractor is None:
extractor = FeaturesExtractor(
with_sa=extract_sa,
with_lhs=extract_lhs,
)
assert isinstance(solver, InternalSolver)
self.components: Dict[str, Component] = {}
self.internal_solver: Optional[InternalSolver] = None
self.internal_solver_prototype: InternalSolver = solver
self.mode: str = mode
self.solve_lp: bool = solve_lp
self.tee = False
self.use_lazy_cb: bool = use_lazy_cb
self.extractor = extractor
if components is not None:
for comp in components:
self._add_component(comp)
else:
self._add_component(ObjectiveValueComponent())
self._add_component(PrimalSolutionComponent(mode=mode))
self._add_component(DynamicLazyConstraintsComponent())
self._add_component(UserCutsComponent())
assert self.mode in ["exact", "heuristic"]
def _solve(
self,
instance: Instance,
model: Any = None,
discard_output: bool = False,
tee: bool = False,
) -> LearningSolveStats:
"""
Solves the given instance. If trained machine-learning models are
available, they will be used to accelerate the solution process.
The argument `instance` may be either an Instance object or a
filename pointing to a pickled Instance object.
This method adds a new training sample to `instance.training_sample`.
If a filename is provided, then the file is modified in-place. That is,
the original file is overwritten.
If `solver.solve_lp_first` is False, the properties lp_solution and
lp_value will be set to dummy values.
Parameters
----------
instance: Instance
The instance to be solved.
model: Any
The corresponding Pyomo model. If not provided, it will be created.
discard_output: bool
If True, do not write the modified instances anywhere; simply discard
them. Useful during benchmarking.
tee: bool
If true, prints solver log to screen.
Returns
-------
LearningSolveStats
A dictionary of solver statistics containing at least the following
keys: "Lower bound", "Upper bound", "Wallclock time", "Nodes",
"Sense", "Log", "Warm start value" and "LP value".
Additional components may generate additional keys. For example,
ObjectiveValueComponent adds the keys "Predicted LB" and
"Predicted UB". See the documentation of each component for more
details.
"""
# Generate model
# -------------------------------------------------------
instance.load()
if model is None:
with _RedirectOutput([]):
model = instance.to_model()
# Initialize training sample
# -------------------------------------------------------
sample = instance.create_sample()
# Initialize stats
# -------------------------------------------------------
stats: LearningSolveStats = {}
# Initialize internal solver
# -------------------------------------------------------
self.tee = tee
self.internal_solver = self.internal_solver_prototype.clone()
assert self.internal_solver is not None
assert isinstance(self.internal_solver, InternalSolver)
self.internal_solver.set_instance(instance, model)
# Extract features (after-load)
# -------------------------------------------------------
logger.info("Extracting features (after-load)...")
initial_time = time.time()
self.extractor.extract_after_load_features(
instance, self.internal_solver, sample
)
logger.info(
"Features (after-load) extracted in %.2f seconds"
% (time.time() - initial_time)
)
callback_args = (
self,
instance,
model,
stats,
sample,
)
# Solve root LP relaxation
# -------------------------------------------------------
lp_stats = None
if self.solve_lp:
logger.debug("Running before_solve_lp callbacks...")
for component in self.components.values():
component.before_solve_lp(*callback_args)
logger.info("Solving root LP relaxation...")
lp_stats = self.internal_solver.solve_lp(tee=tee)
stats.update(cast(LearningSolveStats, lp_stats.__dict__))
assert lp_stats.lp_wallclock_time is not None
logger.info(
"LP relaxation solved in %.2f seconds" % lp_stats.lp_wallclock_time
)
logger.debug("Running after_solve_lp callbacks...")
for component in self.components.values():
component.after_solve_lp(*callback_args)
# Extract features (after-lp)
# -------------------------------------------------------
logger.info("Extracting features (after-lp)...")
initial_time = time.time()
self.extractor.extract_after_lp_features(
self.internal_solver, sample, lp_stats
)
logger.info(
"Features (after-lp) extracted in %.2f seconds"
% (time.time() - initial_time)
)
# Callback wrappers
# -------------------------------------------------------
def iteration_cb_wrapper() -> bool:
should_repeat = False
for comp in self.components.values():
if comp.iteration_cb(self, instance, model):
should_repeat = True
return should_repeat
def lazy_cb_wrapper(
cb_solver: InternalSolver,
cb_model: Any,
) -> None:
for comp in self.components.values():
comp.lazy_cb(self, instance, model)
def user_cut_cb_wrapper(
cb_solver: InternalSolver,
cb_model: Any,
) -> None:
for comp in self.components.values():
comp.user_cut_cb(self, instance, model)
lazy_cb = None
if self.use_lazy_cb:
lazy_cb = lazy_cb_wrapper
user_cut_cb = None
if instance.has_user_cuts():
user_cut_cb = user_cut_cb_wrapper
# Before-solve callbacks
# -------------------------------------------------------
logger.debug("Running before_solve_mip callbacks...")
for component in self.components.values():
component.before_solve_mip(*callback_args)
# Solve MIP
# -------------------------------------------------------
logger.info("Solving MIP...")
mip_stats = self.internal_solver.solve(
tee=tee,
iteration_cb=iteration_cb_wrapper,
user_cut_cb=user_cut_cb,
lazy_cb=lazy_cb,
)
assert mip_stats.mip_wallclock_time is not None
logger.info("MIP solved in %.2f seconds" % mip_stats.mip_wallclock_time)
stats.update(cast(LearningSolveStats, mip_stats.__dict__))
stats["Solver"] = "default"
stats["Gap"] = self._compute_gap(
ub=mip_stats.mip_upper_bound,
lb=mip_stats.mip_lower_bound,
)
stats["Mode"] = self.mode
# Extract features (after-mip)
# -------------------------------------------------------
logger.info("Extracting features (after-mip)...")
initial_time = time.time()
for (k, v) in mip_stats.__dict__.items():
sample.put_scalar(k, v)
self.extractor.extract_after_mip_features(self.internal_solver, sample)
logger.info(
"Features (after-mip) extracted in %.2f seconds"
% (time.time() - initial_time)
)
# After-solve callbacks
# -------------------------------------------------------
logger.debug("Calling after_solve_mip callbacks...")
for component in self.components.values():
component.after_solve_mip(*callback_args)
# Flush
# -------------------------------------------------------
if not discard_output:
instance.flush()
instance.free()
return stats
def solve(
self,
arg: Union[Any, List[str]],
build_model: Optional[Callable] = None,
tee: bool = False,
progress: bool = False,
skip: bool = False,
) -> Union[LearningSolveStats, List[LearningSolveStats]]:
if isinstance(arg, list):
def optimize(self, model: Union[str, AbstractModel], build_model=None):
if isinstance(model, str):
h5_filename = _to_h5_filename(model)
assert build_model is not None
stats = []
for i in tqdm(arg, disable=not progress):
instance = FileInstanceWrapper(i, build_model)
solved = False
if exists(instance.sample_filename):
try:
with Hdf5Sample(instance.sample_filename, mode="r") as sample:
if sample.get_scalar("mip_lower_bound"):
solved = True
except OSError:
# File exists but it is unreadable/corrupted. Delete it.
remove(instance.sample_filename)
if solved and skip:
stats.append({})
else:
s = self._solve(instance, tee=tee)
# Export to gzipped MPS file
mps_filename = instance.sample_filename.replace(".h5", ".mps")
instance.model.write(
filename=mps_filename,
io_options={
"labeler": pe.NameLabeler(),
"skip_objective_sense": True,
},
)
with open(mps_filename, "rb") as original:
with gzip.open(f"{mps_filename}.gz", "wb") as compressed:
compressed.writelines(original)
remove(mps_filename)
stats.append(s)
return stats
model = build_model(model)
else:
return self._solve(MemoryInstanceWrapper(arg), tee=tee)
h5_filename = NamedTemporaryFile().name
stats = {}
mode = "r+" if exists(h5_filename) else "w"
with H5File(h5_filename, mode) as h5:
model.extract_after_load(h5)
if not self.skip_lp:
relaxed = model.relax()
relaxed.optimize()
relaxed.extract_after_lp(h5)
for comp in self.components:
comp.before_mip(h5_filename, model, stats)
model.optimize()
model.extract_after_mip(h5)
def fit(
self,
filenames: List[str],
build_model: Callable,
progress: bool = False,
n_jobs: int = 1,
) -> None:
instances: List[Instance] = [
FileInstanceWrapper(f, build_model, mode="r") for f in filenames
]
self._fit(instances, progress=progress, n_jobs=n_jobs)
def parallel_solve(
self,
filenames: List[str],
build_model: Optional[Callable] = None,
n_jobs: int = 4,
progress: bool = False,
label: str = "solve",
skip: bool = False,
) -> List[LearningSolveStats]:
self.internal_solver = None
self._silence_miplearn_logger()
_GLOBAL[0].solver = self
_GLOBAL[0].build_model = build_model
_GLOBAL[0].filenames = filenames
_GLOBAL[0].skip = skip
results = p_umap(
_parallel_solve,
list(range(len(filenames))),
num_cpus=n_jobs,
disable=not progress,
desc=label,
)
stats: List[LearningSolveStats] = [{} for _ in range(len(filenames))]
for (idx, s) in results:
if s:
stats[idx] = s
self._restore_miplearn_logger()
return stats
def _fit(
self,
training_instances: List[Instance],
n_jobs: int = 1,
progress: bool = False,
) -> None:
if len(training_instances) == 0:
logger.warning("Empty list of training instances provided. Skipping.")
return
Component.fit_multiple(
list(self.components.values()),
training_instances,
n_jobs=n_jobs,
progress=progress,
)
def _add_component(self, component: Component) -> None:
name = component.__class__.__name__
self.components[name] = component
def _silence_miplearn_logger(self) -> None:
miplearn_logger = logging.getLogger("miplearn")
self.prev_log_level = miplearn_logger.getEffectiveLevel()
miplearn_logger.setLevel(logging.WARNING)
def _restore_miplearn_logger(self) -> None:
miplearn_logger = logging.getLogger("miplearn")
miplearn_logger.setLevel(self.prev_log_level)
def __getstate__(self) -> Dict:
self.internal_solver = None
return self.__dict__
@staticmethod
def _compute_gap(ub: Optional[float], lb: Optional[float]) -> Optional[float]:
if lb is None or ub is None or lb * ub < 0:
# solver did not find a solution and/or bound
return None
elif abs(ub - lb) < 1e-6:
# avoid division by zero when ub = lb = 0
return 0.0
else:
# divide by max(abs(ub),abs(lb)) to ensure gap <= 1
return (ub - lb) / max(abs(ub), abs(lb))

366
miplearn/solvers/pyomo.py Normal file
View File

@@ -0,0 +1,366 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from numbers import Number
from typing import Optional, Dict, List, Any
import numpy as np
import pyomo
from pyomo.core import Objective, Var, Suffix
from pyomo.core.base import _GeneralVarData
from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression
from scipy.sparse import coo_matrix
from miplearn.h5 import H5File
from miplearn.solvers.abstract import AbstractModel
import pyomo.environ as pe
class PyomoModel(AbstractModel):
def __init__(self, model: pe.ConcreteModel, solver_name: str = "gurobi_persistent"):
self.inner = model
self.solver_name = solver_name
self.solver = pe.SolverFactory(solver_name)
self.is_persistent = hasattr(self.solver, "set_instance")
if self.is_persistent:
self.solver.set_instance(model)
self.results = None
self._is_warm_start_available = False
if not hasattr(self.inner, "dual"):
self.inner.dual = Suffix(direction=Suffix.IMPORT)
self.inner.rc = Suffix(direction=Suffix.IMPORT)
self.inner.slack = Suffix(direction=Suffix.IMPORT)
def add_constrs(
self,
var_names: np.ndarray,
constrs_lhs: np.ndarray,
constrs_sense: np.ndarray,
constrs_rhs: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
variables = self._var_names_to_vars(var_names)
if not hasattr(self.inner, "added_eqs"):
self.inner.added_eqs = pe.ConstraintList()
for i in range(len(constrs_sense)):
lhs = sum([variables[j] * constrs_lhs[i, j] for j in range(len(variables))])
sense = constrs_sense[i]
rhs = constrs_rhs[i]
if sense == b"=":
eq = self.inner.added_eqs.add(lhs == rhs)
elif sense == b"<":
eq = self.inner.added_eqs.add(lhs <= rhs)
elif sense == b">":
eq = self.inner.added_eqs.add(lhs >= rhs)
else:
raise Exception(f"Unknown sense: {sense}")
self.solver.add_constraint(eq)
def _var_names_to_vars(self, var_names):
varname_to_var = {}
for var in self.inner.component_objects(Var):
for idx in var:
v = var[idx]
varname_to_var[v.name] = var[idx]
return [varname_to_var[var_name.decode()] for var_name in var_names]
def extract_after_load(self, h5: H5File) -> None:
self._extract_after_load_vars(h5)
self._extract_after_load_constrs(h5)
h5.put_scalar("static_sense", self._get_sense())
def extract_after_lp(self, h5: H5File) -> None:
self._extract_after_lp_vars(h5)
self._extract_after_lp_constrs(h5)
h5.put_scalar("lp_obj_value", self.results["Problem"][0]["Lower bound"])
h5.put_scalar("lp_wallclock_time", self._get_runtime())
def _get_runtime(self):
solver_dict = self.results["Solver"][0]
for key in ["Wallclock time", "User time"]:
if isinstance(solver_dict[key], Number):
return solver_dict[key]
raise Exception("Time unavailable")
def extract_after_mip(self, h5: H5File) -> None:
h5.put_scalar("mip_wallclock_time", self._get_runtime())
if self.results["Solver"][0]["Termination condition"] == "infeasible":
return
self._extract_after_mip_vars(h5)
self._extract_after_mip_constrs(h5)
if self._get_sense() == "max":
obj_value = self.results["Problem"][0]["Lower bound"]
obj_bound = self.results["Problem"][0]["Upper bound"]
else:
obj_value = self.results["Problem"][0]["Upper bound"]
obj_bound = self.results["Problem"][0]["Lower bound"]
h5.put_scalar("mip_obj_value", obj_value)
h5.put_scalar("mip_obj_bound", obj_bound)
h5.put_scalar("mip_gap", self._gap(obj_value, obj_bound))
def fix_variables(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
variables = self._var_names_to_vars(var_names)
for (var, val) in zip(variables, var_values):
if np.isfinite(val):
var.fix(val)
self.solver.update_var(var)
def optimize(self) -> None:
if self.is_persistent:
self.results = self.solver.solve(
tee=True,
warmstart=self._is_warm_start_available,
)
else:
self.results = self.solver.solve(
self.inner,
tee=True,
)
def relax(self) -> "AbstractModel":
relaxed = self.inner.clone()
for var in relaxed.component_objects(Var):
for idx in var:
if var[idx].domain == pyomo.core.base.set_types.Binary:
lb, ub = var[idx].bounds
var[idx].setlb(lb)
var[idx].setub(ub)
var[idx].domain = pyomo.core.base.set_types.Reals
return PyomoModel(relaxed, self.solver_name)
def set_warm_starts(
self,
var_names: np.ndarray,
var_values: np.ndarray,
stats: Optional[Dict] = None,
) -> None:
assert len(var_values.shape) == 2
(n_starts, n_vars) = var_values.shape
assert len(var_names.shape) == 1
assert var_names.shape[0] == n_vars
assert n_starts == 1, "Pyomo does not support multiple warm starts"
variables = self._var_names_to_vars(var_names)
for (var, val) in zip(variables, var_values[0, :]):
if np.isfinite(val):
var.value = val
self._is_warm_start_available = True
def _extract_after_load_vars(self, h5):
names: List[str] = []
types: List[str] = []
upper_bounds: List[float] = []
lower_bounds: List[float] = []
obj_coeffs: List[float] = []
obj = None
obj_offset = 0.0
obj_count = 0
for obj in self.inner.component_objects(Objective):
obj, obj_offset = self._parse_pyomo_expr(obj.expr)
obj_count += 1
assert obj_count == 1, f"One objective function expected; found {obj_count}"
for (i, var) in enumerate(self.inner.component_objects(pyomo.core.Var)):
for idx in var:
v = var[idx]
# Variable name
if idx is None:
names.append(var.name)
else:
names.append(var[idx].name)
# Variable type
if v.domain == pyomo.core.Binary:
types.append("B")
elif v.domain in [
pyomo.core.Reals,
pyomo.core.NonNegativeReals,
pyomo.core.NonPositiveReals,
pyomo.core.NegativeReals,
pyomo.core.PositiveReals,
]:
types.append("C")
else:
raise Exception(f"unknown variable domain: {v.domain}")
# Variable upper/lower bounds
lb, ub = v.bounds
if lb is None:
lb = -float("inf")
if ub is None:
ub = float("Inf")
upper_bounds.append(float(ub))
lower_bounds.append(float(lb))
# Objective coefficients
if v.name in obj:
obj_coeffs.append(obj[v.name])
else:
obj_coeffs.append(0.0)
h5.put_array("static_var_names", np.array(names, dtype="S"))
h5.put_array("static_var_types", np.array(types, dtype="S"))
h5.put_array("static_var_lower_bounds", np.array(lower_bounds))
h5.put_array("static_var_upper_bounds", np.array(upper_bounds))
h5.put_array("static_var_obj_coeffs", np.array(obj_coeffs))
h5.put_scalar("static_obj_offset", obj_offset)
def _extract_after_load_constrs(self, h5):
names: List[str] = []
rhs: List[float] = []
senses: List[str] = []
lhs_row: List[int] = []
lhs_col: List[int] = []
lhs_data: List[float] = []
varname_to_idx = {}
for var in self.inner.component_objects(Var):
for idx in var:
varname = var.name
if idx is not None:
varname = var[idx].name
varname_to_idx[varname] = len(varname_to_idx)
def _parse_constraint(c: pe.Constraint, row: int) -> None:
# Extract RHS and sense
has_ub = c.has_ub()
has_lb = c.has_lb()
assert (
(not has_lb) or (not has_ub) or c.upper() == c.lower()
), "range constraints not supported"
if not has_ub:
senses.append(">")
rhs.append(float(c.lower()))
elif not has_lb:
senses.append("<")
rhs.append(float(c.upper()))
else:
senses.append("=")
rhs.append(float(c.upper()))
# Extract LHS
expr = c.body
if isinstance(expr, SumExpression):
for term in expr._args_:
if isinstance(term, MonomialTermExpression):
lhs_row.append(row)
lhs_col.append(varname_to_idx[term._args_[1].name])
lhs_data.append(float(term._args_[0]))
elif isinstance(term, _GeneralVarData):
lhs_row.append(row)
lhs_col.append(varname_to_idx[term.name])
lhs_data.append(1.0)
else:
raise Exception(f"Unknown term type: {term.__class__.__name__}")
elif isinstance(expr, _GeneralVarData):
lhs_row.append(row)
lhs_col.append(varname_to_idx[expr.name])
lhs_data.append(1.0)
else:
raise Exception(f"Unknown expression type: {expr.__class__.__name__}")
curr_row = 0
for (i, constr) in enumerate(
self.inner.component_objects(pyomo.core.Constraint)
):
if len(constr) > 0:
for idx in constr:
names.append(constr[idx].name)
_parse_constraint(constr[idx], curr_row)
curr_row += 1
else:
names.append(constr.name)
_parse_constraint(constr, curr_row)
curr_row += 1
lhs = coo_matrix((lhs_data, (lhs_row, lhs_col))).tocoo()
h5.put_sparse("static_constr_lhs", lhs)
h5.put_array("static_constr_names", np.array(names, dtype="S"))
h5.put_array("static_constr_rhs", np.array(rhs))
h5.put_array("static_constr_sense", np.array(senses, dtype="S"))
def _extract_after_lp_vars(self, h5):
rc = []
values = []
for var in self.inner.component_objects(Var):
for idx in var:
v = var[idx]
rc.append(self.inner.rc[v])
values.append(v.value)
h5.put_array("lp_var_reduced_costs", np.array(rc))
h5.put_array("lp_var_values", np.array(values))
def _extract_after_lp_constrs(self, h5):
dual = []
slacks = []
for constr in self.inner.component_objects(pyomo.core.Constraint):
for idx in constr:
c = constr[idx]
dual.append(self.inner.dual[c])
slacks.append(abs(self.inner.slack[c]))
h5.put_array("lp_constr_dual_values", np.array(dual))
h5.put_array("lp_constr_slacks", np.array(slacks))
def _extract_after_mip_vars(self, h5):
values = []
for var in self.inner.component_objects(Var):
for idx in var:
v = var[idx]
values.append(v.value)
h5.put_array("mip_var_values", np.array(values))
def _extract_after_mip_constrs(self, h5):
slacks = []
for constr in self.inner.component_objects(pyomo.core.Constraint):
for idx in constr:
c = constr[idx]
slacks.append(abs(self.inner.slack[c]))
h5.put_array("mip_constr_slacks", np.array(slacks))
def _parse_pyomo_expr(self, expr: Any):
lhs = {}
offset = 0.0
if isinstance(expr, SumExpression):
for term in expr._args_:
if isinstance(term, MonomialTermExpression):
lhs[term._args_[1].name] = float(term._args_[0])
elif isinstance(term, _GeneralVarData):
lhs[term.name] = 1.0
elif isinstance(term, Number):
offset += term
else:
raise Exception(f"Unknown term type: {term.__class__.__name__}")
elif isinstance(expr, _GeneralVarData):
lhs[expr.name] = 1.0
else:
raise Exception(f"Unknown expression type: {expr.__class__.__name__}")
return lhs, offset
def _gap(self, zp, zd, tol=1e-6):
# Reference: https://www.gurobi.com/documentation/9.5/refman/mipgap2.html
if abs(zp) < tol:
if abs(zd) < tol:
return 0
else:
return float("inf")
else:
return abs(zp - zd) / abs(zp)
def _get_sense(self):
for obj in self.inner.component_objects(Objective):
sense = obj.sense
if sense == pyomo.core.kernel.objective.minimize:
return "min"
elif sense == pyomo.core.kernel.objective.maximize:
return "max"
else:
raise Exception(f"Unknown sense: ${sense}")
def write(self, filename: str) -> None:
self.inner.write(filename, io_options={"symbolic_solver_labels": True})

View File

@@ -1,3 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.

View File

@@ -1,677 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import re
import sys
from io import StringIO
from typing import Any, List, Dict, Optional
import numpy as np
import pyomo
from overrides import overrides
from pyomo import environ as pe
from pyomo.core import Var, Suffix, Objective
from pyomo.core.base import _GeneralVarData
from pyomo.core.base.constraint import ConstraintList
from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression
from pyomo.opt import TerminationCondition
from pyomo.opt.base.solvers import SolverFactory
from scipy.sparse import coo_matrix
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput, _none_if_empty
from miplearn.solvers.internal import (
InternalSolver,
LPSolveStats,
IterationCallback,
LazyCallback,
MIPSolveStats,
Variables,
Constraints,
)
from miplearn.types import (
SolverParams,
UserCutCallback,
Solution,
)
logger = logging.getLogger(__name__)
class BasePyomoSolver(InternalSolver):
"""
Base class for all Pyomo solvers.
"""
def __init__(
self,
solver_factory: SolverFactory,
params: SolverParams,
) -> None:
self.instance: Optional[Instance] = None
self.model: Optional[pe.ConcreteModel] = None
self.params = params
self._all_vars: List[pe.Var] = []
self._bin_vars: List[pe.Var] = []
self._is_warm_start_available: bool = False
self._pyomo_solver: SolverFactory = solver_factory
self._obj_sense: str = "min"
self._varname_to_var: Dict[bytes, pe.Var] = {}
self._varname_to_idx: Dict[str, int] = {}
self._name_buffer = {}
self._cname_to_constr: Dict[str, pe.Constraint] = {}
self._termination_condition: str = ""
self._has_lp_solution = False
self._has_mip_solution = False
self._obj: Dict[str, float] = {}
for (key, value) in params.items():
self._pyomo_solver.options[key] = value
def add_constraint(
self,
constr: Any,
) -> None:
assert self.model is not None
self._pyomo_solver.add_constraint(constr)
self._termination_condition = ""
self._has_lp_solution = False
self._has_mip_solution = False
@overrides
def add_constraints(self, cf: Constraints) -> None:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert self.model is not None
lhs = cf.lhs.tocsr()
for i in range(len(cf.names)):
row = lhs[i, :]
lhsi = 0.0
for j in range(row.getnnz()):
lhsi += self._all_vars[row.indices[j]] * row.data[j]
if cf.senses[i] == b"=":
expr = lhsi == cf.rhs[i]
elif cf.senses[i] == b"<":
expr = lhsi <= cf.rhs[i]
elif cf.senses[i] == b">":
expr = lhsi >= cf.rhs[i]
else:
raise Exception(f"Unknown sense: {cf.senses[i]}")
cl = pe.Constraint(expr=expr, name=cf.names[i])
self.model.add_component(cf.names[i].decode(), cl)
self._pyomo_solver.add_constraint(cl)
self._cname_to_constr[cf.names[i]] = cl
self._termination_condition = ""
self._has_lp_solution = False
self._has_mip_solution = False
@overrides
def are_callbacks_supported(self) -> bool:
return False
@overrides
def are_constraints_satisfied(
self,
cf: Constraints,
tol: float = 1e-5,
) -> List[bool]:
assert cf.names is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert cf.senses is not None
x = [v.value for v in self._all_vars]
lhs = cf.lhs.tocsr() * x
result = []
for i in range(len(lhs)):
if cf.senses[i] == b"<":
result.append(lhs[i] <= cf.rhs[i] + tol)
elif cf.senses[i] == b">":
result.append(lhs[i] >= cf.rhs[i] - tol)
elif cf.senses[i] == b"=":
result.append(abs(cf.rhs[i] - lhs[i]) < tol)
else:
raise Exception(f"unknown sense: {cf.senses[i]}")
return result
@overrides
def build_test_instance_infeasible(self) -> Instance:
return PyomoTestInstanceInfeasible()
@overrides
def build_test_instance_knapsack(self) -> Instance:
return PyomoTestInstanceKnapsack(
weights=[23.0, 26.0, 20.0, 18.0],
prices=[505.0, 352.0, 458.0, 220.0],
capacity=67.0,
)
@overrides
def fix(self, solution: Solution) -> None:
for (varname, value) in solution.items():
if value is None:
continue
var = self._varname_to_var[varname]
var.fix(value)
self._pyomo_solver.update_var(var)
@overrides
def get_constraints(
self,
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> Constraints:
model = self.model
assert model is not None
names: List[str] = []
rhs: List[float] = []
senses: List[str] = []
dual_values: List[float] = []
slacks: List[float] = []
lhs_row: List[int] = []
lhs_col: List[int] = []
lhs_data: List[float] = []
lhs: Optional[coo_matrix] = None
def _parse_constraint(c: pe.Constraint, row: int) -> None:
assert model is not None
if with_static:
# Extract RHS and sense
has_ub = c.has_ub()
has_lb = c.has_lb()
assert (
(not has_lb) or (not has_ub) or c.upper() == c.lower()
), "range constraints not supported"
if not has_ub:
senses.append(">")
rhs.append(float(c.lower()))
elif not has_lb:
senses.append("<")
rhs.append(float(c.upper()))
else:
senses.append("=")
rhs.append(float(c.upper()))
if with_lhs:
# Extract LHS
expr = c.body
if isinstance(expr, SumExpression):
for term in expr._args_:
if isinstance(term, MonomialTermExpression):
lhs_row.append(row)
lhs_col.append(
self._varname_to_idx[self.name(term._args_[1])]
)
lhs_data.append(float(term._args_[0]))
elif isinstance(term, _GeneralVarData):
lhs_row.append(row)
lhs_col.append(self._varname_to_idx[self.name(term)])
lhs_data.append(1.0)
else:
raise Exception(
f"Unknown term type: {term.__class__.__name__}"
)
elif isinstance(expr, _GeneralVarData):
lhs_row.append(row)
lhs_col.append(self._varname_to_idx[self.name(expr)])
lhs_data.append(1.0)
else:
raise Exception(
f"Unknown expression type: {expr.__class__.__name__}"
)
# Extract dual values
if self._has_lp_solution:
dual_values.append(model.dual[c])
# Extract slacks
if self._has_mip_solution or self._has_lp_solution:
slacks.append(model.slack[c])
curr_row = 0
for (i, constr) in enumerate(model.component_objects(pyomo.core.Constraint)):
if isinstance(constr, pe.ConstraintList):
for idx in constr:
names.append(self.name(constr[idx]))
_parse_constraint(constr[idx], curr_row)
curr_row += 1
else:
names.append(self.name(constr))
_parse_constraint(constr, curr_row)
curr_row += 1
if len(lhs_data) > 0:
lhs = coo_matrix((lhs_data, (lhs_row, lhs_col))).tocoo()
return Constraints(
names=_none_if_empty(np.array(names, dtype="S")),
rhs=_none_if_empty(np.array(rhs, dtype=float)),
senses=_none_if_empty(np.array(senses, dtype="S")),
lhs=lhs,
slacks=_none_if_empty(np.array(slacks, dtype=float)),
dual_values=_none_if_empty(np.array(dual_values, dtype=float)),
)
@overrides
def get_constraint_attrs(self) -> List[str]:
return [
"dual_values",
"lhs",
"names",
"rhs",
"senses",
"slacks",
]
@overrides
def get_solution(self) -> Optional[Solution]:
assert self.model is not None
if self.is_infeasible():
return None
solution: Solution = {}
for var in self.model.component_objects(Var):
for index in var:
if var[index].fixed:
continue
solution[self.name(var[index]).encode()] = var[index].value
return solution
@overrides
def get_variables(
self,
with_static: bool = True,
with_sa: bool = True,
) -> Variables:
assert self.model is not None
names: List[str] = []
types: List[str] = []
upper_bounds: List[float] = []
lower_bounds: List[float] = []
obj_coeffs: List[float] = []
reduced_costs: List[float] = []
values: List[float] = []
for (i, var) in enumerate(self.model.component_objects(pyomo.core.Var)):
for idx in var:
v = var[idx]
# Variable name
if idx is None:
names.append(self.name(var))
else:
names.append(self.name(var[idx]))
if with_static:
# Variable type
if v.domain == pyomo.core.Binary:
types.append("B")
elif v.domain in [
pyomo.core.Reals,
pyomo.core.NonNegativeReals,
pyomo.core.NonPositiveReals,
pyomo.core.NegativeReals,
pyomo.core.PositiveReals,
]:
types.append("C")
else:
raise Exception(f"unknown variable domain: {v.domain}")
# Bounds
lb, ub = v.bounds
if ub is not None:
upper_bounds.append(float(ub))
else:
upper_bounds.append(float("inf"))
if lb is not None:
lower_bounds.append(float(lb))
else:
lower_bounds.append(-float("inf"))
# Objective coefficient
name = self.name(v)
if name in self._obj:
obj_coeffs.append(self._obj[name])
else:
obj_coeffs.append(0.0)
# Reduced costs
if self._has_lp_solution:
reduced_costs.append(self.model.rc[v])
# Values
if self._has_lp_solution or self._has_mip_solution:
values.append(v.value)
return Variables(
names=_none_if_empty(np.array(names, dtype="S")),
types=_none_if_empty(np.array(types, dtype="S")),
upper_bounds=_none_if_empty(np.array(upper_bounds, dtype=float)),
lower_bounds=_none_if_empty(np.array(lower_bounds, dtype=float)),
obj_coeffs=_none_if_empty(np.array(obj_coeffs, dtype=float)),
reduced_costs=_none_if_empty(np.array(reduced_costs, dtype=float)),
values=_none_if_empty(np.array(values, dtype=float)),
)
@overrides
def get_variable_attrs(self) -> List[str]:
return [
"names",
# "basis_status",
"categories",
"lower_bounds",
"obj_coeffs",
"reduced_costs",
# "sa_lb_down",
# "sa_lb_up",
# "sa_obj_down",
# "sa_obj_up",
# "sa_ub_down",
# "sa_ub_up",
"types",
"upper_bounds",
"user_features",
"values",
]
@overrides
def is_infeasible(self) -> bool:
return self._termination_condition == TerminationCondition.infeasible
@overrides
def remove_constraints(self, names: List[str]) -> None:
assert self.model is not None
for name in names:
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 set_instance(
self,
instance: Instance,
model: Any = None,
) -> None:
if model is None:
model = instance.to_model()
assert isinstance(
model, pe.ConcreteModel
), f"expected pe.ConcreteModel; found {model.__class__} instead"
self.instance = instance
self.model = model
self.model.extra_constraints = ConstraintList()
self.model.dual = Suffix(direction=Suffix.IMPORT)
self.model.rc = Suffix(direction=Suffix.IMPORT)
self.model.slack = Suffix(direction=Suffix.IMPORT)
self._pyomo_solver.set_instance(model)
self._update_obj()
self._update_vars()
self._update_constrs()
@overrides
def set_warm_start(self, solution: Solution) -> None:
self._clear_warm_start()
count_fixed = 0
for (var_name, value) in solution.items():
if value is None:
continue
var = self._varname_to_var[var_name]
var.value = solution[var_name]
count_fixed += 1
if count_fixed > 0:
self._is_warm_start_available = True
@overrides
def solve(
self,
tee: bool = False,
iteration_cb: Optional[IterationCallback] = None,
lazy_cb: Optional[LazyCallback] = None,
user_cut_cb: Optional[UserCutCallback] = None,
) -> MIPSolveStats:
assert lazy_cb is None, "callbacks are not currently supported"
assert user_cut_cb is None, "callbacks are not currently supported"
total_wallclock_time = 0
streams: List[Any] = [StringIO()]
if tee:
streams += [sys.stdout]
if iteration_cb is None:
iteration_cb = lambda: False
while True:
logger.debug("Solving MIP...")
with _RedirectOutput(streams):
results = self._pyomo_solver.solve(
tee=True,
warmstart=self._is_warm_start_available,
)
self._termination_condition = results["Solver"][0]["Termination condition"]
total_wallclock_time += results["Solver"][0]["Wallclock time"]
if self.is_infeasible():
break
should_repeat = iteration_cb()
if not should_repeat:
break
log = streams[0].getvalue()
node_count = self._extract_node_count(log)
ws_value = self._extract_warm_start_value(log)
lb, ub = None, None
self._has_mip_solution = False
self._has_lp_solution = False
if not self.is_infeasible():
self._has_mip_solution = True
lb = results["Problem"][0]["Lower bound"]
ub = results["Problem"][0]["Upper bound"]
return MIPSolveStats(
mip_lower_bound=lb,
mip_upper_bound=ub,
mip_wallclock_time=total_wallclock_time,
mip_sense=self._obj_sense,
mip_log=log,
mip_nodes=node_count,
mip_warm_start_value=ws_value,
)
@overrides
def solve_lp(
self,
tee: bool = False,
) -> LPSolveStats:
self._relax()
streams: List[Any] = [StringIO()]
if tee:
streams += [sys.stdout]
with _RedirectOutput(streams):
results = self._pyomo_solver.solve(tee=True)
self._termination_condition = results["Solver"][0]["Termination condition"]
self._restore_integrality()
opt_value = None
self._has_lp_solution = False
self._has_mip_solution = False
if not self.is_infeasible():
opt_value = results["Problem"][0]["Lower bound"]
self._has_lp_solution = True
return LPSolveStats(
lp_value=opt_value,
lp_log=streams[0].getvalue(),
lp_wallclock_time=results["Solver"][0]["Wallclock time"],
)
def _clear_warm_start(self) -> None:
for var in self._all_vars:
if not var.fixed:
var.value = None
self._is_warm_start_available = False
@staticmethod
def _extract(
log: str,
regexp: Optional[str],
default: Optional[str] = None,
) -> Optional[str]:
if regexp is None:
return default
value = default
for line in log.splitlines():
matches = re.findall(regexp, line)
if len(matches) == 0:
continue
value = matches[0]
return value
def _extract_node_count(self, log: str) -> Optional[int]:
value = self._extract(log, self._get_node_count_regexp())
if value is None:
return None
return int(value)
def _extract_warm_start_value(self, log: str) -> Optional[float]:
value = self._extract(log, self._get_warm_start_regexp())
if value is None:
return None
return float(value)
def _get_node_count_regexp(self) -> Optional[str]:
return None
def _get_warm_start_regexp(self) -> Optional[str]:
return None
def _parse_pyomo_expr(self, expr: Any) -> Dict[str, float]:
lhs = {}
if isinstance(expr, SumExpression):
for term in expr._args_:
if isinstance(term, MonomialTermExpression):
lhs[self.name(term._args_[1])] = float(term._args_[0])
elif isinstance(term, _GeneralVarData):
lhs[self.name(term)] = 1.0
else:
raise Exception(f"Unknown term type: {term.__class__.__name__}")
elif isinstance(expr, _GeneralVarData):
lhs[self.name(expr)] = 1.0
else:
raise Exception(f"Unknown expression type: {expr.__class__.__name__}")
return lhs
def _relax(self) -> None:
for var in self._bin_vars:
lb, ub = var.bounds
var.setlb(lb)
var.setub(ub)
var.domain = pyomo.core.base.set_types.Reals
self._pyomo_solver.update_var(var)
def _restore_integrality(self) -> None:
for var in self._bin_vars:
var.domain = pyomo.core.base.set_types.Binary
self._pyomo_solver.update_var(var)
def _update_obj(self) -> None:
self._obj_sense = "max"
if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize:
self._obj_sense = "min"
def _update_vars(self) -> None:
assert self.model is not None
self._all_vars = []
self._bin_vars = []
self._varname_to_var = {}
self._varname_to_idx = {}
for var in self.model.component_objects(Var):
for idx in var:
varname = self.name(var)
if idx is not None:
varname = self.name(var[idx])
self._varname_to_var[varname.encode()] = var[idx]
self._varname_to_idx[varname] = len(self._all_vars)
self._all_vars += [var[idx]]
if var[idx].domain == pyomo.core.base.set_types.Binary:
self._bin_vars += [var[idx]]
for obj in self.model.component_objects(Objective):
self._obj = self._parse_pyomo_expr(obj.expr)
break
def _update_constrs(self) -> None:
assert self.model is not None
self._cname_to_constr.clear()
for constr in self.model.component_objects(pyomo.core.Constraint):
if isinstance(constr, pe.ConstraintList):
for idx in constr:
self._cname_to_constr[self.name(constr[idx])] = constr[idx]
else:
self._cname_to_constr[self.name(constr)] = constr
def name(self, comp):
return comp.getname(name_buffer=self._name_buffer)
class PyomoTestInstanceInfeasible(Instance):
@overrides
def to_model(self) -> pe.ConcreteModel:
model = pe.ConcreteModel()
model.x = pe.Var([0], domain=pe.Binary)
model.OBJ = pe.Objective(expr=model.x[0], sense=pe.maximize)
model.eq = pe.Constraint(expr=model.x[0] >= 2)
return model
class PyomoTestInstanceKnapsack(Instance):
"""
Simpler (one-dimensional) Knapsack Problem, used for testing.
"""
def __init__(
self,
weights: List[float],
prices: List[float],
capacity: float,
) -> None:
super().__init__()
self.weights = weights
self.prices = prices
self.capacity = capacity
self.n = len(weights)
@overrides
def to_model(self) -> pe.ConcreteModel:
model = pe.ConcreteModel()
items = range(len(self.weights))
model.x = pe.Var(items, domain=pe.Binary)
model.z = pe.Var(domain=pe.Reals, bounds=(0, self.capacity))
model.OBJ = pe.Objective(
expr=sum(model.x[v] * self.prices[v] for v in items),
sense=pe.maximize,
)
model.eq_capacity = pe.Constraint(
expr=sum(model.x[v] * self.weights[v] for v in items) == model.z
)
return model
@overrides
def get_instance_features(self) -> np.ndarray:
return np.array(
[
self.capacity,
np.average(self.weights),
]
)
@overrides
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
return np.vstack(
[
[[self.weights[i], self.prices[i]] for i in range(self.n)],
[0.0, 0.0],
]
)
@overrides
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
return np.array(
["default" if n.decode().startswith("x") else "" for n in names],
dtype="S",
)

View File

@@ -1,48 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import Optional
from overrides import overrides
from pyomo import environ as pe
from scipy.stats import randint
from miplearn.solvers.pyomo.base import BasePyomoSolver
from miplearn.types import SolverParams
class CplexPyomoSolver(BasePyomoSolver):
"""
An InternalSolver that uses CPLEX and the Pyomo modeling language.
Parameters
----------
params: dict
Dictionary of options to pass to the Pyomo solver. For example,
{"mip_display": 5} to increase the log verbosity.
"""
def __init__(
self,
params: Optional[SolverParams] = None,
) -> None:
if params is None:
params = {}
if "mip_display" not in params.keys():
params["mip_display"] = 4
super().__init__(
solver_factory=pe.SolverFactory("cplex_persistent"),
params=params,
)
@overrides
def _get_warm_start_regexp(self) -> str:
return "MIP start .* with objective ([0-9.e+-]*)\\."
@overrides
def _get_node_count_regexp(self) -> str:
return "^[ *] *([0-9]+)"
@overrides
def clone(self) -> "CplexPyomoSolver":
return CplexPyomoSolver(params=self.params)

View File

@@ -1,61 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Optional
from overrides import overrides
from pyomo import environ as pe
from scipy.stats import randint
from miplearn.solvers.pyomo.base import BasePyomoSolver
from miplearn.types import SolverParams
logger = logging.getLogger(__name__)
class GurobiPyomoSolver(BasePyomoSolver):
"""
An InternalSolver that uses Gurobi and the Pyomo modeling language.
Parameters
----------
params: dict
Dictionary of options to pass to the Pyomo solver. For example,
{"Threads": 4} to set the number of threads.
"""
def __init__(
self,
params: Optional[SolverParams] = None,
) -> None:
if params is None:
params = {}
super().__init__(
solver_factory=pe.SolverFactory("gurobi_persistent"),
params=params,
)
@overrides
def clone(self) -> "GurobiPyomoSolver":
return GurobiPyomoSolver(params=self.params)
@overrides
def _extract_node_count(self, log: str) -> int:
return max(1, int(self._pyomo_solver._solver_model.getAttr("NodeCount")))
@overrides
def _get_warm_start_regexp(self) -> str:
return "MIP start with objective ([0-9.e+-]*)"
@overrides
def _get_node_count_regexp(self) -> Optional[str]:
return None
def set_priorities(self, priorities):
for (var_name, priority) in priorities.items():
pvar = self._varname_to_var[var_name]
gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[pvar]
gvar.branchPriority = priority
return None

View File

@@ -1,42 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Optional
from overrides import overrides
from pyomo import environ as pe
from scipy.stats import randint
from miplearn.solvers.pyomo.base import BasePyomoSolver
from miplearn.types import SolverParams
logger = logging.getLogger(__name__)
class XpressPyomoSolver(BasePyomoSolver):
"""
An InternalSolver that uses XPRESS and the Pyomo modeling language.
Parameters
----------
params: dict
Dictionary of options to pass to the Pyomo solver. For example,
{"Threads": 4} to set the number of threads.
"""
def __init__(
self,
params: Optional[SolverParams] = None,
) -> None:
if params is None:
params = {}
super().__init__(
solver_factory=pe.SolverFactory("xpress_persistent"),
params=params,
)
@overrides
def clone(self) -> "XpressPyomoSolver":
return XpressPyomoSolver(params=self.params)

View File

@@ -1,288 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import Any, List
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.solvers.internal import InternalSolver, Variables, Constraints
inf = float("inf")
# NOTE:
# This file is in the main source folder, so that it can be called from Julia.
def _filter_attrs(allowed_keys: List[str], obj: Any) -> Any:
for key in obj.__dict__.keys():
if key not in allowed_keys:
setattr(obj, key, None)
return obj
def run_internal_solver_tests(solver: InternalSolver) -> None:
run_basic_usage_tests(solver.clone())
run_warm_start_tests(solver.clone())
run_infeasibility_tests(solver.clone())
run_iteration_cb_tests(solver.clone())
if solver.are_callbacks_supported():
run_lazy_cb_tests(solver.clone())
def run_basic_usage_tests(solver: InternalSolver) -> None:
# Create and set instance
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
solver.set_instance(instance, model)
# Fetch variables (after-load)
assert_equals(
solver.get_variables(),
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
lower_bounds=np.array([0.0, 0.0, 0.0, 0.0, 0.0]),
upper_bounds=np.array([1.0, 1.0, 1.0, 1.0, 67.0]),
types=np.array(["B", "B", "B", "B", "C"], dtype="S"),
obj_coeffs=np.array([505.0, 352.0, 458.0, 220.0, 0.0]),
),
)
# Fetch constraints (after-load)
assert_equals(
solver.get_constraints(),
Constraints(
names=np.array(["eq_capacity"], dtype="S"),
rhs=np.array([0.0]),
lhs=coo_matrix([[23.0, 26.0, 20.0, 18.0, -1.0]]),
senses=np.array(["="], dtype="S"),
),
)
# Solve linear programming relaxation
lp_stats = solver.solve_lp()
assert not solver.is_infeasible()
assert lp_stats.lp_value is not None
assert_equals(round(lp_stats.lp_value, 3), 1287.923)
assert lp_stats.lp_log is not None
assert len(lp_stats.lp_log) > 100
assert lp_stats.lp_wallclock_time is not None
assert lp_stats.lp_wallclock_time > 0
# Fetch variables (after-lp)
assert_equals(
solver.get_variables(with_static=False),
_filter_attrs(
solver.get_variable_attrs(),
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
basis_status=np.array(["U", "B", "U", "L", "U"], dtype="S"),
reduced_costs=np.array(
[193.615385, 0.0, 187.230769, -23.692308, 13.538462]
),
sa_lb_down=np.array([-inf, -inf, -inf, -0.111111, -inf]),
sa_lb_up=np.array([1.0, 0.923077, 1.0, 1.0, 67.0]),
sa_obj_down=np.array(
[311.384615, 317.777778, 270.769231, -inf, -13.538462]
),
sa_obj_up=np.array([inf, 570.869565, inf, 243.692308, inf]),
sa_ub_down=np.array([0.913043, 0.923077, 0.9, 0.0, 43.0]),
sa_ub_up=np.array([2.043478, inf, 2.2, inf, 69.0]),
values=np.array([1.0, 0.923077, 1.0, 0.0, 67.0]),
),
),
)
# Fetch constraints (after-lp)
assert_equals(
solver.get_constraints(with_static=False),
_filter_attrs(
solver.get_constraint_attrs(),
Constraints(
basis_status=np.array(["N"], dtype="S"),
dual_values=np.array([13.538462]),
names=np.array(["eq_capacity"], dtype="S"),
sa_rhs_down=np.array([-24.0]),
sa_rhs_up=np.array([2.0]),
slacks=np.array([0.0]),
),
),
)
# Solve MIP
mip_stats = solver.solve(
tee=True,
)
assert not solver.is_infeasible()
assert mip_stats.mip_log is not None
assert len(mip_stats.mip_log) > 100
assert mip_stats.mip_lower_bound is not None
assert_equals(mip_stats.mip_lower_bound, 1183.0)
assert mip_stats.mip_upper_bound is not None
assert_equals(mip_stats.mip_upper_bound, 1183.0)
assert mip_stats.mip_sense is not None
assert_equals(mip_stats.mip_sense, "max")
assert mip_stats.mip_wallclock_time is not None
assert isinstance(mip_stats.mip_wallclock_time, float)
assert mip_stats.mip_wallclock_time > 0
# Fetch variables (after-mip)
assert_equals(
solver.get_variables(with_static=False),
_filter_attrs(
solver.get_variable_attrs(),
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
values=np.array([1.0, 0.0, 1.0, 1.0, 61.0]),
),
),
)
# Fetch constraints (after-mip)
assert_equals(
solver.get_constraints(with_static=False),
_filter_attrs(
solver.get_constraint_attrs(),
Constraints(
names=np.array(["eq_capacity"], dtype="S"),
slacks=np.array([0.0]),
),
),
)
# Build new constraint and verify that it is violated
cf = Constraints(
names=np.array(["cut"], dtype="S"),
lhs=coo_matrix([[1.0, 0.0, 0.0, 0.0, 0.0]]),
rhs=np.array([0.0]),
senses=np.array(["<"], dtype="S"),
)
assert_equals(solver.are_constraints_satisfied(cf), [False])
# Add constraint and verify it affects solution
solver.add_constraints(cf)
assert_equals(
solver.get_constraints(with_static=True),
_filter_attrs(
solver.get_constraint_attrs(),
Constraints(
names=np.array(["eq_capacity", "cut"], dtype="S"),
rhs=np.array([0.0, 0.0]),
lhs=coo_matrix(
[
[23.0, 26.0, 20.0, 18.0, -1.0],
[1.0, 0.0, 0.0, 0.0, 0.0],
]
),
senses=np.array(["=", "<"], dtype="S"),
),
),
)
stats = solver.solve()
assert_equals(stats.mip_lower_bound, 1030.0)
assert_equals(solver.are_constraints_satisfied(cf), [True])
# Remove the new constraint
solver.remove_constraints(np.array(["cut"], dtype="S"))
# New constraint should no longer affect solution
stats = solver.solve()
assert_equals(stats.mip_lower_bound, 1183.0)
def run_warm_start_tests(solver: InternalSolver) -> None:
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
solver.set_instance(instance, model)
solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
if stats.mip_warm_start_value is not None:
assert_equals(stats.mip_warm_start_value, 725.0)
solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 1.0, b"x[2]": 1.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
assert stats.mip_warm_start_value is None
solver.fix({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
assert_equals(stats.mip_lower_bound, 725.0)
assert_equals(stats.mip_upper_bound, 725.0)
def run_infeasibility_tests(solver: InternalSolver) -> None:
instance = solver.build_test_instance_infeasible()
solver.set_instance(instance)
mip_stats = solver.solve()
assert solver.is_infeasible()
assert solver.get_solution() is None
assert mip_stats.mip_upper_bound is None
assert mip_stats.mip_lower_bound is None
lp_stats = solver.solve_lp()
assert solver.get_solution() is None
assert lp_stats.lp_value is None
def run_iteration_cb_tests(solver: InternalSolver) -> None:
instance = solver.build_test_instance_knapsack()
solver.set_instance(instance)
count = 0
def custom_iteration_cb() -> bool:
nonlocal count
count += 1
return count < 5
solver.solve(iteration_cb=custom_iteration_cb)
assert_equals(count, 5)
def run_lazy_cb_tests(solver: InternalSolver) -> None:
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
def lazy_cb(cb_solver: InternalSolver, cb_model: Any) -> None:
relsol = cb_solver.get_solution()
assert relsol is not None
assert relsol[b"x[0]"] is not None
if relsol[b"x[0]"] > 0:
instance.enforce_lazy_constraint(cb_solver, cb_model, None)
solver.set_instance(instance, model)
solver.solve(lazy_cb=lazy_cb)
solution = solver.get_solution()
assert solution is not None
assert_equals(solution[b"x[0]"], 0.0)
def _equals_preprocess(obj: Any) -> Any:
if isinstance(obj, np.ndarray):
if obj.dtype == "float64":
return np.round(obj, decimals=6).tolist()
else:
return obj.tolist()
elif isinstance(obj, coo_matrix):
return obj.todense().tolist()
elif isinstance(obj, (int, str, bool, np.bool_, np.bytes_, bytes, bytearray)):
return obj
elif isinstance(obj, float):
return round(obj, 6)
elif isinstance(obj, list):
return [_equals_preprocess(i) for i in obj]
elif isinstance(obj, tuple):
return tuple(_equals_preprocess(i) for i in obj)
elif obj is None:
return None
elif isinstance(obj, dict):
return {k: _equals_preprocess(v) for (k, v) in obj.items()}
else:
for key in obj.__dict__.keys():
obj.__dict__[key] = _equals_preprocess(obj.__dict__[key])
return obj
def assert_equals(left: Any, right: Any) -> None:
left = _equals_preprocess(left)
right = _equals_preprocess(right)
assert left == right, f"left:\n{left}\nright:\n{right}"