Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allowing xtol and ftol stopping criteria in OptimisationController #1508

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"unmoved/unchanged" is a bit vague and not immediately obvious to me in terms of whether it refers to xtol or ftol? unmoved parameters, unchanged objective?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! But can't think of anything better that doesn't involve deprecating set_max_unchanged_iterations which is probably one of the most used methods of the controller class

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmmm, given all the different names that exist already, inventing another set that are pints specific doesn't seem great anyway - and even more so if they are vague!

- [#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
Expand Down
256 changes: 185 additions & 71 deletions pints/_optimisers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand All @@ -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.
"""
Expand Down Expand Up @@ -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)``.
Expand All @@ -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)``.
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand Down