Skip to content

Commit

Permalink
New mixin BoundsProviderMixin to have generic gap based early stoppers
Browse files Browse the repository at this point in the history
We create a new abstract mixin for solvers exposing
- current internal objective value
- current internal objective (dual) bound

These solver can thus compute a (relative or absolute) gap at each
iteration in a user callback. So we implement a generic stopper based on
gap (following the existing one for CP-SAT) and also a stats retriever
callback that stores also these objective values/bounds.

The mixin api is implemented for
- cpsat based solvers
- mathopt based solvers
- gurobi based solvers
  • Loading branch information
nhuet committed Feb 27, 2025
1 parent 0a4b6b2 commit 96c5ee8
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 13 deletions.
47 changes: 46 additions & 1 deletion discrete_optimization/generic_tools/callbacks/early_stoppers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Optional

from discrete_optimization.generic_tools.callbacks.callback import Callback
from discrete_optimization.generic_tools.do_solver import SolverDO
from discrete_optimization.generic_tools.do_solver import BoundsProviderMixin, SolverDO
from discrete_optimization.generic_tools.ortools_cpsat_tools import OrtoolsCpSatSolver
from discrete_optimization.generic_tools.result_storage.result_storage import (
ResultStorage,
Expand Down Expand Up @@ -104,3 +104,48 @@ def on_step_end(
f"Stopping search, relative gap {abs(bound-best_sol)/abs(bound)}<{self.objective_gap_rel}"
)
return True


class ObjectiveGapStopper(Callback):
"""Stop the solver according to some classical convergence criteria: relative and absolute gap.
It assumes that the solver is able to provide the current best value and bound for the internal objective.
"""

def __init__(
self,
objective_gap_rel: Optional[float] = None,
objective_gap_abs: Optional[float] = None,
):
self.objective_gap_rel = objective_gap_rel
self.objective_gap_abs = objective_gap_abs

def on_step_end(
self, step: int, res: ResultStorage, solver: SolverDO
) -> Optional[bool]:
if not isinstance(solver, BoundsProviderMixin):
raise ValueError(
"The ObjectiveGapStopper can be applied only to a solver deriving from BoundsProviderMixin."
)
abs_gap = None
if self.objective_gap_abs is not None:
abs_gap = solver.get_current_absolute_gap()
if abs_gap is not None:
if abs_gap <= self.objective_gap_abs:
logger.debug(
f"Stopping search, absolute gap {abs_gap} <= {self.objective_gap_abs}"
)
return True
if self.objective_gap_rel is not None:
bound = solver.get_current_best_internal_objective_bound()
if bound is not None and bound != 0:
if self.objective_gap_abs is None:
abs_gap = solver.get_current_absolute_gap()
if abs_gap is not None: # could be still None (e.g. mathopt + cp-sat)
rel_gap = abs_gap / abs(bound)
if rel_gap <= self.objective_gap_rel:
logger.debug(
f"Stopping search, relative gap {rel_gap} <= {self.objective_gap_rel}"
)
return True
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Optional

from discrete_optimization.generic_tools.callbacks.callback import Callback
from discrete_optimization.generic_tools.do_solver import SolverDO
from discrete_optimization.generic_tools.do_solver import BoundsProviderMixin, SolverDO
from discrete_optimization.generic_tools.ortools_cpsat_tools import OrtoolsCpSatSolver
from discrete_optimization.generic_tools.result_storage.result_storage import (
ResultStorage,
Expand Down Expand Up @@ -86,3 +86,24 @@ def on_solve_end(self, res: ResultStorage, solver: OrtoolsCpSatSolver):
}
)
self.final_status = status_name


class StatsWithBoundsCallback(BasicStatsCallback):
"""
This callback is specific to BoundsProviderMixin solvers.
"""

def on_step_end(
self, step: int, res: ResultStorage, solver: SolverDO
) -> Optional[bool]:
if not isinstance(solver, BoundsProviderMixin):
raise ValueError(
"The ObjectiveGapStopper can be applied only to a solver deriving from BoundsProviderMixin."
)
super().on_step_end(step=step, res=res, solver=solver)
self.stats[-1].update(
{
"obj": solver.get_current_best_internal_objective_value(),
"bound": solver.get_current_best_internal_objective_bound(),
}
)
29 changes: 29 additions & 0 deletions discrete_optimization/generic_tools/do_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,35 @@ def set_warm_start(self, solution: Solution) -> None:
...


