Merge branch 'feature/convert-ineqs' into dev

This commit is contained in:
2021-01-19 07:22:14 -06:00
29 changed files with 1144 additions and 408 deletions

View File

@@ -5,6 +5,7 @@ import re
import sys
import logging
from io import StringIO
from random import randint
from . import RedirectOutput
from .internal import InternalSolver
@@ -33,6 +34,7 @@ class GurobiSolver(InternalSolver):
"""
if params is None:
params = {}
params["InfUnbdInfo"] = True
from gurobipy import GRB
self.GRB = GRB
@@ -84,16 +86,19 @@ class GurobiSolver(InternalSolver):
self._bin_vars[name] = {}
self._bin_vars[name][idx] = var
def _apply_params(self):
for (name, value) in self.params.items():
self.model.setParam(name, value)
def _apply_params(self, streams):
with RedirectOutput(streams):
for (name, value) in self.params.items():
self.model.setParam(name, value)
if "seed" not in [k.lower() for k in self.params.keys()]:
self.model.setParam("Seed", randint(0, 1_000_000))
def solve_lp(self, tee=False):
self._raise_if_callback()
self._apply_params()
streams = [StringIO()]
if tee:
streams += [sys.stdout]
self._apply_params(streams)
for (varname, vardict) in self._bin_vars.items():
for (idx, var) in vardict.items():
var.vtype = self.GRB.CONTINUOUS
@@ -122,16 +127,15 @@ class GurobiSolver(InternalSolver):
if lazy_cb:
self.params["LazyConstraints"] = 1
self._apply_params()
total_wallclock_time = 0
total_nodes = 0
streams = [StringIO()]
if tee:
streams += [sys.stdout]
self._apply_params(streams)
if iteration_cb is None:
iteration_cb = lambda: False
while True:
logger.debug("Solving MIP...")
with RedirectOutput(streams):
if lazy_cb is None:
self.model.optimize()
@@ -161,6 +165,12 @@ class GurobiSolver(InternalSolver):
"Warm start value": self._extract_warm_start_value(log),
}
def get_sense(self):
if self.model.modelSense == 1:
return "min"
else:
return "max"
def get_solution(self):
self._raise_if_callback()
@@ -175,6 +185,16 @@ class GurobiSolver(InternalSolver):
var = self._all_vars[var_name][index]
return self._get_value(var)
def is_infeasible(self):
return self.model.status in [self.GRB.INFEASIBLE, self.GRB.INF_OR_UNBD]
def get_dual(self, cid):
c = self.model.getConstrByName(cid)
if self.is_infeasible():
return c.farkasDual
else:
return c.pi
def _get_value(self, var):
if self.cb_where == self.GRB.Callback.MIPSOL:
return self.model.cbGetSolution(var)
@@ -271,13 +291,18 @@ class GurobiSolver(InternalSolver):
else:
raise Exception("Unknown sense: %s" % sense)
def get_constraint_slacks(self):
return {c.ConstrName: c.Slack for c in self.model.getConstrs()}
def get_inequality_slacks(self):
ineqs = [c for c in self.model.getConstrs() if c.sense != "="]
return {c.ConstrName: c.Slack for c in ineqs}
def set_constraint_sense(self, cid, sense):
c = self.model.getConstrByName(cid)
c.Sense = sense
def get_constraint_sense(self, cid):
c = self.model.getConstrByName(cid)
return c.Sense
def set_constraint_rhs(self, cid, rhs):
c = self.model.getConstrByName(cid)
c.RHS = rhs

View File

@@ -184,13 +184,39 @@ class InternalSolver(ABC):
pass
@abstractmethod
def get_constraint_slacks(self):
def get_inequality_slacks(self):
"""
Returns a dictionary mapping constraint name to the constraint slack
in the current solution.
"""
pass
@abstractmethod
def is_infeasible(self):
"""
Returns True if the model has been proved to be infeasible.
Must be called after solve.
"""
pass
@abstractmethod
def get_dual(self, cid):
"""
If the model is feasible and has been solved to optimality, returns the optimal
value of the dual variable associated with this constraint. If the model is infeasible,
returns a portion of the infeasibility certificate corresponding to the given constraint.
Solve must be called prior to this method.
"""
pass
@abstractmethod
def get_sense(self):
"""
Returns the sense of the problem (either "min" or "max").
"""
pass
@abstractmethod
def is_constraint_satisfied(self, cobj):
pass
@@ -199,6 +225,10 @@ class InternalSolver(ABC):
def set_constraint_sense(self, cid, sense):
pass
@abstractmethod
def get_constraint_sense(self, cid):
pass
@abstractmethod
def set_constraint_rhs(self, cid, rhs):
pass

View File

@@ -11,7 +11,9 @@ import gzip
from copy import deepcopy
from typing import Optional, List
from p_tqdm import p_map
from tempfile import NamedTemporaryFile
from . import RedirectOutput
from .. import (
ObjectiveValueComponent,
PrimalSolutionComponent,
@@ -23,7 +25,6 @@ from .pyomo.gurobi import GurobiPyomoSolver
logger = logging.getLogger(__name__)
# Global memory for multiprocessing
SOLVER = [None] # type: List[Optional[LearningSolver]]
INSTANCES = [None] # type: List[Optional[dict]]
@@ -44,6 +45,52 @@ def _parallel_solve(idx):
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.
Parameters
----------
components
Set of components in the solver. By default, includes:
- ObjectiveValueComponent
- PrimalSolutionComponent
- DynamicLazyConstraintsComponent
- UserCutsComponent
gap_tolerance
Relative MIP gap tolerance. By default, 1e-4.
mode
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
The internal MIP solver to use. Can be either "cplex", "gurobi", a
solver class such as GurobiSolver, or a solver instance such as
GurobiSolver().
threads
Maximum number of threads to use. If None, uses solver default.
time_limit
Maximum running time in seconds. If None, uses solver default.
node_limit
Maximum number of branch-and-bound nodes to explore. If None, uses
solver default.
use_lazy_cb
If True, uses lazy callbacks to enforce lazy constraints, instead of
a simple solver loop. This functionality may not supported by
all internal MIP solvers.
solve_lp_first: bool
If true, solve LP relaxation first, then solve original MILP. 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.
simulate_perfect: bool
If true, each call to solve actually performs three actions: solve
the original problem, train the ML models on the data that was just
collected, and solve the problem again. This is useful for evaluating
the theoretical performance of perfect ML models.
"""
def __init__(
self,
components=None,
@@ -55,47 +102,8 @@ class LearningSolver:
node_limit=None,
solve_lp_first=True,
use_lazy_cb=False,
simulate_perfect=False,
):
"""
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.
Parameters
----------
components
Set of components in the solver. By default, includes:
- ObjectiveValueComponent
- PrimalSolutionComponent
- DynamicLazyConstraintsComponent
- UserCutsComponent
gap_tolerance
Relative MIP gap tolerance. By default, 1e-4.
mode
If "exact", solves problem to optimality, keeping all optimality
guarantees provided by the MIP solver. If "heuristic", uses machine
learning more agressively, and may return suboptimal solutions.
solver
The internal MIP solver to use. Can be either "cplex", "gurobi", a
solver class such as GurobiSolver, or a solver instance such as
GurobiSolver().
threads
Maximum number of threads to use. If None, uses solver default.
time_limit
Maximum running time in seconds. If None, uses solver default.
node_limit
Maximum number of branch-and-bound nodes to explore. If None, uses
solver default.
use_lazy_cb
If True, uses lazy callbacks to enforce lazy constraints, instead of
a simple solver loop. This functionality may not supported by
all internal MIP solvers.
solve_lp_first: bool
If true, solve LP relaxation first, then solve original MILP. 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.
"""
self.components = {}
self.mode = mode
self.internal_solver = None
@@ -107,6 +115,7 @@ class LearningSolver:
self.node_limit = node_limit
self.solve_lp_first = solve_lp_first
self.use_lazy_cb = use_lazy_cb
self.simulate_perfect = simulate_perfect
if components is not None:
for comp in components:
@@ -202,7 +211,31 @@ class LearningSolver:
"Predicted UB". See the documentation of each component for more
details.
"""
if self.simulate_perfect:
if not isinstance(instance, str):
raise Exception("Not implemented")
with tempfile.NamedTemporaryFile(suffix=os.path.basename(instance)) as tmp:
self._solve(
instance=instance,
model=model,
output=tmp.name,
tee=tee,
)
self.fit([tmp.name])
return self._solve(
instance=instance,
model=model,
output=output,
tee=tee,
)
def _solve(
self,
instance,
model=None,
output="",
tee=False,
):
filename = None
fileformat = None
if isinstance(instance, str):
@@ -218,7 +251,8 @@ class LearningSolver:
instance = pickle.load(file)
if model is None:
model = instance.to_model()
with RedirectOutput([]):
model = instance.to_model()
self.tee = tee
self.internal_solver = self._create_internal_solver()
@@ -253,22 +287,27 @@ class LearningSolver:
lazy_cb = lazy_cb_wrapper
logger.info("Solving MILP...")
results = self.internal_solver.solve(
stats = self.internal_solver.solve(
tee=tee,
iteration_cb=iteration_cb,
lazy_cb=lazy_cb,
)
results["LP value"] = instance.lp_value
stats["LP value"] = instance.lp_value
# Read MIP solution and bounds
instance.lower_bound = results["Lower bound"]
instance.upper_bound = results["Upper bound"]
instance.solver_log = results["Log"]
instance.lower_bound = stats["Lower bound"]
instance.upper_bound = stats["Upper bound"]
instance.solver_log = stats["Log"]
instance.solution = self.internal_solver.get_solution()
logger.debug("Calling after_solve callbacks...")
training_data = {}
for component in self.components.values():
component.after_solve(self, instance, model, results)
component.after_solve(self, instance, model, stats, training_data)
if not hasattr(instance, "training_data"):
instance.training_data = []
instance.training_data += [training_data]
if filename is not None and output is not None:
output_filename = output
@@ -282,7 +321,7 @@ class LearningSolver:
with gzip.GzipFile(output_filename, "wb") as file:
pickle.dump(instance, file)
return results
return stats
def parallel_solve(self, instances, n_jobs=4, label="Solve", output=[]):
"""

