From acc5a90d491c0ace14390a0e84050a83beb36be1 Mon Sep 17 00:00:00 2001 From: Nolwen Date: Mon, 16 Dec 2024 16:53:34 +0100 Subject: [PATCH] Extract solutions at the end of mathopt solve for OrtoolsMathOptMilpSolver + HiGHS By default, we extract on the fly the solutions in a callback to feed to any potential user callback. However ortools/mathopt wrapper around HiGHS currently does not support callbacks (ignore them actually). So we extract rather the solutions from the mathopt `SolveResult` returned by `mathopt.solve()`. More precisely we add some options to `OrtoolsMathOptMilpSolver.solve()`: - `store_mathopt_res`: to store the `SolveResult` object (containing stats but also a kind of copy of the result storage), False by default - `extract_solutions_from_mathopt_res`: by default False except when using HiGHS. Will construct the final result storage from the mathopt result object (with solutions in reversed order to be consistent with what is returned usually). NB: we still extract on the fly solutions in the callback as: - it will do nothing in HiGHS as never called, - it is necessary for the user callback if it is actually called. --- .../generic_tools/lp_tools.py | 41 ++++++++++++++++++- tests/coloring/solvers/test_solvers.py | 11 +++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/discrete_optimization/generic_tools/lp_tools.py b/discrete_optimization/generic_tools/lp_tools.py index cb71bf90b..897217fab 100644 --- a/discrete_optimization/generic_tools/lp_tools.py +++ b/discrete_optimization/generic_tools/lp_tools.py @@ -371,6 +371,7 @@ class OrtoolsMathOptMilpSolver(MilpSolver, WarmstartMixin): """ random_seed: Optional[int] = None + mathopt_res: Optional[mathopt.SolveResult] = None def remove_constraints(self, constraints: Iterable[Any]) -> None: for cstr in constraints: @@ -442,6 +443,8 @@ def solve( mathopt_enable_output: bool = False, mathopt_model_parameters: Optional[mathopt.ModelSolveParameters] = None, mathopt_additional_solve_parameters: Optional[mathopt.SolveParameters] = None, + store_mathopt_res: bool = False, + extract_solutions_from_mathopt_res: Optional[bool] = None, **kwargs: Any, ) -> ResultStorage: """Solve with OR-Tools MathOpt API @@ -458,11 +461,21 @@ def solve( mathopt_additional_solve_parameters: passed to `mathopt.solve()` as `params`, except that parameters defined by above `time_limit` and `parameters_milp` will be overriden by them. + store_mathopt_res: whether to store the `mathopt.SolveResult` generated by `mathopt.solve()` + extract_solutions_from_mathopt_res: whether to extract solutions from the `mathopt.SolveResult` generated by `mathopt.solve()`. + If False, the solutions are rather extracted on the fly inside a callback at each mip solution improvement. + By default, this is False except for HiGHS solver type, as its OR-Tools wrapper does not yet integrate callbacks. **kwargs: passed to init_model() if model not yet existing Returns: """ + # Default parameters + if extract_solutions_from_mathopt_res is None: + extract_solutions_from_mathopt_res = ( + mathopt_solver_type == mathopt.SolverType.HIGHS + ) + self.early_stopping_exception = None callbacks_list = CallbackList(callbacks=callbacks) @@ -473,7 +486,7 @@ def solve( mathopt_cb = MathOptCallback(do_solver=self, callback=callbacks_list) # optimize - self.optimize_model( + mathopt_res = self.optimize_model( parameters_milp=parameters_milp, time_limit=time_limit, mathopt_solver_type=mathopt_solver_type, @@ -483,9 +496,14 @@ def solve( mathopt_additional_solve_parameters=mathopt_additional_solve_parameters, **kwargs, ) + if store_mathopt_res: + self.mathopt_res = mathopt_res # get result storage - res = mathopt_cb.res + if extract_solutions_from_mathopt_res: + res = self._extract_result_storage(mathopt_res) + else: + res = mathopt_cb.res # callback: solve end callbacks_list.on_solve_end(res=res, solver=self) @@ -630,6 +648,25 @@ def construct_linear_sum(expr: Iterable) -> Any: """Generate a linear sum (with variables) ready for the internal model.""" return mathopt.LinearSum(expr) + def _extract_result_storage( + self, mathopt_res: mathopt.SolveResult + ) -> ResultStorage: + list_solution_fits = [] + for internal_sol in mathopt_res.solutions: + get_var_value_for_current_solution = ( + lambda var: internal_sol.primal_solution.variable_values[var] + ) + get_obj_value_for_current_solution = ( + lambda: internal_sol.primal_solution.objective_value + ) + sol = self.retrieve_current_solution( + get_var_value_for_current_solution=get_var_value_for_current_solution, + get_obj_value_for_current_solution=get_obj_value_for_current_solution, + ) + fit = self.aggreg_from_sol(sol) + list_solution_fits.append((sol, fit)) + return self.create_result_storage(list(reversed(list_solution_fits))) + map_mathopt_status_to_do_status: dict[mathopt.TerminationReason, StatusSolver] = { mathopt.TerminationReason.OPTIMAL: StatusSolver.OPTIMAL, diff --git a/tests/coloring/solvers/test_solvers.py b/tests/coloring/solvers/test_solvers.py index 44fa264ad..9280db89b 100644 --- a/tests/coloring/solvers/test_solvers.py +++ b/tests/coloring/solvers/test_solvers.py @@ -8,6 +8,7 @@ import numpy as np import pytest from minizinc.solver import Solver +from ortools.math_opt.python import mathopt from discrete_optimization.coloring.parser import get_data_available, parse_file from discrete_optimization.coloring.problem import ( @@ -563,7 +564,10 @@ def test_color_lp_gurobi_cb_exception(): @pytest.mark.parametrize("use_cliques", [False, True]) @pytest.mark.parametrize("greedy_start", [True, False]) -def test_color_lp_ortools_mathopt(use_cliques, greedy_start): +@pytest.mark.parametrize( + "solver_type", [mathopt.SolverType.CP_SAT, mathopt.SolverType.HIGHS] +) +def test_color_lp_ortools_mathopt(use_cliques, greedy_start, solver_type): file = [f for f in get_data_available() if "gc_70_1" in f][0] color_problem = parse_file(file) solver = MathOptColoringSolver( @@ -574,13 +578,14 @@ def test_color_lp_ortools_mathopt(use_cliques, greedy_start): use_cliques=use_cliques, greedy_start=greedy_start, parameters_milp=ParametersMilp.default(), + mathopt_solver_type=solver_type, ) result_store = solver.solve(**kwargs) solution = result_store.get_best_solution_fit()[0] assert color_problem.satisfy(solution) - # Test warm-start only once - if greedy_start and not use_cliques: + # Test warm-start only once (and not for HiGHS as only 1 solution found) + if greedy_start and not use_cliques and solver_type != mathopt.SolverType.HIGHS: # first solution is not start_solution assert result_store[0][0].colors != solver.start_solution.colors