class BoundsProviderMixin(ABC):
"""Mixin class for solvers providing bounds on objective.
e.g. to be used in callback for early stopping.
"""

@abstractmethod
def get_current_best_internal_objective_bound(self) -> Optional[float]:
...

@abstractmethod
def get_current_best_internal_objective_value(self) -> Optional[float]:
...

def get_current_absolute_gap(self) -> Optional[float]:
"""Get current (absolute) optimality gap.
i.e. |current_obj_value - current_obj_bound|
"""
bound = self.get_current_best_internal_objective_bound()
best_sol = self.get_current_best_internal_objective_value()
if bound is None or best_sol is None:
return None
else:
return abs(best_sol - bound)


class TrivialSolverFromResultStorage(SolverDO, WarmstartMixin):
"""Trivial solver created from an already computed result storage."""

Expand Down
53 changes: 49 additions & 4 deletions discrete_optimization/generic_tools/lp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Solution,
)
from discrete_optimization.generic_tools.do_solver import (
BoundsProviderMixin,
SolverDO,
StatusSolver,
WarmstartMixin,
Expand Down Expand Up @@ -349,7 +350,7 @@ def set_warm_start_from_values(
raise NotImplementedError()


class OrtoolsMathOptMilpSolver(MilpSolver, WarmstartMixin):
class OrtoolsMathOptMilpSolver(MilpSolver, WarmstartMixin, BoundsProviderMixin):
"""Milp solver wrapping a solver from pymip library."""

hyperparameters = [
Expand All @@ -373,6 +374,9 @@ class OrtoolsMathOptMilpSolver(MilpSolver, WarmstartMixin):
random_seed: Optional[int] = None
mathopt_res: Optional[mathopt.SolveResult] = None

_current_internal_objective_best_value: Optional[float] = None
_current_internal_objective_best_bound: Optional[float] = None

def remove_constraints(self, constraints: Iterable[Any]) -> None:
for cstr in constraints:
self.model.delete_linear_constraint(cstr)
Expand Down Expand Up @@ -483,7 +487,11 @@ def solve(
callbacks_list.on_solve_start(solver=self)

# wrap user callback in a mathopt callback
mathopt_cb = MathOptCallback(do_solver=self, callback=callbacks_list)
mathopt_cb = MathOptCallback(
do_solver=self,
callback=callbacks_list,
mathopt_solver_type=mathopt_solver_type,
)

# optimize
mathopt_res = self.optimize_model(
Expand Down Expand Up @@ -667,6 +675,12 @@ def _extract_result_storage(
list_solution_fits.append((sol, fit))
return self.create_result_storage(list(reversed(list_solution_fits)))

def get_current_best_internal_objective_bound(self) -> Optional[float]:
return self._current_internal_objective_best_bound

def get_current_best_internal_objective_value(self) -> Optional[float]:
return self._current_internal_objective_best_value


map_mathopt_status_to_do_status: dict[mathopt.TerminationReason, StatusSolver] = {
mathopt.TerminationReason.OPTIMAL: StatusSolver.OPTIMAL,
Expand All @@ -682,7 +696,13 @@ def _extract_result_storage(


class MathOptCallback:
def __init__(self, do_solver: OrtoolsMathOptMilpSolver, callback: Callback):
def __init__(
self,
do_solver: OrtoolsMathOptMilpSolver,
callback: Callback,
mathopt_solver_type: mathopt.SolverType,
):
self.solver_type = mathopt_solver_type
self.do_solver = do_solver
self.callback = callback
self.res = do_solver.create_result_storage()
Expand All @@ -708,6 +728,14 @@ def __call__(self, callback_data: mathopt.CallbackData) -> mathopt.CallbackResul
fit = self.do_solver.aggreg_from_sol(sol)
self.res.append((sol, fit))
self.nb_solutions += 1
# store mip stats
if self.solver_type != mathopt.SolverType.CP_SAT:
self.do_solver._current_internal_objective_best_value = (
callback_data.mip_stats.primal_bound
)
self.do_solver._current_internal_objective_best_bound = (
callback_data.mip_stats.dual_bound
)
# end of step callback: stopping?
stopping = self.callback.on_step_end(
step=self.nb_solutions, res=self.res, solver=self.do_solver
Expand All @@ -721,15 +749,19 @@ def __call__(self, callback_data: mathopt.CallbackData) -> mathopt.CallbackResul
self.do_solver.early_stopping_exception = SolveEarlyStop(
f"{self.do_solver.__class__.__name__}.solve() stopped by user callback."
)

return mathopt.CallbackResult(terminate=stopping)


class GurobiMilpSolver(MilpSolver, WarmstartMixin):
class GurobiMilpSolver(MilpSolver, WarmstartMixin, BoundsProviderMixin):
"""Milp solver wrapping a solver from gurobi library."""

model: Optional["gurobipy.Model"] = None
early_stopping_exception: Optional[Exception] = None

_current_internal_objective_best_value: Optional[float] = None
_current_internal_objective_best_bound: Optional[float] = None

def remove_constraints(self, constraints: Iterable[Any]) -> None:
self.model.remove(list(constraints))
self.model.update()
Expand Down Expand Up @@ -968,6 +1000,12 @@ def set_warm_start_from_values(
var.Start = val
var.VarHintVal = val

def get_current_best_internal_objective_bound(self) -> Optional[float]:
return self._current_internal_objective_best_bound

def get_current_best_internal_objective_value(self) -> Optional[float]:
return self._current_internal_objective_best_value


class GurobiCallback:
def __init__(self, do_solver: GurobiMilpSolver, callback: Callback):
Expand All @@ -989,6 +1027,13 @@ def __call__(self, model, where) -> None:
fit = self.do_solver.aggreg_from_sol(sol)
self.res.append((sol, fit))
self.nb_solutions += 1
# store mip stats
self.do_solver._current_internal_objective_best_value = model.cbGet(
gurobipy.GRB.Callback.MIPSOL_OBJBST
)
self.do_solver._current_internal_objective_best_bound = model.cbGet(
gurobipy.GRB.Callback.MIPSOL_OBJBND
)
# end of step callback: stopping?
stopping = self.callback.on_step_end(
step=self.nb_solutions, res=self.res, solver=self.do_solver
Expand Down
19 changes: 17 additions & 2 deletions discrete_optimization/generic_tools/ortools_cpsat_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
)
from discrete_optimization.generic_tools.cp_tools import CpSolver, ParametersCp
from discrete_optimization.generic_tools.do_problem import Solution
from discrete_optimization.generic_tools.do_solver import StatusSolver
from discrete_optimization.generic_tools.do_solver import (
BoundsProviderMixin,
StatusSolver,
)
from discrete_optimization.generic_tools.exceptions import SolveEarlyStop
from discrete_optimization.generic_tools.result_storage.result_storage import (
ResultStorage,
Expand All @@ -32,7 +35,7 @@
logger = logging.getLogger(__name__)


class OrtoolsCpSatSolver(CpSolver):
class OrtoolsCpSatSolver(CpSolver, BoundsProviderMixin):
"""Generic ortools cp-sat solver."""

cp_model: Optional[CpModel] = None
Expand Down Expand Up @@ -133,6 +136,18 @@ def remove_constraints(self, constraints: Iterable[Any]) -> None:
raise RuntimeError()
cstr.proto.Clear()

def get_current_best_internal_objective_bound(self) -> Optional[float]:
if self.clb is None:
return None
else:
return self.clb.BestObjectiveBound()

def get_current_best_internal_objective_value(self) -> Optional[float]:
if self.clb is None:
return None
else:
return self.clb.ObjectiveValue()


class OrtoolsCpSatCallback(CpSolverSolutionCallback):
def __init__(
Expand Down
11 changes: 9 additions & 2 deletions discrete_optimization/gpdp/solvers/lp_iterative.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,8 +1124,13 @@ def set_warm_start(self, solution: GpdpSolution) -> None:


class TemporaryResultMathOptCallback(MathOptCallback):
def __init__(self, do_solver: MathOptLinearFlowGpdpSolver):
def __init__(
self,
do_solver: MathOptLinearFlowGpdpSolver,
mathopt_solver_type: mathopt.SolverType,
):
self.do_solver = do_solver
self.mathopt_solver_type = mathopt_solver_type
self.temporary_results = []

def __call__(self, callback_data: mathopt.CallbackData) -> mathopt.CallbackResult:
Expand Down Expand Up @@ -1168,7 +1173,9 @@ def solve_one_iteration(
**kwargs: Any,
) -> list[TemporaryResult]:

mathopt_cb = TemporaryResultMathOptCallback(do_solver=self)
mathopt_cb = TemporaryResultMathOptCallback(
do_solver=self, mathopt_solver_type=mathopt_solver_type
)
self.optimize_model(
parameters_milp=parameters_milp,
time_limit=time_limit,
Expand Down
Loading

0 comments on commit 96c5ee8

Please sign in to comment.