mirror of
https://github.com/ANL-CEEESA/MIPLearn.git
synced 2025-12-06 01:18:52 -06:00
MIPLearn v0.3
This commit is contained in:
@@ -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
|
||||
|
||||
70
miplearn/solvers/abstract.py
Normal file
70
miplearn/solvers/abstract.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
366
miplearn/solvers/pyomo.py
Normal 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})
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user