From fec8efad5760f5e42b1e5940a938ea3a62dac674 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 6 Dec 2023 13:19:05 +0000 Subject: [PATCH 1/5] Add SciPyDifferentialEvolution cls, updt tests w/ reduced assertions for SciPyMinimize --- pybop/__init__.py | 2 +- pybop/optimisation.py | 4 +- pybop/optimisers/scipy_minimize.py | 55 ------------ pybop/optimisers/scipy_optimisers.py | 124 +++++++++++++++++++++++++++ tests/unit/test_optimisation.py | 7 +- tests/unit/test_parameterisations.py | 10 ++- 6 files changed, 142 insertions(+), 60 deletions(-) delete mode 100644 pybop/optimisers/scipy_minimize.py create mode 100644 pybop/optimisers/scipy_optimisers.py diff --git a/pybop/__init__.py b/pybop/__init__.py index 48a24b2df..ff2b8af57 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -49,7 +49,7 @@ # from .optimisers.base_optimiser import BaseOptimiser from .optimisers.nlopt_optimize import NLoptOptimize -from .optimisers.scipy_minimize import SciPyMinimize +from .optimisers.scipy_optimisers import SciPyMinimize, SciPyDifferentialEvolution from .optimisers.pints_optimisers import ( GradientDescent, Adam, diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6dc947de7..019c49177 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -58,7 +58,9 @@ def __init__( if issubclass(self.optimiser, pybop.NLoptOptimize): self.optimiser = self.optimiser(self.n_parameters) - elif issubclass(self.optimiser, pybop.SciPyMinimize): + elif issubclass( + self.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution) + ): self.optimiser = self.optimiser() else: diff --git a/pybop/optimisers/scipy_minimize.py b/pybop/optimisers/scipy_minimize.py deleted file mode 100644 index a1f57fe65..000000000 --- a/pybop/optimisers/scipy_minimize.py +++ /dev/null @@ -1,55 +0,0 @@ -from scipy.optimize import minimize -from .base_optimiser import BaseOptimiser - - -class SciPyMinimize(BaseOptimiser): - """ - Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. - """ - - def __init__(self, method=None, bounds=None): - super().__init__() - self.method = method - self.bounds = bounds - - if self.method is None: - self.method = "L-BFGS-B" - - def _runoptimise(self, cost_function, x0, bounds): - """ - Run the SciPy optimisation method. - - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array - """ - - if bounds is not None: - # Reformat bounds and run the optimser - bounds = ( - (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) - ) - output = minimize(cost_function, x0, method=self.method, bounds=bounds) - else: - output = minimize(cost_function, x0, method=self.method) - - # Get performance statistics - x = output.x - final_cost = output.fun - - return x, final_cost - - def needs_sensitivities(self): - """ - Returns True if the optimiser needs sensitivities. - """ - return False - - def name(self): - """ - Returns the name of the optimiser. - """ - return "SciPyMinimize" diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py new file mode 100644 index 000000000..0e91326dd --- /dev/null +++ b/pybop/optimisers/scipy_optimisers.py @@ -0,0 +1,124 @@ +from scipy.optimize import minimize, differential_evolution +from .base_optimiser import BaseOptimiser + + +class SciPyMinimize(BaseOptimiser): + """ + Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + """ + + def __init__(self, method=None, bounds=None): + super().__init__() + self.method = method + self.bounds = bounds + + if self.method is None: + self.method = "COBYLA" # "L-BFGS-B" + + def _runoptimise(self, cost_function, x0, bounds): + """ + Run the SciPy optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + """ + + if bounds is not None: + # Reformat bounds and run the optimser + bounds = ( + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + ) + output = minimize(cost_function, x0, method=self.method, bounds=bounds) + else: + output = minimize(cost_function, x0, method=self.method) + + # Get performance statistics + x = output.x + final_cost = output.fun + + return x, final_cost + + def needs_sensitivities(self): + """ + Returns True if the optimiser needs sensitivities. + """ + return False + + def name(self): + """ + Returns the name of the optimiser. + """ + return "SciPyMinimize" + + +class SciPyDifferentialEvolution(BaseOptimiser): + """ + Wrapper class for the SciPy differential_evolution optimisation method. Extends the BaseOptimiser class. + """ + + def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): + super().__init__() + self.bounds = bounds + self.strategy = strategy + self.maxiter = maxiter + self.popsize = popsize + + def _runoptimise(self, cost_function, x0=None, bounds=None): + """ + Run the SciPy differential_evolution optimisation method. + + Inputs + ---------- + cost_function : function + The objective function to be minimized. + x0 : array_like + Initial guess. Only used to determine the dimensionality of the problem. + bounds : sequence or `Bounds` + Bounds for variables. There are two ways to specify the bounds: + 1. Instance of `Bounds` class. + 2. Sequence of (min, max) pairs for each element in x, defining the finite lower and upper bounds for the optimizing argument of `cost_function`. + """ + + if bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") + + if x0 is not None: + print( + "Ignoring x0. Initial conditions are not used for differential_evolution." + ) + + # Reformat bounds if necessary + if isinstance(bounds, dict): + bounds = [ + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + ] + + output = differential_evolution( + cost_function, + bounds, + strategy=self.strategy, + maxiter=self.maxiter, + popsize=self.popsize, + ) + + # Get performance statistics + x = output.x + final_cost = output.fun + + return x, final_cost + + def needs_sensitivities(self): + """ + Returns False as differential_evolution does not need sensitivities. + """ + return False + + def name(self): + """ + Returns the name of the optimiser. + """ + return "SciPyDifferentialEvolution" diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 22822753d..dc96367cf 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -46,6 +46,7 @@ def cost(self, problem): [ (pybop.NLoptOptimize, "NLoptOptimize"), (pybop.SciPyMinimize, "SciPyMinimize"), + (pybop.SciPyDifferentialEvolution, "SciPyDifferentialEvolution"), (pybop.GradientDescent, "Gradient descent"), (pybop.Adam, "Adam"), (pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"), @@ -63,7 +64,11 @@ def test_optimiser_classes(self, cost, optimiser_class, expected_name): assert opt.optimiser is not None assert opt.optimiser.name() == expected_name - if optimiser_class not in [pybop.NLoptOptimize, pybop.SciPyMinimize]: + if optimiser_class not in [ + pybop.NLoptOptimize, + pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, + ]: assert opt.optimiser.boundaries is None if optimiser_class == pybop.NLoptOptimize: diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index 398f137a3..ac718ea39 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -106,6 +106,7 @@ def test_spm_optimisers(self, init_soc): optimisers = [ pybop.NLoptOptimize, pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, pybop.CMAES, pybop.Adam, pybop.GradientDescent, @@ -150,8 +151,13 @@ def test_spm_optimisers(self, init_soc): x, final_cost = parameterisation.run() # Assertions - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=1e-1) + # Note: SciPyMinimize has a different tolerance due to the local optimisation algorithms + if optimiser in [pybop.SciPyMinimize]: + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=2e-1) + else: + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit From 3cdcb22089eac9934d849127e7137f615ae93a17 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 6 Dec 2023 13:25:04 +0000 Subject: [PATCH 2/5] Updt. changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0eaaa05f..27c01b6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) ## Features +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods @@ -9,6 +10,7 @@ - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations ## Bug Fixes +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Increases the SciPyMinimize optimiser assertion tolerances reduce CI/CD failures # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - Initial release From 4947adced161ccd9141af8388cc5c9557fb78764 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:12:11 +0000 Subject: [PATCH 3/5] Add logging --- pybop/optimisers/scipy_optimisers.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 0e91326dd..c5fe0d8e2 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -27,14 +27,21 @@ def _runoptimise(self, cost_function, x0, bounds): bounds: bounds array """ + # Add callback storing history of parameter values + self.log = [[x0]] + + def callback(x): + self.log.append([x]) + + # Reformat bounds if bounds is not None: - # Reformat bounds and run the optimser bounds = ( (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ) - output = minimize(cost_function, x0, method=self.method, bounds=bounds) - else: - output = minimize(cost_function, x0, method=self.method) + + output = minimize( + cost_function, x0, method=self.method, bounds=bounds, callback=callback + ) # Get performance statistics x = output.x @@ -91,6 +98,12 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): "Ignoring x0. Initial conditions are not used for differential_evolution." ) + # Add callback storing history of parameter values + self.log = [] + + def callback(x, convergence): + self.log.append([x]) + # Reformat bounds if necessary if isinstance(bounds, dict): bounds = [ @@ -103,6 +116,7 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): strategy=self.strategy, maxiter=self.maxiter, popsize=self.popsize, + callback=callback, ) # Get performance statistics From 097bc514d7d2da179b969a4302a74c48d992f32a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 16:59:58 +0000 Subject: [PATCH 4/5] Adds maximum iterations functionality to scipyminimize, nloptoptimize, baseoptimise, restore test_spm_optimisers assertions --- CHANGELOG.md | 2 +- pybop/optimisation.py | 1 + pybop/optimisers/base_optimiser.py | 3 ++- pybop/optimisers/nlopt_optimize.py | 7 ++++++- pybop/optimisers/scipy_optimisers.py | 14 ++++++++++++-- tests/unit/test_parameterisations.py | 9 ++------- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e6e17c5..13535bd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Features -- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser, adds functionality for user-selectable maximum iteration limit to `SciPyMinimize`, `NLoptOptimize`, and `BaseOptimiser` classes. - [#107](https://github.com/pybop-team/PyBOP/issues/107) - Adds Equivalent Circuit Model (ECM) with examples, Import/Export parameter methods `ParameterSet.import_parameter` and `ParameterSet.export_parameters`, updates default FittingProblem.signal definition to `"Voltage [V]"`, and testing infrastructure - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6ff7141d7..5da349a0b 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -135,6 +135,7 @@ def _run_pybop(self): cost_function=self.cost, x0=self.x0, bounds=self.bounds, + maxiter=self._max_iterations, ) self.log = self.optimiser.log diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 9d9a8b2c1..b0b13385d 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -8,7 +8,7 @@ class BaseOptimiser: def __init__(self): pass - def optimise(self, cost_function, x0=None, bounds=None): + def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): """ Optimisiation method to be overloaded by child classes. @@ -16,6 +16,7 @@ def optimise(self, cost_function, x0=None, bounds=None): self.cost_function = cost_function self.x0 = x0 self.bounds = bounds + self.maxiter = maxiter # Run optimisation result = self._runoptimise(self.cost_function, x0=self.x0, bounds=self.bounds) diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index b10e4f5db..7d78a699f 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -8,9 +8,10 @@ class NLoptOptimize(BaseOptimiser): Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. """ - def __init__(self, n_param, xtol=None, method=None): + def __init__(self, n_param, xtol=None, method=None, maxiter=None): super().__init__() self.n_param = n_param + self.maxiter = maxiter if method is not None: self.optim = nlopt.opt(method, self.n_param) @@ -46,6 +47,10 @@ def cost_wrapper(x, grad): self.optim.set_lower_bounds(bounds["lower"]) self.optim.set_upper_bounds(bounds["upper"]) + # Set max iterations + if self.maxiter is not None: + self.optim.set_maxeval(self.maxiter) + # Run the optimser x = self.optim.optimize(x0) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index c5fe0d8e2..59f9e6388 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -7,10 +7,15 @@ class SciPyMinimize(BaseOptimiser): Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. """ - def __init__(self, method=None, bounds=None): + def __init__(self, method=None, bounds=None, maxiter=None): super().__init__() self.method = method self.bounds = bounds + self.maxiter = maxiter + if self.maxiter is not None: + self.options = {"maxiter": self.maxiter} + else: + self.options = {} if self.method is None: self.method = "COBYLA" # "L-BFGS-B" @@ -40,7 +45,12 @@ def callback(x): ) output = minimize( - cost_function, x0, method=self.method, bounds=bounds, callback=callback + cost_function, + x0, + method=self.method, + bounds=bounds, + options=self.options, + callback=callback, ) # Get performance statistics diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index af8e7fbdf..af1a4d573 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -138,13 +138,8 @@ def test_spm_optimisers(self, spm_cost, x0): x, final_cost = parameterisation.run() # Assertions - # Note: SciPyMinimize has a different tolerance due to the local optimisation algorithms - if optimiser in [pybop.SciPyMinimize]: - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=2e-1) - else: - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=1e-1) + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit From 707f16ad072bc07b08d202f4405b5697da7c15d1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 17:29:55 +0000 Subject: [PATCH 5/5] updt. changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13535bd22..164050d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations ## Bug Fixes -- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Increases the SciPyMinimize optimiser assertion tolerances reduce CI/CD failures # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - Initial release