From 8aef4f989f4c6a43ad517d0cf0a56f470e8a7fdd Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 23 Mar 2024 00:11:32 +0000 Subject: [PATCH 01/85] Enable passing of kwargs to SciPy --- CHANGELOG.md | 1 + pybop/_optimisation.py | 84 ++++++++++------- pybop/optimisers/base_optimiser.py | 10 +- pybop/optimisers/scipy_optimisers.py | 135 +++++++++++++++++---------- tests/unit/test_optimisation.py | 79 ++++++++++++---- 5 files changed, 203 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc36be14..f4ed41ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ## Bug Fixes +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Allows passing of keyword arguments to SciPy optimisers and fixes setting of max_iterations. - [#233](https://github.com/pybop-team/PyBOP/pull/233) - Enforces model rebuild on initialisation of a Problem to allow a change of experiment, fixes if statement triggering current function update, updates `predictions` to `simulation` to keep distinction between `predict` and `simulate` and adds `test_changes`. - [#123](https://github.com/pybop-team/PyBOP/issues/123) - Reinstates check for availability of parameter sets via PyBaMM upon retrieval by `pybop.ParameterSet.pybamm()`. - [#196](https://github.com/pybop-team/PyBOP/issues/196) - Fixes failing observer cost tests. diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 134ce0b12..94e49b42a 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -48,6 +48,7 @@ def __init__( verbose=False, physical_viability=True, allow_infeasible_solutions=True, + **optimiser_kwargs, ): self.cost = cost self.x0 = x0 or cost.x0 @@ -95,54 +96,62 @@ def __init__( if issubclass( self.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution) ): - self.optimiser = self.optimiser(bounds=self.bounds) + if "maxiter" in optimiser_kwargs.keys(): + self.set_max_iterations(optimiser_kwargs["maxiter"]) + else: + self.set_max_iterations() + optimiser_kwargs["maxiter"] = self._max_iterations + + self.optimiser = self.optimiser(bounds=self.bounds, **optimiser_kwargs) else: raise ValueError("Unknown optimiser type") if self.pints: - self.optimiser = self.optimiser(self.x0, self.sigma0, self.bounds) + self.optimiser = self.optimiser( + self.x0, self.sigma0, self.bounds, **optimiser_kwargs + ) - # Check if sensitivities are required - self._needs_sensitivities = self.optimiser.needs_sensitivities() + # Check if sensitivities are required + self._needs_sensitivities = self.optimiser.needs_sensitivities() - # Track optimiser's f_best or f_guessed - self._use_f_guessed = None - self.set_f_guessed_tracking() + # Track optimiser's f_best or f_guessed + self._use_f_guessed = None + self.set_f_guessed_tracking() - # Parallelisation - self._parallel = False - self._n_workers = 1 - self.set_parallel() + # Parallelisation + self._parallel = False + self._n_workers = 1 + self.set_parallel() - # User callback - self._callback = None + # User callback + self._callback = None - # Define stopping criteria - # Maximum iterations - self._max_iterations = None - self.set_max_iterations() + # Define stopping criteria + # Maximum iterations + self._max_iterations = None + self.set_max_iterations() - # Minimum iterations - self._min_iterations = None - self.set_min_iterations() + # Minimum iterations + self._min_iterations = None + self.set_min_iterations() - # Maximum unchanged iterations - self._unchanged_threshold = 1 # smallest significant f change - self._unchanged_max_iterations = None - self.set_max_unchanged_iterations() + # Maximum unchanged iterations + self._unchanged_threshold = 1 # smallest significant f change + self._unchanged_max_iterations = None + self.set_max_unchanged_iterations() - # Maximum evaluations - self._max_evaluations = None + # Maximum evaluations + self._max_evaluations = None - # Threshold value - self._threshold = None + # Threshold value + self._threshold = None - # Post-run statistics - self._evaluations = None - self._iterations = None + # Post-run statistics + self._evaluations = None + self._iterations = None - def run(self): + def run(self, **optimiser_kwargs): """ Run the optimization and return the optimized parameters and final cost. @@ -157,7 +166,7 @@ def run(self): if self.pints: x, final_cost = self._run_pints() elif not self.pints: - x, final_cost = self._run_pybop() + x, final_cost = self._run_pybop(**optimiser_kwargs) # Store the optimised parameters if self.cost.problem is not None: @@ -169,7 +178,7 @@ def run(self): return x, final_cost - def _run_pybop(self): + def _run_pybop(self, **optimiser_kwargs): """ Internal method to run the optimization using a PyBOP optimiser. @@ -180,10 +189,15 @@ def _run_pybop(self): final_cost : float The final cost associated with the best parameters. """ + if "maxiter" in optimiser_kwargs.keys(): + self.set_max_iterations(optimiser_kwargs["maxiter"]) + else: + optimiser_kwargs["maxiter"] = self._max_iterations + result = self.optimiser.optimise( cost_function=self.cost, x0=self.x0, - maxiter=self._max_iterations, + **optimiser_kwargs, ) self.log = self.optimiser.log self._iterations = result.nit diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index d89c3df04..93c1b3f10 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -18,9 +18,10 @@ def __init__(self, bounds=None): Bounds on the parameters. Default is None. """ self.bounds = bounds + self._max_iterations = None pass - def optimise(self, cost_function, x0=None, maxiter=None): + def optimise(self, cost_function, x0=None, **optimiser_kwargs): """ Initiates the optimisation process. @@ -32,8 +33,6 @@ def optimise(self, cost_function, x0=None, maxiter=None): The cost function to be minimised by the optimiser. x0 : ndarray, optional Initial guess for the parameters. Default is None. - maxiter : int, optional - Maximum number of iterations to perform. Default is None. Returns ------- @@ -41,14 +40,13 @@ def optimise(self, cost_function, x0=None, maxiter=None): """ self.cost_function = cost_function self.x0 = x0 - self._max_iterations = maxiter # Run optimisation - result = self._runoptimise(self.cost_function, x0=self.x0) + result = self._runoptimise(self.cost_function, x0=self.x0, **optimiser_kwargs) return result - def _runoptimise(self, cost_function, x0=None): + def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): """ Contains the logic for the optimisation algorithm. diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 185a09abc..5f8abd5f3 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -3,36 +3,54 @@ from .base_optimiser import BaseOptimiser +DEFAULT_SCIPY_MINIMIZE_OPTIONS = dict( + bounds=None, + method="Nelder-Mead", + tol=1e-5, + options=dict(), +) +DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS = dict( + bounds=None, + strategy="best1bin", + popsize=15, + maxiter=1000, + tol=1e-5, + options=dict(), +) + class SciPyMinimize(BaseOptimiser): """ Adapts SciPy's minimize function for use as an optimization strategy. - This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. + This class provides an interface to various scalar minimization algorithms implemented in SciPy, + allowing fine-tuning of the optimization process through method selection and option configuration. Parameters ---------- - method : str, optional - The type of solver to use. If not specified, defaults to 'Nelder-Mead'. - Options: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', 'trust-exact', 'trust-krylov'. bounds : sequence or ``Bounds``, optional Bounds for variables as supported by the selected method. maxiter : int, optional Maximum number of iterations to perform. + **optimiser_kwargs : optional + Valid SciPy Minimize option keys and their values. For example, to specify the solver + use: `method='Nelder-Mead'`. Other options are: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', + 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', + 'trust-exact', 'trust-krylov'. """ - def __init__(self, method=None, bounds=None, maxiter=None, tol=1e-5): - super().__init__() - self.method = method - self.bounds = bounds - self.tol = tol - self.options = {} - self._max_iterations = maxiter + def __init__(self, bounds=None, **optimiser_kwargs): + super().__init__(bounds) + self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS + for key, value in optimiser_kwargs.items(): + self.options[key] = value - if self.method is None: - self.method = "Nelder-Mead" + # Overwrite the value of maxiter in the options dictionary + if "maxiter" in optimiser_kwargs.keys(): + self.options["options"]["maxiter"] = self.options["maxiter"] + del self.options["maxiter"] - def _runoptimise(self, cost_function, x0): + def _runoptimise(self, cost_function, x0, **optimiser_kwargs): """ Executes the optimization process using SciPy's minimize function. @@ -46,11 +64,21 @@ def _runoptimise(self, cost_function, x0): Returns ------- tuple - A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. + A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` + at the optimum. """ + for key, value in optimiser_kwargs.items(): + if key == "bounds": + self.bounds = value + else: + self.options[key] = value + + # Overwrite the value of maxiter in the options dictionary + if "maxiter" in optimiser_kwargs.keys(): + self.options["options"]["maxiter"] = self.options["maxiter"] + del self.options["maxiter"] self.log = [[x0]] - self.options = {"maxiter": self._max_iterations} # Add callback storing history of parameter values def callback(x): @@ -70,19 +98,21 @@ def cost_wrapper(x): return cost # Reformat bounds - if self.bounds is not None: + if isinstance(self.bounds, dict): bounds = ( (lower, upper) for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) ) + else: + bounds = self.bounds result = minimize( cost_wrapper, x0, - method=self.method, + method=self.options["method"], bounds=bounds, - tol=self.tol, - options=self.options, + tol=self.options["tol"], + options=self.options["options"], callback=callback, ) @@ -115,7 +145,8 @@ class SciPyDifferentialEvolution(BaseOptimiser): """ Adapts SciPy's differential_evolution function for global optimization. - This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. + This class provides a global optimization strategy based on differential evolution, useful for + problems involving continuous parameters and potentially multiple local minima. Parameters ---------- @@ -129,28 +160,13 @@ class SciPyDifferentialEvolution(BaseOptimiser): The number of individuals in the population. Defaults to 15. """ - def __init__( - self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15, tol=1e-5 - ): - super().__init__() - self.tol = tol - self.strategy = strategy - self._max_iterations = maxiter - self._population_size = popsize - - if bounds is None: - raise ValueError("Bounds must be specified for differential_evolution.") - elif not all( - np.isfinite(value) for sublist in bounds.values() for value in sublist - ): - raise ValueError("Bounds must be specified for differential_evolution.") - elif isinstance(bounds, dict): - bounds = [ - (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) - ] - self.bounds = bounds + def __init__(self, bounds, **optimiser_kwargs): + super().__init__(bounds) + self.options = DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS + for key, value in optimiser_kwargs.items(): + self.options[key] = value - def _runoptimise(self, cost_function, x0=None): + def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): """ Executes the optimization process using SciPy's differential_evolution function. @@ -164,8 +180,14 @@ def _runoptimise(self, cost_function, x0=None): Returns ------- tuple - A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. + A tuple (x, final_cost) containing the optimized parameters and the value of + ``cost_function`` at the optimum. """ + for key, value in optimiser_kwargs.items(): + if key == "bounds": + self.bounds = value + else: + self.options[key] = value self.log = [] @@ -178,13 +200,28 @@ def _runoptimise(self, cost_function, x0=None): def callback(x, convergence): self.log.append([x]) + # Reformat bounds + if self.bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") + elif not all( + np.isfinite(value) for sublist in self.bounds.values() for value in sublist + ): + raise ValueError("Bounds must be specified for differential_evolution.") + elif isinstance(self.bounds, dict): + bounds = [ + (lower, upper) + for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) + ] + else: + bounds = self.bounds + result = differential_evolution( cost_function, - self.bounds, - strategy=self.strategy, - maxiter=self._max_iterations, - popsize=self._population_size, - tol=self.tol, + bounds, + strategy=self.options["strategy"], + maxiter=self.options["maxiter"], + popsize=self.options["popsize"], + tol=self.options["tol"], callback=callback, ) @@ -201,7 +238,7 @@ def set_population_size(self, population_size=None): population_size = int(population_size) if population_size < 1: raise ValueError("Population size must be at least 1.") - self._population_size = population_size + self.options["popsize"] = population_size def needs_sensitivities(self): """ diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 4a9ada4ca..283f2af42 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -59,29 +59,76 @@ def cost(self, problem): ) @pytest.mark.unit def test_optimiser_classes(self, cost, optimiser_class, expected_name): - opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - assert opt.optimiser is not None - assert opt.optimiser.name() == expected_name + assert optim.optimiser is not None + assert optim.optimiser.name() == expected_name - # Test without bounds + # Test construction without bounds + bounds = cost.bounds cost.bounds = None - if optimiser_class in [pybop.SciPyDifferentialEvolution]: - with pytest.raises(ValueError): - pybop.Optimisation(cost=cost, optimiser=optimiser_class) + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + + if optimiser_class in [pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution]: + assert optim.optimiser.bounds is None else: - opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + assert optim.optimiser.boundaries is None + + # Reset + cost.bounds = bounds + + @pytest.mark.parametrize( + "optimiser_class", + [pybop.SciPyDifferentialEvolution], + ) + @pytest.mark.unit + def test_missing_boundaries(self, cost, optimiser_class): + bounds = cost.bounds + cost.bounds = None + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + with pytest.raises( + ValueError, match="Bounds must be specified for differential_evolution." + ): + optim.run() + + # Reset + cost.bounds = bounds + optim.run(bounds=bounds) - if optimiser_class in [pybop.SciPyMinimize]: - assert opt.optimiser.bounds is None - else: - assert opt.optimiser.boundaries is None + @pytest.mark.parametrize( + "optimiser_class", + [pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], + ) + @pytest.mark.unit + def test_scipy_kwargs(self, cost, optimiser_class): + optim = pybop.Optimisation( + cost=cost, optimiser=optimiser_class, maxiter=1, tol=1e-3 + ) + + # Check and update tol + assert optim.optimiser.options["tol"] == 1e-3 + optim.run(tol=1e-2) + assert optim.optimiser.options["tol"] == 1e-2 + + # Check and update maximum iterations + assert optim._max_iterations == 1 + assert optim._iterations == 1 + optim.run(maxiter=10) + assert optim._max_iterations == 10 + assert optim._iterations <= 10 + + if optimiser_class in [pybop.SciPyDifferentialEvolution]: + # Check and update population size + optim.optimiser.set_population_size(10) + assert optim.optimiser.options["popsize"] == 10 + optim.run(popsize=5) + assert optim.optimiser.options["popsize"] == 5 @pytest.mark.unit def test_default_optimiser(self, cost): - opt = pybop.Optimisation(cost=cost) + optim = pybop.Optimisation(cost=cost) assert ( - opt.optimiser.name() + optim.optimiser.name() == "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)" ) @@ -97,9 +144,9 @@ class RandomClass: def test_prior_sampling(self, cost): # Tests prior sampling for i in range(50): - opt = pybop.Optimisation(cost=cost, optimiser=pybop.CMAES) + optim = pybop.Optimisation(cost=cost, optimiser=pybop.CMAES) - assert opt.x0 <= 0.62 and opt.x0 >= 0.58 + assert optim.x0 <= 0.62 and optim.x0 >= 0.58 @pytest.mark.unit def test_halting(self, cost): From 5e1843dbb16286ea2b42f9f57e515e25aae11e25 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 23 Mar 2024 18:05:44 +0000 Subject: [PATCH 02/85] Update checks on bounds --- pybop/optimisers/scipy_optimisers.py | 14 ++++++++------ tests/unit/test_optimisation.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 5f8abd5f3..4ce22a06c 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -28,7 +28,7 @@ class SciPyMinimize(BaseOptimiser): Parameters ---------- - bounds : sequence or ``Bounds``, optional + bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. maxiter : int, optional Maximum number of iterations to perform. @@ -150,7 +150,7 @@ class SciPyDifferentialEvolution(BaseOptimiser): Parameters ---------- - bounds : sequence or ``Bounds`` + bounds : dict, sequence or scipy.optimize.Bounds Bounds for variables. Must be provided as it is essential for differential evolution. strategy : str, optional The differential evolution strategy to use. Defaults to 'best1bin'. @@ -203,16 +203,18 @@ def callback(x, convergence): # Reformat bounds if self.bounds is None: raise ValueError("Bounds must be specified for differential_evolution.") - elif not all( - np.isfinite(value) for sublist in self.bounds.values() for value in sublist - ): - raise ValueError("Bounds must be specified for differential_evolution.") elif isinstance(self.bounds, dict): + if not all( + np.isfinite(value) for sublist in self.bounds.values() for value in sublist + ): + raise ValueError("Bounds must be specified for differential_evolution.") bounds = [ (lower, upper) for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) ] else: + if not all(np.isfinite(value) for pair in self.bounds for value in pair): + raise ValueError("Bounds must be specified for differential_evolution.") bounds = self.bounds result = differential_evolution( diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 283f2af42..e47dc6a17 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -104,6 +104,19 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim = pybop.Optimisation( cost=cost, optimiser=optimiser_class, maxiter=1, tol=1e-3 ) + assert optim.optimiser.needs_sensitivities() == False + + # Check and update bounds + assert optim.optimiser.bounds == cost.bounds + bounds = {"upper": [0.61], "lower": [0.59]} + optim.run(bounds=bounds) + assert optim.optimiser.bounds == bounds + bounds = [ + (lower, upper) + for lower, upper in zip(bounds["lower"], bounds["upper"]) + ] + optim.run(bounds=bounds) + assert optim.optimiser.bounds == bounds # Check and update tol assert optim.optimiser.options["tol"] == 1e-3 From 6513e9c144d55f488f9af4607876dd318d0423ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 18:06:22 +0000 Subject: [PATCH 03/85] style: pre-commit fixes --- pybop/optimisers/scipy_optimisers.py | 4 +++- tests/unit/test_optimisation.py | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 4ce22a06c..6abd9699f 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -205,7 +205,9 @@ def callback(x, convergence): raise ValueError("Bounds must be specified for differential_evolution.") elif isinstance(self.bounds, dict): if not all( - np.isfinite(value) for sublist in self.bounds.values() for value in sublist + np.isfinite(value) + for sublist in self.bounds.values() + for value in sublist ): raise ValueError("Bounds must be specified for differential_evolution.") bounds = [ diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index e47dc6a17..e651fd001 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -112,8 +112,7 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim.run(bounds=bounds) assert optim.optimiser.bounds == bounds bounds = [ - (lower, upper) - for lower, upper in zip(bounds["lower"], bounds["upper"]) + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ] optim.run(bounds=bounds) assert optim.optimiser.bounds == bounds From ffc3a4e938d2e99098977f009f831720f1fb30f7 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 23 Mar 2024 18:08:55 +0000 Subject: [PATCH 04/85] Change assert False to not --- tests/unit/test_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index e651fd001..7f88741e8 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -104,7 +104,7 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim = pybop.Optimisation( cost=cost, optimiser=optimiser_class, maxiter=1, tol=1e-3 ) - assert optim.optimiser.needs_sensitivities() == False + assert not optim.optimiser.needs_sensitivities() # Check and update bounds assert optim.optimiser.bounds == cost.bounds From c56d9e6f46db0307cbbb531817c9d588cba91dda Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 23 Mar 2024 19:57:45 +0000 Subject: [PATCH 05/85] Add update_options and error for Pints --- pybop/_optimisation.py | 8 +++-- pybop/optimisers/scipy_optimisers.py | 48 +++++++++++++++------------- tests/unit/test_optimisation.py | 36 +++++++++------------ 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 94e49b42a..bb2463ae4 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -108,9 +108,11 @@ def __init__( raise ValueError("Unknown optimiser type") if self.pints: - self.optimiser = self.optimiser( - self.x0, self.sigma0, self.bounds, **optimiser_kwargs - ) + if optimiser_kwargs: + raise ValueError( + "Additional keyword arguments cannot currently be passed to PINTS optimisers." + ) + self.optimiser = self.optimiser(self.x0, self.sigma0, self.bounds) # Check if sensitivities are required self._needs_sensitivities = self.optimiser.needs_sensitivities() diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 6abd9699f..b37aa32ce 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -42,8 +42,17 @@ class SciPyMinimize(BaseOptimiser): def __init__(self, bounds=None, **optimiser_kwargs): super().__init__(bounds) self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS + self.update_options(**optimiser_kwargs) + + def update_options(self, **optimiser_kwargs): + """ + Update the optimiser options. + """ for key, value in optimiser_kwargs.items(): - self.options[key] = value + if key == "bounds": + self.bounds = value + else: + self.options[key] = value # Overwrite the value of maxiter in the options dictionary if "maxiter" in optimiser_kwargs.keys(): @@ -67,17 +76,7 @@ def _runoptimise(self, cost_function, x0, **optimiser_kwargs): A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. """ - for key, value in optimiser_kwargs.items(): - if key == "bounds": - self.bounds = value - else: - self.options[key] = value - - # Overwrite the value of maxiter in the options dictionary - if "maxiter" in optimiser_kwargs.keys(): - self.options["options"]["maxiter"] = self.options["maxiter"] - del self.options["maxiter"] - + self.update_options(**optimiser_kwargs) self.log = [[x0]] # Add callback storing history of parameter values @@ -163,8 +162,20 @@ class SciPyDifferentialEvolution(BaseOptimiser): def __init__(self, bounds, **optimiser_kwargs): super().__init__(bounds) self.options = DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS + self.update_options(**optimiser_kwargs) + + def update_options(self, **optimiser_kwargs): + """ + Update the optimiser options. + """ for key, value in optimiser_kwargs.items(): - self.options[key] = value + if key == "bounds": + self.bounds = value + else: + self.options[key] = value + + if self.bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): """ @@ -183,12 +194,7 @@ def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. """ - for key, value in optimiser_kwargs.items(): - if key == "bounds": - self.bounds = value - else: - self.options[key] = value - + self.update_options(**optimiser_kwargs) self.log = [] if x0 is not None: @@ -201,9 +207,7 @@ def callback(x, convergence): self.log.append([x]) # Reformat bounds - if self.bounds is None: - raise ValueError("Bounds must be specified for differential_evolution.") - elif isinstance(self.bounds, dict): + if isinstance(self.bounds, dict): if not all( np.isfinite(value) for sublist in self.bounds.values() diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 7f88741e8..453e6a39d 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -59,6 +59,7 @@ def cost(self, problem): ) @pytest.mark.unit def test_optimiser_classes(self, cost, optimiser_class, expected_name): + # Test construction optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) assert optim.optimiser is not None @@ -67,34 +68,21 @@ def test_optimiser_classes(self, cost, optimiser_class, expected_name): # Test construction without bounds bounds = cost.bounds cost.bounds = None - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - - if optimiser_class in [pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution]: + if optimiser_class in [pybop.SciPyDifferentialEvolution]: + with pytest.raises( + ValueError, match="Bounds must be specified for differential_evolution." + ): + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + elif optimiser_class in [pybop.SciPyMinimize]: + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) assert optim.optimiser.bounds is None else: + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) assert optim.optimiser.boundaries is None # Reset cost.bounds = bounds - @pytest.mark.parametrize( - "optimiser_class", - [pybop.SciPyDifferentialEvolution], - ) - @pytest.mark.unit - def test_missing_boundaries(self, cost, optimiser_class): - bounds = cost.bounds - cost.bounds = None - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - with pytest.raises( - ValueError, match="Bounds must be specified for differential_evolution." - ): - optim.run() - - # Reset - cost.bounds = bounds - optim.run(bounds=bounds) - @pytest.mark.parametrize( "optimiser_class", [pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], @@ -135,6 +123,12 @@ def test_scipy_kwargs(self, cost, optimiser_class): assert optim.optimiser.options["popsize"] == 10 optim.run(popsize=5) assert optim.optimiser.options["popsize"] == 5 + else: + with pytest.raises( + ValueError, + match="Additional keyword arguments cannot currently be passed to PINTS optimisers.", + ): + optim = pybop.Optimisation(cost=cost, maxiter=1, tol=1e-3) @pytest.mark.unit def test_default_optimiser(self, cost): From 22ab3b1c3b46a219788114768372f049b64e025d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 27 Mar 2024 20:28:40 +0000 Subject: [PATCH 06/85] Rename opt to optim --- tests/unit/test_optimisation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index ada953ed2..e9b46587e 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -87,7 +87,7 @@ def two_param_cost(self, model, two_parameters, dataset): def test_optimiser_classes(self, two_param_cost, optimiser_class, expected_name): # Test class construction cost = two_param_cost - opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) assert optim.optimiser is not None assert optim.optimiser.name() == expected_name @@ -183,7 +183,7 @@ class RandomClass: def test_prior_sampling(self, cost): # Tests prior sampling for i in range(50): - opt = pybop.Optimisation(cost=cost) + optim = pybop.Optimisation(cost=cost) assert optim.x0 <= 0.62 and optim.x0 >= 0.58 From 4a1f0e0e440e8a69f9111b36aee8db74c734ed1b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 11 Apr 2024 11:57:28 +0100 Subject: [PATCH 07/85] Remove stray comments --- pybop/optimisers/base_optimiser.py | 2 -- pybop/optimisers/scipy_optimisers.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 93c1b3f10..37bf4bb78 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -25,8 +25,6 @@ def optimise(self, cost_function, x0=None, **optimiser_kwargs): """ Initiates the optimisation process. - This method should be overridden by child classes with the specific optimisation algorithm. - Parameters ---------- cost_function : callable diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index b37aa32ce..20202ccfb 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -30,8 +30,6 @@ class SciPyMinimize(BaseOptimiser): ---------- bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. - maxiter : int, optional - Maximum number of iterations to perform. **optimiser_kwargs : optional Valid SciPy Minimize option keys and their values. For example, to specify the solver use: `method='Nelder-Mead'`. Other options are: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', From 129b99996af97c64c76de300301a4b4256269278 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sun, 14 Apr 2024 12:54:24 +0100 Subject: [PATCH 08/85] Add BaseSciPyOptimiser and BasePintsOptimiser --- pybop/__init__.py | 7 +- pybop/_optimisation.py | 6 +- pybop/optimisers/base_optimiser.py | 3 +- pybop/optimisers/pints_optimisers.py | 121 ++++++++++++++----------- pybop/optimisers/scipy_optimisers.py | 131 +++++++++++++++++---------- tests/unit/test_optimisation.py | 11 ++- 6 files changed, 166 insertions(+), 113 deletions(-) diff --git a/pybop/__init__.py b/pybop/__init__.py index 5a7c3ac23..2837d416a 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -82,8 +82,13 @@ # Optimiser class # from .optimisers.base_optimiser import BaseOptimiser -from .optimisers.scipy_optimisers import SciPyMinimize, SciPyDifferentialEvolution +from .optimisers.scipy_optimisers import ( + BaseSciPyOptimiser, + SciPyMinimize, + SciPyDifferentialEvolution +) from .optimisers.pints_optimisers import ( + BasePintsOptimiser, GradientDescent, Adam, CMAES, diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index cbd8f8d0c..e18e3b5f3 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -88,14 +88,12 @@ def __init__( if self.optimiser is None: self.optimiser = pybop.XNES - elif issubclass(self.optimiser, pints.Optimiser): + elif issubclass(self.optimiser, pybop.BasePintsOptimiser): pass else: self.pints = False - if issubclass( - self.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution) - ): + if issubclass(self.optimiser, pybop.BaseSciPyOptimiser): if "maxiter" in optimiser_kwargs.keys(): self.set_max_iterations(optimiser_kwargs["maxiter"]) else: diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 37bf4bb78..1a497a20b 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -19,7 +19,6 @@ def __init__(self, bounds=None): """ self.bounds = bounds self._max_iterations = None - pass def optimise(self, cost_function, x0=None, **optimiser_kwargs): """ @@ -66,7 +65,7 @@ def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): def name(self): """ - Returns the name of the optimiser. + Returns the name of the optimiser, to be overwritten by child classes. Returns ------- diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 1ee289f1b..25e6e90bf 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -1,8 +1,55 @@ import numpy as np import pints +from .base_optimiser import BaseOptimiser -class GradientDescent(pints.GradientDescent): + + +class BasePintsOptimiser(BaseOptimiser): + """ + A base class for defining optimisation methods from the PINTS library. + + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size or standard deviation depending on the optimiser (default is 0.1). + bounds : dict, optional + A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper + bounds on the parameters. + """ + + def __init__(self, pints_class, x0, sigma0, bounds=None): + super().__init__(bounds) + + self._pints_class = pints_class + if bounds is not None: + boundaries = pints.RectangularBoundaries( + bounds["lower"], bounds["upper"] + ) + else: + boundaries = None + self._boundaries = boundaries + + self._pints_class.__init__(self, x0, sigma0, self._boundaries) + + def name(self): + """ + Provides the name of the optimisation strategy. + + Overwrites the method in Base Optimiser with the method from the PINTS class + and therefore requires the instance of self to be passed as an input. + + Returns + ------- + str + The name given by PINTS. + """ + return self._pints_class.name(self) + + +class GradientDescent(BasePintsOptimiser, pints.GradientDescent): """ Implements a simple gradient descent optimization algorithm. @@ -27,12 +74,11 @@ class GradientDescent(pints.GradientDescent): def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: print("NOTE: Boundaries ignored by Gradient Descent") + bounds = None # Bounds ignored in pints.GradDesc + BasePintsOptimiser.__init__(self, pints.GradientDescent, x0, sigma0, bounds) - self.boundaries = None # Bounds ignored in pints.GradDesc - super().__init__(x0, sigma0, self.boundaries) - -class Adam(pints.Adam): +class Adam(BasePintsOptimiser, pints.Adam): """ Implements the Adam optimization algorithm. @@ -57,12 +103,11 @@ class Adam(pints.Adam): def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: print("NOTE: Boundaries ignored by Adam") - - self.boundaries = None # Bounds ignored in pints.Adam - super().__init__(x0, sigma0, self.boundaries) + bounds = None # Bounds ignored in pints.Adam + BasePintsOptimiser.__init__(self, pints.Adam, x0, sigma0, bounds) -class IRPropMin(pints.IRPropMin): +class IRPropMin(BasePintsOptimiser, pints.IRPropMin): """ Implements the iRpropMin optimization algorithm. @@ -86,16 +131,10 @@ class IRPropMin(pints.IRPropMin): """ def __init__(self, x0, sigma0=0.1, bounds=None): - if bounds is not None: - self.boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) - else: - self.boundaries = None - super().__init__(x0, sigma0, self.boundaries) + BasePintsOptimiser.__init__(self, pints.IRPropMin, x0, sigma0, bounds) -class PSO(pints.PSO): +class PSO(BasePintsOptimiser, pints.PSO): """ Implements a particle swarm optimization (PSO) algorithm. @@ -119,22 +158,16 @@ class PSO(pints.PSO): """ def __init__(self, x0, sigma0=0.1, bounds=None): - if bounds is None: - self.boundaries = None - elif not all( + if bounds is not None and not all( np.isfinite(value) for sublist in bounds.values() for value in sublist ): raise ValueError( "Either all bounds or no bounds must be set for Pints PSO." ) - else: - self.boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) - super().__init__(x0, sigma0, self.boundaries) + BasePintsOptimiser.__init__(self, pints.PSO, x0, sigma0, bounds) -class SNES(pints.SNES): +class SNES(BasePintsOptimiser, pints.SNES): """ Implements the stochastic natural evolution strategy (SNES) optimization algorithm. @@ -158,16 +191,10 @@ class SNES(pints.SNES): """ def __init__(self, x0, sigma0=0.1, bounds=None): - if bounds is not None: - self.boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) - else: - self.boundaries = None - super().__init__(x0, sigma0, self.boundaries) + BasePintsOptimiser.__init__(self, pints.SNES, x0, sigma0, bounds) -class XNES(pints.XNES): +class XNES(BasePintsOptimiser, pints.XNES): """ Implements the Exponential Natural Evolution Strategy (XNES) optimiser from PINTS. @@ -191,16 +218,10 @@ class XNES(pints.XNES): """ def __init__(self, x0, sigma0=0.1, bounds=None): - if bounds is not None: - self.boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) - else: - self.boundaries = None - super().__init__(x0, sigma0, self.boundaries) + BasePintsOptimiser.__init__(self, pints.XNES, x0, sigma0, bounds) -class NelderMead(pints.NelderMead): +class NelderMead(BasePintsOptimiser, pints.NelderMead): """ Implements the Nelder-Mead downhill simplex method from PINTS. @@ -226,12 +247,11 @@ class NelderMead(pints.NelderMead): def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: print("NOTE: Boundaries ignored by NelderMead") - - self.boundaries = None # Bounds ignored in pints.NelderMead - super().__init__(x0, sigma0, self.boundaries) + bounds = None # Bounds ignored in pints.NelderMead + BasePintsOptimiser.__init__(self, pints.NelderMead, x0, sigma0, bounds) -class CMAES(pints.CMAES): +class CMAES(BasePintsOptimiser, pints.CMAES): """ Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimiser in PINTS. @@ -260,11 +280,4 @@ def __init__(self, x0, sigma0=0.1, bounds=None): "CMAES requires optimisation of >= 2 parameters at once. " + "Please choose another optimiser." ) - if bounds is not None: - self.boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) - else: - self.boundaries = None - - super().__init__(x0, sigma0, self.boundaries) + BasePintsOptimiser.__init__(self, pints.CMAES, x0, sigma0, bounds) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 20202ccfb..918c2143d 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -6,6 +6,7 @@ DEFAULT_SCIPY_MINIMIZE_OPTIONS = dict( bounds=None, method="Nelder-Mead", + jac=False, tol=1e-5, options=dict(), ) @@ -19,27 +20,20 @@ ) -class SciPyMinimize(BaseOptimiser): +class BaseSciPyOptimiser(BaseOptimiser): """ - Adapts SciPy's minimize function for use as an optimization strategy. - - This class provides an interface to various scalar minimization algorithms implemented in SciPy, - allowing fine-tuning of the optimization process through method selection and option configuration. + A base class for defining optimisation methods from the SciPy library. Parameters ---------- bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. **optimiser_kwargs : optional - Valid SciPy Minimize option keys and their values. For example, to specify the solver - use: `method='Nelder-Mead'`. Other options are: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', - 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', - 'trust-exact', 'trust-krylov'. + Valid SciPy option keys and their values. """ def __init__(self, bounds=None, **optimiser_kwargs): super().__init__(bounds) - self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS self.update_options(**optimiser_kwargs) def update_options(self, **optimiser_kwargs): @@ -52,6 +46,61 @@ def update_options(self, **optimiser_kwargs): else: self.options[key] = value + # Set optimiser_specific options if required + self._update_optimiser_options(**optimiser_kwargs) + + def _update_optimiser_options(self, **optimiser_kwargs): + """ + Update optimiser-specific options. This function should be implemented in + child classes if required. + """ + pass + + def name(self): + """ + Provides the name of the optimisation strategy. + + Overwrites the method in Base Optimiser with the method from the PINTS class + and therefore requires the instance of self to be passed as an input. + + Returns + ------- + str + The name given by PINTS. + """ + return self._pints_class.name(self) + + +class SciPyMinimize(BaseSciPyOptimiser): + """ + Adapts SciPy's minimize function for use as an optimization strategy. + + This class provides an interface to various scalar minimization algorithms implemented in SciPy, + allowing fine-tuning of the optimization process through method selection and option configuration. + + Parameters + ---------- + bounds : dict, sequence or scipy.optimize.Bounds, optional + Bounds for variables as supported by the selected method. + **optimiser_kwargs : optional + Valid SciPy Minimize option keys and their values. For example, to specify the solver + use: `method='Nelder-Mead'`. Other options are: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', + 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', + 'trust-exact', 'trust-krylov'. + + See Also + -------- + scipy.optimize.minimize : The SciPy method this class is based on. + """ + + def __init__(self, bounds=None, **optimiser_kwargs): + self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS + super().__init__(bounds, **optimiser_kwargs) + + def _update_optimiser_options(self, **optimiser_kwargs): + """ + Update the optimiser-specific options. + """ # Overwrite the value of maxiter in the options dictionary if "maxiter" in optimiser_kwargs.keys(): self.options["options"]["maxiter"] = self.options["maxiter"] @@ -59,7 +108,7 @@ def update_options(self, **optimiser_kwargs): def _runoptimise(self, cost_function, x0, **optimiser_kwargs): """ - Executes the optimization process using SciPy's minimize function. + Executes the optimisation process using SciPy's minimize function. Parameters ---------- @@ -87,12 +136,18 @@ def callback(x): if np.isinf(self.cost0): raise Exception("The initial parameter values return an infinite cost.") - def cost_wrapper(x): - cost = cost_function(x) / self.cost0 - if np.isinf(cost): - self.inf_count += 1 - cost = 1 + 0.9**self.inf_count # for fake finite gradient - return cost + if not self.options["jac"]: + def cost_wrapper(x): + cost = cost_function(x) / self.cost0 + if np.isinf(cost): + self.inf_count += 1 + cost = 1 + 0.9**self.inf_count # for fake finite gradient + return cost + elif self.options["jac"] is True: + def cost_wrapper(x): + return cost_function.evaluateS1(x) + else: + raise ValueError("Expected the jac option to be either True, False or None.") # Reformat bounds if isinstance(self.bounds, dict): @@ -107,6 +162,7 @@ def cost_wrapper(x): cost_wrapper, x0, method=self.options["method"], + jac=self.options["jac"], bounds=bounds, tol=self.options["tol"], options=self.options["options"], @@ -115,17 +171,6 @@ def cost_wrapper(x): return result - def needs_sensitivities(self): - """ - Determines if the optimization algorithm requires gradient information. - - Returns - ------- - bool - False, indicating that gradient information is not required. - """ - return False - def name(self): """ Provides the name of the optimization strategy. @@ -138,7 +183,7 @@ def name(self): return "SciPyMinimize" -class SciPyDifferentialEvolution(BaseOptimiser): +class SciPyDifferentialEvolution(BaseSciPyOptimiser): """ Adapts SciPy's differential_evolution function for global optimization. @@ -155,23 +200,20 @@ class SciPyDifferentialEvolution(BaseOptimiser): Maximum number of iterations to perform. Defaults to 1000. popsize : int, optional The number of individuals in the population. Defaults to 15. + + See Also + -------- + scipy.optimize.differential_evolution : The SciPy method this class is based on. """ def __init__(self, bounds, **optimiser_kwargs): - super().__init__(bounds) self.options = DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS - self.update_options(**optimiser_kwargs) + super().__init__(bounds, **optimiser_kwargs) - def update_options(self, **optimiser_kwargs): + def _update_optimiser_options(self, **optimiser_kwargs): """ - Update the optimiser options. + Update the optimiser-specific options. """ - for key, value in optimiser_kwargs.items(): - if key == "bounds": - self.bounds = value - else: - self.options[key] = value - if self.bounds is None: raise ValueError("Bounds must be specified for differential_evolution.") @@ -246,17 +288,6 @@ def set_population_size(self, population_size=None): raise ValueError("Population size must be at least 1.") self.options["popsize"] = population_size - def needs_sensitivities(self): - """ - Determines if the optimization algorithm requires gradient information. - - Returns - ------- - bool - False, indicating that gradient information is not required for differential evolution. - """ - return False - def name(self): """ Provides the name of the optimization strategy. diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index e9b46587e..38e9194ac 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -105,7 +105,7 @@ def test_optimiser_classes(self, two_param_cost, optimiser_class, expected_name) assert optim.optimiser.bounds is None else: optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - assert optim.optimiser.boundaries is None + assert optim.optimiser._boundaries is None # Reset cost.bounds = bounds @@ -119,7 +119,6 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim = pybop.Optimisation( cost=cost, optimiser=optimiser_class, maxiter=1, tol=1e-3 ) - assert not optim.optimiser.needs_sensitivities() # Check and update bounds assert optim.optimiser.bounds == cost.bounds @@ -151,6 +150,14 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim.run(popsize=5) assert optim.optimiser.options["popsize"] == 5 else: + # Check a method that uses gradient information + optim.run(method='L-BFGS-B', jac=True) + with pytest.raises( + ValueError, match="Expected the jac option to be either True, False or None.", + ): + optim.run(jac="Invalid string") + optim.run(method='Nelder-Mead', jac=False) + with pytest.raises( ValueError, match="Additional keyword arguments cannot currently be passed to PINTS optimisers.", From d43b101113d0b21cd96e5b145dc588061f2ad4e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 14 Apr 2024 11:55:04 +0000 Subject: [PATCH 09/85] style: pre-commit fixes --- pybop/optimisers/pints_optimisers.py | 7 ++----- pybop/optimisers/scipy_optimisers.py | 8 ++++++-- tests/unit/test_optimisation.py | 7 ++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 25e6e90bf..016aaaa15 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -4,7 +4,6 @@ from .base_optimiser import BaseOptimiser - class BasePintsOptimiser(BaseOptimiser): """ A base class for defining optimisation methods from the PINTS library. @@ -25,15 +24,13 @@ def __init__(self, pints_class, x0, sigma0, bounds=None): self._pints_class = pints_class if bounds is not None: - boundaries = pints.RectangularBoundaries( - bounds["lower"], bounds["upper"] - ) + boundaries = pints.RectangularBoundaries(bounds["lower"], bounds["upper"]) else: boundaries = None self._boundaries = boundaries self._pints_class.__init__(self, x0, sigma0, self._boundaries) - + def name(self): """ Provides the name of the optimisation strategy. diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 918c2143d..d497a13ba 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -55,7 +55,7 @@ def _update_optimiser_options(self, **optimiser_kwargs): child classes if required. """ pass - + def name(self): """ Provides the name of the optimisation strategy. @@ -137,6 +137,7 @@ def callback(x): raise Exception("The initial parameter values return an infinite cost.") if not self.options["jac"]: + def cost_wrapper(x): cost = cost_function(x) / self.cost0 if np.isinf(cost): @@ -144,10 +145,13 @@ def cost_wrapper(x): cost = 1 + 0.9**self.inf_count # for fake finite gradient return cost elif self.options["jac"] is True: + def cost_wrapper(x): return cost_function.evaluateS1(x) else: - raise ValueError("Expected the jac option to be either True, False or None.") + raise ValueError( + "Expected the jac option to be either True, False or None." + ) # Reformat bounds if isinstance(self.bounds, dict): diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 38e9194ac..b7f137849 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -151,12 +151,13 @@ def test_scipy_kwargs(self, cost, optimiser_class): assert optim.optimiser.options["popsize"] == 5 else: # Check a method that uses gradient information - optim.run(method='L-BFGS-B', jac=True) + optim.run(method="L-BFGS-B", jac=True) with pytest.raises( - ValueError, match="Expected the jac option to be either True, False or None.", + ValueError, + match="Expected the jac option to be either True, False or None.", ): optim.run(jac="Invalid string") - optim.run(method='Nelder-Mead', jac=False) + optim.run(method="Nelder-Mead", jac=False) with pytest.raises( ValueError, From 710d49e46d3b368ca0a22673a6d1aa14410d16ed Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:27:17 +0100 Subject: [PATCH 10/85] Align optimiser option setting --- CHANGELOG.md | 2 +- pybop/_optimisation.py | 463 ++----------------- pybop/optimisers/base_optimiser.py | 96 ++-- pybop/optimisers/pints_optimisers.py | 470 ++++++++++++++++++-- pybop/optimisers/scipy_optimisers.py | 147 +++--- tests/integration/test_parameterisations.py | 30 +- tests/unit/test_optimisation.py | 105 +++-- 7 files changed, 685 insertions(+), 628 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a625128..df8f14e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ codesigned binaries and source distributions via `sigstore-python`. ## Bug Fixes -- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Allows passing of keyword arguments to SciPy optimisers and fixes setting of max_iterations. +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Allows passing of keyword arguments to optimisers and fixes setting of max_iterations. - [#270](https://github.com/pybop-team/PyBOP/pull/270) - Updates PR template. - [#91](https://github.com/pybop-team/PyBOP/issues/91) - Adds a check on the number of parameters for CMAES and makes XNES the default optimiser. diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index e18e3b5f3..ddbed55a6 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -1,25 +1,22 @@ import warnings -import numpy as np -import pints - import pybop class Optimisation: """ - A class for conducting optimization using PyBOP or PINTS optimisers. + A class for conducting optimisation using PyBOP or PINTS optimisers. Parameters ---------- cost : pybop.BaseCost or pints.ErrorMeasure - An objective function to be optimized, which can be either a pybop.Cost or PINTS error measure + An objective function to be optimised, which can be either a pybop.Cost or PINTS error measure optimiser : pybop.Optimiser or subclass of pybop.BaseOptimiser, optional - An optimiser from either the PINTS or PyBOP framework to perform the optimization (default: None). + An optimiser from either the PINTS or PyBOP framework to perform the optimisation (default: None). sigma0 : float or sequence, optional Initial step size or standard deviation for the optimiser (default: None). verbose : bool, optional - If True, the optimization progress is printed (default: False). + If True, the optimisation progress is printed (default: False). physical_viability : bool, optional If True, the feasibility of the optimised parameters is checked (default: True). allow_infeasible_solutions : bool, optional @@ -28,15 +25,15 @@ class Optimisation: Attributes ---------- x0 : numpy.ndarray - Initial parameter values for the optimization. + Initial parameter values for the optimisation. bounds : dict Dictionary containing the parameter bounds with keys 'lower' and 'upper'. _n_parameters : int - Number of parameters in the optimization problem. + Number of parameters in the optimisation problem. sigma0 : float or sequence Initial step size or standard deviation for the optimiser. log : list - Log of the optimization process. + Log of the optimisation process. """ def __init__( @@ -52,17 +49,17 @@ def __init__( ): self.cost = cost self.x0 = x0 or cost.x0 - self.optimiser = optimiser + self.optimiser = optimiser or pybop.XNES self.verbose = verbose self.bounds = cost.bounds self.sigma0 = sigma0 or cost.sigma0 self._n_parameters = cost._n_parameters self.physical_viability = physical_viability self.allow_infeasible_solutions = allow_infeasible_solutions - self.log = [] - # Convert x0 to pints vector - self._x0 = pints.vector(self.x0) + # Check optimiser + if not issubclass(self.optimiser, pybop.BaseOptimiser): + raise ValueError("Unknown optimiser type") # Set whether to allow infeasible locations if self.cost.problem is not None and hasattr(self.cost.problem, "_model"): @@ -74,454 +71,60 @@ def __init__( self.physical_viability = False self.allow_infeasible_solutions = False - # PyBOP doesn't currently support the pints transformation class - self._transformation = None - - # Check if minimising or maximising - if isinstance(cost, pybop.BaseLikelihood): - self.cost._minimising = False - self._minimising = self.cost._minimising - self._function = self.cost - # Construct Optimiser - self.pints = True - - if self.optimiser is None: - self.optimiser = pybop.XNES - elif issubclass(self.optimiser, pybop.BasePintsOptimiser): - pass - else: - self.pints = False - - if issubclass(self.optimiser, pybop.BaseSciPyOptimiser): - if "maxiter" in optimiser_kwargs.keys(): - self.set_max_iterations(optimiser_kwargs["maxiter"]) - else: - self.set_max_iterations() - optimiser_kwargs["maxiter"] = self._max_iterations - - self.optimiser = self.optimiser(bounds=self.bounds, **optimiser_kwargs) - - else: - raise ValueError("Unknown optimiser type") - - if self.pints: - if optimiser_kwargs: - raise ValueError( - "Additional keyword arguments cannot currently be passed to PINTS optimisers." - ) - self.optimiser = self.optimiser(self.x0, self.sigma0, self.bounds) - - # Check if sensitivities are required - self._needs_sensitivities = self.optimiser.needs_sensitivities() - - # Track optimiser's f_best or f_guessed - self._use_f_guessed = None - self.set_f_guessed_tracking() - - # Parallelisation - self._parallel = False - self._n_workers = 1 - self.set_parallel() - - # User callback - self._callback = None - - # Define stopping criteria - # Maximum iterations - self._max_iterations = None - self.set_max_iterations() - - # Minimum iterations - self._min_iterations = None - self.set_min_iterations() - - # Maximum unchanged iterations - self._unchanged_threshold = 1 # smallest significant f change - self._unchanged_max_iterations = None - self.set_max_unchanged_iterations() - - # Maximum evaluations - self._max_evaluations = None + self.optimiser = self.optimiser( + self.x0, self.sigma0, self.bounds, **optimiser_kwargs + ) - # Threshold value - self._threshold = None + # Pass cost and settings to optimiser + self.optimiser._cost_function = self.cost + self.optimiser.verbose = self.verbose - # Post-run statistics - self._evaluations = None - self._iterations = None + # Check if minimising or maximising + if isinstance(self.cost, pybop.BaseLikelihood): + self.cost._minimising = False + self.optimiser._minimising = self.cost._minimising def run(self, **optimiser_kwargs): """ - Run the optimization and return the optimized parameters and final cost. + Run the optimisation and return the optimised parameters and final cost. + + **optimiser_kwargs : optional + Valid SciPy option keys and their values. Returns ------- x : numpy.ndarray - The best parameter set found by the optimization. + The best parameter set found by the optimisation. final_cost : float The final cost associated with the best parameters. """ - if self.pints: - x, final_cost = self._run_pints() - elif not self.pints: - x, final_cost = self._run_pybop(**optimiser_kwargs) + x, final_cost = self.optimiser.run(**optimiser_kwargs) # Store the optimised parameters if self.cost.problem is not None: self.store_optimised_parameters(x) + # Store the log + self.log = self.optimiser.log + # Check if parameters are viable if self.physical_viability: self.check_optimal_parameters(x) return x, final_cost - def _run_pybop(self, **optimiser_kwargs): - """ - Internal method to run the optimization using a PyBOP optimiser. - - Returns - ------- - x : numpy.ndarray - The best parameter set found by the optimization. - final_cost : float - The final cost associated with the best parameters. - """ - if "maxiter" in optimiser_kwargs.keys(): - self.set_max_iterations(optimiser_kwargs["maxiter"]) - else: - optimiser_kwargs["maxiter"] = self._max_iterations - - result = self.optimiser.optimise( - cost_function=self.cost, - x0=self.x0, - **optimiser_kwargs, - ) - self.log = self.optimiser.log - self._iterations = result.nit - - return result.x, self.cost(result.x) - - def _run_pints(self): - """ - Internal method to run the optimization using a PINTS optimiser. - - Returns - ------- - x : numpy.ndarray - The best parameter set found by the optimization. - final_cost : float - The final cost associated with the best parameters. - - See Also - -------- - This method is heavily based on the run method in the PINTS.OptimisationController class. - """ - - # 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: - raise ValueError("At least one stopping criterion must be set.") - - # Iterations and function evaluations - iteration = 0 - evaluations = 0 - - # Unchanged iterations counter - unchanged_iterations = 0 - - # Choose method to evaluate - f = self._function - if self._needs_sensitivities: - f = f.evaluateS1 - - # Create evaluator object - if self._parallel: - # Get number of workers - n_workers = self._n_workers - - # For population based optimisers, don't use more workers than - # particles! - if isinstance(self._optimiser, pints.PopulationBasedOptimiser): - n_workers = min(n_workers, self._optimiser.population_size()) - evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) - else: - evaluator = pints.SequentialEvaluator(f) - - # Keep track of current best and best-guess scores. - fb = fg = np.inf - - # Internally we always minimise! Keep a 2nd value to show the user. - fg_user = (fb, fg) if self._minimising else (-fb, -fg) - - # Keep track of the last significant change - f_sig = np.inf - - # Run the ask-and-tell loop - running = True - try: - while running: - # Ask optimiser for new points - xs = self.optimiser.ask() - - # Evaluate points - fs = evaluator.evaluate(xs) - - # Tell optimiser about function values - self.optimiser.tell(fs) - - # Update the scores - fb = self.optimiser.f_best() - fg = self.optimiser.f_guessed() - 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 - - # Update counts - evaluations += len(fs) - iteration += 1 - self.log.append(xs) - - # 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 - and iteration >= self._min_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) - + "." - ) - - # Error in optimiser - error = self.optimiser.stop() - if error: - running = False - halt_message = str(error) - - elif self._callback is not None: - self._callback(iteration - 1, self.optimiser) - - except (Exception, SystemExit, KeyboardInterrupt): - # Show last result and exit - print("\n" + "-" * 40) - print("Unexpected termination.") - print("Current score: " + str(fg_user)) - print("Current position:") - - # Show current parameters - x_user = self.optimiser.x_guessed() - if self._transformation is not None: - x_user = self._transformation.to_model(x_user) - for p in x_user: - print(pints.strfloat(p)) - print("-" * 40) - raise - - if self.verbose: - print("Halt: " + halt_message) - - # Save post-run statistics - self._evaluations = evaluations - self._iterations = iteration - - # Get best parameters - if self._use_f_guessed: - x = self.optimiser.x_guessed() - f = self.optimiser.f_guessed() - else: - x = self.optimiser.x_best() - f = self.optimiser.f_best() - - # Inverse transform search parameters - if self._transformation is not None: - x = self._transformation.to_model(x) - - # Store the optimised parameters - self.store_optimised_parameters(x) - - # Return best position and the score used internally, - # i.e the negative log-likelihood in the case of - # self._minimising = False - return x, f - - def f_guessed_tracking(self): - """ - Check if f_guessed instead of f_best is being tracked. - Credit: PINTS - - Returns - ------- - bool - True if f_guessed is being tracked, False otherwise. - """ - return self._use_f_guessed - - def set_f_guessed_tracking(self, use_f_guessed=False): - """ - Set the method used to track the optimiser progress. - Credit: PINTS - - Parameters - ---------- - use_f_guessed : bool, optional - If True, track f_guessed; otherwise, track f_best (default: False). - """ - self._use_f_guessed = bool(use_f_guessed) - - def set_max_evaluations(self, evaluations=None): - """ - Set a maximum number of evaluations stopping criterion. - Credit: PINTS - - Parameters - ---------- - evaluations : int, optional - The maximum number of evaluations after which to stop the optimization (default: None). - """ - if evaluations is not None: - evaluations = int(evaluations) - if evaluations < 0: - raise ValueError("Maximum number of evaluations cannot be negative.") - self._max_evaluations = evaluations - - def set_parallel(self, parallel=False): - """ - Enable or disable parallel evaluation. - Credit: PINTS - - Parameters - ---------- - parallel : bool or int, optional - If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. - If False or 0, disable parallelism (default: False). - """ - if parallel is True: - self._parallel = True - self._n_workers = pints.ParallelEvaluator.cpu_count() - elif parallel >= 1: - self._parallel = True - self._n_workers = int(parallel) - else: - self._parallel = False - self._n_workers = 1 - - def set_max_iterations(self, iterations=1000): - """ - Set the maximum number of iterations as a stopping criterion. - Credit: PINTS - - Parameters - ---------- - iterations : int, optional - The maximum number of iterations to run (default is 1000). - Set to `None` to remove this stopping criterion. - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Maximum number of iterations cannot be negative.") - self._max_iterations = iterations - - def set_min_iterations(self, iterations=2): - """ - Set the minimum number of iterations as a stopping criterion. - - Parameters - ---------- - iterations : int, optional - The minimum number of iterations to run (default is 100). - Set to `None` to remove this stopping criterion. - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Minimum number of iterations cannot be negative.") - self._min_iterations = iterations - - def set_max_unchanged_iterations(self, iterations=15, threshold=1e-5): - """ - Set the maximum number of iterations without significant change as a stopping criterion. - Credit: PINTS - - Parameters - ---------- - iterations : int, optional - The maximum number of unchanged iterations to run (default is 15). - Set to `None` to remove this stopping criterion. - threshold : float, optional - The minimum significant change in the objective function value that resets the unchanged iteration counter (default is 1e-5). - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Maximum number of iterations cannot be negative.") - - threshold = float(threshold) - if threshold < 0: - raise ValueError("Minimum significant change cannot be negative.") - - self._unchanged_max_iterations = iterations - self._unchanged_threshold = threshold - def store_optimised_parameters(self, x): """ - Update the problem parameters with optimized values. + Update the problem parameters with optimised values. - The optimized parameter values are stored within the associated PyBOP parameter class. + The optimised parameter values are stored within the associated PyBOP parameter class. Parameters ---------- x : array-like - Optimized parameter values. + Optimised parameter values. """ for i, param in enumerate(self.cost.parameters): param.update(value=x[i]) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 1a497a20b..dc7d1d45e 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -3,47 +3,79 @@ class BaseOptimiser: A base class for defining optimisation methods. This class serves as a template 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 the optimise and _runoptimise - methods with specific algorithms. + an optimisation algorithm, including the initial setup and a method stub for performing the + optimisation process. Child classes should override update_options and the_run method with + a specific algorithm. """ - def __init__(self, bounds=None): + def __init__(self, x0=None, sigma0=None, bounds=None): """ - Initializes the BaseOptimiser. + Initialises the BaseOptimiser. Parameters ---------- + x0 : array_like + Initial position from which optimisation will start. + sigma0 : float, optional + Initial step size or standard deviation depending on the optimiser. bounds : sequence or Bounds, optional - Bounds on the parameters. Default is None. + Bounds on the parameters (default: None). """ + self._cost_function = None + self.x0 = x0 + self.sigma0 = sigma0 self.bounds = bounds + + self.log = [] self._max_iterations = None + self.set_max_iterations() + + def update_options(self, **optimiser_kwargs): + """ + Update the optimiser options, to be overwritten by child classes. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid option keys and their values. + """ + pass + + def name(self): + """ + Returns the name of the optimiser, to be overwritten by child classes. + + Returns + ------- + str + The name of the optimiser, which is "BaseOptimiser" for this base class. + """ + return "BaseOptimiser" - def optimise(self, cost_function, x0=None, **optimiser_kwargs): + def run(self, x0=None, **optimiser_kwargs): """ Initiates the optimisation process. Parameters ---------- - cost_function : callable - The cost function to be minimised by the optimiser. x0 : ndarray, optional - Initial guess for the parameters. Default is None. + Initial guess for the parameters (default: None). + **optimiser_kwargs : optional + Valid option keys and their values. Returns ------- The result of the optimisation process. The specific type of this result will depend on the child implementation. """ - self.cost_function = cost_function - self.x0 = x0 + if x0 is not None: + self.x0 = x0 # Run optimisation - result = self._runoptimise(self.cost_function, x0=self.x0, **optimiser_kwargs) + result = self._run(**optimiser_kwargs) return result - def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): + def _run(self, **optimiser_kwargs): """ Contains the logic for the optimisation algorithm. @@ -51,25 +83,29 @@ def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): Parameters ---------- - cost_function : callable - The cost function to be minimised by the optimiser. - x0 : ndarray, optional - Initial guess for the parameters. Default is None. + **optimiser_kwargs : optional + Valid option keys and their values. - Returns - ------- - This method is expected to return the result of the optimisation, the format of which - will be determined by the child class implementation. + Raises + ------ + NotImplementedError + If the method has not been implemented by the subclass. """ - pass + raise NotImplementedError - def name(self): + def set_max_iterations(self, iterations=1000): """ - Returns the name of the optimiser, to be overwritten by child classes. + Set the maximum number of iterations as a stopping criterion. + Credit: PINTS - Returns - ------- - str - The name of the optimiser, which is "BaseOptimiser" for this base class. + Parameters + ---------- + iterations : int, optional + The maximum number of iterations to run (default: 1000). + Set to `None` to remove this stopping criterion. """ - return "BaseOptimiser" + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Maximum number of iterations cannot be negative.") + self._max_iterations = iterations diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 016aaaa15..ad27939da 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -17,36 +17,412 @@ class BasePintsOptimiser(BaseOptimiser): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. """ - def __init__(self, pints_class, x0, sigma0, bounds=None): - super().__init__(bounds) + def __init__( + self, pints_class, x0=None, sigma0=None, bounds=None, **optimiser_kwargs + ): + super().__init__(x0, sigma0, bounds) - self._pints_class = pints_class + # Convert bounds to PINTS boundaries if bounds is not None: boundaries = pints.RectangularBoundaries(bounds["lower"], bounds["upper"]) else: boundaries = None self._boundaries = boundaries - self._pints_class.__init__(self, x0, sigma0, self._boundaries) + # Convert x0 to PINTS vector + self._x0 = pints.vector(self.x0) + + # PyBOP doesn't currently support the PINTS transformation class + self._transformation = None + + # Create an instance of the PINTS optimiser class + self.pints_optimiser = pints_class(x0, sigma0, self._boundaries) + + # Check if sensitivities are required + self._needs_sensitivities = self.pints_optimiser.needs_sensitivities() + + # Track optimiser's f_best or f_guessed + self._use_f_guessed = None + self.set_f_guessed_tracking() + + # Parallelisation + self._parallel = False + self._n_workers = 1 + self.set_parallel() + + # User callback + self._callback = None + + # Define stopping criteria + # Maximum iterations set in BaseOptimiser + + # Minimum iterations + self._min_iterations = None + self.set_min_iterations() + + # Maximum unchanged iterations + self._unchanged_threshold = 1 # smallest significant f change + self._unchanged_max_iterations = None + self.set_max_unchanged_iterations() + + # Maximum evaluations + self._max_evaluations = None + + # Threshold value + self._threshold = None + + # Post-run statistics + self._evaluations = None + self._iterations = None + + self.update_options(**optimiser_kwargs) + + def update_options(self, **optimiser_kwargs): + """ + Update the optimiser options. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid PINTS option keys and their values. + """ + for key, value in optimiser_kwargs.items(): + if key == "use_f_guessed": + self.set_f_guessed_tracking(value) + elif key == "parallel": + self.set_parallel(value) + elif key == "maxiter" or key == "max_iterations": + self.set_max_iterations(value) + elif key == "min_iterations": + self.set_min_iterations(value) + elif key == "max_unchanged_iterations": + if "threshold" in optimiser_kwargs.keys(): + self.set_max_unchanged_iterations( + value, + optimiser_kwargs["threshold"], + ) + else: + self.set_max_unchanged_iterations(value) + elif key == "threshold": + pass # only used with unchanged_max_iterations + elif key == "max_evaluations": + self.set_max_evaluations(value) + else: + raise ValueError(f"Unrecognised or invalid keyword argument: {key}") def name(self): """ Provides the name of the optimisation strategy. - Overwrites the method in Base Optimiser with the method from the PINTS class - and therefore requires the instance of self to be passed as an input. - Returns ------- str The name given by PINTS. """ - return self._pints_class.name(self) + return self.pints_optimiser.name() + + def _run(self, **optimiser_kwargs): + """ + Internal method to run the optimization using a PINTS optimiser. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid PINTS option keys and their values. + Returns + ------- + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. + + See Also + -------- + This method is heavily based on the run method in the PINTS.OptimisationController class. + """ + self.update_options(**optimiser_kwargs) + + # 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: + raise ValueError("At least one stopping criterion must be set.") + + # Iterations and function evaluations + iteration = 0 + evaluations = 0 + + # Unchanged iterations counter + unchanged_iterations = 0 + + # Choose method to evaluate + f = self._cost_function + if self._needs_sensitivities: + f = f.evaluateS1 + + # Create evaluator object + if self._parallel: + # Get number of workers + n_workers = self._n_workers + + # For population based optimisers, don't use more workers than + # particles! + if isinstance(self.pints_optimiser, pints.PopulationBasedOptimiser): + n_workers = min(n_workers, self.pints_optimiser.population_size()) + evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) + else: + evaluator = pints.SequentialEvaluator(f) + + # Keep track of current best and best-guess scores. + fb = fg = np.inf + + # Internally we always minimise! Keep a 2nd value to show the user. + fg_user = (fb, fg) if self._minimising else (-fb, -fg) + + # Keep track of the last significant change + f_sig = np.inf + + # Run the ask-and-tell loop + running = True + try: + while running: + # Ask optimiser for new points + xs = self.pints_optimiser.ask() + + # Evaluate points + fs = evaluator.evaluate(xs) + + # Tell optimiser about function values + self.pints_optimiser.tell(fs) + + # Update the scores + fb = self.pints_optimiser.f_best() + fg = self.pints_optimiser.f_guessed() + 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 + + # Update counts + evaluations += len(fs) + iteration += 1 + self.log.append(xs) + + # 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 + and iteration >= self._min_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) + + "." + ) + + # Error in optimiser + error = self.pints_optimiser.stop() + if error: + running = False + halt_message = str(error) + + elif self._callback is not None: + self._callback(iteration - 1, self) + + except (Exception, SystemExit, KeyboardInterrupt): + # Show last result and exit + print("\n" + "-" * 40) + print("Unexpected termination.") + print("Current score: " + str(fg_user)) + print("Current position:") + + # Show current parameters + x_user = self.pints_optimiser.x_guessed() + if self._transformation is not None: + x_user = self._transformation.to_model(x_user) + for p in x_user: + print(pints.strfloat(p)) + print("-" * 40) + raise + + if self.verbose: + print("Halt: " + halt_message) + + # Save post-run statistics + self._evaluations = evaluations + self._iterations = iteration + + # Get best parameters + if self._use_f_guessed: + x = self.pints_optimiser.x_guessed() + f = self.pints_optimiser.f_guessed() + else: + x = self.pints_optimiser.x_best() + f = self.pints_optimiser.f_best() + + # Inverse transform search parameters + if self._transformation is not None: + x = self._transformation.to_model(x) + + # Return best position and the score used internally, + # i.e the negative log-likelihood in the case of + # self._minimising = False + return x, f + + def f_guessed_tracking(self): + """ + Check if f_guessed instead of f_best is being tracked. + Credit: PINTS -class GradientDescent(BasePintsOptimiser, pints.GradientDescent): + Returns + ------- + bool + True if f_guessed is being tracked, False otherwise. + """ + return self._use_f_guessed + + def set_f_guessed_tracking(self, use_f_guessed=False): + """ + Set the method used to track the optimiser progress. + Credit: PINTS + + Parameters + ---------- + use_f_guessed : bool, optional + If True, track f_guessed; otherwise, track f_best (default: False). + """ + self._use_f_guessed = bool(use_f_guessed) + + def set_parallel(self, parallel=False): + """ + Enable or disable parallel evaluation. + Credit: PINTS + + Parameters + ---------- + parallel : bool or int, optional + If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. + If False or 0, disable parallelism (default: False). + """ + if parallel is True: + self._parallel = True + self._n_workers = pints.ParallelEvaluator.cpu_count() + elif parallel >= 1: + self._parallel = True + self._n_workers = int(parallel) + else: + self._parallel = False + self._n_workers = 1 + + def set_min_iterations(self, iterations=2): + """ + Set the minimum number of iterations as a stopping criterion. + + Parameters + ---------- + iterations : int, optional + The minimum number of iterations to run (default: 2). + Set to `None` to remove this stopping criterion. + """ + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Minimum number of iterations cannot be negative.") + self._min_iterations = iterations + + def set_max_unchanged_iterations(self, iterations=15, threshold=1e-5): + """ + Set the maximum number of iterations without significant change as a stopping criterion. + Credit: PINTS + + Parameters + ---------- + iterations : int, optional + The maximum number of unchanged iterations to run (default: 15). + Set to `None` to remove this stopping criterion. + threshold : float, optional + The minimum significant change in the objective function value that resets the + unchanged iteration counter (default: 1e-5). + """ + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Maximum number of iterations cannot be negative.") + + threshold = float(threshold) + if threshold < 0: + raise ValueError("Minimum significant change cannot be negative.") + + self._unchanged_max_iterations = iterations + self._unchanged_threshold = threshold + + def set_max_evaluations(self, evaluations=None): + """ + Set a maximum number of evaluations stopping criterion. + Credit: PINTS + + Parameters + ---------- + evaluations : int, optional + The maximum number of evaluations after which to stop the optimisation + (default: None). + """ + if evaluations is not None: + evaluations = int(evaluations) + if evaluations < 0: + raise ValueError("Maximum number of evaluations cannot be negative.") + self._max_evaluations = evaluations + + +class GradientDescent(BasePintsOptimiser): """ Implements a simple gradient descent optimization algorithm. @@ -62,20 +438,34 @@ class GradientDescent(BasePintsOptimiser, pints.GradientDescent): Initial step size (default is 0.1). bounds : dict, optional Ignored by this optimiser, provided for API consistency. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.GradientDescent : The PINTS implementation this class is based on. """ - def __init__(self, x0, sigma0=0.1, bounds=None): + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): if bounds is not None: print("NOTE: Boundaries ignored by Gradient Descent") - bounds = None # Bounds ignored in pints.GradDesc - BasePintsOptimiser.__init__(self, pints.GradientDescent, x0, sigma0, bounds) + bounds = None # Bounds ignored in pints.GradientDescent + super().__init__(pints.GradientDescent, x0, sigma0, bounds, **optimiser_kwargs) + + def set_learning_rate(self, eta): + """ + Sets the learning rate for this optimiser. + Credit: PINTS + + Parameters + ---------- + eta : float + The learning rate, as a float greater than zero. + """ + self.pints_optimiser.set_learning_rate(eta) -class Adam(BasePintsOptimiser, pints.Adam): +class Adam(BasePintsOptimiser): """ Implements the Adam optimization algorithm. @@ -91,20 +481,22 @@ class Adam(BasePintsOptimiser, pints.Adam): Initial step size (default is 0.1). bounds : dict, optional Ignored by this optimiser, provided for API consistency. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.Adam : The PINTS implementation this class is based on. """ - def __init__(self, x0, sigma0=0.1, bounds=None): + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): if bounds is not None: print("NOTE: Boundaries ignored by Adam") bounds = None # Bounds ignored in pints.Adam - BasePintsOptimiser.__init__(self, pints.Adam, x0, sigma0, bounds) + super().__init__(pints.Adam, x0, sigma0, bounds, **optimiser_kwargs) -class IRPropMin(BasePintsOptimiser, pints.IRPropMin): +class IRPropMin(BasePintsOptimiser): """ Implements the iRpropMin optimization algorithm. @@ -121,17 +513,19 @@ class IRPropMin(BasePintsOptimiser, pints.IRPropMin): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.IRPropMin : The PINTS implementation this class is based on. """ - def __init__(self, x0, sigma0=0.1, bounds=None): - BasePintsOptimiser.__init__(self, pints.IRPropMin, x0, sigma0, bounds) + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): + super().__init__(pints.IRPropMin, x0, sigma0, bounds, **optimiser_kwargs) -class PSO(BasePintsOptimiser, pints.PSO): +class PSO(BasePintsOptimiser): """ Implements a particle swarm optimization (PSO) algorithm. @@ -148,23 +542,25 @@ class PSO(BasePintsOptimiser, pints.PSO): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.PSO : The PINTS implementation this class is based on. """ - def __init__(self, x0, sigma0=0.1, bounds=None): + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): if bounds is not None and not all( np.isfinite(value) for sublist in bounds.values() for value in sublist ): raise ValueError( "Either all bounds or no bounds must be set for Pints PSO." ) - BasePintsOptimiser.__init__(self, pints.PSO, x0, sigma0, bounds) + super().__init__(pints.PSO, x0, sigma0, bounds, **optimiser_kwargs) -class SNES(BasePintsOptimiser, pints.SNES): +class SNES(BasePintsOptimiser): """ Implements the stochastic natural evolution strategy (SNES) optimization algorithm. @@ -181,17 +577,19 @@ class SNES(BasePintsOptimiser, pints.SNES): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.SNES : The PINTS implementation this class is based on. """ - def __init__(self, x0, sigma0=0.1, bounds=None): - BasePintsOptimiser.__init__(self, pints.SNES, x0, sigma0, bounds) + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): + super().__init__(pints.SNES, x0, sigma0, bounds, **optimiser_kwargs) -class XNES(BasePintsOptimiser, pints.XNES): +class XNES(BasePintsOptimiser): """ Implements the Exponential Natural Evolution Strategy (XNES) optimiser from PINTS. @@ -208,17 +606,19 @@ class XNES(BasePintsOptimiser, pints.XNES): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.XNES : PINTS implementation of XNES algorithm. """ - def __init__(self, x0, sigma0=0.1, bounds=None): - BasePintsOptimiser.__init__(self, pints.XNES, x0, sigma0, bounds) + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): + super().__init__(pints.XNES, x0, sigma0, bounds, **optimiser_kwargs) -class NelderMead(BasePintsOptimiser, pints.NelderMead): +class NelderMead(BasePintsOptimiser): """ Implements the Nelder-Mead downhill simplex method from PINTS. @@ -235,20 +635,22 @@ class NelderMead(BasePintsOptimiser, pints.NelderMead): Does not appear to be used. bounds : dict, optional Ignored by this optimiser, provided for API consistency. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.NelderMead : PINTS implementation of Nelder-Mead algorithm. """ - def __init__(self, x0, sigma0=0.1, bounds=None): + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): if bounds is not None: print("NOTE: Boundaries ignored by NelderMead") bounds = None # Bounds ignored in pints.NelderMead - BasePintsOptimiser.__init__(self, pints.NelderMead, x0, sigma0, bounds) + super().__init__(pints.NelderMead, x0, sigma0, bounds, **optimiser_kwargs) -class CMAES(BasePintsOptimiser, pints.CMAES): +class CMAES(BasePintsOptimiser): """ Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimiser in PINTS. @@ -265,16 +667,18 @@ class CMAES(BasePintsOptimiser, pints.CMAES): bounds : dict, optional A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. See Also -------- pints.CMAES : PINTS implementation of CMA-ES algorithm. """ - def __init__(self, x0, sigma0=0.1, bounds=None): + def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): if len(x0) == 1: raise ValueError( "CMAES requires optimisation of >= 2 parameters at once. " + "Please choose another optimiser." ) - BasePintsOptimiser.__init__(self, pints.CMAES, x0, sigma0, bounds) + super().__init__(pints.CMAES, x0, sigma0, bounds, **optimiser_kwargs) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index d497a13ba..a6a8e1724 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -14,7 +14,6 @@ bounds=None, strategy="best1bin", popsize=15, - maxiter=1000, tol=1e-5, options=dict(), ) @@ -26,49 +25,68 @@ class BaseSciPyOptimiser(BaseOptimiser): Parameters ---------- + x0 : array_like + Initial position from which optimisation will start. + sigma0 : float, optional + Initial step size or standard deviation depending on the optimiser. bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. **optimiser_kwargs : optional Valid SciPy option keys and their values. """ - def __init__(self, bounds=None, **optimiser_kwargs): - super().__init__(bounds) + def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): + super().__init__(x0, sigma0, bounds) self.update_options(**optimiser_kwargs) def update_options(self, **optimiser_kwargs): """ Update the optimiser options. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid SciPy option keys and their values. """ + # Use the first available value for maxiter and remove others + if "max_iterations" in optimiser_kwargs.keys(): + optimiser_kwargs["maxiter"] = optimiser_kwargs.pop("max_iterations") + if "options" in optimiser_kwargs.keys(): + if "maxiter" in optimiser_kwargs.keys(): + optimiser_kwargs["options"].pop("maxiter", None) + elif "maxiter" in optimiser_kwargs["options"].keys(): + optimiser_kwargs["maxiter"] = optimiser_kwargs["options"].pop("maxiter") + for key, value in optimiser_kwargs.items(): if key == "bounds": self.bounds = value + elif key == "maxiter": + self.set_max_iterations(value) else: self.options[key] = value - # Set optimiser_specific options if required - self._update_optimiser_options(**optimiser_kwargs) - - def _update_optimiser_options(self, **optimiser_kwargs): - """ - Update optimiser-specific options. This function should be implemented in - child classes if required. - """ - pass - - def name(self): + def _run(self, **optimiser_kwargs): """ - Provides the name of the optimisation strategy. + Internal method to run the optimization using a PyBOP optimiser. - Overwrites the method in Base Optimiser with the method from the PINTS class - and therefore requires the instance of self to be passed as an input. + Parameters + ---------- + **optimiser_kwargs : optional + Valid SciPy option keys and their values. Returns ------- - str - The name given by PINTS. + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. """ - return self._pints_class.name(self) + self.update_options(**optimiser_kwargs) + + result = self._run_optimiser() + self._iterations = result.nit + + return result.x, self._cost_function(result.x) class SciPyMinimize(BaseSciPyOptimiser): @@ -80,6 +98,10 @@ class SciPyMinimize(BaseSciPyOptimiser): Parameters ---------- + x0 : array_like + Initial position from which optimisation will start. + sigma0 : float, optional + Initial step size or standard deviation depending on the optimiser (default: None). bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. **optimiser_kwargs : optional @@ -93,29 +115,18 @@ class SciPyMinimize(BaseSciPyOptimiser): scipy.optimize.minimize : The SciPy method this class is based on. """ - def __init__(self, bounds=None, **optimiser_kwargs): + def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS - super().__init__(bounds, **optimiser_kwargs) + super().__init__(x0, sigma0, bounds, **optimiser_kwargs) - def _update_optimiser_options(self, **optimiser_kwargs): - """ - Update the optimiser-specific options. - """ - # Overwrite the value of maxiter in the options dictionary - if "maxiter" in optimiser_kwargs.keys(): - self.options["options"]["maxiter"] = self.options["maxiter"] - del self.options["maxiter"] - - def _runoptimise(self, cost_function, x0, **optimiser_kwargs): + def _run_optimiser(self): """ Executes the optimisation process using SciPy's minimize function. Parameters ---------- - cost_function : callable - The objective function to minimize. - x0 : array_like - Initial guess for the parameters. + **optimiser_kwargs : optional + Valid SciPy option keys and their values. Returns ------- @@ -123,23 +134,22 @@ def _runoptimise(self, cost_function, x0, **optimiser_kwargs): A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. """ - self.update_options(**optimiser_kwargs) - self.log = [[x0]] + self.log = [[self.x0]] # Add callback storing history of parameter values def callback(x): self.log.append([x]) # Scale the cost function and eliminate nan values - self.cost0 = cost_function(x0) + self._cost0 = self._cost_function(self.x0) self.inf_count = 0 - if np.isinf(self.cost0): + if np.isinf(self._cost0): raise Exception("The initial parameter values return an infinite cost.") if not self.options["jac"]: def cost_wrapper(x): - cost = cost_function(x) / self.cost0 + cost = self._cost_function(x) / self._cost0 if np.isinf(cost): self.inf_count += 1 cost = 1 + 0.9**self.inf_count # for fake finite gradient @@ -147,7 +157,7 @@ def cost_wrapper(x): elif self.options["jac"] is True: def cost_wrapper(x): - return cost_function.evaluateS1(x) + return self._cost_function.evaluateS1(x) else: raise ValueError( "Expected the jac option to be either True, False or None." @@ -162,9 +172,12 @@ def cost_wrapper(x): else: bounds = self.bounds + # Retrieve maximum iterations + self.options["options"]["maxiter"] = self._max_iterations + result = minimize( cost_wrapper, - x0, + self.x0, method=self.options["method"], jac=self.options["jac"], bounds=bounds, @@ -198,60 +211,55 @@ class SciPyDifferentialEvolution(BaseSciPyOptimiser): ---------- bounds : dict, sequence or scipy.optimize.Bounds Bounds for variables. Must be provided as it is essential for differential evolution. - strategy : str, optional - The differential evolution strategy to use. Defaults to 'best1bin'. - maxiter : int, optional - Maximum number of iterations to perform. Defaults to 1000. - popsize : int, optional - The number of individuals in the population. Defaults to 15. + **optimiser_kwargs : optional + Valid SciPy option keys and their values, such as: + strategy : str, optional + The differential evolution strategy to use. Defaults to 'best1bin'. + maxiter : int, optional + Maximum number of iterations to perform. Defaults to 1000. + popsize : int, optional + The number of individuals in the population. Defaults to 15. See Also -------- scipy.optimize.differential_evolution : The SciPy method this class is based on. """ - def __init__(self, bounds, **optimiser_kwargs): + def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): self.options = DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS - super().__init__(bounds, **optimiser_kwargs) + super().__init__(x0, sigma0, bounds, **optimiser_kwargs) - def _update_optimiser_options(self, **optimiser_kwargs): - """ - Update the optimiser-specific options. - """ - if self.bounds is None: - raise ValueError("Bounds must be specified for differential_evolution.") - - def _runoptimise(self, cost_function, x0=None, **optimiser_kwargs): + def _run_optimiser(self): """ Executes the optimization process using SciPy's differential_evolution function. Parameters ---------- - cost_function : callable - The objective function to minimize. - x0 : array_like, optional - Ignored parameter, provided for API consistency. + **optimiser_kwargs : optional + Valid SciPy Differential Evolution option keys and their values. Returns ------- tuple A tuple (x, final_cost) containing the optimized parameters and the value of - ``cost_function`` at the optimum. + the cost function at the optimum. """ - self.update_options(**optimiser_kwargs) self.log = [] - if x0 is not None: + if self.x0 is not None: print( "Ignoring x0. Initial conditions are not used for differential_evolution." ) + self.x0 = None # Add callback storing history of parameter values def callback(x, convergence): self.log.append([x]) # Reformat bounds - if isinstance(self.bounds, dict): + if self.bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") + elif isinstance(self.bounds, dict): if not all( np.isfinite(value) for sublist in self.bounds.values() @@ -267,8 +275,11 @@ def callback(x, convergence): raise ValueError("Bounds must be specified for differential_evolution.") bounds = self.bounds + # Retrieve maximum iterations + self.options["maxiter"] = self._max_iterations + result = differential_evolution( - cost_function, + self._cost_function, bounds, strategy=self.options["strategy"], maxiter=self.options["maxiter"], diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index 1f7d8b28c..e278618d7 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -111,33 +111,36 @@ def test_spm_optimisers(self, optimiser, spm_costs): parameterisation = pybop.Optimisation( cost=spm_costs, optimiser=optimiser, sigma0=0.05 ) - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=5e-4) - parameterisation.set_max_iterations(125) + if issubclass(optimiser, pybop.BasePintsOptimiser): + parameterisation.optimiser.set_max_unchanged_iterations( + iterations=35, threshold=5e-4 + ) + parameterisation.optimiser.set_max_iterations(125) initial_cost = parameterisation.cost(spm_costs.x0) if optimiser in [pybop.CMAES]: - parameterisation.set_f_guessed_tracking(True) + parameterisation.optimiser.set_f_guessed_tracking(True) parameterisation.cost.problem.model.allow_infeasible_solutions = False - assert parameterisation._use_f_guessed is True - parameterisation.set_max_iterations(1) + assert parameterisation.optimiser._use_f_guessed is True + parameterisation.optimiser.set_max_iterations(1) x, final_cost = parameterisation.run() - parameterisation.set_f_guessed_tracking(False) - parameterisation.set_max_iterations(125) + parameterisation.optimiser.set_f_guessed_tracking(False) + parameterisation.optimiser.set_max_iterations(125) x, final_cost = parameterisation.run() - assert parameterisation._max_iterations == 125 + assert parameterisation.optimiser._max_iterations == 125 elif optimiser in [pybop.GradientDescent]: if isinstance( spm_costs, (pybop.GaussianLogLikelihoodKnownSigma, pybop.MAP) ): parameterisation.optimiser.set_learning_rate(1.8e-5) - parameterisation.set_min_iterations(150) + parameterisation.optimiser.set_min_iterations(150) else: parameterisation.optimiser.set_learning_rate(0.02) - parameterisation.set_max_iterations(150) + parameterisation.optimiser.set_max_iterations(150) x, final_cost = parameterisation.run() elif optimiser in [pybop.SciPyDifferentialEvolution]: @@ -213,8 +216,11 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): parameterisation = pybop.Optimisation( cost=spm_two_signal_cost, optimiser=multi_optimiser, sigma0=0.03 ) - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=5e-4) - parameterisation.set_max_iterations(125) + if issubclass(multi_optimiser, pybop.BasePintsOptimiser): + parameterisation.optimiser.set_max_unchanged_iterations( + iterations=35, threshold=5e-4 + ) + parameterisation.optimiser.set_max_iterations(125) initial_cost = parameterisation.cost(spm_two_signal_cost.x0) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index b7f137849..03e344b92 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -95,16 +95,9 @@ def test_optimiser_classes(self, two_param_cost, optimiser_class, expected_name) # Test construction without bounds bounds = cost.bounds cost.bounds = None - if optimiser_class in [pybop.SciPyDifferentialEvolution]: - with pytest.raises( - ValueError, match="Bounds must be specified for differential_evolution." - ): - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - elif optimiser_class in [pybop.SciPyMinimize]: - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - assert optim.optimiser.bounds is None - else: - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + assert optim.optimiser.bounds is None + if issubclass(optimiser_class, pybop.BasePintsOptimiser): assert optim.optimiser._boundaries is None # Reset @@ -112,36 +105,42 @@ def test_optimiser_classes(self, two_param_cost, optimiser_class, expected_name) @pytest.mark.parametrize( "optimiser_class", - [pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], + [pybop.XNES, pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], ) @pytest.mark.unit - def test_scipy_kwargs(self, cost, optimiser_class): - optim = pybop.Optimisation( - cost=cost, optimiser=optimiser_class, maxiter=1, tol=1e-3 - ) - - # Check and update bounds - assert optim.optimiser.bounds == cost.bounds - bounds = {"upper": [0.61], "lower": [0.59]} - optim.run(bounds=bounds) - assert optim.optimiser.bounds == bounds - bounds = [ - (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) - ] - optim.run(bounds=bounds) - assert optim.optimiser.bounds == bounds - - # Check and update tol - assert optim.optimiser.options["tol"] == 1e-3 - optim.run(tol=1e-2) - assert optim.optimiser.options["tol"] == 1e-2 + def test_optimiser_kwargs(self, cost, optimiser_class): + optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class, maxiter=1) # Check and update maximum iterations - assert optim._max_iterations == 1 - assert optim._iterations == 1 - optim.run(maxiter=10) - assert optim._max_iterations == 10 - assert optim._iterations <= 10 + optim.run() + assert optim.optimiser._max_iterations == 1 + assert optim.optimiser._iterations == 1 + optim.run(max_iterations=10) + assert optim.optimiser._max_iterations == 10 + assert optim.optimiser._iterations <= 10 + + if issubclass(optimiser_class, pybop.BasePintsOptimiser): + # PINTS method + with pytest.raises( + ValueError, + match="Unrecognised or invalid keyword argument: tol", + ): + optim = pybop.Optimisation(cost=cost, tol=1e-3) + else: + # Check and update bounds + assert optim.optimiser.bounds == cost.bounds + bounds = {"upper": [0.61], "lower": [0.59]} + optim.run(bounds=bounds) + assert optim.optimiser.bounds == bounds + bounds = [ + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + ] + optim.run(bounds=bounds) + assert optim.optimiser.bounds == bounds + + # Update tol + optim.run(tol=1e-2) + assert optim.optimiser.options["tol"] == 1e-2 if optimiser_class in [pybop.SciPyDifferentialEvolution]: # Check and update population size @@ -149,7 +148,7 @@ def test_scipy_kwargs(self, cost, optimiser_class): assert optim.optimiser.options["popsize"] == 10 optim.run(popsize=5) assert optim.optimiser.options["popsize"] == 5 - else: + elif optimiser_class in [pybop.SciPyMinimize]: # Check a method that uses gradient information optim.run(method="L-BFGS-B", jac=True) with pytest.raises( @@ -159,12 +158,6 @@ def test_scipy_kwargs(self, cost, optimiser_class): optim.run(jac="Invalid string") optim.run(method="Nelder-Mead", jac=False) - with pytest.raises( - ValueError, - match="Additional keyword arguments cannot currently be passed to PINTS optimisers.", - ): - optim = pybop.Optimisation(cost=cost, maxiter=1, tol=1e-3) - @pytest.mark.unit def test_single_parameter(self, cost): # Test catch for optimisers that can only run with multiple parameters @@ -187,6 +180,10 @@ class RandomClass: with pytest.raises(ValueError): pybop.Optimisation(cost=cost, optimiser=RandomClass) + optim = pybop.Optimisation(cost=cost, optimiser=pybop.BaseOptimiser) + with pytest.raises(NotImplementedError): + optim.run() + @pytest.mark.unit def test_prior_sampling(self, cost): # Tests prior sampling @@ -199,26 +196,26 @@ def test_prior_sampling(self, cost): def test_halting(self, cost): # Test max evalutions optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) - optim.set_max_evaluations(1) + optim.optimiser.set_max_evaluations(1) x, __ = optim.run() - assert optim._iterations == 1 + assert optim.optimiser._iterations == 1 # Test max unchanged iterations optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) - optim.set_max_unchanged_iterations(1) - optim.set_min_iterations(1) + optim.optimiser.set_max_unchanged_iterations(1) + optim.optimiser.set_min_iterations(1) x, __ = optim.run() - assert optim._iterations == 2 + assert optim.optimiser._iterations == 2 # Test invalid values with pytest.raises(ValueError): - optim.set_max_evaluations(-1) + optim.optimiser.set_max_evaluations(-1) with pytest.raises(ValueError): - optim.set_min_iterations(-1) + optim.optimiser.set_min_iterations(-1) with pytest.raises(ValueError): - optim.set_max_unchanged_iterations(-1) + optim.optimiser.set_max_unchanged_iterations(-1) with pytest.raises(ValueError): - optim.set_max_unchanged_iterations(1, threshold=-1) + optim.optimiser.set_max_unchanged_iterations(1, threshold=-1) @pytest.mark.unit def test_infeasible_solutions(self, cost): @@ -227,9 +224,9 @@ def test_infeasible_solutions(self, cost): optim = pybop.Optimisation( cost=cost, optimiser=optimiser, allow_infeasible_solutions=False ) - optim.set_max_iterations(1) + optim.optimiser.set_max_iterations(1) optim.run() - assert optim._iterations == 1 + assert optim.optimiser._iterations == 1 @pytest.mark.unit def test_unphysical_result(self, cost): From 00d2815438451124e6010d056b076f7503f55185 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:28:11 +0100 Subject: [PATCH 11/85] Update notebooks with kwargs --- .../notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb | 5 +++-- examples/notebooks/equivalent_circuit_identification.ipynb | 3 +-- examples/notebooks/pouch_cell_identification.ipynb | 3 +-- examples/notebooks/spm_Adam.ipynb | 6 +++--- examples/notebooks/spm_CMAES.ipynb | 3 +-- examples/notebooks/spm_electrode_design.ipynb | 3 +-- examples/notebooks/spm_scipy_DifferentialEvolution.ipynb | 5 +++-- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb index 55b550208..67b9dadf2 100644 --- a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb +++ b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb @@ -1462,8 +1462,9 @@ } ], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.PSO)\n", - "optim.set_max_unchanged_iterations(iterations=55, threshold=1e-6)\n", + "optim = pybop.Optimisation(\n", + " cost, optimiser=pybop.PSO, max_unchanged_iterations=55, threshold=1e-6\n", + ")\n", "x, final_cost = optim.run()\n", "print(\"Initial parameters:\", cost.x0)\n", "print(\"Estimated parameters:\", x)" diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index 2d267cff2..b02dbc5e4 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -416,8 +416,7 @@ } ], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)\n", - "optim.set_max_iterations(300)\n", + "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=300)\n", "x, final_cost = optim.run()\n", "print(\"Initial parameters:\", cost.x0)\n", "print(\"Estimated parameters:\", x)" diff --git a/examples/notebooks/pouch_cell_identification.ipynb b/examples/notebooks/pouch_cell_identification.ipynb index 41fc4faf7..17d46189d 100644 --- a/examples/notebooks/pouch_cell_identification.ipynb +++ b/examples/notebooks/pouch_cell_identification.ipynb @@ -298,8 +298,7 @@ " model, parameters, dataset, additional_variables=additional_variables\n", ")\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)\n", - "optim.set_max_iterations(30)" + "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=30)" ] }, { diff --git a/examples/notebooks/spm_Adam.ipynb b/examples/notebooks/spm_Adam.ipynb index 7083c002e..57d1386af 100644 --- a/examples/notebooks/spm_Adam.ipynb +++ b/examples/notebooks/spm_Adam.ipynb @@ -339,9 +339,9 @@ "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.Adam)\n", - "optim.set_max_unchanged_iterations(40)\n", - "optim.set_max_iterations(150)" + "optim = pybop.Optimisation(\n", + " cost, optimiser=pybop.Adam, max_unchanged_iterations=40, max_iterations=150\n", + ")" ] }, { diff --git a/examples/notebooks/spm_CMAES.ipynb b/examples/notebooks/spm_CMAES.ipynb index 62d06f20c..20d3a7fdc 100644 --- a/examples/notebooks/spm_CMAES.ipynb +++ b/examples/notebooks/spm_CMAES.ipynb @@ -323,8 +323,7 @@ "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)\n", - "optim.set_max_iterations(100)" + "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100)" ] }, { diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index 5da8e2905..21f44e261 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -232,8 +232,7 @@ }, "outputs": [], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True)\n", - "optim.set_max_iterations(5)" + "optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True, max_iterations=5)" ] }, { diff --git a/examples/notebooks/spm_scipy_DifferentialEvolution.ipynb b/examples/notebooks/spm_scipy_DifferentialEvolution.ipynb index 6629dadf8..600acc8a6 100644 --- a/examples/notebooks/spm_scipy_DifferentialEvolution.ipynb +++ b/examples/notebooks/spm_scipy_DifferentialEvolution.ipynb @@ -323,8 +323,9 @@ "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.SciPyDifferentialEvolution)\n", - "optim.set_max_iterations(400)" + "optim = pybop.Optimisation(\n", + " cost, optimiser=pybop.SciPyDifferentialEvolution, max_iterations=400\n", + ")" ] }, { From 3d7d9b86e5350a53a7e7910485432fa33e9da267 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:32:31 +0100 Subject: [PATCH 12/85] Update scripts with kwargs --- examples/scripts/BPX_spm.py | 3 +-- examples/scripts/ecm_CMAES.py | 3 +-- examples/scripts/spm_CMAES.py | 3 +-- examples/scripts/spm_IRPropMin.py | 3 +-- examples/scripts/spm_MAP.py | 11 +++++++---- examples/scripts/spm_MLE.py | 11 +++++++---- examples/scripts/spm_NelderMead.py | 4 ++-- examples/scripts/spm_SNES.py | 3 +-- examples/scripts/spm_UKF.py | 2 +- examples/scripts/spm_XNES.py | 3 +-- examples/scripts/spm_adam.py | 4 ++-- examples/scripts/spm_descent.py | 7 +++++-- examples/scripts/spm_pso.py | 3 +-- examples/scripts/spme_max_energy.py | 3 +-- 14 files changed, 32 insertions(+), 31 deletions(-) diff --git a/examples/scripts/BPX_spm.py b/examples/scripts/BPX_spm.py index d77fdae0e..051e7bac5 100644 --- a/examples/scripts/BPX_spm.py +++ b/examples/scripts/BPX_spm.py @@ -43,8 +43,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 6ba31f999..adff74d95 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -75,8 +75,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 77495f6f4..25a2f58d5 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -42,8 +42,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 2e5e24498..2189f25d5 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -36,8 +36,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_MAP.py b/examples/scripts/spm_MAP.py index cc22c315c..53719cabc 100644 --- a/examples/scripts/spm_MAP.py +++ b/examples/scripts/spm_MAP.py @@ -45,10 +45,13 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) -optim.set_max_unchanged_iterations(20) -optim.set_min_iterations(20) -optim.set_max_iterations(100) +optim = pybop.Optimisation( + cost, + optimiser=pybop.CMAES, + max_unchanged_iterations=20, + min_iterations=20, + max_iterations=100, +) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_MLE.py b/examples/scripts/spm_MLE.py index 8b6c5a011..e4ce282ed 100644 --- a/examples/scripts/spm_MLE.py +++ b/examples/scripts/spm_MLE.py @@ -45,10 +45,13 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.03, 0.03]) -optim = pybop.Optimisation(likelihood, optimiser=pybop.CMAES) -optim.set_max_unchanged_iterations(20) -optim.set_min_iterations(20) -optim.set_max_iterations(100) +optim = pybop.Optimisation( + likelihood, + optimiser=pybop.CMAES, + max_unchanged_iterations=20, + min_iterations=20, + max_iterations=100, +) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_NelderMead.py b/examples/scripts/spm_NelderMead.py index 99abf5a8a..2c6f27971 100644 --- a/examples/scripts/spm_NelderMead.py +++ b/examples/scripts/spm_NelderMead.py @@ -60,9 +60,9 @@ def noise(sigma): verbose=True, allow_infeasible_solutions=True, sigma0=0.05, + max_iterations=100, + max_unchanged_iterations=45, ) -optim.set_max_iterations(100) -optim.set_max_unchanged_iterations(45) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index 650c6efd3..1fd8ebb86 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -36,8 +36,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.SNES) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.SNES, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_UKF.py b/examples/scripts/spm_UKF.py index 5299c5816..4774b80ce 100644 --- a/examples/scripts/spm_UKF.py +++ b/examples/scripts/spm_UKF.py @@ -61,7 +61,7 @@ # Parameter identification using the current observer implementation is very slow # so let's restrict the number of iterations and reduce the number of plots -optim.set_max_iterations(5) +optim.optimiser.set_max_iterations(5) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index a72e0d6c9..e2ac81d21 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -37,8 +37,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.XNES) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.XNES, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index e0796744c..641fdce7d 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -60,9 +60,9 @@ def noise(sigma): verbose=True, allow_infeasible_solutions=True, sigma0=0.05, + max_iterations=100, + max_unchanged_iterations=45, ) -optim.set_max_iterations(100) -optim.set_max_unchanged_iterations(45) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 3fe078689..6f38631b8 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -37,9 +37,12 @@ problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation( - cost, optimiser=pybop.GradientDescent, sigma0=0.022, verbose=True + cost, + optimiser=pybop.GradientDescent, + sigma0=0.022, + verbose=True, + max_iterations=125, ) -optim.set_max_iterations(125) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index f6a01b09f..816750b0a 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -37,8 +37,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.PSO) -optim.set_max_iterations(100) +optim = pybop.Optimisation(cost, optimiser=pybop.PSO, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index e4441c326..eaa0bb0d0 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -50,10 +50,9 @@ optim = pybop.Optimisation( cost, optimiser=pybop.PSO, verbose=True, allow_infeasible_solutions=False ) -optim.set_max_iterations(15) # Run optimisation -x, final_cost = optim.run() +x, final_cost = optim.run(max_iterations=15) print("Estimated parameters:", x) print(f"Initial gravimetric energy density: {-cost(cost.x0):.2f} Wh.kg-1") print(f"Optimised gravimetric energy density: {-final_cost:.2f} Wh.kg-1") From d91e7271d1b70dc0cee71d5abef42f3f5f99fb9c Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 19:56:52 +0100 Subject: [PATCH 13/85] Complete merge --- tests/integration/test_optimisation_options.py | 6 +++--- tests/unit/test_optimisation.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index e026aa40d..467f4c853 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -82,9 +82,9 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): parameterisation = pybop.Optimisation( cost=spm_costs, optimiser=pybop.XNES, sigma0=0.05 ) - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) - parameterisation.set_max_iterations(125) - parameterisation.set_f_guessed_tracking(f_guessed) + parameterisation.optimiser.set_max_unchanged_iterations(iterations=35, threshold=1e-5) + parameterisation.optimiser.set_max_iterations(125) + parameterisation.optimiser.set_f_guessed_tracking(f_guessed) initial_cost = parameterisation.cost(spm_costs.x0) x, final_cost = parameterisation.run() diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index a8a0f626e..c26cc7dff 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -210,8 +210,8 @@ def test_halting(self, cost): assert optim.optimiser._iterations == 2 # Test guessed values - optim.set_f_guessed_tracking(True) - assert optim._use_f_guessed is True + optim.optimiser.set_f_guessed_tracking(True) + assert optim.optimiser._use_f_guessed is True # Test invalid values with pytest.raises(ValueError): From 9125b6abfcecfd9354dd04c2a02816a82f1d27f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 18:57:22 +0000 Subject: [PATCH 14/85] style: pre-commit fixes --- tests/integration/test_optimisation_options.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 467f4c853..98d27c4a9 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -82,7 +82,9 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): parameterisation = pybop.Optimisation( cost=spm_costs, optimiser=pybop.XNES, sigma0=0.05 ) - parameterisation.optimiser.set_max_unchanged_iterations(iterations=35, threshold=1e-5) + parameterisation.optimiser.set_max_unchanged_iterations( + iterations=35, threshold=1e-5 + ) parameterisation.optimiser.set_max_iterations(125) parameterisation.optimiser.set_f_guessed_tracking(f_guessed) From ea8e9a0f07f1236aa87ff2f70c417436722b7648 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 20:15:53 +0100 Subject: [PATCH 15/85] Update notebooks --- examples/notebooks/multi_model_identification.ipynb | 4 ++-- .../notebooks/multi_optimiser_identification.ipynb | 12 ++++++------ examples/notebooks/optimiser_calibration.ipynb | 4 ++-- examples/notebooks/spm_electrode_design.ipynb | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index c5d03580f..1bb8d2dd8 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -6413,8 +6413,8 @@ " problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", " cost = pybop.SumSquaredError(problem)\n", " optim = pybop.Optimisation(cost, optimiser=pybop.XNES, verbose=True)\n", - " optim.set_max_iterations(60)\n", - " optim.set_max_unchanged_iterations(15)\n", + " optim.optimiser.set_max_iterations(60)\n", + " optim.optimiser.set_max_unchanged_iterations(15)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index 44e75747e..c3f8e757f 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -358,8 +358,8 @@ "for optimiser in gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.set_max_unchanged_iterations(15)\n", - " optim.set_max_iterations(60)\n", + " optim.optimiser.set_max_unchanged_iterations(15)\n", + " optim.optimiser.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -387,8 +387,8 @@ "for optimiser in non_gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.set_max_unchanged_iterations(15)\n", - " optim.set_max_iterations(60)\n", + " optim.optimiser.set_max_unchanged_iterations(15)\n", + " optim.optimiser.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -413,8 +413,8 @@ "for optimiser in scipy_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.set_max_unchanged_iterations(15)\n", - " optim.set_max_iterations(60)\n", + " optim.optimiser.set_max_unchanged_iterations(15)\n", + " optim.optimiser.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index f61fc1b2e..5b21e2a1a 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -282,7 +282,7 @@ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", "optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent)\n", - "optim.set_max_iterations(100)" + "optim.optimiser.set_max_iterations(100)" ] }, { @@ -453,7 +453,7 @@ " problem = pybop.FittingProblem(model, parameters, dataset)\n", " cost = pybop.SumSquaredError(problem)\n", " optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent, sigma0=sigma)\n", - " optim.set_max_iterations(100)\n", + " optim.optimiser.set_max_iterations(100)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index 127251b17..7b97b5119 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -233,7 +233,7 @@ "outputs": [], "source": [ "optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True)\n", - "optim.set_max_iterations(15)" + "optim.optimiser.set_max_iterations(15)" ] }, { From a8e60aa2144216af5d6b5cc74f407ef2f459bdf1 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:32:44 +0100 Subject: [PATCH 16/85] Align optimisers with Optimisation as base class --- CHANGELOG.md | 2 +- .../1-single-pulse-circuit-model.ipynb | 4 +- .../equivalent_circuit_identification.ipynb | 2 +- .../notebooks/pouch_cell_identification.ipynb | 2 +- examples/scripts/BPX_spm.py | 2 +- examples/scripts/ecm_CMAES.py | 2 +- examples/scripts/exp_UKF.py | 2 +- examples/scripts/spm_CMAES.py | 2 +- examples/scripts/spm_IRPropMin.py | 2 +- examples/scripts/spm_MAP.py | 3 +- examples/scripts/spm_MLE.py | 3 +- examples/scripts/spm_NelderMead.py | 3 +- examples/scripts/spm_SNES.py | 2 +- examples/scripts/spm_UKF.py | 4 +- examples/scripts/spm_XNES.py | 2 +- examples/scripts/spm_adam.py | 3 +- examples/scripts/spm_descent.py | 3 +- examples/scripts/spm_pso.py | 2 +- examples/scripts/spm_scipymin.py | 2 +- examples/scripts/spme_max_energy.py | 4 +- pybop/__init__.py | 10 +- pybop/_optimisation.py | 193 ++++-- pybop/optimisers/base_optimiser.py | 413 ++++++++++-- pybop/optimisers/pints_optimisers.py | 613 ++++-------------- pybop/optimisers/scipy_optimisers.py | 170 +++-- pybop/plotting/plot_convergence.py | 2 +- .../test_model_experiment_changes.py | 2 +- .../integration/test_optimisation_options.py | 8 +- tests/integration/test_parameterisations.py | 26 +- tests/unit/test_optimisation.py | 102 ++- tests/unit/test_plots.py | 2 +- tests/unit/test_standalone.py | 6 +- 32 files changed, 791 insertions(+), 807 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c1a3561..a2f04061d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ codesigned binaries and source distributions via `sigstore-python`. ## Bug Fixes -- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Allows passing of keyword arguments to optimisers and fixes setting of max_iterations. +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes to allow the passing of keyword arguments and fixes the setting of max_iterations. - [#299](https://github.com/pybop-team/PyBOP/pull/299) - Bugfix multiprocessing support for Linux, MacOS, Windows (WSL) and improves coverage. - [#270](https://github.com/pybop-team/PyBOP/pull/270) - Updates PR template. - [#91](https://github.com/pybop-team/PyBOP/issues/91) - Adds a check on the number of parameters for CMAES and makes XNES the default optimiser. diff --git a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb index 7c92604bb..3181d952a 100644 --- a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb +++ b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb @@ -1639,9 +1639,7 @@ } ], "source": [ - "optim = pybop.Optimisation(\n", - " cost, optimiser=pybop.PSO, max_unchanged_iterations=55, threshold=1e-6\n", - ")\n", + "optim = pybop.PSO(cost, max_unchanged_iterations=55, threshold=1e-6)\n", "x, final_cost = optim.run()\n", "print(\"Initial parameters:\", cost.x0)\n", "print(\"Estimated parameters:\", x)" diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index e398f8fdb..d2eff1bc4 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -417,7 +417,7 @@ } ], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=300)\n", + "optim = pybop.CMAES(cost, max_iterations=300)\n", "x, final_cost = optim.run()\n", "print(\"Initial parameters:\", cost.x0)\n", "print(\"Estimated parameters:\", x)" diff --git a/examples/notebooks/pouch_cell_identification.ipynb b/examples/notebooks/pouch_cell_identification.ipynb index 96eccc194..27b7a33f9 100644 --- a/examples/notebooks/pouch_cell_identification.ipynb +++ b/examples/notebooks/pouch_cell_identification.ipynb @@ -392,7 +392,7 @@ " model, parameters, dataset, additional_variables=additional_variables\n", ")\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=30)" + "optim = pybop.CMAES(cost, max_iterations=30)" ] }, { diff --git a/examples/scripts/BPX_spm.py b/examples/scripts/BPX_spm.py index 051e7bac5..0d1089359 100644 --- a/examples/scripts/BPX_spm.py +++ b/examples/scripts/BPX_spm.py @@ -43,7 +43,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) +optim = pybop.CMAES(cost, max_iterations=100) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index adff74d95..690f2375c 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -75,7 +75,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) +optim = pybop.CMAES(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/exp_UKF.py b/examples/scripts/exp_UKF.py index aa42bbf24..5a61436b6 100644 --- a/examples/scripts/exp_UKF.py +++ b/examples/scripts/exp_UKF.py @@ -97,7 +97,7 @@ # Generate problem, cost function, and optimisation class cost = pybop.ObserverCost(observer) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, verbose=True) +optim = pybop.CMAES(cost, verbose=True) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 25a2f58d5..53754e310 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -42,7 +42,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES, max_iterations=100) +optim = pybop.CMAES(cost, max_iterations=100) # Run the optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 2189f25d5..758a72878 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -36,7 +36,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin, max_iterations=100) +optim = pybop.IRPropMin(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_MAP.py b/examples/scripts/spm_MAP.py index 53719cabc..da16c68a4 100644 --- a/examples/scripts/spm_MAP.py +++ b/examples/scripts/spm_MAP.py @@ -45,9 +45,8 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma) -optim = pybop.Optimisation( +optim = pybop.CMAES( cost, - optimiser=pybop.CMAES, max_unchanged_iterations=20, min_iterations=20, max_iterations=100, diff --git a/examples/scripts/spm_MLE.py b/examples/scripts/spm_MLE.py index e4ce282ed..47caa5aaa 100644 --- a/examples/scripts/spm_MLE.py +++ b/examples/scripts/spm_MLE.py @@ -45,9 +45,8 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.03, 0.03]) -optim = pybop.Optimisation( +optim = pybop.CMAES( likelihood, - optimiser=pybop.CMAES, max_unchanged_iterations=20, min_iterations=20, max_iterations=100, diff --git a/examples/scripts/spm_NelderMead.py b/examples/scripts/spm_NelderMead.py index 2c6f27971..d289ef44b 100644 --- a/examples/scripts/spm_NelderMead.py +++ b/examples/scripts/spm_NelderMead.py @@ -54,9 +54,8 @@ def noise(sigma): model, parameters, dataset, signal=signal, init_soc=init_soc ) cost = pybop.RootMeanSquaredError(problem) -optim = pybop.Optimisation( +optim = pybop.NelderMead( cost, - optimiser=pybop.NelderMead, verbose=True, allow_infeasible_solutions=True, sigma0=0.05, diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index 1fd8ebb86..748d07379 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -36,7 +36,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.SNES, max_iterations=100) +optim = pybop.SNES(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_UKF.py b/examples/scripts/spm_UKF.py index 4774b80ce..0814a22c2 100644 --- a/examples/scripts/spm_UKF.py +++ b/examples/scripts/spm_UKF.py @@ -57,11 +57,11 @@ # Generate problem, cost function, and optimisation class cost = pybop.ObserverCost(observer) -optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True) +optim = pybop.PSO(cost, verbose=True) # Parameter identification using the current observer implementation is very slow # so let's restrict the number of iterations and reduce the number of plots -optim.optimiser.set_max_iterations(5) +optim.set_max_iterations(5) # Run optimisation x, final_cost = optim.run() diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index e2ac81d21..5119a4f72 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -37,7 +37,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.XNES, max_iterations=100) +optim = pybop.XNES(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index 196257284..6e283f17c 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -54,9 +54,8 @@ def noise(sigma): model, parameters, dataset, signal=signal, init_soc=init_soc ) cost = pybop.RootMeanSquaredError(problem) -optim = pybop.Optimisation( +optim = pybop.Adam( cost, - optimiser=pybop.Adam, verbose=True, allow_infeasible_solutions=True, sigma0=0.05, diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index fb9fada0c..fbbd58211 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -36,9 +36,8 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation( +optim = pybop.GradientDescent( cost, - optimiser=pybop.GradientDescent, sigma0=0.022, verbose=True, max_iterations=125, diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 816750b0a..2b0e17230 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -37,7 +37,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.PSO, max_iterations=100) +optim = pybop.PSO(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/scripts/spm_scipymin.py b/examples/scripts/spm_scipymin.py index 74bd72523..12a152349 100644 --- a/examples/scripts/spm_scipymin.py +++ b/examples/scripts/spm_scipymin.py @@ -38,7 +38,7 @@ cost = pybop.RootMeanSquaredError(problem) # Build the optimisation problem -optim = pybop.Optimisation(cost=cost, optimiser=pybop.SciPyMinimize) +optim = pybop.SciPyMinimize(cost) # Run the optimisation problem x, final_cost = optim.run() diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index eaa0bb0d0..30c771600 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -47,9 +47,7 @@ # Generate cost function and optimisation class: cost = pybop.GravimetricEnergyDensity(problem) -optim = pybop.Optimisation( - cost, optimiser=pybop.PSO, verbose=True, allow_infeasible_solutions=False -) +optim = pybop.PSO(cost, verbose=True, allow_infeasible_solutions=False) # Run optimisation x, final_cost = optim.run(max_iterations=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index 6677266dc..eea612ae4 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -93,22 +93,18 @@ # from ._experiment import Experiment -# -# Main optimisation class -# -from ._optimisation import Optimisation - # # Optimiser class # -from .optimisers.base_optimiser import BaseOptimiser +from ._optimisation import Optimisation +from .optimisers.base_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, SciPyMinimize, SciPyDifferentialEvolution ) from .optimisers.pints_optimisers import ( - BasePintsOptimiser, + DefaultOptimiser, GradientDescent, Adam, CMAES, diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index ddbed55a6..37e232121 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -2,17 +2,30 @@ import pybop +DEFAULT_OPTIMISER_OPTIONS = dict( + x0=None, + bounds=None, + sigma0=0.1, + verbose=False, + physical_viability=True, + allow_infeasible_solutions=True, + _max_iterations=None, +) + class Optimisation: """ - A class for conducting optimisation using PyBOP or PINTS optimisers. + A base class for defining optimisation methods. + + This class serves as a template 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 update_options 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 : pybop.Optimiser or subclass of pybop.BaseOptimiser, optional - An optimiser from either the PINTS or PyBOP framework to perform the optimisation (default: None). sigma0 : float or sequence, optional Initial step size or standard deviation for the optimiser (default: None). verbose : bool, optional @@ -39,58 +52,83 @@ class Optimisation: def __init__( self, cost, - x0=None, - optimiser=None, - sigma0=None, - verbose=False, - physical_viability=True, - allow_infeasible_solutions=True, **optimiser_kwargs, ): self.cost = cost - self.x0 = x0 or cost.x0 - self.optimiser = optimiser or pybop.XNES - self.verbose = verbose - self.bounds = cost.bounds - self.sigma0 = sigma0 or cost.sigma0 - self._n_parameters = cost._n_parameters - self.physical_viability = physical_viability - self.allow_infeasible_solutions = allow_infeasible_solutions - - # Check optimiser - if not issubclass(self.optimiser, pybop.BaseOptimiser): - raise ValueError("Unknown optimiser type") + self.__dict__.update(DEFAULT_OPTIMISER_OPTIONS) + if isinstance(cost, pybop.BaseCost): + self.x0 = cost.x0 + self.bounds = cost.bounds + self.sigma0 = cost.sigma0 + self._n_parameters = cost._n_parameters + self.log = [] + self.set_max_iterations() + self.set_allow_infeasible_solutions() + self.update_options(**optimiser_kwargs) - # Set whether to allow infeasible locations - if self.cost.problem is not None and hasattr(self.cost.problem, "_model"): - self.cost.problem._model.allow_infeasible_solutions = ( - self.allow_infeasible_solutions + # Check if minimising or maximising + if isinstance(self.cost, pybop.BaseLikelihood): + self.cost._minimising = False + self._minimising = self.cost._minimising + + def update_options(self, **optimiser_kwargs): + """ + Update the optimiser options and check that all have been applied. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid option keys and their values. + """ + # Update and remove optimiser options from the optimiser_kwargs dictionary + # in the child class first and then in this base class + optimiser_kwargs = self._update_options(**optimiser_kwargs) + + key_list = list(optimiser_kwargs.keys()) + for key in key_list: + if key in ["x0", "bounds", "sigma0", "verbose"]: + self.__dict__.update({key: optimiser_kwargs.pop(key)}) + elif key == "allow_infeasible_solutions": + self.allow_infeasible_solutions = self.set_allow_infeasible_solutions( + optimiser_kwargs.pop(key) + ) + elif key == "parallel": + self.set_parallel(optimiser_kwargs.pop(key)) + + # Throw an error if any arguments remain + if optimiser_kwargs: + raise ValueError( + f"Unrecognised keyword arguments were not used: {optimiser_kwargs}" ) - else: - # Turn off this feature as there is no model - self.physical_viability = False - self.allow_infeasible_solutions = False - # Construct Optimiser - self.optimiser = self.optimiser( - self.x0, self.sigma0, self.bounds, **optimiser_kwargs - ) + def _update_options(self, **optimiser_kwargs): + """ + Update the optimiser options and remove the corresponding entries from the + optimiser_kwargs dictionary in advance of passing to the parent class + - this function is to be overwritten by child classes. - # Pass cost and settings to optimiser - self.optimiser._cost_function = self.cost - self.optimiser.verbose = self.verbose + Parameters + ---------- + **optimiser_kwargs : optional + Valid option keys and their values. - # Check if minimising or maximising - if isinstance(self.cost, pybop.BaseLikelihood): - self.cost._minimising = False - self.optimiser._minimising = self.cost._minimising + Returns + ------- + optimiser_kwargs : dict + Remaining option keys and their values. + """ + return optimiser_kwargs - def run(self, **optimiser_kwargs): + def run(self, x0=None, **optimiser_kwargs): """ Run the optimisation and return the optimised parameters and final cost. + Parameters + ---------- + x0 : ndarray, optional + Initial guess for the parameters (default: None). **optimiser_kwargs : optional - Valid SciPy option keys and their values. + Valid option keys and their values. Returns ------- @@ -99,15 +137,19 @@ def run(self, **optimiser_kwargs): final_cost : float The final cost associated with the best parameters. """ + self.update_options(**optimiser_kwargs) + + if x0 is not None: + self.x0 = x0 - x, final_cost = self.optimiser.run(**optimiser_kwargs) + x, final_cost = self._run() # Store the optimised parameters if self.cost.problem is not None: self.store_optimised_parameters(x) # Store the log - self.log = self.optimiser.log + self.log = self.log # Check if parameters are viable if self.physical_viability: @@ -115,6 +157,19 @@ def run(self, **optimiser_kwargs): return x, final_cost + 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 + def store_optimised_parameters(self, x): """ Update the problem parameters with optimised values. @@ -145,3 +200,53 @@ def check_optimal_parameters(self, x): UserWarning, stacklevel=2, ) + + 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" + + def set_max_iterations(self, iterations=1000): + """ + Set the maximum number of iterations as a stopping criterion. + Credit: PINTS + + Parameters + ---------- + iterations : int, optional + The maximum number of iterations to run. + Set to `None` to remove this stopping criterion. + """ + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Maximum number of iterations cannot be negative.") + self._max_iterations = iterations + + 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 self.cost.problem is not None 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 diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index dc7d1d45e..df4bdda37 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -1,111 +1,402 @@ -class BaseOptimiser: +import numpy as np +import pints + +from pybop import Optimisation + +DEFAULT_PINTS_OPTIMISER_OPTIONS = dict( + _boundaries=None, + _x0=None, + _transformation=None, # PyBOP doesn't currently support the PINTS transformation class + _use_f_guessed=None, + _parallel=False, + _n_workers=1, + _callback=None, + _min_iterations=2, + _unchanged_threshold=1e-5, # smallest significant f change + _unchanged_max_iterations=15, + _max_evaluations=None, + _threshold=None, + _evaluations=None, + _iterations=None, +) + + +class BasePintsOptimiser(Optimisation): """ - A base class for defining optimisation methods. + A base class for defining optimisation methods from the PINTS library. - This class serves as a template 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 update_options and the_run method with - a specific algorithm. + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size or standard deviation depending on the optimiser (default is 0.1). + bounds : dict, optional + A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper + bounds on the parameters. + **optimiser_kwargs : optional + Valid PINTS option keys and their values. """ - def __init__(self, x0=None, sigma0=None, bounds=None): - """ - Initialises the BaseOptimiser. + def __init__(self, cost, pints_method, **optimiser_kwargs): + self.__dict__.update(DEFAULT_PINTS_OPTIMISER_OPTIONS) + super().__init__(cost, **optimiser_kwargs) - Parameters - ---------- - x0 : array_like - Initial position from which optimisation will start. - sigma0 : float, optional - Initial step size or standard deviation depending on the optimiser. - bounds : sequence or Bounds, optional - Bounds on the parameters (default: None). - """ - self._cost_function = None - self.x0 = x0 - self.sigma0 = sigma0 - self.bounds = bounds + # Create an instance of the PINTS optimiser class + self.method = pints_method(self.x0, self.sigma0, self._boundaries) - self.log = [] - self._max_iterations = None - self.set_max_iterations() + # Check if sensitivities are required + self._needs_sensitivities = self.method.needs_sensitivities() - def update_options(self, **optimiser_kwargs): + def _update_options(self, **optimiser_kwargs): """ - Update the optimiser options, to be overwritten by child classes. + Update the optimiser options and remove the corresponding entries from the + optimiser_kwargs dictionary in advance of passing to the parent class. Parameters ---------- **optimiser_kwargs : optional - Valid option keys and their values. + Valid PINTS option keys and their values. + + Returns + ------- + optimiser_kwargs : dict + Remaining option keys and their values. """ - pass + key_list = list(optimiser_kwargs.keys()) + for key in key_list: + if key == "x0": + self.x0 = optimiser_kwargs.pop(key) + # Convert x0 to PINTS vector + self._x0 = pints.vector(self.x0) + elif key == "bounds": + # Convert bounds to PINTS boundaries + self.bounds = optimiser_kwargs.pop(key) + if self.bounds is not None: + self._boundaries = pints.RectangularBoundaries( + self.bounds["lower"], self.bounds["upper"] + ) + else: + self._boundaries = None + elif key == "use_f_guessed": + self.set_f_guessed_tracking(optimiser_kwargs.pop(key)) + elif key == "parallel": + self.set_parallel(optimiser_kwargs.pop(key)) + elif key == "maxiter" or key == "max_iterations": + self.set_max_iterations(optimiser_kwargs.pop(key)) + elif key == "min_iterations": + self.set_min_iterations(optimiser_kwargs.pop(key)) + elif key == "max_unchanged_iterations": + if "threshold" in optimiser_kwargs.keys(): + self.set_max_unchanged_iterations( + optimiser_kwargs.pop(key), + optimiser_kwargs["threshold"], + ) + else: + self.set_max_unchanged_iterations(optimiser_kwargs.pop(key)) + elif key == "threshold": + pass # only used with unchanged_max_iterations + elif key == "max_evaluations": + self.set_max_evaluations(optimiser_kwargs.pop(key)) + + return optimiser_kwargs def name(self): """ - Returns the name of the optimiser, to be overwritten by child classes. + Provides the name of the optimisation strategy. Returns ------- str - The name of the optimiser, which is "BaseOptimiser" for this base class. + The name given by PINTS. """ - return "BaseOptimiser" + return self.method.name() - def run(self, x0=None, **optimiser_kwargs): + def _run(self): """ - Initiates the optimisation process. - - Parameters - ---------- - x0 : ndarray, optional - Initial guess for the parameters (default: None). - **optimiser_kwargs : optional - Valid option keys and their values. + Internal method to run the optimization using a PINTS optimiser. Returns ------- - The result of the optimisation process. The specific type of this result will depend on the child implementation. + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. + + See Also + -------- + This method is heavily based on the run method in the PINTS.OptimisationController class. + """ + # 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: + raise ValueError("At least one stopping criterion must be set.") + + # Iterations and function evaluations + iteration = 0 + evaluations = 0 + + # Unchanged iterations counter + unchanged_iterations = 0 + + # Choose method to evaluate + f = self.cost + if self._needs_sensitivities: + f = f.evaluateS1 + + # Create evaluator object + if self._parallel: + # Get number of workers + n_workers = self._n_workers + + # For population based optimisers, don't use more workers than + # particles! + if isinstance(self.method, pints.PopulationBasedOptimiser): + n_workers = min(n_workers, self.method.population_size()) + evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) + else: + evaluator = pints.SequentialEvaluator(f) + + # Keep track of current best and best-guess scores. + fb = fg = np.inf + + # Internally we always minimise! Keep a 2nd value to show the user. + fg_user = (fb, fg) if self._minimising else (-fb, -fg) + + # Keep track of the last significant change + f_sig = np.inf + + # Run the ask-and-tell loop + running = True + try: + while running: + # Ask optimiser for new points + xs = self.method.ask() + + # Evaluate points + fs = evaluator.evaluate(xs) + + # Tell optimiser about function values + self.method.tell(fs) + + # Update the scores + fb = self.method.f_best() + fg = self.method.f_guessed() + 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 + + # Update counts + evaluations += len(fs) + iteration += 1 + self.log.append(xs) + + # 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 + and iteration >= self._min_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) + + "." + ) + + # Error in optimiser + error = self.method.stop() + if error: + running = False + halt_message = str(error) + + elif self._callback is not None: + self._callback(iteration - 1, self) + + except (Exception, SystemExit, KeyboardInterrupt): + # Show last result and exit + print("\n" + "-" * 40) + print("Unexpected termination.") + print("Current score: " + str(fg_user)) + print("Current position:") + + # Show current parameters + x_user = self.method.x_guessed() + if self._transformation is not None: + x_user = self._transformation.to_model(x_user) + for p in x_user: + print(pints.strfloat(p)) + print("-" * 40) + raise + + if self.verbose: + print("Halt: " + halt_message) + + # Save post-run statistics + self._evaluations = evaluations + self._iterations = iteration + + # Get best parameters + if self._use_f_guessed: + x = self.method.x_guessed() + f = self.method.f_guessed() + else: + x = self.method.x_best() + f = self.method.f_best() + + # Inverse transform search parameters + if self._transformation is not None: + x = self._transformation.to_model(x) + + # Return best position and the score used internally, + # i.e the negative log-likelihood in the case of + # self._minimising = False + return x, f + + def f_guessed_tracking(self): """ - if x0 is not None: - self.x0 = x0 + Check if f_guessed instead of f_best is being tracked. + Credit: PINTS - # Run optimisation - result = self._run(**optimiser_kwargs) + Returns + ------- + bool + True if f_guessed is being tracked, False otherwise. + """ + return self._use_f_guessed - return result + def set_f_guessed_tracking(self, use_f_guessed=False): + """ + Set the method used to track the optimiser progress. + Credit: PINTS - def _run(self, **optimiser_kwargs): + Parameters + ---------- + use_f_guessed : bool, optional + If True, track f_guessed; otherwise, track f_best (default: False). """ - Contains the logic for the optimisation algorithm. + self._use_f_guessed = bool(use_f_guessed) - This method should be implemented by child classes to perform the actual optimisation. + def set_parallel(self, parallel=False): + """ + Enable or disable parallel evaluation. + Credit: PINTS Parameters ---------- - **optimiser_kwargs : optional - Valid option keys and their values. + parallel : bool or int, optional + If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. + If False or 0, disable parallelism (default: False). + """ + if parallel is True: + self._parallel = True + self._n_workers = pints.ParallelEvaluator.cpu_count() + elif parallel >= 1: + self._parallel = True + self._n_workers = int(parallel) + else: + self._parallel = False + self._n_workers = 1 + + def set_min_iterations(self, iterations=2): + """ + Set the minimum number of iterations as a stopping criterion. - Raises - ------ - NotImplementedError - If the method has not been implemented by the subclass. + Parameters + ---------- + iterations : int, optional + The minimum number of iterations to run (default: 2). + Set to `None` to remove this stopping criterion. """ - raise NotImplementedError + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Minimum number of iterations cannot be negative.") + self._min_iterations = iterations - def set_max_iterations(self, iterations=1000): + def set_max_unchanged_iterations(self, iterations=15, threshold=1e-5): """ - Set the maximum number of iterations as a stopping criterion. + Set the maximum number of iterations without significant change as a stopping criterion. Credit: PINTS Parameters ---------- iterations : int, optional - The maximum number of iterations to run (default: 1000). + The maximum number of unchanged iterations to run (default: 15). Set to `None` to remove this stopping criterion. + threshold : float, optional + The minimum significant change in the objective function value that resets the + unchanged iteration counter (default: 1e-5). """ if iterations is not None: iterations = int(iterations) if iterations < 0: raise ValueError("Maximum number of iterations cannot be negative.") - self._max_iterations = iterations + + threshold = float(threshold) + if threshold < 0: + raise ValueError("Minimum significant change cannot be negative.") + + self._unchanged_max_iterations = iterations + self._unchanged_threshold = threshold + + def set_max_evaluations(self, evaluations=None): + """ + Set a maximum number of evaluations stopping criterion. + Credit: PINTS + + Parameters + ---------- + evaluations : int, optional + The maximum number of evaluations after which to stop the optimisation + (default: None). + """ + if evaluations is not None: + evaluations = int(evaluations) + if evaluations < 0: + raise ValueError("Maximum number of evaluations cannot be negative.") + self._max_evaluations = evaluations diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index ad27939da..b50d1ddfc 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -1,425 +1,37 @@ import numpy as np import pints -from .base_optimiser import BaseOptimiser +from pybop import BasePintsOptimiser -class BasePintsOptimiser(BaseOptimiser): +class DefaultOptimiser(BasePintsOptimiser): """ - A base class for defining optimisation methods from the PINTS library. + Provides a default option for new users, selected to be the Exponential Natural + Evolution Strategy (XNES) optimiser from PINTS. + + XNES is an evolutionary algorithm that samples from a multivariate normal + distribution, which is updated iteratively to fit the distribution of successful + solutions. Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial step size or standard deviation depending on the optimiser (default is 0.1). - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. **optimiser_kwargs : optional - Valid PINTS option keys and their values. - """ - - def __init__( - self, pints_class, x0=None, sigma0=None, bounds=None, **optimiser_kwargs - ): - super().__init__(x0, sigma0, bounds) - - # Convert bounds to PINTS boundaries - if bounds is not None: - boundaries = pints.RectangularBoundaries(bounds["lower"], bounds["upper"]) - else: - boundaries = None - self._boundaries = boundaries - - # Convert x0 to PINTS vector - self._x0 = pints.vector(self.x0) - - # PyBOP doesn't currently support the PINTS transformation class - self._transformation = None - - # Create an instance of the PINTS optimiser class - self.pints_optimiser = pints_class(x0, sigma0, self._boundaries) - - # Check if sensitivities are required - self._needs_sensitivities = self.pints_optimiser.needs_sensitivities() - - # Track optimiser's f_best or f_guessed - self._use_f_guessed = None - self.set_f_guessed_tracking() - - # Parallelisation - self._parallel = False - self._n_workers = 1 - self.set_parallel() - - # User callback - self._callback = None - - # Define stopping criteria - # Maximum iterations set in BaseOptimiser - - # Minimum iterations - self._min_iterations = None - self.set_min_iterations() - - # Maximum unchanged iterations - self._unchanged_threshold = 1 # smallest significant f change - self._unchanged_max_iterations = None - self.set_max_unchanged_iterations() - - # Maximum evaluations - self._max_evaluations = None - - # Threshold value - self._threshold = None - - # Post-run statistics - self._evaluations = None - self._iterations = None - - self.update_options(**optimiser_kwargs) - - def update_options(self, **optimiser_kwargs): - """ - Update the optimiser options. - - Parameters - ---------- - **optimiser_kwargs : optional - Valid PINTS option keys and their values. - """ - for key, value in optimiser_kwargs.items(): - if key == "use_f_guessed": - self.set_f_guessed_tracking(value) - elif key == "parallel": - self.set_parallel(value) - elif key == "maxiter" or key == "max_iterations": - self.set_max_iterations(value) - elif key == "min_iterations": - self.set_min_iterations(value) - elif key == "max_unchanged_iterations": - if "threshold" in optimiser_kwargs.keys(): - self.set_max_unchanged_iterations( - value, - optimiser_kwargs["threshold"], - ) - else: - self.set_max_unchanged_iterations(value) - elif key == "threshold": - pass # only used with unchanged_max_iterations - elif key == "max_evaluations": - self.set_max_evaluations(value) - else: - raise ValueError(f"Unrecognised or invalid keyword argument: {key}") - - def name(self): - """ - Provides the name of the optimisation strategy. - - Returns - ------- - str - The name given by PINTS. - """ - return self.pints_optimiser.name() - - def _run(self, **optimiser_kwargs): - """ - Internal method to run the optimization using a PINTS optimiser. - - Parameters - ---------- - **optimiser_kwargs : optional - Valid PINTS option keys and their values. - - Returns - ------- - x : numpy.ndarray - The best parameter set found by the optimization. - final_cost : float - The final cost associated with the best parameters. - - See Also - -------- - This method is heavily based on the run method in the PINTS.OptimisationController class. - """ - self.update_options(**optimiser_kwargs) - - # 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: - raise ValueError("At least one stopping criterion must be set.") - - # Iterations and function evaluations - iteration = 0 - evaluations = 0 - - # Unchanged iterations counter - unchanged_iterations = 0 - - # Choose method to evaluate - f = self._cost_function - if self._needs_sensitivities: - f = f.evaluateS1 - - # Create evaluator object - if self._parallel: - # Get number of workers - n_workers = self._n_workers - - # For population based optimisers, don't use more workers than - # particles! - if isinstance(self.pints_optimiser, pints.PopulationBasedOptimiser): - n_workers = min(n_workers, self.pints_optimiser.population_size()) - evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) - else: - evaluator = pints.SequentialEvaluator(f) - - # Keep track of current best and best-guess scores. - fb = fg = np.inf - - # Internally we always minimise! Keep a 2nd value to show the user. - fg_user = (fb, fg) if self._minimising else (-fb, -fg) - - # Keep track of the last significant change - f_sig = np.inf - - # Run the ask-and-tell loop - running = True - try: - while running: - # Ask optimiser for new points - xs = self.pints_optimiser.ask() - - # Evaluate points - fs = evaluator.evaluate(xs) - - # Tell optimiser about function values - self.pints_optimiser.tell(fs) - - # Update the scores - fb = self.pints_optimiser.f_best() - fg = self.pints_optimiser.f_guessed() - 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 - - # Update counts - evaluations += len(fs) - iteration += 1 - self.log.append(xs) - - # 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 - and iteration >= self._min_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) - + "." - ) - - # Error in optimiser - error = self.pints_optimiser.stop() - if error: - running = False - halt_message = str(error) - - elif self._callback is not None: - self._callback(iteration - 1, self) - - except (Exception, SystemExit, KeyboardInterrupt): - # Show last result and exit - print("\n" + "-" * 40) - print("Unexpected termination.") - print("Current score: " + str(fg_user)) - print("Current position:") - - # Show current parameters - x_user = self.pints_optimiser.x_guessed() - if self._transformation is not None: - x_user = self._transformation.to_model(x_user) - for p in x_user: - print(pints.strfloat(p)) - print("-" * 40) - raise - - if self.verbose: - print("Halt: " + halt_message) - - # Save post-run statistics - self._evaluations = evaluations - self._iterations = iteration - - # Get best parameters - if self._use_f_guessed: - x = self.pints_optimiser.x_guessed() - f = self.pints_optimiser.f_guessed() - else: - x = self.pints_optimiser.x_best() - f = self.pints_optimiser.f_best() - - # Inverse transform search parameters - if self._transformation is not None: - x = self._transformation.to_model(x) - - # Return best position and the score used internally, - # i.e the negative log-likelihood in the case of - # self._minimising = False - return x, f - - def f_guessed_tracking(self): - """ - Check if f_guessed instead of f_best is being tracked. - Credit: PINTS - - Returns - ------- - bool - True if f_guessed is being tracked, False otherwise. - """ - return self._use_f_guessed + Valid PINTS option keys and their values, for example: + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float + Initial standard deviation of the sampling distribution. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. If ``None``, no bounds are enforced. - def set_f_guessed_tracking(self, use_f_guessed=False): - """ - Set the method used to track the optimiser progress. - Credit: PINTS - - Parameters - ---------- - use_f_guessed : bool, optional - If True, track f_guessed; otherwise, track f_best (default: False). - """ - self._use_f_guessed = bool(use_f_guessed) - - def set_parallel(self, parallel=False): - """ - Enable or disable parallel evaluation. - Credit: PINTS - - Parameters - ---------- - parallel : bool or int, optional - If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. - If False or 0, disable parallelism (default: False). - """ - if parallel is True: - self._parallel = True - self._n_workers = pints.ParallelEvaluator.cpu_count() - elif parallel >= 1: - self._parallel = True - self._n_workers = int(parallel) - else: - self._parallel = False - self._n_workers = 1 - - def set_min_iterations(self, iterations=2): - """ - Set the minimum number of iterations as a stopping criterion. - - Parameters - ---------- - iterations : int, optional - The minimum number of iterations to run (default: 2). - Set to `None` to remove this stopping criterion. - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Minimum number of iterations cannot be negative.") - self._min_iterations = iterations - - def set_max_unchanged_iterations(self, iterations=15, threshold=1e-5): - """ - Set the maximum number of iterations without significant change as a stopping criterion. - Credit: PINTS - - Parameters - ---------- - iterations : int, optional - The maximum number of unchanged iterations to run (default: 15). - Set to `None` to remove this stopping criterion. - threshold : float, optional - The minimum significant change in the objective function value that resets the - unchanged iteration counter (default: 1e-5). - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Maximum number of iterations cannot be negative.") - - threshold = float(threshold) - if threshold < 0: - raise ValueError("Minimum significant change cannot be negative.") - - self._unchanged_max_iterations = iterations - self._unchanged_threshold = threshold - - def set_max_evaluations(self, evaluations=None): - """ - Set a maximum number of evaluations stopping criterion. - Credit: PINTS + See Also + -------- + pybop.XNES : PINTS implementation of XNES algorithm. + """ - Parameters - ---------- - evaluations : int, optional - The maximum number of evaluations after which to stop the optimisation - (default: None). - """ - if evaluations is not None: - evaluations = int(evaluations) - if evaluations < 0: - raise ValueError("Maximum number of evaluations cannot be negative.") - self._max_evaluations = evaluations + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, pints.XNES, **optimiser_kwargs) class GradientDescent(BasePintsOptimiser): @@ -432,25 +44,27 @@ class GradientDescent(BasePintsOptimiser): Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial step size (default is 0.1). - bounds : dict, optional - Ignored by this optimiser, provided for API consistency. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial position from which optimization will start. + sigma0 : float + Initial step size. + learning_rate : float + The learning rate, as a float greater than zero. See Also -------- pints.GradientDescent : The PINTS implementation this class is based on. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - if bounds is not None: + def __init__(self, cost, **optimiser_kwargs): + if optimiser_kwargs.pop("bounds", None) is not None: print("NOTE: Boundaries ignored by Gradient Descent") bounds = None # Bounds ignored in pints.GradientDescent - super().__init__(pints.GradientDescent, x0, sigma0, bounds, **optimiser_kwargs) + if optimiser_kwargs.pop("learning_rate", None) is not None: + self.set_learning_rate(optimiser_kwargs["learning_rate"]) + super().__init__(cost, pints.GradientDescent, bounds=bounds, **optimiser_kwargs) def set_learning_rate(self, eta): """ @@ -462,7 +76,7 @@ def set_learning_rate(self, eta): eta : float The learning rate, as a float greater than zero. """ - self.pints_optimiser.set_learning_rate(eta) + self.method.set_learning_rate(eta) class Adam(BasePintsOptimiser): @@ -475,25 +89,23 @@ class Adam(BasePintsOptimiser): Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial step size (default is 0.1). - bounds : dict, optional - Ignored by this optimiser, provided for API consistency. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial position from which optimization will start. + sigma0 : float + Initial step size. See Also -------- pints.Adam : The PINTS implementation this class is based on. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - if bounds is not None: + def __init__(self, cost, **optimiser_kwargs): + if optimiser_kwargs.pop("bounds", None) is not None: print("NOTE: Boundaries ignored by Adam") bounds = None # Bounds ignored in pints.Adam - super().__init__(pints.Adam, x0, sigma0, bounds, **optimiser_kwargs) + super().__init__(cost, pints.Adam, bounds=bounds, **optimiser_kwargs) class IRPropMin(BasePintsOptimiser): @@ -506,23 +118,23 @@ class IRPropMin(BasePintsOptimiser): Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial step size (default is 0.1). - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial position from which optimization will start. + 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 -------- pints.IRPropMin : The PINTS implementation this class is based on. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - super().__init__(pints.IRPropMin, x0, sigma0, bounds, **optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, pints.IRPropMin, **optimiser_kwargs) class PSO(BasePintsOptimiser): @@ -535,29 +147,35 @@ class PSO(BasePintsOptimiser): Parameters ---------- - x0 : array_like - Initial positions of particles, which the optimization will use. - sigma0 : float, optional - Spread of the initial particle positions (default is 0.1). - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial positions of particles, which the optimization will use. + sigma0 : float + Spread of the initial particle positions. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. See Also -------- pints.PSO : The PINTS implementation this class is based on. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - if bounds is not None and not all( - np.isfinite(value) for sublist in bounds.values() for value in sublist + def __init__(self, cost, **optimiser_kwargs): + if ( + "bounds" in optimiser_kwargs.keys() + and optimiser_kwargs["bounds"] is not None ): - raise ValueError( - "Either all bounds or no bounds must be set for Pints PSO." - ) - super().__init__(pints.PSO, x0, sigma0, bounds, **optimiser_kwargs) + if not all( + np.isfinite(value) + for sublist in optimiser_kwargs["bounds"].values() + for value in sublist + ): + raise ValueError( + "Either all bounds or no bounds must be set for Pints PSO." + ) + super().__init__(cost, pints.PSO, **optimiser_kwargs) class SNES(BasePintsOptimiser): @@ -570,23 +188,23 @@ class SNES(BasePintsOptimiser): Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial standard deviation of the sampling distribution, defaults to 0.1. - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial position from which optimization will start. + sigma0 : float + Initial standard deviation of the sampling distribution. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. See Also -------- pints.SNES : The PINTS implementation this class is based on. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - super().__init__(pints.SNES, x0, sigma0, bounds, **optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, pints.SNES, **optimiser_kwargs) class XNES(BasePintsOptimiser): @@ -599,23 +217,23 @@ class XNES(BasePintsOptimiser): Parameters ---------- - x0 : array_like - The initial parameter vector to optimize. - sigma0 : float, optional - Initial standard deviation of the sampling distribution, defaults to 0.1. - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. If ``None``, no bounds are enforced. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float + Initial standard deviation of the sampling distribution. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upperbounds on the parameters. If ``None``, no bounds are enforced. See Also -------- pints.XNES : PINTS implementation of XNES algorithm. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - super().__init__(pints.XNES, x0, sigma0, bounds, **optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, pints.XNES, **optimiser_kwargs) class NelderMead(BasePintsOptimiser): @@ -628,26 +246,24 @@ class NelderMead(BasePintsOptimiser): Parameters ---------- - x0 : array_like - The initial parameter vector to optimize. - sigma0 : float, optional - Initial standard deviation of the sampling distribution, defaults to 0.1. - Does not appear to be used. - bounds : dict, optional - Ignored by this optimiser, provided for API consistency. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float + Initial standard deviation of the sampling distribution. + Does not appear to be used. See Also -------- pints.NelderMead : PINTS implementation of Nelder-Mead algorithm. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - if bounds is not None: + def __init__(self, cost, **optimiser_kwargs): + if optimiser_kwargs.pop("bounds", None) is not None: print("NOTE: Boundaries ignored by NelderMead") bounds = None # Bounds ignored in pints.NelderMead - super().__init__(pints.NelderMead, x0, sigma0, bounds, **optimiser_kwargs) + super().__init__(cost, pints.NelderMead, bounds=bounds, **optimiser_kwargs) class CMAES(BasePintsOptimiser): @@ -660,25 +276,26 @@ class CMAES(BasePintsOptimiser): Parameters ---------- - x0 : array_like - The initial parameter vector to optimize. - sigma0 : float, optional - Initial standard deviation of the sampling distribution, defaults to 0.1. - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. If ``None``, no bounds are enforced. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float + Initial standard deviation of the sampling distribution. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. If ``None``, no bounds are enforced. See Also -------- pints.CMAES : PINTS implementation of CMA-ES algorithm. """ - def __init__(self, x0=None, sigma0=0.1, bounds=None, **optimiser_kwargs): - if len(x0) == 1: + def __init__(self, cost, **optimiser_kwargs): + x0 = optimiser_kwargs.pop("x0", cost.x0) + if x0 is not None and len(x0) == 1: raise ValueError( "CMAES requires optimisation of >= 2 parameters at once. " + "Please choose another optimiser." ) - super().__init__(pints.CMAES, x0, sigma0, bounds, **optimiser_kwargs) + super().__init__(cost, pints.CMAES, **optimiser_kwargs) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index a6a8e1724..e2f24fecb 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -1,17 +1,17 @@ +import warnings + import numpy as np from scipy.optimize import differential_evolution, minimize -from .base_optimiser import BaseOptimiser +from pybop import Optimisation DEFAULT_SCIPY_MINIMIZE_OPTIONS = dict( - bounds=None, method="Nelder-Mead", jac=False, tol=1e-5, options=dict(), ) DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS = dict( - bounds=None, strategy="best1bin", popsize=15, tol=1e-5, @@ -19,7 +19,7 @@ ) -class BaseSciPyOptimiser(BaseOptimiser): +class BaseSciPyOptimiser(Optimisation): """ A base class for defining optimisation methods from the SciPy library. @@ -35,45 +35,50 @@ class BaseSciPyOptimiser(BaseOptimiser): Valid SciPy option keys and their values. """ - def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): - super().__init__(x0, sigma0, bounds) - self.update_options(**optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, **optimiser_kwargs) - def update_options(self, **optimiser_kwargs): + def _update_options(self, **optimiser_kwargs): """ - Update the optimiser options. + Update the optimiser options and remove the corresponding entries from the + optimiser_kwargs dictionary in advance of passing to the parent class. Parameters ---------- **optimiser_kwargs : optional Valid SciPy option keys and their values. + + Returns + ------- + optimiser_kwargs : dict + Remaining option keys and their values. """ - # Use the first available value for maxiter and remove others + # Unpack nested values from SciPy options dictionary + if "options" in optimiser_kwargs.keys(): + options_list = list(optimiser_kwargs.keys()) + for key in options_list: + if key not in optimiser_kwargs: + optimiser_kwargs[key] = optimiser_kwargs["options"].pop(key) + else: + optimiser_kwargs["options"].pop(key) # remove entry + + # Keep max_iterations in preference to maxiter if "max_iterations" in optimiser_kwargs.keys(): optimiser_kwargs["maxiter"] = optimiser_kwargs.pop("max_iterations") - if "options" in optimiser_kwargs.keys(): - if "maxiter" in optimiser_kwargs.keys(): - optimiser_kwargs["options"].pop("maxiter", None) - elif "maxiter" in optimiser_kwargs["options"].keys(): - optimiser_kwargs["maxiter"] = optimiser_kwargs["options"].pop("maxiter") - - for key, value in optimiser_kwargs.items(): - if key == "bounds": - self.bounds = value - elif key == "maxiter": - self.set_max_iterations(value) - else: - self.options[key] = value - - def _run(self, **optimiser_kwargs): + + key_list = list(optimiser_kwargs.keys()) + for key in key_list: + if key == "maxiter": + self.set_max_iterations(optimiser_kwargs.pop(key)) + elif key in ["method", "jac", "tol", "options", "strategy", "popsize"]: + self.__dict__.update({key: optimiser_kwargs.pop(key)}) + + return optimiser_kwargs + + def _run(self): """ Internal method to run the optimization using a PyBOP optimiser. - Parameters - ---------- - **optimiser_kwargs : optional - Valid SciPy option keys and their values. - Returns ------- x : numpy.ndarray @@ -81,12 +86,17 @@ def _run(self, **optimiser_kwargs): final_cost : float The final cost associated with the best parameters. """ - self.update_options(**optimiser_kwargs) - result = self._run_optimiser() self._iterations = result.nit - return result.x, self._cost_function(result.x) + return result.x, self.cost(result.x) + + def set_max_unchanged_iterations(self, **kwargs): + """ + Raise a warning that this stopping criterion is not used by this optimiser. + """ + invalid_criteria_warning = "The maximum unchanged iterations stopping criteria is not used by the SciPy optimisers." + warnings.warn(invalid_criteria_warning, UserWarning) class SciPyMinimize(BaseSciPyOptimiser): @@ -98,36 +108,32 @@ class SciPyMinimize(BaseSciPyOptimiser): Parameters ---------- - x0 : array_like - Initial position from which optimisation will start. - sigma0 : float, optional - Initial step size or standard deviation depending on the optimiser (default: None). - bounds : dict, sequence or scipy.optimize.Bounds, optional - Bounds for variables as supported by the selected method. **optimiser_kwargs : optional - Valid SciPy Minimize option keys and their values. For example, to specify the solver - use: `method='Nelder-Mead'`. Other options are: 'Nelder-Mead', 'Powell', 'CG', 'BFGS', - 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', - 'trust-exact', 'trust-krylov'. + Valid SciPy Minimize option keys and their values, For example: + x0 : array_like + Initial position from which optimisation will start. + sigma0 : float + Initial step size or standard deviation depending on the optimiser. + bounds : dict, sequence or scipy.optimize.Bounds + Bounds for variables as supported by the selected method. + method : str + The optimisation method, options include: + 'Nelder-Mead', 'Powell', 'CG', 'BFGS', 'Newton-CG', 'L-BFGS-B', 'TNC', 'COBYLA', + 'SLSQP', 'trust-constr', 'dogleg', 'trust-ncg', 'trust-exact', 'trust-krylov'. See Also -------- scipy.optimize.minimize : The SciPy method this class is based on. """ - def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): - self.options = DEFAULT_SCIPY_MINIMIZE_OPTIONS - super().__init__(x0, sigma0, bounds, **optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + self.__dict__.update(DEFAULT_SCIPY_MINIMIZE_OPTIONS) + super().__init__(cost, **optimiser_kwargs) def _run_optimiser(self): """ Executes the optimisation process using SciPy's minimize function. - Parameters - ---------- - **optimiser_kwargs : optional - Valid SciPy option keys and their values. - Returns ------- tuple @@ -141,23 +147,23 @@ def callback(x): self.log.append([x]) # Scale the cost function and eliminate nan values - self._cost0 = self._cost_function(self.x0) + self._cost0 = self.cost(self.x0) self.inf_count = 0 if np.isinf(self._cost0): raise Exception("The initial parameter values return an infinite cost.") - if not self.options["jac"]: + if not self.jac: def cost_wrapper(x): - cost = self._cost_function(x) / self._cost0 + cost = self.cost(x) / self._cost0 if np.isinf(cost): self.inf_count += 1 cost = 1 + 0.9**self.inf_count # for fake finite gradient return cost - elif self.options["jac"] is True: + elif self.jac is True: def cost_wrapper(x): - return self._cost_function.evaluateS1(x) + return self.cost.evaluateS1(x) else: raise ValueError( "Expected the jac option to be either True, False or None." @@ -173,16 +179,16 @@ def cost_wrapper(x): bounds = self.bounds # Retrieve maximum iterations - self.options["options"]["maxiter"] = self._max_iterations + self.options["maxiter"] = self._max_iterations result = minimize( cost_wrapper, self.x0, - method=self.options["method"], - jac=self.options["jac"], + method=self.method, + jac=self.jac, bounds=bounds, - tol=self.options["tol"], - options=self.options["options"], + tol=self.tol, + options=self.options, callback=callback, ) @@ -212,40 +218,33 @@ class SciPyDifferentialEvolution(BaseSciPyOptimiser): bounds : dict, sequence or scipy.optimize.Bounds Bounds for variables. Must be provided as it is essential for differential evolution. **optimiser_kwargs : optional - Valid SciPy option keys and their values, such as: - strategy : str, optional - The differential evolution strategy to use. Defaults to 'best1bin'. - maxiter : int, optional - Maximum number of iterations to perform. Defaults to 1000. - popsize : int, optional - The number of individuals in the population. Defaults to 15. + Valid SciPy option keys and their values, for example: + strategy : str + The differential evolution strategy to use. + maxiter : int + Maximum number of iterations to perform. + popsize : int + The number of individuals in the population. See Also -------- scipy.optimize.differential_evolution : The SciPy method this class is based on. """ - def __init__(self, x0=None, sigma0=None, bounds=None, **optimiser_kwargs): - self.options = DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS - super().__init__(x0, sigma0, bounds, **optimiser_kwargs) + def __init__(self, cost, **optimiser_kwargs): + self.__dict__.update(DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS) + super().__init__(cost, **optimiser_kwargs) def _run_optimiser(self): """ Executes the optimization process using SciPy's differential_evolution function. - Parameters - ---------- - **optimiser_kwargs : optional - Valid SciPy Differential Evolution option keys and their values. - Returns ------- tuple A tuple (x, final_cost) containing the optimized parameters and the value of the cost function at the optimum. """ - self.log = [] - if self.x0 is not None: print( "Ignoring x0. Initial conditions are not used for differential_evolution." @@ -275,16 +274,13 @@ def callback(x, convergence): raise ValueError("Bounds must be specified for differential_evolution.") bounds = self.bounds - # Retrieve maximum iterations - self.options["maxiter"] = self._max_iterations - result = differential_evolution( - self._cost_function, + self.cost, bounds, - strategy=self.options["strategy"], - maxiter=self.options["maxiter"], - popsize=self.options["popsize"], - tol=self.options["tol"], + strategy=self.strategy, + maxiter=self._max_iterations, + popsize=self.popsize, + tol=self.tol, callback=callback, ) @@ -301,7 +297,7 @@ def set_population_size(self, population_size=None): population_size = int(population_size) if population_size < 1: raise ValueError("Population size must be at least 1.") - self.options["popsize"] = population_size + self.popsize = population_size def name(self): """ diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 662dedcfa..0f4584abb 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -46,7 +46,7 @@ def plot_convergence(optim, show=True, **layout_kwargs): layout_options=dict( xaxis_title="Iteration", yaxis_title="Cost", title="Convergence" ), - trace_names=optim.optimiser.name(), + trace_names=optim.name(), ) # Generate and display the figure diff --git a/tests/integration/test_model_experiment_changes.py b/tests/integration/test_model_experiment_changes.py index 8d648a84e..12ca3b8a3 100644 --- a/tests/integration/test_model_experiment_changes.py +++ b/tests/integration/test_model_experiment_changes.py @@ -99,6 +99,6 @@ def final_cost(self, solution, model, parameters, init_soc): model, parameters, dataset, signal=signal, x0=x0, init_soc=init_soc ) cost = pybop.RootMeanSquaredError(problem) - optim = pybop.Optimisation(cost, optimiser=pybop.PSO) + optim = pybop.PSO(cost) x, final_cost = optim.run() return final_cost diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 1f94fe63b..9ff700563 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -84,11 +84,9 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): parameterisation = pybop.Optimisation( cost=spm_costs, optimiser=pybop.XNES, sigma0=0.05 ) - parameterisation.optimiser.set_max_unchanged_iterations( - iterations=35, threshold=1e-5 - ) - parameterisation.optimiser.set_max_iterations(125) - parameterisation.optimiser.set_f_guessed_tracking(f_guessed) + parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) + parameterisation.set_max_iterations(125) + parameterisation.set_f_guessed_tracking(f_guessed) # Set parallelisation if not on Windows if sys.platform != "win32": diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index d286bdeff..55f5e2dfe 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -107,14 +107,10 @@ def test_spm_optimisers(self, optimiser, spm_costs): spm_costs.bounds = bounds # Test each optimiser - parameterisation = pybop.Optimisation( - cost=spm_costs, optimiser=optimiser, sigma0=0.05 - ) + parameterisation = optimiser(cost=spm_costs, sigma0=0.05) if issubclass(optimiser, pybop.BasePintsOptimiser): - parameterisation.optimiser.set_max_unchanged_iterations( - iterations=35, threshold=1e-5 - ) - parameterisation.optimiser.set_max_iterations(125) + parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) + parameterisation.set_max_iterations(125) initial_cost = parameterisation.cost(spm_costs.x0) @@ -122,9 +118,9 @@ def test_spm_optimisers(self, optimiser, spm_costs): if isinstance( spm_costs, (pybop.GaussianLogLikelihoodKnownSigma, pybop.MAP) ): - parameterisation.optimiser.set_learning_rate(1.8e-5) + parameterisation.set_learning_rate(1.8e-5) else: - parameterisation.optimiser.set_learning_rate(0.02) + parameterisation.set_learning_rate(0.02) x, final_cost = parameterisation.run() elif optimiser in [pybop.SciPyMinimize]: @@ -192,14 +188,10 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): spm_two_signal_cost.bounds = bounds # Test each optimiser - parameterisation = pybop.Optimisation( - cost=spm_two_signal_cost, optimiser=multi_optimiser, sigma0=0.03 - ) + parameterisation = multi_optimiser(cost=spm_two_signal_cost, sigma0=0.03) if issubclass(multi_optimiser, pybop.BasePintsOptimiser): - parameterisation.optimiser.set_max_unchanged_iterations( - iterations=35, threshold=5e-4 - ) - parameterisation.optimiser.set_max_iterations(125) + parameterisation.set_max_unchanged_iterations(iterations=35, threshold=5e-4) + parameterisation.set_max_iterations(125) initial_cost = parameterisation.cost(spm_two_signal_cost.x0) x, final_cost = parameterisation.run() @@ -234,7 +226,7 @@ def test_model_misparameterisation(self, parameters, model, init_soc): optimiser = pybop.CMAES # Build the optimisation problem - parameterisation = pybop.Optimisation(cost=cost, optimiser=optimiser) + parameterisation = optimiser(cost=cost) # Run the optimisation problem x, final_cost = parameterisation.run() diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index c26cc7dff..8c7fc88bf 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -69,7 +69,7 @@ def two_param_cost(self, model, two_parameters, dataset): return pybop.SumSquaredError(problem) @pytest.mark.parametrize( - "optimiser_class, expected_name", + "optimiser, expected_name", [ (pybop.SciPyMinimize, "SciPyMinimize"), (pybop.SciPyDifferentialEvolution, "SciPyDifferentialEvolution"), @@ -84,73 +84,73 @@ def two_param_cost(self, model, two_parameters, dataset): ], ) @pytest.mark.unit - def test_optimiser_classes(self, two_param_cost, optimiser_class, expected_name): + def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): # Test class construction cost = two_param_cost - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + optim = optimiser(cost=cost) - assert optim.optimiser is not None - assert optim.optimiser.name() == expected_name + assert optim.cost is not None + assert optim.name() == expected_name # Test construction without bounds bounds = cost.bounds cost.bounds = None - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class) - assert optim.optimiser.bounds is None - if issubclass(optimiser_class, pybop.BasePintsOptimiser): - assert optim.optimiser._boundaries is None + optim = optimiser(cost=cost) + assert optim.bounds is None + if issubclass(optimiser, pybop.BasePintsOptimiser): + assert optim._boundaries is None # Reset cost.bounds = bounds @pytest.mark.parametrize( - "optimiser_class", + "optimiser", [pybop.XNES, pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], ) @pytest.mark.unit - def test_optimiser_kwargs(self, cost, optimiser_class): - optim = pybop.Optimisation(cost=cost, optimiser=optimiser_class, maxiter=1) + def test_optimiser_kwargs(self, cost, optimiser): + optim = optimiser(cost=cost, maxiter=1) # Check and update maximum iterations optim.run() - assert optim.optimiser._max_iterations == 1 - assert optim.optimiser._iterations == 1 + assert optim._max_iterations == 1 + assert optim._iterations == 1 optim.run(max_iterations=10) - assert optim.optimiser._max_iterations == 10 - assert optim.optimiser._iterations <= 10 + assert optim._max_iterations == 10 + assert optim._iterations <= 10 - if issubclass(optimiser_class, pybop.BasePintsOptimiser): + if issubclass(optimiser, pybop.BasePintsOptimiser): # PINTS method with pytest.raises( ValueError, - match="Unrecognised or invalid keyword argument: tol", + match="Unrecognised keyword arguments", ): - optim = pybop.Optimisation(cost=cost, tol=1e-3) + optim = optimiser(cost=cost, tol=1e-3) else: # Check and update bounds - assert optim.optimiser.bounds == cost.bounds + assert optim.bounds == cost.bounds bounds = {"upper": [0.61], "lower": [0.59]} optim.run(bounds=bounds) - assert optim.optimiser.bounds == bounds + assert optim.bounds == bounds bounds = [ (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ] optim.run(bounds=bounds) - assert optim.optimiser.bounds == bounds + assert optim.bounds == bounds # Update tol optim.run(tol=1e-2) - assert optim.optimiser.options["tol"] == 1e-2 + assert optim.tol == 1e-2 - if optimiser_class in [pybop.SciPyDifferentialEvolution]: + if optimiser in [pybop.SciPyDifferentialEvolution]: # Check and update population size with pytest.raises(ValueError): - optim.optimiser.set_population_size(-5) - optim.optimiser.set_population_size(10) - assert optim.optimiser.options["popsize"] == 10 + optim.set_population_size(-5) + optim.set_population_size(10) + assert optim.popsize == 10 optim.run(popsize=5) - assert optim.optimiser.options["popsize"] == 5 - elif optimiser_class in [pybop.SciPyMinimize]: + assert optim.popsize == 5 + elif optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information optim.run(method="L-BFGS-B", jac=True) with pytest.raises( @@ -167,12 +167,12 @@ def test_single_parameter(self, cost): ValueError, match=r"requires optimisation of >= 2 parameters at once.", ): - pybop.Optimisation(cost=cost, optimiser=pybop.CMAES) + pybop.CMAES(cost=cost) @pytest.mark.unit def test_default_optimiser(self, cost): - optim = pybop.Optimisation(cost=cost) - assert optim.optimiser.name() == "Exponential Natural Evolution Strategy (xNES)" + optim = pybop.DefaultOptimiser(cost=cost) + assert optim.name() == "Exponential Natural Evolution Strategy (xNES)" @pytest.mark.unit def test_incorrect_optimiser_class(self, cost): @@ -180,9 +180,9 @@ class RandomClass: pass with pytest.raises(ValueError): - pybop.Optimisation(cost=cost, optimiser=RandomClass) + pybop.BasePintsOptimiser(cost=cost, pints_method=RandomClass) - optim = pybop.Optimisation(cost=cost, optimiser=pybop.BaseOptimiser) + optim = pybop.Optimisation(cost=cost) with pytest.raises(NotImplementedError): optim.run() @@ -197,42 +197,40 @@ def test_prior_sampling(self, cost): @pytest.mark.unit def test_halting(self, cost): # Test max evalutions - optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) - optim.optimiser.set_max_evaluations(1) + optim = pybop.GradientDescent(cost=cost) + optim.set_max_evaluations(1) x, __ = optim.run() - assert optim.optimiser._iterations == 1 + assert optim._iterations == 1 # Test max unchanged iterations - optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) - optim.optimiser.set_max_unchanged_iterations(1) - optim.optimiser.set_min_iterations(1) + optim = pybop.GradientDescent(cost=cost) + optim.set_max_unchanged_iterations(1) + optim.set_min_iterations(1) x, __ = optim.run() - assert optim.optimiser._iterations == 2 + assert optim._iterations == 2 # Test guessed values - optim.optimiser.set_f_guessed_tracking(True) - assert optim.optimiser._use_f_guessed is True + optim.set_f_guessed_tracking(True) + assert optim._use_f_guessed is True # Test invalid values with pytest.raises(ValueError): - optim.optimiser.set_max_evaluations(-1) + optim.set_max_evaluations(-1) with pytest.raises(ValueError): - optim.optimiser.set_min_iterations(-1) + optim.set_min_iterations(-1) with pytest.raises(ValueError): - optim.optimiser.set_max_unchanged_iterations(-1) + optim.set_max_unchanged_iterations(-1) with pytest.raises(ValueError): - optim.optimiser.set_max_unchanged_iterations(1, threshold=-1) + optim.set_max_unchanged_iterations(1, threshold=-1) @pytest.mark.unit def test_infeasible_solutions(self, cost): # Test infeasible solutions for optimiser in [pybop.SciPyMinimize, pybop.GradientDescent]: - optim = pybop.Optimisation( - cost=cost, optimiser=optimiser, allow_infeasible_solutions=False - ) - optim.optimiser.set_max_iterations(1) + optim = optimiser(cost=cost, allow_infeasible_solutions=False) + optim.set_max_iterations(1) optim.run() - assert optim.optimiser._iterations == 1 + assert optim._iterations == 1 @pytest.mark.unit def test_unphysical_result(self, cost): diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 6d0fadb34..b537049f4 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -108,7 +108,7 @@ def test_cost_plots(self, cost): @pytest.fixture def optim(self, cost): # Define and run an example optimisation - optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) + optim = pybop.DefaultOptimiser(cost) optim.run() return optim diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index e9054f5b4..ea7f208f8 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -15,10 +15,10 @@ class TestStandalone: def test_standalone(self): # Build an Optimisation problem with a StandaloneCost cost = StandaloneCost() - opt = pybop.Optimisation(cost=cost, optimiser=pybop.SciPyDifferentialEvolution) - x, final_cost = opt.run() + optim = pybop.DefaultOptimiser(cost=cost) + x, final_cost = optim.run() - assert len(opt.x0) == opt._n_parameters + assert len(optim.x0) == optim._n_parameters np.testing.assert_allclose(x, 0, atol=1e-2) np.testing.assert_allclose(final_cost, 42, atol=1e-2) From a496ae2919721b1b2171eae7be865f2718064a58 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:34:21 +0100 Subject: [PATCH 17/85] Update stopping criteria in spm_NelderMead.py Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- examples/scripts/spm_NelderMead.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/spm_NelderMead.py b/examples/scripts/spm_NelderMead.py index d289ef44b..6a549758e 100644 --- a/examples/scripts/spm_NelderMead.py +++ b/examples/scripts/spm_NelderMead.py @@ -60,7 +60,7 @@ def noise(sigma): allow_infeasible_solutions=True, sigma0=0.05, max_iterations=100, - max_unchanged_iterations=45, + max_unchanged_iterations=20, ) # Run optimisation From ddf2afac3d29deb65f49fc5694dce429903cbd1d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:35:18 +0100 Subject: [PATCH 18/85] Update stopping criteria in spm_adam.py Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- examples/scripts/spm_adam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index 6e283f17c..6a18f1ae6 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -60,7 +60,7 @@ def noise(sigma): allow_infeasible_solutions=True, sigma0=0.05, max_iterations=100, - max_unchanged_iterations=45, + max_unchanged_iterations=20, ) # Run optimisation From d4dd9d247557a6e7db1d5b7c1a45214ac9cefbe1 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:37:45 +0100 Subject: [PATCH 19/85] Update sigma0 in spm_descent.py Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- examples/scripts/spm_descent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index fbbd58211..03a973c65 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -38,7 +38,7 @@ cost = pybop.SumSquaredError(problem) optim = pybop.GradientDescent( cost, - sigma0=0.022, + sigma0=0.011, verbose=True, max_iterations=125, ) From 88267cc2e4bf48da85d0ad932c1ce534a8fc9c5b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:44:20 +0100 Subject: [PATCH 20/85] Update description --- pybop/_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 37e232121..d62082b04 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -17,7 +17,7 @@ class Optimisation: """ A base class for defining optimisation methods. - This class serves as a template for creating optimisers. It provides a basic structure for + 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 update_options and the_run method with a specific algorithm. From 967754ba5f321256c596195d65646975bbd60410 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:52:13 +0100 Subject: [PATCH 21/85] Update GradientDescent --- pybop/optimisers/pints_optimisers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index b50d1ddfc..97dc1399e 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -49,7 +49,7 @@ class GradientDescent(BasePintsOptimiser): x0 : array_like Initial position from which optimization will start. sigma0 : float - Initial step size. + Initial step size (default: 0.02). learning_rate : float The learning rate, as a float greater than zero. @@ -62,8 +62,10 @@ def __init__(self, cost, **optimiser_kwargs): if optimiser_kwargs.pop("bounds", None) is not None: print("NOTE: Boundaries ignored by Gradient Descent") bounds = None # Bounds ignored in pints.GradientDescent - if optimiser_kwargs.pop("learning_rate", None) is not None: - self.set_learning_rate(optimiser_kwargs["learning_rate"]) + if "learning_rate" in optimiser_kwargs.keys(): + self.set_learning_rate(optimiser_kwargs.pop("learning_rate")) + if "sigma0" not in optimiser_kwargs.keys(): + optimiser_kwargs["sigma0"] = 0.02 # set default initial step size super().__init__(cost, pints.GradientDescent, bounds=bounds, **optimiser_kwargs) def set_learning_rate(self, eta): From 73e78de0e73df7c885eada46997965c3444324fa Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:59:08 +0100 Subject: [PATCH 22/85] Change update to set and check pint_method --- pybop/_optimisation.py | 14 +++++++------- pybop/optimisers/base_optimiser.py | 7 +++++-- pybop/optimisers/scipy_optimisers.py | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index d62082b04..5bac0082b 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -19,8 +19,8 @@ class Optimisation: 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 update_options and the_run method with - a specific algorithm. + optimisation process. Child classes should override set_options and the _run method with a + specific algorithm. Parameters ---------- @@ -64,14 +64,14 @@ def __init__( self.log = [] self.set_max_iterations() self.set_allow_infeasible_solutions() - self.update_options(**optimiser_kwargs) + self.set_options(**optimiser_kwargs) # Check if minimising or maximising if isinstance(self.cost, pybop.BaseLikelihood): self.cost._minimising = False self._minimising = self.cost._minimising - def update_options(self, **optimiser_kwargs): + def set_options(self, **optimiser_kwargs): """ Update the optimiser options and check that all have been applied. @@ -82,7 +82,7 @@ def update_options(self, **optimiser_kwargs): """ # Update and remove optimiser options from the optimiser_kwargs dictionary # in the child class first and then in this base class - optimiser_kwargs = self._update_options(**optimiser_kwargs) + optimiser_kwargs = self._set_options(**optimiser_kwargs) key_list = list(optimiser_kwargs.keys()) for key in key_list: @@ -101,7 +101,7 @@ def update_options(self, **optimiser_kwargs): f"Unrecognised keyword arguments were not used: {optimiser_kwargs}" ) - def _update_options(self, **optimiser_kwargs): + def _set_options(self, **optimiser_kwargs): """ Update the optimiser options and remove the corresponding entries from the optimiser_kwargs dictionary in advance of passing to the parent class @@ -137,7 +137,7 @@ def run(self, x0=None, **optimiser_kwargs): final_cost : float The final cost associated with the best parameters. """ - self.update_options(**optimiser_kwargs) + self.set_options(**optimiser_kwargs) if x0 is not None: self.x0 = x0 diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index df4bdda37..5ae0c8243 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -43,12 +43,15 @@ def __init__(self, cost, pints_method, **optimiser_kwargs): super().__init__(cost, **optimiser_kwargs) # Create an instance of the PINTS optimiser class - self.method = pints_method(self.x0, self.sigma0, self._boundaries) + if issubclass(pints_method, pints.Optimiser): + self.method = pints_method(self.x0, self.sigma0, self._boundaries) + else: + raise ValueError("The pints_method is not recognised as a PINTS optimiser.") # Check if sensitivities are required self._needs_sensitivities = self.method.needs_sensitivities() - def _update_options(self, **optimiser_kwargs): + def _set_options(self, **optimiser_kwargs): """ Update the optimiser options and remove the corresponding entries from the optimiser_kwargs dictionary in advance of passing to the parent class. diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index e2f24fecb..41db03151 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -38,7 +38,7 @@ class BaseSciPyOptimiser(Optimisation): def __init__(self, cost, **optimiser_kwargs): super().__init__(cost, **optimiser_kwargs) - def _update_options(self, **optimiser_kwargs): + def _set_options(self, **optimiser_kwargs): """ Update the optimiser options and remove the corresponding entries from the optimiser_kwargs dictionary in advance of passing to the parent class. From 8e2cfdf5df2c6b3f5e4826bfacbc10c76c16df8a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 00:07:12 +0100 Subject: [PATCH 23/85] Update test_optimisation_options --- pybop/_optimisation.py | 4 +--- tests/integration/test_optimisation_options.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 5bac0082b..6866e3065 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -97,9 +97,7 @@ def set_options(self, **optimiser_kwargs): # Throw an error if any arguments remain if optimiser_kwargs: - raise ValueError( - f"Unrecognised keyword arguments were not used: {optimiser_kwargs}" - ) + raise ValueError(f"Unrecognised keyword arguments: {optimiser_kwargs}") def _set_options(self, **optimiser_kwargs): """ diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 9ff700563..5a510a4ea 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -81,9 +81,7 @@ def spm_costs(self, model, parameters, cost_class): @pytest.mark.integration def test_optimisation_f_guessed(self, f_guessed, spm_costs): # Test each optimiser - parameterisation = pybop.Optimisation( - cost=spm_costs, optimiser=pybop.XNES, sigma0=0.05 - ) + parameterisation = pybop.XNES(cost=spm_costs, sigma0=0.05) parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) parameterisation.set_max_iterations(125) parameterisation.set_f_guessed_tracking(f_guessed) From 078955a568d6a64e0f02cb1b89a214f248fb8ea0 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 01:28:59 +0100 Subject: [PATCH 24/85] Update notebooks --- .../1-single-pulse-circuit-model.ipynb | 6 ++--- .../equivalent_circuit_identification.ipynb | 4 +-- .../multi_model_identification.ipynb | 6 ++--- .../multi_optimiser_identification.ipynb | 26 +++++++++---------- .../notebooks/optimiser_calibration.ipynb | 12 ++++----- .../notebooks/pouch_cell_identification.ipynb | 6 ++--- examples/notebooks/spm_electrode_design.ipynb | 6 ++--- 7 files changed, 32 insertions(+), 34 deletions(-) diff --git a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb index 3181d952a..4e735aa09 100644 --- a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb +++ b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb @@ -1679,7 +1679,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" ] }, { @@ -1726,7 +1726,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim);" + "pybop.plot_parameters(optim)" ] }, { @@ -1850,7 +1850,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Parameter Extrapolation\");" + "pybop.quick_plot(problem, parameter_values=x, title=\"Parameter Extrapolation\")" ] }, { diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index d2eff1bc4..fef32061e 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -457,7 +457,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" ] }, { @@ -504,7 +504,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim);" + "pybop.plot_parameters(optim)" ] }, { diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index 1bb8d2dd8..35c64797c 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -6412,9 +6412,9 @@ " print(f\"Running {model.name}\")\n", " problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", " cost = pybop.SumSquaredError(problem)\n", - " optim = pybop.Optimisation(cost, optimiser=pybop.XNES, verbose=True)\n", - " optim.optimiser.set_max_iterations(60)\n", - " optim.optimiser.set_max_unchanged_iterations(15)\n", + " optim = pybop.XNES(cost, verbose=True)\n", + " optim.set_max_iterations(60)\n", + " optim.set_max_unchanged_iterations(15)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index c3f8e757f..d738d5fb4 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -357,9 +357,9 @@ "cost = pybop.SumSquaredError(problem)\n", "for optimiser in gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.optimiser.set_max_unchanged_iterations(15)\n", - " optim.optimiser.set_max_iterations(60)\n", + " optim = optimiser(cost)\n", + " optim.set_max_unchanged_iterations(15)\n", + " optim.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -386,9 +386,9 @@ "source": [ "for optimiser in non_gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.optimiser.set_max_unchanged_iterations(15)\n", - " optim.optimiser.set_max_iterations(60)\n", + " optim = optimiser(cost)\n", + " optim.set_max_unchanged_iterations(15)\n", + " optim.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -412,9 +412,9 @@ "source": [ "for optimiser in scipy_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = pybop.Optimisation(cost, optimiser=optimiser)\n", - " optim.optimiser.set_max_unchanged_iterations(15)\n", - " optim.optimiser.set_max_iterations(60)\n", + " optim = optimiser(cost)\n", + " optim.set_max_unchanged_iterations(15)\n", + " optim.set_max_iterations(60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -611,9 +611,7 @@ ], "source": [ "for optim, x in zip(optims, xs):\n", - " pybop.quick_plot(\n", - " optim.cost.problem, parameter_values=x, title=optim.optimiser.name()\n", - " )" + " pybop.quick_plot(optim.cost.problem, parameter_values=x, title=optim.name())" ] }, { @@ -821,7 +819,7 @@ ], "source": [ "for optim in optims:\n", - " pybop.plot_convergence(optim, title=optim.optimiser.name())\n", + " pybop.plot_convergence(optim, title=optim.name())\n", " pybop.plot_parameters(optim)" ] }, @@ -941,7 +939,7 @@ "# Plot the cost landscape with optimisation path and updated bounds\n", "bounds = np.array([[0.5, 0.8], [0.5, 0.8]])\n", "for optim in optims:\n", - " pybop.plot2d(optim, bounds=bounds, steps=10, title=optim.optimiser.name())" + " pybop.plot2d(optim, bounds=bounds, steps=10, title=optim.name())" ] }, { diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 5b21e2a1a..022bca8d7 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -281,8 +281,8 @@ "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent)\n", - "optim.optimiser.set_max_iterations(100)" + "optim = pybop.GradientDescent(cost)\n", + "optim.set_max_iterations(100)" ] }, { @@ -452,8 +452,8 @@ " print(sigma)\n", " problem = pybop.FittingProblem(model, parameters, dataset)\n", " cost = pybop.SumSquaredError(problem)\n", - " optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent, sigma0=sigma)\n", - " optim.optimiser.set_max_iterations(100)\n", + " optim = pybop.GradientDescent(cost, sigma0=sigma)\n", + " optim.set_max_iterations(100)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -486,7 +486,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.optimiser.f_best()} | Results: {optim.optimiser.x_best()} |\"\n", + " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.f_best()} | Results: {optim.x_best()} |\"\n", " )" ] }, @@ -719,7 +719,7 @@ } ], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent, sigma0=0.0115)\n", + "optim = pybop.GradientDescent(cost, sigma0=0.0115)\n", "x, final_cost = optim.run()\n", "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" ] diff --git a/examples/notebooks/pouch_cell_identification.ipynb b/examples/notebooks/pouch_cell_identification.ipynb index 27b7a33f9..18a3f143b 100644 --- a/examples/notebooks/pouch_cell_identification.ipynb +++ b/examples/notebooks/pouch_cell_identification.ipynb @@ -517,7 +517,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" ] }, { @@ -2610,7 +2610,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim);" + "pybop.plot_parameters(optim)" ] }, { @@ -2645,7 +2645,7 @@ } ], "source": [ - "pybop.plot2d(optim, steps=15);" + "pybop.plot2d(optim, steps=15)" ] } ], diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index 7b97b5119..80cefbc64 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -232,8 +232,8 @@ }, "outputs": [], "source": [ - "optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True)\n", - "optim.optimiser.set_max_iterations(15)" + "optim = pybop.PSO(cost, verbose=True)\n", + "optim.set_max_iterations(15)" ] }, { @@ -330,7 +330,7 @@ "source": [ "if cost.update_capacity:\n", " problem._model.approximate_capacity(x)\n", - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" ] }, { From 524f344f6cbed53521d9613df21341b95d229b63 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 01:45:09 +0100 Subject: [PATCH 25/85] Update set learning rate --- pybop/optimisers/pints_optimisers.py | 20 ++------------------ tests/integration/test_parameterisations.py | 4 ++-- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 97dc1399e..5fb8e16de 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -49,9 +49,7 @@ class GradientDescent(BasePintsOptimiser): x0 : array_like Initial position from which optimization will start. sigma0 : float - Initial step size (default: 0.02). - learning_rate : float - The learning rate, as a float greater than zero. + The learning rate / Initial step size (default: 0.02). See Also -------- @@ -62,24 +60,10 @@ def __init__(self, cost, **optimiser_kwargs): if optimiser_kwargs.pop("bounds", None) is not None: print("NOTE: Boundaries ignored by Gradient Descent") bounds = None # Bounds ignored in pints.GradientDescent - if "learning_rate" in optimiser_kwargs.keys(): - self.set_learning_rate(optimiser_kwargs.pop("learning_rate")) if "sigma0" not in optimiser_kwargs.keys(): - optimiser_kwargs["sigma0"] = 0.02 # set default initial step size + optimiser_kwargs["sigma0"] = 0.02 # set default super().__init__(cost, pints.GradientDescent, bounds=bounds, **optimiser_kwargs) - def set_learning_rate(self, eta): - """ - Sets the learning rate for this optimiser. - Credit: PINTS - - Parameters - ---------- - eta : float - The learning rate, as a float greater than zero. - """ - self.method.set_learning_rate(eta) - class Adam(BasePintsOptimiser): """ diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index 55f5e2dfe..226f1ad63 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -118,9 +118,9 @@ def test_spm_optimisers(self, optimiser, spm_costs): if isinstance( spm_costs, (pybop.GaussianLogLikelihoodKnownSigma, pybop.MAP) ): - parameterisation.set_learning_rate(1.8e-5) + parameterisation.method.set_learning_rate(1.8e-5) else: - parameterisation.set_learning_rate(0.02) + parameterisation.method.set_learning_rate(0.02) x, final_cost = parameterisation.run() elif optimiser in [pybop.SciPyMinimize]: From 65e59d6e883845a52d6975ae98119476c0aec17f Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 01:45:30 +0100 Subject: [PATCH 26/85] Pop threshold --- pybop/optimisers/base_optimiser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 5ae0c8243..c4da9a4ad 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -93,7 +93,7 @@ def _set_options(self, **optimiser_kwargs): if "threshold" in optimiser_kwargs.keys(): self.set_max_unchanged_iterations( optimiser_kwargs.pop(key), - optimiser_kwargs["threshold"], + optimiser_kwargs.pop("threshold"), ) else: self.set_max_unchanged_iterations(optimiser_kwargs.pop(key)) From 9b3fea206cd69dce985c2a84f482166dd1cfb8a7 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 01:45:51 +0100 Subject: [PATCH 27/85] Fix bug in model.simulate --- pybop/models/base_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index a89b755aa..a26a5f31f 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -359,7 +359,7 @@ def simulate(self, inputs, t_eval) -> np.ndarray[np.float64]: ) except Exception as e: print(f"Error: {e}") - return [np.inf] + return {signal: [np.inf] for signal in self.signal} else: return {signal: [np.inf] for signal in self.signal} From c6cc8cf7c9ebd765cbe443fee37b524b7fee5876 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 03:07:38 +0100 Subject: [PATCH 28/85] Update notebooks --- .../notebooks/multi_optimiser_identification.ipynb | 10 +++------- examples/notebooks/optimiser_calibration.ipynb | 2 +- pybop/optimisers/scipy_optimisers.py | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index d738d5fb4..b87185a3a 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -461,14 +461,10 @@ ], "source": [ "for optim in optims:\n", - " if isinstance(\n", - " optim.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution)\n", - " ):\n", - " print(f\"| Optimiser: {optim.optimiser.name()} | Results: {optim.result.x} |\")\n", + " if isinstance(optim, pybop.BaseSciPyOptimiser):\n", + " print(f\"| Optimiser: {optim.name()} | Results: {optim.result.x} |\")\n", " else:\n", - " print(\n", - " f\"| Optimiser: {optim.optimiser.name()} | Results: {optim.optimiser.x_best()} |\"\n", - " )" + " print(f\"| Optimiser: {optim.name()} | Results: {optim.method.x_best()} |\")" ] }, { diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 022bca8d7..9b4384339 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -486,7 +486,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.f_best()} | Results: {optim.x_best()} |\"\n", + " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.method.f_best()} | Results: {optim.method.x_best()} |\"\n", " )" ] }, diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 41db03151..5be075b9c 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -91,7 +91,7 @@ def _run(self): return result.x, self.cost(result.x) - def set_max_unchanged_iterations(self, **kwargs): + def set_max_unchanged_iterations(self, *args, **kwargs): """ Raise a warning that this stopping criterion is not used by this optimiser. """ From 4cb30f30b12205143fff8caf8ef701c829113bdf Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 10:22:17 +0100 Subject: [PATCH 29/85] Update test_models.py --- tests/unit/test_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 75c882388..2ad4980b2 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -269,5 +269,6 @@ def test_non_converged_solution(self): res = problem.evaluate([-0.2, -0.2]) _, res_grad = problem.evaluateS1([-0.2, -0.2]) - assert np.isinf(res).any() + for key in problem.signal: + assert np.isinf(res.get(key, [])).any() assert np.isinf(res_grad).any() From b2ada2148b37e75b58d8688fa7615ed2bd88224a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 10:32:02 +0100 Subject: [PATCH 30/85] Store SciPy result --- pybop/optimisers/scipy_optimisers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 5be075b9c..92a7b109d 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -86,10 +86,10 @@ def _run(self): final_cost : float The final cost associated with the best parameters. """ - result = self._run_optimiser() - self._iterations = result.nit + self.result = self._run_optimiser() + self._iterations = self.result.nit - return result.x, self.cost(result.x) + return self.result.x, self.cost(self.result.x) def set_max_unchanged_iterations(self, *args, **kwargs): """ From 89b80202efbc6c72c63b7606a8b765ee4079c6a7 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:14:55 +0100 Subject: [PATCH 31/85] Update x0 input and add tests --- pybop/_optimisation.py | 11 ++++----- pybop/optimisers/scipy_optimisers.py | 4 ++-- pybop/plotting/plot2d.py | 31 +++++++++++++------------ tests/unit/test_optimisation.py | 34 ++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 6866e3065..e3ac0b410 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -117,16 +117,16 @@ def _set_options(self, **optimiser_kwargs): """ return optimiser_kwargs - def run(self, x0=None, **optimiser_kwargs): + def run(self, **optimiser_kwargs): """ Run the optimisation and return the optimised parameters and final cost. Parameters ---------- - x0 : ndarray, optional - Initial guess for the parameters (default: None). **optimiser_kwargs : optional - Valid option keys and their values. + Valid option keys and their values, for example: + x0 : ndarray + Initial guess for the parameters. Returns ------- @@ -137,9 +137,6 @@ def run(self, x0=None, **optimiser_kwargs): """ self.set_options(**optimiser_kwargs) - if x0 is not None: - self.x0 = x0 - x, final_cost = self._run() # Store the optimised parameters diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 92a7b109d..06a14ae27 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -55,9 +55,9 @@ def _set_options(self, **optimiser_kwargs): """ # Unpack nested values from SciPy options dictionary if "options" in optimiser_kwargs.keys(): - options_list = list(optimiser_kwargs.keys()) + options_list = list(optimiser_kwargs["options"].keys()) for key in options_list: - if key not in optimiser_kwargs: + if key not in optimiser_kwargs.keys(): optimiser_kwargs[key] = optimiser_kwargs["options"].pop(key) else: optimiser_kwargs["options"].pop(key) # remove entry diff --git a/pybop/plotting/plot2d.py b/pybop/plotting/plot2d.py index 34e75ddd8..0a6c2ab02 100644 --- a/pybop/plotting/plot2d.py +++ b/pybop/plotting/plot2d.py @@ -128,22 +128,23 @@ def plot2d( ) # Plot the initial guess - fig.add_trace( - go.Scatter( - x=[optim.x0[0]], - y=[optim.x0[1]], - mode="markers", - marker_symbol="circle", - marker=dict( - color="mediumspringgreen", - line_color="mediumspringgreen", - line_width=1, - size=14, - showscale=False, - ), - showlegend=False, + if optim.x0 is not None: + fig.add_trace( + go.Scatter( + x=[optim.x0[0]], + y=[optim.x0[1]], + mode="markers", + marker_symbol="circle", + marker=dict( + color="mediumspringgreen", + line_color="mediumspringgreen", + line_width=1, + size=14, + showscale=False, + ), + showlegend=False, + ) ) - ) # Update the layout and display the figure fig.update_layout(**layout_kwargs) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 8c7fc88bf..df2068b47 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -119,19 +119,28 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim._max_iterations == 10 assert optim._iterations <= 10 + # Check and update bounds + assert optim.bounds == cost.bounds + bounds = {"upper": [0.61], "lower": [0.59]} + optim.run(bounds=bounds) + assert optim.bounds == bounds + if issubclass(optimiser, pybop.BasePintsOptimiser): - # PINTS method + optim.run( + use_f_guessed=True, + parallel=True, + min_iterations=3, + max_unchanged_iterations=5, + threshold=1e-2, + max_evaluations=20, + ) with pytest.raises( ValueError, match="Unrecognised keyword arguments", ): optim = optimiser(cost=cost, tol=1e-3) else: - # Check and update bounds - assert optim.bounds == cost.bounds - bounds = {"upper": [0.61], "lower": [0.59]} - optim.run(bounds=bounds) - assert optim.bounds == bounds + # Check bounds in list format bounds = [ (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ] @@ -142,6 +151,10 @@ def test_optimiser_kwargs(self, cost, optimiser): optim.run(tol=1e-2) assert optim.tol == 1e-2 + # Pass nested options + options = dict(maxiter=10) + optim.run(options=options) + if optimiser in [pybop.SciPyDifferentialEvolution]: # Check and update population size with pytest.raises(ValueError): @@ -150,7 +163,14 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim.popsize == 10 optim.run(popsize=5) assert optim.popsize == 5 - elif optimiser in [pybop.SciPyMinimize]: + else: + # Check and update initial values + assert optim.x0 == cost.x0 + x0_new = cost.x0 * 1.01 + optim.run(x0=x0_new) + assert optim.x0 == x0_new + + if optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information optim.run(method="L-BFGS-B", jac=True) with pytest.raises( From 8e3d75643922b0acb525bc6c40cbb894de170d7c Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:00:37 +0100 Subject: [PATCH 32/85] Update bounds to avoid x0 outside --- tests/unit/test_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index df2068b47..32a7cbbee 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -121,7 +121,7 @@ def test_optimiser_kwargs(self, cost, optimiser): # Check and update bounds assert optim.bounds == cost.bounds - bounds = {"upper": [0.61], "lower": [0.59]} + bounds = {"upper": [0.63], "lower": [0.57]} optim.run(bounds=bounds) assert optim.bounds == bounds From 85e1fe52344f0e970b2de7ffae47133eabd117b6 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:01:44 +0100 Subject: [PATCH 33/85] Re-initialise pints_method on certain options --- pybop/optimisers/base_optimiser.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index c4da9a4ad..fec12eb14 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -40,11 +40,12 @@ class BasePintsOptimiser(Optimisation): def __init__(self, cost, pints_method, **optimiser_kwargs): self.__dict__.update(DEFAULT_PINTS_OPTIMISER_OPTIONS) + self.pints_method = pints_method super().__init__(cost, **optimiser_kwargs) # Create an instance of the PINTS optimiser class if issubclass(pints_method, pints.Optimiser): - self.method = pints_method(self.x0, self.sigma0, self._boundaries) + self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) else: raise ValueError("The pints_method is not recognised as a PINTS optimiser.") @@ -66,12 +67,18 @@ def _set_options(self, **optimiser_kwargs): optimiser_kwargs : dict Remaining option keys and their values. """ + reinit_required = False + key_list = list(optimiser_kwargs.keys()) for key in key_list: if key == "x0": self.x0 = optimiser_kwargs.pop(key) # Convert x0 to PINTS vector self._x0 = pints.vector(self.x0) + reinit_required = True + elif key == "sigma0": + self.sigma0 = optimiser_kwargs.pop(key) + reinit_required = True elif key == "bounds": # Convert bounds to PINTS boundaries self.bounds = optimiser_kwargs.pop(key) @@ -81,6 +88,7 @@ def _set_options(self, **optimiser_kwargs): ) else: self._boundaries = None + reinit_required = True elif key == "use_f_guessed": self.set_f_guessed_tracking(optimiser_kwargs.pop(key)) elif key == "parallel": @@ -102,6 +110,9 @@ def _set_options(self, **optimiser_kwargs): elif key == "max_evaluations": self.set_max_evaluations(optimiser_kwargs.pop(key)) + if reinit_required: + self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) + return optimiser_kwargs def name(self): From 9b3fca30939ac4c86d999b6463ed2d35819b478d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:26:12 +0100 Subject: [PATCH 34/85] Update x0_new test --- tests/unit/test_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 32a7cbbee..1a718551b 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -166,7 +166,7 @@ def test_optimiser_kwargs(self, cost, optimiser): else: # Check and update initial values assert optim.x0 == cost.x0 - x0_new = cost.x0 * 1.01 + x0_new = np.array([0.6]) optim.run(x0=x0_new) assert optim.x0 == x0_new From 62fcb607212de5cef7f0bb64ec92b42dc61432e2 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:32:19 +0100 Subject: [PATCH 35/85] Update test_optimisation.py --- tests/unit/test_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 1a718551b..36a1c41d9 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -128,7 +128,7 @@ def test_optimiser_kwargs(self, cost, optimiser): if issubclass(optimiser, pybop.BasePintsOptimiser): optim.run( use_f_guessed=True, - parallel=True, + parallel=False, min_iterations=3, max_unchanged_iterations=5, threshold=1e-2, From 91b86deba26c79c6f22ecc778de215218d0f36c4 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:48:49 +0100 Subject: [PATCH 36/85] Update test_optimisation.py --- tests/unit/test_optimisation.py | 39 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 36a1c41d9..6f72e87c3 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -105,7 +105,17 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): @pytest.mark.parametrize( "optimiser", - [pybop.XNES, pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution], + [ + pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, + pybop.GradientDescent, + pybop.Adam, + pybop.SNES, + pybop.XNES, + pybop.PSO, + pybop.IRPropMin, + pybop.NelderMead, + ], ) @pytest.mark.unit def test_optimiser_kwargs(self, cost, optimiser): @@ -119,11 +129,15 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim._max_iterations == 10 assert optim._iterations <= 10 - # Check and update bounds - assert optim.bounds == cost.bounds - bounds = {"upper": [0.63], "lower": [0.57]} - optim.run(bounds=bounds) - assert optim.bounds == bounds + if issubclass(optimiser, (pybop.GradientDescent, pybop.Adam, pybop.NelderMead)): + # Unused bounds + assert optim.bounds is None + else: + # Check and update bounds + assert optim.bounds == cost.bounds + bounds = {"upper": [0.63], "lower": [0.57]} + optim.run(bounds=bounds) + assert optim.bounds == bounds if issubclass(optimiser, pybop.BasePintsOptimiser): optim.run( @@ -140,20 +154,23 @@ def test_optimiser_kwargs(self, cost, optimiser): ): optim = optimiser(cost=cost, tol=1e-3) else: - # Check bounds in list format + # Check bounds in list format and update tol bounds = [ (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ] - optim.run(bounds=bounds) + optim.run(bounds=bounds, tol=1e-2) assert optim.bounds == bounds - - # Update tol - optim.run(tol=1e-2) assert optim.tol == 1e-2 # Pass nested options options = dict(maxiter=10) optim.run(options=options) + optim.run(maxiter=5, options=options) + assert optim._max_iterations == 5 + + # Trigger unused input warning + with pytest.raises(ValueError): + optim.run(max_unchanged_iterations=5, threshold=1e-2) if optimiser in [pybop.SciPyDifferentialEvolution]: # Check and update population size From b5fb981986901ac8928b53215f1db64e410c3d2b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:44:00 +0100 Subject: [PATCH 37/85] Create initialise_method for PINTS optimisers --- pybop/optimisers/base_optimiser.py | 61 ++++++++++++++++------------ pybop/optimisers/pints_optimisers.py | 15 ++----- tests/unit/test_optimisation.py | 4 +- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index fec12eb14..c73a6c57a 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -43,14 +43,8 @@ def __init__(self, cost, pints_method, **optimiser_kwargs): self.pints_method = pints_method super().__init__(cost, **optimiser_kwargs) - # Create an instance of the PINTS optimiser class - if issubclass(pints_method, pints.Optimiser): - self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) - else: - raise ValueError("The pints_method is not recognised as a PINTS optimiser.") - - # Check if sensitivities are required - self._needs_sensitivities = self.method.needs_sensitivities() + # Create an instance of the PINTS optimiser class + self.initialise_method() def _set_options(self, **optimiser_kwargs): """ @@ -71,23 +65,8 @@ def _set_options(self, **optimiser_kwargs): key_list = list(optimiser_kwargs.keys()) for key in key_list: - if key == "x0": - self.x0 = optimiser_kwargs.pop(key) - # Convert x0 to PINTS vector - self._x0 = pints.vector(self.x0) - reinit_required = True - elif key == "sigma0": - self.sigma0 = optimiser_kwargs.pop(key) - reinit_required = True - elif key == "bounds": - # Convert bounds to PINTS boundaries - self.bounds = optimiser_kwargs.pop(key) - if self.bounds is not None: - self._boundaries = pints.RectangularBoundaries( - self.bounds["lower"], self.bounds["upper"] - ) - else: - self._boundaries = None + if key in ["x0", "sigma0", "bounds"]: + self.__dict__.update({key: optimiser_kwargs.pop(key)}) reinit_required = True elif key == "use_f_guessed": self.set_f_guessed_tracking(optimiser_kwargs.pop(key)) @@ -111,10 +90,40 @@ def _set_options(self, **optimiser_kwargs): self.set_max_evaluations(optimiser_kwargs.pop(key)) if reinit_required: - self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) + self.initialise_method() return optimiser_kwargs + def initialise_method(self): + """ + Creates an instance of the PINTS optimiser class. + """ + if issubclass(self.pints_method, pints.Optimiser): + self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) + else: + raise ValueError("The pints_method is not recognised as a PINTS optimiser.") + + # Convert x0 to PINTS vector + self._x0 = pints.vector(self.x0) + + # Convert bounds to PINTS boundaries + if self.bounds is not None: + if issubclass( + self.pints_method, (pints.GradientDescent, pints.Adam, pints.NelderMead) + ): + print(f"NOTE: Boundaries ignored by {self.pints_method}") + self.bounds = None + self._boundaries = None + else: + self._boundaries = pints.RectangularBoundaries( + self.bounds["lower"], self.bounds["upper"] + ) + else: + self._boundaries = None + + # Check if sensitivities are required + self._needs_sensitivities = self.method.needs_sensitivities() + def name(self): """ Provides the name of the optimisation strategy. diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 5fb8e16de..f8ac7b985 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -57,12 +57,9 @@ class GradientDescent(BasePintsOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - if optimiser_kwargs.pop("bounds", None) is not None: - print("NOTE: Boundaries ignored by Gradient Descent") - bounds = None # Bounds ignored in pints.GradientDescent if "sigma0" not in optimiser_kwargs.keys(): optimiser_kwargs["sigma0"] = 0.02 # set default - super().__init__(cost, pints.GradientDescent, bounds=bounds, **optimiser_kwargs) + super().__init__(cost, pints.GradientDescent, **optimiser_kwargs) class Adam(BasePintsOptimiser): @@ -88,10 +85,7 @@ class Adam(BasePintsOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - if optimiser_kwargs.pop("bounds", None) is not None: - print("NOTE: Boundaries ignored by Adam") - bounds = None # Bounds ignored in pints.Adam - super().__init__(cost, pints.Adam, bounds=bounds, **optimiser_kwargs) + super().__init__(cost, pints.Adam, **optimiser_kwargs) class IRPropMin(BasePintsOptimiser): @@ -246,10 +240,7 @@ class NelderMead(BasePintsOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - if optimiser_kwargs.pop("bounds", None) is not None: - print("NOTE: Boundaries ignored by NelderMead") - bounds = None # Bounds ignored in pints.NelderMead - super().__init__(cost, pints.NelderMead, bounds=bounds, **optimiser_kwargs) + super().__init__(cost, pints.NelderMead, **optimiser_kwargs) class CMAES(BasePintsOptimiser): diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 6f72e87c3..c63178bcf 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -131,6 +131,7 @@ def test_optimiser_kwargs(self, cost, optimiser): if issubclass(optimiser, (pybop.GradientDescent, pybop.Adam, pybop.NelderMead)): # Unused bounds + optim.run(bounds=cost.bounds) assert optim.bounds is None else: # Check and update bounds @@ -169,8 +170,7 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim._max_iterations == 5 # Trigger unused input warning - with pytest.raises(ValueError): - optim.run(max_unchanged_iterations=5, threshold=1e-2) + optim.set_max_unchanged_iterations(5) if optimiser in [pybop.SciPyDifferentialEvolution]: # Check and update population size From 08e752ff298e41f8ef224b0fa3db16ae9d843eaa Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:08:33 +0100 Subject: [PATCH 38/85] Align optimisation result --- .../multi_optimiser_identification.ipynb | 7 +-- pybop/optimisers/base_optimiser.py | 45 +++++++++++++++---- pybop/optimisers/scipy_optimisers.py | 4 +- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index b87185a3a..b275e3747 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -461,10 +461,7 @@ ], "source": [ "for optim in optims:\n", - " if isinstance(optim, pybop.BaseSciPyOptimiser):\n", - " print(f\"| Optimiser: {optim.name()} | Results: {optim.result.x} |\")\n", - " else:\n", - " print(f\"| Optimiser: {optim.name()} | Results: {optim.method.x_best()} |\")" + " print(f\"| Optimiser: {optim.name()} | Results: {optim.result.x} |\")" ] }, { @@ -974,7 +971,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.7" + "version": "3.10.12" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index c73a6c57a..4a195009e 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -27,15 +27,15 @@ class BasePintsOptimiser(Optimisation): Parameters ---------- - x0 : array_like - Initial position from which optimization will start. - sigma0 : float, optional - Initial step size or standard deviation depending on the optimiser (default is 0.1). - bounds : dict, optional - A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper - bounds on the parameters. **optimiser_kwargs : optional - Valid PINTS option keys and their values. + Valid PINTS option keys and their values, for example: + x0 : array_like + Initial position from which optimization will start. + sigma0 : float + Initial step size or standard deviation depending on the optimiser. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. """ def __init__(self, cost, pints_method, **optimiser_kwargs): @@ -163,6 +163,9 @@ def _run(self): iteration = 0 evaluations = 0 + # Empty result + self.result = Result() + # Unchanged iterations counter unchanged_iterations = 0 @@ -315,6 +318,11 @@ def _run(self): if self._transformation is not None: x = self._transformation.to_model(x) + # Store result + self.result.x = x + self.result.final_cost = f + self.result.nit = self._iterations + # Return best position and the score used internally, # i.e the negative log-likelihood in the case of # self._minimising = False @@ -423,3 +431,24 @@ def set_max_evaluations(self, evaluations=None): if evaluations < 0: raise ValueError("Maximum number of evaluations cannot be negative.") self._max_evaluations = evaluations + + +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. + + """ + + def __init__(self): + self.x = None + self.final_cost = None + self.nit = None diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 06a14ae27..eabf7ffae 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -87,9 +87,11 @@ def _run(self): The final cost associated with the best parameters. """ self.result = self._run_optimiser() + + self.result.final_cost = self.cost(self.result.x) self._iterations = self.result.nit - return self.result.x, self.cost(self.result.x) + return self.result.x, self.result.final_cost def set_max_unchanged_iterations(self, *args, **kwargs): """ From e48e91b1721c7b185462b887c5d1bdb400b532c2 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:42:38 +0100 Subject: [PATCH 39/85] Update checks on bounds --- pybop/optimisers/base_optimiser.py | 9 +++++++++ pybop/optimisers/pints_optimisers.py | 13 ------------- tests/unit/test_optimisation.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 4a195009e..39c093621 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -114,6 +114,15 @@ def initialise_method(self): print(f"NOTE: Boundaries ignored by {self.pints_method}") self.bounds = None self._boundaries = None + elif issubclass(self.pints_method, pints.PSO): + 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: self._boundaries = pints.RectangularBoundaries( self.bounds["lower"], self.bounds["upper"] diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index f8ac7b985..62fb9ed13 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -1,4 +1,3 @@ -import numpy as np import pints from pybop import BasePintsOptimiser @@ -143,18 +142,6 @@ class PSO(BasePintsOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - if ( - "bounds" in optimiser_kwargs.keys() - and optimiser_kwargs["bounds"] is not None - ): - if not all( - np.isfinite(value) - for sublist in optimiser_kwargs["bounds"].values() - for value in sublist - ): - raise ValueError( - "Either all bounds or no bounds must be set for Pints PSO." - ) super().__init__(cost, pints.PSO, **optimiser_kwargs) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index c63178bcf..b112c9e24 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -133,6 +133,17 @@ def test_optimiser_kwargs(self, cost, optimiser): # Unused bounds optim.run(bounds=cost.bounds) assert optim.bounds is None + elif issubclass(optimiser, pybop.PSO): + assert optim.bounds == cost.bounds + # Cannot accept infinite bounds + bounds = {"upper": [np.inf], "lower": [0.57]} + with pytest.raises( + ValueError, + match="Either all bounds or no bounds must be set", + ): + optim.run(bounds=bounds) + # Reset + optim.set_options(bounds=cost.bounds) else: # Check and update bounds assert optim.bounds == cost.bounds @@ -180,6 +191,15 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim.popsize == 10 optim.run(popsize=5) assert optim.popsize == 5 + + # Test invalid bounds + with pytest.raises(ValueError): + optim.run(bounds=None) + with pytest.raises(ValueError): + optim.run(bounds=[(0, np.inf)]) + with pytest.raises(ValueError): + optim.run(bounds={"upper": [np.inf], "lower": [0.57]}) + else: # Check and update initial values assert optim.x0 == cost.x0 From d65e0f5a9bd5ff48831719db7abd057d827d043b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:03:46 +0100 Subject: [PATCH 40/85] Make DefaultOptimiser a subclass --- pybop/optimisers/pints_optimisers.py | 37 ++++++---------------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 62fb9ed13..74a5110e1 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -3,36 +3,6 @@ from pybop import BasePintsOptimiser -class DefaultOptimiser(BasePintsOptimiser): - """ - Provides a default option for new users, selected to be the Exponential Natural - Evolution Strategy (XNES) optimiser from PINTS. - - XNES is an evolutionary algorithm that samples from a multivariate normal - distribution, which is updated iteratively to fit the distribution of successful - solutions. - - Parameters - ---------- - **optimiser_kwargs : optional - Valid PINTS option keys and their values, for example: - x0 : array_like - The initial parameter vector to optimize. - sigma0 : float - Initial standard deviation of the sampling distribution. - bounds : dict - A dictionary with 'lower' and 'upper' keys containing arrays for lower and - upper bounds on the parameters. If ``None``, no bounds are enforced. - - See Also - -------- - pybop.XNES : PINTS implementation of XNES algorithm. - """ - - def __init__(self, cost, **optimiser_kwargs): - super().__init__(cost, pints.XNES, **optimiser_kwargs) - - class GradientDescent(BasePintsOptimiser): """ Implements a simple gradient descent optimization algorithm. @@ -263,3 +233,10 @@ def __init__(self, cost, **optimiser_kwargs): + "Please choose another optimiser." ) super().__init__(cost, pints.CMAES, **optimiser_kwargs) + + +class DefaultOptimiser(XNES): + """ + Provides a default option for new users, selected to be the Exponential Natural + Evolution Strategy (XNES) optimiser from PINTS. + """ From 936b159a2566721d0ce796da5158586809729971 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 27 Apr 2024 19:07:13 +0100 Subject: [PATCH 41/85] Reset --- examples/notebooks/multi_optimiser_identification.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index b275e3747..5904a0f42 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -971,7 +971,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.11.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { From 5800c7517a2b10dc105e8d41a049d0f4667331f2 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 27 Apr 2024 19:14:53 +0100 Subject: [PATCH 42/85] Reset semicolons --- .../notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb | 6 +++--- examples/notebooks/equivalent_circuit_identification.ipynb | 4 ++-- examples/notebooks/pouch_cell_identification.ipynb | 6 +++--- examples/notebooks/spm_electrode_design.ipynb | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb index 4e735aa09..3181d952a 100644 --- a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb +++ b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb @@ -1679,7 +1679,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" ] }, { @@ -1726,7 +1726,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim)" + "pybop.plot_parameters(optim);" ] }, { @@ -1850,7 +1850,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Parameter Extrapolation\")" + "pybop.quick_plot(problem, parameter_values=x, title=\"Parameter Extrapolation\");" ] }, { diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index fef32061e..d2eff1bc4 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -457,7 +457,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" ] }, { @@ -504,7 +504,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim)" + "pybop.plot_parameters(optim);" ] }, { diff --git a/examples/notebooks/pouch_cell_identification.ipynb b/examples/notebooks/pouch_cell_identification.ipynb index 18a3f143b..27b7a33f9 100644 --- a/examples/notebooks/pouch_cell_identification.ipynb +++ b/examples/notebooks/pouch_cell_identification.ipynb @@ -517,7 +517,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" ] }, { @@ -2610,7 +2610,7 @@ ], "source": [ "pybop.plot_convergence(optim)\n", - "pybop.plot_parameters(optim)" + "pybop.plot_parameters(optim);" ] }, { @@ -2645,7 +2645,7 @@ } ], "source": [ - "pybop.plot2d(optim, steps=15)" + "pybop.plot2d(optim, steps=15);" ] } ], diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index 80cefbc64..a1840c523 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -330,7 +330,7 @@ "source": [ "if cost.update_capacity:\n", " problem._model.approximate_capacity(x)\n", - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\")" + "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" ] }, { From 0fdd511ca6f8dbc7d2cbdd23feb5e80e3dc4a7cf Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Sat, 27 Apr 2024 19:28:45 +0100 Subject: [PATCH 43/85] Apply suggestions Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- pybop/optimisers/pints_optimisers.py | 16 ++++++++-------- pybop/optimisers/scipy_optimisers.py | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 74a5110e1..da0390d57 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -16,7 +16,7 @@ class GradientDescent(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - Initial position from which optimization will start. + Initial position from which optimisation will start. sigma0 : float The learning rate / Initial step size (default: 0.02). @@ -44,7 +44,7 @@ class Adam(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - Initial position from which optimization will start. + Initial position from which optimisation will start. sigma0 : float Initial step size. @@ -70,7 +70,7 @@ class IRPropMin(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - Initial position from which optimization will start. + Initial position from which optimisation will start. sigma0 : float Initial step size. bounds : dict @@ -99,7 +99,7 @@ class PSO(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - Initial positions of particles, which the optimization will use. + Initial positions of particles, which the optimisation will use. sigma0 : float Spread of the initial particle positions. bounds : dict @@ -128,7 +128,7 @@ class SNES(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - Initial position from which optimization will start. + Initial position from which optimisation will start. sigma0 : float Initial standard deviation of the sampling distribution. bounds : dict @@ -157,7 +157,7 @@ class XNES(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - The initial parameter vector to optimize. + The initial parameter vector to optimise. sigma0 : float Initial standard deviation of the sampling distribution. bounds : dict @@ -186,7 +186,7 @@ class NelderMead(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - The initial parameter vector to optimize. + The initial parameter vector to optimise. sigma0 : float Initial standard deviation of the sampling distribution. Does not appear to be used. @@ -213,7 +213,7 @@ class CMAES(BasePintsOptimiser): **optimiser_kwargs : optional Valid PINTS option keys and their values, for example: x0 : array_like - The initial parameter vector to optimize. + The initial parameter vector to optimise. sigma0 : float Initial standard deviation of the sampling distribution. bounds : dict diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index eabf7ffae..c65b6803b 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -114,8 +114,6 @@ class SciPyMinimize(BaseSciPyOptimiser): Valid SciPy Minimize option keys and their values, For example: x0 : array_like Initial position from which optimisation will start. - sigma0 : float - Initial step size or standard deviation depending on the optimiser. bounds : dict, sequence or scipy.optimize.Bounds Bounds for variables as supported by the selected method. method : str @@ -251,7 +249,7 @@ def _run_optimiser(self): print( "Ignoring x0. Initial conditions are not used for differential_evolution." ) - self.x0 = None + self.x0 = None # Add callback storing history of parameter values def callback(x, convergence): From dc1eb620646615850e8c7b276e447b45d04d655a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 14:34:10 +0100 Subject: [PATCH 44/85] Add standalone optimiser --- examples/standalone/cost.py | 2 +- examples/standalone/optimiser.py | 90 ++++++++++++++++++++++++++++++++ tests/unit/test_standalone.py | 12 ++++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 examples/standalone/optimiser.py diff --git a/examples/standalone/cost.py b/examples/standalone/cost.py index 149acd567..c3763ff4c 100644 --- a/examples/standalone/cost.py +++ b/examples/standalone/cost.py @@ -48,7 +48,7 @@ def __init__(self, problem=None): upper=[10], ) - def __call__(self, x, grad=None): + def _evaluate(self, x, grad=None): """ Calculate the cost for a given parameter value. diff --git a/examples/standalone/optimiser.py b/examples/standalone/optimiser.py new file mode 100644 index 000000000..ab437d9aa --- /dev/null +++ b/examples/standalone/optimiser.py @@ -0,0 +1,90 @@ +import numpy as np +from scipy.optimize import minimize + +from pybop import Optimisation + + +class StandaloneOptimiser(Optimisation): + """ + Defines an example standalone optimiser without a Cost. + """ + + def __init__(self, cost=None, **optimiser_kwargs): + # Define cost function + def cost(x): + x1, x2 = x + return (x1 - 2) ** 2 + (x2 - 4) ** 4 + + # Set cost function, initial values and 0ther options + optimiser_options = dict( + x0=np.array([0, 0]), + bounds=None, + method="Nelder-Mead", + jac=False, + maxiter=100, + ) + optimiser_options.update(optimiser_kwargs) + super().__init__(cost, **optimiser_options) + + def _set_up_optimiser(self): + """ + Parse optimiser options. + """ + # Reformat bounds + if isinstance(self.bounds, dict): + self._scipy_bounds = [ + (lower, upper) + for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) + ] + else: + self._scipy_bounds = self.bounds + + # Parse additional options and remove them from the options dictionary + self._options = self.unset_options + self.unset_options = dict() + self._options["options"] = self._options.pop("options", dict()) + if "maxiter" in self._options.keys(): + # Nest this option within an options dictionary for SciPy minimize + self._options["options"]["maxiter"] = self._options.pop("maxiter") + + def _run(self): + """ + Executes the optimisation process using SciPy's minimize function. + + Returns + ------- + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. + """ + self.log = [[self.x0]] + + # Add callback storing history of parameter values + def callback(x): + self.log.append([x]) + + # Run optimiser + self.result = minimize( + self.cost, + self.x0, + bounds=self._scipy_bounds, + callback=callback, + **self._options, + ) + + self.result.final_cost = self.cost(self.result.x) + self._iterations = self.result.nit + + return self.result.x, self.result.final_cost + + def name(self): + """ + Provides the name of the optimization strategy. + + Returns + ------- + str + The name 'SciPyMinimize'. + """ + return "StandaloneOptimiser" diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index ea7f208f8..86464ee84 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -3,6 +3,7 @@ import pybop from examples.standalone.cost import StandaloneCost +from examples.standalone.optimiser import StandaloneOptimiser from examples.standalone.problem import StandaloneProblem @@ -12,13 +13,20 @@ class TestStandalone: """ @pytest.mark.unit - def test_standalone(self): + def test_standalone_optimiser(self): + optim = StandaloneOptimiser() + x, final_cost = optim.run() + + assert optim.cost(optim.x0) > final_cost + np.testing.assert_allclose(x, [2, 4], atol=1e-2) + + @pytest.mark.unit + def test_standalone_cost(self): # Build an Optimisation problem with a StandaloneCost cost = StandaloneCost() optim = pybop.DefaultOptimiser(cost=cost) x, final_cost = optim.run() - assert len(optim.x0) == optim._n_parameters np.testing.assert_allclose(x, 0, atol=1e-2) np.testing.assert_allclose(final_cost, 42, atol=1e-2) From f192426e792f684da0fef618b28bf610247d24e9 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 15:07:40 +0100 Subject: [PATCH 45/85] Simplify optimiser set-up and align _minimising --- CHANGELOG.md | 2 +- examples/scripts/spme_max_energy.py | 14 +- pybop/_optimisation.py | 163 +++++------- pybop/costs/_likelihoods.py | 1 + pybop/costs/base_cost.py | 12 +- pybop/costs/design_costs.py | 28 +- pybop/optimisers/base_optimiser.py | 196 ++++++++------ pybop/optimisers/scipy_optimisers.py | 251 +++++++++--------- pybop/plotting/plot_convergence.py | 20 +- .../integration/test_optimisation_options.py | 8 +- tests/integration/test_parameterisations.py | 18 +- tests/unit/test_cost.py | 10 +- tests/unit/test_optimisation.py | 91 +++---- 13 files changed, 411 insertions(+), 403 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a13534d9..a79b197f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes to allow the passing of keyword arguments and fixes the setting of max_iterations and minimising. - [#304](https://github.com/pybop-team/PyBOP/pull/304) - Decreases the testing suite completion time. - [#301](https://github.com/pybop-team/PyBOP/pull/301) - Updates default echem solver to "fast with events" mode. - [#251](https://github.com/pybop-team/PyBOP/pull/251) - Increment PyBaMM > v23.5, remove redundant tests within integration tests, increment citation version, fix examples with incorrect model definitions. @@ -18,7 +19,6 @@ codesigned binaries and source distributions via `sigstore-python`. ## Bug Fixes -- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes to allow the passing of keyword arguments and fixes the setting of max_iterations. - [#317](https://github.com/pybop-team/PyBOP/pull/317) - Installs seed packages into `nox` sessions, ensuring that scheduled tests can pass. - [#308](https://github.com/pybop-team/PyBOP/pull/308) - Enables testing on both macOS Intel and macOS ARM (Silicon) runners and fixes the scheduled tests. - [#299](https://github.com/pybop-team/PyBOP/pull/299) - Bugfix multiprocessing support for Linux, MacOS, Windows (WSL) and improves coverage. diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index 30c771600..2824a446a 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -23,12 +23,12 @@ parameters = [ pybop.Parameter( "Positive electrode thickness [m]", - prior=pybop.Gaussian(7.56e-05, 0.05e-05), + prior=pybop.Gaussian(7.56e-05, 0.1e-05), bounds=[65e-06, 10e-05], ), pybop.Parameter( "Positive particle radius [m]", - prior=pybop.Gaussian(5.22e-06, 0.05e-06), + prior=pybop.Gaussian(5.22e-06, 0.1e-06), bounds=[2e-06, 9e-06], ), ] @@ -47,13 +47,15 @@ # Generate cost function and optimisation class: cost = pybop.GravimetricEnergyDensity(problem) -optim = pybop.PSO(cost, verbose=True, allow_infeasible_solutions=False) +optim = pybop.PSO( + cost, verbose=True, allow_infeasible_solutions=False, max_iterations=15 +) # Run optimisation -x, final_cost = optim.run(max_iterations=15) +x, final_cost = optim.run() print("Estimated parameters:", x) -print(f"Initial gravimetric energy density: {-cost(cost.x0):.2f} Wh.kg-1") -print(f"Optimised gravimetric energy density: {-final_cost:.2f} Wh.kg-1") +print(f"Initial gravimetric energy density: {cost(cost.x0):.2f} Wh.kg-1") +print(f"Optimised gravimetric energy density: {final_cost:.2f} Wh.kg-1") # Plot the timeseries output if cost.update_capacity: diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index e3ac0b410..9a7574f83 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -1,16 +1,6 @@ import warnings -import pybop - -DEFAULT_OPTIMISER_OPTIONS = dict( - x0=None, - bounds=None, - sigma0=0.1, - verbose=False, - physical_viability=True, - allow_infeasible_solutions=True, - _max_iterations=None, -) +from pybop import BaseCost class Optimisation: @@ -19,21 +9,15 @@ class Optimisation: 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_options and the _run method with a - specific algorithm. + 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 - sigma0 : float or sequence, optional - Initial step size or standard deviation for the optimiser (default: None). - verbose : bool, optional - If True, the optimisation progress is printed (default: False). - 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). + **optimiser_kwargs : optional + Valid option keys and their values. Attributes ---------- @@ -41,12 +25,16 @@ class Optimisation: Initial parameter values for the optimisation. bounds : dict Dictionary containing the parameter bounds with keys 'lower' and 'upper'. - _n_parameters : int - Number of parameters in the optimisation problem. sigma0 : float or sequence Initial step size or standard deviation for the optimiser. + verbose : bool, optional + If True, the optimisation progress is printed (default: False). + 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 : list - Log of the optimisation process. + A log of the parameter values tried during the optimisation. """ def __init__( @@ -54,80 +42,74 @@ def __init__( cost, **optimiser_kwargs, ): - self.cost = cost - self.__dict__.update(DEFAULT_OPTIMISER_OPTIONS) - if isinstance(cost, pybop.BaseCost): + # First set attributes to default values + self.x0 = None + self.bounds = None + self.sigma0 = 0.1 + self.verbose = False + self.log = [] + self._minimising = True + self.physical_viability = False + self.allow_infeasible_solutions = False + self.default_max_iterations = 1000 + self.result = None + + if isinstance(cost, BaseCost): + self.cost = cost self.x0 = cost.x0 self.bounds = cost.bounds self.sigma0 = cost.sigma0 - self._n_parameters = cost._n_parameters - self.log = [] - self.set_max_iterations() - self.set_allow_infeasible_solutions() - self.set_options(**optimiser_kwargs) + self._minimising = cost._minimising + self.set_allow_infeasible_solutions() + else: + warnings.warn( + "The cost is not an instance of pybop.BaseCost. Continuing " + + "under the assumption that it is a callable function.", + UserWarning, + ) + self.cost = BaseCost() - # Check if minimising or maximising - if isinstance(self.cost, pybop.BaseLikelihood): - self.cost._minimising = False - self._minimising = self.cost._minimising + def cost_evaluate(x, grad=None): + return cost(x) - def set_options(self, **optimiser_kwargs): - """ - Update the optimiser options and check that all have been applied. + self.cost._evaluate = cost_evaluate - Parameters - ---------- - **optimiser_kwargs : optional - Valid option keys and their values. - """ - # Update and remove optimiser options from the optimiser_kwargs dictionary - # in the child class first and then in this base class - optimiser_kwargs = self._set_options(**optimiser_kwargs) + self.unset_options = optimiser_kwargs + self.set_base_options() + self._set_up_optimiser() - key_list = list(optimiser_kwargs.keys()) + # Throw an error if any options remain + if self.unset_options: + raise ValueError(f"Unrecognised keyword arguments: {self.unset_options}") + + def set_base_options(self): + """ + Update the base optimiser options and remove them from the options dictionary. + """ + key_list = list(self.unset_options.keys()) for key in key_list: if key in ["x0", "bounds", "sigma0", "verbose"]: - self.__dict__.update({key: optimiser_kwargs.pop(key)}) + self.__dict__.update({key: self.unset_options.pop(key)}) elif key == "allow_infeasible_solutions": - self.allow_infeasible_solutions = self.set_allow_infeasible_solutions( - optimiser_kwargs.pop(key) - ) - elif key == "parallel": - self.set_parallel(optimiser_kwargs.pop(key)) + self.set_allow_infeasible_solutions(self.unset_options.pop(key)) - # Throw an error if any arguments remain - if optimiser_kwargs: - raise ValueError(f"Unrecognised keyword arguments: {optimiser_kwargs}") - - def _set_options(self, **optimiser_kwargs): + def _set_up_optimiser(self): """ - Update the optimiser options and remove the corresponding entries from the - optimiser_kwargs dictionary in advance of passing to the parent class - - this function is to be overwritten by child classes. + Parse optimiser options and prepare the optimiser. - Parameters - ---------- - **optimiser_kwargs : optional - Valid option keys and their values. + This method should be implemented by child classes. - Returns - ------- - optimiser_kwargs : dict - Remaining option keys and their values. + Raises + ------ + NotImplementedError + If the method has not been implemented by the subclass. """ - return optimiser_kwargs + raise NotImplementedError - def run(self, **optimiser_kwargs): + def run(self): """ Run the optimisation and return the optimised parameters and final cost. - Parameters - ---------- - **optimiser_kwargs : optional - Valid option keys and their values, for example: - x0 : ndarray - Initial guess for the parameters. - Returns ------- x : numpy.ndarray @@ -135,12 +117,10 @@ def run(self, **optimiser_kwargs): final_cost : float The final cost associated with the best parameters. """ - self.set_options(**optimiser_kwargs) - x, final_cost = self._run() # Store the optimised parameters - if self.cost.problem is not None: + if hasattr(self.cost, "parameters"): self.store_optimised_parameters(x) # Store the log @@ -207,23 +187,6 @@ def name(self): """ return "Optimisation" - def set_max_iterations(self, iterations=1000): - """ - Set the maximum number of iterations as a stopping criterion. - Credit: PINTS - - Parameters - ---------- - iterations : int, optional - The maximum number of iterations to run. - Set to `None` to remove this stopping criterion. - """ - if iterations is not None: - iterations = int(iterations) - if iterations < 0: - raise ValueError("Maximum number of iterations cannot be negative.") - self._max_iterations = iterations - def set_allow_infeasible_solutions(self, allow=True): """ Set whether to allow infeasible solutions or not. @@ -237,7 +200,7 @@ def set_allow_infeasible_solutions(self, allow=True): self.physical_viability = allow self.allow_infeasible_solutions = allow - if self.cost.problem is not None and hasattr(self.cost.problem, "_model"): + if hasattr(self.cost, "problem") and hasattr(self.cost.problem, "_model"): self.cost.problem._model.allow_infeasible_solutions = ( self.allow_infeasible_solutions ) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 91374cc07..545eba4cc 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -11,6 +11,7 @@ class BaseLikelihood(BaseCost): def __init__(self, problem, sigma=None): super(BaseLikelihood, self).__init__(problem, sigma) self.n_time_data = problem.n_time_data + self._minimising = False def set_sigma(self, sigma): """ diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index f3ac95170..1160ba9e8 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -53,13 +53,13 @@ def __init__(self, problem=None, sigma=None): def n_parameters(self): return self._n_parameters - def __call__(self, x, grad=None): + def __call__(self, x, grad=None, minimising=True): """ Call the evaluate function for a given set of parameters. """ - return self.evaluate(x, grad) + return self.evaluate(x, grad, minimising) - def evaluate(self, x, grad=None): + def evaluate(self, x, grad=None, minimising=True): """ Call the evaluate function for a given set of parameters. @@ -82,7 +82,7 @@ def evaluate(self, x, grad=None): If an error occurs during the calculation of the cost. """ try: - if self._minimising: + if minimising: return self._evaluate(x, grad) else: # minimise the negative cost return -self._evaluate(x, grad) @@ -119,7 +119,7 @@ def _evaluate(self, x, grad=None): """ raise NotImplementedError - def evaluateS1(self, x): + def evaluateS1(self, x, minimising=True): """ Call _evaluateS1 for a given set of parameters. @@ -140,7 +140,7 @@ def evaluateS1(self, x): If an error occurs during the calculation of the cost or gradient. """ try: - if self._minimising: + if minimising: return self._evaluateS1(x) else: # minimise the negative cost L, dl = self._evaluateS1(x) diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index b02940bf0..f51a1b063 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -90,14 +90,14 @@ class GravimetricEnergyDensity(DesignCost): """ Represents the gravimetric energy density of a battery cell, calculated based on a normalised discharge from upper to lower voltage limits. The goal is to - maximise the energy density, which is achieved by minimizing the negative energy - density reported by this class. + maximise the energy density, which is achieved by setting _minimising = False. Inherits all parameters and attributes from ``DesignCost``. """ def __init__(self, problem, update_capacity=False): super(GravimetricEnergyDensity, self).__init__(problem, update_capacity) + self._minimising = False def _evaluate(self, x, grad=None): """ @@ -113,7 +113,7 @@ def _evaluate(self, x, grad=None): Returns ------- float - The negative gravimetric energy density or infinity in case of infeasible parameters. + The gravimetric energy density or -infinity in case of infeasible parameters. """ if not all(is_numeric(i) for i in x): raise ValueError("Input must be a numeric array.") @@ -128,35 +128,35 @@ def _evaluate(self, x, grad=None): solution = self.problem.evaluate(x) voltage, current = solution["Voltage [V]"], solution["Current [A]"] - negative_energy_density = -np.trapz(voltage * current, dx=self.dt) / ( + energy_density = np.trapz(voltage * current, dx=self.dt) / ( 3600 * self.problem.model.cell_mass(self.parameter_set) ) - return negative_energy_density + return energy_density # Catch infeasible solutions and return infinity except UserWarning as e: print(f"Ignoring this sample due to: {e}") - return np.inf + return -np.inf # Catch any other exception and return infinity except Exception as e: print(f"An error occurred during the evaluation: {e}") - return np.inf + return -np.inf class VolumetricEnergyDensity(DesignCost): """ Represents the volumetric energy density of a battery cell, calculated based on a normalised discharge from upper to lower voltage limits. The goal is to - maximise the energy density, which is achieved by minimizing the negative energy - density reported by this class. + maximise the energy density, which is achieved by setting _minimising = False. Inherits all parameters and attributes from ``DesignCost``. """ def __init__(self, problem, update_capacity=False): super(VolumetricEnergyDensity, self).__init__(problem, update_capacity) + self._minimising = False def _evaluate(self, x, grad=None): """ @@ -172,7 +172,7 @@ def _evaluate(self, x, grad=None): Returns ------- float - The negative volumetric energy density or infinity in case of infeasible parameters. + The volumetric energy density or -infinity in case of infeasible parameters. """ if not all(is_numeric(i) for i in x): raise ValueError("Input must be a numeric array.") @@ -186,18 +186,18 @@ def _evaluate(self, x, grad=None): solution = self.problem.evaluate(x) voltage, current = solution["Voltage [V]"], solution["Current [A]"] - negative_energy_density = -np.trapz(voltage * current, dx=self.dt) / ( + energy_density = np.trapz(voltage * current, dx=self.dt) / ( 3600 * self.problem.model.cell_volume(self.parameter_set) ) - return negative_energy_density + return energy_density # Catch infeasible solutions and return infinity except UserWarning as e: print(f"Ignoring this sample due to: {e}") - return np.inf + return -np.inf # Catch any other exception and return infinity except Exception as e: print(f"An error occurred during the evaluation: {e}") - return np.inf + return -np.inf diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 39c093621..43c90c236 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -3,23 +3,6 @@ from pybop import Optimisation -DEFAULT_PINTS_OPTIMISER_OPTIONS = dict( - _boundaries=None, - _x0=None, - _transformation=None, # PyBOP doesn't currently support the PINTS transformation class - _use_f_guessed=None, - _parallel=False, - _n_workers=1, - _callback=None, - _min_iterations=2, - _unchanged_threshold=1e-5, # smallest significant f change - _unchanged_max_iterations=15, - _max_evaluations=None, - _threshold=None, - _evaluations=None, - _iterations=None, -) - class BasePintsOptimiser(Optimisation): """ @@ -38,83 +21,115 @@ class BasePintsOptimiser(Optimisation): upper bounds on the parameters. """ - def __init__(self, cost, pints_method, **optimiser_kwargs): - self.__dict__.update(DEFAULT_PINTS_OPTIMISER_OPTIONS) - self.pints_method = pints_method + def __init__(self, cost, pints_optimiser, **optimiser_kwargs): + # First set attributes to default values + self._boundaries = None + self._needs_sensitivities = None + self._use_f_guessed = None + self._parallel = False + self._n_workers = 1 + self._callback = None + self._max_iterations = None + self._min_iterations = 2 + self._unchanged_threshold = 1e-5 # smallest significant f change + self._unchanged_max_iterations = 15 + self._max_evaluations = None + self._threshold = None + self._evaluations = None + self._iterations = None + + # PyBOP doesn't currently support the PINTS transformation class + self._transformation = None + + self.pints_optimiser = pints_optimiser super().__init__(cost, **optimiser_kwargs) - # Create an instance of the PINTS optimiser class - self.initialise_method() - - def _set_options(self, **optimiser_kwargs): + def _set_up_optimiser(self): + """ + Parse optimiser options and create an instance of the PINTS optimiser. """ - Update the optimiser options and remove the corresponding entries from the - optimiser_kwargs dictionary in advance of passing to the parent class. + # Check and remove any duplicate keywords in self.unset_options + self._sanitise_inputs() - Parameters - ---------- - **optimiser_kwargs : optional - Valid PINTS option keys and their values. + # Create an instance of the PINTS optimiser class + if issubclass(self.pints_optimiser, pints.Optimiser): + self.method = self.pints_optimiser(self.x0, self.sigma0, self._boundaries) + else: + raise ValueError( + "The pints_optimiser is not a recognised PINTS optimiser class." + ) - Returns - ------- - optimiser_kwargs : dict - Remaining option keys and their values. - """ - reinit_required = False + # Check if sensitivities are required + self._needs_sensitivities = self.method.needs_sensitivities() + + # Apply default maxiter + self.set_max_iterations() - key_list = list(optimiser_kwargs.keys()) + # Apply additional options and remove them from options + key_list = list(self.unset_options.keys()) for key in key_list: - if key in ["x0", "sigma0", "bounds"]: - self.__dict__.update({key: optimiser_kwargs.pop(key)}) - reinit_required = True - elif key == "use_f_guessed": - self.set_f_guessed_tracking(optimiser_kwargs.pop(key)) + if key == "use_f_guessed": + self.set_f_guessed_tracking(self.unset_options.pop(key)) elif key == "parallel": - self.set_parallel(optimiser_kwargs.pop(key)) - elif key == "maxiter" or key == "max_iterations": - self.set_max_iterations(optimiser_kwargs.pop(key)) + self.set_parallel(self.unset_options.pop(key)) + elif key == "max_iterations": + self.set_max_iterations(self.unset_options.pop(key)) elif key == "min_iterations": - self.set_min_iterations(optimiser_kwargs.pop(key)) + self.set_min_iterations(self.unset_options.pop(key)) elif key == "max_unchanged_iterations": - if "threshold" in optimiser_kwargs.keys(): + if "threshold" in self.unset_options.keys(): self.set_max_unchanged_iterations( - optimiser_kwargs.pop(key), - optimiser_kwargs.pop("threshold"), + self.unset_options.pop(key), + self.unset_options.pop("threshold"), ) else: - self.set_max_unchanged_iterations(optimiser_kwargs.pop(key)) + self.set_max_unchanged_iterations(self.unset_options.pop(key)) elif key == "threshold": pass # only used with unchanged_max_iterations elif key == "max_evaluations": - self.set_max_evaluations(optimiser_kwargs.pop(key)) - - if reinit_required: - self.initialise_method() + self.set_max_evaluations(self.unset_options.pop(key)) - return optimiser_kwargs - - def initialise_method(self): + def _sanitise_inputs(self): """ - Creates an instance of the PINTS optimiser class. + Check and remove any duplicate optimiser options. """ - if issubclass(self.pints_method, pints.Optimiser): - self.method = self.pints_method(self.x0, self.sigma0, self._boundaries) - else: - raise ValueError("The pints_method is not recognised as a PINTS optimiser.") - - # Convert x0 to PINTS vector - self._x0 = pints.vector(self.x0) + # Unpack values from any nested options dictionary + if "options" in self.unset_options.keys(): + key_list = list(self.unset_options["options"].keys()) + for key in key_list: + if key not in self.unset_options.keys(): + self.unset_options[key] = self.unset_options["options"].pop(key) + else: + raise Exception( + f"A duplicate {key} option was found in the options dictionary." + ) + self.unset_options.pop("options") + + # Check for duplicate keywords + expected_keys = [ + "max_iterations", + "popsize", + "threshold", + ] + alternative_keys = ["maxiter", "population_size", "tol"] + for exp_key, alt_key in zip(expected_keys, alternative_keys): + if alt_key in self.unset_options.keys(): + if exp_key in self.unset_options.keys(): + raise Exception( + "The alternative {alt_key} option was passed in addition to the expected {exp_key} option." + ) + else: # rename + self.unset_options[exp_key] = self.unset_options.pop(alt_key) # Convert bounds to PINTS boundaries if self.bounds is not None: if issubclass( - self.pints_method, (pints.GradientDescent, pints.Adam, pints.NelderMead) + self.pints_optimiser, + (pints.GradientDescent, pints.Adam, pints.NelderMead), ): - print(f"NOTE: Boundaries ignored by {self.pints_method}") + print(f"NOTE: Boundaries ignored by {self.pints_optimiser}") self.bounds = None - self._boundaries = None - elif issubclass(self.pints_method, pints.PSO): + elif issubclass(self.pints_optimiser, pints.PSO): if not all( np.isfinite(value) for sublist in self.bounds.values() @@ -127,11 +142,6 @@ def initialise_method(self): self._boundaries = pints.RectangularBoundaries( self.bounds["lower"], self.bounds["upper"] ) - else: - self._boundaries = None - - # Check if sensitivities are required - self._needs_sensitivities = self.method.needs_sensitivities() def name(self): """ @@ -179,9 +189,14 @@ def _run(self): unchanged_iterations = 0 # Choose method to evaluate - f = self.cost if self._needs_sensitivities: - f = f.evaluateS1 + + def f(x): + return self.cost.evaluateS1(x, minimising=self._minimising) + else: + + def f(x, grad=None): + return self.cost(x, grad, minimising=self._minimising) # Create evaluator object if self._parallel: @@ -329,13 +344,13 @@ def _run(self): # Store result self.result.x = x - self.result.final_cost = f + final_cost = self.cost(x) + self.result.final_cost = final_cost self.result.nit = self._iterations - # Return best position and the score used internally, - # i.e the negative log-likelihood in the case of - # self._minimising = False - return x, f + # Return best position and the score, + # i.e the negative log-likelihood in the case of self._minimising = False + return x, final_cost def f_guessed_tracking(self): """ @@ -382,6 +397,25 @@ def set_parallel(self, parallel=False): self._parallel = False self._n_workers = 1 + def set_max_iterations(self, iterations="default"): + """ + Set the maximum number of iterations as a stopping criterion. + Credit: PINTS + + Parameters + ---------- + iterations : int, optional + The maximum number of iterations to run. + Set to `None` to remove this stopping criterion. + """ + if iterations == "default": + iterations = self.default_max_iterations + if iterations is not None: + iterations = int(iterations) + if iterations < 0: + raise ValueError("Maximum number of iterations cannot be negative.") + self._max_iterations = iterations + def set_min_iterations(self, iterations=2): """ Set the minimum number of iterations as a stopping criterion. diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index c65b6803b..6ed849f2b 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -1,23 +1,8 @@ -import warnings - import numpy as np from scipy.optimize import differential_evolution, minimize from pybop import Optimisation -DEFAULT_SCIPY_MINIMIZE_OPTIONS = dict( - method="Nelder-Mead", - jac=False, - tol=1e-5, - options=dict(), -) -DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS = dict( - strategy="best1bin", - popsize=15, - tol=1e-5, - options=dict(), -) - class BaseSciPyOptimiser(Optimisation): """ @@ -27,8 +12,6 @@ class BaseSciPyOptimiser(Optimisation): ---------- x0 : array_like Initial position from which optimisation will start. - sigma0 : float, optional - Initial step size or standard deviation depending on the optimiser. bounds : dict, sequence or scipy.optimize.Bounds, optional Bounds for variables as supported by the selected method. **optimiser_kwargs : optional @@ -38,42 +21,46 @@ class BaseSciPyOptimiser(Optimisation): def __init__(self, cost, **optimiser_kwargs): super().__init__(cost, **optimiser_kwargs) - def _set_options(self, **optimiser_kwargs): + def _sanitise_inputs(self): """ - Update the optimiser options and remove the corresponding entries from the - optimiser_kwargs dictionary in advance of passing to the parent class. - - Parameters - ---------- - **optimiser_kwargs : optional - Valid SciPy option keys and their values. - - Returns - ------- - optimiser_kwargs : dict - Remaining option keys and their values. + Check and remove any duplicate optimiser options. """ - # Unpack nested values from SciPy options dictionary - if "options" in optimiser_kwargs.keys(): - options_list = list(optimiser_kwargs["options"].keys()) - for key in options_list: - if key not in optimiser_kwargs.keys(): - optimiser_kwargs[key] = optimiser_kwargs["options"].pop(key) + # Unpack values from any nested options dictionary + if "options" in self.unset_options.keys(): + key_list = list(self.unset_options["options"].keys()) + for key in key_list: + if key not in self.unset_options.keys(): + self.unset_options[key] = self.unset_options["options"].pop(key) else: - optimiser_kwargs["options"].pop(key) # remove entry - - # Keep max_iterations in preference to maxiter - if "max_iterations" in optimiser_kwargs.keys(): - optimiser_kwargs["maxiter"] = optimiser_kwargs.pop("max_iterations") - - key_list = list(optimiser_kwargs.keys()) - for key in key_list: - if key == "maxiter": - self.set_max_iterations(optimiser_kwargs.pop(key)) - elif key in ["method", "jac", "tol", "options", "strategy", "popsize"]: - self.__dict__.update({key: optimiser_kwargs.pop(key)}) - - return optimiser_kwargs + raise Exception( + f"A duplicate {key} option was found in the options dictionary." + ) + self.unset_options.pop("options") + + # Check for duplicate keywords + expected_keys = ["maxiter", "popsize", "tol"] + alternative_keys = [ + "max_iterations", + "population_size", + "threshold", + ] + for exp_key, alt_key in zip(expected_keys, alternative_keys): + if alt_key in self.unset_options.keys(): + if exp_key in self.unset_options.keys(): + raise Exception( + "The alternative {alt_key} option was passed in addition to the expected {exp_key} option." + ) + else: # rename + self.unset_options[exp_key] = self.unset_options.pop(alt_key) + + # Convert bounds to SciPy format + if isinstance(self.bounds, dict): + self._scipy_bounds = [ + (lower, upper) + for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) + ] + else: + self._scipy_bounds = self.bounds def _run(self): """ @@ -93,13 +80,6 @@ def _run(self): return self.result.x, self.result.final_cost - def set_max_unchanged_iterations(self, *args, **kwargs): - """ - Raise a warning that this stopping criterion is not used by this optimiser. - """ - invalid_criteria_warning = "The maximum unchanged iterations stopping criteria is not used by the SciPy optimisers." - warnings.warn(invalid_criteria_warning, UserWarning) - class SciPyMinimize(BaseSciPyOptimiser): """ @@ -127,8 +107,41 @@ class SciPyMinimize(BaseSciPyOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - self.__dict__.update(DEFAULT_SCIPY_MINIMIZE_OPTIONS) - super().__init__(cost, **optimiser_kwargs) + optimiser_options = dict(method="Nelder-Mead", jac=False) + optimiser_options.update(**optimiser_kwargs) + super().__init__(cost, **optimiser_options) + + def _set_up_optimiser(self): + """ + Parse optimiser options. + """ + # Check and remove any duplicate keywords in self.unset_options + self._sanitise_inputs() + + # Apply default maxiter + self._options = dict() + self._options["options"] = dict() + self._options["options"]["maxiter"] = self.default_max_iterations + + # Apply additional options and remove them from the options dictionary + key_list = list(self.unset_options.keys()) + for key in key_list: + if key in [ + "method", + "jac", + "hess", + "hessp", + "constraints", + "tol", + ]: + self._options.update({key: self.unset_options.pop(key)}) + elif key == "maxiter": + # Nest this option within an options dictionary for SciPy minimize + self._options["options"]["maxiter"] = self.unset_options.pop(key) + + # Throw an error if any options remain + if self.unset_options: + raise ValueError(f"Unrecognised keyword arguments: {self.unset_options}") def _run_optimiser(self): """ @@ -147,49 +160,34 @@ def callback(x): self.log.append([x]) # Scale the cost function and eliminate nan values - self._cost0 = self.cost(self.x0) + self._cost0 = self.cost(self.x0, minimising=self._minimising) self.inf_count = 0 if np.isinf(self._cost0): raise Exception("The initial parameter values return an infinite cost.") - if not self.jac: + if not self._options["jac"]: def cost_wrapper(x): - cost = self.cost(x) / self._cost0 + cost = self.cost(x, minimising=self._minimising) / self._cost0 if np.isinf(cost): self.inf_count += 1 cost = 1 + 0.9**self.inf_count # for fake finite gradient return cost - elif self.jac is True: + elif self._options["jac"] is True: def cost_wrapper(x): - return self.cost.evaluateS1(x) + return self.cost.evaluateS1(x, minimising=self._minimising) else: raise ValueError( "Expected the jac option to be either True, False or None." ) - # Reformat bounds - if isinstance(self.bounds, dict): - bounds = ( - (lower, upper) - for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) - ) - else: - bounds = self.bounds - - # Retrieve maximum iterations - self.options["maxiter"] = self._max_iterations - result = minimize( cost_wrapper, self.x0, - method=self.method, - jac=self.jac, - bounds=bounds, - tol=self.tol, - options=self.options, + bounds=self._scipy_bounds, callback=callback, + **self._options, ) return result @@ -232,8 +230,53 @@ class SciPyDifferentialEvolution(BaseSciPyOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - self.__dict__.update(DEFAULT_SCIPY_DIFFERENTIAL_EVOLUTION_OPTIONS) - super().__init__(cost, **optimiser_kwargs) + optimiser_options = dict(strategy="best1bin", popsize=15) + optimiser_options.update(**optimiser_kwargs) + super().__init__(cost, **optimiser_options) + + def _set_up_optimiser(self): + """ + Parse optimiser options. + """ + # Check and remove any duplicate keywords in self.unset_options + self._sanitise_inputs() + + # Check bounds + if self._scipy_bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") + else: + if not all( + np.isfinite(value) for pair in self._scipy_bounds for value in pair + ): + raise ValueError("Bounds must be specified for differential_evolution.") + + # Apply default maxiter + self._options = dict() + self._options["maxiter"] = self.default_max_iterations + + # Apply additional options and remove them from the options dictionary + key_list = list(self.unset_options.keys()) + for key in key_list: + if key in [ + "strategy", + "maxiter", + "popsize", + "tol", + "mutation", + "recombination", + "seed", + "disp", + "polish", + "init", + "atol", + "updating", + "workers", + "constraints", + "tol", + "integrality", + "vectorized", + ]: + self._options.update({key: self.unset_options.pop(key)}) def _run_optimiser(self): """ @@ -255,50 +298,18 @@ def _run_optimiser(self): def callback(x, convergence): self.log.append([x]) - # Reformat bounds - if self.bounds is None: - raise ValueError("Bounds must be specified for differential_evolution.") - elif isinstance(self.bounds, dict): - if not all( - np.isfinite(value) - for sublist in self.bounds.values() - for value in sublist - ): - raise ValueError("Bounds must be specified for differential_evolution.") - bounds = [ - (lower, upper) - for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) - ] - else: - if not all(np.isfinite(value) for pair in self.bounds for value in pair): - raise ValueError("Bounds must be specified for differential_evolution.") - bounds = self.bounds + def cost_wrapper(x): + return self.cost(x, minimising=self._minimising) result = differential_evolution( - self.cost, - bounds, - strategy=self.strategy, - maxiter=self._max_iterations, - popsize=self.popsize, - tol=self.tol, + cost_wrapper, + self._scipy_bounds, callback=callback, + **self._options, ) return result - def set_population_size(self, population_size=None): - """ - Sets a population size to use in this optimisation. - Credit: PINTS - - """ - # Check population size or set using heuristic - if population_size is not None: - population_size = int(population_size) - if population_size < 1: - raise ValueError("Population size must be at least 1.") - self.popsize = population_size - def name(self): """ Provides the name of the optimization strategy. diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 0f4584abb..c04365552 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -30,19 +30,25 @@ def plot_convergence(optim, show=True, **layout_kwargs): cost = optim.cost log = optim.log - # Compute the minimum cost for each iteration - min_cost_per_iteration = [ - min((cost(solution) for solution in log_entry), default=np.inf) - for log_entry in log - ] + # Find the best cost from each iteration + if optim._minimising: + best_cost_per_iteration = [ + min((cost(solution) for solution in log_entry), default=np.inf) + for log_entry in log + ] + else: + best_cost_per_iteration = [ + max((cost(solution) for solution in log_entry), default=-np.inf) + for log_entry in log + ] # Generate a list of iteration numbers - iteration_numbers = list(range(1, len(min_cost_per_iteration) + 1)) + iteration_numbers = list(range(1, len(best_cost_per_iteration) + 1)) # Create a plotting dictionary plot_dict = pybop.StandardPlot( x=iteration_numbers, - y=min_cost_per_iteration, + y=best_cost_per_iteration, layout_options=dict( xaxis_title="Iteration", yaxis_title="Cost", title="Convergence" ), diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 1a88bf172..0d815cd5a 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -81,9 +81,8 @@ def spm_costs(self, model, parameters, cost_class): @pytest.mark.integration def test_optimisation_f_guessed(self, f_guessed, spm_costs): # Test each optimiser - parameterisation = pybop.XNES(cost=spm_costs, sigma0=0.05) + parameterisation = pybop.XNES(cost=spm_costs, sigma0=0.05, max_iterations=125) parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) - parameterisation.set_max_iterations(125) parameterisation.set_f_guessed_tracking(f_guessed) # Set parallelisation if not on Windows @@ -94,7 +93,10 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): x, final_cost = parameterisation.run() # Assertions - assert initial_cost > final_cost + if parameterisation._minimising: + assert initial_cost > final_cost + else: + assert initial_cost < final_cost np.testing.assert_allclose(x, self.ground_truth, atol=2.5e-2) def getdata(self, model, x, init_soc): diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index 360a02557..81c86efa9 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -107,10 +107,9 @@ def test_spm_optimisers(self, optimiser, spm_costs): spm_costs.bounds = bounds # Test each optimiser - parameterisation = optimiser(cost=spm_costs, sigma0=0.05) + parameterisation = optimiser(cost=spm_costs, sigma0=0.05, max_iterations=125) if issubclass(optimiser, pybop.BasePintsOptimiser): parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) - parameterisation.set_max_iterations(125) initial_cost = parameterisation.cost(spm_costs.x0) @@ -131,7 +130,10 @@ def test_spm_optimisers(self, optimiser, spm_costs): x, final_cost = parameterisation.run() # Assertions - assert initial_cost > final_cost + if parameterisation._minimising: + assert initial_cost > final_cost + else: + assert initial_cost < final_cost np.testing.assert_allclose(x, self.ground_truth, atol=2.5e-2) @pytest.fixture @@ -188,16 +190,20 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): spm_two_signal_cost.bounds = bounds # Test each optimiser - parameterisation = multi_optimiser(cost=spm_two_signal_cost, sigma0=0.03) + parameterisation = multi_optimiser( + cost=spm_two_signal_cost, sigma0=0.03, max_iterations=125 + ) if issubclass(multi_optimiser, pybop.BasePintsOptimiser): parameterisation.set_max_unchanged_iterations(iterations=35, threshold=5e-4) - parameterisation.set_max_iterations(125) initial_cost = parameterisation.cost(spm_two_signal_cost.x0) x, final_cost = parameterisation.run() # Assertions - assert initial_cost > final_cost + if parameterisation._minimising: + assert initial_cost > final_cost + else: + assert initial_cost < final_cost np.testing.assert_allclose(x, self.ground_truth, atol=2.5e-2) @pytest.mark.parametrize("init_soc", [0.4, 0.6]) diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 6ffeb58ea..f68df92bb 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -211,14 +211,14 @@ def test_energy_density_costs( # Test type of returned value assert np.isscalar(cost([0.5])) - assert cost([0.4]) <= 0 # Should be a viable design - assert cost([0.8]) == np.inf # Should exceed active material + porosity < 1 - assert cost([1.4]) == np.inf # Definitely not viable - assert cost([-0.1]) == np.inf # Should not be a viable design + assert cost([0.4]) >= 0 # Should be a viable design + assert cost([0.8]) == -np.inf # Should exceed active material + porosity < 1 + assert cost([1.4]) == -np.inf # Definitely not viable + assert cost([-0.1]) == -np.inf # Should not be a viable design # Test infeasible locations cost.problem._model.allow_infeasible_solutions = False - assert cost([1.1]) == np.inf + assert cost([1.1]) == -np.inf # Test exception for non-numeric inputs with pytest.raises(ValueError): diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index b112c9e24..0a3c2e1a5 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -92,16 +92,12 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): assert optim.cost is not None assert optim.name() == expected_name - # Test construction without bounds - bounds = cost.bounds - cost.bounds = None - optim = optimiser(cost=cost) - assert optim.bounds is None - if issubclass(optimiser, pybop.BasePintsOptimiser): - assert optim._boundaries is None - - # Reset - cost.bounds = bounds + if optimiser not in [pybop.SciPyDifferentialEvolution]: + # Test construction without bounds + optim = optimiser(cost=cost, bounds=None) + assert optim.bounds is None + if issubclass(optimiser, pybop.BasePintsOptimiser): + assert optim._boundaries is None @pytest.mark.parametrize( "optimiser", @@ -121,17 +117,13 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): def test_optimiser_kwargs(self, cost, optimiser): optim = optimiser(cost=cost, maxiter=1) - # Check and update maximum iterations + # Check maximum iterations optim.run() - assert optim._max_iterations == 1 assert optim._iterations == 1 - optim.run(max_iterations=10) - assert optim._max_iterations == 10 - assert optim._iterations <= 10 if issubclass(optimiser, (pybop.GradientDescent, pybop.Adam, pybop.NelderMead)): - # Unused bounds - optim.run(bounds=cost.bounds) + # Ignored bounds + optim = optimiser(cost=cost, bounds=cost.bounds) assert optim.bounds is None elif issubclass(optimiser, pybop.PSO): assert optim.bounds == cost.bounds @@ -141,18 +133,17 @@ def test_optimiser_kwargs(self, cost, optimiser): ValueError, match="Either all bounds or no bounds must be set", ): - optim.run(bounds=bounds) - # Reset - optim.set_options(bounds=cost.bounds) + optim = optimiser(cost=cost, bounds=bounds) else: # Check and update bounds assert optim.bounds == cost.bounds bounds = {"upper": [0.63], "lower": [0.57]} - optim.run(bounds=bounds) + optim = optimiser(cost=cost, bounds=bounds) assert optim.bounds == bounds if issubclass(optimiser, pybop.BasePintsOptimiser): - optim.run( + optim = optimiser( + cost=cost, use_f_guessed=True, parallel=False, min_iterations=3, @@ -170,52 +161,43 @@ def test_optimiser_kwargs(self, cost, optimiser): bounds = [ (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ] - optim.run(bounds=bounds, tol=1e-2) + optim = optimiser(cost=cost, bounds=bounds, tol=1e-2) assert optim.bounds == bounds - assert optim.tol == 1e-2 # Pass nested options - options = dict(maxiter=10) - optim.run(options=options) - optim.run(maxiter=5, options=options) - assert optim._max_iterations == 5 - - # Trigger unused input warning - optim.set_max_unchanged_iterations(5) + optim = optimiser(cost=cost, options=dict(maxiter=10)) + with pytest.raises( + Exception, + match="A duplicate maxiter option was found in the options dictionary.", + ): + optimiser(cost=cost, maxiter=5, options=dict(maxiter=10)) if optimiser in [pybop.SciPyDifferentialEvolution]: - # Check and update population size - with pytest.raises(ValueError): - optim.set_population_size(-5) - optim.set_population_size(10) - assert optim.popsize == 10 - optim.run(popsize=5) - assert optim.popsize == 5 + # Update population size + optimiser(cost=cost, popsize=5) # Test invalid bounds with pytest.raises(ValueError): - optim.run(bounds=None) + optimiser(cost=cost, bounds=[(0, np.inf)]) with pytest.raises(ValueError): - optim.run(bounds=[(0, np.inf)]) - with pytest.raises(ValueError): - optim.run(bounds={"upper": [np.inf], "lower": [0.57]}) + optimiser(cost=cost, bounds={"upper": [np.inf], "lower": [0.57]}) else: # Check and update initial values assert optim.x0 == cost.x0 x0_new = np.array([0.6]) - optim.run(x0=x0_new) + optim = optimiser(cost=cost, x0=x0_new) assert optim.x0 == x0_new if optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information - optim.run(method="L-BFGS-B", jac=True) + optimiser(cost=cost, method="L-BFGS-B", jac=True) with pytest.raises( ValueError, match="Expected the jac option to be either True, False or None.", ): - optim.run(jac="Invalid string") - optim.run(method="Nelder-Mead", jac=False) + optim = optimiser(cost=cost, jac="Invalid string") + optim.run() @pytest.mark.unit def test_single_parameter(self, cost): @@ -236,18 +218,20 @@ def test_incorrect_optimiser_class(self, cost): class RandomClass: pass - with pytest.raises(ValueError): - pybop.BasePintsOptimiser(cost=cost, pints_method=RandomClass) + with pytest.raises( + ValueError, + match="The pints_optimiser is not a recognised PINTS optimiser class.", + ): + pybop.BasePintsOptimiser(cost=cost, pints_optimiser=RandomClass) - optim = pybop.Optimisation(cost=cost) with pytest.raises(NotImplementedError): - optim.run() + pybop.Optimisation(cost=cost) @pytest.mark.unit def test_prior_sampling(self, cost): # Tests prior sampling for i in range(50): - optim = pybop.Optimisation(cost=cost) + optim = pybop.DefaultOptimiser(cost=cost) assert optim.x0 <= 0.62 and optim.x0 >= 0.58 @@ -284,13 +268,12 @@ def test_halting(self, cost): def test_infeasible_solutions(self, cost): # Test infeasible solutions for optimiser in [pybop.SciPyMinimize, pybop.GradientDescent]: - optim = optimiser(cost=cost, allow_infeasible_solutions=False) - optim.set_max_iterations(1) + optim = optimiser(cost=cost, allow_infeasible_solutions=False, maxiter=1) optim.run() assert optim._iterations == 1 @pytest.mark.unit def test_unphysical_result(self, cost): # Trigger parameters not physically viable warning - optim = pybop.Optimisation(cost=cost) + optim = pybop.DefaultOptimiser(cost=cost) optim.check_optimal_parameters(np.array([2])) From 4ae9418834c9b4b70a406414409b6098c5bae5a8 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 15:41:22 +0100 Subject: [PATCH 46/85] Update option setting in notebooks --- examples/notebooks/multi_model_identification.ipynb | 4 +--- .../notebooks/multi_optimiser_identification.ipynb | 12 +++--------- examples/notebooks/optimiser_calibration.ipynb | 6 ++---- examples/notebooks/spm_electrode_design.ipynb | 5 ++--- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index 477953c6e..35a0cab30 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -3813,9 +3813,7 @@ " print(f\"Running {model.name}\")\n", " problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", " cost = pybop.SumSquaredError(problem)\n", - " optim = pybop.XNES(cost, verbose=True)\n", - " optim.set_max_iterations(60)\n", - " optim.set_max_unchanged_iterations(15)\n", + " optim = pybop.XNES(cost, verbose=True, max_iterations=60, max_unchanged_iterations=15)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index 02f5d9595..82e462558 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -358,9 +358,7 @@ "cost = pybop.SumSquaredError(problem)\n", "for optimiser in gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = optimiser(cost)\n", - " optim.set_max_unchanged_iterations(20)\n", - " optim.set_max_iterations(60)\n", + " optim = optimiser(cost, max_unchanged_iterations=20, max_iterations=60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -387,9 +385,7 @@ "source": [ "for optimiser in non_gradient_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = optimiser(cost)\n", - " optim.set_max_unchanged_iterations(20)\n", - " optim.set_max_iterations(60)\n", + " optim = optimiser(cost, max_unchanged_iterations=20, max_iterations=60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" @@ -413,9 +409,7 @@ "source": [ "for optimiser in scipy_optimisers:\n", " print(f\"Running {optimiser.__name__}\")\n", - " optim = optimiser(cost)\n", - " optim.set_max_unchanged_iterations(20)\n", - " optim.set_max_iterations(60)\n", + " optim = optimiser(cost, max_iterations=60)\n", " x, _ = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 8df5fc851..d01fc1dc1 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -281,8 +281,7 @@ "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)\n", - "optim = pybop.GradientDescent(cost, sigma0=0.2)\n", - "optim.set_max_iterations(100)" + "optim = pybop.GradientDescent(cost, sigma0=0.2, max_iterations=100)" ] }, { @@ -456,8 +455,7 @@ " print(sigma)\n", " problem = pybop.FittingProblem(model, parameters, dataset)\n", " cost = pybop.SumSquaredError(problem)\n", - " optim = pybop.GradientDescent(cost, sigma0=sigma)\n", - " optim.set_max_iterations(100)\n", + " optim = pybop.GradientDescent(cost, sigma0=sigma, max_iterations=100)\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index a1840c523..fd5373528 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -215,7 +215,7 @@ "id": "eQiGurUV04qB" }, "source": [ - "Let's construct PyBOP's optimisation class. This class provides the methods needed to fit the forward model. For this example, we use particle swarm optimisation (PSO). Due to the computational requirements of the design optimisation methods, we limit the number of iterations to 5 for this example." + "Let's construct PyBOP's optimisation class. This class provides the methods needed to fit the forward model. For this example, we use particle swarm optimisation (PSO). Due to the computational requirements of the design optimisation methods, we limit the number of iterations to 15 for this example." ] }, { @@ -232,8 +232,7 @@ }, "outputs": [], "source": [ - "optim = pybop.PSO(cost, verbose=True)\n", - "optim.set_max_iterations(15)" + "optim = pybop.PSO(cost, verbose=True, max_iterations=15)" ] }, { From 06758e2d0f5db142b26da72c3fd797bcf3b2b5da Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 15:42:28 +0100 Subject: [PATCH 47/85] Take abs of cost0 --- pybop/optimisers/scipy_optimisers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 6ed849f2b..2e690feb9 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -160,7 +160,7 @@ def callback(x): self.log.append([x]) # Scale the cost function and eliminate nan values - self._cost0 = self.cost(self.x0, minimising=self._minimising) + self._cost0 = np.abs(self.cost(self.x0)) self.inf_count = 0 if np.isinf(self._cost0): raise Exception("The initial parameter values return an infinite cost.") From 44b5b2e3e97f70471438de71ad1021fc596eeb29 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 15:43:25 +0100 Subject: [PATCH 48/85] Update comment --- pybop/costs/fitting_costs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index 0665454b0..50c1944a7 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -287,7 +287,8 @@ class MAP(BaseLikelihood): Maximum a posteriori cost function. Computes the maximum a posteriori cost function, which is the sum of the - negative log likelihood and the log prior. + log likelihood and the log prior. The goal of maximising is achieved by + setting _minimising = False. Inherits all parameters and attributes from ``BaseLikelihood``. From 71b4b88d202e5d23807be0e8695abf24002f757d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 May 2024 14:46:20 +0000 Subject: [PATCH 49/85] style: pre-commit fixes --- .../multi_model_identification.ipynb | 338 +++++++++--------- 1 file changed, 170 insertions(+), 168 deletions(-) diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index 35a0cab30..e30102d85 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -222,13 +222,13 @@ 6.0046189376443415, 8.006158583525789, 10.007698229407236, - 12.009237875288683, + 12.009237875288685, 14.01077752117013, 16.012317167051577, 18.013856812933025, 20.01539645881447, 22.01693610469592, - 24.018475750577366, + 24.01847575057737, 26.020015396458813, 28.02155504234026, 30.023094688221708, @@ -263,23 +263,23 @@ 88.06774441878368, 90.06928406466513, 92.07082371054656, - 94.07236335642801, + 94.072363356428, 96.07390300230946, 98.07544264819091, 100.07698229407237, 102.0785219399538, - 104.08006158583525, + 104.08006158583524, 106.0816012317167, 108.08314087759814, - 110.08468052347959, + 110.0846805234796, 112.08622016936104, - 114.08775981524249, + 114.08775981524248, 116.08929946112394, 118.09083910700538, - 120.09237875288683, + 120.09237875288684, 122.09391839876828, 124.09545804464972, - 126.09699769053117, + 126.09699769053115, 128.09853733641262, 130.10007698229407, 132.10161662817552, @@ -287,7 +287,7 @@ 136.10469591993842, 138.10623556581984, 140.1077752117013, - 142.10931485758275, + 142.10931485758277, 144.1108545034642, 146.11239414934565, 148.1139337952271, @@ -309,40 +309,40 @@ 180.13856812933025, 182.1401077752117, 184.14164742109313, - 186.14318706697458, + 186.14318706697455, 188.14472671285603, 190.14626635873748, 192.14780600461893, - 194.14934565050038, + 194.1493456505004, 196.15088529638183, 198.15242494226328, 200.15396458814473, 202.15550423402615, 204.1570438799076, - 206.15858352578906, + 206.15858352578903, 208.1601231716705, 210.16166281755196, 212.1632024634334, - 214.16474210931486, + 214.1647421093149, 216.16628175519628, 218.16782140107773, - 220.16936104695918, + 220.1693610469592, 222.17090069284063, 224.17244033872208, 226.17397998460353, - 228.17551963048498, + 228.17551963048496, 230.17705927636644, 232.1785989222479, 234.1801385681293, 236.18167821401076, 238.1832178598922, - 240.18475750577366, + 240.1847575057737, 242.1862971516551, 244.18783679753656, 246.189376443418, 248.19091608929944, 250.19245573518089, - 252.19399538106234, + 252.1939953810623, 254.1955350269438, 256.19707467282524, 258.1986143187067, @@ -397,12 +397,12 @@ 356.2740569668976, 358.27559661277905, 360.2771362586605, - 362.27867590454196, + 362.2786759045419, 364.2802155504234, 366.28175519630486, 368.28329484218625, 370.2848344880677, - 372.28637413394915, + 372.2863741339491, 374.2879137798306, 376.28945342571205, 378.2909930715935, @@ -410,7 +410,7 @@ 382.2940723633564, 384.29561200923786, 386.2971516551193, - 388.29869130100076, + 388.2986913010008, 390.3002309468822, 392.30177059276366, 394.3033102386451, @@ -423,7 +423,7 @@ 408.3140877598152, 410.31562740569666, 412.3171670515781, - 414.31870669745956, + 414.3187066974595, 416.320246343341, 418.32178598922246, 420.3233256351039, @@ -431,12 +431,12 @@ 424.3264049268668, 426.32794457274827, 428.3294842186297, - 430.33102386451117, + 430.3310238645112, 432.33256351039256, 434.334103156274, 436.33564280215546, 438.3371824480369, - 440.33872209391836, + 440.3387220939184, 442.3402617397998, 444.34180138568126, 446.3433410315627, @@ -444,18 +444,18 @@ 450.3464203233256, 452.34795996920707, 454.3494996150885, - 456.35103926096997, + 456.35103926097, 458.3525789068514, 460.35411855273287, 462.3556581986143, 464.3571978444958, - 466.35873749037717, + 466.3587374903772, 468.3602771362586, 470.36181678214007, 472.3633564280215, 474.36489607390297, 476.3664357197844, - 478.36797536566587, + 478.3679753656658, 480.3695150115473, 482.3710546574288, 484.3725943033102, @@ -668,77 +668,77 @@ 898.6913010007697, 900.6928406466512, 902.6943802925326, - 904.6959199384141, - 906.6974595842955, + 904.695919938414, + 906.6974595842956, 908.698999230177, 910.7005388760584, - 912.7020785219399, - 914.7036181678213, + 912.70207852194, + 914.7036181678212, 916.7051578137028, 918.7066974595842, - 920.7082371054657, - 922.7097767513471, + 920.7082371054656, + 922.7097767513472, 924.7113163972286, 926.71285604311, 928.7143956889915, - 930.7159353348729, - 932.7174749807543, + 930.7159353348728, + 932.7174749807544, 934.7190146266358, 936.7205542725172, - 938.7220939183987, - 940.7236335642801, + 938.7220939183989, + 940.72363356428, 942.7251732101616, 944.726712856043, - 946.7282525019245, - 948.7297921478059, + 946.7282525019244, + 948.729792147806, 950.7313317936874, 952.7328714395688, 954.7344110854503, - 956.7359507313317, + 956.7359507313316, 958.7374903772132, 960.7390300230946, 962.740569668976, 964.7421093148575, - 966.7436489607389, + 966.7436489607388, 968.7451886066204, 970.7467282525018, - 972.7482678983833, - 974.7498075442647, + 972.7482678983832, + 974.7498075442649, 976.7513471901462, 978.7528868360276, 980.7544264819091, - 982.7559661277905, + 982.7559661277904, 984.757505773672, 986.7590454195534, 988.760585065435, 990.7621247113163, - 992.7636643571977, + 992.7636643571976, 994.7652040030792, 996.7667436489606, - 998.7682832948421, - 1000.7698229407235, + 998.768283294842, + 1000.7698229407237, 1002.771362586605, 1004.7729022324864, 1006.774441878368, - 1008.7759815242493, + 1008.7759815242492, 1010.7775211701309, 1012.7790608160122, 1014.7806004618938, 1016.7821401077751, - 1018.7836797536567, + 1018.7836797536568, 1020.785219399538, 1022.7867590454196, 1024.788298691301, 1026.7898383371823, - 1028.7913779830637, + 1028.7913779830635, 1030.7929176289454, 1032.7944572748268, - 1034.7959969207081, + 1034.795996920708, 1036.7975365665895, 1038.7990762124712, 1040.8006158583526, 1042.802155504234, - 1044.8036951501153, + 1044.8036951501151, 1046.805234795997, 1048.8067744418784, 1050.8083140877598, @@ -751,7 +751,7 @@ 1064.81909160893, 1066.8206312548114, 1068.8221709006928, - 1070.8237105465741, + 1070.823710546574, 1072.8252501924558, 1074.8267898383372, 1076.8283294842186, @@ -1016,7 +1016,7 @@ 1595.2270977675134, 1597.2286374133948, 1599.2301770592762, - 1601.2317167051579, + 1601.231716705158, 1603.2332563510392, 1605.2347959969206, 1607.236335642802, @@ -1029,12 +1029,12 @@ 1621.2471131639722, 1623.2486528098536, 1625.250192455735, - 1627.2517321016167, + 1627.2517321016169, 1629.253271747498, 1631.2548113933794, 1633.2563510392608, 1635.2578906851422, - 1637.2594303310239, + 1637.259430331024, 1639.2609699769052, 1641.2625096227866, 1643.264049268668, @@ -1042,12 +1042,12 @@ 1647.267128560431, 1649.2686682063124, 1651.2702078521938, - 1653.2717474980755, + 1653.2717474980757, 1655.2732871439568, 1657.2748267898382, 1659.2763664357196, 1661.2779060816013, - 1663.2794457274827, + 1663.2794457274829, 1665.280985373364, 1667.2825250192454, 1669.2840646651268, @@ -1055,17 +1055,17 @@ 1673.2871439568898, 1675.2886836027712, 1677.2902232486526, - 1679.2917628945343, + 1679.2917628945345, 1681.2933025404157, 1683.294842186297, 1685.2963818321784, 1687.29792147806, - 1689.2994611239415, + 1689.2994611239417, 1691.3010007698228, 1693.3025404157042, - 1695.3040800615859, + 1695.304080061586, 1697.3056197074673, - 1699.3071593533487, + 1699.3071593533489, 1701.30869899923, 1703.3102386451114, 1705.311778290993, @@ -1073,129 +1073,129 @@ 1709.3148575827558, 1711.3163972286372, 1713.3179368745189, - 1715.3194765204003, + 1715.3194765204005, 1717.3210161662817, 1719.322555812163, - 1721.3240954580447, + 1721.324095458045, 1723.325635103926, - 1725.3271747498075, + 1725.3271747498077, 1727.3287143956888, 1729.3302540415702, - 1731.3317936874519, + 1731.331793687452, 1733.3333333333333, - 1735.3348729792147, + 1735.3348729792149, 1737.336412625096, 1739.3379522709777, 1741.339491916859, 1743.3410315627405, 1745.3425712086218, - 1747.3441108545035, + 1747.3441108545037, 1749.3456505003849, - 1751.3471901462663, + 1751.3471901462665, 1753.3487297921477, 1755.3502694380293, - 1757.3518090839107, + 1757.351809083911, 1759.353348729792, - 1761.3548883756735, + 1761.3548883756737, 1763.3564280215548, 1765.3579676674365, - 1767.3595073133179, + 1767.359507313318, 1769.3610469591993, - 1771.3625866050807, - 1773.3641262509623, + 1771.3625866050809, + 1773.3641262509625, 1775.3656658968437, 1777.367205542725, 1779.3687451886065, 1781.370284834488, - 1783.3718244803695, + 1783.3718244803697, 1785.3733641262509, - 1787.3749037721323, - 1789.3764434180139, + 1787.3749037721325, + 1789.376443418014, 1791.3779830638953, - 1793.3795227097767, + 1793.379522709777, 1795.381062355658, - 1797.3826020015395, + 1797.3826020015397, 1799.384141647421, 1801.3856812933025, - 1803.3872209391839, + 1803.387220939184, 1805.3887605850653, 1807.3903002309469, - 1809.3918398768283, + 1809.3918398768285, 1811.3933795227097, 1813.394919168591, - 1815.3964588144727, + 1815.396458814473, 1817.397998460354, - 1819.3995381062355, + 1819.3995381062357, 1821.4010777521169, 1823.4026173979985, - 1825.4041570438799, + 1825.40415704388, 1827.4056966897613, - 1829.4072363356427, + 1829.407236335643, 1831.408775981524, 1833.4103156274057, 1835.411855273287, 1837.4133949191685, - 1839.4149345650499, - 1841.4164742109315, + 1839.41493456505, + 1841.4164742109317, 1843.4180138568129, - 1845.4195535026943, + 1845.4195535026945, 1847.4210931485757, 1849.4226327944573, - 1851.4241724403387, + 1851.424172440339, 1853.42571208622, - 1855.4272517321015, + 1855.4272517321017, 1857.428791377983, 1859.4303310238645, - 1861.4318706697459, + 1861.431870669746, 1863.4334103156273, - 1865.4349499615087, - 1867.4364896073903, + 1865.434949961509, + 1867.4364896073905, 1869.4380292532717, 1871.439568899153, 1873.4411085450345, 1875.442648190916, - 1877.4441878367975, + 1877.4441878367977, 1879.4457274826789, - 1881.4472671285603, + 1881.4472671285605, 1883.448806774442, 1885.4503464203233, - 1887.4518860662047, + 1887.451886066205, 1889.453425712086, - 1891.4549653579675, + 1891.4549653579677, 1893.456505003849, 1895.4580446497305, - 1897.4595842956119, + 1897.459584295612, 1899.4611239414933, 1901.462663587375, - 1903.4642032332563, + 1903.4642032332565, 1905.4657428791377, 1907.467282525019, 1909.4688221709007, 1911.470361816782, - 1913.4719014626635, + 1913.4719014626637, 1915.4734411085449, 1917.4749807544265, 1919.476520400308, 1921.4780600461893, - 1923.4795996920707, + 1923.479599692071, 1925.481139337952, 1927.4826789838337, 1929.484218629715, 1931.4857582755965, - 1933.4872979214779, + 1933.487297921478, 1935.4888375673595, 1937.490377213241, - 1939.4919168591223, + 1939.4919168591225, 1941.4934565050037, 1943.4949961508853, 1945.4965357967667, 1947.498075442648, - 1949.4996150885295, + 1949.4996150885297, 1951.501154734411, 1953.5026943802925, 1955.504234026174, 1957.5057736720553, - 1959.5073133179367, + 1959.507313317937, 1961.5088529638183, 1963.5103926096997, 1965.511932255581, @@ -1203,12 +1203,12 @@ 1969.515011547344, 1971.5165511932255, 1973.518090839107, - 1975.5196304849883, + 1975.5196304849885, 1977.52117013087, 1979.5227097767513, 1981.5242494226327, 1983.525789068514, - 1985.5273287143955, + 1985.5273287143957, 1987.528868360277, 1989.5304080061585, 1991.53194765204, @@ -1521,9 +1521,9 @@ 3.6335352582511224, 3.627122765758587, 3.6221080504702927, - 3.6167877206306964, - 3.6099456996342925, - 3.6079976088554133, + 3.616787720630696, + 3.609945699634293, + 3.6079976088554138, 3.6041933507058945, 3.599416815430014, 3.596429034067178, @@ -1536,7 +1536,7 @@ 3.5767560805316228, 3.5778961045754163, 3.573395988130047, - 3.5716009277392002, + 3.5716009277392, 3.569414943962698, 3.56804429486429, 3.565781981746516, @@ -1549,7 +1549,7 @@ 3.556524010915444, 3.554449159138821, 3.5532192510155975, - 3.5527656831964722, + 3.552765683196472, 3.550199878180258, 3.549509216928912, 3.548893107140227, @@ -1578,7 +1578,7 @@ 3.527087236644306, 3.528229306392814, 3.526727091932129, - 3.5237259462102766, + 3.523725946210277, 3.523358276153151, 3.522630420043115, 3.522098228220245, @@ -1620,7 +1620,7 @@ 3.5002807746928624, 3.499093637966968, 3.498788374056211, - 3.4984785984896574, + 3.498478598489658, 3.499784189727196, 3.4980584873070817, 3.4978661714478565, @@ -1660,7 +1660,7 @@ 3.4802370894043246, 3.4791879782425816, 3.4798530512948234, - 3.4766935468990114, + 3.476693546899011, 3.4763588018753024, 3.476681441889914, 3.4768659814457297, @@ -1668,13 +1668,13 @@ 3.4759573804777584, 3.4754182985385587, 3.475088650570002, - 3.4750419779837722, + 3.475041977983772, 3.4740322920318754, 3.474713449213277, 3.4712986801974104, 3.472166561698215, 3.4729038701998873, - 3.4716258231387678, + 3.471625823138768, 3.4710018101751983, 3.469920256552502, 3.470005188370876, @@ -1697,7 +1697,7 @@ 3.4613173368361783, 3.4605987473085724, 3.460051650131785, - 3.4605857572438286, + 3.460585757243829, 3.458095126030117, 3.4573193340700423, 3.4588208311353927, @@ -1707,16 +1707,16 @@ 3.455795586844551, 3.4551590878391885, 3.4551470829661604, - 3.4528124177107506, + 3.45281241771075, 3.453870587520652, - 3.4525640870671914, + 3.452564087067191, 3.452854596728473, - 3.4523545110455522, + 3.452354511045552, 3.4522287822375475, 3.4524039267482483, 3.449481200352293, 3.4495961613926567, - 3.4495790702643934, + 3.449579070264394, 3.4499032704341643, 3.4473506521553916, 3.448579307300168, @@ -1742,7 +1742,7 @@ 3.4385302193614287, 3.4388296239847107, 3.437374775020289, - 3.4369630087453094, + 3.43696300874531, 3.4370575942904726, 3.4354414175833337, 3.433350597269167, @@ -1764,7 +1764,7 @@ 3.425675118914293, 3.4239398422529828, 3.424405251210055, - 3.4256202296229046, + 3.425620229622905, 3.4246181248431715, 3.4245147207270255, 3.4234377313942064, @@ -1796,7 +1796,7 @@ 3.4088624835301315, 3.409400010386363, 3.4089002837937876, - 3.4043316012608438, + 3.404331601260844, 3.405602956841617, 3.4058965057299115, 3.4037530841116435, @@ -1810,7 +1810,7 @@ 3.4008274783649455, 3.3982736446323356, 3.398255436333994, - 3.3979981104187362, + 3.397998110418736, 3.3973541883390483, 3.396557982147283, 3.3962656154323327, @@ -1825,7 +1825,7 @@ 3.3894078395551404, 3.3902200575620274, 3.388872959111816, - 3.3883761938346066, + 3.388376193834606, 3.3870552125518865, 3.3878270723081485, 3.387585612367422, @@ -1861,14 +1861,14 @@ 3.3696476494448895, 3.367429240451547, 3.367937111474518, - 3.3668403478437114, + 3.366840347843711, 3.3667700881465765, 3.364773916866811, 3.3644332515585815, 3.3646660705372655, 3.363713926563092, 3.362030364559914, - 3.3621003060691446, + 3.362100306069145, 3.3611096028770846, 3.362355117269347, 3.359605825975925, @@ -1888,7 +1888,7 @@ 3.351255044928824, 3.349873197568557, 3.350384960373241, - 3.3490740525256926, + 3.349074052525693, 3.349395231789357, 3.3499964228847237, 3.348957915671143, @@ -1912,7 +1912,7 @@ 3.3365457652580406, 3.3371357055418263, 3.3355780802166617, - 3.3350307184712866, + 3.335030718471286, 3.334581980956628, 3.3347449494903323, 3.332397143584648, @@ -1927,7 +1927,7 @@ 3.328468078369807, 3.3264722747097855, 3.3264740971812445, - 3.3263484277963626, + 3.326348427796362, 3.3269444391677094, 3.3251653788076845, 3.3241792323746235, @@ -1969,7 +1969,7 @@ 3.3042288403057793, 3.30318847639957, 3.302552207516486, - 3.3025289871230314, + 3.302528987123031, 3.3030817905476333, 3.301487538416779, 3.3005801042655447, @@ -1984,7 +1984,7 @@ 3.294587695962234, 3.295356459380646, 3.295325058130884, - 3.2942938908757546, + 3.294293890875754, 3.2931469773690125, 3.292465691134877, 3.292922787827163, @@ -2004,9 +2004,9 @@ 3.284283693955734, 3.2844997293378606, 3.283894245570275, - 3.2812791082580994, - 3.2835561757766962, - 3.2807389934706914, + 3.281279108258099, + 3.283556175776696, + 3.280738993470691, 3.2800229168493167, 3.2796782451088715, 3.280696044222345, @@ -2035,7 +2035,7 @@ 3.265833208779954, 3.2648993126614485, 3.264520317659554, - 3.2645989709271594, + 3.264598970927159, 3.2639331642046656, 3.26300832526535, 3.2620274603567396, @@ -2060,7 +2060,7 @@ 3.2498301744221636, 3.2484191555565975, 3.2495375450880597, - 3.2496608445863386, + 3.249660844586338, 3.2479874595240443, 3.2480653348143775, 3.2467569005104284, @@ -2083,7 +2083,7 @@ 3.23473989270413, 3.2338207778331114, 3.232648897215429, - 3.2319583354284562, + 3.231958335428456, 3.230292451195767, 3.2300868858564953, 3.230276254436496, @@ -2120,11 +2120,11 @@ 3.202861747895686, 3.2048299718419333, 3.20289379856001, - 3.2011294841985842, + 3.201129484198584, 3.2020406481011654, 3.200773748202545, 3.1972107852830907, - 3.1974456115274354, + 3.197445611527435, 3.1940430938771263, 3.1966281631012397, 3.194506145195832, @@ -2142,7 +2142,7 @@ 3.1794393850701703, 3.1805502324547144, 3.179233929087816, - 3.1753467841102854, + 3.175346784110286, 3.1759748251486055, 3.175148530276636, 3.1739869639506555, @@ -2178,7 +2178,7 @@ 3.129075295272788, 3.128499460307512, 3.1265802693942244, - 3.1233904986178054, + 3.123390498617806, 3.1220177337683377, 3.119837510442696, 3.119471223885885, @@ -2196,7 +2196,7 @@ 3.096233415710886, 3.096267445757284, 3.0924498412227113, - 3.0927446894168478, + 3.092744689416848, 3.0894123164291205, 3.0878208666268723, 3.0850873984150406, @@ -2211,7 +2211,7 @@ 3.0682659679861666, 3.0656539970557897, 3.064777913055409, - 3.0628719874551638, + 3.062871987455164, 3.061682480522884, 3.0600846476417445, 3.056441441856114, @@ -2234,7 +2234,7 @@ 3.0233082335006816, 3.022538393309558, 3.0199778845498586, - 3.0185978807033518, + 3.018597880703352, 3.016247604592899, 3.0159944894661264, 3.0124265215698096, @@ -2246,14 +2246,14 @@ 3.0031644147016014, 2.9986072255292604, 2.9972424122895704, - 2.9945551850490926, + 2.994555185049093, 2.9924769980717434, - 2.9921779047190826, + 2.992177904719082, 2.988851399414526, 2.9866736696411067, 2.9851283773477686, 2.9839384964523537, - 2.9792683213926034, + 2.979268321392603, 2.981255387192858, 2.9765528190042043, 2.974490727420044, @@ -2292,7 +2292,7 @@ 2.904786313835339, 2.900256751448325, 2.8981925274112315, - 2.8977374862115646, + 2.897737486211565, 2.8947833619899863, 2.8903411016187497, 2.8883419048861096, @@ -2304,7 +2304,7 @@ 2.873524514724052, 2.8710953534031085, 2.8664954124218793, - 2.8627190996199166, + 2.862719099619917, 2.8616687768139433, 2.8581014549780654, 2.853612984175054, @@ -2323,7 +2323,7 @@ 3.017105908670216, 3.0260204962242256, 3.0313112271610314, - 3.0375381162522634, + 3.037538116252263, 3.0423816592497843, 3.047441150856107, 3.053904554898202, @@ -2369,7 +2369,7 @@ 3.122520001718058, 3.121436324847094, 3.1236821014451492, - 3.1249554322993562, + 3.124955432299356, 3.124101733862297, 3.1277055752788216, 3.125958123065746, @@ -2390,7 +2390,7 @@ 3.13598224416989, 3.1340810350136445, 3.1330863209733084, - 3.1340519200207146, + 3.134051920020714, 3.1355149666008377, 3.1370180490764823, 3.1382483712998437, @@ -2452,7 +2452,7 @@ 3.1526845353657937, 3.150264505194456, 3.152343097230909, - 3.1496983277400594, + 3.149698327740059, 3.152704924210647, 3.1528360365522334, 3.1513854044580083, @@ -2481,7 +2481,7 @@ 3.155952316038506, 3.1576893741548457, 3.154906701673075, - 3.1565445784477846, + 3.156544578447785, 3.1556487424094097, 3.1564370369308525, 3.1551899105724677, @@ -2489,16 +2489,16 @@ 3.1564572466801617, 3.1566887261007137, 3.1564750767572693, - 3.1556267884641334, + 3.155626788464134, 3.157456078769005, 3.1565676331820223, - 3.1558866593740746, + 3.155886659374074, 3.158045638967055, 3.1577032500732862, 3.157677970327829, 3.1596063851604472, 3.1581139539170056, - 3.1596051688361078, + 3.159605168836108, 3.1586944485749235, 3.1597259305826944, 3.159585769510318, @@ -2570,8 +2570,8 @@ 3.1663481856459543, 3.16398037257859, 3.167712151701927, - 3.1646596926562442, - 3.1638533574706598, + 3.164659692656244, + 3.16385335747066, 3.1671811075769156, 3.1649837523850404, 3.166231800858901, @@ -2584,19 +2584,19 @@ 3.1676883201028687, 3.1655246745390406, 3.1662109382849386, - 3.1682666978810006, + 3.168266697881001, 3.167043406206983, 3.166782490411575, 3.1676691054232364, 3.165405507417199, - 3.1673441173388954, + 3.167344117338895, 3.168009496741464, 3.1671164839705157, - 3.1659034437352642, + 3.165903443735264, 3.1681662750644315, 3.166440388634248, 3.1671807941305685, - 3.1688126230166374, + 3.168812623016638, 3.1686597082977057, 3.1689027702400887, 3.1651387246492435, @@ -2619,7 +2619,7 @@ 3.1683403354114676, 3.1683910486111464, 3.167296870033912, - 3.1697328362221238, + 3.169732836222124, 3.169916562205053, 3.170293595658261, 3.1682678986443307, @@ -2794,7 +2794,7 @@ 3.176262695977816, 3.175941723309431, 3.176091203046386, - 3.1747710801668734, + 3.174771080166874, 3.178003559100353, 3.175937387883821, 3.1754171342338617, @@ -3813,7 +3813,9 @@ " print(f\"Running {model.name}\")\n", " problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", " cost = pybop.SumSquaredError(problem)\n", - " optim = pybop.XNES(cost, verbose=True, max_iterations=60, max_unchanged_iterations=15)\n", + " optim = pybop.XNES(\n", + " cost, verbose=True, max_iterations=60, max_unchanged_iterations=15\n", + " )\n", " x, final_cost = optim.run()\n", " optims.append(optim)\n", " xs.append(x)" From 811ab21edc4fc5d4995d57dbca681b65ac17a0d5 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 16:57:55 +0100 Subject: [PATCH 50/85] Implement suggestions from Brady --- pybop/optimisers/base_optimiser.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 43c90c236..1adc6ede5 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -53,7 +53,7 @@ def _set_up_optimiser(self): # Create an instance of the PINTS optimiser class if issubclass(self.pints_optimiser, pints.Optimiser): - self.method = self.pints_optimiser(self.x0, self.sigma0, self._boundaries) + self.method = self.pints_optimiser(self.x0, sigma0=self.sigma0, boundaries=self._boundaries) else: raise ValueError( "The pints_optimiser is not a recognised PINTS optimiser class." @@ -182,9 +182,6 @@ def _run(self): iteration = 0 evaluations = 0 - # Empty result - self.result = Result() - # Unchanged iterations counter unchanged_iterations = 0 @@ -343,10 +340,8 @@ def f(x, grad=None): x = self._transformation.to_model(x) # Store result - self.result.x = x final_cost = self.cost(x) - self.result.final_cost = final_cost - self.result.nit = self._iterations + self.result = Result(x=x, final_cost=final_cost, nit=self._iterations) # Return best position and the score, # i.e the negative log-likelihood in the case of self._minimising = False @@ -491,7 +486,7 @@ class Result: """ - def __init__(self): - self.x = None - self.final_cost = None - self.nit = None + def __init__(self, x = None, final_cost = None, nit = None): + self.x = x + self.final_cost = final_cost + self.nit = nit From 73916261f9383834d127f8c21949db00c82f7c8c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 May 2024 15:59:03 +0000 Subject: [PATCH 51/85] style: pre-commit fixes --- pybop/optimisers/base_optimiser.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 1adc6ede5..ab3a30c2b 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -53,7 +53,9 @@ def _set_up_optimiser(self): # Create an instance of the PINTS optimiser class if issubclass(self.pints_optimiser, pints.Optimiser): - self.method = self.pints_optimiser(self.x0, sigma0=self.sigma0, boundaries=self._boundaries) + self.method = self.pints_optimiser( + self.x0, sigma0=self.sigma0, boundaries=self._boundaries + ) else: raise ValueError( "The pints_optimiser is not a recognised PINTS optimiser class." @@ -486,7 +488,7 @@ class Result: """ - def __init__(self, x = None, final_cost = None, nit = None): + def __init__(self, x=None, final_cost=None, nit=None): self.x = x self.final_cost = final_cost self.nit = nit From 40954c1687cb3be6b2c816ab81f9f5e0658ceb74 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 17:00:35 +0100 Subject: [PATCH 52/85] Remove unnecessary lines --- pybop/_optimisation.py | 3 --- tests/integration/test_parameterisations.py | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 9a7574f83..2ca62c726 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -123,9 +123,6 @@ def run(self): if hasattr(self.cost, "parameters"): self.store_optimised_parameters(x) - # Store the log - self.log = self.log - # Check if parameters are viable if self.physical_viability: self.check_optimal_parameters(x) diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index 81c86efa9..cfa195a50 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -120,14 +120,11 @@ def test_spm_optimisers(self, optimiser, spm_costs): parameterisation.method.set_learning_rate(1.8e-5) else: parameterisation.method.set_learning_rate(0.015) - x, final_cost = parameterisation.run() elif optimiser in [pybop.SciPyMinimize]: parameterisation.cost.problem.model.allow_infeasible_solutions = False - x, final_cost = parameterisation.run() - else: - x, final_cost = parameterisation.run() + x, final_cost = parameterisation.run() # Assertions if parameterisation._minimising: From 6c2de22396b4f3d5d79108e2c5cfad5e9b4ce19a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 17:02:56 +0100 Subject: [PATCH 53/85] Rename base_optimiser.py to base_pints_optimiser.py --- pybop/optimisers/{base_optimiser.py => base_pints_optimiser.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pybop/optimisers/{base_optimiser.py => base_pints_optimiser.py} (100%) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_pints_optimiser.py similarity index 100% rename from pybop/optimisers/base_optimiser.py rename to pybop/optimisers/base_pints_optimiser.py From 5d4093006af02f6eb1919cc895c58c3eed45869d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 17:05:08 +0100 Subject: [PATCH 54/85] Update filename in nit --- pybop/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/__init__.py b/pybop/__init__.py index eea612ae4..6016447f9 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -97,7 +97,7 @@ # Optimiser class # from ._optimisation import Optimisation -from .optimisers.base_optimiser import BasePintsOptimiser +from .optimisers.base_pints_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, SciPyMinimize, From 6c0c7f1c84c72f9c06949ae3e453cf426d7b97e5 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 17:13:16 +0100 Subject: [PATCH 55/85] Revert "Update filename in nit" This reverts commit 5d4093006af02f6eb1919cc895c58c3eed45869d. --- pybop/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/__init__.py b/pybop/__init__.py index 6016447f9..eea612ae4 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -97,7 +97,7 @@ # Optimiser class # from ._optimisation import Optimisation -from .optimisers.base_pints_optimiser import BasePintsOptimiser +from .optimisers.base_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, SciPyMinimize, From 17ae9ec64c479aa862b26be609fe129c376d7865 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 17:13:34 +0100 Subject: [PATCH 56/85] Revert "Rename base_optimiser.py to base_pints_optimiser.py" This reverts commit 6c2de22396b4f3d5d79108e2c5cfad5e9b4ce19a. --- pybop/optimisers/{base_pints_optimiser.py => base_optimiser.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pybop/optimisers/{base_pints_optimiser.py => base_optimiser.py} (100%) diff --git a/pybop/optimisers/base_pints_optimiser.py b/pybop/optimisers/base_optimiser.py similarity index 100% rename from pybop/optimisers/base_pints_optimiser.py rename to pybop/optimisers/base_optimiser.py From 3d18ad3ed7ce926604a67e513e8a0ecec31f54ce Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 20:48:12 +0100 Subject: [PATCH 57/85] Update tests and base option setting --- pybop/_optimisation.py | 30 ++++++++++++--------- tests/integration/test_parameterisations.py | 4 +-- tests/unit/test_optimisation.py | 9 +++++++ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index 2ca62c726..edd78b9bc 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -62,12 +62,16 @@ def __init__( self._minimising = cost._minimising self.set_allow_infeasible_solutions() else: - warnings.warn( - "The cost is not an instance of pybop.BaseCost. Continuing " - + "under the assumption that it is a callable function.", - UserWarning, - ) - self.cost = BaseCost() + try: + cost(optimiser_kwargs.get("x0", [])) + warnings.warn( + "The cost is not an instance of pybop.BaseCost, but let's " + + "continue assuming that it is a callable function.", + UserWarning, + ) + self.cost = BaseCost() + except: + raise Exception("The cost is not a recognised cost object or function.") def cost_evaluate(x, grad=None): return cost(x) @@ -86,12 +90,14 @@ def set_base_options(self): """ Update the base optimiser options and remove them from the options dictionary. """ - key_list = list(self.unset_options.keys()) - for key in key_list: - if key in ["x0", "bounds", "sigma0", "verbose"]: - self.__dict__.update({key: self.unset_options.pop(key)}) - elif key == "allow_infeasible_solutions": - self.set_allow_infeasible_solutions(self.unset_options.pop(key)) + self.x0 = self.unset_options.pop("x0", self.x0) + self.bounds = self.unset_options.pop("bounds", self.bounds) + self.sigma0 = self.unset_options.pop("sigma0", self.sigma0) + self.verbose = self.unset_options.pop("verbose", self.verbose) + if "allow_infeasible_solutions" in self.unset_options.keys(): + self.set_allow_infeasible_solutions( + self.unset_options.pop("allow_infeasible_solutions") + ) def _set_up_optimiser(self): """ diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index cfa195a50..f60ba92f8 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -12,7 +12,7 @@ class TestModelParameterisation: @pytest.fixture(autouse=True) def setup(self): self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( - loc=0.0, scale=0.05, size=2 + loc=0.0, scale=0.03, size=2 ) @pytest.fixture @@ -122,7 +122,7 @@ def test_spm_optimisers(self, optimiser, spm_costs): parameterisation.method.set_learning_rate(0.015) elif optimiser in [pybop.SciPyMinimize]: - parameterisation.cost.problem.model.allow_infeasible_solutions = False + parameterisation.set_allow_infeasible_solutions(False) x, final_cost = parameterisation.run() diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 0a3c2e1a5..3c8964fe7 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -208,6 +208,15 @@ def test_single_parameter(self, cost): ): pybop.CMAES(cost=cost) + @pytest.mark.unit + def test_invalid_cost(self): + # Test without valid cost + with pytest.raises( + Exception, + match="The cost is not a recognised cost object or function.", + ): + pybop.DefaultOptimiser(cost="Invalid string") + @pytest.mark.unit def test_default_optimiser(self, cost): optim = pybop.DefaultOptimiser(cost=cost) From b831ad4f84fd0f2dfdb3859e48496ef20ffe0f1c Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 21:28:38 +0100 Subject: [PATCH 58/85] Update test_invalid_cost --- pybop/_optimisation.py | 22 +++++++++++++--------- tests/unit/test_optimisation.py | 9 +++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index edd78b9bc..f5558a7e7 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -1,5 +1,7 @@ import warnings +import numpy as np + from pybop import BaseCost @@ -63,20 +65,22 @@ def __init__( self.set_allow_infeasible_solutions() else: try: - cost(optimiser_kwargs.get("x0", [])) + cost_test = cost(optimiser_kwargs.get("x0", [])) warnings.warn( - "The cost is not an instance of pybop.BaseCost, but let's " - + "continue assuming that it is a callable function.", + "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 = BaseCost() - except: - raise Exception("The cost is not a recognised cost object or function.") + self.cost = cost + self._minimising = True - def cost_evaluate(x, grad=None): - return cost(x) + except Exception: + raise Exception("The cost is not a recognised cost object or function.") - self.cost._evaluate = cost_evaluate + 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." + ) self.unset_options = optimiser_kwargs self.set_base_options() diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 3c8964fe7..b5e718e38 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -217,6 +217,15 @@ def test_invalid_cost(self): ): pybop.DefaultOptimiser(cost="Invalid string") + def invalid_cost(x): + return [1, 2] + + with pytest.raises( + Exception, + match="not a scalar numeric value.", + ): + pybop.DefaultOptimiser(cost=invalid_cost) + @pytest.mark.unit def test_default_optimiser(self, cost): optim = pybop.DefaultOptimiser(cost=cost) From 769db0de0682095f28528b6d41028763046a02e3 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 9 May 2024 23:42:08 +0100 Subject: [PATCH 59/85] Increase coverage --- pybop/optimisers/scipy_optimisers.py | 4 -- tests/unit/test_optimisation.py | 66 +++++++++++++++++++++++----- tests/unit/test_plots.py | 2 + 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 2e690feb9..2fece1a61 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -139,10 +139,6 @@ def _set_up_optimiser(self): # Nest this option within an options dictionary for SciPy minimize self._options["options"]["maxiter"] = self.unset_options.pop(key) - # Throw an error if any options remain - if self.unset_options: - raise ValueError(f"Unrecognised keyword arguments: {self.unset_options}") - def _run_optimiser(self): """ Executes the optimisation process using SciPy's minimize function. diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index b5e718e38..cb2131d09 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -121,11 +121,11 @@ def test_optimiser_kwargs(self, cost, optimiser): optim.run() assert optim._iterations == 1 - if issubclass(optimiser, (pybop.GradientDescent, pybop.Adam, pybop.NelderMead)): + if optimiser in [pybop.GradientDescent, pybop.Adam, pybop.NelderMead]: # Ignored bounds optim = optimiser(cost=cost, bounds=cost.bounds) assert optim.bounds is None - elif issubclass(optimiser, pybop.PSO): + elif optimiser in [pybop.PSO]: assert optim.bounds == cost.bounds # Cannot accept infinite bounds bounds = {"upper": [np.inf], "lower": [0.57]} @@ -164,6 +164,11 @@ def test_optimiser_kwargs(self, cost, optimiser): optim = optimiser(cost=cost, bounds=bounds, tol=1e-2) assert optim.bounds == bounds + if optimiser in [ + pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, + pybop.CMAES, + ]: # Pass nested options optim = optimiser(cost=cost, options=dict(maxiter=10)) with pytest.raises( @@ -172,14 +177,29 @@ def test_optimiser_kwargs(self, cost, optimiser): ): optimiser(cost=cost, maxiter=5, options=dict(maxiter=10)) + # Pass similar keywords + with pytest.raises( + Exception, + match="option was passed in addition to the expected", + ): + optimiser(cost=cost, maxiter=5, max_iterations=10) + if optimiser in [pybop.SciPyDifferentialEvolution]: # Update population size optimiser(cost=cost, popsize=5) # Test invalid bounds - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Bounds must be specified for differential_evolution." + ): + optimiser(cost=cost, bounds=None) + with pytest.raises( + ValueError, match="Bounds must be specified for differential_evolution." + ): optimiser(cost=cost, bounds=[(0, np.inf)]) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Bounds must be specified for differential_evolution." + ): optimiser(cost=cost, bounds={"upper": [np.inf], "lower": [0.57]}) else: @@ -192,6 +212,7 @@ def test_optimiser_kwargs(self, cost, optimiser): if optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information optimiser(cost=cost, method="L-BFGS-B", jac=True) + optim.run() with pytest.raises( ValueError, match="Expected the jac option to be either True, False or None.", @@ -256,23 +277,24 @@ def test_prior_sampling(self, cost): @pytest.mark.unit def test_halting(self, cost): # Test max evalutions - optim = pybop.GradientDescent(cost=cost) - optim.set_max_evaluations(1) + optim = pybop.GradientDescent(cost=cost, max_evaluations=1, verbose=True) x, __ = optim.run() assert optim._iterations == 1 # Test max unchanged iterations - optim = pybop.GradientDescent(cost=cost) - optim.set_max_unchanged_iterations(1) - optim.set_min_iterations(1) + optim = pybop.GradientDescent( + cost=cost, max_unchanged_iterations=1, min_iterations=1 + ) x, __ = optim.run() assert optim._iterations == 2 # Test guessed values optim.set_f_guessed_tracking(True) - assert optim._use_f_guessed is True + assert optim.f_guessed_tracking() is True # Test invalid values + with pytest.raises(ValueError): + optim.set_max_iterations(-1) with pytest.raises(ValueError): optim.set_max_evaluations(-1) with pytest.raises(ValueError): @@ -282,6 +304,30 @@ def test_halting(self, cost): with pytest.raises(ValueError): optim.set_max_unchanged_iterations(1, threshold=-1) + optim = pybop.DefaultOptimiser(cost=cost) + + # Trigger threshold + optim._threshold = np.inf + optim.run() + optim.set_max_unchanged_iterations() + + # Trigger optimiser error + def optimiser_error(): + return "Optimiser error message" + + optim.method.stop = optimiser_error + optim.run() + + # Test no stopping condition + with pytest.raises( + ValueError, match="At least one stopping criterion must be set." + ): + optim._max_iterations = None + optim._unchanged_max_iterations = None + optim._max_evaluations = None + optim._threshold = None + optim.run() + @pytest.mark.unit def test_infeasible_solutions(self, cost): # Test infeasible solutions diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index b537049f4..b0dd9ad36 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -116,6 +116,8 @@ def optim(self, cost): def test_optim_plots(self, optim): # Plot convergence pybop.plot_convergence(optim) + optim._minimising = False + pybop.plot_convergence(optim) # Plot the parameter traces pybop.plot_parameters(optim) From 402f8c915b131fa4910d22d77bc58136fc36c5cb Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 10 May 2024 00:29:56 +0100 Subject: [PATCH 60/85] Increase coverage --- tests/unit/test_optimisation.py | 11 ++++++++++- tests/unit/test_standalone.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index cb2131d09..0cf2d0406 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -167,7 +167,7 @@ def test_optimiser_kwargs(self, cost, optimiser): if optimiser in [ pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution, - pybop.CMAES, + pybop.XNES, ]: # Pass nested options optim = optimiser(cost=cost, options=dict(maxiter=10)) @@ -311,6 +311,15 @@ def test_halting(self, cost): optim.run() optim.set_max_unchanged_iterations() + # Test callback and halting output + def callback_error(iteration, s): + raise Exception("Callback error message") + + optim._callback = callback_error + with pytest.raises(Exception, match="Callback error message"): + optim.run() + optim._callback = None + # Trigger optimiser error def optimiser_error(): return "Optimiser error message" diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index 86464ee84..099cbae32 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -15,8 +15,16 @@ class TestStandalone: @pytest.mark.unit def test_standalone_optimiser(self): optim = StandaloneOptimiser() + assert optim.name() == "StandaloneOptimiser" + x, final_cost = optim.run() + assert optim.cost(optim.x0) > final_cost + np.testing.assert_allclose(x, [2, 4], atol=1e-2) + # Test with bounds + optim = StandaloneOptimiser(bounds=dict(upper=[5, 6], lower=[1, 2])) + + x, final_cost = optim.run() assert optim.cost(optim.x0) > final_cost np.testing.assert_allclose(x, [2, 4], atol=1e-2) From 192231ed5257b0fffc88ad2e64b1fe5b307fb887 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 10 May 2024 09:50:28 +0100 Subject: [PATCH 61/85] Sort out notebook changes --- .../multi_model_identification.ipynb | 334 +++++++++--------- .../multi_optimiser_identification.ipynb | 2 +- .../notebooks/optimiser_calibration.ipynb | 2 +- 3 files changed, 169 insertions(+), 169 deletions(-) diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index e30102d85..f2ab1822f 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -222,13 +222,13 @@ 6.0046189376443415, 8.006158583525789, 10.007698229407236, - 12.009237875288685, + 12.009237875288683, 14.01077752117013, 16.012317167051577, 18.013856812933025, 20.01539645881447, 22.01693610469592, - 24.01847575057737, + 24.018475750577366, 26.020015396458813, 28.02155504234026, 30.023094688221708, @@ -263,23 +263,23 @@ 88.06774441878368, 90.06928406466513, 92.07082371054656, - 94.072363356428, + 94.07236335642801, 96.07390300230946, 98.07544264819091, 100.07698229407237, 102.0785219399538, - 104.08006158583524, + 104.08006158583525, 106.0816012317167, 108.08314087759814, - 110.0846805234796, + 110.08468052347959, 112.08622016936104, - 114.08775981524248, + 114.08775981524249, 116.08929946112394, 118.09083910700538, - 120.09237875288684, + 120.09237875288683, 122.09391839876828, 124.09545804464972, - 126.09699769053115, + 126.09699769053117, 128.09853733641262, 130.10007698229407, 132.10161662817552, @@ -287,7 +287,7 @@ 136.10469591993842, 138.10623556581984, 140.1077752117013, - 142.10931485758277, + 142.10931485758275, 144.1108545034642, 146.11239414934565, 148.1139337952271, @@ -309,40 +309,40 @@ 180.13856812933025, 182.1401077752117, 184.14164742109313, - 186.14318706697455, + 186.14318706697458, 188.14472671285603, 190.14626635873748, 192.14780600461893, - 194.1493456505004, + 194.14934565050038, 196.15088529638183, 198.15242494226328, 200.15396458814473, 202.15550423402615, 204.1570438799076, - 206.15858352578903, + 206.15858352578906, 208.1601231716705, 210.16166281755196, 212.1632024634334, - 214.1647421093149, + 214.16474210931486, 216.16628175519628, 218.16782140107773, - 220.1693610469592, + 220.16936104695918, 222.17090069284063, 224.17244033872208, 226.17397998460353, - 228.17551963048496, + 228.17551963048498, 230.17705927636644, 232.1785989222479, 234.1801385681293, 236.18167821401076, 238.1832178598922, - 240.1847575057737, + 240.18475750577366, 242.1862971516551, 244.18783679753656, 246.189376443418, 248.19091608929944, 250.19245573518089, - 252.1939953810623, + 252.19399538106234, 254.1955350269438, 256.19707467282524, 258.1986143187067, @@ -397,12 +397,12 @@ 356.2740569668976, 358.27559661277905, 360.2771362586605, - 362.2786759045419, + 362.27867590454196, 364.2802155504234, 366.28175519630486, 368.28329484218625, 370.2848344880677, - 372.2863741339491, + 372.28637413394915, 374.2879137798306, 376.28945342571205, 378.2909930715935, @@ -410,7 +410,7 @@ 382.2940723633564, 384.29561200923786, 386.2971516551193, - 388.2986913010008, + 388.29869130100076, 390.3002309468822, 392.30177059276366, 394.3033102386451, @@ -423,7 +423,7 @@ 408.3140877598152, 410.31562740569666, 412.3171670515781, - 414.3187066974595, + 414.31870669745956, 416.320246343341, 418.32178598922246, 420.3233256351039, @@ -431,12 +431,12 @@ 424.3264049268668, 426.32794457274827, 428.3294842186297, - 430.3310238645112, + 430.33102386451117, 432.33256351039256, 434.334103156274, 436.33564280215546, 438.3371824480369, - 440.3387220939184, + 440.33872209391836, 442.3402617397998, 444.34180138568126, 446.3433410315627, @@ -444,18 +444,18 @@ 450.3464203233256, 452.34795996920707, 454.3494996150885, - 456.35103926097, + 456.35103926096997, 458.3525789068514, 460.35411855273287, 462.3556581986143, 464.3571978444958, - 466.3587374903772, + 466.35873749037717, 468.3602771362586, 470.36181678214007, 472.3633564280215, 474.36489607390297, 476.3664357197844, - 478.3679753656658, + 478.36797536566587, 480.3695150115473, 482.3710546574288, 484.3725943033102, @@ -668,77 +668,77 @@ 898.6913010007697, 900.6928406466512, 902.6943802925326, - 904.695919938414, - 906.6974595842956, + 904.6959199384141, + 906.6974595842955, 908.698999230177, 910.7005388760584, - 912.70207852194, - 914.7036181678212, + 912.7020785219399, + 914.7036181678213, 916.7051578137028, 918.7066974595842, - 920.7082371054656, - 922.7097767513472, + 920.7082371054657, + 922.7097767513471, 924.7113163972286, 926.71285604311, 928.7143956889915, - 930.7159353348728, - 932.7174749807544, + 930.7159353348729, + 932.7174749807543, 934.7190146266358, 936.7205542725172, - 938.7220939183989, - 940.72363356428, + 938.7220939183987, + 940.7236335642801, 942.7251732101616, 944.726712856043, - 946.7282525019244, - 948.729792147806, + 946.7282525019245, + 948.7297921478059, 950.7313317936874, 952.7328714395688, 954.7344110854503, - 956.7359507313316, + 956.7359507313317, 958.7374903772132, 960.7390300230946, 962.740569668976, 964.7421093148575, - 966.7436489607388, + 966.7436489607389, 968.7451886066204, 970.7467282525018, - 972.7482678983832, - 974.7498075442649, + 972.7482678983833, + 974.7498075442647, 976.7513471901462, 978.7528868360276, 980.7544264819091, - 982.7559661277904, + 982.7559661277905, 984.757505773672, 986.7590454195534, 988.760585065435, 990.7621247113163, - 992.7636643571976, + 992.7636643571977, 994.7652040030792, 996.7667436489606, - 998.768283294842, - 1000.7698229407237, + 998.7682832948421, + 1000.7698229407235, 1002.771362586605, 1004.7729022324864, 1006.774441878368, - 1008.7759815242492, + 1008.7759815242493, 1010.7775211701309, 1012.7790608160122, 1014.7806004618938, 1016.7821401077751, - 1018.7836797536568, + 1018.7836797536567, 1020.785219399538, 1022.7867590454196, 1024.788298691301, 1026.7898383371823, - 1028.7913779830635, + 1028.7913779830637, 1030.7929176289454, 1032.7944572748268, - 1034.795996920708, + 1034.7959969207081, 1036.7975365665895, 1038.7990762124712, 1040.8006158583526, 1042.802155504234, - 1044.8036951501151, + 1044.8036951501153, 1046.805234795997, 1048.8067744418784, 1050.8083140877598, @@ -751,7 +751,7 @@ 1064.81909160893, 1066.8206312548114, 1068.8221709006928, - 1070.823710546574, + 1070.8237105465741, 1072.8252501924558, 1074.8267898383372, 1076.8283294842186, @@ -1016,7 +1016,7 @@ 1595.2270977675134, 1597.2286374133948, 1599.2301770592762, - 1601.231716705158, + 1601.2317167051579, 1603.2332563510392, 1605.2347959969206, 1607.236335642802, @@ -1029,12 +1029,12 @@ 1621.2471131639722, 1623.2486528098536, 1625.250192455735, - 1627.2517321016169, + 1627.2517321016167, 1629.253271747498, 1631.2548113933794, 1633.2563510392608, 1635.2578906851422, - 1637.259430331024, + 1637.2594303310239, 1639.2609699769052, 1641.2625096227866, 1643.264049268668, @@ -1042,12 +1042,12 @@ 1647.267128560431, 1649.2686682063124, 1651.2702078521938, - 1653.2717474980757, + 1653.2717474980755, 1655.2732871439568, 1657.2748267898382, 1659.2763664357196, 1661.2779060816013, - 1663.2794457274829, + 1663.2794457274827, 1665.280985373364, 1667.2825250192454, 1669.2840646651268, @@ -1055,17 +1055,17 @@ 1673.2871439568898, 1675.2886836027712, 1677.2902232486526, - 1679.2917628945345, + 1679.2917628945343, 1681.2933025404157, 1683.294842186297, 1685.2963818321784, 1687.29792147806, - 1689.2994611239417, + 1689.2994611239415, 1691.3010007698228, 1693.3025404157042, - 1695.304080061586, + 1695.3040800615859, 1697.3056197074673, - 1699.3071593533489, + 1699.3071593533487, 1701.30869899923, 1703.3102386451114, 1705.311778290993, @@ -1073,129 +1073,129 @@ 1709.3148575827558, 1711.3163972286372, 1713.3179368745189, - 1715.3194765204005, + 1715.3194765204003, 1717.3210161662817, 1719.322555812163, - 1721.324095458045, + 1721.3240954580447, 1723.325635103926, - 1725.3271747498077, + 1725.3271747498075, 1727.3287143956888, 1729.3302540415702, - 1731.331793687452, + 1731.3317936874519, 1733.3333333333333, - 1735.3348729792149, + 1735.3348729792147, 1737.336412625096, 1739.3379522709777, 1741.339491916859, 1743.3410315627405, 1745.3425712086218, - 1747.3441108545037, + 1747.3441108545035, 1749.3456505003849, - 1751.3471901462665, + 1751.3471901462663, 1753.3487297921477, 1755.3502694380293, - 1757.351809083911, + 1757.3518090839107, 1759.353348729792, - 1761.3548883756737, + 1761.3548883756735, 1763.3564280215548, 1765.3579676674365, - 1767.359507313318, + 1767.3595073133179, 1769.3610469591993, - 1771.3625866050809, - 1773.3641262509625, + 1771.3625866050807, + 1773.3641262509623, 1775.3656658968437, 1777.367205542725, 1779.3687451886065, 1781.370284834488, - 1783.3718244803697, + 1783.3718244803695, 1785.3733641262509, - 1787.3749037721325, - 1789.376443418014, + 1787.3749037721323, + 1789.3764434180139, 1791.3779830638953, - 1793.379522709777, + 1793.3795227097767, 1795.381062355658, - 1797.3826020015397, + 1797.3826020015395, 1799.384141647421, 1801.3856812933025, - 1803.387220939184, + 1803.3872209391839, 1805.3887605850653, 1807.3903002309469, - 1809.3918398768285, + 1809.3918398768283, 1811.3933795227097, 1813.394919168591, - 1815.396458814473, + 1815.3964588144727, 1817.397998460354, - 1819.3995381062357, + 1819.3995381062355, 1821.4010777521169, 1823.4026173979985, - 1825.40415704388, + 1825.4041570438799, 1827.4056966897613, - 1829.407236335643, + 1829.4072363356427, 1831.408775981524, 1833.4103156274057, 1835.411855273287, 1837.4133949191685, - 1839.41493456505, - 1841.4164742109317, + 1839.4149345650499, + 1841.4164742109315, 1843.4180138568129, - 1845.4195535026945, + 1845.4195535026943, 1847.4210931485757, 1849.4226327944573, - 1851.424172440339, + 1851.4241724403387, 1853.42571208622, - 1855.4272517321017, + 1855.4272517321015, 1857.428791377983, 1859.4303310238645, - 1861.431870669746, + 1861.4318706697459, 1863.4334103156273, - 1865.434949961509, - 1867.4364896073905, + 1865.4349499615087, + 1867.4364896073903, 1869.4380292532717, 1871.439568899153, 1873.4411085450345, 1875.442648190916, - 1877.4441878367977, + 1877.4441878367975, 1879.4457274826789, - 1881.4472671285605, + 1881.4472671285603, 1883.448806774442, 1885.4503464203233, - 1887.451886066205, + 1887.4518860662047, 1889.453425712086, - 1891.4549653579677, + 1891.4549653579675, 1893.456505003849, 1895.4580446497305, - 1897.459584295612, + 1897.4595842956119, 1899.4611239414933, 1901.462663587375, - 1903.4642032332565, + 1903.4642032332563, 1905.4657428791377, 1907.467282525019, 1909.4688221709007, 1911.470361816782, - 1913.4719014626637, + 1913.4719014626635, 1915.4734411085449, 1917.4749807544265, 1919.476520400308, 1921.4780600461893, - 1923.479599692071, + 1923.4795996920707, 1925.481139337952, 1927.4826789838337, 1929.484218629715, 1931.4857582755965, - 1933.487297921478, + 1933.4872979214779, 1935.4888375673595, 1937.490377213241, - 1939.4919168591225, + 1939.4919168591223, 1941.4934565050037, 1943.4949961508853, 1945.4965357967667, 1947.498075442648, - 1949.4996150885297, + 1949.4996150885295, 1951.501154734411, 1953.5026943802925, 1955.504234026174, 1957.5057736720553, - 1959.507313317937, + 1959.5073133179367, 1961.5088529638183, 1963.5103926096997, 1965.511932255581, @@ -1203,12 +1203,12 @@ 1969.515011547344, 1971.5165511932255, 1973.518090839107, - 1975.5196304849885, + 1975.5196304849883, 1977.52117013087, 1979.5227097767513, 1981.5242494226327, 1983.525789068514, - 1985.5273287143957, + 1985.5273287143955, 1987.528868360277, 1989.5304080061585, 1991.53194765204, @@ -1521,9 +1521,9 @@ 3.6335352582511224, 3.627122765758587, 3.6221080504702927, - 3.616787720630696, - 3.609945699634293, - 3.6079976088554138, + 3.6167877206306964, + 3.6099456996342925, + 3.6079976088554133, 3.6041933507058945, 3.599416815430014, 3.596429034067178, @@ -1536,7 +1536,7 @@ 3.5767560805316228, 3.5778961045754163, 3.573395988130047, - 3.5716009277392, + 3.5716009277392002, 3.569414943962698, 3.56804429486429, 3.565781981746516, @@ -1549,7 +1549,7 @@ 3.556524010915444, 3.554449159138821, 3.5532192510155975, - 3.552765683196472, + 3.5527656831964722, 3.550199878180258, 3.549509216928912, 3.548893107140227, @@ -1578,7 +1578,7 @@ 3.527087236644306, 3.528229306392814, 3.526727091932129, - 3.523725946210277, + 3.5237259462102766, 3.523358276153151, 3.522630420043115, 3.522098228220245, @@ -1620,7 +1620,7 @@ 3.5002807746928624, 3.499093637966968, 3.498788374056211, - 3.498478598489658, + 3.4984785984896574, 3.499784189727196, 3.4980584873070817, 3.4978661714478565, @@ -1660,7 +1660,7 @@ 3.4802370894043246, 3.4791879782425816, 3.4798530512948234, - 3.476693546899011, + 3.4766935468990114, 3.4763588018753024, 3.476681441889914, 3.4768659814457297, @@ -1668,13 +1668,13 @@ 3.4759573804777584, 3.4754182985385587, 3.475088650570002, - 3.475041977983772, + 3.4750419779837722, 3.4740322920318754, 3.474713449213277, 3.4712986801974104, 3.472166561698215, 3.4729038701998873, - 3.471625823138768, + 3.4716258231387678, 3.4710018101751983, 3.469920256552502, 3.470005188370876, @@ -1697,7 +1697,7 @@ 3.4613173368361783, 3.4605987473085724, 3.460051650131785, - 3.460585757243829, + 3.4605857572438286, 3.458095126030117, 3.4573193340700423, 3.4588208311353927, @@ -1707,16 +1707,16 @@ 3.455795586844551, 3.4551590878391885, 3.4551470829661604, - 3.45281241771075, + 3.4528124177107506, 3.453870587520652, - 3.452564087067191, + 3.4525640870671914, 3.452854596728473, - 3.452354511045552, + 3.4523545110455522, 3.4522287822375475, 3.4524039267482483, 3.449481200352293, 3.4495961613926567, - 3.449579070264394, + 3.4495790702643934, 3.4499032704341643, 3.4473506521553916, 3.448579307300168, @@ -1742,7 +1742,7 @@ 3.4385302193614287, 3.4388296239847107, 3.437374775020289, - 3.43696300874531, + 3.4369630087453094, 3.4370575942904726, 3.4354414175833337, 3.433350597269167, @@ -1764,7 +1764,7 @@ 3.425675118914293, 3.4239398422529828, 3.424405251210055, - 3.425620229622905, + 3.4256202296229046, 3.4246181248431715, 3.4245147207270255, 3.4234377313942064, @@ -1796,7 +1796,7 @@ 3.4088624835301315, 3.409400010386363, 3.4089002837937876, - 3.404331601260844, + 3.4043316012608438, 3.405602956841617, 3.4058965057299115, 3.4037530841116435, @@ -1810,7 +1810,7 @@ 3.4008274783649455, 3.3982736446323356, 3.398255436333994, - 3.397998110418736, + 3.3979981104187362, 3.3973541883390483, 3.396557982147283, 3.3962656154323327, @@ -1825,7 +1825,7 @@ 3.3894078395551404, 3.3902200575620274, 3.388872959111816, - 3.388376193834606, + 3.3883761938346066, 3.3870552125518865, 3.3878270723081485, 3.387585612367422, @@ -1861,14 +1861,14 @@ 3.3696476494448895, 3.367429240451547, 3.367937111474518, - 3.366840347843711, + 3.3668403478437114, 3.3667700881465765, 3.364773916866811, 3.3644332515585815, 3.3646660705372655, 3.363713926563092, 3.362030364559914, - 3.362100306069145, + 3.3621003060691446, 3.3611096028770846, 3.362355117269347, 3.359605825975925, @@ -1888,7 +1888,7 @@ 3.351255044928824, 3.349873197568557, 3.350384960373241, - 3.349074052525693, + 3.3490740525256926, 3.349395231789357, 3.3499964228847237, 3.348957915671143, @@ -1912,7 +1912,7 @@ 3.3365457652580406, 3.3371357055418263, 3.3355780802166617, - 3.335030718471286, + 3.3350307184712866, 3.334581980956628, 3.3347449494903323, 3.332397143584648, @@ -1927,7 +1927,7 @@ 3.328468078369807, 3.3264722747097855, 3.3264740971812445, - 3.326348427796362, + 3.3263484277963626, 3.3269444391677094, 3.3251653788076845, 3.3241792323746235, @@ -1969,7 +1969,7 @@ 3.3042288403057793, 3.30318847639957, 3.302552207516486, - 3.302528987123031, + 3.3025289871230314, 3.3030817905476333, 3.301487538416779, 3.3005801042655447, @@ -1984,7 +1984,7 @@ 3.294587695962234, 3.295356459380646, 3.295325058130884, - 3.294293890875754, + 3.2942938908757546, 3.2931469773690125, 3.292465691134877, 3.292922787827163, @@ -2004,9 +2004,9 @@ 3.284283693955734, 3.2844997293378606, 3.283894245570275, - 3.281279108258099, - 3.283556175776696, - 3.280738993470691, + 3.2812791082580994, + 3.2835561757766962, + 3.2807389934706914, 3.2800229168493167, 3.2796782451088715, 3.280696044222345, @@ -2035,7 +2035,7 @@ 3.265833208779954, 3.2648993126614485, 3.264520317659554, - 3.264598970927159, + 3.2645989709271594, 3.2639331642046656, 3.26300832526535, 3.2620274603567396, @@ -2060,7 +2060,7 @@ 3.2498301744221636, 3.2484191555565975, 3.2495375450880597, - 3.249660844586338, + 3.2496608445863386, 3.2479874595240443, 3.2480653348143775, 3.2467569005104284, @@ -2083,7 +2083,7 @@ 3.23473989270413, 3.2338207778331114, 3.232648897215429, - 3.231958335428456, + 3.2319583354284562, 3.230292451195767, 3.2300868858564953, 3.230276254436496, @@ -2120,11 +2120,11 @@ 3.202861747895686, 3.2048299718419333, 3.20289379856001, - 3.201129484198584, + 3.2011294841985842, 3.2020406481011654, 3.200773748202545, 3.1972107852830907, - 3.197445611527435, + 3.1974456115274354, 3.1940430938771263, 3.1966281631012397, 3.194506145195832, @@ -2142,7 +2142,7 @@ 3.1794393850701703, 3.1805502324547144, 3.179233929087816, - 3.175346784110286, + 3.1753467841102854, 3.1759748251486055, 3.175148530276636, 3.1739869639506555, @@ -2178,7 +2178,7 @@ 3.129075295272788, 3.128499460307512, 3.1265802693942244, - 3.123390498617806, + 3.1233904986178054, 3.1220177337683377, 3.119837510442696, 3.119471223885885, @@ -2196,7 +2196,7 @@ 3.096233415710886, 3.096267445757284, 3.0924498412227113, - 3.092744689416848, + 3.0927446894168478, 3.0894123164291205, 3.0878208666268723, 3.0850873984150406, @@ -2211,7 +2211,7 @@ 3.0682659679861666, 3.0656539970557897, 3.064777913055409, - 3.062871987455164, + 3.0628719874551638, 3.061682480522884, 3.0600846476417445, 3.056441441856114, @@ -2234,7 +2234,7 @@ 3.0233082335006816, 3.022538393309558, 3.0199778845498586, - 3.018597880703352, + 3.0185978807033518, 3.016247604592899, 3.0159944894661264, 3.0124265215698096, @@ -2246,14 +2246,14 @@ 3.0031644147016014, 2.9986072255292604, 2.9972424122895704, - 2.994555185049093, + 2.9945551850490926, 2.9924769980717434, - 2.992177904719082, + 2.9921779047190826, 2.988851399414526, 2.9866736696411067, 2.9851283773477686, 2.9839384964523537, - 2.979268321392603, + 2.9792683213926034, 2.981255387192858, 2.9765528190042043, 2.974490727420044, @@ -2292,7 +2292,7 @@ 2.904786313835339, 2.900256751448325, 2.8981925274112315, - 2.897737486211565, + 2.8977374862115646, 2.8947833619899863, 2.8903411016187497, 2.8883419048861096, @@ -2304,7 +2304,7 @@ 2.873524514724052, 2.8710953534031085, 2.8664954124218793, - 2.862719099619917, + 2.8627190996199166, 2.8616687768139433, 2.8581014549780654, 2.853612984175054, @@ -2323,7 +2323,7 @@ 3.017105908670216, 3.0260204962242256, 3.0313112271610314, - 3.037538116252263, + 3.0375381162522634, 3.0423816592497843, 3.047441150856107, 3.053904554898202, @@ -2369,7 +2369,7 @@ 3.122520001718058, 3.121436324847094, 3.1236821014451492, - 3.124955432299356, + 3.1249554322993562, 3.124101733862297, 3.1277055752788216, 3.125958123065746, @@ -2390,7 +2390,7 @@ 3.13598224416989, 3.1340810350136445, 3.1330863209733084, - 3.134051920020714, + 3.1340519200207146, 3.1355149666008377, 3.1370180490764823, 3.1382483712998437, @@ -2452,7 +2452,7 @@ 3.1526845353657937, 3.150264505194456, 3.152343097230909, - 3.149698327740059, + 3.1496983277400594, 3.152704924210647, 3.1528360365522334, 3.1513854044580083, @@ -2481,7 +2481,7 @@ 3.155952316038506, 3.1576893741548457, 3.154906701673075, - 3.156544578447785, + 3.1565445784477846, 3.1556487424094097, 3.1564370369308525, 3.1551899105724677, @@ -2489,16 +2489,16 @@ 3.1564572466801617, 3.1566887261007137, 3.1564750767572693, - 3.155626788464134, + 3.1556267884641334, 3.157456078769005, 3.1565676331820223, - 3.155886659374074, + 3.1558866593740746, 3.158045638967055, 3.1577032500732862, 3.157677970327829, 3.1596063851604472, 3.1581139539170056, - 3.159605168836108, + 3.1596051688361078, 3.1586944485749235, 3.1597259305826944, 3.159585769510318, @@ -2570,8 +2570,8 @@ 3.1663481856459543, 3.16398037257859, 3.167712151701927, - 3.164659692656244, - 3.16385335747066, + 3.1646596926562442, + 3.1638533574706598, 3.1671811075769156, 3.1649837523850404, 3.166231800858901, @@ -2584,19 +2584,19 @@ 3.1676883201028687, 3.1655246745390406, 3.1662109382849386, - 3.168266697881001, + 3.1682666978810006, 3.167043406206983, 3.166782490411575, 3.1676691054232364, 3.165405507417199, - 3.167344117338895, + 3.1673441173388954, 3.168009496741464, 3.1671164839705157, - 3.165903443735264, + 3.1659034437352642, 3.1681662750644315, 3.166440388634248, 3.1671807941305685, - 3.168812623016638, + 3.1688126230166374, 3.1686597082977057, 3.1689027702400887, 3.1651387246492435, @@ -2619,7 +2619,7 @@ 3.1683403354114676, 3.1683910486111464, 3.167296870033912, - 3.169732836222124, + 3.1697328362221238, 3.169916562205053, 3.170293595658261, 3.1682678986443307, @@ -2794,7 +2794,7 @@ 3.176262695977816, 3.175941723309431, 3.176091203046386, - 3.174771080166874, + 3.1747710801668734, 3.178003559100353, 3.175937387883821, 3.1754171342338617, diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index 82e462558..5c38e963e 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -966,7 +966,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.12.2" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index d01fc1dc1..4b5beee9e 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -755,7 +755,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.11.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { From b59833fa6f0793ec2504bb4328c2f8a57fd0c148 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:10:50 +0100 Subject: [PATCH 62/85] Fix merge --- pybop/optimisers/scipy_optimisers.py | 10 ++++----- .../test_thevenin_parameterisation.py | 21 ++++++++++++------- tests/unit/test_optimisation.py | 7 ++++--- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 2dae57665..b3223221e 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -157,14 +157,14 @@ def callback(x): self.log.append([x]) # Check x0 and resample if required - self.cost0 = np.abs(self.cost(self.x0)) - if np.isinf(self.cost0): + self._cost0 = np.abs(self.cost(self.x0)) + if np.isinf(self._cost0): for i in range(1, self.num_resamples): x0 = self.cost.problem.sample_initial_conditions(seed=i) - self.cost0 = np.abs(self.cost(x0)) - if not np.isinf(self.cost0): + self._cost0 = np.abs(self.cost(x0)) + if not np.isinf(self._cost0): break - if np.isinf(self.cost0): + if np.isinf(self._cost0): raise ValueError( "The initial parameter values return an infinite cost." ) diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py index 2f609a0e5..e552fa137 100644 --- a/tests/integration/test_thevenin_parameterisation.py +++ b/tests/integration/test_thevenin_parameterisation.py @@ -70,22 +70,29 @@ def cost(self, model, parameters, cost_class): def test_optimisers_on_simple_model(self, optimiser, cost): x0 = cost.x0 if optimiser in [pybop.GradientDescent]: - parameterisation = pybop.Optimisation( - cost=cost, optimiser=optimiser, sigma0=2.5e-4 + parameterisation = optimiser( + cost=cost, + sigma0=2.5e-4, + max_iterations=250, ) else: - parameterisation = pybop.Optimisation( - cost=cost, optimiser=optimiser, sigma0=0.03 + parameterisation = optimiser( + cost=cost, + sigma0=0.03, + max_iterations=250, ) + if isinstance(optimiser, pybop.BasePintsOptimiser): + parameterisation.set_max_unchanged_iterations(iterations=55, threshold=1e-5) - parameterisation.set_max_unchanged_iterations(iterations=55, threshold=1e-5) - parameterisation.set_max_iterations(250) initial_cost = parameterisation.cost(x0) x, final_cost = parameterisation.run() # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - assert initial_cost > final_cost + if parameterisation._minimising: + assert initial_cost > final_cost + else: + assert initial_cost < final_cost np.testing.assert_allclose(x, self.ground_truth, atol=1e-2) def getdata(self, model, x): diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index c58ba2e6d..06898a2ae 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -295,10 +295,11 @@ def test_scipy_prior_resampling( cost = pybop.SumSquaredError(problem) # Create the optimisation class with infeasible solutions disabled - opt = pybop.Optimisation( - cost=cost, optimiser=pybop.SciPyMinimize, allow_infeasible_solutions=False + opt = pybop.SciPyMinimize( + cost=cost, + allow_infeasible_solutions=False, + max_iterations=1, ) - opt.set_max_iterations(1) # If small sigma, expect a ValueError due inability to resample a non np.inf cost if expect_exception: From 4be31039cddc89a40a28ed868aa48e99173f33a8 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:14:16 +0100 Subject: [PATCH 63/85] Reset scale parameter --- tests/integration/test_spm_parameterisations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py index d4049881b..77d123c88 100644 --- a/tests/integration/test_spm_parameterisations.py +++ b/tests/integration/test_spm_parameterisations.py @@ -14,7 +14,7 @@ class Test_SPM_Parameterisation: @pytest.fixture(autouse=True) def setup(self): self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( - loc=0.0, scale=0.03, size=2 + loc=0.0, scale=0.05, size=2 ) @pytest.fixture From 1419111e8cb31edf83e9c1c9c54d2307d20f2a6a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:28:03 +0100 Subject: [PATCH 64/85] Move settings into arguments --- tests/integration/test_optimisation_options.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 0d815cd5a..6f689aecd 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -81,9 +81,14 @@ def spm_costs(self, model, parameters, cost_class): @pytest.mark.integration def test_optimisation_f_guessed(self, f_guessed, spm_costs): # Test each optimiser - parameterisation = pybop.XNES(cost=spm_costs, sigma0=0.05, max_iterations=125) - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) - parameterisation.set_f_guessed_tracking(f_guessed) + parameterisation = pybop.XNES( + cost=spm_costs, + sigma0=0.05, + max_iterations=125, + max_unchanged_iterations=35, + threshold=1e-5, + use_f_guessed=f_guessed, + ) # Set parallelisation if not on Windows if sys.platform != "win32": From 4237ad5067a42231b97109d4a5384bbc5e8f2e84 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:34:05 +0100 Subject: [PATCH 65/85] Update comments --- pybop/optimisers/scipy_optimisers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index b3223221e..472012578 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -156,7 +156,7 @@ def _run_optimiser(self): def callback(x): self.log.append([x]) - # Check x0 and resample if required + # Compute the absolute initial cost and resample if required self._cost0 = np.abs(self.cost(self.x0)) if np.isinf(self._cost0): for i in range(1, self.num_resamples): @@ -169,7 +169,7 @@ def callback(x): "The initial parameter values return an infinite cost." ) - # Scale the cost function and eliminate nan values + # Scale the cost function, preserving the sign convention, and eliminate nan values self.inf_count = 0 if not self._options["jac"]: From 4991a37dc5eb2c9a12474f923790275bf6596459 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:45:34 +0100 Subject: [PATCH 66/85] Update optimiser call --- examples/scripts/gitt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/gitt.py b/examples/scripts/gitt.py index d31ac2d4c..b81f7433a 100644 --- a/examples/scripts/gitt.py +++ b/examples/scripts/gitt.py @@ -54,7 +54,7 @@ cost = pybop.RootMeanSquaredError(problem) # Build the optimisation problem -optim = pybop.Optimisation(cost=cost, optimiser=pybop.PSO, verbose=True) +optim = pybop.PSO(cost=cost, verbose=True) # Run the optimisation problem x, final_cost = optim.run() From ec027a5ef3e496a5284fb3be828375d1a093069d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:54:07 +0100 Subject: [PATCH 67/85] Move check on jac --- pybop/optimisers/scipy_optimisers.py | 11 ++++++----- tests/unit/test_optimisation.py | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 472012578..7a2ed7d9f 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -129,13 +129,18 @@ def _set_up_optimiser(self): for key in key_list: if key in [ "method", - "jac", "hess", "hessp", "constraints", "tol", ]: self._options.update({key: self.unset_options.pop(key)}) + elif key == "jac": + if self.unset_options["jac"] not in [True, False, None]: + raise ValueError( + f"Expected the jac option to be either True, False or None. Received: {self.unset_options[key]}" + ) + self._options.update({key: self.unset_options.pop(key)}) elif key == "maxiter": # Nest this option within an options dictionary for SciPy minimize self._options["options"]["maxiter"] = self.unset_options.pop(key) @@ -184,10 +189,6 @@ def cost_wrapper(x): def cost_wrapper(x): return self.cost.evaluateS1(x, minimising=self._minimising) - else: - raise ValueError( - "Expected the jac option to be either True, False or None." - ) result = minimize( cost_wrapper, diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 06898a2ae..04ceede19 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -216,7 +216,6 @@ def test_optimiser_kwargs(self, cost, optimiser): match="Expected the jac option to be either True, False or None.", ): optim = optimiser(cost=cost, jac="Invalid string") - optim.run() @pytest.mark.unit def test_single_parameter(self, cost): From 40c91ee3d0a00830c4a3114ee615127e0b5c2160 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:56:11 +0100 Subject: [PATCH 68/85] Add assertions --- tests/unit/test_optimisation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 04ceede19..d07048876 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -211,6 +211,7 @@ def test_optimiser_kwargs(self, cost, optimiser): # Check a method that uses gradient information optimiser(cost=cost, method="L-BFGS-B", jac=True) optim.run() + assert optim._iterations > 0 with pytest.raises( ValueError, match="Expected the jac option to be either True, False or None.", @@ -362,6 +363,7 @@ def optimiser_error(): optim.method.stop = optimiser_error optim.run() + assert optim._iterations == 1 # Test no stopping condition with pytest.raises( From b6633963966b4cc23fd26344a0af1e25421004a7 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:57:23 +0100 Subject: [PATCH 69/85] Add maxiter to test --- tests/unit/test_optimisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index d07048876..049e02e89 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -209,7 +209,7 @@ def test_optimiser_kwargs(self, cost, optimiser): if optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information - optimiser(cost=cost, method="L-BFGS-B", jac=True) + optimiser(cost=cost, method="L-BFGS-B", jac=True, maxiter=10) optim.run() assert optim._iterations > 0 with pytest.raises( From e2ae1dd492a49e8c11cf2c07751a6598cea6550a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 16:58:37 +0100 Subject: [PATCH 70/85] Add assertion --- tests/unit/test_optimisation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 049e02e89..efdb0db6a 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -206,6 +206,7 @@ def test_optimiser_kwargs(self, cost, optimiser): x0_new = np.array([0.6]) optim = optimiser(cost=cost, x0=x0_new) assert optim.x0 == x0_new + assert optim.x0 != cost.x0 if optimiser in [pybop.SciPyMinimize]: # Check a method that uses gradient information From aa73bff23c5805706e5aa6b0646730159d1c8ceb Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 17:03:13 +0100 Subject: [PATCH 71/85] Update to lambda functions Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- pybop/optimisers/base_optimiser.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index ab3a30c2b..c5e475422 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -189,13 +189,9 @@ def _run(self): # Choose method to evaluate if self._needs_sensitivities: - - def f(x): - return self.cost.evaluateS1(x, minimising=self._minimising) + f = lambda x: self.cost.evaluateS1(x, minimising=self._minimising) else: - - def f(x, grad=None): - return self.cost(x, grad, minimising=self._minimising) + f = lambda x, grad=None: self.cost(x, grad, minimising=self._minimising) # Create evaluator object if self._parallel: From 9fa11552630ce00a8540d75e60a4f9072a4fd944 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 17:03:55 +0100 Subject: [PATCH 72/85] Update comment Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- examples/standalone/optimiser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/standalone/optimiser.py b/examples/standalone/optimiser.py index ab437d9aa..89414e10b 100644 --- a/examples/standalone/optimiser.py +++ b/examples/standalone/optimiser.py @@ -15,7 +15,7 @@ def cost(x): x1, x2 = x return (x1 - 2) ** 2 + (x2 - 4) ** 4 - # Set cost function, initial values and 0ther options + # Set initial values and other options optimiser_options = dict( x0=np.array([0, 0]), bounds=None, From dd6446bb1efd3fc3dcf441f0611cce758ee2f58a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 17:05:28 +0100 Subject: [PATCH 73/85] Update to list comprehension Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- pybop/plotting/plot_convergence.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index c04365552..fe5ca15c5 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -31,16 +31,12 @@ def plot_convergence(optim, show=True, **layout_kwargs): log = optim.log # Find the best cost from each iteration - if optim._minimising: - best_cost_per_iteration = [ - min((cost(solution) for solution in log_entry), default=np.inf) - for log_entry in log - ] - else: - best_cost_per_iteration = [ - max((cost(solution) for solution in log_entry), default=-np.inf) - for log_entry in log - ] + best_cost_per_iteration = [ + min((cost(solution) for solution in log_entry), default=np.inf) + if optim._minimising + else max((cost(solution) for solution in log_entry), default=-np.inf) + for log_entry in log +] # Generate a list of iteration numbers iteration_numbers = list(range(1, len(best_cost_per_iteration) + 1)) From dc04f47fc73ce24fc26a474876a98019ffc739b1 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 17:10:29 +0100 Subject: [PATCH 74/85] Formatting --- pybop/plotting/plot_convergence.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index fe5ca15c5..2743b386f 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -31,12 +31,12 @@ def plot_convergence(optim, show=True, **layout_kwargs): log = optim.log # Find the best cost from each iteration - best_cost_per_iteration = [ - min((cost(solution) for solution in log_entry), default=np.inf) - if optim._minimising - else max((cost(solution) for solution in log_entry), default=-np.inf) - for log_entry in log -] + best_cost_per_iteration = [ + min((cost(solution) for solution in log_entry), default=np.inf) + if optim._minimising + else max((cost(solution) for solution in log_entry), default=-np.inf) + for log_entry in log + ] # Generate a list of iteration numbers iteration_numbers = list(range(1, len(best_cost_per_iteration) + 1)) From da2b74211ccdd845efbf4e64844575767a699d69 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 17:14:34 +0100 Subject: [PATCH 75/85] Revert "Update to lambda functions" This reverts commit aa73bff23c5805706e5aa6b0646730159d1c8ceb. --- pybop/optimisers/base_optimiser.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index c5e475422..ab3a30c2b 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -189,9 +189,13 @@ def _run(self): # Choose method to evaluate if self._needs_sensitivities: - f = lambda x: self.cost.evaluateS1(x, minimising=self._minimising) + + def f(x): + return self.cost.evaluateS1(x, minimising=self._minimising) else: - f = lambda x, grad=None: self.cost(x, grad, minimising=self._minimising) + + def f(x, grad=None): + return self.cost(x, grad, minimising=self._minimising) # Create evaluator object if self._parallel: From 1627754e7b48634b804535d3cff2054b35e1b701 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 18:49:36 +0100 Subject: [PATCH 76/85] Move minimising out of costs --- pybop/_optimisation.py | 13 ++++++++---- pybop/costs/_likelihoods.py | 1 - pybop/costs/base_cost.py | 20 ++++++------------- pybop/costs/design_costs.py | 8 ++++---- pybop/costs/fitting_costs.py | 2 +- pybop/optimisers/base_optimiser.py | 14 ++++++------- pybop/optimisers/scipy_optimisers.py | 9 +++++---- pybop/plotting/plot_convergence.py | 2 +- .../integration/test_optimisation_options.py | 2 +- .../integration/test_spm_parameterisations.py | 4 ++-- .../test_thevenin_parameterisation.py | 2 +- 11 files changed, 37 insertions(+), 40 deletions(-) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index f5558a7e7..1f57e96c6 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -2,7 +2,7 @@ import numpy as np -from pybop import BaseCost +from pybop import BaseCost, BaseLikelihood, DesignCost class Optimisation: @@ -31,6 +31,9 @@ class Optimisation: Initial step size or standard deviation for the optimiser. 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 @@ -50,7 +53,7 @@ def __init__( self.sigma0 = 0.1 self.verbose = False self.log = [] - self._minimising = True + self.minimising = True self.physical_viability = False self.allow_infeasible_solutions = False self.default_max_iterations = 1000 @@ -61,8 +64,9 @@ def __init__( self.x0 = cost.x0 self.bounds = cost.bounds self.sigma0 = cost.sigma0 - self._minimising = cost._minimising self.set_allow_infeasible_solutions() + if isinstance(cost, (BaseLikelihood, DesignCost)): + self.minimising = False else: try: cost_test = cost(optimiser_kwargs.get("x0", [])) @@ -72,7 +76,7 @@ def __init__( UserWarning, ) self.cost = cost - self._minimising = True + self.minimising = True except Exception: raise Exception("The cost is not a recognised cost object or function.") @@ -98,6 +102,7 @@ def set_base_options(self): self.bounds = self.unset_options.pop("bounds", self.bounds) self.sigma0 = self.unset_options.pop("sigma0", self.sigma0) 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") diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 545eba4cc..91374cc07 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -11,7 +11,6 @@ class BaseLikelihood(BaseCost): def __init__(self, problem, sigma=None): super(BaseLikelihood, self).__init__(problem, sigma) self.n_time_data = problem.n_time_data - self._minimising = False def set_sigma(self, sigma): """ diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index 1160ba9e8..025cb2e42 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -38,7 +38,6 @@ def __init__(self, problem=None, sigma=None): self.x0 = None self.bounds = None self.sigma0 = sigma - self._minimising = True if isinstance(self.problem, BaseProblem): self._target = problem._target self.parameters = problem.parameters @@ -53,13 +52,13 @@ def __init__(self, problem=None, sigma=None): def n_parameters(self): return self._n_parameters - def __call__(self, x, grad=None, minimising=True): + def __call__(self, x, grad=None): """ Call the evaluate function for a given set of parameters. """ - return self.evaluate(x, grad, minimising) + return self.evaluate(x, grad) - def evaluate(self, x, grad=None, minimising=True): + def evaluate(self, x, grad=None): """ Call the evaluate function for a given set of parameters. @@ -82,10 +81,7 @@ def evaluate(self, x, grad=None, minimising=True): If an error occurs during the calculation of the cost. """ try: - if minimising: - return self._evaluate(x, grad) - else: # minimise the negative cost - return -self._evaluate(x, grad) + return self._evaluate(x, grad) except NotImplementedError as e: raise e @@ -119,7 +115,7 @@ def _evaluate(self, x, grad=None): """ raise NotImplementedError - def evaluateS1(self, x, minimising=True): + def evaluateS1(self, x): """ Call _evaluateS1 for a given set of parameters. @@ -140,11 +136,7 @@ def evaluateS1(self, x, minimising=True): If an error occurs during the calculation of the cost or gradient. """ try: - if minimising: - return self._evaluateS1(x) - else: # minimise the negative cost - L, dl = self._evaluateS1(x) - return -L, -dl + return self._evaluateS1(x) except NotImplementedError as e: raise e diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index f51a1b063..f6364cdc6 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -90,14 +90,14 @@ class GravimetricEnergyDensity(DesignCost): """ Represents the gravimetric energy density of a battery cell, calculated based on a normalised discharge from upper to lower voltage limits. The goal is to - maximise the energy density, which is achieved by setting _minimising = False. + maximise the energy density, which is achieved by setting minimising = False + in the optimiser settings. Inherits all parameters and attributes from ``DesignCost``. """ def __init__(self, problem, update_capacity=False): super(GravimetricEnergyDensity, self).__init__(problem, update_capacity) - self._minimising = False def _evaluate(self, x, grad=None): """ @@ -149,14 +149,14 @@ class VolumetricEnergyDensity(DesignCost): """ Represents the volumetric energy density of a battery cell, calculated based on a normalised discharge from upper to lower voltage limits. The goal is to - maximise the energy density, which is achieved by setting _minimising = False. + maximise the energy density, which is achieved by setting minimising = False + in the optimiser settings. Inherits all parameters and attributes from ``DesignCost``. """ def __init__(self, problem, update_capacity=False): super(VolumetricEnergyDensity, self).__init__(problem, update_capacity) - self._minimising = False def _evaluate(self, x, grad=None): """ diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index 50c1944a7..b7b266591 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -288,7 +288,7 @@ class MAP(BaseLikelihood): Computes the maximum a posteriori cost function, which is the sum of the log likelihood and the log prior. The goal of maximising is achieved by - setting _minimising = False. + setting minimising = False in the optimiser settings. Inherits all parameters and attributes from ``BaseLikelihood``. diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index ab3a30c2b..0c3b77995 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -191,11 +191,12 @@ def _run(self): if self._needs_sensitivities: def f(x): - return self.cost.evaluateS1(x, minimising=self._minimising) + L, dl = self.cost.evaluateS1(x) + return (L, dl) if self.minimising else (-L, -dl) else: def f(x, grad=None): - return self.cost(x, grad, minimising=self._minimising) + return self.cost(x, grad) if self.minimising else -self.cost(x, grad) # Create evaluator object if self._parallel: @@ -214,7 +215,7 @@ def f(x, grad=None): fb = fg = np.inf # Internally we always minimise! Keep a 2nd value to show the user. - fg_user = (fb, fg) if self._minimising else (-fb, -fg) + fg_user = (fb, fg) if self.minimising else (-fb, -fg) # Keep track of the last significant change f_sig = np.inf @@ -235,7 +236,7 @@ def f(x, grad=None): # Update the scores fb = self.method.f_best() fg = self.method.f_guessed() - fg_user = (fb, fg) if self._minimising else (-fb, -fg) + fg_user = (fb, fg) if self.minimising else (-fb, -fg) # Check for significant changes f_new = fg if self._use_f_guessed else fb @@ -342,11 +343,10 @@ def f(x, grad=None): x = self._transformation.to_model(x) # Store result - final_cost = self.cost(x) + final_cost = f if self.minimising else -f self.result = Result(x=x, final_cost=final_cost, nit=self._iterations) - # Return best position and the score, - # i.e the negative log-likelihood in the case of self._minimising = False + # Return best position and its cost return x, final_cost def f_guessed_tracking(self): diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 7a2ed7d9f..16a8e898a 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -180,15 +180,16 @@ def callback(x): if not self._options["jac"]: def cost_wrapper(x): - cost = self.cost(x, minimising=self._minimising) / self._cost0 + cost = self.cost(x) / self._cost0 if np.isinf(cost): self.inf_count += 1 cost = 1 + 0.9**self.inf_count # for fake finite gradient - return cost + return cost if self.minimising else -cost elif self._options["jac"] is True: def cost_wrapper(x): - return self.cost.evaluateS1(x, minimising=self._minimising) + L, dl = self.cost.evaluateS1(x) + return L, dl if self.minimising else -L, -dl result = minimize( cost_wrapper, @@ -307,7 +308,7 @@ def callback(x, convergence): self.log.append([x]) def cost_wrapper(x): - return self.cost(x, minimising=self._minimising) + return self.cost(x) if self.minimising else -self.cost(x) result = differential_evolution( cost_wrapper, diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 2743b386f..06b3274d3 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -33,7 +33,7 @@ def plot_convergence(optim, show=True, **layout_kwargs): # Find the best cost from each iteration best_cost_per_iteration = [ min((cost(solution) for solution in log_entry), default=np.inf) - if optim._minimising + if optim.minimising else max((cost(solution) for solution in log_entry), default=-np.inf) for log_entry in log ] diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 6f689aecd..92020af20 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -98,7 +98,7 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): x, final_cost = parameterisation.run() # Assertions - if parameterisation._minimising: + if parameterisation.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py index 77d123c88..8400c7f29 100644 --- a/tests/integration/test_spm_parameterisations.py +++ b/tests/integration/test_spm_parameterisations.py @@ -119,7 +119,7 @@ def test_spm_optimisers(self, optimiser, spm_costs): # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation._minimising: + if parameterisation.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost @@ -194,7 +194,7 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation._minimising: + if parameterisation.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py index e552fa137..3ef6b53bb 100644 --- a/tests/integration/test_thevenin_parameterisation.py +++ b/tests/integration/test_thevenin_parameterisation.py @@ -89,7 +89,7 @@ def test_optimisers_on_simple_model(self, optimiser, cost): # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation._minimising: + if parameterisation.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost From 2c615000fddde66a9fdbf18a277034a430a20d0a Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 17 May 2024 19:18:39 +0100 Subject: [PATCH 77/85] Improve conditioning of StandaloneCost --- examples/standalone/cost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/standalone/cost.py b/examples/standalone/cost.py index c3763ff4c..1eb497890 100644 --- a/examples/standalone/cost.py +++ b/examples/standalone/cost.py @@ -70,4 +70,4 @@ def _evaluate(self, x, grad=None): The calculated cost value for the given parameter. """ - return x[0] ** 2 + 42 + return 100 * x[0] ** 2 + 42 From 658181e4742c5461b5142040c17789ead6773181 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 20 May 2024 10:41:39 +0100 Subject: [PATCH 78/85] Reset StandaloneCost --- examples/standalone/cost.py | 2 +- tests/unit/test_standalone.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/standalone/cost.py b/examples/standalone/cost.py index 1eb497890..c3763ff4c 100644 --- a/examples/standalone/cost.py +++ b/examples/standalone/cost.py @@ -70,4 +70,4 @@ def _evaluate(self, x, grad=None): The calculated cost value for the given parameter. """ - return 100 * x[0] ** 2 + 42 + return x[0] ** 2 + 42 diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index 099cbae32..8a2c22bfd 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -32,7 +32,7 @@ def test_standalone_optimiser(self): def test_standalone_cost(self): # Build an Optimisation problem with a StandaloneCost cost = StandaloneCost() - optim = pybop.DefaultOptimiser(cost=cost) + optim = pybop.SciPyDifferentialEvolution(cost=cost) x, final_cost = optim.run() np.testing.assert_allclose(x, 0, atol=1e-2) From afaf69ed23d25fe230dba604c174942e5f5d3fa3 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 20 May 2024 17:23:27 +0100 Subject: [PATCH 79/85] Update description --- examples/scripts/spme_max_energy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index 2824a446a..8590f68c6 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -12,8 +12,8 @@ # NOTE: This script can be easily adjusted to consider the volumetric # (instead of gravimetric) energy density by changing the line which # defines the cost and changing the output to: -# print(f"Initial volumetric energy density: {-cost(cost.x0):.2f} Wh.m-3") -# print(f"Optimised volumetric energy density: {-final_cost:.2f} Wh.m-3") +# print(f"Initial volumetric energy density: {cost(cost.x0):.2f} Wh.m-3") +# print(f"Optimised volumetric energy density: {final_cost:.2f} Wh.m-3") # Define parameter set and model parameter_set = pybop.ParameterSet.pybamm("Chen2020") From dec74d7352339e9fab1c7f2d231d925e5b950bfc Mon Sep 17 00:00:00 2001 From: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> Date: Wed, 22 May 2024 16:45:00 +0100 Subject: [PATCH 80/85] Updates to #236 to avoid breaking change to `pybop.Optimisation` (#309) * Splits Optimisation -> BaseOptimiser/Optimisation, enables two optimisation APIs, updts where required, moves _optimisation to optimisers/ * increase coverage * Pass optimiser_kwargs though run() * updt examples * Converts DefaultOptimiser -> Optimisation * split Optimisation and BaseOptimsier classes, loosen standalone cost unit test * add incorrect attr test * fix: updt changelog entry, optimsation_interface notebook, review suggestions * fix: updt notebook state * Updt assertions, optimisation object name --------- Co-authored-by: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> --- CHANGELOG.md | 2 +- examples/notebooks/optimiser_interface.ipynb | 285 ++++++++++++++++++ examples/scripts/spm_pso.py | 2 +- examples/standalone/optimiser.py | 4 +- pybop/__init__.py | 4 +- pybop/{ => optimisers}/_optimisation.py | 2 +- pybop/optimisers/base_optimiser.py | 4 +- pybop/optimisers/optimisation.py | 65 ++++ pybop/optimisers/pints_optimisers.py | 7 - pybop/optimisers/scipy_optimisers.py | 4 +- pybop/plotting/plot2d.py | 2 +- .../integration/test_optimisation_options.py | 18 +- .../integration/test_spm_parameterisations.py | 30 +- .../test_thevenin_parameterisation.py | 12 +- tests/unit/test_optimisation.py | 30 +- tests/unit/test_plots.py | 2 +- tests/unit/test_standalone.py | 7 +- 17 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 examples/notebooks/optimiser_interface.ipynb rename pybop/{ => optimisers}/_optimisation.py (99%) create mode 100644 pybop/optimisers/optimisation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df79cce2..9e26270dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Features -- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes to allow the passing of keyword arguments and fixes the setting of max_iterations and minimising. +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes, adds a new optimisation API through direct construction and keyword arguments, and fixes the setting of `max_iterations`, and `_minimising`. Introduces `pybop.BaseOptimiser`, `pybop.BasePintsOptimiser`, and `pybop.BaseSciPyOptimiser` classes. - [#321](https://github.com/pybop-team/PyBOP/pull/321) - Updates Prior classes with BaseClass, adds a `problem.sample_initial_conditions` method to improve stability of SciPy.Minimize optimiser. - [#249](https://github.com/pybop-team/PyBOP/pull/249) - Add WeppnerHuggins model and GITT example. - [#304](https://github.com/pybop-team/PyBOP/pull/304) - Decreases the testing suite completion time. diff --git a/examples/notebooks/optimiser_interface.ipynb b/examples/notebooks/optimiser_interface.ipynb new file mode 100644 index 000000000..bbd763132 --- /dev/null +++ b/examples/notebooks/optimiser_interface.ipynb @@ -0,0 +1,285 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "00940c64-4748-4b08-9a35-ea98ce311e71", + "metadata": {}, + "source": [ + "# Interacting with PyBOP optimisers\n", + "\n", + "This notebook introduces two interfaces to interact with PyBOP's optimiser classes.\n", + "\n", + "### Set the Environment" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "dd0e1a20-1ba3-4ff5-8f6a-f9c6f25c2a4a", + "metadata": { + "execution": { + "iopub.execute_input": "2024-04-14T18:57:35.622147Z", + "iopub.status.busy": "2024-04-14T18:57:35.621660Z", + "iopub.status.idle": "2024-04-14T18:57:40.849137Z", + "shell.execute_reply": "2024-04-14T18:57:40.848620Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: pip in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (24.0)\n", + "Requirement already satisfied: ipywidgets in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (8.1.2)\n", + "Requirement already satisfied: comm>=0.1.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\n", + "Requirement already satisfied: ipython>=6.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (8.23.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (5.14.2)\n", + "Requirement already satisfied: widgetsnbextension~=4.0.10 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (4.0.10)\n", + "Requirement already satisfied: jupyterlab-widgets~=3.0.10 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (3.0.10)\n", + "Requirement already satisfied: decorator in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\n", + "Requirement already satisfied: jedi>=0.16 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\n", + "Requirement already satisfied: matplotlib-inline in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.6)\n", + "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.43)\n", + "Requirement already satisfied: pygments>=2.4.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.17.2)\n", + "Requirement already satisfied: stack-data in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n", + "Requirement already satisfied: pexpect>4.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n", + "Requirement already satisfied: wcwidth in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\n", + "Requirement already satisfied: executing>=1.2.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\n", + "Requirement already satisfied: pure-eval in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\n", + "Requirement already satisfied: six>=1.12.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\n", + "Note: you may need to restart the kernel to use updated packages.\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install --upgrade pip ipywidgets\n", + "%pip install pybop -q\n", + "\n", + "# Import the necessary libraries\n", + "import numpy as np\n", + "\n", + "import pybop" + ] + }, + { + "cell_type": "markdown", + "id": "017695fd-ee78-4113-af18-2fea04cf6126", + "metadata": {}, + "source": [ + "## Setup the model, problem, and cost\n", + "\n", + "The code block below sets up the model, problem, and cost objects. For more information on this process, take a look at other notebooks in the examples directory." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c346b106-99a9-46bc-8b5d-d330ed911660", + "metadata": { + "execution": { + "iopub.execute_input": "2024-04-14T18:57:46.438835Z", + "iopub.status.busy": "2024-04-14T18:57:46.438684Z", + "iopub.status.idle": "2024-04-14T18:57:46.478613Z", + "shell.execute_reply": "2024-04-14T18:57:46.478339Z" + } + }, + "outputs": [], + "source": [ + "# Load the parameters\n", + "parameter_set = pybop.ParameterSet(\n", + " json_path=\"../scripts/parameters/initial_ecm_parameters.json\"\n", + ")\n", + "parameter_set.import_parameters()\n", + "# Define the model\n", + "model = pybop.empirical.Thevenin(\n", + " parameter_set=parameter_set, options={\"number of rc elements\": 1}\n", + ")\n", + "\n", + "# Define the parameters\n", + "parameters = [\n", + " pybop.Parameter(\n", + " \"R0 [Ohm]\",\n", + " prior=pybop.Gaussian(0.0002, 0.0001),\n", + " bounds=[1e-4, 1e-2],\n", + " )\n", + "]\n", + "\n", + "# Generate synthetic data\n", + "t_eval = np.arange(0, 900, 2)\n", + "values = model.predict(t_eval=t_eval)\n", + "\n", + "# Form dataset\n", + "dataset = pybop.Dataset(\n", + " {\n", + " \"Time [s]\": t_eval,\n", + " \"Current function [A]\": values[\"Current [A]\"].data,\n", + " \"Voltage [V]\": values[\"Voltage [V]\"].data,\n", + " }\n", + ")\n", + "\n", + "# Construct problem and cost\n", + "problem = pybop.FittingProblem(model, parameters, dataset)\n", + "cost = pybop.SumSquaredError(problem)" + ] + }, + { + "cell_type": "markdown", + "id": "3ef5b0da-f755-43c6-8904-79d7ee0f218c", + "metadata": {}, + "source": [ + "## Interacting with the Optimisers\n", + "\n", + "Now that we have setup the required objects, we can introduce the two interfaces fo interacting with PyBOP optimisers. These are:\n", + " \n", + "1. The direct optimiser (i.e. `pybop.XNES`)\n", + "2. The optimisation class (i.e. `pybop.Optimisation`)\n", + " \n", + "These two methods provide two equivalent ways of interacting with PyBOP's optimisers. The first method provides a direct way to select the Optimiser, with the second method being more general method with a default optimiser (`pybop.XNES`) set if you don't provide an optimiser. \n", + "\n", + "First, the direct interface is presented. With this interface the user can select from the [list of optimisers](https://github.com/pybop-team/PyBOP?tab=readme-ov-file#supported-methods) supported in PyBOP and construct them directly. Options can be passed as kwargs, or through get() / set() methods in the case of Pints' based optimisers." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6244882e-11ad-4bfe-a512-f1c687a06a08", + "metadata": { + "execution": { + "iopub.execute_input": "2024-04-14T18:57:46.512725Z", + "iopub.status.busy": "2024-04-14T18:57:46.512597Z", + "iopub.status.idle": "2024-04-14T18:57:49.259154Z", + "shell.execute_reply": "2024-04-14T18:57:49.257712Z" + } + }, + "outputs": [], + "source": [ + "optim_one = pybop.XNES(\n", + " cost, max_iterations=50\n", + ") # Direct optimiser class with options as kwargs\n", + "optim_one.set_max_iterations(\n", + " 50\n", + ") # Alternatively, set() / get() methods for Pints' optimisers\n", + "x1, final_cost = optim_one.run()" + ] + }, + { + "cell_type": "markdown", + "id": "c62e23f7", + "metadata": {}, + "source": [ + "Next, the `Optimisation` interface is less direct than the previous one, but provides a single class to work with across PyBOP workflows. The options are passed the same way as the above method, through kwargs or get() / set() methods." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "479fc846", + "metadata": {}, + "outputs": [], + "source": [ + "optim_two = pybop.Optimisation(\n", + " cost, optimiser=pybop.XNES, max_iterations=50\n", + ") # Optimisation class with options as kwargs\n", + "optim_two.set_max_iterations(\n", + " 50\n", + ") # Alternatively, set() / get() methods for Pints' optimisers\n", + "x2, final_cost = optim_two.run()" + ] + }, + { + "cell_type": "markdown", + "id": "5c6ea9fd", + "metadata": {}, + "source": [ + "We can show the equivalence of these two methods by comparing the optimiser objects:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "de56587e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "isinstance(optim_one, type(optim_two.optimiser))" + ] + }, + { + "cell_type": "markdown", + "id": "9f6634c0", + "metadata": {}, + "source": [ + "For completeness, we can show the optimiser solutions:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "66b74f3e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Estimated parameters x1: [0.00099965]\n", + "Estimated parameters x2: [0.00099985]\n" + ] + } + ], + "source": [ + "print(\"Estimated parameters x1:\", x1)\n", + "print(\"Estimated parameters x2:\", x2)" + ] + }, + { + "cell_type": "markdown", + "id": "94653584", + "metadata": {}, + "source": [ + "## Closing Comments\n", + "\n", + "As both of these API's provide access to the same optimisers, please use either as you prefer. A couple things to note:\n", + "\n", + "- If you are using a SciPy-based optimiser (`pybop.SciPyMinimize`, `pybop.SciPyDifferentialEvolution`), the `set()` / `get()` methods for the optimiser options are not currently supported. These optimisers required options to be passed as kwargs.\n", + "- The optimiser passed to `pybop.Optimisation` must not be a constructed object." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index ddfa72bb0..4b99bd129 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -37,7 +37,7 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) -optim = pybop.PSO(cost, max_iterations=100) +optim = pybop.Optimisation(cost, optimiser=pybop.PSO, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) diff --git a/examples/standalone/optimiser.py b/examples/standalone/optimiser.py index 89414e10b..eb16fe555 100644 --- a/examples/standalone/optimiser.py +++ b/examples/standalone/optimiser.py @@ -1,10 +1,10 @@ import numpy as np from scipy.optimize import minimize -from pybop import Optimisation +from pybop import BaseOptimiser -class StandaloneOptimiser(Optimisation): +class StandaloneOptimiser(BaseOptimiser): """ Defines an example standalone optimiser without a Cost. """ diff --git a/pybop/__init__.py b/pybop/__init__.py index 362e068ca..4e1b772fa 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -95,7 +95,7 @@ # # Optimiser class # -from ._optimisation import Optimisation +from .optimisers._optimisation import BaseOptimiser from .optimisers.base_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, @@ -103,7 +103,6 @@ SciPyDifferentialEvolution ) from .optimisers.pints_optimisers import ( - DefaultOptimiser, GradientDescent, Adam, CMAES, @@ -113,6 +112,7 @@ SNES, XNES, ) +from .optimisers.optimisation import Optimisation # # Parameter classes diff --git a/pybop/_optimisation.py b/pybop/optimisers/_optimisation.py similarity index 99% rename from pybop/_optimisation.py rename to pybop/optimisers/_optimisation.py index 1f57e96c6..713df4d49 100644 --- a/pybop/_optimisation.py +++ b/pybop/optimisers/_optimisation.py @@ -5,7 +5,7 @@ from pybop import BaseCost, BaseLikelihood, DesignCost -class Optimisation: +class BaseOptimiser: """ A base class for defining optimisation methods. diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 0c3b77995..17d932722 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -1,10 +1,10 @@ import numpy as np import pints -from pybop import Optimisation +from pybop import BaseOptimiser -class BasePintsOptimiser(Optimisation): +class BasePintsOptimiser(BaseOptimiser): """ A base class for defining optimisation methods from the PINTS library. diff --git a/pybop/optimisers/optimisation.py b/pybop/optimisers/optimisation.py new file mode 100644 index 000000000..aaa0ab3bf --- /dev/null +++ b/pybop/optimisers/optimisation.py @@ -0,0 +1,65 @@ +from pybop import XNES, BasePintsOptimiser, BaseSciPyOptimiser + + +class Optimisation: + """ + A high-level class for optimisation using PyBOP or PINTS optimisers. + + This class provides an alternative API to the `PyBOP.Optimiser()` API, + specifically allowing for single user-friendly interface for the + optimisation process.The class can be used with either PyBOP or PINTS + optimisers. + + Parameters + ---------- + cost : pybop.BaseCost or pints.ErrorMeasure + An objective function to be optimized, which can be either a pybop.Cost + optimiser : pybop.Optimiser or subclass of pybop.BaseOptimiser, optional + An optimiser from either the PINTS or PyBOP framework to perform the optimization (default: None). + sigma0 : float or sequence, optional + Initial step size or standard deviation for the optimiser (default: None). + verbose : bool, optional + If True, the optimization progress is printed (default: False). + 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). + + Attributes + ---------- + All attributes from the pybop.optimiser() class + + """ + + def __init__(self, cost, optimiser=None, **optimiser_kwargs): + self.__dict__["optimiser"] = ( + None # Pre-define optimiser to avoid recursion during initialisation + ) + if optimiser is None: + self.optimiser = XNES(cost, **optimiser_kwargs) + elif issubclass(optimiser, BasePintsOptimiser): + self.optimiser = optimiser(cost, **optimiser_kwargs) + elif issubclass(optimiser, BaseSciPyOptimiser): + self.optimiser = optimiser(cost, **optimiser_kwargs) + else: + raise ValueError("Unknown optimiser type") + + def run(self): + return self.optimiser.run() + + def __getattr__(self, attr): + if "optimiser" in self.__dict__ and hasattr(self.optimiser, attr): + return getattr(self.optimiser, attr) + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{attr}'" + ) + + def __setattr__(self, name: str, value) -> None: + if ( + name in self.__dict__ + or "optimiser" not in self.__dict__ + or not hasattr(self.optimiser, name) + ): + object.__setattr__(self, name, value) + else: + setattr(self.optimiser, name, value) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index da0390d57..e3d8ee31b 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -233,10 +233,3 @@ def __init__(self, cost, **optimiser_kwargs): + "Please choose another optimiser." ) super().__init__(cost, pints.CMAES, **optimiser_kwargs) - - -class DefaultOptimiser(XNES): - """ - Provides a default option for new users, selected to be the Exponential Natural - Evolution Strategy (XNES) optimiser from PINTS. - """ diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 16a8e898a..6c2498093 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -1,10 +1,10 @@ import numpy as np from scipy.optimize import differential_evolution, minimize -from pybop import Optimisation +from pybop import BaseOptimiser -class BaseSciPyOptimiser(Optimisation): +class BaseSciPyOptimiser(BaseOptimiser): """ A base class for defining optimisation methods from the SciPy library. diff --git a/pybop/plotting/plot2d.py b/pybop/plotting/plot2d.py index 0a6c2ab02..957279613 100644 --- a/pybop/plotting/plot2d.py +++ b/pybop/plotting/plot2d.py @@ -45,7 +45,7 @@ def plot2d( """ # Assign input as a cost or optimisation object - if isinstance(cost_or_optim, pybop.Optimisation): + if isinstance(cost_or_optim, (pybop.BaseOptimiser, pybop.Optimisation)): optim = cost_or_optim plot_optim = True cost = optim.cost diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 92020af20..1505a37dd 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -80,8 +80,9 @@ def spm_costs(self, model, parameters, cost_class): ) @pytest.mark.integration def test_optimisation_f_guessed(self, f_guessed, spm_costs): + x0 = spm_costs.x0 # Test each optimiser - parameterisation = pybop.XNES( + optim = pybop.XNES( cost=spm_costs, sigma0=0.05, max_iterations=125, @@ -92,16 +93,17 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): # Set parallelisation if not on Windows if sys.platform != "win32": - parameterisation.set_parallel(True) + optim.set_parallel(True) - initial_cost = parameterisation.cost(spm_costs.x0) - x, final_cost = parameterisation.run() + initial_cost = optim.cost(x0) + x, final_cost = optim.run() # Assertions - if parameterisation.minimising: - assert initial_cost > final_cost - else: - assert initial_cost < final_cost + if not np.allclose(x0, self.ground_truth, atol=1e-5): + if optim.minimising: + assert initial_cost > final_cost + else: + assert initial_cost < final_cost np.testing.assert_allclose(x, self.ground_truth, atol=2.5e-2) def getdata(self, model, x, init_soc): diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py index 8400c7f29..eada8d9b9 100644 --- a/tests/integration/test_spm_parameterisations.py +++ b/tests/integration/test_spm_parameterisations.py @@ -110,16 +110,21 @@ def test_spm_optimisers(self, optimiser, spm_costs): spm_costs.bounds = bounds # Test each optimiser - parameterisation = optimiser(cost=spm_costs, sigma0=0.05, max_iterations=125) + if optimiser in [pybop.PSO]: + optim = pybop.Optimisation( + cost=spm_costs, optimiser=optimiser, sigma0=0.05, max_iterations=125 + ) + else: + optim = optimiser(cost=spm_costs, sigma0=0.05, max_iterations=125) if issubclass(optimiser, pybop.BasePintsOptimiser): - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=1e-5) + optim.set_max_unchanged_iterations(iterations=35, threshold=1e-5) - initial_cost = parameterisation.cost(x0) - x, final_cost = parameterisation.run() + initial_cost = optim.cost(x0) + x, final_cost = optim.run() # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation.minimising: + if optim.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost @@ -183,18 +188,18 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): spm_two_signal_cost.bounds = bounds # Test each optimiser - parameterisation = multi_optimiser( + optim = multi_optimiser( cost=spm_two_signal_cost, sigma0=0.03, max_iterations=125 ) if issubclass(multi_optimiser, pybop.BasePintsOptimiser): - parameterisation.set_max_unchanged_iterations(iterations=35, threshold=5e-4) + optim.set_max_unchanged_iterations(iterations=35, threshold=5e-4) - initial_cost = parameterisation.cost(spm_two_signal_cost.x0) - x, final_cost = parameterisation.run() + initial_cost = optim.cost(spm_two_signal_cost.x0) + x, final_cost = optim.run() # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation.minimising: + if optim.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost @@ -231,9 +236,12 @@ def test_model_misparameterisation(self, parameters, model, init_soc): # Run the optimisation problem x, final_cost = parameterisation.run() - # Assertions + # Assertion for final_cost with np.testing.assert_raises(AssertionError): np.testing.assert_allclose(final_cost, 0, atol=1e-2) + + # Assertion for x + with np.testing.assert_raises(AssertionError): np.testing.assert_allclose(x, self.ground_truth, atol=2e-2) def getdata(self, model, x, init_soc): diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py index 3ef6b53bb..5ac6e84ef 100644 --- a/tests/integration/test_thevenin_parameterisation.py +++ b/tests/integration/test_thevenin_parameterisation.py @@ -70,26 +70,26 @@ def cost(self, model, parameters, cost_class): def test_optimisers_on_simple_model(self, optimiser, cost): x0 = cost.x0 if optimiser in [pybop.GradientDescent]: - parameterisation = optimiser( + optim = optimiser( cost=cost, sigma0=2.5e-4, max_iterations=250, ) else: - parameterisation = optimiser( + optim = optimiser( cost=cost, sigma0=0.03, max_iterations=250, ) if isinstance(optimiser, pybop.BasePintsOptimiser): - parameterisation.set_max_unchanged_iterations(iterations=55, threshold=1e-5) + optim.set_max_unchanged_iterations(iterations=55, threshold=1e-5) - initial_cost = parameterisation.cost(x0) - x, final_cost = parameterisation.run() + initial_cost = optim.cost(x0) + x, final_cost = optim.run() # Assertions if not np.allclose(x0, self.ground_truth, atol=1e-5): - if parameterisation.minimising: + if optim.minimising: assert initial_cost > final_cost else: assert initial_cost < final_cost diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index efdb0db6a..2bfce2ba2 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -90,6 +90,12 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): assert optim.cost is not None assert optim.name() == expected_name + # Test pybop.Optimisation construction + optim = pybop.Optimisation(cost=cost, optimiser=optimiser) + + assert optim.cost is not None + assert optim.name() == expected_name + if optimiser not in [pybop.SciPyDifferentialEvolution]: # Test construction without bounds optim = optimiser(cost=cost, bounds=None) @@ -235,7 +241,7 @@ def test_invalid_cost(self): Exception, match="The cost is not a recognised cost object or function.", ): - pybop.DefaultOptimiser(cost="Invalid string") + pybop.Optimisation(cost="Invalid string") def invalid_cost(x): return [1, 2] @@ -244,13 +250,20 @@ def invalid_cost(x): Exception, match="not a scalar numeric value.", ): - pybop.DefaultOptimiser(cost=invalid_cost) + pybop.Optimisation(cost=invalid_cost) @pytest.mark.unit def test_default_optimiser(self, cost): - optim = pybop.DefaultOptimiser(cost=cost) + optim = pybop.Optimisation(cost=cost) assert optim.name() == "Exponential Natural Evolution Strategy (xNES)" + # Test incorrect setting attribute + with pytest.raises( + AttributeError, + match="'Optimisation' object has no attribute 'not_a_valid_attribute'", + ): + optim.not_a_valid_attribute + @pytest.mark.unit def test_incorrect_optimiser_class(self, cost): class RandomClass: @@ -263,13 +276,16 @@ class RandomClass: pybop.BasePintsOptimiser(cost=cost, pints_optimiser=RandomClass) with pytest.raises(NotImplementedError): - pybop.Optimisation(cost=cost) + pybop.BaseOptimiser(cost=cost) + + with pytest.raises(ValueError): + pybop.Optimisation(cost=cost, optimiser=RandomClass) @pytest.mark.unit def test_prior_sampling(self, cost): # Tests prior sampling for i in range(50): - optim = pybop.DefaultOptimiser(cost=cost) + optim = pybop.Optimisation(cost=cost) assert optim.x0 <= 0.62 and optim.x0 >= 0.58 @@ -342,7 +358,7 @@ def test_halting(self, cost): with pytest.raises(ValueError): optim.set_max_unchanged_iterations(1, threshold=-1) - optim = pybop.DefaultOptimiser(cost=cost) + optim = pybop.Optimisation(cost=cost) # Trigger threshold optim._threshold = np.inf @@ -387,5 +403,5 @@ def test_infeasible_solutions(self, cost): @pytest.mark.unit def test_unphysical_result(self, cost): # Trigger parameters not physically viable warning - optim = pybop.DefaultOptimiser(cost=cost) + optim = pybop.Optimisation(cost=cost) optim.check_optimal_parameters(np.array([2])) diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index b0dd9ad36..f5998c0af 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -108,7 +108,7 @@ def test_cost_plots(self, cost): @pytest.fixture def optim(self, cost): # Define and run an example optimisation - optim = pybop.DefaultOptimiser(cost) + optim = pybop.Optimisation(cost) optim.run() return optim diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index 8a2c22bfd..d524555a5 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -29,14 +29,15 @@ def test_standalone_optimiser(self): np.testing.assert_allclose(x, [2, 4], atol=1e-2) @pytest.mark.unit - def test_standalone_cost(self): + def test_optimisation_on_standalone_cost(self): # Build an Optimisation problem with a StandaloneCost cost = StandaloneCost() optim = pybop.SciPyDifferentialEvolution(cost=cost) x, final_cost = optim.run() - np.testing.assert_allclose(x, 0, atol=1e-2) - np.testing.assert_allclose(final_cost, 42, atol=1e-2) + initial_cost = optim.cost(cost.x0) + assert initial_cost > final_cost + np.testing.assert_allclose(final_cost, 42, atol=1e-1) @pytest.mark.unit def test_standalone_problem(self): From f1fc9138170a1182b0264f4fc2c56f8e01dfbb7d Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 22 May 2024 18:34:03 +0100 Subject: [PATCH 81/85] Rename method back to optimiser --- .../notebooks/optimiser_calibration.ipynb | 2 +- pybop/optimisers/base_optimiser.py | 30 +++++++++---------- tests/unit/test_optimisation.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 4b5beee9e..5d1870834 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -488,7 +488,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.method.f_best()} | Results: {optim.method.x_best()} |\"\n", + " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.optimiser.f_best()} | Results: {optim.optimiser.x_best()} |\"\n", " )" ] }, diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 17d932722..c119006c5 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -53,7 +53,7 @@ def _set_up_optimiser(self): # Create an instance of the PINTS optimiser class if issubclass(self.pints_optimiser, pints.Optimiser): - self.method = self.pints_optimiser( + self.optimiser = self.pints_optimiser( self.x0, sigma0=self.sigma0, boundaries=self._boundaries ) else: @@ -62,7 +62,7 @@ def _set_up_optimiser(self): ) # Check if sensitivities are required - self._needs_sensitivities = self.method.needs_sensitivities() + self._needs_sensitivities = self.optimiser.needs_sensitivities() # Apply default maxiter self.set_max_iterations() @@ -154,7 +154,7 @@ def name(self): str The name given by PINTS. """ - return self.method.name() + return self.optimiser.name() def _run(self): """ @@ -205,8 +205,8 @@ def f(x, grad=None): # For population based optimisers, don't use more workers than # particles! - if isinstance(self.method, pints.PopulationBasedOptimiser): - n_workers = min(n_workers, self.method.population_size()) + if isinstance(self.optimiser, pints.PopulationBasedOptimiser): + n_workers = min(n_workers, self.optimiser.population_size()) evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) else: evaluator = pints.SequentialEvaluator(f) @@ -225,17 +225,17 @@ def f(x, grad=None): try: while running: # Ask optimiser for new points - xs = self.method.ask() + xs = self.optimiser.ask() # Evaluate points fs = evaluator.evaluate(xs) # Tell optimiser about function values - self.method.tell(fs) + self.optimiser.tell(fs) # Update the scores - fb = self.method.f_best() - fg = self.method.f_guessed() + fb = self.optimiser.f_best() + fg = self.optimiser.f_guessed() fg_user = (fb, fg) if self.minimising else (-fb, -fg) # Check for significant changes @@ -299,7 +299,7 @@ def f(x, grad=None): ) # Error in optimiser - error = self.method.stop() + error = self.optimiser.stop() if error: running = False halt_message = str(error) @@ -315,7 +315,7 @@ def f(x, grad=None): print("Current position:") # Show current parameters - x_user = self.method.x_guessed() + x_user = self.optimiser.x_guessed() if self._transformation is not None: x_user = self._transformation.to_model(x_user) for p in x_user: @@ -332,11 +332,11 @@ def f(x, grad=None): # Get best parameters if self._use_f_guessed: - x = self.method.x_guessed() - f = self.method.f_guessed() + x = self.optimiser.x_guessed() + f = self.optimiser.f_guessed() else: - x = self.method.x_best() - f = self.method.f_best() + x = self.optimiser.x_best() + f = self.optimiser.f_best() # Inverse transform search parameters if self._transformation is not None: diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 2bfce2ba2..3b19b00b2 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -378,7 +378,7 @@ def callback_error(iteration, s): def optimiser_error(): return "Optimiser error message" - optim.method.stop = optimiser_error + optim.optimiser.stop = optimiser_error optim.run() assert optim._iterations == 1 From 2f3690b2cc170ca4cb85efa554a93ec69b0077ba Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 22 May 2024 19:04:45 +0100 Subject: [PATCH 82/85] Update comments --- examples/notebooks/optimiser_interface.ipynb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/notebooks/optimiser_interface.ipynb b/examples/notebooks/optimiser_interface.ipynb index bbd763132..1200e3996 100644 --- a/examples/notebooks/optimiser_interface.ipynb +++ b/examples/notebooks/optimiser_interface.ipynb @@ -133,14 +133,14 @@ "source": [ "## Interacting with the Optimisers\n", "\n", - "Now that we have setup the required objects, we can introduce the two interfaces fo interacting with PyBOP optimisers. These are:\n", + "Now that we have set up the required objects, we can introduce the two interfaces for interacting with PyBOP optimisers. These are:\n", " \n", - "1. The direct optimiser (i.e. `pybop.XNES`)\n", + "1. The direct optimiser (e.g. `pybop.XNES`)\n", "2. The optimisation class (i.e. `pybop.Optimisation`)\n", " \n", - "These two methods provide two equivalent ways of interacting with PyBOP's optimisers. The first method provides a direct way to select the Optimiser, with the second method being more general method with a default optimiser (`pybop.XNES`) set if you don't provide an optimiser. \n", + "These two methods provide two equivalent ways of interacting with PyBOP's optimisers. The first method provides a direct way to select the Optimiser, with the second method being a more general method with a default optimiser (`pybop.XNES`) set if you don't provide an optimiser. \n", "\n", - "First, the direct interface is presented. With this interface the user can select from the [list of optimisers](https://github.com/pybop-team/PyBOP?tab=readme-ov-file#supported-methods) supported in PyBOP and construct them directly. Options can be passed as kwargs, or through get() / set() methods in the case of Pints' based optimisers." + "First, the direct interface is presented. With this interface the user can select from the [list of optimisers](https://github.com/pybop-team/PyBOP?tab=readme-ov-file#supported-methods) supported in PyBOP and construct them directly. Options can be passed as kwargs, or through get() / set() methods in the case of PINTS-based optimisers." ] }, { @@ -162,7 +162,7 @@ ") # Direct optimiser class with options as kwargs\n", "optim_one.set_max_iterations(\n", " 50\n", - ") # Alternatively, set() / get() methods for Pints' optimisers\n", + ") # Alternative set() / get() methods for PINTS optimisers\n", "x1, final_cost = optim_one.run()" ] }, @@ -186,7 +186,7 @@ ") # Optimisation class with options as kwargs\n", "optim_two.set_max_iterations(\n", " 50\n", - ") # Alternatively, set() / get() methods for Pints' optimisers\n", + ") # Alternative set() / get() methods for PINTS optimisers\n", "x2, final_cost = optim_two.run()" ] }, @@ -256,7 +256,7 @@ "\n", "As both of these API's provide access to the same optimisers, please use either as you prefer. A couple things to note:\n", "\n", - "- If you are using a SciPy-based optimiser (`pybop.SciPyMinimize`, `pybop.SciPyDifferentialEvolution`), the `set()` / `get()` methods for the optimiser options are not currently supported. These optimisers required options to be passed as kwargs.\n", + "- If you are using a SciPy-based optimiser (`pybop.SciPyMinimize`, `pybop.SciPyDifferentialEvolution`), the `set()` / `get()` methods for the optimiser options are not currently supported. These optimisers require options to be passed as kwargs.\n", "- The optimiser passed to `pybop.Optimisation` must not be a constructed object." ] } From 55abec86a28680caa451b9b82de1efc6890d6377 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 22 May 2024 20:44:42 +0100 Subject: [PATCH 83/85] Change optimiser to pints_optimiser --- .../notebooks/optimiser_calibration.ipynb | 2 +- pybop/optimisers/base_optimiser.py | 30 +++++++++---------- tests/unit/test_optimisation.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 5d1870834..f94ecec65 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -488,7 +488,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.optimiser.f_best()} | Results: {optim.optimiser.x_best()} |\"\n", + " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.pints_optimiser.f_best()} | Results: {optim.pints_optimiser.x_best()} |\"\n", " )" ] }, diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index c119006c5..543d32b64 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -53,7 +53,7 @@ def _set_up_optimiser(self): # Create an instance of the PINTS optimiser class if issubclass(self.pints_optimiser, pints.Optimiser): - self.optimiser = self.pints_optimiser( + self.pints_optimiser = self.pints_optimiser( self.x0, sigma0=self.sigma0, boundaries=self._boundaries ) else: @@ -62,7 +62,7 @@ def _set_up_optimiser(self): ) # Check if sensitivities are required - self._needs_sensitivities = self.optimiser.needs_sensitivities() + self._needs_sensitivities = self.pints_optimiser.needs_sensitivities() # Apply default maxiter self.set_max_iterations() @@ -154,7 +154,7 @@ def name(self): str The name given by PINTS. """ - return self.optimiser.name() + return self.pints_optimiser.name() def _run(self): """ @@ -205,8 +205,8 @@ def f(x, grad=None): # For population based optimisers, don't use more workers than # particles! - if isinstance(self.optimiser, pints.PopulationBasedOptimiser): - n_workers = min(n_workers, self.optimiser.population_size()) + if isinstance(self.pints_optimiser, pints.PopulationBasedOptimiser): + n_workers = min(n_workers, self.pints_optimiser.population_size()) evaluator = pints.ParallelEvaluator(f, n_workers=n_workers) else: evaluator = pints.SequentialEvaluator(f) @@ -225,17 +225,17 @@ def f(x, grad=None): try: while running: # Ask optimiser for new points - xs = self.optimiser.ask() + xs = self.pints_optimiser.ask() # Evaluate points fs = evaluator.evaluate(xs) # Tell optimiser about function values - self.optimiser.tell(fs) + self.pints_optimiser.tell(fs) # Update the scores - fb = self.optimiser.f_best() - fg = self.optimiser.f_guessed() + fb = self.pints_optimiser.f_best() + fg = self.pints_optimiser.f_guessed() fg_user = (fb, fg) if self.minimising else (-fb, -fg) # Check for significant changes @@ -299,7 +299,7 @@ def f(x, grad=None): ) # Error in optimiser - error = self.optimiser.stop() + error = self.pints_optimiser.stop() if error: running = False halt_message = str(error) @@ -315,7 +315,7 @@ def f(x, grad=None): print("Current position:") # Show current parameters - x_user = self.optimiser.x_guessed() + x_user = self.pints_optimiser.x_guessed() if self._transformation is not None: x_user = self._transformation.to_model(x_user) for p in x_user: @@ -332,11 +332,11 @@ def f(x, grad=None): # Get best parameters if self._use_f_guessed: - x = self.optimiser.x_guessed() - f = self.optimiser.f_guessed() + x = self.pints_optimiser.x_guessed() + f = self.pints_optimiser.f_guessed() else: - x = self.optimiser.x_best() - f = self.optimiser.f_best() + x = self.pints_optimiser.x_best() + f = self.pints_optimiser.f_best() # Inverse transform search parameters if self._transformation is not None: diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 3b19b00b2..949fe7ef1 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -378,7 +378,7 @@ def callback_error(iteration, s): def optimiser_error(): return "Optimiser error message" - optim.optimiser.stop = optimiser_error + optim.pints_optimiser.stop = optimiser_error optim.run() assert optim._iterations == 1 From 8f734698bc348a82bdcdb0bfb923481ac7f34920 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 22 May 2024 20:52:56 +0100 Subject: [PATCH 84/85] Rename base_optimiser to pints_base_optimiser --- pybop/__init__.py | 2 +- pybop/optimisers/{base_optimiser.py => base_pints_optimiser.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename pybop/optimisers/{base_optimiser.py => base_pints_optimiser.py} (100%) diff --git a/pybop/__init__.py b/pybop/__init__.py index 4e1b772fa..4043e99ff 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -96,7 +96,7 @@ # Optimiser class # from .optimisers._optimisation import BaseOptimiser -from .optimisers.base_optimiser import BasePintsOptimiser +from .optimisers.base_pints_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, SciPyMinimize, diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_pints_optimiser.py similarity index 100% rename from pybop/optimisers/base_optimiser.py rename to pybop/optimisers/base_pints_optimiser.py From 6b37c3f7f3b595b954b367c10ecb80691c9bee8e Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 22 May 2024 20:53:37 +0100 Subject: [PATCH 85/85] Rename _optimisation to base_optimiser --- pybop/__init__.py | 2 +- pybop/optimisers/{_optimisation.py => base_optimiser.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename pybop/optimisers/{_optimisation.py => base_optimiser.py} (100%) diff --git a/pybop/__init__.py b/pybop/__init__.py index 4043e99ff..ecd420198 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -95,7 +95,7 @@ # # Optimiser class # -from .optimisers._optimisation import BaseOptimiser +from .optimisers.base_optimiser import BaseOptimiser from .optimisers.base_pints_optimiser import BasePintsOptimiser from .optimisers.scipy_optimisers import ( BaseSciPyOptimiser, diff --git a/pybop/optimisers/_optimisation.py b/pybop/optimisers/base_optimiser.py similarity index 100% rename from pybop/optimisers/_optimisation.py rename to pybop/optimisers/base_optimiser.py