import warnings
import numpy as np
from pybop import BaseCost, BaseLikelihood, DesignCost, Parameter, Parameters
[docs]
class BaseOptimiser:
"""
A base class for defining optimisation methods.
This class serves as a base class for creating optimisers. It provides a basic structure for
an optimisation algorithm, including the initial setup and a method stub for performing the
optimisation process. Child classes should override _set_up_optimiser and the _run method with
a specific algorithm.
Parameters
----------
cost : pybop.BaseCost or pints.ErrorMeasure
An objective function to be optimised, which can be either a pybop.Cost or PINTS error measure
**optimiser_kwargs : optional
Valid option keys and their values.
Attributes
----------
x0 : numpy.ndarray
Initial parameter values for the optimisation.
bounds : dict
Dictionary containing the parameter bounds with keys 'lower' and 'upper'.
sigma0 : float or sequence
Initial step size or standard deviation around ``x0``. Either a scalar value (one
standard deviation for all coordinates) or an array with one entry per dimension.
Not all methods will use this information.
verbose : bool, optional
If True, the optimisation progress is printed (default: False).
minimising : bool, optional
If True, the target is to minimise the cost, else target is to maximise by minimising
the negative cost (default: True).
physical_viability : bool, optional
If True, the feasibility of the optimised parameters is checked (default: True).
allow_infeasible_solutions : bool, optional
If True, infeasible parameter values will be allowed in the optimisation (default: True).
log : dict
A log of the parameter values tried during the optimisation and associated costs.
"""
def __init__(
self,
cost,
**optimiser_kwargs,
):
# First set attributes to default values
[docs]
self.parameters = Parameters()
[docs]
self.log = dict(x=[], x_best=[], cost=[])
[docs]
self.physical_viability = False
[docs]
self.allow_infeasible_solutions = False
[docs]
self.default_max_iterations = 1000
if isinstance(cost, BaseCost):
self.cost = cost
self.parameters.join(cost.parameters)
self.set_allow_infeasible_solutions()
if isinstance(cost, (BaseLikelihood, DesignCost)):
self.minimising = False
else:
try:
self.x0 = optimiser_kwargs.get("x0", [])
cost_test = cost(self.x0)
warnings.warn(
"The cost is not an instance of pybop.BaseCost, but let's continue "
+ "assuming that it is a callable function to be minimised.",
UserWarning,
)
self.cost = cost
for i, value in enumerate(self.x0):
self.parameters.add(
Parameter(name=f"Parameter {i}", initial_value=value)
)
self.minimising = True
except Exception:
raise Exception("The cost is not a recognised cost object or function.")
if not np.isscalar(cost_test) or not np.isreal(cost_test):
raise TypeError(
f"Cost returned {type(cost_test)}, not a scalar numeric value."
)
if len(self.parameters) == 0:
raise ValueError("There are no parameters to optimise.")
[docs]
self.unset_options = optimiser_kwargs
self.set_base_options()
self._set_up_optimiser()
# Throw an warning if any options remain
if self.unset_options:
warnings.warn(
f"Unrecognised keyword arguments: {self.unset_options} will not be used.",
UserWarning,
stacklevel=2,
)
[docs]
def set_base_options(self):
"""
Update the base optimiser options and remove them from the options dictionary.
"""
# Set initial values, if x0 is None, initial values are unmodified.
self.parameters.update(initial_values=self.unset_options.pop("x0", None))
self.x0 = self.parameters.initial_value()
# Set default bounds (for all or no parameters)
self.bounds = self.unset_options.pop("bounds", self.parameters.get_bounds())
# Set default initial standard deviation (for all or no parameters)
self.sigma0 = self.unset_options.pop(
"sigma0", self.parameters.get_sigma0() or self.sigma0
)
# Set other options
self.verbose = self.unset_options.pop("verbose", self.verbose)
self.minimising = self.unset_options.pop("minimising", self.minimising)
if "allow_infeasible_solutions" in self.unset_options.keys():
self.set_allow_infeasible_solutions(
self.unset_options.pop("allow_infeasible_solutions")
)
[docs]
def _set_up_optimiser(self):
"""
Parse optimiser options and prepare the optimiser.
This method should be implemented by child classes.
Raises
------
NotImplementedError
If the method has not been implemented by the subclass.
"""
raise NotImplementedError
[docs]
def run(self):
"""
Run the optimisation and return the optimised parameters and final cost.
Returns
-------
x : numpy.ndarray
The best parameter set found by the optimisation.
final_cost : float
The final cost associated with the best parameters.
"""
self.result = self._run()
# Store the optimised parameters
x = self.result.x
self.parameters.update(values=x)
# Check if parameters are viable
if self.physical_viability:
self.check_optimal_parameters(x)
return x, self.result.final_cost
[docs]
def _run(self):
"""
Contains the logic for the optimisation algorithm.
This method should be implemented by child classes to perform the actual optimisation.
Raises
------
NotImplementedError
If the method has not been implemented by the subclass.
"""
raise NotImplementedError
[docs]
def store_optimised_parameters(self, x):
"""
Update the problem parameters with optimised values.
The optimised parameter values are stored within the associated PyBOP parameter class.
Parameters
----------
x : array-like
Optimised parameter values.
"""
for i, param in enumerate(self.cost.parameters):
param.update(value=x[i])
[docs]
def check_optimal_parameters(self, x):
"""
Check if the optimised parameters are physically viable.
Parameters
----------
x : array-like
Optimised parameter values.
"""
if self.cost.problem._model.check_params(
inputs=x, allow_infeasible_solutions=False
):
return
else:
warnings.warn(
"Optimised parameters are not physically viable! \nConsider retrying the optimisation"
+ " with a non-gradient-based optimiser and the option allow_infeasible_solutions=False",
UserWarning,
stacklevel=2,
)
[docs]
def name(self):
"""
Returns the name of the optimiser, to be overwritten by child classes.
Returns
-------
str
The name of the optimiser, which is "Optimisation" for this base class.
"""
return "Optimisation"
[docs]
def set_allow_infeasible_solutions(self, allow=True):
"""
Set whether to allow infeasible solutions or not.
Parameters
----------
iterations : bool, optional
Whether to allow infeasible solutions.
"""
# Set whether to allow infeasible locations
self.physical_viability = allow
self.allow_infeasible_solutions = allow
if hasattr(self.cost, "problem") and hasattr(self.cost.problem, "_model"):
self.cost.problem._model.allow_infeasible_solutions = (
self.allow_infeasible_solutions
)
else:
# Turn off this feature as there is no model
self.physical_viability = False
self.allow_infeasible_solutions = False
[docs]
class Result:
"""
Stores the result of the optimisation.
Attributes
----------
x : ndarray
The solution of the optimisation.
final_cost : float
The cost associated with the solution x.
nit : int
Number of iterations performed by the optimiser.
scipy_result : scipy.optimize.OptimizeResult, optional
The result obtained from a SciPy optimiser.
"""
def __init__(
self,
x: np.ndarray = None,
final_cost: float = None,
n_iterations: int = None,
scipy_result=None,
):
[docs]
self.final_cost = final_cost
[docs]
self.n_iterations = n_iterations
[docs]
self.scipy_result = scipy_result