From ab452d282b693b0b92925df42b49b8e826331a7b Mon Sep 17 00:00:00 2001 From: ElFosco Date: Tue, 30 Sep 2025 17:18:13 +0200 Subject: [PATCH 1/3] adding tuner --- cpmpy/tools/tune_solver.py | 142 ++++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/cpmpy/tools/tune_solver.py b/cpmpy/tools/tune_solver.py index 0e3def99a..a177acebc 100644 --- a/cpmpy/tools/tune_solver.py +++ b/cpmpy/tools/tune_solver.py @@ -14,6 +14,7 @@ The parameter tuner iteratively finds better hyperparameters close to the current best configuration during the search. Searching and time-out start at the default configuration for a solver (if available in the solver class) """ +import math import time from random import shuffle @@ -42,7 +43,7 @@ def __init__(self, solvername, model, all_params=None, defaults=None): self.best_params = SolverLookup.lookup(solvername).default_params() self._param_order = list(self.all_params.keys()) - self._best_config = self._params_to_np([self.best_params]) + self._best_config = self._params_to_np([self.best_params])[0] def tune(self, time_limit=None, max_tries=None, fix_params={}): """ @@ -55,7 +56,9 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): # Init solver solver = SolverLookup.get(self.solvername, self.model) - solver.solve(**self.best_params) + solver.solve(**self.best_params,time_limit=time_limit) + if time_limit is not None and solver.status().runtime >= time_limit: + raise TimeoutError("Time's up before solving init solver call") self.base_runtime = solver.status().runtime self.best_runtime = self.base_runtime @@ -87,22 +90,89 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): timeout = self.best_runtime # set timeout depending on time budget if time_limit is not None: - timeout = min(timeout, time_limit - (time.time() - start_time)) + if (time.time() - start_time) >= time_limit: + break + timeout = min(timeout, max(1e-4, time_limit - (time.time() - start_time))) # run solver solver.solve(**params_dict, time_limit=timeout) if solver.status().exitstatus == ExitStatus.OPTIMAL and solver.status().runtime < self.best_runtime: self.best_runtime = solver.status().runtime # update surrogate self._best_config = params_np - - if time_limit is not None and (time.time() - start_time) >= time_limit: - break i += 1 self.best_params = self._np_to_params(self._best_config) self.best_params.update(fix_params) return self.best_params + + def tune_list(self, time_limit=None, max_tries=None, fix_params={}): + """ + :param time_limit: Time budget to run tuner in seconds. Solver will be interrupted when time budget is exceeded + :param max_tries: Maximum number of configurations to test + :param fix_params: Non-default parameters to run solvers with. + """ + cumulative_runtime = 0 + self.best_runtime_models = 0 + for mdl in self.model: + solver = SolverLookup.get(self.solvername, mdl) + solver.solve(**self.best_params,time_limit=time_limit) + if time_limit is not None and solver.status().runtime >= time_limit: + raise TimeoutError("Time's up before solving all instances") + cumulative_runtime += solver.status().runtime + self.best_runtime_models += solver.status().runtime + time_limit -= solver.status().runtime + + # Get all possible hyperparameter configurations + combos = list(param_combinations(self.all_params)) + combos_np = self._params_to_np(combos) + + + # Ensure random start + np.random.shuffle(combos_np) + + i = 0 + if max_tries is None: + max_tries = len(combos_np) + while len(combos_np) and i < max_tries: + # Apply scoring to all combos + scores = self._get_score(combos_np) + max_idx = np.where(scores == scores.min())[0][0] + # Get index of optimal combo + params_np = combos_np[max_idx] + # Remove optimal combo from combos + combos_np = np.delete(combos_np, max_idx, axis=0) + # Convert numpy array back to dictionary + params_dict = self._np_to_params(params_np) + # set fixed params + params_dict.update(fix_params) + if time_limit is not None: + if cumulative_runtime > time_limit: + break + timeout = min(self.best_runtime_models, max(1e-4,time_limit - cumulative_runtime)) + # run solver + all_optimal = True + runtime_models = 0 + for mdl in self.model: + solver = SolverLookup.get(self.solvername, mdl) + solver.solve(**self.best_params, time_limit=timeout) + cumulative_runtime += solver.status().runtime + if solver.status().exitstatus == ExitStatus.OPTIMAL: + runtime_models += solver.status().runtime + timeout = max(timeout - solver.status().runtime, 1e-4) + else: + all_optimal = False + break + if all_optimal and runtime_models < self.best_runtime_models: + self.best_runtime_models = runtime_models + self._best_config = params_np.copy() + + i += 1 + self.best_params = self._np_to_params(self._best_config) + self.best_params.update(fix_params) + return self.best_params + + def _get_score(self, combos): """ Return the hamming distance for each remaining configuration to the current best config. @@ -135,7 +205,9 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): # Init solver solver = SolverLookup.get(self.solvername, self.model) - solver.solve(**self.best_params) + solver.solve(**self.best_params,time_limit=time_limit) + if time_limit is not None and solver.status().runtime >= time_limit: + raise TimeoutError("Time's up before solving init solver call") self.base_runtime = solver.status().runtime @@ -156,7 +228,9 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): timeout = self.best_runtime # set timeout depending on time budget if time_limit is not None: - timeout = min(timeout, time_limit - (time.time() - start_time)) + if (time.time() - start_time) >= time_limit: + break + timeout = min(timeout, max(1e-4, time_limit - (time.time() - start_time))) # run solver solver.solve(**params_dict, time_limit=timeout) if solver.status().exitstatus == ExitStatus.OPTIMAL and solver.status().runtime < self.best_runtime: @@ -169,5 +243,57 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): return self.best_params + def tune_list(self, time_limit=None, max_tries=None, fix_params={}): + """ + :param time_limit: Time budget to run tuner in seconds. Solver will be interrupted when time budget is exceeded + :param max_tries: Maximum number of configurations to test + :param fix_params: Non-default parameters to run solvers with. + """ + cumulative_runtime = 0 + self.best_runtime_models = 0 + for mdl in self.model: + solver = SolverLookup.get(self.solvername, mdl) + solver.solve(**self.best_params,time_limit=time_limit) + if time_limit is not None and solver.status().runtime >= time_limit: + raise TimeoutError("Time's up before solving all instances") + cumulative_runtime += solver.status().runtime + self.best_runtime_models += solver.status().runtime + time_limit -= solver.status().runtime + + # Get all possible hyperparameter configurations + combos = list(param_combinations(self.all_params)) + shuffle(combos) # test in random order + + if max_tries is not None: + combos = combos[:max_tries] + + for params_dict in combos: + params_dict.update(fix_params) + if time_limit is not None: + if cumulative_runtime > time_limit: + break + timeout = min(self.best_runtime_models, max(1e-4,time_limit - cumulative_runtime)) + # run solver + all_optimal = True + runtime_models = 0 + for mdl in self.model: + solver = SolverLookup.get(self.solvername, mdl) + solver.solve(**self.best_params, time_limit=timeout) + cumulative_runtime += solver.status().runtime + if solver.status().exitstatus == ExitStatus.OPTIMAL: + runtime_models += solver.status().runtime + timeout = max(timeout - solver.status().runtime, 1e-4) + else: + all_optimal = False + break + if all_optimal and runtime_models < self.best_runtime_models: + self.best_runtime_models = runtime_models + # update surrogate + self.best_params = params_dict + + self.best_params = self._np_to_params(self._best_config) + self.best_params.update(fix_params) + return self.best_params + From 63aefa1ae0d5f34d3c03ab8cbc65adeb5a8c2a9d Mon Sep 17 00:00:00 2001 From: ElFosco Date: Wed, 1 Oct 2025 14:29:33 +0200 Subject: [PATCH 2/3] polishing code - MultiSolver added & has_finished function --- cpmpy/tools/tune_solver.py | 257 +++++++++++++++++++------------------ 1 file changed, 129 insertions(+), 128 deletions(-) diff --git a/cpmpy/tools/tune_solver.py b/cpmpy/tools/tune_solver.py index a177acebc..dbddcd667 100644 --- a/cpmpy/tools/tune_solver.py +++ b/cpmpy/tools/tune_solver.py @@ -17,11 +17,10 @@ import math import time from random import shuffle - import numpy as np - from ..solvers.utils import SolverLookup, param_combinations -from ..solvers.solver_interface import ExitStatus +from ..solvers.solver_interface import ExitStatus, SolverInterface, SolverStatus + class ParameterTuner: """ @@ -55,9 +54,12 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): start_time = time.time() # Init solver - solver = SolverLookup.get(self.solvername, self.model) - solver.solve(**self.best_params,time_limit=time_limit) - if time_limit is not None and solver.status().runtime >= time_limit: + if not isinstance(self.model, list): + solver = SolverLookup.get(self.solvername, self.model) + else: + solver = MultiSolver(self.solvername, self.model) + solver.solve(**self.best_params, time_limit=time_limit) + if not _has_finished(solver): raise TimeoutError("Time's up before solving init solver call") self.base_runtime = solver.status().runtime @@ -75,7 +77,10 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): max_tries = len(combos_np) while len(combos_np) and i < max_tries: # Make new solver - solver = SolverLookup.get(self.solvername, self.model) + if not isinstance(self.model, list): + solver = SolverLookup.get(self.solvername, self.model) + else: + solver = MultiSolver(self.solvername, self.model) # Apply scoring to all combos scores = self._get_score(combos_np) max_idx = np.where(scores == scores.min())[0][0] @@ -92,10 +97,10 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): if time_limit is not None: if (time.time() - start_time) >= time_limit: break - timeout = min(timeout, max(1e-4, time_limit - (time.time() - start_time))) + timeout = min(timeout, time_limit - (time.time() - start_time)) # run solver solver.solve(**params_dict, time_limit=timeout) - if solver.status().exitstatus == ExitStatus.OPTIMAL and solver.status().runtime < self.best_runtime: + if _has_finished(solver): self.best_runtime = solver.status().runtime # update surrogate self._best_config = params_np @@ -106,73 +111,6 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): return self.best_params - def tune_list(self, time_limit=None, max_tries=None, fix_params={}): - """ - :param time_limit: Time budget to run tuner in seconds. Solver will be interrupted when time budget is exceeded - :param max_tries: Maximum number of configurations to test - :param fix_params: Non-default parameters to run solvers with. - """ - cumulative_runtime = 0 - self.best_runtime_models = 0 - for mdl in self.model: - solver = SolverLookup.get(self.solvername, mdl) - solver.solve(**self.best_params,time_limit=time_limit) - if time_limit is not None and solver.status().runtime >= time_limit: - raise TimeoutError("Time's up before solving all instances") - cumulative_runtime += solver.status().runtime - self.best_runtime_models += solver.status().runtime - time_limit -= solver.status().runtime - - # Get all possible hyperparameter configurations - combos = list(param_combinations(self.all_params)) - combos_np = self._params_to_np(combos) - - - # Ensure random start - np.random.shuffle(combos_np) - - i = 0 - if max_tries is None: - max_tries = len(combos_np) - while len(combos_np) and i < max_tries: - # Apply scoring to all combos - scores = self._get_score(combos_np) - max_idx = np.where(scores == scores.min())[0][0] - # Get index of optimal combo - params_np = combos_np[max_idx] - # Remove optimal combo from combos - combos_np = np.delete(combos_np, max_idx, axis=0) - # Convert numpy array back to dictionary - params_dict = self._np_to_params(params_np) - # set fixed params - params_dict.update(fix_params) - if time_limit is not None: - if cumulative_runtime > time_limit: - break - timeout = min(self.best_runtime_models, max(1e-4,time_limit - cumulative_runtime)) - # run solver - all_optimal = True - runtime_models = 0 - for mdl in self.model: - solver = SolverLookup.get(self.solvername, mdl) - solver.solve(**self.best_params, time_limit=timeout) - cumulative_runtime += solver.status().runtime - if solver.status().exitstatus == ExitStatus.OPTIMAL: - runtime_models += solver.status().runtime - timeout = max(timeout - solver.status().runtime, 1e-4) - else: - all_optimal = False - break - if all_optimal and runtime_models < self.best_runtime_models: - self.best_runtime_models = runtime_models - self._best_config = params_np.copy() - - i += 1 - self.best_params = self._np_to_params(self._best_config) - self.best_params.update(fix_params) - return self.best_params - - def _get_score(self, combos): """ Return the hamming distance for each remaining configuration to the current best config. @@ -204,9 +142,12 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): start_time = time.time() # Init solver - solver = SolverLookup.get(self.solvername, self.model) + if not isinstance(self.model, list): + solver = SolverLookup.get(self.solvername, self.model) + else: + solver = MultiSolver(self.solvername, self.model) solver.solve(**self.best_params,time_limit=time_limit) - if time_limit is not None and solver.status().runtime >= time_limit: + if not _has_finished(solver): raise TimeoutError("Time's up before solving init solver call") @@ -222,7 +163,10 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): for params_dict in combos: # Make new solver - solver = SolverLookup.get(self.solvername, self.model) + if not isinstance(self.model, list): + solver = SolverLookup.get(self.solvername, self.model) + else: + solver = MultiSolver(self.solvername, self.model) # set fixed params params_dict.update(fix_params) timeout = self.best_runtime @@ -230,70 +174,127 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): if time_limit is not None: if (time.time() - start_time) >= time_limit: break - timeout = min(timeout, max(1e-4, time_limit - (time.time() - start_time))) + timeout = min(timeout, time_limit - (time.time() - start_time)) # run solver solver.solve(**params_dict, time_limit=timeout) - if solver.status().exitstatus == ExitStatus.OPTIMAL and solver.status().runtime < self.best_runtime: + if _has_finished(solver): self.best_runtime = solver.status().runtime # update surrogate self.best_params = params_dict + return self.best_params + +def _has_finished(solver): + """ + Check whether a given solver has found the target solution. + Parameters + ---------- + solver : SolverInterface + + Returns + ------- + bool + True if the solver has has found the target solution. This means: + - For a `MultiSolver`: its own `has_finished()` method determines completion. + - For a problem with an objective: status is OPTIMAL. + - For a problem without an objective: status is FEASIBLE. + - For an unsat problem: status is UNSATISFIABLE. + False otherwise. + """ + if isinstance(solver,MultiSolver): + return solver.has_finished() + elif (((solver.has_objective() and solver.status().exitstatus == ExitStatus.OPTIMAL) or + (not solver.has_objective() and solver.status().exitstatus == ExitStatus.FEASIBLE)) or + (solver.status().exitstatus == ExitStatus.UNSATISFIABLE)): + return True + return False - if time_limit is not None and (time.time() - start_time) >= time_limit: - break - return self.best_params - def tune_list(self, time_limit=None, max_tries=None, fix_params={}): +class MultiSolver(SolverInterface): + """ + Class that manages multiple solver instances. + Attributes + ---------- + name : str + Name of the solver used for all instances. + solvers : list of SolverInterface + The solver instances corresponding to each model. + cpm_status : SolverStatus + Aggregated solver status. Tracks runtime and per-solver exit statuses. + """ + + def __init__(self,solvername,models): """ - :param time_limit: Time budget to run tuner in seconds. Solver will be interrupted when time budget is exceeded - :param max_tries: Maximum number of configurations to test - :param fix_params: Non-default parameters to run solvers with. + Initialize a MultiSolver with the given list of solvers. + Parameters + ---------- + solvername : str + Name of the solver backend (e.g., "ortools", "gurobi"). + models : list of Model + The models to create solver instances for. """ - cumulative_runtime = 0 - self.best_runtime_models = 0 - for mdl in self.model: - solver = SolverLookup.get(self.solvername, mdl) - solver.solve(**self.best_params,time_limit=time_limit) - if time_limit is not None and solver.status().runtime >= time_limit: - raise TimeoutError("Time's up before solving all instances") - cumulative_runtime += solver.status().runtime - self.best_runtime_models += solver.status().runtime - time_limit -= solver.status().runtime - # Get all possible hyperparameter configurations - combos = list(param_combinations(self.all_params)) - shuffle(combos) # test in random order - - if max_tries is not None: - combos = combos[:max_tries] + self.name = solvername + self.solvers = [] + for mdl in models: + self.solvers.append(SolverLookup.get(solvername,mdl)) + self.cpm_status = SolverStatus(self.name) + self.cpm_status.exitstatus = [ExitStatus.NOT_RUN] * len(self.solvers) - for params_dict in combos: - params_dict.update(fix_params) + def solve(self, time_limit=None, **kwargs): + """ + Solve the models sequentially using the solvers. + + Parameters + ---------- + time_limit : + Global time limit in seconds for all solvers combined. + **kwargs : dict + Additional arguments passed to each solve method. + + Returns + ------- + bool + True if all solvers returned a solution, False otherwise. + """ + start = time.time() + all_has_sol = True + # initialize exitstatus list + for i, s in enumerate(self.solvers): + # call solver + has_sol = s.solve(time_limit=time_limit, **kwargs) + # update only the current solver's exitstatus + self.cpm_status.exitstatus[i] = s.status().exitstatus if time_limit is not None: - if cumulative_runtime > time_limit: + time_limit = time_limit - (time.time() - start) + if time_limit <= 0: break - timeout = min(self.best_runtime_models, max(1e-4,time_limit - cumulative_runtime)) - # run solver - all_optimal = True - runtime_models = 0 - for mdl in self.model: - solver = SolverLookup.get(self.solvername, mdl) - solver.solve(**self.best_params, time_limit=timeout) - cumulative_runtime += solver.status().runtime - if solver.status().exitstatus == ExitStatus.OPTIMAL: - runtime_models += solver.status().runtime - timeout = max(timeout - solver.status().runtime, 1e-4) - else: - all_optimal = False - break - if all_optimal and runtime_models < self.best_runtime_models: - self.best_runtime_models = runtime_models - # update surrogate - self.best_params = params_dict + all_has_sol = all_has_sol and has_sol + end = time.time() + # update runtime + self.cpm_status.runtime = end - start + return all_has_sol - self.best_params = self._np_to_params(self._best_config) - self.best_params.update(fix_params) - return self.best_params + def has_finished(self): + """ + Check whether all solvers in the MultiSolver have finished. + A solver is considered finished if: + - It has an objective and reached OPTIMAL, or + - It has no objective and reached FEASIBLE, or + - It reached UNSATISFIABLE. + + Returns + ------- + bool + True if all solvers have finished, False otherwise. + """ + all_have_finished = True + for s in self.solvers: + finished = ((s.has_objective() and s.status().exitstatus == ExitStatus.OPTIMAL) or + (not s.has_objective() and s.status().exitstatus == ExitStatus.FEASIBLE) or + (s.status().exitstatus == ExitStatus.UNSATISFIABLE)) + all_have_finished = all_have_finished and finished + return all_have_finished From 816a68b518a71a760a1021d02bf114dc63edc27f Mon Sep 17 00:00:00 2001 From: ElFosco Date: Thu, 2 Oct 2025 09:51:19 +0200 Subject: [PATCH 3/3] bug fix --- cpmpy/tools/tune_solver.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cpmpy/tools/tune_solver.py b/cpmpy/tools/tune_solver.py index dbddcd667..9a629f577 100644 --- a/cpmpy/tools/tune_solver.py +++ b/cpmpy/tools/tune_solver.py @@ -65,6 +65,8 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): self.base_runtime = solver.status().runtime self.best_runtime = self.base_runtime + + # Get all possible hyperparameter configurations combos = list(param_combinations(self.all_params)) combos_np = self._params_to_np(combos) @@ -104,6 +106,7 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): self.best_runtime = solver.status().runtime # update surrogate self._best_config = params_np + i += 1 self.best_params = self._np_to_params(self._best_config) @@ -153,7 +156,6 @@ def tune(self, time_limit=None, max_tries=None, fix_params={}): self.base_runtime = solver.status().runtime self.best_runtime = self.base_runtime - # Get all possible hyperparameter configurations combos = list(param_combinations(self.all_params)) shuffle(combos) # test in random order @@ -238,8 +240,6 @@ def __init__(self,solvername,models): self.solvers = [] for mdl in models: self.solvers.append(SolverLookup.get(solvername,mdl)) - self.cpm_status = SolverStatus(self.name) - self.cpm_status.exitstatus = [ExitStatus.NOT_RUN] * len(self.solvers) def solve(self, time_limit=None, **kwargs): """ @@ -257,11 +257,14 @@ def solve(self, time_limit=None, **kwargs): bool True if all solvers returned a solution, False otherwise. """ - start = time.time() + self.cpm_status = SolverStatus(self.name) + self.cpm_status.exitstatus = [ExitStatus.NOT_RUN] * len(self.solvers) all_has_sol = True # initialize exitstatus list + init_start = time.time() for i, s in enumerate(self.solvers): # call solver + start = time.time() has_sol = s.solve(time_limit=time_limit, **kwargs) # update only the current solver's exitstatus self.cpm_status.exitstatus[i] = s.status().exitstatus @@ -272,7 +275,7 @@ def solve(self, time_limit=None, **kwargs): all_has_sol = all_has_sol and has_sol end = time.time() # update runtime - self.cpm_status.runtime = end - start + self.cpm_status.runtime = end - init_start return all_has_sol def has_finished(self):