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

Extended IRPropMin with boundary support and methods to set min and max step size #1465

Merged
merged 6 commits into from
Aug 29, 2022
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file.
- [#1466](https://github.com/pints-team/pints/pull/1466) Added a `TransformedRectangularBoundaries` class that preserves the `RectangularBoundaries` methods after transformation.
- [#1462](https://github.com/pints-team/pints/pull/1461) The `OptimisationController` now has a stopping criterion `max_evaluations`.
- [#1460](https://github.com/pints-team/pints/pull/1460) Added the `Adam` local optimiser.
- [#1459](https://github.com/pints-team/pints/pull/1459) Added the `iRprop-` local optimiser.
- [#1459](https://github.com/pints-team/pints/pull/1459) [#1465](https://github.com/pints-team/pints/pull/1465) Added the `iRprop-` local optimiser.
- [#1456](https://github.com/pints-team/pints/pull/1456) Added an optional `translation` to `ScalingTransform` and added a `UnitCubeTransformation` class.
- [#1432](https://github.com/pints-team/pints/pull/1432) Added 2 new stochastic models: production and degradation model, Schlogl's system of chemical reactions. Moved the stochastic logistic model into `pints.stochastic` to take advantage of the `MarkovJumpModel`.
- [#1420](https://github.com/pints-team/pints/pull/1420) The `Optimiser` class now distinguishes between a best-visited point (`x_best`, with score `f_best`) and a best-guessed point (`x_guessed`, with approximate score `f_guessed`). For most optimisers, the two values are equivalent. The `OptimisationController` still tracks `x_best` and `f_best` by default, but this can be modified using the methods `set_f_guessed_tracking` and `f_guessed_tracking`.
Expand Down
224 changes: 201 additions & 23 deletions examples/optimisation/irpropmin.ipynb

Large diffs are not rendered by default.

150 changes: 131 additions & 19 deletions pints/_optimisers/_irpropmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class IRPropMin(pints.Optimiser):
"""
iRprop- algorithm, as described in Figure 3 of [1]_.

The name "iRprop-" was introduced by [1]_, and is a variation on the
"Resilient backpropagation (Rprop)" optimiser introduced in [2]_.

This is a local optimiser that requires gradient information, although it
uses only the direction (sign) of the gradient in each dimension and
ignores the magnitude. Instead, it maintains a separate step size for each
Expand All @@ -25,27 +28,43 @@ class IRPropMin(pints.Optimiser):
objective function (so both are scalars)::

if df_j[i] * df_j[i - 1] > 0:
step_size_j[i] = 1.2 * step_size_j[i-1]
step_size_j[i] = 1.2 * step_size_j[i - 1]
elif df_j[i] * df_j[i - 1] < 0:
step_size_j[i] = 0.5 * step_size_j[i-1]
df_j[i - 1] = 0
step_size_j[i] = 0.5 * step_size_j[i - 1]
df_j[i] = 0
step_size_j[i] = min(max(step_size_j[i], min_step_size), max_step_size)
p_j[i] = p_j[i] - sign(df_j[i]) * step_size_j[i]

The line ``df_j[i - 1] = 0`` has two effects:
The line ``df_j[i] = 0`` has two effects:

1. It sets the update at this iteration to zero (using
``sign(df_j[i]) * step_size_j[i] = 0 * step_size_j[i]``).
2. It ensures that the next iteration is performed (since
``df_j[i + 1] * df_j[i] = 0`` so neither if statement holds).
``df_j[i] * df_j[i - 1] == 0`` so neither if-statement holds).

In this implementation, the ``step_size`` is initialised as ``sigma_0``,
the increase (0.5) & decrease factors (1.2) are fixed, and a minimum step
size of ``1e-3 * min(sigma0)`` is enforced.
In this implementation, the initial ``step_size`` is set to ``sigma0``, the
default minimum step size is set as ``1e-3 * min(sigma0)``, and no default
maximum step size is set. Minimum and maximum step sizes can be changed
with :meth:`set_min_step_size` and :meth:`set_max_step_size` or through the
hyper-parameter interface.

This is an unbounded method: Any ``boundaries`` will be ignored.
If boundaries are provided, an extra step is added at the end of the
algorithm that reduces the step size where boundary constraints are not
met. For :class:`RectangularBoundaries` this works on a per-dimension
basis::

The name "iRprop-" was introduced by [1]_, and is a variation on the
"Resilient backpropagation (Rprop)" optimiser introduced in [2]_.
while p_j[i] < lower or p_j[i] >= upper:
step_size_j[i] *= 0.5
p_j[i] = p_j[i] - sign(df_j[i]) * step_size_j[i]

For general boundaries a more complex heuristic is used: First, the step
size in all dimensions is reduced until the boundary constraints are met.
Next, this reduction is undone for each dimension in turn: if the
constraint is still met without the reduction the step size in this
dimension is left unchanged.

The numbers 0.5 and 1.2 shown in the (main and boundary) pseudo-code are
technically hyper-parameters, but are fixed in this implementation.

References
----------
Expand All @@ -71,6 +90,7 @@ def __init__(self, x0, sigma0=0.1, boundaries=None):

# Minimum and maximum step sizes
self._step_min = 1e-3 * np.min(self._sigma0)
self._step_max = None

# Current point, score, and gradient
self._current = self._x0
Expand All @@ -89,12 +109,30 @@ def __init__(self, x0, sigma0=0.1, boundaries=None):
# Current step sizes
self._step_size = np.array(self._sigma0)

# Rectangular boundaries
self._lower = self._upper = None
if isinstance(self._boundaries, pints.RectangularBoundaries):
self._lower = self._boundaries.lower()
self._upper = self._boundaries.upper()
MichaelClerx marked this conversation as resolved.
Show resolved Hide resolved

# Reduced step sizes due to boundary violations
self._breaches = []

def ask(self):
""" See :meth:`Optimiser.ask()`. """

# Running, and ready for tell now
# First call
if not self._running:
if (self._step_min is not None and self._step_max is not None
and self._step_min >= self._step_max):
raise Exception(
'Max step size must be larger than min step size (current'
' settings: min_step_size = ' + str(self._step_min) + ', '
' max_step_size = ' + str(self._step_max) + ').')
self._running = True

# Ready for tell now
self._ready_for_tell = True
self._running = True

# Return proposed points (just the one)
return [self._proposed]
Expand All @@ -111,11 +149,21 @@ def _log_init(self, logger):
""" See :meth:`Loggable._log_init()`. """
logger.add_float('Min. step')
logger.add_float('Max. step')
logger.add_string('Bound corr.', 11)

def _log_write(self, logger):
""" See :meth:`Loggable._log_write()`. """
logger.log(np.min(self._step_size))
logger.log(np.max(self._step_size))
logger.log(','.join([str(x) for x in self._breaches]))

def max_step_size(self):
""" Returns the maximum step size (or ``None`` if not set). """
return self._step_max

def min_step_size(self):
""" Returns the minimum step size (or ``None`` if not set). """
return self._step_min

def name(self):
""" See :meth:`Optimiser.name()`. """
Expand All @@ -127,12 +175,35 @@ def needs_sensitivities(self):

def n_hyper_parameters(self):
""" See :meth:`pints.TunableMethod.n_hyper_parameters()`. """
return 0
return 2

def running(self):
""" See :meth:`Optimiser.running()`. """
return self._running

def set_hyper_parameters(self, x):
"""
See :meth:`pints.TunableMethod.set_hyper_parameters()`.

The hyper-parameter vector is ``[min_step_size, max_step_size]``.
"""
self.set_min_step_size(x[0])
self.set_max_step_size(x[1])

def set_max_step_size(self, step_size):
"""
Sets the maximum step size (use ``None`` to let step sizes grow
indefinitely).
"""
self._step_max = None if step_size is None else float(step_size)
MichaelClerx marked this conversation as resolved.
Show resolved Hide resolved

def set_min_step_size(self, step_size):
"""
Sets the minimum step size (use ``None`` to let step sizes shrink
indefinitely).
"""
self._step_min = None if step_size is None else float(step_size)
MichaelClerx marked this conversation as resolved.
Show resolved Hide resolved

def tell(self, reply):
""" See :meth:`Optimiser.tell()`. """

Expand All @@ -153,17 +224,15 @@ def tell(self, reply):
# Get product of new and previous gradient
dprod = dfx * self._current_df

# Note: Could implement boundaries here by setting all dprod to < 0 if
# the point is out of bounds?

# Adapt step sizes
self._step_size[dprod > 0] *= self._eta_max
self._step_size[dprod < 0] *= self._eta_min

# Bound step sizes
if self._step_min is not None:
self._step_size = np.maximum(self._step_size, self._step_min)
# Note: Could implement step_max here if desired
if self._step_max is not None:
self._step_size = np.minimum(self._step_size, self._step_max)

# Remove "weight backtracking"
# This step ensures that, for each i where dprod < 0:
Expand All @@ -178,7 +247,50 @@ def tell(self, reply):
self._current_df = dfx

# Take step in direction indicated by current gradient
self._proposed = self._current - self._step_size * np.sign(dfx)
p = self._current - self._step_size * np.sign(dfx)

# Allow boundaries to reduce step size
if self._lower is not None:
MichaelClerx marked this conversation as resolved.
Show resolved Hide resolved
# Rectangular boundaries: reduce individual step sizes until OK
out = np.logical_or(p < self._lower, p >= self._upper)
DavAug marked this conversation as resolved.
Show resolved Hide resolved
self._breaches = np.flatnonzero(out)
sign = np.sign(dfx)
while np.any(out):
self._step_size[out] *= self._eta_min
p = self._current - self._step_size * sign
out = np.logical_or(p < self._lower, p >= self._upper)

elif self._boundaries is not None and not self._boundaries.check(p):
# General boundaries: reduce all step sizes until OK
s = np.copy(self._step_size)
sign = np.sign(dfx)
while not self._boundaries.check(p):
s *= self._eta_min
p = self._current - s * sign

# Attempt restoring one-by-one
self._breaches = []
for i, s_big in enumerate(self._step_size):
small = s[i]
s[i] = s_big
if not self._boundaries.check(self._current - s * sign):
s[i] = small
self._breaches.append(i)
self._step_size = s
p = self._current - s * sign

# An alternative method would be to reduce each dimension's step
# size one at a time, and check if that restores the boundaries.
# However, if that doesn't work we then need to test all tuples of
# dimenions, then triples, etc. The method above looks clumsy, but
# avoids these combinatorics.

elif self._breaches:
# No boundary breaces: empty list (if needed)
self._breaches = []

# Store proposed as read-only, so that it can be passed to user
self._proposed = p
self._proposed.setflags(write=False)

# Update x_best and f_best
Expand Down
Loading