From 37e1f7ddbabb60b1476967153fe4df9f763f01a0 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Tue, 21 Nov 2023 15:07:28 +0000 Subject: [PATCH 01/11] Added OptimisationController.set_unmoved_iterations and slightly refactored its run method. --- pints/_optimisers/__init__.py | 256 ++++++++++++++++++++++++---------- 1 file changed, 185 insertions(+), 71 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 5887051d3..608f12f8e 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -442,29 +442,90 @@ def __init__( # :meth:`run` can only be called once self._has_run = False + # Post-run statistics + self._evaluations = None + self._iterations = None + self._time = None + # # Stopping criteria + # Note that we always minimise: likelihoods are wrapped in an Error + # class that multiplies by -1 # # Maximum iterations self._max_iterations = None - self.set_max_iterations() + self.set_max_iterations() # Enable, with default arguments - # Maximum unchanged iterations + # Maximum number of iterations where f did not change significantly self._unchanged_max_iterations = None # n_iter w/o change until stop self._unchanged_threshold = 1 # smallest significant f change - self.set_max_unchanged_iterations() + self.set_max_unchanged_iterations() # Enable, with default arguments + + # Maximum number of iterations where x did not change significantly + self._unmoved_max_iterations = None # n iter w/o change + self._unmoved_threshold = None # smallest sig. x change, per parameter # Maximum evaluations self._max_evaluations = None - # Threshold value + # Function threshold: stop if f(x) < threshold self._threshold = None - # Post-run statistics - self._evaluations = None - self._iterations = None - self._time = None + def _check_stopping_criteria(self, iterations, unchanged_iterations, + unmoved_iterations, evaluations, f_new): + """ + Checks the stopping criteria, returns either ``None`` or a string + explaining why to stop. + + Note: The 'error in optimiser' criterion is not checked here. + + Parameters + ---------- + iterations + The current number of iterations. + unchanged_iterations + The current number of iterations without a change in f (best or + guessed). + unmoved_iterations + The current number of iterations without a change in x (best or + guessed). + evaluations + The current number of function evaluations. + f_new + The current function value (best or guessed). + + """ + # Maximum number of iterations + if (self._max_iterations is not None and + iterations >= self._max_iterations): + return f'Maximum number of iterations ({iterations}) reached.' + + # Maximum number of iterations without significant change in f + if (self._unchanged_max_iterations is not None and + unchanged_iterations >= self._unchanged_max_iterations): + return (f'No significant change for {unchanged_iterations}' + ' iterations.') + + # Maximum number of iterations without significant change in x + if (self._unmoved_max_iterations is not None and + unmoved_iterations >= self._unmoved_max_iterations): + return ('No significant change in position for' + f' {unmoved_iterations} iterations.') + + # Maximum number of evaluations + if (self._max_evaluations is not None and + evaluations >= self._max_evaluations): + return (f'Maximum number of evaluations ({self._max_evaluations})' + ' reached.') + + # Threshold function value + if self._threshold is not None and f_new < self._threshold: + return ('Objective function crossed threshold (' + f'{self._threshold}).') + + # All ok + return None def evaluations(self): """ @@ -490,6 +551,16 @@ def f_guessed_tracking(self): """ return self._use_f_guessed + def _has_stopping_criterion(self): + """ Returns ``True`` iff a stopping criterion has been set. """ + return any(( + self._max_iterations is not None, + self._unchanged_max_iterations is not None, + self._unmoved_max_iterations is not None, + self._max_evaluations is not None, + self._threshold is not None, + )) + def iterations(self): """ Returns the number of iterations performed during the last run, or @@ -517,6 +588,16 @@ def max_unchanged_iterations(self): return (None, None) return (self._unchanged_max_iterations, self._unchanged_threshold) + def max_unmoved_iterations(self): + """ + Returns a tuple ``(iterations, threshold)`` specifying a maximum + iterations without movement stopping criterion, or ``(None, None)`` if + no such criterion is set. + """ + if self._unmoved_max_iterations is None: + return (None, None) + return (self._unmoved_max_iterations, self._unmoved_threshold) + def optimiser(self): """ Returns the underlying optimiser object, allowing detailed @@ -533,7 +614,12 @@ def parallel(self): def run(self): """ - Runs the optimisation, returns a tuple ``(x_best, f_best)``. + Runs the optimisation, returns a tuple ``(x, f)``. + + The returned ``x`` and ``f`` correspond to either the best ``f`` seen + during the optimisation, or to the best guessed ``f``, depending on the + setting for :meth:`set_f_guessed_tracking()`. See + :meth:Optimiser.f_guessed()` for details. An optional ``callback`` function can be passed in that will be called at the end of every iteration. The callback should take the arguments @@ -545,22 +631,17 @@ def run(self): raise RuntimeError("Controller is valid for single use only") self._has_run = True - # Check stopping criteria - has_stopping_criterion = False - has_stopping_criterion |= (self._max_iterations is not None) - has_stopping_criterion |= (self._unchanged_max_iterations is not None) - has_stopping_criterion |= (self._max_evaluations is not None) - has_stopping_criterion |= (self._threshold is not None) - if not has_stopping_criterion: + # Check if any stopping criteria have been set + if not self._has_stopping_criterion(): raise ValueError('At least one stopping criterion must be set.') # Iterations and function evaluations iteration = 0 evaluations = 0 - # Unchanged iterations count (used for stopping or just for - # information) + # Unchanged and unmoved iteration count unchanged_iterations = 0 + unmoved_iterations = 0 # Choose method to evaluate f = self._function @@ -586,8 +667,9 @@ def run(self): # Internally we always minimise! Keep a 2nd value to show the user. fb_user, fg_user = (fb, fg) if self._minimising else (-fb, -fg) - # Keep track of the last significant change + # Keep track of the last significant change in f and x f_sig = np.inf + x_sig = np.ones(self._function.n_parameters()) * np.inf # Set up progress reporting next_message = 0 @@ -655,14 +737,29 @@ def run(self): fb = self._optimiser.f_best() fg = self._optimiser.f_guessed() fb_user, fg_user = (fb, fg) if self._minimising else (-fb, -fg) - - # Check for significant changes f_new = fg if self._use_f_guessed else fb - if np.abs(f_new - f_sig) >= self._unchanged_threshold: - unchanged_iterations = 0 - f_sig = f_new - else: - unchanged_iterations += 1 + + # Check for significant changes in f or in x + if self._unchanged_max_iterations: + if np.abs(f_new - f_sig) >= self._unchanged_threshold: + unchanged_iterations = 0 + # Note: f_sig is only updated after a change, so that a + # slow drift that becomes significant over multiple + # iterations is still detected. + f_sig = f_new + else: + unchanged_iterations += 1 + + if self._unmoved_max_iterations: + x_new = (self._optimiser.x_guessed() if self._use_f_guessed + else self._optimiser.x_best()) + if np.any(np.abs(x_new - x_sig) + >= self._unmoved_threshold): + unmoved_iterations = 0 + # Note: Only update here (see above) + x_sig = x_new + else: + unmoved_iterations += 1 # Update evaluation count evaluations += len(fs) @@ -684,40 +781,11 @@ def run(self): # Update iteration count iteration += 1 - # - # Check stopping criteria - # - - # Maximum number of iterations - if (self._max_iterations is not None and - iteration >= self._max_iterations): - running = False - halt_message = ('Maximum number of iterations (' - + str(iteration) + ') reached.') - - # Maximum number of iterations without significant change - halt = (self._unchanged_max_iterations is not None and - unchanged_iterations >= self._unchanged_max_iterations) - if running and halt: - running = False - halt_message = ('No significant change for ' + - str(unchanged_iterations) + ' iterations.') - - # Maximum number of evaluations - if (self._max_evaluations is not None and - evaluations >= self._max_evaluations): - running = False - halt_message = ( - 'Maximum number of evaluations (' - + str(self._max_evaluations) + ') reached.') - - # Threshold value - halt = (self._threshold is not None - and f_new < self._threshold) - if running and halt: - running = False - halt_message = ('Objective function crossed threshold: ' - + str(self._threshold) + '.') + # Check stopping criteria, set message if stopping + halt_message = self._check_stopping_criteria( + iteration, unchanged_iterations, unmoved_iterations, + evaluations, f_new) + running = halt_message is None # Error in optimiser error = self._optimiser.stop() @@ -801,7 +869,8 @@ def set_f_guessed_tracking(self, use_f_guessed=False): :meth:`pints.Optimiser.f_guessed()` or :meth:`pints.Optimiser.f_best()` (default). - The tracked ``f`` value is used to evaluate stopping criteria. + The tracked ``f`` (and/or ``x``) value is used to evaluate stopping + criteria, and is the one returned from :method:`run`. """ self._use_f_guessed = bool(use_f_guessed) @@ -811,9 +880,9 @@ def set_log_interval(self, iters=20, warm_up=3): Parameters ---------- - ``interval`` + interval A log message will be shown every ``iters`` iterations. - ``warm_up`` + warm_up A log message will be shown every iteration, for the first ``warm_up`` iterations. """ @@ -849,8 +918,8 @@ def set_log_to_screen(self, enabled): def set_max_evaluations(self, evaluations=None): """ - Adds a stopping criterion, allowing the routine to halt after the - given number of ``evaluations``. + Adds a stopping criterion so that the routine halts after the given + number of ``evaluations``. This criterion is disabled by default. To enable, pass in any positive integer. To disable again, use ``set_max_evaluations(None)``. @@ -864,8 +933,8 @@ def set_max_evaluations(self, evaluations=None): def set_max_iterations(self, iterations=10000): """ - Adds a stopping criterion, allowing the routine to halt after the - given number of ``iterations``. + Adds a stopping criterion so that the routine halts after the given + number of ``iterations``. This criterion is enabled by default. To disable it, use ``set_max_iterations(None)``. @@ -879,12 +948,15 @@ def set_max_iterations(self, iterations=10000): def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): """ - Adds a stopping criterion, allowing the routine to halt if the - objective function doesn't change by more than ``threshold`` for the - given number of ``iterations``. + Adds a stopping criterion so that the routine halts if the objective + function does not change by more than ``threshold`` for the given + number of ``iterations``. This criterion is enabled by default. To disable it, use ``set_max_unchanged_iterations(None)``. + + Note that this can be used to implement an absolute "ftol" stopping + criteria, by calling ``set_max_unchanged_iterations(1, ftol)``. """ if iterations is not None: iterations = int(iterations) @@ -899,6 +971,47 @@ def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): self._unchanged_max_iterations = iterations self._unchanged_threshold = threshold + def set_max_unmoved_iterations(self, iterations=200, threshold=1e-11): + """ + Adds a stopping criterion so that the routine halts if the position in + parameter space does not change by more ``threshold`` for the given + number of ``iterations``. + + Thresholds can be defined per parameter, or a single scalar value can + be passed in. The position is deemed to have moved if + ``np.any(np.abs(x_new - x_sig) >= self._unmoved_threshold)``, where + ``x_sig`` is the last position at which a significant move was + detected. + + This criterion is disabled by default. Once enabled, it can be disabled + again by calling ``set_max_unmoved_iterations(None)``. + + Note that this can be used to implement an absolute "xtol" stopping + criteria, by calling ``set_max_unmoved_iterations(1, xtol)``. + """ + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError( + 'Maximum number of iterations cannot be negative.') + + # Test threshold size, convert scalar if needed, check sign + np = self._function.n_parameters() + if np.isscalar(threshold): + threshold = np.ones(np) * float(threshold) + elif len(threshold) == np: + threshold = pints.vector(threshold) + else: + raise ValueError( + 'Minimum significant parameter change must be a scalar or have' + f' length {np}, got {len(threshold)}.') + if np.any(threshold < 0): + raise ValueError( + 'Minimum significant parameter change cannot be negative.') + + self._unmoved_max_iterations = iterations + self._unmoved_threshold = threshold + def set_parallel(self, parallel=False): """ Enables/disables parallel evaluation. @@ -922,7 +1035,8 @@ def set_parallel(self, parallel=False): def set_threshold(self, threshold): """ - Adds a stopping criterion, allowing the routine to halt once the + Adds a stopping criterion causing the routine to stop once the + objective function is less than the given ``threshold`` (when maximi objective function goes below a set ``threshold``. This criterion is disabled by default, but can be enabled by calling From 61054722ce43a43eb7433c4f20baf01980942a47 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Tue, 21 Nov 2023 15:07:34 +0000 Subject: [PATCH 02/11] Updated (and corrected) changelog. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21785a15..0174de598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added +- [#1508](https://github.com/pints-team/pints/pull/1508) Added a method `OptimisationController.set_max_unmoved_iterations` that allows methods to stop after 1 or more iterations with no significant movement in parameter space. +- [#1506](https://github.com/pints-team/pints/pull/1506) Added notes to `ErrorMeasure` and `LogPDF` to say parameters must be real and continuous. - [#1499](https://github.com/pints-team/pints/pull/1499) Added a log-uniform prior class. -- [#1505](https://github.com/pints-team/pints/pull/1505) Added notes to `ErrorMeasure` and `LogPDF` to say parameters must be real and continuous. ### Changed ### Deprecated ### Removed From 8a554419c336f97b8637b6d2b6a1d8124971c6b2 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 21:48:25 +0000 Subject: [PATCH 03/11] Updated new names for function and parameter max iter criteria in optimisation controller --- pints/_optimisers/__init__.py | 233 ++++++++++++++++++++-------------- 1 file changed, 138 insertions(+), 95 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 2748b02f9..cfff3e1e5 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -464,22 +464,24 @@ def __init__( self.set_max_iterations() # Enable, with default arguments # Maximum number of iterations where f did not change significantly - self._unchanged_max_iterations = None # n_iter w/o change until stop - self._unchanged_threshold = 1 # smallest significant f change - self.set_max_unchanged_iterations() # Enable, with default arguments + self._unchanged_f_max_iterations = None # max iter without change + self._unchanged_f_threshold = None # smallest significant change # Maximum number of iterations where x did not change significantly - self._unmoved_max_iterations = None # n iter w/o change - self._unmoved_threshold = None # smallest sig. x change, per parameter + self._unchanged_x_max_iterations = None # max iter without change + self._unchanged_x_threshold = None # smallest sig change, p param # Maximum evaluations self._max_evaluations = None # Function threshold: stop if f(x) < threshold - self._threshold = None + self._function_threshold = None - def _check_stopping_criteria(self, iterations, unchanged_iterations, - unmoved_iterations, evaluations, f_new): + # Default stopping criterion + self.set_max_unchanged_function_iterations() + + def _check_stopping_criteria(self, iterations, unchanged_f_iterations, + unchanged_x_iterations, evaluations, f_new): """ Checks the stopping criteria, returns either ``None`` or a string explaining why to stop. @@ -490,10 +492,10 @@ def _check_stopping_criteria(self, iterations, unchanged_iterations, ---------- iterations The current number of iterations. - unchanged_iterations + unchanged_f_iterations The current number of iterations without a change in f (best or guessed). - unmoved_iterations + unchanged_x_iterations The current number of iterations without a change in x (best or guessed). evaluations @@ -508,27 +510,28 @@ def _check_stopping_criteria(self, iterations, unchanged_iterations, return f'Maximum number of iterations ({iterations}) reached.' # Maximum number of iterations without significant change in f - if (self._unchanged_max_iterations is not None and - unchanged_iterations >= self._unchanged_max_iterations): - return (f'No significant change for {unchanged_iterations}' - ' iterations.') + if (self._unchanged_f_max_iterations is not None and + unchanged_f_iterations >= self._unchanged_f_max_iterations): + return ('No significant change in function for' + f' {unchanged_f_iterations} iterations.') # Maximum number of iterations without significant change in x - if (self._unmoved_max_iterations is not None and - unmoved_iterations >= self._unmoved_max_iterations): + if (self._unchanged_x_max_iterations is not None and + unchanged_x_iterations >= self._unchanged_x_max_iterations): return ('No significant change in position for' - f' {unmoved_iterations} iterations.') + f' {unchanged_x_iterations} iterations.') # Maximum number of evaluations if (self._max_evaluations is not None and evaluations >= self._max_evaluations): - return (f'Maximum number of evaluations ({self._max_evaluations})' - ' reached.') + return ('Maximum number of evaluations reached' + f' ({self._max_evaluations}).') # Threshold function value - if self._threshold is not None and f_new < self._threshold: + if (self._function_threshold is not None and + f_new < self._function_threshold): return ('Objective function crossed threshold (' - f'{self._threshold}).') + f'{self._function_threshold}).') # All ok return None @@ -540,13 +543,6 @@ def evaluations(self): """ return self._evaluations - def max_evaluations(self): - """ - Returns the maximum number of evaluations if this stopping criteria is - set, or ``None`` if it is not. See :meth:`set_max_evaluations`. - """ - return self._max_evaluations - def f_guessed_tracking(self): """ Returns ``True`` if the controller is set to track the optimiser @@ -558,14 +554,14 @@ def f_guessed_tracking(self): return self._use_f_guessed def _has_stopping_criterion(self): - """ Returns ``True`` iff a stopping criterion has been set. """ - return any(( + """ Returns whether a stopping criterion has been set. """ + return any( + self._unchanged_f_max_iterations is not None, + self._unchanged_x_max_iterations is not None, self._max_iterations is not None, - self._unchanged_max_iterations is not None, - self._unmoved_max_iterations is not None, self._max_evaluations is not None, - self._threshold is not None, - )) + self._function_threshold is not None, + ) def iterations(self): """ @@ -574,35 +570,61 @@ def iterations(self): """ return self._iterations + def max_evaluations(self): + """ + Returns the maximum number of evaluations if this stopping criterion is + set, or ``None`` if it is not. + + See :meth:`set_max_evaluations`. + """ + return self._max_evaluations + def max_iterations(self): """ Returns the maximum iterations if this stopping criterion is set, or - ``None`` if it is not. See :meth:`set_max_iterations()`. + ``None`` if it is not. + + See :meth:`set_max_iterations()`. """ return self._max_iterations def max_unchanged_iterations(self): """ - Returns a tuple ``(iterations, threshold)`` specifying a maximum - unchanged iterations stopping criterion, or ``(None, None)`` if no such - criterion is set. + Deprecated alias of :meth:`max_unchanged_function_iterations()`. + """ + # Deprecated on 2026-04-04 + import warnings + warnings.warn( + 'The method `max_unchanged_iterations` is deprecated.' + ' Please use `max_unchanged_function_iterations` instead.') + + return self.max_unchanged_function_iterations() + + def max_unchanged_function_iterations(self): + """ + Returns a tuple ``(iterations, threshold)`` specifying the maximum + iterations without a significant change in best function evaluation, + if this stopping criterion is set, else ``(None, None)``. The entries in the tuple correspond directly to the arguments to - :meth:`set_max_unchanged_iterations()`. + :meth:`set_max_unchanged_function_iterations()`. """ - if self._unchanged_max_iterations is None: + if self._unchanged_f_max_iterations is None: return (None, None) - return (self._unchanged_max_iterations, self._unchanged_threshold) + return (self._unchanged_f_max_iterations, self._unchanged_f_threshold) - def max_unmoved_iterations(self): + def max_unchanged_parameter_iterations(self): """ - Returns a tuple ``(iterations, threshold)`` specifying a maximum - iterations without movement stopping criterion, or ``(None, None)`` if - no such criterion is set. + Returns a tuple ``(iterations, threshold)`` specifying the maximum + iterations without a significant change in best parameters, if this + stopping criterion is set, else ``(None, None)``. + + The entries in the tuple correspond directly to the arguments to + :meth:`set_max_unchanged_parameter_iterations()`. """ - if self._unmoved_max_iterations is None: + if self._unchanged_x_max_iterations is None: return (None, None) - return (self._unmoved_max_iterations, self._unmoved_threshold) + return (self._unchanged_x_max_iterations, self._unchanged_x_threshold) def optimiser(self): """ @@ -645,9 +667,9 @@ def run(self): iteration = 0 evaluations = 0 - # Unchanged and unmoved iteration count - unchanged_iterations = 0 - unmoved_iterations = 0 + # Number of iterations without a change in f(x) or x + unchanged_f_iterations = 0 + unchanged_x_iterations = 0 # Choose method to evaluate f = self._function @@ -748,26 +770,26 @@ def run(self): f_new = fg if self._use_f_guessed else fb # Check for significant changes in f or in x - if self._unchanged_max_iterations: - if np.abs(f_new - f_sig) >= self._unchanged_threshold: - unchanged_iterations = 0 + if self._unchanged_f_max_iterations: + if np.abs(f_new - f_sig) >= self._unchanged_f_threshold: + unchanged_f_iterations = 0 # Note: f_sig is only updated after a change, so that a # slow drift that becomes significant over multiple # iterations is still detected. f_sig = f_new else: - unchanged_iterations += 1 + unchanged_f_iterations += 1 - if self._unmoved_max_iterations: + if self._unchanged_x_max_iterations: x_new = (self._optimiser.x_guessed() if self._use_f_guessed else self._optimiser.x_best()) if np.any(np.abs(x_new - x_sig) - >= self._unmoved_threshold): - unmoved_iterations = 0 + >= self._unchanged_x_threshold): + unchanged_x_iterations = 0 # Note: Only update here (see above) x_sig = x_new else: - unmoved_iterations += 1 + unchanged_x_iterations += 1 # Update evaluation count evaluations += len(fs) @@ -791,7 +813,7 @@ def run(self): # Check stopping criteria, set message if stopping halt_message = self._check_stopping_criteria( - iteration, unchanged_iterations, unmoved_iterations, + iteration, unchanged_f_iterations, unchanged_x_iterations, evaluations, f_new) running = halt_message is None @@ -927,7 +949,7 @@ def set_log_to_screen(self, enabled): def set_max_evaluations(self, evaluations=None): """ Adds a stopping criterion so that the routine halts after the given - number of ``evaluations``. + number of function ``evaluations``. This criterion is disabled by default. To enable, pass in any positive integer. To disable again, use ``set_max_evaluations(None)``. @@ -956,15 +978,29 @@ def set_max_iterations(self, iterations=10000): def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): """ + Deprecated alias of :meth:`max_unchanged_function_iterations()`. + """ + # Deprecated on 2026-04-04 + import warnings + warnings.warn( + 'The method `set_max_unchanged_iterations` is deprecated.' + ' Please use `set_max_unchanged_function_iterations` instead.') + + self.set_max_unchanged_function_iterations(iterations, threshold) + + def set_max_unchanged_function_iterations( + self, iterations=200, threshold=1e-11): + """ Adds a stopping criterion so that the routine halts if the objective function does not change by more than ``threshold`` for the given number of ``iterations``. This criterion is enabled by default. To disable it, use - ``set_max_unchanged_iterations(None)``. + ``set_max_unchanged_function_iterations(None)``. Note that this can be used to implement an absolute "ftol" stopping - criteria, by calling ``set_max_unchanged_iterations(1, ftol)``. + criteria, by calling + ``set_max_unchanged_function_iterations(1, ftol)``. """ if iterations is not None: iterations = int(iterations) @@ -972,30 +1008,35 @@ def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): raise ValueError( 'Maximum number of iterations cannot be negative.') - threshold = float(threshold) - if threshold < 0: - raise ValueError('Minimum significant change cannot be negative.') + threshold = float(threshold) + if threshold < 0: + raise ValueError( + 'Minimum significant function change cannot be negative.') + else: + threshold = None - self._unchanged_max_iterations = iterations - self._unchanged_threshold = threshold + self._unchanged_f_max_iterations = iterations + self._unchanged_f_threshold = threshold - def set_max_unmoved_iterations(self, iterations=200, threshold=1e-11): + def set_max_unchanged_parameter_iterations( + self, iterations=200, threshold=1e-11): """ Adds a stopping criterion so that the routine halts if the position in parameter space does not change by more ``threshold`` for the given number of ``iterations``. Thresholds can be defined per parameter, or a single scalar value can - be passed in. The position is deemed to have moved if - ``np.any(np.abs(x_new - x_sig) >= self._unmoved_threshold)``, where - ``x_sig`` is the last position at which a significant move was - detected. + be passed in. The position is deemed to have changed if + ``np.any(np.abs(x_new - x_sig) >= threshold)``, where ``x_sig`` is + either the starting position, or the last position for which the + criterion was met. This criterion is disabled by default. Once enabled, it can be disabled - again by calling ``set_max_unmoved_iterations(None)``. + again by calling ``set_max_unchanged_parameter_iterations(None)``. Note that this can be used to implement an absolute "xtol" stopping - criteria, by calling ``set_max_unmoved_iterations(1, xtol)``. + criteria, by calling + ``set_max_unchanged_parameter_iterations(1, xtol)``. """ if iterations is not None: iterations = int(iterations) @@ -1003,22 +1044,24 @@ def set_max_unmoved_iterations(self, iterations=200, threshold=1e-11): raise ValueError( 'Maximum number of iterations cannot be negative.') - # Test threshold size, convert scalar if needed, check sign - np = self._function.n_parameters() - if np.isscalar(threshold): - threshold = np.ones(np) * float(threshold) - elif len(threshold) == np: - threshold = pints.vector(threshold) + # Test threshold size, convert scalar if needed, check sign + np = self._function.n_parameters() + if np.isscalar(threshold): + threshold = np.ones(np) * float(threshold) + elif len(threshold) == np: + threshold = pints.vector(threshold) + else: + raise ValueError( + 'Minimum significant parameter change must be a scalar or' + f' have length {np}, got {len(threshold)}.') + if np.any(threshold < 0): + raise ValueError( + 'Minimum significant parameter change cannot be negative.') else: - raise ValueError( - 'Minimum significant parameter change must be a scalar or have' - f' length {np}, got {len(threshold)}.') - if np.any(threshold < 0): - raise ValueError( - 'Minimum significant parameter change cannot be negative.') + threshold = None - self._unmoved_max_iterations = iterations - self._unmoved_threshold = threshold + self._unchanged_x_max_iterations = iterations + self._unchanged_x_threshold = threshold def set_parallel(self, parallel=False): """ @@ -1044,24 +1087,24 @@ def set_parallel(self, parallel=False): def set_threshold(self, threshold): """ Adds a stopping criterion causing the routine to stop once the - objective function is less than the given ``threshold`` (when maximi - objective function goes below a set ``threshold``. + objective function is less than the given ``threshold`` + (when minimising, or more when maximising). This criterion is disabled by default, but can be enabled by calling this method with a valid ``threshold``. To disable it, use ``set_treshold(None)``. """ if threshold is None: - self._threshold = None + self._function_threshold = None else: - self._threshold = float(threshold) + self._function_threshold = float(threshold) def threshold(self): """ Returns the threshold stopping criterion, or ``None`` if no threshold stopping criterion is set. See :meth:`set_threshold()`. """ - return self._threshold + return self._function_threshold def time(self): """ @@ -1227,7 +1270,7 @@ def f(x, a, b, c): # Set stopping criteria opt.set_threshold(threshold) opt.set_max_iterations(max_iter) - opt.set_max_unchanged_iterations(max_unchanged) + opt.set_max_unchanged_function_iterations(max_unchanged) # Set parallelisation opt.set_parallel(parallel) @@ -1335,7 +1378,7 @@ def f(x): # Set stopping criteria opt.set_threshold(threshold) opt.set_max_iterations(max_iter) - opt.set_max_unchanged_iterations(max_unchanged) + opt.set_max_unchanged_function_iterations(max_unchanged) # Set parallelisation opt.set_parallel(parallel) From 76a9daf0cb86753d67f97eddb446629dbf46b641 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 21:54:24 +0000 Subject: [PATCH 04/11] Updated tests for optimisers --- pints/_optimisers/__init__.py | 8 +++--- pints/tests/test_opt_controller.py | 42 +++++++++++++++-------------- pints/tests/test_opt_nelder_mead.py | 3 ++- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index cfff3e1e5..ba17530df 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -512,13 +512,13 @@ def _check_stopping_criteria(self, iterations, unchanged_f_iterations, # Maximum number of iterations without significant change in f if (self._unchanged_f_max_iterations is not None and unchanged_f_iterations >= self._unchanged_f_max_iterations): - return ('No significant change in function for' + return ('No significant change in best function evaluation for' f' {unchanged_f_iterations} iterations.') # Maximum number of iterations without significant change in x if (self._unchanged_x_max_iterations is not None and unchanged_x_iterations >= self._unchanged_x_max_iterations): - return ('No significant change in position for' + return ('No significant change in best parameters for' f' {unchanged_x_iterations} iterations.') # Maximum number of evaluations @@ -555,13 +555,13 @@ def f_guessed_tracking(self): def _has_stopping_criterion(self): """ Returns whether a stopping criterion has been set. """ - return any( + return any(( self._unchanged_f_max_iterations is not None, self._unchanged_x_max_iterations is not None, self._max_iterations is not None, self._max_evaluations is not None, self._function_threshold is not None, - ) + )) def iterations(self): """ diff --git a/pints/tests/test_opt_controller.py b/pints/tests/test_opt_controller.py index 2313039bb..b8857a75b 100755 --- a/pints/tests/test_opt_controller.py +++ b/pints/tests/test_opt_controller.py @@ -115,7 +115,7 @@ def cb(*arg): s = 0.01 opt = pints.OptimisationController(r, x0, s, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) # Pass in an invalid value @@ -144,7 +144,7 @@ def cb(*arg): self.assertEqual(len(args), 0) opt = pints.OptimisationController(r, x0, s, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) opt.set_callback(cb) opt.set_callback(None) @@ -175,7 +175,7 @@ def test_transform(self): with warnings.catch_warnings(record=True): opt = pints.OptimisationController(r, x0, s, b, t, method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) opt.run() @@ -188,7 +188,7 @@ def test_transform(self): with warnings.catch_warnings(record=True): opt = pints.OptimisationController(r, x0, s, b, t, method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) x, _ = opt.run() @@ -205,7 +205,7 @@ def test_stopping_max_evaluations(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_evaluations(10) self.assertEqual(opt.max_evaluations(), 10) self.assertRaises(ValueError, opt.set_max_evaluations, -1) @@ -222,7 +222,7 @@ def test_stopping_max_iterations(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) self.assertEqual(opt.max_iterations(), 10) self.assertRaises(ValueError, opt.set_max_iterations, -1) @@ -240,7 +240,7 @@ def test_logging(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_log_interval(3) opt.set_max_iterations(10) with StreamCapture() as c: @@ -278,7 +278,7 @@ def test_logging(self): x = np.array([1.01, 1.01]) opt = pints.OptimisationController(r, x, method=pints.SNES) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_log_interval(4) opt.set_max_iterations(11) opt.optimiser().set_population_size(4) @@ -321,14 +321,16 @@ def test_stopping_max_unchanged(self): opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_iterations(None) - self.assertEqual(opt.max_unchanged_iterations(), (None, None)) - opt.set_max_unchanged_iterations(2, 1e-6) - self.assertEqual(opt.max_unchanged_iterations(), (2, 1e-6)) - opt.set_max_unchanged_iterations(3) - self.assertEqual(opt.max_unchanged_iterations(), (3, 1e-11)) - self.assertRaises(ValueError, opt.set_max_unchanged_iterations, -1) - self.assertRaises(ValueError, opt.set_max_unchanged_iterations, 10, -1) + opt.set_max_unchanged_function_iterations(None) + self.assertEqual(opt.max_unchanged_function_iterations(), (None, None)) + opt.set_max_unchanged_function_iterations(2, 1e-6) + self.assertEqual(opt.max_unchanged_function_iterations(), (2, 1e-6)) + opt.set_max_unchanged_function_iterations(3) + self.assertEqual(opt.max_unchanged_function_iterations(), (3, 1e-11)) + self.assertRaises( + ValueError, opt.set_max_unchanged_function_iterations, -1) + self.assertRaises( + ValueError, opt.set_max_unchanged_function_iterations, 10, -1) with StreamCapture() as c: opt.run() self.assertIn('Halting: No significant change', c.text()) @@ -343,7 +345,7 @@ def test_stopping_threshold(self): opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_threshold(5) self.assertEqual(opt.threshold(), 5) with StreamCapture() as c: @@ -361,7 +363,7 @@ def test_stopping_no_criterion(self): opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(debug) opt.set_max_iterations(None) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) self.assertRaises(ValueError, opt.run) def test_set_population_size(self): @@ -438,7 +440,7 @@ def test_post_run_statistics(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(50, 1e-11) + opt.set_max_unchanged_function_iterations(50, 1e-11) # Before run methods return None self.assertIsNone(opt.iterations()) @@ -466,7 +468,7 @@ def test_exception_on_multi_use(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_iterations(None) + opt.set_max_unchanged_function_iterations(None) opt.set_max_iterations(10) opt.run() self.assertRaisesRegex( diff --git a/pints/tests/test_opt_nelder_mead.py b/pints/tests/test_opt_nelder_mead.py index 89f85ad1c..3eb5ac17b 100755 --- a/pints/tests/test_opt_nelder_mead.py +++ b/pints/tests/test_opt_nelder_mead.py @@ -197,7 +197,8 @@ def test_rosenbrock(self): '400 416 0 0 0:00.0', '420 443 0 0 0:00.0', '428 452 0 0 0:00.0', - 'Halting: No significant change for 200 iterations.', + 'Halting: No significant change in best function evaluation' + ' for 200 iterations.', ) # Compare lenght of log From b2a785e6b4b56340fcc33a06a5e1da50c8c6be1b Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 22:55:54 +0000 Subject: [PATCH 05/11] Improving tests for OptimisationController stopping criteria --- pints/_optimisers/__init__.py | 12 +- pints/tests/test_opt_controller.py | 295 +++++++++++++++++++++-------- 2 files changed, 217 insertions(+), 90 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index ba17530df..2e43a6139 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -461,7 +461,6 @@ def __init__( # Maximum iterations self._max_iterations = None - self.set_max_iterations() # Enable, with default arguments # Maximum number of iterations where f did not change significantly self._unchanged_f_max_iterations = None # max iter without change @@ -477,7 +476,8 @@ def __init__( # Function threshold: stop if f(x) < threshold self._function_threshold = None - # Default stopping criterion + # Default stopping critera + self.set_max_iterations() self.set_max_unchanged_function_iterations() def _check_stopping_criteria(self, iterations, unchanged_f_iterations, @@ -1045,15 +1045,15 @@ def set_max_unchanged_parameter_iterations( 'Maximum number of iterations cannot be negative.') # Test threshold size, convert scalar if needed, check sign - np = self._function.n_parameters() + n_parameters = self._function.n_parameters() if np.isscalar(threshold): - threshold = np.ones(np) * float(threshold) - elif len(threshold) == np: + threshold = np.ones(n_parameters) * float(threshold) + elif len(threshold) == n_parameters: threshold = pints.vector(threshold) else: raise ValueError( 'Minimum significant parameter change must be a scalar or' - f' have length {np}, got {len(threshold)}.') + f' have length {n_parameters}, got {len(threshold)}.') if np.any(threshold < 0): raise ValueError( 'Minimum significant parameter change cannot be negative.') diff --git a/pints/tests/test_opt_controller.py b/pints/tests/test_opt_controller.py index b8857a75b..37d87a70d 100755 --- a/pints/tests/test_opt_controller.py +++ b/pints/tests/test_opt_controller.py @@ -20,6 +20,45 @@ method = pints.XNES +class Mock1DError(pints.ErrorMeasure): + """ Mock-up 1d error, returned values intended to be ignored. """ + def n_parameters(self): + return 1 + + def __call__(self, x): + return 0 + + +class List1DOptimiser(pints.Optimiser): + """ Mock-up optimiser using a fixed lists of values and evaluations. """ + xs = [] + fs = [] + + def __init__(self, x0, sigma0=None, boundaries=None): + super().__init__(x0, sigma0, boundaries) + self._i = 0 + + def ask(self): + return np.array([self.xs[self._i]]) + + def name(self): + return 'List1D' + + def tell(self, f): + self._i += 1 + return self.fs[self._i - 1] + + def x_best(self): + try: + return np.array([self.xs[self._i]]) + except IndexError: + raise Exception('List1DOptimiser has exhausted list values at' + f' index {self._i}') + + def f_best(self): + return self.fs[self._i] + + class TestOptimisationController(unittest.TestCase): """ Tests shared optimisation properties. @@ -151,85 +190,6 @@ def cb(*arg): opt.run() self.assertEqual(len(args), 0) - def test_optimise(self): - # Tests :meth: `pints.optimise()`. - - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - s = 0.01 - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - with StreamCapture(): - x, f = pints.optimise(r, x, s, b, method=pints.XNES) - self.assertEqual(x.shape, (2, )) - self.assertTrue(f < 1e-6) - - def test_transform(self): - # Test optimisation with parameter transformation. - - # Test with LogPDF - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x0 = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - t = pints.RectangularBoundariesTransformation(b) - with warnings.catch_warnings(record=True): - opt = pints.OptimisationController(r, x0, s, b, t, method) - opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) - opt.set_max_iterations(10) - opt.run() - - # Test with ErrorMeasure - r = pints.toy.ParabolicError() - x0 = [0.1, 0.1] - b = pints.RectangularBoundaries([-1, -1], [1, 1]) - s = 0.1 - t = pints.RectangularBoundariesTransformation(b) - with warnings.catch_warnings(record=True): - opt = pints.OptimisationController(r, x0, s, b, t, method) - opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) - opt.set_max_iterations(10) - x, _ = opt.run() - - # Test output is detransformed - self.assertEqual(x.shape, (2, )) - self.assertTrue(b.check(x)) - - def test_stopping_max_evaluations(self): - # Runs an optimisation with the max_fevals stopping criterion. - - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) - opt.set_log_to_screen(True) - opt.set_max_unchanged_function_iterations(None) - opt.set_max_evaluations(10) - self.assertEqual(opt.max_evaluations(), 10) - self.assertRaises(ValueError, opt.set_max_evaluations, -1) - with StreamCapture() as c: - opt.run() - self.assertIn('Halting: Maximum number of evaluations', c.text()) - - def test_stopping_max_iterations(self): - # Runs an optimisation with the max_iter stopping criterion. - - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) - opt.set_log_to_screen(True) - opt.set_max_unchanged_function_iterations(None) - opt.set_max_iterations(10) - self.assertEqual(opt.max_iterations(), 10) - self.assertRaises(ValueError, opt.set_max_iterations, -1) - with StreamCapture() as c: - opt.run() - self.assertIn('Halting: Maximum number of iterations', c.text()) - def test_logging(self): # Test with logpdf @@ -312,28 +272,195 @@ def test_logging(self): # Invalid log interval self.assertRaises(ValueError, opt.set_log_interval, 0) - def test_stopping_max_unchanged(self): - # Runs an optimisation with the max_unchanged stopping criterion. + def test_optimise(self): + # Tests :meth: `pints.optimise()`. + + r = pints.toy.TwistedGaussianLogPDF(2, 0.01) + x = np.array([0, 1.01]) + s = 0.01 + b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) + with StreamCapture(): + x, f = pints.optimise(r, x, s, b, method=pints.XNES) + self.assertEqual(x.shape, (2, )) + self.assertTrue(f < 1e-6) + + def test_transform(self): + # Test optimisation with parameter transformation. + + # Test with LogPDF + r = pints.toy.TwistedGaussianLogPDF(2, 0.01) + x0 = np.array([0, 1.01]) + b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) + s = 0.01 + t = pints.RectangularBoundariesTransformation(b) + with warnings.catch_warnings(record=True): + opt = pints.OptimisationController(r, x0, s, b, t, method) + opt.set_log_to_screen(False) + opt.set_max_unchanged_function_iterations(None) + opt.set_max_iterations(10) + opt.run() + + # Test with ErrorMeasure + r = pints.toy.ParabolicError() + x0 = [0.1, 0.1] + b = pints.RectangularBoundaries([-1, -1], [1, 1]) + s = 0.1 + t = pints.RectangularBoundariesTransformation(b) + with warnings.catch_warnings(record=True): + opt = pints.OptimisationController(r, x0, s, b, t, method) + opt.set_log_to_screen(False) + opt.set_max_unchanged_function_iterations(None) + opt.set_max_iterations(10) + x, _ = opt.run() + + # Test output is detransformed + self.assertEqual(x.shape, (2, )) + self.assertTrue(b.check(x)) + + def test_stopping_max_evaluations(self): + # Runs an optimisation with the max_fevals stopping criterion. + + + + + r = pints.toy.TwistedGaussianLogPDF(2, 0.01) + x = np.array([0, 1.01]) + b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) + s = 0.01 + opt = pints.OptimisationController(r, x, s, b, method=method) + opt.set_log_to_screen(True) + opt.set_max_unchanged_function_iterations(None) + opt.set_max_evaluations(10) + self.assertEqual(opt.max_evaluations(), 10) + self.assertRaises(ValueError, opt.set_max_evaluations, -1) + with StreamCapture() as c: + opt.run() + self.assertIn('Halting: Maximum number of evaluations', c.text()) + + def test_stopping_max_iterations(self): + # Runs an optimisation with the max_iter stopping criterion. + + + + r = pints.toy.TwistedGaussianLogPDF(2, 0.01) x = np.array([0, 1.01]) b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) + opt.set_max_unchanged_function_iterations(None) + opt.set_max_iterations(10) + self.assertEqual(opt.max_iterations(), 10) + self.assertRaises(ValueError, opt.set_max_iterations, -1) + with StreamCapture() as c: + opt.run() + self.assertIn('Halting: Maximum number of iterations', c.text()) + + + def test_stopping_max_unchanged_function(self): + # Runs a mock optimisation with the max_unchanged function criterion. + # Test case starts with drift (each step below threshold, but total + # change is above), then should halt at 5 + + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) + m = opt.optimiser() + m.fs = [0, 0.5, 1, 1.5, 2.0, 2.5, 4, 5, 5.1, 5.2, 5.3, 5.4, 1, 3, 0] + m.xs = [0] * len(m.fs) + opt.set_log_to_screen(True) opt.set_max_iterations(None) + + # Set by default + self.assertEqual(opt.max_unchanged_function_iterations(), (200, 1e-11)) + + # Unset and reset without threshold opt.set_max_unchanged_function_iterations(None) self.assertEqual(opt.max_unchanged_function_iterations(), (None, None)) - opt.set_max_unchanged_function_iterations(2, 1e-6) - self.assertEqual(opt.max_unchanged_function_iterations(), (2, 1e-6)) opt.set_max_unchanged_function_iterations(3) self.assertEqual(opt.max_unchanged_function_iterations(), (3, 1e-11)) + + # Unset and reset with threshold + opt.set_max_unchanged_function_iterations(None, None) + self.assertEqual(opt.max_unchanged_function_iterations(), (None, None)) + opt.set_max_unchanged_function_iterations(4, 1) + self.assertEqual(opt.max_unchanged_function_iterations(), (4, 1)) + + # Bad calls self.assertRaises( ValueError, opt.set_max_unchanged_function_iterations, -1) self.assertRaises( ValueError, opt.set_max_unchanged_function_iterations, 10, -1) + + # Test deprecated aliases + a = opt.max_unchanged_function_iterations() + with warnings.catch_warnings(record=True) as w: + b = opt.max_unchanged_iterations() + self.assertIn('deprecated', str(w[-1].message)) + self.assertEqual(a, b) + with warnings.catch_warnings(record=True) as w: + opt.set_max_unchanged_iterations(1, 0) + self.assertIn('deprecated', str(w[-1].message)) + self.assertEqual(opt.max_unchanged_function_iterations(), (1, 0)) + opt.set_max_unchanged_function_iterations(4, 1) + + # Test + with StreamCapture() as c: + opt.run() + self.assertIn('No significant change in best function', c.text()) + self.assertEqual(opt.iterations(), 11) + + def test_stopping_max_unchanged_parameter(self): + # Runs a mock optimisation with the max_unchanged parameter criterion. + + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) + m = opt.optimiser() + m.xs = [0, 1, 1.1, 1.2, 2, 3, 4, 4.1, 4.2, 4.3, 5, 6, 7] + m.fs = [0] * len(m.xs) + opt.set_log_to_screen(True) + opt.set_max_iterations(None) + opt.set_max_unchanged_function_iterations(None) + self.assertEqual( + opt.max_unchanged_parameter_iterations(), (None, None)) + + # Set without threshold + opt.set_max_unchanged_parameter_iterations(2) + n, t = opt.max_unchanged_parameter_iterations() + self.assertEqual(n, 2) + self.assertEqual(list(t), [1e-11]) + + # Unset and reset without threshold + opt.set_max_unchanged_parameter_iterations(None) + n, t = opt.max_unchanged_parameter_iterations() + self.assertIsNone(n) + self.assertIsNone(t) + opt.set_max_unchanged_parameter_iterations(2) + n, t = opt.max_unchanged_parameter_iterations() + self.assertEqual(n, 2) + self.assertEqual(list(t), [1e-11]) + + # Unset and reset with threshold + opt.set_max_unchanged_parameter_iterations(None, None) + n, t = opt.max_unchanged_parameter_iterations() + self.assertIsNone(n) + self.assertIsNone(t) + opt.set_max_unchanged_parameter_iterations(3, 1) + n, t = opt.max_unchanged_parameter_iterations() + self.assertEqual(n, 3) + self.assertEqual(list(t), [1]) + + # Bad calls + self.assertRaises( + ValueError, opt.set_max_unchanged_parameter_iterations, -1) + self.assertRaises( + ValueError, opt.set_max_unchanged_parameter_iterations, 10, -1) + + # Test with StreamCapture() as c: opt.run() - self.assertIn('Halting: No significant change', c.text()) + self.assertIn('No significant change in best parameters', c.text()) + self.assertEqual(opt.iterations(), 9) def test_stopping_threshold(self): # Runs an optimisation with the threshold stopping criterion. From 6cef5a661df8cd3d8db463157b62f4fa04291db6 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 22:58:03 +0000 Subject: [PATCH 06/11] Updated changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d05590945..d59f5c220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,11 @@ All notable changes to this project will be documented in this file. ### Added - [#1715](https://github.com/pints-team/pints/pull/1715) Added methods `ProblemErrorMeasure.problem()`, `ProblemLogLikelihood.problem()`, `SingleOutputProblem.model()` and `MultiOutputProblem.model()`. -- [#1508](https://github.com/pints-team/pints/pull/1508) Added a method `OptimisationController.set_max_unmoved_iterations` that allows methods to stop after 1 or more iterations with no significant movement in parameter space. +- [#1508](https://github.com/pints-team/pints/pull/1508) Added a method `OptimisationController.set_max_unchanged_parameter_iterations` that allows methods to stop after a fixed number of iterations with no significant movement in parameter space. ### Changed - [#1713](https://github.com/pints-team/pints/pull/1713) PINTS now requires matplotlib 2.2 or newer. ### Deprecated +- [#1508](https://github.com/pints-team/pints/pull/1508) The methods `OptimisationController.max_unchanged_iterations` and `set_max_unchanged_iterations` are deprecated, in favour of `max_unchanged_function_iterations` and `set_max_unchanged_function_iterations` respectively. ### Removed ### Fixed - [#1713](https://github.com/pints-team/pints/pull/1713) Fixed Numpy 2.4.1 compatibility issues. From fda727db1ce3dda44655c3248832b751d6f13363 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 23:24:37 +0000 Subject: [PATCH 07/11] Wrote better tests for optimiser stopping criteria. --- pints/_optimisers/__init__.py | 2 +- pints/tests/test_opt_controller.py | 120 +++++++++++++++++------------ 2 files changed, 72 insertions(+), 50 deletions(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 2e43a6139..8dc3d9e11 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -507,7 +507,7 @@ def _check_stopping_criteria(self, iterations, unchanged_f_iterations, # Maximum number of iterations if (self._max_iterations is not None and iterations >= self._max_iterations): - return f'Maximum number of iterations ({iterations}) reached.' + return f'Maximum number of iterations reached ({iterations}).' # Maximum number of iterations without significant change in f if (self._unchanged_f_max_iterations is not None and diff --git a/pints/tests/test_opt_controller.py b/pints/tests/test_opt_controller.py index 37d87a70d..582144279 100755 --- a/pints/tests/test_opt_controller.py +++ b/pints/tests/test_opt_controller.py @@ -30,27 +30,29 @@ def __call__(self, x): class List1DOptimiser(pints.Optimiser): - """ Mock-up optimiser using a fixed lists of values and evaluations. """ - xs = [] - fs = [] + """ + Mock-up optimiser using a fixed lists of values and evaluations. + """ + fs = np.linspace(1, 0, 100) + xs = np.zeros(100) + np = 1 def __init__(self, x0, sigma0=None, boundaries=None): super().__init__(x0, sigma0, boundaries) self._i = 0 def ask(self): - return np.array([self.xs[self._i]]) + return np.array(self.xs[self._i: self._i + self.np]) def name(self): return 'List1D' def tell(self, f): - self._i += 1 - return self.fs[self._i - 1] + self._i += self.np def x_best(self): try: - return np.array([self.xs[self._i]]) + return np.array(self.xs[self._i: self._i + self.np]) except IndexError: raise Exception('List1DOptimiser has exhausted list values at' f' index {self._i}') @@ -228,7 +230,7 @@ def test_logging(self): self.assertEqual(lines[11][:-3], '10 47 -4.140462 -4.140463 0:0') self.assertEqual( - lines[12], 'Halting: Maximum number of iterations (10) reached.') + lines[12], 'Halting: Maximum number of iterations reached (10).') # Invalid log interval self.assertRaises(ValueError, opt.set_log_interval, 0) @@ -267,7 +269,7 @@ def test_logging(self): self.assertEqual(lines[11][:-3], '11 44 0.0165 3.601763 0:0') self.assertEqual( - lines[12], 'Halting: Maximum number of iterations (11) reached.') + lines[12], 'Halting: Maximum number of iterations reached (11).') # Invalid log interval self.assertRaises(ValueError, opt.set_log_interval, 0) @@ -318,45 +320,56 @@ def test_transform(self): self.assertTrue(b.check(x)) def test_stopping_max_evaluations(self): - # Runs an optimisation with the max_fevals stopping criterion. - - + # Runs an optimisation with the max evaluations stopping criterion. - - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) + opt.optimiser().np = 2 # Two evaluations per iteration opt.set_log_to_screen(True) + opt.set_max_iterations(None) opt.set_max_unchanged_function_iterations(None) - opt.set_max_evaluations(10) - self.assertEqual(opt.max_evaluations(), 10) + + # Test getting and setting + self.assertIs(opt.max_evaluations(), None) + opt.set_max_evaluations(5) + self.assertEqual(opt.max_evaluations(), 5) + opt.set_max_evaluations(None) + self.assertIs(opt.max_evaluations(), None) + opt.set_max_evaluations(23) + self.assertEqual(opt.max_evaluations(), 23) self.assertRaises(ValueError, opt.set_max_evaluations, -1) + + # Run, test result with StreamCapture() as c: opt.run() - self.assertIn('Halting: Maximum number of evaluations', c.text()) + self.assertIn('Maximum number of evaluations reached (23)', c.text()) + self.assertEqual(opt.iterations(), 12) def test_stopping_max_iterations(self): - # Runs an optimisation with the max_iter stopping criterion. - + # Runs a mock optimisation with the max iterations stopping criterion. - - - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) + opt.optimiser().np = 2 # Two evaluations per iteration opt.set_log_to_screen(True) + opt.set_max_iterations(None) opt.set_max_unchanged_function_iterations(None) - opt.set_max_iterations(10) - self.assertEqual(opt.max_iterations(), 10) + + # Test getting and setting + self.assertIs(opt.max_iterations(), None) + opt.set_max_iterations(3) + self.assertEqual(opt.max_iterations(), 3) + opt.set_max_iterations(None) + self.assertIs(opt.max_iterations(), None) + opt.set_max_iterations(15) + self.assertEqual(opt.max_iterations(), 15) self.assertRaises(ValueError, opt.set_max_iterations, -1) + + # Run, test result with StreamCapture() as c: opt.run() - self.assertIn('Halting: Maximum number of iterations', c.text()) - + self.assertIn('Maximum number of iterations reached (15)', c.text()) + self.assertEqual(opt.iterations(), 15) def test_stopping_max_unchanged_function(self): # Runs a mock optimisation with the max_unchanged function criterion. @@ -412,11 +425,13 @@ def test_stopping_max_unchanged_function(self): def test_stopping_max_unchanged_parameter(self): # Runs a mock optimisation with the max_unchanged parameter criterion. + # Test case starts with drift (each step below threshold, but total + # change is above), then should halt at 4 e = Mock1DError() opt = pints.OptimisationController(e, [0], method=List1DOptimiser) m = opt.optimiser() - m.xs = [0, 1, 1.1, 1.2, 2, 3, 4, 4.1, 4.2, 4.3, 5, 6, 7] + m.xs = [0, 1, 1.5, 2.0, 2.5, 3, 4, 4.1, 4.2, 4.3, 5, 6, 7] m.fs = [0] * len(m.xs) opt.set_log_to_screen(True) opt.set_max_iterations(None) @@ -462,32 +477,39 @@ def test_stopping_max_unchanged_parameter(self): self.assertIn('No significant change in best parameters', c.text()) self.assertEqual(opt.iterations(), 9) - def test_stopping_threshold(self): - # Runs an optimisation with the threshold stopping criterion. + def test_stopping_function_threshold(self): + # Runs a mock optimisation with the function threshold stopping crit. - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0.008, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) + m = opt.optimiser() + m.fs = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + m.xs = [0] * len(m.fs) opt.set_log_to_screen(True) opt.set_max_iterations(None) opt.set_max_unchanged_function_iterations(None) + + # Test getting and setting + self.assertIsNone(opt.threshold()) + opt.set_threshold(3) + self.assertEqual(opt.threshold(), 3) + opt.set_threshold(None) + self.assertIsNone(opt.threshold()) opt.set_threshold(5) self.assertEqual(opt.threshold(), 5) + + # Run, test result with StreamCapture() as c: opt.run() - self.assertIn( - 'Halting: Objective function crossed threshold', c.text()) + self.assertIn( + 'Halting: Objective function crossed threshold (5.0)', c.text()) + self.assertEqual(opt.iterations(), 6) def test_stopping_no_criterion(self): # Tries to run an optimisation with the no stopping criterion. - r = pints.toy.TwistedGaussianLogPDF(2, 0.01) - x = np.array([0, 1.01]) - b = pints.RectangularBoundaries([-0.01, 0.95], [0.01, 1.05]) - s = 0.01 - opt = pints.OptimisationController(r, x, s, b, method=method) + e = Mock1DError() + opt = pints.OptimisationController(e, [0], method=List1DOptimiser) opt.set_log_to_screen(debug) opt.set_max_iterations(None) opt.set_max_unchanged_function_iterations(None) From ac3a58c22629c4d6a8aa281d5dfa82be7821f40e Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 23:26:21 +0000 Subject: [PATCH 08/11] Added missing test for opt controller --- pints/tests/test_opt_controller.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pints/tests/test_opt_controller.py b/pints/tests/test_opt_controller.py index 582144279..c364cb44a 100755 --- a/pints/tests/test_opt_controller.py +++ b/pints/tests/test_opt_controller.py @@ -460,7 +460,11 @@ def test_stopping_max_unchanged_parameter(self): n, t = opt.max_unchanged_parameter_iterations() self.assertIsNone(n) self.assertIsNone(t) - opt.set_max_unchanged_parameter_iterations(3, 1) + opt.set_max_unchanged_parameter_iterations(3, 2) + n, t = opt.max_unchanged_parameter_iterations() + self.assertEqual(n, 3) + self.assertEqual(list(t), [2]) + opt.set_max_unchanged_parameter_iterations(3, [1]) n, t = opt.max_unchanged_parameter_iterations() self.assertEqual(n, 3) self.assertEqual(list(t), [1]) @@ -470,6 +474,8 @@ def test_stopping_max_unchanged_parameter(self): ValueError, opt.set_max_unchanged_parameter_iterations, -1) self.assertRaises( ValueError, opt.set_max_unchanged_parameter_iterations, 10, -1) + self.assertRaises( + ValueError, opt.set_max_unchanged_parameter_iterations, 10, [1, 1]) # Test with StreamCapture() as c: From 21702eebe6af55c0419f6b1cea5c58ddf3c4f2a9 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 23:28:07 +0000 Subject: [PATCH 09/11] Fix to docstring --- pints/_optimisers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 8dc3d9e11..b66c3701c 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -900,7 +900,7 @@ def set_f_guessed_tracking(self, use_f_guessed=False): :meth:`pints.Optimiser.f_best()` (default). The tracked ``f`` (and/or ``x``) value is used to evaluate stopping - criteria, and is the one returned from :method:`run`. + criteria, and is the one returned from :meth:`run`. """ self._use_f_guessed = bool(use_f_guessed) From 3a43e0a8f4009edea572dd60a81e8a62c89d8e04 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Wed, 4 Feb 2026 23:29:58 +0000 Subject: [PATCH 10/11] Fix to PSO test --- pints/tests/test_opt_pso.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pints/tests/test_opt_pso.py b/pints/tests/test_opt_pso.py index 29b70a446..a3c73002f 100755 --- a/pints/tests/test_opt_pso.py +++ b/pints/tests/test_opt_pso.py @@ -139,7 +139,7 @@ def test_logging(self): for line in lines[5:-1]: self.assertTrue(pattern.match(line)) self.assertEqual( - lines[-1], 'Halting: Maximum number of iterations (10) reached.') + lines[-1], 'Halting: Maximum number of iterations reached (10).') # Log to file opt = pints.OptimisationController(r, x, s, b, method=method) From 3949338440477448166f34bfb969fd7d2f682091 Mon Sep 17 00:00:00 2001 From: Michael Clerx Date: Thu, 5 Feb 2026 15:32:38 +0000 Subject: [PATCH 11/11] Changed opt controller stopping crit names to function_tolerance and parameter_tolerance. --- CHANGELOG.md | 4 +- pints/_optimisers/__init__.py | 141 ++++++++++++++--------------- pints/tests/test_opt_adam.py | 1 - pints/tests/test_opt_controller.py | 100 ++++++++++---------- pints/tests/test_opt_irpropmin.py | 2 - 5 files changed, 118 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e63ca7a8..042350506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ All notable changes to this project will be documented in this file. ### Added - [#1716](https://github.com/pints-team/pints/pull/1716) PINTS is now tested on Python 3.14. - [#1715](https://github.com/pints-team/pints/pull/1715) Added methods `ProblemErrorMeasure.problem()`, `ProblemLogLikelihood.problem()`, `SingleOutputProblem.model()` and `MultiOutputProblem.model()`. -- [#1508](https://github.com/pints-team/pints/pull/1508) Added a method `OptimisationController.set_max_unchanged_parameter_iterations` that allows methods to stop after a fixed number of iterations with no significant movement in parameter space. +- [#1508](https://github.com/pints-team/pints/pull/1508) Added a method `OptimisationController.set_parameter_tolerance` that allows methods to stop after a fixed number of iterations with no significant movement in parameter space. ### Changed - [#1713](https://github.com/pints-team/pints/pull/1713) PINTS now requires matplotlib 2.2 or newer. ### Deprecated -- [#1508](https://github.com/pints-team/pints/pull/1508) The methods `OptimisationController.max_unchanged_iterations` and `set_max_unchanged_iterations` are deprecated, in favour of `max_unchanged_function_iterations` and `set_max_unchanged_function_iterations` respectively. +- [#1508](https://github.com/pints-team/pints/pull/1508) The methods `OptimisationController.max_unchanged_iterations` and `set_max_unchanged_iterations` are deprecated, in favour of `function_tolerance` and `set_function_tolerance` respectively. ### Removed ### Fixed - [#1713](https://github.com/pints-team/pints/pull/1713) Fixed Numpy 2.4.1 compatibility issues. diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py index 315ecd5e6..22341ce5f 100644 --- a/pints/_optimisers/__init__.py +++ b/pints/_optimisers/__init__.py @@ -462,13 +462,13 @@ def __init__( # Maximum iterations self._max_iterations = None - # Maximum number of iterations where f did not change significantly - self._unchanged_f_max_iterations = None # max iter without change - self._unchanged_f_threshold = None # smallest significant change + # Maximum number of iterations without significant change in f(x) + self._ftol_max = None # max number of iterations without change + self._ftol_threshold = None # smallest significant change - # Maximum number of iterations where x did not change significantly - self._unchanged_x_max_iterations = None # max iter without change - self._unchanged_x_threshold = None # smallest sig change, p param + # Maximum number of iterations without significant change in x + self._xtol_max = None # max number of iterations without change + self._xtol_threshold = None # smallest significant change per param # Maximum evaluations self._max_evaluations = None @@ -478,7 +478,7 @@ def __init__( # Default stopping critera self.set_max_iterations() - self.set_max_unchanged_function_iterations() + self.set_function_tolerance() def _check_stopping_criteria(self, iterations, unchanged_f_iterations, unchanged_x_iterations, evaluations, f_new): @@ -509,24 +509,24 @@ def _check_stopping_criteria(self, iterations, unchanged_f_iterations, iterations >= self._max_iterations): return f'Maximum number of iterations reached ({iterations}).' + # Maximum number of evaluations + if (self._max_evaluations is not None and + evaluations >= self._max_evaluations): + return ('Maximum number of evaluations reached' + f' ({self._max_evaluations}).') + # Maximum number of iterations without significant change in f - if (self._unchanged_f_max_iterations is not None and - unchanged_f_iterations >= self._unchanged_f_max_iterations): + if (self._ftol_max is not None and + unchanged_f_iterations >= self._ftol_max): return ('No significant change in best function evaluation for' f' {unchanged_f_iterations} iterations.') # Maximum number of iterations without significant change in x - if (self._unchanged_x_max_iterations is not None and - unchanged_x_iterations >= self._unchanged_x_max_iterations): + if (self._xtol_max is not None and + unchanged_x_iterations >= self._xtol_max): return ('No significant change in best parameters for' f' {unchanged_x_iterations} iterations.') - # Maximum number of evaluations - if (self._max_evaluations is not None and - evaluations >= self._max_evaluations): - return ('Maximum number of evaluations reached' - f' ({self._max_evaluations}).') - # Threshold function value if (self._function_threshold is not None and f_new < self._function_threshold): @@ -553,13 +553,26 @@ def f_guessed_tracking(self): """ return self._use_f_guessed + def function_tolerance(self): + """ + Returns a tuple ``(iterations, threshold)`` specifying the maximum + iterations without a significant change in best function evaluation, + if this stopping criterion is set, else ``(None, None)``. + + The entries in the tuple correspond directly to the arguments to + :meth:`set_function_tolerance()`. + """ + if self._ftol_max is None: + return (None, None) + return (self._ftol_max, self._ftol_threshold) + def _has_stopping_criterion(self): """ Returns whether a stopping criterion has been set. """ return any(( - self._unchanged_f_max_iterations is not None, - self._unchanged_x_max_iterations is not None, self._max_iterations is not None, self._max_evaluations is not None, + self._ftol_max is not None, + self._xtol_max is not None, self._function_threshold is not None, )) @@ -590,41 +603,15 @@ def max_iterations(self): def max_unchanged_iterations(self): """ - Deprecated alias of :meth:`max_unchanged_function_iterations()`. + Deprecated alias of :meth:`function_tolerance()`. """ - # Deprecated on 2026-04-04 + # Deprecated on 2026-02-05 import warnings warnings.warn( 'The method `max_unchanged_iterations` is deprecated.' - ' Please use `max_unchanged_function_iterations` instead.') + ' Please use `function_tolerance` instead.') - return self.max_unchanged_function_iterations() - - def max_unchanged_function_iterations(self): - """ - Returns a tuple ``(iterations, threshold)`` specifying the maximum - iterations without a significant change in best function evaluation, - if this stopping criterion is set, else ``(None, None)``. - - The entries in the tuple correspond directly to the arguments to - :meth:`set_max_unchanged_function_iterations()`. - """ - if self._unchanged_f_max_iterations is None: - return (None, None) - return (self._unchanged_f_max_iterations, self._unchanged_f_threshold) - - def max_unchanged_parameter_iterations(self): - """ - Returns a tuple ``(iterations, threshold)`` specifying the maximum - iterations without a significant change in best parameters, if this - stopping criterion is set, else ``(None, None)``. - - The entries in the tuple correspond directly to the arguments to - :meth:`set_max_unchanged_parameter_iterations()`. - """ - if self._unchanged_x_max_iterations is None: - return (None, None) - return (self._unchanged_x_max_iterations, self._unchanged_x_threshold) + return self.function_tolerance() def optimiser(self): """ @@ -640,6 +627,19 @@ def parallel(self): """ return self._n_workers if self._parallel else False + def parameter_tolerance(self): + """ + Returns a tuple ``(iterations, threshold)`` specifying the maximum + iterations without a significant change in best parameters, if this + stopping criterion is set, else ``(None, None)``. + + The entries in the tuple correspond directly to the arguments to + :meth:`set_parameter_tolerance()`. + """ + if self._xtol_max is None: + return (None, None) + return (self._xtol_max, self._xtol_threshold) + def run(self): """ Runs the optimisation, returns a tuple ``(x, f)``. @@ -774,8 +774,8 @@ def run(self): f_new = fg if self._use_f_guessed else fb # Check for significant changes in f or in x - if self._unchanged_f_max_iterations: - if np.abs(f_new - f_sig) >= self._unchanged_f_threshold: + if self._ftol_max: + if np.abs(f_new - f_sig) >= self._ftol_threshold: unchanged_f_iterations = 0 # Note: f_sig is only updated after a change, so that a # slow drift that becomes significant over multiple @@ -784,11 +784,10 @@ def run(self): else: unchanged_f_iterations += 1 - if self._unchanged_x_max_iterations: + if self._xtol_max: x_new = (self._optimiser.x_guessed() if self._use_f_guessed else self._optimiser.x_best()) - if np.any(np.abs(x_new - x_sig) - >= self._unchanged_x_threshold): + if np.any(np.abs(x_new - x_sig) >= self._xtol_threshold): unchanged_x_iterations = 0 # Note: Only update here (see above) x_sig = x_new @@ -982,29 +981,28 @@ def set_max_iterations(self, iterations=10000): def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): """ - Deprecated alias of :meth:`max_unchanged_function_iterations()`. + Deprecated alias of :meth:`function_tolerance()`. """ - # Deprecated on 2026-04-04 + # Deprecated on 2026-02-05 import warnings warnings.warn( 'The method `set_max_unchanged_iterations` is deprecated.' - ' Please use `set_max_unchanged_function_iterations` instead.') + ' Please use `set_function_tolerance` instead.') - self.set_max_unchanged_function_iterations(iterations, threshold) + self.set_function_tolerance(iterations, threshold) - def set_max_unchanged_function_iterations( - self, iterations=200, threshold=1e-11): + def set_function_tolerance(self, iterations=200, threshold=1e-11): """ Adds a stopping criterion so that the routine halts if the objective function does not change by more than ``threshold`` for the given number of ``iterations``. This criterion is enabled by default. To disable it, use - ``set_max_unchanged_function_iterations(None)``. + ``set_function_tolerance(None)``. Note that this can be used to implement an absolute "ftol" stopping criteria, by calling - ``set_max_unchanged_function_iterations(1, ftol)``. + ``set_function_tolerance(1, ftol)``. """ if iterations is not None: iterations = int(iterations) @@ -1019,11 +1017,10 @@ def set_max_unchanged_function_iterations( else: threshold = None - self._unchanged_f_max_iterations = iterations - self._unchanged_f_threshold = threshold + self._ftol_max = iterations + self._ftol_threshold = threshold - def set_max_unchanged_parameter_iterations( - self, iterations=200, threshold=1e-11): + def set_parameter_tolerance(self, iterations=200, threshold=1e-11): """ Adds a stopping criterion so that the routine halts if the position in parameter space does not change by more ``threshold`` for the given @@ -1036,11 +1033,11 @@ def set_max_unchanged_parameter_iterations( criterion was met. This criterion is disabled by default. Once enabled, it can be disabled - again by calling ``set_max_unchanged_parameter_iterations(None)``. + again by calling ``set_parameter_tolerance(None)``. Note that this can be used to implement an absolute "xtol" stopping criteria, by calling - ``set_max_unchanged_parameter_iterations(1, xtol)``. + ``set_parameter_tolerance(1, xtol)``. """ if iterations is not None: iterations = int(iterations) @@ -1064,8 +1061,8 @@ def set_max_unchanged_parameter_iterations( else: threshold = None - self._unchanged_x_max_iterations = iterations - self._unchanged_x_threshold = threshold + self._xtol_max = iterations + self._xtol_threshold = threshold def set_parallel(self, parallel=False): """ @@ -1274,7 +1271,7 @@ def f(x, a, b, c): # Set stopping criteria opt.set_threshold(threshold) opt.set_max_iterations(max_iter) - opt.set_max_unchanged_function_iterations(max_unchanged) + opt.set_function_tolerance(max_unchanged) # Set parallelisation opt.set_parallel(parallel) @@ -1382,7 +1379,7 @@ def f(x): # Set stopping criteria opt.set_threshold(threshold) opt.set_max_iterations(max_iter) - opt.set_max_unchanged_function_iterations(max_unchanged) + opt.set_function_tolerance(max_unchanged) # Set parallelisation opt.set_parallel(parallel) diff --git a/pints/tests/test_opt_adam.py b/pints/tests/test_opt_adam.py index 39f541c4c..1a3c488ee 100755 --- a/pints/tests/test_opt_adam.py +++ b/pints/tests/test_opt_adam.py @@ -94,7 +94,6 @@ def test_logging(self): r, x, s = self.problem() opt = pints.OptimisationController(r, x, s, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) opt.set_max_iterations(3) with StreamCapture() as c: opt.run() diff --git a/pints/tests/test_opt_controller.py b/pints/tests/test_opt_controller.py index 0ce8ab4b6..bd59ac0e4 100755 --- a/pints/tests/test_opt_controller.py +++ b/pints/tests/test_opt_controller.py @@ -156,7 +156,7 @@ def cb(*arg): s = 0.01 opt = pints.OptimisationController(r, x0, s, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_max_iterations(10) # Pass in an invalid value @@ -185,7 +185,7 @@ def cb(*arg): self.assertEqual(len(args), 0) opt = pints.OptimisationController(r, x0, s, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_max_iterations(10) opt.set_callback(cb) opt.set_callback(None) @@ -202,7 +202,7 @@ def test_logging(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_log_interval(3) opt.set_max_iterations(10) with StreamCapture() as c: @@ -240,7 +240,7 @@ def test_logging(self): x = np.array([1.01, 1.01]) opt = pints.OptimisationController(r, x, method=pints.SNES) opt.set_log_to_screen(True) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_log_interval(4) opt.set_max_iterations(11) opt.optimiser().set_population_size(4) @@ -298,7 +298,7 @@ def test_transform(self): with warnings.catch_warnings(record=True): opt = pints.OptimisationController(r, x0, s, b, t, method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_max_iterations(10) opt.run() @@ -311,7 +311,7 @@ def test_transform(self): with warnings.catch_warnings(record=True): opt = pints.OptimisationController(r, x0, s, b, t, method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_max_iterations(10) x, _ = opt.run() @@ -327,7 +327,7 @@ def test_stopping_max_evaluations(self): opt.optimiser().np = 2 # Two evaluations per iteration opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) # Test getting and setting self.assertIs(opt.max_evaluations(), None) @@ -353,7 +353,7 @@ def test_stopping_max_iterations(self): opt.optimiser().np = 2 # Two evaluations per iteration opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) # Test getting and setting self.assertIs(opt.max_iterations(), None) @@ -371,8 +371,8 @@ def test_stopping_max_iterations(self): self.assertIn('Maximum number of iterations reached (15)', c.text()) self.assertEqual(opt.iterations(), 15) - def test_stopping_max_unchanged_function(self): - # Runs a mock optimisation with the max_unchanged function criterion. + def test_stopping_function_tolerance(self): + # Runs a mock optimisation with the function tolerance criterion. # Test case starts with drift (each step below threshold, but total # change is above), then should halt at 5 @@ -385,28 +385,26 @@ def test_stopping_max_unchanged_function(self): opt.set_max_iterations(None) # Set by default - self.assertEqual(opt.max_unchanged_function_iterations(), (200, 1e-11)) + self.assertEqual(opt.function_tolerance(), (200, 1e-11)) # Unset and reset without threshold - opt.set_max_unchanged_function_iterations(None) - self.assertEqual(opt.max_unchanged_function_iterations(), (None, None)) - opt.set_max_unchanged_function_iterations(3) - self.assertEqual(opt.max_unchanged_function_iterations(), (3, 1e-11)) + opt.set_function_tolerance(None) + self.assertEqual(opt.function_tolerance(), (None, None)) + opt.set_function_tolerance(3) + self.assertEqual(opt.function_tolerance(), (3, 1e-11)) # Unset and reset with threshold - opt.set_max_unchanged_function_iterations(None, None) - self.assertEqual(opt.max_unchanged_function_iterations(), (None, None)) - opt.set_max_unchanged_function_iterations(4, 1) - self.assertEqual(opt.max_unchanged_function_iterations(), (4, 1)) + opt.set_function_tolerance(None, None) + self.assertEqual(opt.function_tolerance(), (None, None)) + opt.set_function_tolerance(4, 1) + self.assertEqual(opt.function_tolerance(), (4, 1)) # Bad calls - self.assertRaises( - ValueError, opt.set_max_unchanged_function_iterations, -1) - self.assertRaises( - ValueError, opt.set_max_unchanged_function_iterations, 10, -1) + self.assertRaises(ValueError, opt.set_function_tolerance, -1) + self.assertRaises(ValueError, opt.set_function_tolerance, 10, -1) # Test deprecated aliases - a = opt.max_unchanged_function_iterations() + a = opt.function_tolerance() with warnings.catch_warnings(record=True) as w: b = opt.max_unchanged_iterations() self.assertIn('deprecated', str(w[-1].message)) @@ -414,8 +412,8 @@ def test_stopping_max_unchanged_function(self): with warnings.catch_warnings(record=True) as w: opt.set_max_unchanged_iterations(1, 0) self.assertIn('deprecated', str(w[-1].message)) - self.assertEqual(opt.max_unchanged_function_iterations(), (1, 0)) - opt.set_max_unchanged_function_iterations(4, 1) + self.assertEqual(opt.function_tolerance(), (1, 0)) + opt.set_function_tolerance(4, 1) # Test with StreamCapture() as c: @@ -423,8 +421,8 @@ def test_stopping_max_unchanged_function(self): self.assertIn('No significant change in best function', c.text()) self.assertEqual(opt.iterations(), 11) - def test_stopping_max_unchanged_parameter(self): - # Runs a mock optimisation with the max_unchanged parameter criterion. + def test_stopping_parameter_tolerance(self): + # Runs a mock optimisation with the parameter tolerance criterion. # Test case starts with drift (each step below threshold, but total # change is above), then should halt at 4 @@ -435,47 +433,43 @@ def test_stopping_max_unchanged_parameter(self): m.fs = [0] * len(m.xs) opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_function_iterations(None) - self.assertEqual( - opt.max_unchanged_parameter_iterations(), (None, None)) + opt.set_function_tolerance(None) + self.assertEqual(opt.parameter_tolerance(), (None, None)) # Set without threshold - opt.set_max_unchanged_parameter_iterations(2) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(2) + n, t = opt.parameter_tolerance() self.assertEqual(n, 2) self.assertEqual(list(t), [1e-11]) # Unset and reset without threshold - opt.set_max_unchanged_parameter_iterations(None) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(None) + n, t = opt.parameter_tolerance() self.assertIsNone(n) self.assertIsNone(t) - opt.set_max_unchanged_parameter_iterations(2) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(2) + n, t = opt.parameter_tolerance() self.assertEqual(n, 2) self.assertEqual(list(t), [1e-11]) # Unset and reset with threshold - opt.set_max_unchanged_parameter_iterations(None, None) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(None, None) + n, t = opt.parameter_tolerance() self.assertIsNone(n) self.assertIsNone(t) - opt.set_max_unchanged_parameter_iterations(3, 2) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(3, 2) + n, t = opt.parameter_tolerance() self.assertEqual(n, 3) self.assertEqual(list(t), [2]) - opt.set_max_unchanged_parameter_iterations(3, [1]) - n, t = opt.max_unchanged_parameter_iterations() + opt.set_parameter_tolerance(3, [1]) + n, t = opt.parameter_tolerance() self.assertEqual(n, 3) self.assertEqual(list(t), [1]) # Bad calls - self.assertRaises( - ValueError, opt.set_max_unchanged_parameter_iterations, -1) - self.assertRaises( - ValueError, opt.set_max_unchanged_parameter_iterations, 10, -1) - self.assertRaises( - ValueError, opt.set_max_unchanged_parameter_iterations, 10, [1, 1]) + self.assertRaises(ValueError, opt.set_parameter_tolerance, -1) + self.assertRaises(ValueError, opt.set_parameter_tolerance, 10, -1) + self.assertRaises(ValueError, opt.set_parameter_tolerance, 10, [1, 1]) # Test with StreamCapture() as c: @@ -493,7 +487,7 @@ def test_stopping_function_threshold(self): m.xs = [0] * len(m.fs) opt.set_log_to_screen(True) opt.set_max_iterations(None) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) # Test getting and setting self.assertIsNone(opt.threshold()) @@ -518,7 +512,7 @@ def test_stopping_no_criterion(self): opt = pints.OptimisationController(e, [0], method=List1DOptimiser) opt.set_log_to_screen(debug) opt.set_max_iterations(None) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) self.assertRaises(ValueError, opt.run) def test_population_size_not_set(self): @@ -581,7 +575,7 @@ def test_post_run_statistics(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(50, 1e-11) + opt.set_function_tolerance(50, 1e-11) # Before run methods return None self.assertIsNone(opt.iterations()) @@ -609,7 +603,7 @@ def test_exception_on_multi_use(self): s = 0.01 opt = pints.OptimisationController(r, x, s, b, method=method) opt.set_log_to_screen(False) - opt.set_max_unchanged_function_iterations(None) + opt.set_function_tolerance(None) opt.set_max_iterations(10) opt.run() self.assertRaisesRegex( diff --git a/pints/tests/test_opt_irpropmin.py b/pints/tests/test_opt_irpropmin.py index 4a4a30893..72d1cd2d9 100755 --- a/pints/tests/test_opt_irpropmin.py +++ b/pints/tests/test_opt_irpropmin.py @@ -190,7 +190,6 @@ def test_logging(self): r, x, s = self.problem() opt = pints.OptimisationController(r, x, s, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) opt.set_max_iterations(2) with StreamCapture() as c: opt.run() @@ -207,7 +206,6 @@ def test_logging(self): r, x, s = self.problem() opt = pints.OptimisationController(r, x, s, method=method) opt.set_log_to_screen(True) - opt.set_max_unchanged_iterations(None) opt.set_max_iterations(4) opt.set_log_interval(1) opt.optimiser().set_min_step_size(0.03)