mirror of
https://github.com/ANL-CEEESA/MIPLearn.git
synced 2025-12-06 17:38:51 -06:00
Merge branch 'feature/convert-ineqs' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=[]):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user