diff --git a/CHANGELOG.md b/CHANGELOG.md index 283dd7e6a..e4752a39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - [#196](https://github.com/pybop-team/PyBOP/issues/196) - Fixes failing observer cost tests. - [#63](https://github.com/pybop-team/PyBOP/issues/63) - Removes NLOpt Optimiser from future releases. This is to support deployment to the Apple M-Series platform. - [#164](https://github.com/pybop-team/PyBOP/issues/164) - Fixes convergence issues with gradient-based optimisers, changes default `model.check_params()` to allow infeasible solutions during optimisation iterations. Adds a feasibility check on the optimal parameters. +- [#211](https://github.com/pybop-team/PyBOP/issues/211) - Allows a subset of parameter bounds or bounds=None to be passed, returning warnings where needed. # [v23.12](https://github.com/pybop-team/PyBOP/tree/v23.12) - 2023-12-19 diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index a72bc92c8..107d96200 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -10,12 +10,10 @@ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.6, 0.05), - bounds=[0.5, 0.8], ), pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.48, 0.05), - bounds=[0.4, 0.7], ), ] @@ -52,7 +50,8 @@ pybop.plot_parameters(optim) # Plot the cost landscape -pybop.plot_cost2d(cost, steps=15) +bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +pybop.plot_cost2d(cost, bounds=bounds, steps=15) # Plot the cost landscape with optimisation path -pybop.plot_cost2d(cost, optim=optim, steps=15) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index c9d4b3431..7d1584930 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -10,12 +10,10 @@ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.68, 0.05), - bounds=[0.5, 0.8], ), pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.58, 0.05), - bounds=[0.4, 0.7], ), ] @@ -54,7 +52,8 @@ pybop.plot_parameters(optim) # Plot the cost landscape -pybop.plot_cost2d(cost, steps=15) +bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +pybop.plot_cost2d(cost, bounds=bounds, steps=15) # Plot the cost landscape with optimisation path -pybop.plot_cost2d(cost, optim=optim, steps=15) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 1b1ba0e8d..6bf163293 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -10,12 +10,10 @@ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.68, 0.05), - bounds=[0.5, 0.8], ), pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.58, 0.05), - bounds=[0.4, 0.7], ), ] @@ -56,7 +54,8 @@ pybop.plot_parameters(optim) # Plot the cost landscape -pybop.plot_cost2d(cost, steps=15) +bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +pybop.plot_cost2d(cost, bounds=bounds, steps=15) # Plot the cost landscape with optimisation path -pybop.plot_cost2d(cost, optim=optim, steps=15) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index cc367ccc5..7ce236302 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -51,8 +51,8 @@ def __init__( self.verbose = verbose self.x0 = cost.x0 self.bounds = cost.bounds + self.sigma0 = sigma0 or cost.sigma0 self.n_parameters = cost.n_parameters - self.sigma0 = sigma0 self.physical_viability = physical_viability self.allow_infeasible_solutions = allow_infeasible_solutions self.log = [] @@ -94,7 +94,7 @@ def __init__( if issubclass( self.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution) ): - self.optimiser = self.optimiser() + self.optimiser = self.optimiser(bounds=self.bounds) else: raise ValueError("Unknown optimiser type") @@ -178,7 +178,6 @@ def _run_pybop(self): x, final_cost = self.optimiser.optimise( cost_function=self.cost, x0=self.x0, - bounds=self.bounds, maxiter=self._max_iterations, ) self.log = self.optimiser.log diff --git a/pybop/_problem.py b/pybop/_problem.py index 38e28ce20..9cae745ac 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -45,11 +45,30 @@ def __init__( self._time_data = None self._target = None - # Set bounds - self.bounds = dict( - lower=[param.bounds[0] for param in self.parameters], - upper=[param.bounds[1] for param in self.parameters], - ) + # Set bounds (for all or no parameters) + all_unbounded = True # assumption + self.bounds = {"lower": [], "upper": []} + for param in self.parameters: + if param.bounds is not None: + self.bounds["lower"].append(param.bounds[0]) + self.bounds["upper"].append(param.bounds[1]) + all_unbounded = False + else: + self.bounds["lower"].append(-np.inf) + self.bounds["upper"].append(np.inf) + if all_unbounded: + self.bounds = None + + # Set initial standard deviation (for all or no parameters) + all_have_sigma = True # assumption + self.sigma0 = [] + for param in self.parameters: + if hasattr(param.prior, "sigma"): + self.sigma0.append(param.prior.sigma) + else: + all_have_sigma = False + if not all_have_sigma: + self.sigma0 = None # Sample from prior for x0 if x0 is None: diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index 171e09b37..4c572121a 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -18,6 +18,10 @@ class BaseCost: The initial guess for the model parameters. bounds : tuple The bounds for the model parameters. + sigma0 : scalar or array + Initial standard deviation around ``x0``. Either a scalar value (one + standard deviation for all coordinates) or an array with one entry + per dimension. Not all methods will use this information. n_parameters : int The number of parameters in the model. n_outputs : int @@ -26,10 +30,14 @@ class BaseCost: def __init__(self, problem): self.problem = problem + self.x0 = None + self.bounds = None + self.sigma0 = None if problem is not None: self._target = problem._target self.x0 = problem.x0 self.bounds = problem.bounds + self.sigma0 = problem.sigma0 self.n_parameters = problem.n_parameters self.n_outputs = problem.n_outputs diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 29cc219a2..3f7ac1e46 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -8,13 +8,19 @@ class BaseOptimiser: methods with specific algorithms. """ - def __init__(self): + def __init__(self, bounds=None): """ Initializes the BaseOptimiser. + + Parameters + ---------- + bounds : sequence or Bounds, optional + Bounds on the parameters. Default is None. """ + self.bounds = bounds pass - def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): + def optimise(self, cost_function, x0=None, maxiter=None): """ Initiates the optimisation process. @@ -26,8 +32,6 @@ def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): The cost function to be minimised by the optimiser. x0 : ndarray, optional Initial guess for the parameters. Default is None. - bounds : sequence or Bounds, optional - Bounds on the parameters. Default is None. maxiter : int, optional Maximum number of iterations to perform. Default is None. @@ -37,15 +41,14 @@ def optimise(self, cost_function, x0=None, bounds=None, maxiter=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) + result = self._runoptimise(self.cost_function, x0=self.x0) return result - def _runoptimise(self, cost_function, x0=None, bounds=None): + def _runoptimise(self, cost_function, x0=None): """ Contains the logic for the optimisation algorithm. @@ -57,8 +60,6 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): The cost function to be minimised by the optimiser. x0 : ndarray, optional Initial guess for the parameters. Default is None. - bounds : sequence or Bounds, optional - Bounds on the parameters. Default is None. Returns ------- diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 7b70e97f6..8b5a7c2c7 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -1,4 +1,5 @@ import pints +import numpy as np class GradientDescent(pints.GradientDescent): @@ -116,12 +117,18 @@ class PSO(pints.PSO): """ def __init__(self, x0, sigma0=0.1, bounds=None): - if bounds is not None: + if bounds is None: + self.boundaries = None + elif 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"] ) - else: - self.boundaries = None super().__init__(x0, sigma0, self.boundaries) @@ -138,7 +145,7 @@ class SNES(pints.SNES): x0 : array_like Initial position from which optimization will start. sigma0 : float, optional - Initial step size (default is 0.1). + Initial standard deviation of the sampling distribution, defaults to 0.1. bounds : dict, optional Lower and upper bounds for each optimization parameter. diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 7e717b81e..026213645 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -29,7 +29,7 @@ def __init__(self, method=None, bounds=None, maxiter=None): if self.method is None: self.method = "COBYLA" # "L-BFGS-B" - def _runoptimise(self, cost_function, x0, bounds): + def _runoptimise(self, cost_function, x0): """ Executes the optimization process using SciPy's minimize function. @@ -39,8 +39,6 @@ def _runoptimise(self, cost_function, x0, bounds): The objective function to minimize. x0 : array_like Initial guess for the parameters. - bounds : sequence or `Bounds` - Bounds for the variables. Returns ------- @@ -68,9 +66,10 @@ def cost_wrapper(x): return cost # Reformat bounds - if bounds is not None: + if self.bounds is not None: bounds = ( - (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + (lower, upper) + for lower, upper in zip(self.bounds["lower"], self.bounds["upper"]) ) # Set max iterations @@ -137,12 +136,23 @@ class SciPyDifferentialEvolution(BaseOptimiser): def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): super().__init__() - self.bounds = bounds self.strategy = strategy self._max_iterations = maxiter self._population_size = popsize - def _runoptimise(self, cost_function, x0=None, bounds=None): + 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 _runoptimise(self, cost_function, x0=None): """ Executes the optimization process using SciPy's differential_evolution function. @@ -152,8 +162,6 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): The objective function to minimize. x0 : array_like, optional Ignored parameter, provided for API consistency. - bounds : sequence or ``Bounds`` - Bounds for the variables, required for differential evolution. Returns ------- @@ -161,9 +169,6 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. """ - 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." @@ -175,15 +180,9 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): def callback(x, convergence): self.log.append([x]) - # 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, + self.bounds, strategy=self.strategy, maxiter=self._max_iterations, popsize=self._population_size, diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index 6136b0acb..c1b4ad1c1 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -39,14 +39,9 @@ def __init__( self.true_value = true_value self.initial_value = initial_value self.value = initial_value - self.bounds = bounds - self.lower_bound = self.bounds[0] - self.upper_bound = self.bounds[1] + self.set_bounds(bounds) self.margin = 1e-4 - if self.lower_bound >= self.upper_bound: - raise ValueError("Lower bound must be less than upper bound") - def rvs(self, n_samples): """ Draw random samples from the parameter's prior distribution. @@ -67,8 +62,11 @@ def rvs(self, n_samples): samples = self.prior.rvs(n_samples) # Constrain samples to be within bounds - offset = self.margin * (self.upper_bound - self.lower_bound) - samples = np.clip(samples, self.lower_bound + offset, self.upper_bound - offset) + if self.bounds is not None: + offset = self.margin * (self.upper_bound - self.lower_bound) + samples = np.clip( + samples, self.lower_bound + offset, self.upper_bound - offset + ) return samples @@ -120,3 +118,28 @@ def set_margin(self, margin): raise ValueError("Margin must be between 0 and 1") self.margin = margin + + def set_bounds(self, bounds=None): + """ + Set the upper and lower bounds. + + Parameters + ---------- + bounds : tuple, optional + A tuple defining the lower and upper bounds for the parameter. + Defaults to None. + + Raises + ------ + ValueError + If the lower bound is not strictly less than the upper bound, or if + the margin is set outside the interval (0, 1). + """ + if bounds is not None: + if bounds[0] >= bounds[1]: + raise ValueError("Lower bound must be less than upper bound") + else: + self.lower_bound = bounds[0] + self.upper_bound = bounds[1] + + self.bounds = bounds diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 482baff4d..2acbfc36d 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -1,4 +1,5 @@ import scipy.stats as stats +import numpy as np class Gaussian: @@ -168,6 +169,20 @@ def __repr__(self): """ return f"{self.name}, lower: {self.lower}, upper: {self.upper}" + @property + def mean(self): + """ + Returns the mean of the distribution. + """ + return (self.upper - self.lower) / 2 + + @property + def sigma(self): + """ + Returns the standard deviation of the distribution. + """ + return (self.upper - self.lower) / (2 * np.sqrt(3)) + class Exponential: """ @@ -247,3 +262,17 @@ def __repr__(self): Returns a string representation of the Uniform object. """ return f"{self.name}, scale: {self.scale}" + + @property + def mean(self): + """ + Returns the mean of the distribution. + """ + return self.scale + + @property + def sigma(self): + """ + Returns the standard deviation of the distribution. + """ + return self.scale diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 81e9ef65c..2640a28d4 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -1,4 +1,5 @@ import pybop +import numpy as np def plot_convergence( @@ -29,7 +30,7 @@ def plot_convergence( # Compute the minimum cost for each iteration min_cost_per_iteration = [ - min(cost_function(solution) for solution in log_entry) + min((cost_function(solution) for solution in log_entry), default=np.inf) for log_entry in optim.log ] diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index 1565ed243..2c390979d 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -30,8 +30,8 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): If the cost function does not return a valid cost when called with a parameter list. """ + # Set up parameter bounds if bounds is None: - # Set up parameter bounds bounds = get_param_bounds(cost) else: bounds = bounds @@ -73,7 +73,10 @@ def get_param_bounds(cost): """ bounds = np.empty((len(cost.problem.parameters), 2)) for i, param in enumerate(cost.problem.parameters): - bounds[i] = param.bounds + if param.bounds is not None: + bounds[i] = param.bounds + else: + raise ValueError("plot_cost2d could not find bounds required for plotting") return bounds diff --git a/tests/integration/test_parameterisations.py b/tests/integration/test_parameterisations.py index e9d7cd9af..78c82d3ed 100644 --- a/tests/integration/test_parameterisations.py +++ b/tests/integration/test_parameterisations.py @@ -24,7 +24,7 @@ def parameters(self): pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.5, 0.02), - bounds=[0.375, 0.625], + # no bounds ), ] @@ -41,7 +41,7 @@ def cost_class(self, request): return request.param @pytest.fixture - def spm_costs(self, parameters, model, x0, cost_class, init_soc): + def spm_cost(self, parameters, model, x0, cost_class, init_soc): # Form dataset solution = self.getdata(model, x0, init_soc) dataset = pybop.Dataset( @@ -74,9 +74,19 @@ def spm_costs(self, parameters, model, x0, cost_class, init_soc): ], ) @pytest.mark.integration - def test_spm_optimisers(self, optimiser, spm_costs, x0): + def test_spm_optimisers(self, optimiser, spm_cost, x0): + # Some optimisers require a complete set of bounds + if optimiser in [pybop.SciPyDifferentialEvolution, pybop.PSO]: + spm_cost.problem.parameters[1].set_bounds([0.375, 0.625]) + bounds = {"lower": [], "upper": []} + for param in spm_cost.problem.parameters: + bounds["lower"].append(param.bounds[0]) + bounds["upper"].append(param.bounds[1]) + spm_cost.problem.bounds = bounds + spm_cost.bounds = bounds + # Test each optimiser - parameterisation = pybop.Optimisation(cost=spm_costs, optimiser=optimiser) + parameterisation = pybop.Optimisation(cost=spm_cost, optimiser=optimiser) parameterisation.set_max_unchanged_iterations(iterations=25, threshold=5e-4) if optimiser in [pybop.CMAES]: @@ -147,6 +157,16 @@ def spm_two_signal_cost(self, parameters, model, x0): ) @pytest.mark.integration def test_multiple_signals(self, optimiser, spm_two_signal_cost, x0): + # Some optimisers require a complete set of bounds + if optimiser in [pybop.SciPyDifferentialEvolution]: + spm_two_signal_cost.problem.parameters[1].set_bounds([0.375, 0.625]) + bounds = {"lower": [], "upper": []} + for param in spm_two_signal_cost.problem.parameters: + bounds["lower"].append(param.bounds[0]) + bounds["upper"].append(param.bounds[1]) + spm_two_signal_cost.problem.bounds = bounds + spm_two_signal_cost.bounds = bounds + # Test each optimiser parameterisation = pybop.Optimisation( cost=spm_two_signal_cost, optimiser=optimiser diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 6569d1ad5..a7c733090 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -58,32 +58,32 @@ def cost(self, problem): ) @pytest.mark.unit def test_optimiser_classes(self, cost, optimiser_class, expected_name): - cost.bounds = None opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) assert opt.optimiser is not None assert opt.optimiser.name() == expected_name - if optimiser_class not in [ - pybop.SciPyMinimize, - pybop.SciPyDifferentialEvolution, - ]: - assert opt.optimiser.boundaries is None + # Test without bounds + cost.bounds = None + if optimiser_class in [pybop.SciPyDifferentialEvolution]: + with pytest.raises(ValueError): + pybop.Optimisation(cost=cost, optimiser=optimiser_class) + else: + opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + + if optimiser_class in [pybop.SciPyMinimize]: + assert opt.optimiser.bounds is None + else: + assert opt.optimiser.boundaries is None @pytest.mark.unit - def test_default_optimiser_with_bounds(self, cost): + def test_default_optimiser(self, cost): opt = pybop.Optimisation(cost=cost) assert ( opt.optimiser.name() == "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)" ) - @pytest.mark.unit - def test_default_optimiser_no_bounds(self, cost): - cost.bounds = None - opt = pybop.Optimisation(cost=cost) - assert opt.optimiser.boundaries is None - @pytest.mark.unit def test_incorrect_optimiser_class(self, cost): class RandomClass: diff --git a/tests/unit/test_parameters.py b/tests/unit/test_parameters.py index 28d5e5cd0..098ef2d39 100644 --- a/tests/unit/test_parameters.py +++ b/tests/unit/test_parameters.py @@ -22,6 +22,8 @@ def test_parameter_construction(self, parameter): assert parameter.prior.mean == 0.6 assert parameter.prior.sigma == 0.02 assert parameter.bounds == [0.375, 0.7] + assert parameter.lower_bound == 0.375 + assert parameter.upper_bound == 0.7 assert parameter.initial_value == 0.6 @pytest.mark.unit @@ -52,6 +54,13 @@ def test_parameter_margin(self, parameter): parameter.set_margin(margin=1e-3) assert parameter.margin == 1e-3 + @pytest.mark.unit + def test_no_bounds(self): + parameter = pybop.Parameter( + "Negative electrode active material volume fraction", + ) + assert parameter.bounds is None + @pytest.mark.unit def test_invalid_inputs(self, parameter): # Test error with invalid value diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 11e935f06..8dde39c5d 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -68,6 +68,13 @@ def test_cost_plots(self, cost): # Plot the cost landscape pybop.plot_cost2d(cost, steps=5) + # Test without bounds + for param in cost.problem.parameters: + param.bounds = None + with pytest.raises(ValueError): + pybop.plot_cost2d(cost, steps=5) + pybop.plot_cost2d(cost, bounds=np.array([[1e-6, 9e-6], [1e-6, 9e-6]]), steps=5) + @pytest.fixture def optim(self, cost): # Define and run an example optimisation diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index d9b73388e..a4271e0b6 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -32,6 +32,14 @@ def test_priors(self, Gaussian, Uniform, Exponential): np.testing.assert_allclose(Uniform.logpdf(0.5), 0, atol=1e-4) np.testing.assert_allclose(Exponential.logpdf(1), -1, atol=1e-4) + # Test properties + assert Uniform.mean == (Uniform.upper - Uniform.lower) / 2 + np.testing.assert_allclose( + Uniform.sigma, (Uniform.upper - Uniform.lower) / (2 * np.sqrt(3)), atol=1e-8 + ) + assert Exponential.mean == Exponential.scale + assert Exponential.sigma == Exponential.scale + @pytest.mark.unit def test_gaussian_rvs(self, Gaussian): samples = Gaussian.rvs(size=500) diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py index 0cdfd8bce..94f1ff1a0 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -83,6 +83,12 @@ def test_base_problem(self, parameters, model): with pytest.raises(ValueError): pybop._problem.BaseProblem(parameters, model=model, signal=[1e-5, 1e-5]) + # Test without bounds + for param in parameters: + param.bounds = None + problem = pybop._problem.BaseProblem(parameters, model=model) + assert problem.bounds is None + @pytest.mark.unit def test_fitting_problem(self, parameters, dataset, model, signal): # Test incorrect number of initial parameter values