diff --git a/pypesto/optimize/ess/__init__.py b/pypesto/optimize/ess/__init__.py index d4c53756a..dcda2ceeb 100644 --- a/pypesto/optimize/ess/__init__.py +++ b/pypesto/optimize/ess/__init__.py @@ -9,6 +9,7 @@ from .refset import RefSet from .sacess import ( SacessFidesFactory, + SacessIpoptFactory, SacessOptimizer, SacessOptions, get_default_ess_options, diff --git a/pypesto/optimize/ess/ess.py b/pypesto/optimize/ess/ess.py index 2d6f0775e..2b709cc14 100644 --- a/pypesto/optimize/ess/ess.py +++ b/pypesto/optimize/ess/ess.py @@ -545,7 +545,7 @@ def _do_local_search( self, x_best_children: np.ndarray, fx_best_children: np.ndarray ) -> None: """ - Perform a local search to refine the next generation. + Perform local searches to refine the next generation. See [PenasGon2017]_ Algorithm 2. """ @@ -606,29 +606,8 @@ def _do_local_search( local_search_x0, local_search_fx0, ) in local_search_x0_fx0_candidates: - optimizer = ( - self.local_optimizer - if isinstance(self.local_optimizer, pypesto.optimize.Optimizer) - else self.local_optimizer( - max_eval=self._get_remaining_eval(), - max_walltime_s=self._get_remaining_time(), - ) - ) - optimizer_result: OptimizerResult = optimizer.minimize( - problem=self.evaluator.problem, - x0=local_search_x0, - id="0", - ) - # add function evaluations during local search to our function - # evaluation counter (NOTE: depending on the setup, we might neglect - # gradient evaluations). - self.evaluator.n_eval += optimizer_result.n_fval - self.evaluator.n_eval_round += optimizer_result.n_fval - - self.logger.info( - f"Local search: {local_search_fx0} -> {optimizer_result.fval} " - f"took {optimizer_result.time:.3g}s, finished with " - f"{optimizer_result.exitflag}: {optimizer_result.message}" + optimizer_result = self._local_minimize( + x0=local_search_x0, fx0=local_search_fx0 ) if np.isfinite(optimizer_result.fval): self.local_solutions.append(optimizer_result) @@ -647,6 +626,56 @@ def _do_local_search( self.last_local_search_niter = self.n_iter self.evaluator.reset_round_counter() + def _local_minimize(self, x0: np.ndarray, fx0: float) -> OptimizerResult: + """Perform a local search from the given startpoint.""" + max_walltime_s = self._get_remaining_time() + max_eval = self._get_remaining_eval() + # If we are out of budget, return a dummy result. + # This prevents issues with optimizer that fail on if the budget is 0, + # e.g., Ipopt. + if max_walltime_s < 1 or max_eval < 1: + msg = "No time or function evaluations left for local search." + self.logger.info(msg) + return OptimizerResult( + id="0", + x=x0, + fval=np.inf, + message=msg, + n_fval=0, + n_grad=0, + time=0, + history=None, + ) + + # create optimizer instance if necessary + optimizer = ( + self.local_optimizer + if isinstance(self.local_optimizer, pypesto.optimize.Optimizer) + else self.local_optimizer( + max_eval=max_eval, + max_walltime_s=max_walltime_s, + ) + ) + # actual local search + optimizer_result: OptimizerResult = optimizer.minimize( + problem=self.evaluator.problem, + x0=x0, + id="0", + ) + + # add function evaluations during local search to our function + # evaluation counter (NOTE: depending on the setup, we might neglect + # gradient evaluations). + self.evaluator.n_eval += optimizer_result.n_fval + self.evaluator.n_eval_round += optimizer_result.n_fval + + self.logger.info( + f"Local search: {fx0} -> {optimizer_result.fval} " + f"took {optimizer_result.time:.3g}s, finished with " + f"{optimizer_result.exitflag}: {optimizer_result.message}" + ) + return optimizer_result + def _maybe_update_global_best(self, x, fx): """Update the global best value if the provided value is better.""" if fx < self.fx_best: diff --git a/pypesto/optimize/ess/sacess.py b/pypesto/optimize/ess/sacess.py index 04428d9e1..b0af2121f 100644 --- a/pypesto/optimize/ess/sacess.py +++ b/pypesto/optimize/ess/sacess.py @@ -1248,6 +1248,53 @@ def __repr__(self): return f"{self.__class__.__name__}(fides_options={self._fides_options}, fides_kwargs={self._fides_kwargs})" +class SacessIpoptFactory: + """Factory for :class:`IpoptOptimizer` instances for use with :class:`SacessOptimizer`. + + :meth:`__call__` will forward the walltime limit and function evaluation + limit imposed on :class:`SacessOptimizer` to :class:`IpoptOptimizer`. + Besides that, default options are used. + + + Parameters + ---------- + ipopt_options: + Options for the :class:`IpoptOptimizer`. + See https://coin-or.github.io/Ipopt/OPTIONS.html. + """ + + def __init__( + self, + ipopt_options: dict[str, Any] | None = None, + ): + if ipopt_options is None: + ipopt_options = {} + + self._ipopt_options = ipopt_options + + def __call__( + self, max_walltime_s: float, max_eval: float + ) -> pypesto.optimize.IpoptOptimizer: + """Create a :class:`IpoptOptimizer` instance.""" + + options = self._ipopt_options.copy() + # only accepts int + if np.isfinite(max_walltime_s): + options["max_wall_time"] = int(max_walltime_s) + + # only accepts int + if np.isfinite(max_eval): + raise NotImplementedError( + "Ipopt does not support function evaluation limits." + ) + return pypesto.optimize.IpoptOptimizer(options=options) + + def __repr__(self): + return ( + f"{self.__class__.__name__}(ipopt_options={self._ipopt_options})" + ) + + @dataclass class SacessWorkerResult: """Container for :class:`SacessWorker` results. diff --git a/test/optimize/test_optimize.py b/test/optimize/test_optimize.py index ce83bc069..5e896cdd8 100644 --- a/test/optimize/test_optimize.py +++ b/test/optimize/test_optimize.py @@ -25,6 +25,7 @@ FunctionEvaluatorMP, RefSet, SacessFidesFactory, + SacessIpoptFactory, SacessOptimizer, SacessOptions, get_default_ess_options, @@ -462,7 +463,12 @@ def test_history_beats_optimizer(): @pytest.mark.parametrize("ess_type", ["ess", "sacess"]) @pytest.mark.parametrize( "local_optimizer", - [None, optimize.FidesOptimizer(), SacessFidesFactory()], + [ + None, + optimize.FidesOptimizer(), + SacessFidesFactory(), + SacessIpoptFactory(), + ], ) @pytest.mark.flaky(reruns=3) def test_ess(problem, local_optimizer, ess_type, request): @@ -492,7 +498,7 @@ def test_ess(problem, local_optimizer, ess_type, request): for x in ess_init_args: x["local_optimizer"] = local_optimizer ess = SacessOptimizer( - max_walltime_s=1, + max_walltime_s=4, sacess_loglevel=logging.DEBUG, ess_loglevel=logging.WARNING, ess_init_args=ess_init_args,