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

Adds cuckoo optimiser #319

Merged
merged 15 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
218 changes: 153 additions & 65 deletions examples/notebooks/optimiser_calibration.ipynb

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pybop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
#
# Optimiser class
#
from .optimisers._cuckoo import _CuckooSearch
from .optimisers.base_optimiser import BaseOptimiser
from .optimisers.base_pints_optimiser import BasePintsOptimiser
from .optimisers.scipy_optimisers import (
Expand All @@ -118,6 +119,7 @@
PSO,
SNES,
XNES,
CuckooSearch,
)
from .optimisers.optimisation import Optimisation

Expand Down
195 changes: 195 additions & 0 deletions pybop/optimisers/_cuckoo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import numpy as np
from pints import PopulationBasedOptimiser
from scipy.special import gamma


class _CuckooSearch(PopulationBasedOptimiser):
"""
Cuckoo Search (CS) optimization algorithm, inspired by the brood parasitism
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
of some cuckoo species. This algorithm was introduced by Yang and Deb in 2009.

The algorithm uses a population of host nests (solutions), where each cuckoo
(new solution) tries to replace a worse nest in the population. The quality
or fitness of the nests is determined by the objective function. A fraction
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
of the worst nests is abandoned at each generation, and new ones are built
randomly.

The pseudo-code for the Cuckoo Search is as follows:

1. Initialize population of n host nests
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
2. While (t < max_generations):
a. Get a cuckoo randomly by Lévy flights
b. Evaluate its quality/fitness F
c. Choose a nest among n (say, j) randomly
d. If (F > fitness of j):
i. Replace j with the new solution
e. Abandon a fraction (pa) of the worst nests and build new ones
f. Keep the best solutions/nests
g. Rank the solutions and find the current best
3. End While

This implementation also uses a decreasing step size for the Lévy flights, calculated
as sigma = sigma0 / sqrt(iterations), where sigma0 is the initial step size and
iterations is the current iteration number.

Parameters:
- pa: Probability of discovering alien eggs/solutions (abandoning rate)

References:
- X. -S. Yang and Suash Deb, "Cuckoo Search via Lévy flights,"
2009 World Congress on Nature & Biologically Inspired Computing (NaBIC),
Coimbatore, India, 2009, pp. 210-214, https://doi.org/10.1109/NABIC.2009.5393690.

- S. Walton, O. Hassan, K. Morgan, M.R. Brown,
Modified cuckoo search: A new gradient free optimisation algorithm,
Chaos, Solitons & Fractals, Volume 44, Issue 9, 2011,
Pages 710-718, ISSN 0960-0779,
https://doi.org/10.1016/j.chaos.2011.06.004.
"""

def __init__(self, x0, sigma0=0.01, boundaries=None, pa=0.25):
super().__init__(x0, sigma0, boundaries=boundaries)

# Problem dimensionality
self._dim = len(x0)

# Population size and abandon rate
self._n = self._population_size
self._pa = pa
self.step_size = self._sigma0
self.beta = 1.5

# Set states
self._running = False
self._ready_for_tell = False

# Initialise nests
if self._boundaries is not None:
self._nests = np.random.uniform(
low=self._boundaries.lower(),
high=self._boundaries.upper(),
size=(self._n, self._dim),
)
else:
self._nests = np.random.normal(self._x0, self._sigma0)

self._fitness = np.full(self._n, np.inf)

# Initialise best solutions
self._x_best = np.copy(x0)
self._f_best = np.inf

# Set iteration count
self._iterations = 1
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved

def ask(self):
"""
Returns a list of next points in the parameter-space
to evaluate from the optimiser.
"""
# Set flag to indicate that the optimiser is ready to receive replies
self._ready_for_tell = True
self._running = True

# Generate new solutions (cuckoos) by Lévy flights
self.step_size = self._sigma0 / np.sqrt(self._iterations)
step = self.levy_flight(self.beta, self._dim) * self.step_size
self.cuckoos = self._nests + step
return self.clip_nests(self.cuckoos)

def tell(self, replies):
"""
Receives a list of function values from the cost function from points
previously specified by `self.ask()`, and updates the optimiser state
accordingly.
"""
# Update iteration count
self._iterations += 1

# Compare cuckoos with current nests
for i in range(self._n):
f_new = replies[i]
if f_new < self._fitness[i]:
self._nests[i] = self.cuckoos[i]
self._fitness[i] = f_new
if f_new < self._f_best:
self._f_best = f_new
self._x_best = self.cuckoos[i]

# Abandon some worse nests
n_abandon = int(self._pa * self._n)
worst_nests = np.argsort(self._fitness)[-n_abandon:]
for idx in worst_nests:
self.abandon_nests(idx)
self._fitness[idx] = np.inf # reset fitness

def levy_flight(self, alpha, size):
"""
Generate step sizes via the Mantegna's algorithm for Levy flights
"""
from numpy import pi, power, random, sin