View File

@@ -256,11 +256,23 @@ class BasePyomoSolver(InternalSolver):
def relax(self):
raise Exception("not implemented")
def get_constraint_slacks(self):
def get_inequality_slacks(self):
raise Exception("not implemented")
def set_constraint_sense(self, cid, sense):
raise Exception("Not implemented")
def get_constraint_sense(self, cid):
raise Exception("Not implemented")
def set_constraint_rhs(self, cid, rhs):
raise Exception("Not implemented")
def is_infeasible(self):
raise Exception("Not implemented")
def get_dual(self, cid):
raise Exception("Not implemented")
def get_sense(self):
raise Exception("Not implemented")

View File

@@ -7,8 +7,11 @@ import pickle
import tempfile
import os
from miplearn import DynamicLazyConstraintsComponent
from miplearn import LearningSolver
from miplearn import (
LearningSolver,
GurobiSolver,
DynamicLazyConstraintsComponent,
)
from . import _get_instance, _get_internal_solvers
@@ -109,3 +112,18 @@ def test_solve_fit_from_disk():
os.remove(filename)
for filename in output:
os.remove(filename)
def test_simulate_perfect():
internal_solver = GurobiSolver()
instance = _get_instance(internal_solver)
with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp:
pickle.dump(instance, tmp)
tmp.flush()
solver = LearningSolver(
solver=internal_solver,
simulate_perfect=True,
)
stats = solver.solve(tmp.name)
assert stats["Lower bound"] == stats["Predicted LB"]