From 96c5ee871488d8d1a0d5da7bf38aeab805bdcd33 Mon Sep 17 00:00:00 2001 From: Nolwen Date: Thu, 27 Feb 2025 17:18:10 +0100 Subject: [PATCH] New mixin BoundsProviderMixin to have generic gap based early stoppers 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 --- .../generic_tools/callbacks/early_stoppers.py | 47 ++++- .../callbacks/stats_retrievers.py | 23 ++- .../generic_tools/do_solver.py | 29 +++ .../generic_tools/lp_tools.py | 53 ++++- .../generic_tools/ortools_cpsat_tools.py | 19 +- .../gpdp/solvers/lp_iterative.py | 11 +- .../test_earlystopobjective_callback.py | 195 +++++++++++++++++- 7 files changed, 364 insertions(+), 13 deletions(-) diff --git a/discrete_optimization/generic_tools/callbacks/early_stoppers.py b/discrete_optimization/generic_tools/callbacks/early_stoppers.py index ab9550896..9405d3763 100644 --- a/discrete_optimization/generic_tools/callbacks/early_stoppers.py +++ b/discrete_optimization/generic_tools/callbacks/early_stoppers.py @@ -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, @@ -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 diff --git a/discrete_optimization/generic_tools/callbacks/stats_retrievers.py b/discrete_optimization/generic_tools/callbacks/stats_retrievers.py index b3f9ea007..3be2b796a 100644 --- a/discrete_optimization/generic_tools/callbacks/stats_retrievers.py +++ b/discrete_optimization/generic_tools/callbacks/stats_retrievers.py @@ -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, @@ -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(), + } + ) diff --git a/discrete_optimization/generic_tools/do_solver.py b/discrete_optimization/generic_tools/do_solver.py index 3d4e30a37..e1508a344 100644 --- a/discrete_optimization/generic_tools/do_solver.py +++ b/discrete_optimization/generic_tools/do_solver.py @@ -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.""" diff --git a/discrete_optimization/generic_tools/lp_tools.py b/discrete_optimization/generic_tools/lp_tools.py index 897217fab..4071dd308 100644 --- a/discrete_optimization/generic_tools/lp_tools.py +++ b/discrete_optimization/generic_tools/lp_tools.py @@ -25,6 +25,7 @@ Solution, ) from discrete_optimization.generic_tools.do_solver import ( + BoundsProviderMixin, SolverDO, StatusSolver, WarmstartMixin, @@ -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 = [ @@ -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) @@ -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( @@ -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, @@ -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() @@ -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 @@ -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() @@ -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): @@ -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 diff --git a/discrete_optimization/generic_tools/ortools_cpsat_tools.py b/discrete_optimization/generic_tools/ortools_cpsat_tools.py index 94cfa2666..fb113fcfd 100644 --- a/discrete_optimization/generic_tools/ortools_cpsat_tools.py +++ b/discrete_optimization/generic_tools/ortools_cpsat_tools.py @@ -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, @@ -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 @@ -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__( diff --git a/discrete_optimization/gpdp/solvers/lp_iterative.py b/discrete_optimization/gpdp/solvers/lp_iterative.py index 7002bfca5..6788f30e7 100644 --- a/discrete_optimization/gpdp/solvers/lp_iterative.py +++ b/discrete_optimization/gpdp/solvers/lp_iterative.py @@ -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: @@ -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, diff --git a/tests/generic_tools/callbacks/test_earlystopobjective_callback.py b/tests/generic_tools/callbacks/test_earlystopobjective_callback.py index f466e0d83..14c3a1664 100644 --- a/tests/generic_tools/callbacks/test_earlystopobjective_callback.py +++ b/tests/generic_tools/callbacks/test_earlystopobjective_callback.py @@ -3,19 +3,37 @@ # LICENSE file in the root directory of this source tree. import pytest +from ortools.math_opt.python import mathopt +from discrete_optimization.coloring.parser import ( + get_data_available as coloring_get_data_available, +) +from discrete_optimization.coloring.parser import parse_file as coloring_parse_file +from discrete_optimization.coloring.solvers.lp import ( + GurobiColoringSolver, + MathOptColoringSolver, +) from discrete_optimization.generic_tools.callbacks.early_stoppers import ( ObjectiveGapCpSatSolver, + ObjectiveGapStopper, +) +from discrete_optimization.generic_tools.callbacks.stats_retrievers import ( + StatsWithBoundsCallback, ) from discrete_optimization.generic_tools.cp_tools import ParametersCp -from discrete_optimization.knapsack.parser import get_data_available, parse_file +from discrete_optimization.knapsack.parser import ( + get_data_available as knapsack_get_data_available, +) +from discrete_optimization.knapsack.parser import parse_file as knapsack_parse_file from discrete_optimization.knapsack.problem import KnapsackProblem, MobjKnapsackModel from discrete_optimization.knapsack.solvers.cpsat import CpSatKnapsackSolver def test_knapsack_ortools_objective_callback(): - model_file = [f for f in get_data_available() if "ks_300_0" in f][0] - model: KnapsackProblem = parse_file(model_file, force_recompute_values=True) + model_file = [f for f in knapsack_get_data_available() if "ks_300_0" in f][0] + model: KnapsackProblem = knapsack_parse_file( + model_file, force_recompute_values=True + ) model: MobjKnapsackModel = MobjKnapsackModel.from_knapsack(model) solver = CpSatKnapsackSolver(model) solver.init_model() @@ -38,3 +56,174 @@ def test_knapsack_ortools_objective_callback(): / abs(solver.clb.BestObjectiveBound()) <= objective_gap_rel ) + + +def test_knapsack_ortools_cpsat_gap_callback(): + model_file = [f for f in knapsack_get_data_available() if "ks_300_0" in f][0] + model: KnapsackProblem = knapsack_parse_file( + model_file, force_recompute_values=True + ) + model: MobjKnapsackModel = MobjKnapsackModel.from_knapsack(model) + + # w/o stop + solver = CpSatKnapsackSolver(model) + solver.init_model() + result_storage = solver.solve( + time_limit=10, + ortools_cpsat_solver_kwargs={"log_search_progress": True}, + ) + nb_solutions_wo_gap_stop = len(result_storage) + + # with gap stop + solver = CpSatKnapsackSolver(model) + solver.init_model() + objective_gap_rel = 0.1 + objective_gap_abs = 10 + mycb = ObjectiveGapStopper( + objective_gap_rel=objective_gap_rel, objective_gap_abs=objective_gap_abs + ) + parameters_cp = ParametersCp.default() + result_storage = solver.solve( + time_limit=10, + parameters_cp=parameters_cp, + callbacks=[mycb], + ortools_cpsat_solver_kwargs={"log_search_progress": True}, + ) + assert ( + abs(solver.clb.ObjectiveValue() - solver.clb.BestObjectiveBound()) + <= objective_gap_abs + or abs(solver.clb.ObjectiveValue() - solver.clb.BestObjectiveBound()) + / abs(solver.clb.BestObjectiveBound()) + <= objective_gap_rel + ) + assert len(result_storage) < nb_solutions_wo_gap_stop + + +@pytest.mark.parametrize( + "solver_type", [mathopt.SolverType.GSCIP, mathopt.SolverType.CP_SAT] +) +def test_coloring_mathopt_gap_callback(solver_type): + file = [f for f in coloring_get_data_available() if "gc_70_1" in f][0] + color_problem = coloring_parse_file(file) + + solver = MathOptColoringSolver( + color_problem, + ) + sol = color_problem.get_dummy_solution() + solver.init_model(greedy_start=False) + solver.set_warm_start(sol) + + # w/o early stopping + stats_cb = StatsWithBoundsCallback() + + result_store = solver.solve(mathopt_solver_type=solver_type, callbacks=[stats_cb]) + nb_solutions_wo_gap_stop = len(result_store) + print(len(result_store), stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + + # with stopping based on gap + objective_gap_abs = 70 + objective_gap_rel = None + stopper_cb = ObjectiveGapStopper( + objective_gap_rel=objective_gap_rel, objective_gap_abs=objective_gap_abs + ) + stats_cb = StatsWithBoundsCallback() + result_store = solver.solve( + mathopt_solver_type=solver_type, callbacks=[stopper_cb, stats_cb] + ) + + print(stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + assert all( + "obj" in stats_item and "bound" in stats_item for stats_item in stats_cb.stats + ) + + if solver_type != mathopt.SolverType.CP_SAT: # cp_sat does not provide bounds + assert len(result_store) < nb_solutions_wo_gap_stop + + # with stopping based on rel gap + objective_gap_abs = None + objective_gap_rel = 2 + stopper_cb = ObjectiveGapStopper( + objective_gap_rel=objective_gap_rel, objective_gap_abs=objective_gap_abs + ) + stats_cb = StatsWithBoundsCallback() + result_store = solver.solve( + mathopt_solver_type=solver_type, callbacks=[stopper_cb, stats_cb] + ) + + print(stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + assert all( + "obj" in stats_item and "bound" in stats_item for stats_item in stats_cb.stats + ) + print(stats_cb.stats) + + if solver_type not in ( + mathopt.SolverType.CP_SAT, + mathopt.SolverType.GSCIP, + ): # GSCIP go on 1 step before actually stopping... + assert len(result_store) < nb_solutions_wo_gap_stop + + +def test_coloring_gurobi_gap_callback(): + file = [f for f in coloring_get_data_available() if "gc_20_1" in f][0] + color_problem = coloring_parse_file(file) + + # w/o early stopping + solver = GurobiColoringSolver( + color_problem, + ) + sol = color_problem.get_dummy_solution() + solver.init_model(greedy_start=False) + solver.set_warm_start(sol) + stats_cb = StatsWithBoundsCallback() + result_store = solver.solve(callbacks=[stats_cb]) + nb_solutions_wo_gap_stop = len(result_store) + print(len(result_store), stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + + # with stopping based on gap + objective_gap_abs = 3e100 + objective_gap_rel = None + solver = GurobiColoringSolver( + color_problem, + ) + sol = color_problem.get_dummy_solution() + solver.init_model(greedy_start=False) + solver.set_warm_start(sol) + stopper_cb = ObjectiveGapStopper( + objective_gap_rel=objective_gap_rel, objective_gap_abs=objective_gap_abs + ) + stats_cb = StatsWithBoundsCallback() + result_store = solver.solve(callbacks=[stopper_cb, stats_cb]) + + print(stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + assert all( + "obj" in stats_item and "bound" in stats_item for stats_item in stats_cb.stats + ) + assert len(result_store) < nb_solutions_wo_gap_stop + + # with stopping based on rel gap + objective_gap_abs = None + objective_gap_rel = 3 + solver = GurobiColoringSolver( + color_problem, + ) + sol = color_problem.get_dummy_solution() + solver.init_model(greedy_start=False) + solver.set_warm_start(sol) + stopper_cb = ObjectiveGapStopper( + objective_gap_rel=objective_gap_rel, objective_gap_abs=objective_gap_abs + ) + stats_cb = StatsWithBoundsCallback() + result_store = solver.solve(callbacks=[stopper_cb, stats_cb]) + + print(stats_cb.stats) + assert len(stats_cb.stats) == len(result_store) + 1 + assert all( + "obj" in stats_item and "bound" in stats_item for stats_item in stats_cb.stats + ) + print(stats_cb.stats) + assert len(result_store) < nb_solutions_wo_gap_stop