diff --git a/miplearn/components/component.py b/miplearn/components/component.py index 095d19a..a51f936 100644 --- a/miplearn/components/component.py +++ b/miplearn/components/component.py @@ -55,11 +55,11 @@ class Component(ABC): Parameters ---------- - solver: "LearningSolver" + solver: LearningSolver The solver calling this method. instance: Instance The instance being solved. - model: + model: Any The concrete optimization model being solved. stats: dict A dictionary containing statistics about the solution process, such as @@ -101,11 +101,11 @@ class Component(ABC): Parameters ---------- - solver: "LearningSolver" + solver: LearningSolver The solver calling this method. instance: Instance The instance being solved. - model: + model: Any The concrete optimization model being solved. """ return False diff --git a/miplearn/solvers/__init__.py b/miplearn/solvers/__init__.py index 37ccc49..1eebed8 100644 --- a/miplearn/solvers/__init__.py +++ b/miplearn/solvers/__init__.py @@ -9,7 +9,7 @@ from typing import Any, List logger = logging.getLogger(__name__) -class RedirectOutput: +class _RedirectOutput: def __init__(self, streams: List[Any]): self.streams = streams diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 9cf862f..1aca514 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -9,7 +9,7 @@ from random import randint from typing import List, Any, Dict, Optional from miplearn.instance import Instance -from miplearn.solvers import RedirectOutput +from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import ( InternalSolver, LPSolveStats, @@ -23,24 +23,25 @@ logger = logging.getLogger(__name__) 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. + """ + def __init__( self, params: Optional[SolverParams] = None, lazy_cb_frequency: int = 1, ) -> None: - """ - An InternalSolver backed by Gurobi's Python API (without Pyomo). - - Parameters - ---------- - params - Parameters to pass to Gurobi. For example, params={"MIPGap": 1e-3} - sets the gap tolerance to 1e-3. - lazy_cb_frequency - 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. - """ import gurobipy if params is None: @@ -108,7 +109,7 @@ class GurobiSolver(InternalSolver): def _apply_params(self, streams: List[Any]) -> None: assert self.model is not None - with RedirectOutput(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()]: @@ -130,7 +131,7 @@ class GurobiSolver(InternalSolver): var.vtype = self.gp.GRB.CONTINUOUS var.lb = 0.0 var.ub = 1.0 - with RedirectOutput(streams): + with _RedirectOutput(streams): self.model.optimize() for (varname, vardict) in self._bin_vars.items(): for (idx, var) in vardict.items(): @@ -174,7 +175,7 @@ class GurobiSolver(InternalSolver): if iteration_cb is None: iteration_cb = lambda: False while True: - with RedirectOutput(streams): + with _RedirectOutput(streams): if lazy_cb is None: self.model.optimize() else: @@ -362,11 +363,13 @@ class GurobiSolver(InternalSolver): 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): + def set_constraint_sense(self, cid: str, sense: str) -> None: + assert self.model is not None c = self.model.getConstrByName(cid) c.Sense = sense - def get_constraint_sense(self, cid): + def get_constraint_sense(self, cid: str) -> str: + assert self.model is not None c = self.model.getConstrByName(cid) return c.Sense diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 7121416..d64457f 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -15,15 +15,12 @@ from miplearn.types import ( VarIndex, Solution, BranchPriorities, + Constraint, ) logger = logging.getLogger(__name__) -class Constraint: - pass - - class InternalSolver(ABC): """ Abstract class representing the MIP solver used internally by LearningSolver. @@ -61,13 +58,13 @@ class InternalSolver(ABC): Parameters ---------- - iteration_cb: + 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: + 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: @@ -75,7 +72,7 @@ class InternalSolver(ABC): - Querying if a constraint is satisfied - Adding a new constraint to the problem Additional operations may be allowed by specific subclasses. - tee + tee: bool If true, prints the solver log to the screen. """ pass @@ -119,7 +116,7 @@ class InternalSolver(ABC): ---------- instance: Instance The instance to be loaded. - model: + 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()`. @@ -184,10 +181,28 @@ class InternalSolver(ABC): @abstractmethod def set_constraint_sense(self, cid: str, sense: str) -> None: + """ + Modifies the sense of a given constraint. + + Parameters + ---------- + cid: str + The name of the constraint. + sense: str + The new sense (either "<", ">" or "="). + """ pass @abstractmethod def get_constraint_sense(self, cid: str) -> str: + """ + Returns the sense of a given constraint (either "<", ">" or "="). + + Parameters + ---------- + cid: str + The name of the constraint. + """ pass @abstractmethod diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 5a540f6..ecdc511 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -17,7 +17,7 @@ from miplearn.components.lazy_dynamic import DynamicLazyConstraintsComponent from miplearn.components.objective import ObjectiveValueComponent from miplearn.components.primal import PrimalSolutionComponent from miplearn.instance import Instance -from miplearn.solvers import RedirectOutput +from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import InternalSolver from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver from miplearn.types import MIPSolveStats, TrainingSample @@ -25,7 +25,7 @@ from miplearn.types import MIPSolveStats, TrainingSample logger = logging.getLogger(__name__) -class GlobalVariables: +class _GlobalVariables: def __init__(self) -> None: self.solver: Optional[LearningSolver] = None self.instances: Optional[Union[List[str], List[Instance]]] = None @@ -36,14 +36,14 @@ class GlobalVariables: # 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()] +_GLOBAL = [_GlobalVariables()] def _parallel_solve(idx): - solver = GLOBAL[0].solver - instances = GLOBAL[0].instances - output_filenames = GLOBAL[0].output_filenames - discard_outputs = GLOBAL[0].discard_outputs + solver = _GLOBAL[0].solver + instances = _GLOBAL[0].instances + output_filenames = _GLOBAL[0].output_filenames + discard_outputs = _GLOBAL[0].discard_outputs if output_filenames is None: output_filename = None else: @@ -64,28 +64,26 @@ class LearningSolver: Parameters ---------- - components: [Component] - Set of components in the solver. By default, includes: - - ObjectiveValueComponent - - PrimalSolutionComponent - - DynamicLazyConstraintsComponent - - UserCutsComponent - mode: + 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: + solver: Callable[[], InternalSolver] A callable that constructs the internal solver. If None is provided, use GurobiPyomoSolver. - use_lazy_cb: + 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_first: + 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: + 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 @@ -150,7 +148,7 @@ class LearningSolver: # Generate model if model is None: - with RedirectOutput([]): + with _RedirectOutput([]): model = instance.to_model() # Initialize training sample @@ -261,23 +259,23 @@ class LearningSolver: Parameters ---------- - instance: + instance: Union[Instance, str] The instance to be solved, or a filename. - model: + model: Any The corresponding Pyomo model. If not provided, it will be created. - output_filename: + output_filename: Optional[str] If instance is a filename and output_filename is provided, write the modified instance to this file, instead of replacing the original one. If output_filename is None (the default), modified the original file in-place. - discard_output: + discard_output: bool If True, do not write the modified instances anywhere; simply discard them. Useful during benchmarking. - tee: + tee: bool If true, prints solver log to screen. Returns ------- - dict + MIPSolveStats 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". @@ -324,34 +322,33 @@ class LearningSolver: Parameters ---------- - output_filenames: + output_filenames: Optional[List[str]] If instances are file names and output_filenames is provided, write the modified instances to these files, instead of replacing the original files. If output_filenames is None, modifies the instances in-place. - discard_outputs: + discard_outputs: bool If True, do not write the modified instances anywhere; simply discard them instead. Useful during benchmarking. - label: + label: str Label to show in the progress bar. - instances: + instances: Union[List[str], List[Instance]] The instances to be solved - n_jobs: + n_jobs: int Number of instances to solve in parallel at a time. Returns ------- - Returns a list of dictionaries, with one entry for each provided instance. - This dictionary is the same you would obtain by calling: - - [solver.solve(p) for p in instances] - + List[MIPSolveStats] + List of solver statistics, with one entry for each provided instance. + The list is the same you would obtain by calling + `[solver.solve(p) for p in instances]` """ self.internal_solver = None self._silence_miplearn_logger() - GLOBAL[0].solver = self - GLOBAL[0].output_filenames = output_filenames - GLOBAL[0].instances = instances - GLOBAL[0].discard_outputs = discard_outputs + _GLOBAL[0].solver = self + _GLOBAL[0].output_filenames = output_filenames + _GLOBAL[0].instances = instances + _GLOBAL[0].discard_outputs = discard_outputs results = p_map( _parallel_solve, list(range(len(instances))), diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index e5ad6a2..f6f7849 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -15,7 +15,7 @@ from pyomo.opt import TerminationCondition from pyomo.opt.base.solvers import SolverFactory from miplearn.instance import Instance -from miplearn.solvers import RedirectOutput +from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import ( InternalSolver, LPSolveStats, @@ -60,7 +60,7 @@ class BasePyomoSolver(InternalSolver): streams: List[Any] = [StringIO()] if tee: streams += [sys.stdout] - with RedirectOutput(streams): + with _RedirectOutput(streams): results = self._pyomo_solver.solve(tee=True) self._restore_integrality() opt_value = None @@ -92,7 +92,7 @@ class BasePyomoSolver(InternalSolver): iteration_cb = lambda: False while True: logger.debug("Solving MIP...") - with RedirectOutput(streams): + with _RedirectOutput(streams): results = self._pyomo_solver.solve( tee=True, warmstart=self._is_warm_start_available, diff --git a/miplearn/solvers/tests/test_internal_solver.py b/miplearn/solvers/tests/test_internal_solver.py index b7a0a24..68be925 100644 --- a/miplearn/solvers/tests/test_internal_solver.py +++ b/miplearn/solvers/tests/test_internal_solver.py @@ -8,7 +8,7 @@ from warnings import warn import pyomo.environ as pe -from miplearn.solvers import RedirectOutput +from miplearn.solvers import _RedirectOutput from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.pyomo.base import BasePyomoSolver from miplearn.solvers.tests import ( @@ -25,7 +25,7 @@ def test_redirect_output(): original_stdout = sys.stdout io = StringIO() - with RedirectOutput([io]): + with _RedirectOutput([io]): print("Hello world") assert sys.stdout == original_stdout assert io.getvalue() == "Hello world\n" diff --git a/miplearn/types.py b/miplearn/types.py index eb2739c..cd112ba 100644 --- a/miplearn/types.py +++ b/miplearn/types.py @@ -54,3 +54,7 @@ LazyCallback = Callable[[Any, Any], None] SolverParams = Dict[str, Any] BranchPriorities = Solution + + +class Constraint: + pass