sigma_u = power(
(gamma(1 + alpha) * sin(pi * alpha / 2))
/ (gamma((1 + alpha) / 2) * alpha * power(2, (alpha - 1) / 2)),
1 / alpha,
)
sigma_v = 1

u = random.normal(0, sigma_u, size=size)
v = random.normal(0, sigma_v, size=size)
step = u / power(abs(v), 1 / alpha)

return step

def abandon_nests(self, idx):
"""
Set the boundaries for the parameter space.
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
"""
if self._boundaries is not None:
self._nests[idx] = np.random.uniform(
low=self._boundaries.lower(),
high=self._boundaries.upper(),
)
else:
self._nests[idx] = np.random.normal(self._x0, self._sigma0)

Check warning on line 155 in pybop/optimisers/_cuckoo.py

View check run for this annotation

Codecov / codecov/patch

pybop/optimisers/_cuckoo.py#L155

Added line #L155 was not covered by tests

def clip_nests(self, x):
"""
Clip the input array to the boundaries.
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
"""
if self._boundaries is not None:
x = np.clip(x, self._boundaries.lower(), self._boundaries.upper())
return x

def _suggested_population_size(self):
"""
Inherited from Pints:PopulationBasedOptimiser.
Returns a suggested population size, based on the
dimension of the parameter space.
"""
return 4 + int(3 * np.log(self._n_parameters))

def running(self):
"""
Returns ``True`` if the optimisation is in progress.
"""
return self._running

Check warning on line 177 in pybop/optimisers/_cuckoo.py

View check run for this annotation

Codecov / codecov/patch

pybop/optimisers/_cuckoo.py#L177

Added line #L177 was not covered by tests

def x_best(self):
"""
Returns the best parameter values found so far.
"""
return self._x_best

def f_best(self):
"""
Returns the best score found so far.
"""
return self._f_best

def name(self):
"""
Returns the name of the optimiser.
"""
return "Cuckoo Search"
18 changes: 9 additions & 9 deletions pybop/optimisers/base_pints_optimiser.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,16 @@ def _sanitise_inputs(self):
):
print(f"NOTE: Boundaries ignored by {self.pints_optimiser}")
self.bounds = None
elif issubclass(self.pints_optimiser, PintsPSO):
if not all(
np.isfinite(value)
for sublist in self.bounds.values()
for value in sublist
):
raise ValueError(
"Either all bounds or no bounds must be set for Pints PSO."
)
else:
if issubclass(self.pints_optimiser, PintsPSO):
if not all(
np.isfinite(value)
for sublist in self.bounds.values()
for value in sublist
):
raise ValueError(
f"Either all bounds or no bounds must be set for {self.pints_optimiser.__name__}."
)
self._boundaries = PintsRectangularBoundaries(
self.bounds["lower"], self.bounds["upper"]
)
Expand Down
30 changes: 29 additions & 1 deletion pybop/optimisers/pints_optimisers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pints import IRPropMin as PintsIRPropMin
from pints import NelderMead as PintsNelderMead

from pybop import BasePintsOptimiser
from pybop import BasePintsOptimiser, _CuckooSearch


class GradientDescent(BasePintsOptimiser):
Expand Down Expand Up @@ -240,3 +240,31 @@ def __init__(self, cost, **optimiser_kwargs):
+ "Please choose another optimiser."
)
super().__init__(cost, PintsCMAES, **optimiser_kwargs)


class CuckooSearch(BasePintsOptimiser):
"""
Adapter for the Cuckoo Search optimiser in PyBOP.

Cuckoo Search is a population-based optimization algorithm inspired by the brood parasitism of some cuckoo species.
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
It is designed to be simple, efficient, and robust, and is suitable for global optimization problems.
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved

Parameters
----------
**optimiser_kwargs : optional
Valid PyBOP option keys and their values, for example:
x0 : array_like
Initial
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
sigma0 : float
Initial step size.
bounds : dict
A dictionary with 'lower' and 'upper' keys containing arrays for lower and
upper bounds on the parameters.

See Also
--------
pybop.CuckooSearch : PyBOP implementation of Cuckoo Search algorithm.
"""

def __init__(self, cost, **optimiser_kwargs):
super().__init__(cost, _CuckooSearch, **optimiser_kwargs)
1 change: 1 addition & 0 deletions tests/integration/test_spm_parameterisations.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def spm_costs(self, model, parameters, cost_class, init_soc):
pybop.SciPyDifferentialEvolution,
pybop.Adam,
pybop.CMAES,
pybop.CuckooSearch,
pybop.IRPropMin,
pybop.NelderMead,
pybop.SNES,
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_optimisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def two_param_cost(self, model, two_parameters, dataset):
(pybop.GradientDescent, "Gradient descent"),
(pybop.Adam, "Adam"),
(pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"),
(pybop.CuckooSearch, "Cuckoo Search"),
(pybop.SNES, "Seperable Natural Evolution Strategy (SNES)"),
(pybop.XNES, "Exponential Natural Evolution Strategy (xNES)"),
(pybop.PSO, "Particle Swarm Optimisation (PSO)"),
Expand Down
Loading