From 0cb69ce275b2495f8628b69511833b51c10b60f6 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 3 Aug 2024 09:11:43 +0100 Subject: [PATCH 1/8] fix: updates for SLF001 linting, additional property vars, update contributing.md --- CONTRIBUTING.md | 10 +++-- benchmarks/benchmark_model.py | 4 +- .../notebooks/optimiser_calibration.ipynb | 2 +- examples/notebooks/spm_electrode_design.ipynb | 2 +- examples/scripts/exp_UKF.py | 2 +- examples/scripts/spme_max_energy.py | 2 +- pybop/costs/_likelihoods.py | 2 +- pybop/costs/_weighted_cost.py | 6 +-- pybop/costs/base_cost.py | 6 ++- pybop/costs/design_costs.py | 4 +- pybop/costs/fitting_costs.py | 2 +- pybop/models/base_model.py | 37 ++++++++++++++++--- pybop/observers/observer.py | 2 +- pybop/optimisers/_adamw.py | 37 +++++++++++++------ pybop/optimisers/base_optimiser.py | 4 +- pybop/optimisers/base_pints_optimiser.py | 4 ++ pybop/plotting/plot_problem.py | 2 +- pybop/problems/base_problem.py | 27 ++++++++------ pybop/problems/design_problem.py | 2 +- pybop/problems/fitting_problem.py | 12 +++--- pybop/transformation/base_transformation.py | 2 +- pybop/transformation/transformations.py | 28 +++++++------- pyproject.toml | 6 ++- tests/unit/test_cost.py | 8 ++-- tests/unit/test_likelihoods.py | 2 +- tests/unit/test_models.py | 16 ++++---- tests/unit/test_optimisation.py | 20 +++++----- tests/unit/test_problem.py | 12 +++--- tests/unit/test_transformations.py | 14 +++---- 29 files changed, 168 insertions(+), 109 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abebe3b0..01c2312c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ pip install -e .[all,dev] Before you commit any code, please perform the following checks using [Nox](https://nox.thea.codes/en/stable/index.html): -- [All tests pass](#testing): `$ nox -s unit` +- [All tests pass](#testing): `$ nox -s quick` ### Installing and using pre-commit @@ -83,16 +83,18 @@ PyBOP follows the [PEP8 recommendations](https://www.python.org/dev/peps/pep-000 ### Ruff -We use [ruff](https://github.com/charliermarsh/ruff) to check our PEP8 adherence. To try this on your system, navigate to the PyBOP directory in a console and type +We use [ruff](https://github.com/charliermarsh/ruff) to lint and ensure adherence to Python PEP standards. To manually trigger `ruff`, navigate to the PyBOP directory in a console and type ```bash python -m pip install pre-commit pre-commit run ruff ``` -ruff is configured inside the file `pre-commit-config.yaml`, allowing us to ignore some errors. If you think this should be added or removed, please submit an [issue](https://guides.github.com/features/issues/). +ruff is configured inside the file `pyproject.toml`, allowing us to ignore some errors. If you think a rule should be added or removed, please submit an [issue](https://guides.github.com/features/issues/). -When you commit your changes they will be checked against ruff automatically (see [Pre-commit checks](#pre-commit-checks)). +When you commit your changes they will be checked against ruff automatically (see [Pre-commit checks](#pre-commit-checks)). If you are having issues getting your commit to pass the linting, it +is possible to skip linting for single lines (this should only be done as a **last resort**) by adding a line comment of `#noqa: $ruff_rule` where the `$ruff_rule` is replaced with the rule in question. +These rules can be found in the ruff configuration in `pyproject.toml` or in the failed pre-commit output. Please note the lint skipping in the pull request for reviewers. ### Naming diff --git a/benchmarks/benchmark_model.py b/benchmarks/benchmark_model.py index 5d496215..7a1720fe 100644 --- a/benchmarks/benchmark_model.py +++ b/benchmarks/benchmark_model.py @@ -79,7 +79,7 @@ def time_model_simulate(self, model, parameter_set): model (pybop.Model): The model class being benchmarked. parameter_set (str): The name of the parameter set being used. """ - self.problem._model.simulate(inputs=self.inputs, t_eval=self.t_eval) + self.problem.model.simulate(inputs=self.inputs, t_eval=self.t_eval) def time_model_simulateS1(self, model, parameter_set): """ @@ -89,4 +89,4 @@ def time_model_simulateS1(self, model, parameter_set): model (pybop.Model): The model class being benchmarked. parameter_set (str): The name of the parameter set being used. """ - self.problem._model.simulateS1(inputs=self.inputs, t_eval=self.t_eval) + self.problem.model.simulateS1(inputs=self.inputs, t_eval=self.t_eval) diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 6551c9a0..ca3bdd59 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -482,7 +482,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim._iterations} | Best Cost: {optim.pints_optimiser.f_best()} | Results: {optim.pints_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/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index e7b2fd57..904494ab 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -276,7 +276,7 @@ ], "source": [ "if cost.update_capacity:\n", - " problem._model.approximate_capacity(x)\n", + " problem.model.approximate_capacity(x)\n", "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, diff --git a/examples/scripts/exp_UKF.py b/examples/scripts/exp_UKF.py index 622a68e5..e596dd27 100644 --- a/examples/scripts/exp_UKF.py +++ b/examples/scripts/exp_UKF.py @@ -40,7 +40,7 @@ # Verification step: make another prediction using the Observer class model.build(parameters=parameters) simulator = pybop.Observer(parameters, model, signal=["2y"]) -simulator._time_data = t_eval +simulator.time_data = t_eval measurements = simulator.evaluate(true_inputs) # Verification step: Compare by plotting diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index 8542b4ad..737879f9 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -57,7 +57,7 @@ # Plot the timeseries output if cost.update_capacity: - problem._model.approximate_capacity(x) + problem.model.approximate_capacity(x) pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot the cost landscape with optimisation path diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 87ed9381..fac5c195 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -279,7 +279,7 @@ def __init__(self, problem, likelihood, sigma0=None, gradient_step=1e-3): raise ValueError(f"{self.likelihood} must be a subclass of BaseLikelihood") self.parameters = self.likelihood.parameters - self._has_separable_problem = self.likelihood._has_separable_problem + self._has_separable_problem = self.likelihood.has_separable_problem def _evaluate(self, inputs: Inputs) -> float: """ diff --git a/pybop/costs/_weighted_cost.py b/pybop/costs/_weighted_cost.py index ac4d9002..674c7276 100644 --- a/pybop/costs/_weighted_cost.py +++ b/pybop/costs/_weighted_cost.py @@ -54,7 +54,7 @@ def __init__(self, *costs, weights: Optional[list[float]] = None): # Check if all costs depend on the same problem self._has_identical_problems = all( - cost._has_separable_problem and cost.problem is self.costs[0].problem + cost.has_separable_problem and cost.problem is self.costs[0].problem for cost in self.costs ) @@ -105,7 +105,7 @@ def _evaluate(self, inputs: Inputs): inputs = cost.parameters.as_dict() if self._has_identical_problems: cost.y = self.y - elif cost._has_separable_problem: + elif cost.has_separable_problem: cost.y = cost.problem.evaluate( inputs, update_capacity=self.update_capacity ) @@ -140,7 +140,7 @@ def _evaluateS1(self, inputs: Inputs): inputs = cost.parameters.as_dict() if self._has_identical_problems: cost.y, cost.dy = (self.y, self.dy) - elif cost._has_separable_problem: + elif cost.has_separable_problem: cost.y, cost.dy = cost.problem.evaluateS1(inputs) e[i], de[:, i] = cost._evaluateS1(inputs) diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index 168a4dee..85206d3b 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -38,7 +38,7 @@ def __init__(self, problem: Optional[BaseProblem] = None): self.dy = None self.set_fail_gradient() if isinstance(self.problem, BaseProblem): - self._target = self.problem._target + self._target = self.problem.target self.parameters.join(self.problem.parameters) self.n_outputs = self.problem.n_outputs self.signal = self.problem.signal @@ -53,6 +53,10 @@ def n_parameters(self): def has_separable_problem(self): return self._has_separable_problem + @property + def target(self): + return self._target + def __call__(self, inputs: Union[Inputs, list]): """ Call the evaluate function for a given set of parameters. diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index ce00ad9e..f448fafa 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -61,8 +61,8 @@ def update_simulation_data(self, inputs: Inputs): if "Time [s]" not in solution: raise ValueError("The solution does not contain time data.") - self.problem._time_data = solution["Time [s]"] - self.problem._target = {key: solution[key] for key in self.problem.signal} + self.problem.time_data = solution["Time [s]"] + self.problem.target = {key: solution[key] for key in self.problem.signal} self.dt = solution["Time [s]"][1] - solution["Time [s]"][0] diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index f3690189..91214365 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -404,7 +404,7 @@ def _evaluate(self, inputs: Inputs): The observer cost (negative of the log likelihood). """ log_likelihood = self._observer.log_likelihood( - self._target, self._observer.time_data(), inputs + self._target, self._observer.time_data, inputs ) return -log_likelihood diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index 9e547b65..59affc2d 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -114,7 +114,7 @@ def build( self._built_model = self.pybamm_model else: - if not self.pybamm_model._built: + if not self.pybamm_model._built: # noqa: SLF001 self.pybamm_model.build_model() self.set_params() @@ -129,7 +129,7 @@ def build( ) # Clear solver and setup model - self._solver._model_set_up = {} + self._solver._model_set_up = {} # noqa: SLF001 self.n_states = self._built_model.len_rhs_and_alg # len_rhs + len_alg @@ -244,7 +244,7 @@ def rebuild( ) # Clear solver and setup model - self._solver._model_set_up = {} + self._solver._model_set_up = {} # noqa: SLF001 def classify_and_update_parameters(self, parameters: Parameters): """ @@ -506,7 +506,7 @@ def predict( """ inputs = self.parameters.verify(inputs) - if not self.pybamm_model._built: + if not self.pybamm_model._built: # noqa: SLF001 self.pybamm_model.build_model() parameter_set = parameter_set or self._unprocessed_parameter_set @@ -659,18 +659,31 @@ def approximate_capacity(self, inputs: Inputs): def built_model(self): return self._built_model + @built_model.setter + def built_model(self, built_model): + self._built_model = built_model if built_model is not None else None + + def built_initial_soc(self): + return self._built_initial_soc + @property def parameter_set(self): return self._parameter_set @parameter_set.setter def parameter_set(self, parameter_set): - self._parameter_set = parameter_set.copy() + self._parameter_set = parameter_set.copy() if parameter_set is not None else {} @property def model_with_set_params(self): return self._model_with_set_params + @model_with_set_params.setter + def model_with_set_params(self, model_with_set_params): + self._model_with_set_params = ( + model_with_set_params.copy() if model_with_set_params is not None else None + ) + @property def geometry(self): return self._geometry @@ -693,6 +706,18 @@ def submesh_types(self, submesh_types: Optional[dict[str, Any]]): def mesh(self): return self._mesh + @mesh.setter + def mesh(self, mesh): + self._mesh = mesh if mesh is not None else None + + @property + def disc(self): + return self._disc + + @disc.setter + def disc(self, disc): + self._disc = disc if disc is not None else None + @property def var_pts(self): return self._var_pts @@ -723,7 +748,7 @@ def get_parameter_info(self, print_info: bool = False): """ Extracts the parameter names and types and returns them as a dictionary. """ - if not self.pybamm_model._built: + if not self.pybamm_model._built: # noqa: SLF001 self.pybamm_model.build_model() info = self.pybamm_model.get_parameter_info() diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index f7d6f25f..07033c6d 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -49,7 +49,7 @@ def __init__( super().__init__( parameters, model, check_model, signal, additional_variables, init_soc ) - if model._built_model is None: + if model.built_model is None: raise ValueError("Only built models can be used in Observers") if model.signal is None: model.signal = self.signal diff --git a/pybop/optimisers/_adamw.py b/pybop/optimisers/_adamw.py index 817d8f29..83eddfc9 100644 --- a/pybop/optimisers/_adamw.py +++ b/pybop/optimisers/_adamw.py @@ -26,7 +26,7 @@ class AdamWImpl(PintsOptimiser): m_j' = m_j[i] / (1 - beta1**(1 + i)) v_j' = v_j[i] / (1 - beta2**(1 + i)) - p_j[i] = p_j[i - 1] - alpha * (m_j' / (sqrt(v_j') + eps) + lambda * p_j[i - 1]) + p_j[i] = p_j[i - 1] - alpha * (m_j' / (sqrt(v_j') + eps) + lam * p_j[i - 1]) The initial values of the moments are ``m_j[0] = v_j[0] = 0``, after which they decay with rates ``beta1`` and ``beta2``. The default values for these are, @@ -38,7 +38,7 @@ class AdamWImpl(PintsOptimiser): The parameter ``alpha`` is a step size, which is set as ``min(sigma0)`` in this implementation. - The parameter ``lambda`` is the weight decay rate, which is set to ``0.01`` + The parameter ``lam`` is the weight decay rate, which is set to ``0.01`` by default in this implementation. Finally, ``eps`` is a small constant used to avoid division by zero, set to @@ -89,7 +89,7 @@ def __init__(self, x0, sigma0=0.015, boundaries=None): self._alpha = np.min(self._sigma0) # Weight decay rate - self.set_lambda() + self._lam = 0.01 # Small number added to avoid divide-by-zero self._eps = np.finfo(float).eps @@ -186,7 +186,7 @@ def tell(self, reply): # Take step with weight decay self._proposed = self._current - self._alpha * ( - m / (np.sqrt(v) + self._eps) + self._lambda * self._current + m / (np.sqrt(v) + self._eps) + self._lam * self._current ) # Update x_best and f_best @@ -206,17 +206,27 @@ def x_guessed(self): """ return self._current - def set_lambda(self, lambda_: float = 0.01) -> None: + @property + def lam(self): + return self._lam + + @lam.setter + def lam(self, lam: float = 0.01) -> None: """ - Sets the lambda_ decay constant. This is the weight decay rate + Sets the lam decay constant. This is the weight decay rate that helps in finding the optimal solution. """ - if not isinstance(lambda_, (int, float)) or not 0 < lambda_ <= 1: - raise ValueError("lambda_ must be a numeric value between 0 and 1.") + if not isinstance(lam, (int, float)) or not 0 < lam <= 1: + raise ValueError("lam must be a numeric value between 0 and 1.") + + self._lam = float(lam) - self._lambda = float(lambda_) + @property + def b1(self): + return self._b1 - def set_b1(self, b1: float) -> None: + @b1.setter + def b1(self, b1: float) -> None: """ Sets the b1 momentum decay constant. """ @@ -225,7 +235,12 @@ def set_b1(self, b1: float) -> None: self._b1 = float(b1) - def set_b2(self, b2: float) -> None: + @property + def b2(self): + return self._b2 + + @b2.setter + def b2(self, b2: float) -> None: """ Sets the b2 momentum decay constant. """ diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index c24b081d..e3f8e242 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -222,7 +222,7 @@ def check_optimal_parameters(self, x): x : array-like Optimised parameter values. """ - if self.cost.problem._model.check_params( + if self.cost.problem.model.check_params( inputs=x, allow_infeasible_solutions=False ): return @@ -259,7 +259,7 @@ def set_allow_infeasible_solutions(self, allow=True): self.allow_infeasible_solutions = allow if hasattr(self.cost, "problem") and hasattr(self.cost.problem, "_model"): - self.cost.problem._model.allow_infeasible_solutions = ( + self.cost.problem.model.allow_infeasible_solutions = ( self.allow_infeasible_solutions ) else: diff --git a/pybop/optimisers/base_pints_optimiser.py b/pybop/optimisers/base_pints_optimiser.py index 8bf856c4..a9058944 100644 --- a/pybop/optimisers/base_pints_optimiser.py +++ b/pybop/optimisers/base_pints_optimiser.py @@ -505,3 +505,7 @@ def set_threshold(self, threshold=None): self._threshold = None else: self._threshold = float(threshold) + + @property + def iterations(self): + return self._iterations diff --git a/pybop/plotting/plot_problem.py b/pybop/plotting/plot_problem.py index fb8759c9..32ef38b2 100644 --- a/pybop/plotting/plot_problem.py +++ b/pybop/plotting/plot_problem.py @@ -37,7 +37,7 @@ def quick_plot(problem, problem_inputs: Inputs = None, show=True, **layout_kwarg problem_inputs = problem.parameters.verify(problem_inputs) # Extract the time data and evaluate the model's output and target values - xaxis_data = problem.time_data() + xaxis_data = problem.time_data model_output = problem.evaluate(problem_inputs) target_output = problem.get_target() diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py index cb3bc597..07fea62e 100644 --- a/pybop/problems/base_problem.py +++ b/pybop/problems/base_problem.py @@ -108,17 +108,6 @@ def evaluateS1(self, inputs: Inputs): """ raise NotImplementedError - def time_data(self): - """ - Returns the time data. - - Returns - ------- - np.ndarray - The time array. - """ - return self._time_data - def get_target(self): """ Return the target dataset. @@ -149,3 +138,19 @@ def set_target(self, dataset): @property def model(self): return self._model + + @property + def target(self): + return self._target + + @target.setter + def target(self, target): + self._target = target + + @property + def time_data(self): + return self._time_data + + @time_data.setter + def time_data(self, time_data): + self._time_data = time_data diff --git a/pybop/problems/design_problem.py b/pybop/problems/design_problem.py index 74b8ae71..72af78a2 100644 --- a/pybop/problems/design_problem.py +++ b/pybop/problems/design_problem.py @@ -66,7 +66,7 @@ def __init__( # Leave the build until later to apply the experiment self._model.classify_and_update_parameters(self.parameters) - elif self._model._built_model is None: + elif self._model.built_model is None: self._model.build( experiment=self.experiment, parameters=self.parameters, diff --git a/pybop/problems/fitting_problem.py b/pybop/problems/fitting_problem.py index 0ad636ce..ac91513f 100644 --- a/pybop/problems/fitting_problem.py +++ b/pybop/problems/fitting_problem.py @@ -66,12 +66,12 @@ def __init__( self._model.n_time_data = self.n_time_data # Build the model from scratch - if self._model._built_model is not None: - self._model._model_with_set_params = None - self._model._built_model = None - self._model._built_initial_soc = None - self._model._mesh = None - self._model._disc = None + if self._model.built_model is not None: + self._model.model_with_set_params = None + self._model.built_model = None + self._model.built_initial_soc = None + self._model.mesh = None + self._model.disc = None self._model.build( dataset=self._dataset, parameters=self.parameters, diff --git a/pybop/transformation/base_transformation.py b/pybop/transformation/base_transformation.py index c9905775..40488949 100644 --- a/pybop/transformation/base_transformation.py +++ b/pybop/transformation/base_transformation.py @@ -105,7 +105,7 @@ def is_elementwise(self) -> bool: """ raise NotImplementedError("is_elementwise method must be implemented if used.") - def _verify_input( + def verify_input( self, inputs: Union[float, int, list[float], np.ndarray, dict[str, float]] ) -> np.ndarray: """Set and validate the transformation parameter.""" diff --git a/pybop/transformation/transformations.py b/pybop/transformation/transformations.py index 782fe46b..97fed769 100644 --- a/pybop/transformation/transformations.py +++ b/pybop/transformation/transformations.py @@ -90,9 +90,9 @@ def __init__( n_parameters: int = 1, ): self._n_parameters = n_parameters - self._intercept = self._verify_input(intercept) - self._coefficient = self._verify_input(coefficient) - self._inverse_coeff = np.reciprocal(self._coefficient) + self.intercept = self.verify_input(intercept) + self.coefficient = self.verify_input(coefficient) + self.inverse_coeff = np.reciprocal(self.coefficient) def is_elementwise(self) -> bool: """See :meth:`Transformation.is_elementwise()`.""" @@ -100,7 +100,7 @@ def is_elementwise(self) -> bool: def jacobian(self, q: np.ndarray) -> np.ndarray: """See :meth:`Transformation.jacobian()`.""" - return np.diag(self._inverse_coeff) + return np.diag(self.inverse_coeff) def jacobian_S1(self, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """See :meth:`Transformation.jacobian_S1()`.""" @@ -109,7 +109,7 @@ def jacobian_S1(self, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def log_jacobian_det(self, q: np.ndarray) -> float: """See :meth:`Transformation.log_jacobian_det()`.""" - return np.sum(np.log(np.abs(self._coefficient))) + return np.sum(np.log(np.abs(self.coefficient))) def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: """See :meth:`Transformation.log_jacobian_det_S1()`.""" @@ -117,11 +117,11 @@ def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: def _transform(self, x: np.ndarray, method: str) -> np.ndarray: """See :meth:`Transformation._transform`.""" - x = self._verify_input(x) + x = self.verify_input(x) if method == "to_model": - return x * self._inverse_coeff - self._intercept + return x * self.inverse_coeff - self.intercept elif method == "to_search": - return self._coefficient * (x + self._intercept) + return self.coefficient * (x + self.intercept) else: raise ValueError(f"Unknown method: {method}") @@ -178,7 +178,7 @@ def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: def _transform(self, x: np.ndarray, method: str) -> np.ndarray: """See :meth:`Transformation._transform`.""" - x = self._verify_input(x) + x = self.verify_input(x) if method == "to_model": return np.exp(x) elif method == "to_search": @@ -260,7 +260,7 @@ def jacobian(self, q: np.ndarray) -> np.ndarray: np.ndarray Diagonal matrix representing the elementwise Jacobian. """ - q = self._verify_input(q) + q = self.verify_input(q) diag = np.zeros(self._n_parameters) lo = 0 @@ -273,7 +273,7 @@ def jacobian(self, q: np.ndarray) -> np.ndarray: def jacobian_S1(self, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """See :meth:`Transformation.jacobian_S1()`.""" - q = self._verify_input(q) + q = self.verify_input(q) output_S1 = np.zeros( (self._n_parameters, self._n_parameters, self._n_parameters) ) @@ -302,7 +302,7 @@ def log_jacobian_det(self, q: np.ndarray) -> float: float Sum of log determinants of individual transformations. """ - q = self._verify_input(q) + q = self.verify_input(q) return sum( transformation.log_jacobian_det(q[lo : lo + transformation.n_parameters]) for lo, transformation in self._iter_transformations() @@ -322,7 +322,7 @@ def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: Tuple[float, np.ndarray] Tuple of sum of log determinants and concatenated first-order sensitivities. """ - q = self._verify_input(q) + q = self.verify_input(q) output = 0.0 output_S1 = np.zeros(self._n_parameters) lo = 0 @@ -338,7 +338,7 @@ def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: def _transform(self, data: np.ndarray, method: str) -> np.ndarray: """See :meth:`Transformation._transform`.""" - data = self._verify_input(data) + data = self.verify_input(data) output = np.zeros_like(data) lo = 0 diff --git a/pyproject.toml b/pyproject.toml index 3be31799..a1b892fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,10 +94,14 @@ select = [ "ISC", # flake8-implicit-str-concat: Check for implicit string concatenation "TID", # flake8-tidy-imports: Validate import hygiene "UP", # pyupgrade: Automatically upgrade syntax for newer versions of Python + "SLF001", # flake8-string-format: Check for private object name access ] ignore = ["E501","E741"] -per-file-ignores = {"**.ipynb" = ["E402", "E703"]} + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["SLF001"] +"**.ipynb" = ["E402", "E703"] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 309b7e74..58e46247 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -92,7 +92,7 @@ def cost(self, problem, request): return cls(problem, p=2) elif cls in [pybop.ObserverCost]: inputs = problem.parameters.initial_value() - state = problem._model.reinit(inputs) + state = problem.model.reinit(inputs) n = len(state) sigma_diag = [0.0] * n sigma_diag[0] = 1e-4 @@ -106,7 +106,7 @@ def cost(self, problem, request): return cls( pybop.UnscentedKalmanFilterObserver( problem.parameters, - problem._model, + problem.model, sigma0=sigma0, process=process, measure=1e-4, @@ -221,7 +221,7 @@ def test_costs(self, cost): assert "Non-physical point encountered" in str(record[i].message) # Test infeasible locations - cost.problem._model.allow_infeasible_solutions = False + cost.problem.model.allow_infeasible_solutions = False assert cost([1.1]) == np.inf assert cost.evaluateS1([1.1]) == (np.inf, cost._de) assert cost([0.01]) == np.inf @@ -286,7 +286,7 @@ def test_design_costs(self, cost_class, design_problem): assert cost([-0.1]) == -np.inf # Should not be a viable design # Test infeasible locations - cost.problem._model.allow_infeasible_solutions = False + cost.problem.model.allow_infeasible_solutions = False assert cost([1.1]) == -np.inf # Test exception for non-numeric inputs diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py index 56ae1e44..e64bd1a1 100644 --- a/tests/unit/test_likelihoods.py +++ b/tests/unit/test_likelihoods.py @@ -77,7 +77,7 @@ def test_base_likelihood_init(self, problem_name, n_outputs, request): assert likelihood.n_outputs == n_outputs assert likelihood.n_time_data == problem.n_time_data assert likelihood.n_parameters == 1 - assert np.array_equal(likelihood._target, problem._target) + assert np.array_equal(likelihood.target, problem.target) @pytest.mark.unit def test_base_likelihood_call_raises_not_implemented_error( diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 551379b7..fe038248 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -183,18 +183,18 @@ def test_parameter_set_definition(self): # Test initilisation with different types of parameter set param_dict = {"Nominal cell capacity [A.h]": 5} model = pybop.BaseModel(parameter_set=None) - assert model._parameter_set is None + assert model.parameter_set is None model = pybop.BaseModel(parameter_set=param_dict) parameter_set = pybamm.ParameterValues(param_dict) - assert model._parameter_set == parameter_set + assert model.parameter_set == parameter_set model = pybop.BaseModel(parameter_set=parameter_set) - assert model._parameter_set == parameter_set + assert model.parameter_set == parameter_set pybop_parameter_set = pybop.ParameterSet(params_dict=param_dict) model = pybop.BaseModel(parameter_set=pybop_parameter_set) - assert model._parameter_set == parameter_set + assert model.parameter_set == parameter_set @pytest.mark.unit def test_rebuild_geometric_parameters(self): @@ -232,8 +232,8 @@ def test_rebuild_geometric_parameters(self): # Test model geometry assert ( - rebuilt_model._mesh["negative electrode"].nodes[1] - != initial_built_model._mesh["negative electrode"].nodes[1] + rebuilt_model.mesh["negative electrode"].nodes[1] + != initial_built_model.mesh["negative electrode"].nodes[1] ) assert ( rebuilt_model.geometry["negative electrode"]["x_n"]["max"] @@ -246,8 +246,8 @@ def test_rebuild_geometric_parameters(self): ) assert ( - rebuilt_model._mesh["positive particle"].nodes[1] - != initial_built_model._mesh["positive particle"].nodes[1] + rebuilt_model.mesh["positive particle"].nodes[1] + != initial_built_model.mesh["positive particle"].nodes[1] ) # Compare model results diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index bb6d08fa..81b12b2d 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -226,29 +226,29 @@ def test_optimiser_kwargs(self, cost, optimiser): # Test AdamW hyperparameters if optimiser in [pybop.AdamW]: - optim = optimiser(cost=cost, b1=0.9, b2=0.999, lambda_=0.1) - optim.pints_optimiser.set_b1(0.9) - optim.pints_optimiser.set_b2(0.9) - optim.pints_optimiser.set_lambda(0.1) + optim = optimiser(cost=cost, b1=0.9, b2=0.999, lam=0.1) + optim.pints_optimiser.b1 = 0.9 + optim.pints_optimiser.b2 = 0.9 + optim.pints_optimiser.lam = 0.1 - assert optim.pints_optimiser._b1 == 0.9 - assert optim.pints_optimiser._b2 == 0.9 - assert optim.pints_optimiser._lambda == 0.1 + assert optim.pints_optimiser.b1 == 0.9 + assert optim.pints_optimiser.b2 == 0.9 + assert optim.pints_optimiser.lam == 0.1 # Incorrect values for i, _match in (("Value", -1),): with pytest.raises( Exception, match="must be a numeric value between 0 and 1." ): - optim.pints_optimiser.set_b1(i) + optim.pints_optimiser.b1 = i with pytest.raises( Exception, match="must be a numeric value between 0 and 1." ): - optim.pints_optimiser.set_b2(i) + optim.pints_optimiser.b2 = i with pytest.raises( Exception, match="must be a numeric value between 0 and 1." ): - optim.pints_optimiser.set_lambda(i) + optim.pints_optimiser.lam = i # Check defaults assert optim.pints_optimiser.n_hyper_parameters() == 5 diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py index c2c40a03..05dadfdc 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -71,7 +71,7 @@ def test_base_problem(self, parameters, model, dataset): # Construct Problem problem = pybop.BaseProblem(parameters, model=model) - assert problem._model == model + assert problem.model == model with pytest.raises(NotImplementedError): problem.evaluate([1e-5, 1e-5]) @@ -112,8 +112,8 @@ def test_fitting_problem(self, parameters, dataset, model, signal): # Construct Problem problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) - assert problem._model == model - assert problem._model._built_model is not None + assert problem.model == model + assert problem.model.built_model is not None # Test get target target = problem.get_target()["Voltage [V]"] @@ -167,9 +167,9 @@ def test_design_problem(self, parameters, experiment, model): # Construct Problem problem = pybop.DesignProblem(model, parameters, experiment) - assert problem._model == model + assert problem.model == model assert ( - problem._model._built_model is None + problem.model.built_model is None ) # building postponed with input experiment # Test model.predict @@ -191,7 +191,7 @@ def test_problem_construct_with_model_predict( # Test problem evaluate problem_output = problem.evaluate([2e-5, 2e-5]) - assert problem._model._built_model is not None + assert problem.model.built_model is not None with pytest.raises(AssertionError): assert_allclose( out["Voltage [V]"].data, diff --git a/tests/unit/test_transformations.py b/tests/unit/test_transformations.py index 9a86d1f0..baf4eee3 100644 --- a/tests/unit/test_transformations.py +++ b/tests/unit/test_transformations.py @@ -74,7 +74,7 @@ def test_scaled_transformation(self, parameters): # Test covariance transformation cov = np.array([[0.5]]) cov_transformed = transformation.convert_covariance_matrix(cov, q) - assert np.array_equal(cov_transformed, cov * transformation._coefficient**2) + assert np.array_equal(cov_transformed, cov * transformation.coefficient**2) # Test incorrect transform with pytest.raises(ValueError, match="Unknown method:"): @@ -158,21 +158,21 @@ def test_composed_transformation(self, parameters): ) @pytest.mark.unit - def test_verify_input(self, parameters): + def testverify_input(self, parameters): q = np.asarray([5.0]) q_dict = {"Identity": q[0]} transformation = parameters["Scaled"].transformation - assert transformation._verify_input(q) == q - assert transformation._verify_input(q.tolist()) == q - assert transformation._verify_input(q_dict) == q + assert transformation.verify_input(q) == q + assert transformation.verify_input(q.tolist()) == q + assert transformation.verify_input(q_dict) == q with pytest.raises( TypeError, match="Transform must be a float, int, list, numpy array," ): - transformation._verify_input("string") + transformation.verify_input("string") with pytest.raises(ValueError, match="Transform must have"): - transformation._verify_input(np.array([1.0, 2.0, 3.0])) + transformation.verify_input(np.array([1.0, 2.0, 3.0])) class TestBaseTransformation: From 9cec067a4d61bac7807771b616cd6ac3cf0bf07f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 3 Aug 2024 10:14:32 +0100 Subject: [PATCH 2/8] refactor: renames cost._evaluate to cost.compute to remove confusion on private attr. Updates docstring for added clarity --- examples/standalone/cost.py | 4 +-- pybop/costs/_likelihoods.py | 48 +++++++++++++++++++++++------------ pybop/costs/_weighted_cost.py | 12 ++++----- pybop/costs/base_cost.py | 30 ++++++++++++++-------- pybop/costs/design_costs.py | 10 ++++---- pybop/costs/fitting_costs.py | 46 +++++++++++++++++++++++---------- tests/unit/test_cost.py | 4 +-- 7 files changed, 99 insertions(+), 55 deletions(-) diff --git a/examples/standalone/cost.py b/examples/standalone/cost.py index 1103ea89..0ac68661 100644 --- a/examples/standalone/cost.py +++ b/examples/standalone/cost.py @@ -43,9 +43,9 @@ def __init__(self, problem=None): ) self.x0 = self.parameters.initial_value() - def _evaluate(self, inputs): + def compute(self, inputs): """ - Calculate the cost for a given parameter value. + Compute the cost for a given parameter value. The cost function is defined as cost(x) = x^2 + 42, where x is the parameter value. diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index fac5c195..bcc4c9b9 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -39,10 +39,13 @@ def __init__(self, problem: BaseProblem, sigma0: Union[list[float], float]): self._offset = -0.5 * self.n_time_data * np.log(2 * np.pi * self.sigma2) self._multip = -1 / (2.0 * self.sigma2) - def _evaluate(self, inputs: Inputs) -> float: + def compute(self, inputs: Inputs) -> float: """ - Evaluates the Gaussian log-likelihood for the given parameters with known sigma. + Compute the Gaussian log-likelihood for the given parameters with known sigma. + + This method only computes the likelihood, without calling the problem.evaluateS1. """ + if not self.verify_prediction(self.y): return -np.inf @@ -59,14 +62,16 @@ def _evaluate(self, inputs: Inputs) -> float: return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: + def computeS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calculates the log-likelihood and gradient. + Compute the Gaussian log-likelihood and it's gradient for the given parameters with known sigma. + + This method only computes the likelihood, without calling the problem.evaluateS1. """ if not self.verify_prediction(self.y): return -np.inf, -self._de * np.ones(self.n_parameters) - likelihood = self._evaluate(inputs) + likelihood = self.compute(inputs) r = np.asarray( [self._target[signal] - self.y[signal] for signal in self.signal] @@ -168,9 +173,11 @@ def dsigma_scale(self, new_value): raise ValueError("dsigma_scale must be non-negative") self._dsigma_scale = new_value - def _evaluate(self, inputs: Inputs) -> float: + def compute(self, inputs: Inputs) -> float: """ - Evaluates the Gaussian log-likelihood for the given parameters. + Compute the Gaussian log-likelihood for the given parameters. + + This method only computes the likelihood, without calling the problem.evaluateS1. Parameters ---------- @@ -207,9 +214,11 @@ def _evaluate(self, inputs: Inputs) -> float: return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: + def computeS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calculates the log-likelihood and sensitivities. + Compute the Gaussian log-likelihood and it's gradient for the given parameters. + + This method only computes the likelihood, without calling the problem.evaluateS1. Parameters ---------- @@ -231,7 +240,7 @@ def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: if not self.verify_prediction(self.y): return -np.inf, -self._de * np.ones(self.n_parameters) - likelihood = self._evaluate(inputs) + likelihood = self.compute(inputs) r = np.asarray( [self._target[signal] - self.y[signal] for signal in self.signal] @@ -281,14 +290,18 @@ def __init__(self, problem, likelihood, sigma0=None, gradient_step=1e-3): self.parameters = self.likelihood.parameters self._has_separable_problem = self.likelihood.has_separable_problem - def _evaluate(self, inputs: Inputs) -> float: + def compute(self, inputs: Inputs) -> float: """ - Calculate the maximum a posteriori cost for a given set of parameters. + Compute the Maximum a Posteriori for the given parameters. + + If self._has_separable_problem is True, then this method only computes the + likelihood, without calling the problem.evaluate(). Else, problem.evaluate() + is called before computing the likelihood. Parameters ---------- inputs : Inputs - The parameters for which to evaluate the cost. + The parameters for which to compute the cost. Returns ------- @@ -309,10 +322,13 @@ def _evaluate(self, inputs: Inputs) -> float: posterior = log_likelihood + log_prior return posterior - def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: + def computeS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Compute the maximum a posteriori with respect to the parameters. - The method passes the likelihood gradient to the optimiser without modification. + Compute the Maximum a Posteriori, and it's gradient for the given parameters. + + If self._has_separable_problem is True, then this method only computes the + likelihood, without calling the problem.evaluateS1(). Else, problem.evaluateS1() + is called before computing the likelihood. Parameters ---------- diff --git a/pybop/costs/_weighted_cost.py b/pybop/costs/_weighted_cost.py index 674c7276..a5848478 100644 --- a/pybop/costs/_weighted_cost.py +++ b/pybop/costs/_weighted_cost.py @@ -22,7 +22,7 @@ class WeightedCost(BaseCost): A list of values with which to weight the cost values. _has_identical_problems : bool If True, the shared problem will be evaluated once and saved before the - self._evaluate() method of each cost is called (default: False). + self.compute() method of each cost is called (default: False). _has_separable_problem: bool If True, the shared problem is seperable from the cost function and will be evaluated for each problem before the cost evaluation is @@ -80,9 +80,9 @@ def __init__(self, *costs, weights: Optional[list[float]] = None): # Weighted costs do not use this functionality self._has_separable_problem = False - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Calculate the weighted cost for a given set of parameters. + Compute the weighted cost for a given set of parameters. Parameters ---------- @@ -109,11 +109,11 @@ def _evaluate(self, inputs: Inputs): cost.y = cost.problem.evaluate( inputs, update_capacity=self.update_capacity ) - e[i] = cost._evaluate(inputs) + e[i] = cost.compute(inputs) return np.dot(e, self.weights) - def _evaluateS1(self, inputs: Inputs): + def computeS1(self, inputs: Inputs): """ Compute the weighted cost and its gradient with respect to the parameters. @@ -142,7 +142,7 @@ def _evaluateS1(self, inputs: Inputs): cost.y, cost.dy = (self.y, self.dy) elif cost.has_separable_problem: cost.y, cost.dy = cost.problem.evaluateS1(inputs) - e[i], de[:, i] = cost._evaluateS1(inputs) + e[i], de[:, i] = cost.computeS1(inputs) e = np.dot(e, self.weights) de = np.dot(de, self.weights) diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index 85206d3b..3448a7d5 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -24,7 +24,7 @@ class BaseCost: The number of outputs in the model. _has_separable_problem : bool If True, the problem is separable from the cost function and will be - evaluated in advance of the call to self._evaluate() (default: False). + evaluated in advance of the call to self.compute() (default: False). """ def __init__(self, problem: Optional[BaseProblem] = None): @@ -65,7 +65,8 @@ def __call__(self, inputs: Union[Inputs, list]): def evaluate(self, inputs: Union[Inputs, list]): """ - Call the evaluate function for a given set of parameters. + This method calls the forward model via problem.evaluateS1(inputs), + and computes the cost for the given output by calling self.computeS1(inputs). Parameters ---------- @@ -92,7 +93,7 @@ def evaluate(self, inputs: Union[Inputs, list]): inputs, update_capacity=self.update_capacity ) - return self._evaluate(inputs) + return self.compute(inputs) except NotImplementedError as e: raise e @@ -100,16 +101,17 @@ def evaluate(self, inputs: Union[Inputs, list]): except Exception as e: raise ValueError(f"Error in cost calculation: {e}") from e - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Calculate the cost function value for a given set of parameters. + Calculates the cost function value for a given set of parameters. + This method only computes the cost, without calling the problem.evaluate. This method must be implemented by subclasses. Parameters ---------- inputs : Inputs - The parameters for which to evaluate the cost. + The parameters for which to compute the cost. Returns ------- @@ -125,12 +127,17 @@ def _evaluate(self, inputs: Inputs): def evaluateS1(self, inputs: Union[Inputs, list]): """ - Call _evaluateS1 for a given set of parameters. + This method calls the forward model via problem.evaluateS1(inputs), + and computes the cost for the given output by calling self.computeS1(inputs). + + This method includes the gradient of the cost function computed by + calling self.computeS1(inputs) with the sensitivity information provided by + the forward model via problem.evaluateS1(inputs). Parameters ---------- inputs : Inputs or array-like - The parameters for which to compute the cost and gradient. + The parameters for which to evaluate the cost and gradient. Returns ------- @@ -151,7 +158,7 @@ def evaluateS1(self, inputs: Union[Inputs, list]): if self._has_separable_problem: self.y, self.dy = self.problem.evaluateS1(inputs) - return self._evaluateS1(inputs) + return self.computeS1(inputs) except NotImplementedError as e: raise e @@ -159,10 +166,13 @@ def evaluateS1(self, inputs: Union[Inputs, list]): except Exception as e: raise ValueError(f"Error in cost calculation: {e}") from e - def _evaluateS1(self, inputs: Inputs): + def computeS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1. + This method must be implemented by subclasses. + Parameters ---------- inputs : Inputs diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index f448fafa..c75921f7 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -78,11 +78,11 @@ class GravimetricEnergyDensity(DesignCost): def __init__(self, problem, update_capacity=False): super().__init__(problem, update_capacity) - self._fixed_problem = False # keep problem evaluation within _evaluate + self._fixed_problem = False # keep problem evaluation within compute - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Computes the cost function for the energy density. + Computes the cost function for the given parameters. Parameters ---------- @@ -118,9 +118,9 @@ class VolumetricEnergyDensity(DesignCost): def __init__(self, problem, update_capacity=False): super().__init__(problem, update_capacity) - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Computes the cost function for the energy density. + Computes the cost function for the given parameters. Parameters ---------- diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index 91214365..0b86f595 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -20,9 +20,11 @@ class RootMeanSquaredError(BaseCost): def __init__(self, problem): super().__init__(problem) - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Calculate the root mean square error for a given set of parameters. + Compute the cost and its gradient with respect to the parameters. + + This method only computes the cost, without calling the problem.evaluate(). Parameters ---------- @@ -50,10 +52,12 @@ def _evaluate(self, inputs: Inputs): return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs: Inputs): + def computeS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1(). + Parameters ---------- inputs : Inputs @@ -106,9 +110,11 @@ class SumSquaredError(BaseCost): def __init__(self, problem): super().__init__(problem) - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ - Calculate the sum of squared errors for a given set of parameters. + Compute the cost and its gradient with respect to the parameters. + + This method only computes the cost, without calling the problem.evaluate(). Parameters ---------- @@ -132,10 +138,12 @@ def _evaluate(self, inputs: Inputs): return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs: Inputs): + def computeS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1(). + Parameters ---------- inputs : Inputs @@ -205,9 +213,11 @@ def __init__(self, problem, p: float = 2.0): ) self.p = float(p) - def _evaluate(self, inputs: Inputs, grad=None): + def compute(self, inputs: Inputs, grad=None): """ - Calculate the Minkowski cost for a given set of parameters. + Compute the cost with respect to the parameters. + + This method only computes the cost, without calling the problem.evaluate(). Parameters ---------- @@ -232,10 +242,12 @@ def _evaluate(self, inputs: Inputs, grad=None): return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs): + def computeS1(self, inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1(). + Parameters ---------- inputs : Inputs @@ -312,9 +324,11 @@ def __init__(self, problem, p: float = 2.0): raise ValueError("p = np.inf is not yet supported.") self.p = float(p) - def _evaluate(self, inputs: Inputs, grad=None): + def compute(self, inputs: Inputs, grad=None): """ - Calculate the Sum of Power cost for a given set of parameters. + Compute the cost with respect to the parameters. + + This method only computes the cost, without calling the problem.evaluate(). Parameters ---------- @@ -338,10 +352,12 @@ def _evaluate(self, inputs: Inputs, grad=None): return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, inputs): + def computeS1(self, inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1(). + Parameters ---------- inputs : Inputs @@ -386,7 +402,7 @@ def __init__(self, observer: Observer): self._observer = observer self._has_separable_problem = False - def _evaluate(self, inputs: Inputs): + def compute(self, inputs: Inputs): """ Calculate the observer cost for a given set of parameters. @@ -408,10 +424,12 @@ def _evaluate(self, inputs: Inputs): ) return -log_likelihood - def _evaluateS1(self, inputs: Inputs): + def computeS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. + This method only computes the cost, without calling the problem.evaluateS1(). + Parameters ---------- inputs : Inputs diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 58e46247..8ceaefa0 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -127,10 +127,10 @@ def test_base(self, problem): @pytest.mark.unit def test_error_in_cost_calculation(self, problem): class RaiseErrorCost(pybop.BaseCost): - def _evaluate(self, inputs, grad=None): + def compute(self, inputs, grad=None): raise ValueError("Error test.") - def _evaluateS1(self, inputs): + def computeS1(self, inputs): raise ValueError("Error test.") cost = RaiseErrorCost(problem) From f20b4688ac2b276f10564ada4f950653135de3d2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 3 Aug 2024 11:46:45 +0100 Subject: [PATCH 3/8] fix: aligns boundaries usage across optimiser docstrings --- pybop/optimisers/pints_optimisers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 83f13aa0..c030c2a4 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -17,8 +17,9 @@ class GradientDescent(BasePintsOptimiser): Implements a simple gradient descent optimization algorithm. This class extends the gradient descent optimiser from the PINTS library, designed - to minimize a scalar function of one or more variables. Note that this optimiser - does not support boundary constraints. + to minimize a scalar function of one or more variables. + + Note that this optimiser does not support boundary constraints. Parameters ---------- @@ -45,8 +46,9 @@ class Adam(BasePintsOptimiser): Implements the Adam optimization algorithm. This class extends the Adam optimiser from the PINTS library, which combines - ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that - this optimiser does not support boundary constraints. + ideas from RMSProp and Stochastic Gradient Descent with momentum. + + Note that this optimiser does not support boundary constraints. Parameters ---------- @@ -81,6 +83,7 @@ class AdamW(BasePintsOptimiser): robust and stable for training deep neural networks, particularly when using larger learning rates. + Note that this optimiser does not support boundary constraints. Parameters ---------- **optimiser_kwargs : optional @@ -223,6 +226,8 @@ class NelderMead(BasePintsOptimiser): either one evaluation, or two sequential evaluations, so that it will not typically benefit from parallelisation. + Note that this optimiser does not support boundary constraints. + Parameters ---------- **optimiser_kwargs : optional From c2dd48052f3fc09312cce77678682d701aaf9a91 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 5 Aug 2024 09:58:03 +0100 Subject: [PATCH 4/8] Increase coverage, fix missing property+setter, move method --- pybop/models/base_model.py | 47 ++++++++++++++++++++++---------------- tests/unit/test_models.py | 18 +++++++++++---- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index 59affc2d..6fd41faf 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -598,6 +598,26 @@ def copy(self): """ return copy.copy(self) + def get_parameter_info(self, print_info: bool = False): + """ + Extracts the parameter names and types and returns them as a dictionary. + """ + if not self.pybamm_model._built: # noqa: SLF001 + self.pybamm_model.build_model() + + info = self.pybamm_model.get_parameter_info() + + reduced_info = dict() + for param, param_type in info.values(): + param_name = getattr(param, "name", str(param)) + reduced_info[param_name] = param_type + + if print_info: + for param, param_type in info.values(): + print(param, " : ", param_type) + + return reduced_info + def cell_mass(self, parameter_set: ParameterSet = None): """ Calculate the cell mass in kilograms. @@ -663,9 +683,16 @@ def built_model(self): def built_model(self, built_model): self._built_model = built_model if built_model is not None else None + @property def built_initial_soc(self): return self._built_initial_soc + @built_initial_soc.setter + def built_initial_soc(self, built_initial_soc): + self._built_initial_soc = ( + built_initial_soc if built_initial_soc is not None else None + ) + @property def parameter_set(self): return self._parameter_set @@ -743,23 +770,3 @@ def solver(self): @solver.setter def solver(self, solver): self._solver = solver.copy() if solver is not None else None - - def get_parameter_info(self, print_info: bool = False): - """ - Extracts the parameter names and types and returns them as a dictionary. - """ - if not self.pybamm_model._built: # noqa: SLF001 - self.pybamm_model.build_model() - - info = self.pybamm_model.get_parameter_info() - - reduced_info = dict() - for param, param_type in info.values(): - param_name = getattr(param, "name", str(param)) - reduced_info[param_name] = param_type - - if print_info: - for param, param_type in info.values(): - print(param, " : ", param_type) - - return reduced_info diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index fe038248..9f7854b1 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -131,12 +131,20 @@ def test_predict_without_allow_infeasible_solutions(self, model): @pytest.mark.unit def test_build(self, model): - model.build() - assert model.built_model is not None + if isinstance(model, pybop.lithium_ion.SPMe): + model.build(init_soc=1.0) - # Test that the model can be built again - model.build() - assert model.built_model is not None + # Test attributes with init_soc + assert model.built_model is not None + assert model.disc is not None + assert model.built_initial_soc is not None + else: + model.build() + assert model.built_model is not None + + # Test that the model can be built again + model.build() + assert model.built_model is not None @pytest.mark.unit def test_rebuild(self, model): From cb84cdcfca93f8e862c915ab2f272649bcce7347 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 5 Aug 2024 10:44:47 +0100 Subject: [PATCH 5/8] refactor: optim.iterations -> optim.result.n_iterations --- examples/notebooks/optimiser_calibration.ipynb | 2 +- pybop/optimisers/base_pints_optimiser.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index ca3bdd59..d8467fd2 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -482,7 +482,7 @@ "source": [ "for optim, sigma in zip(optims, sigmas):\n", " print(\n", - " f\"| Sigma: {sigma} | Num Iterations: {optim.iterations} | Best Cost: {optim.pints_optimiser.f_best()} | Results: {optim.pints_optimiser.x_best()} |\"\n", + " f\"| Sigma: {sigma} | Num Iterations: {optim.result.n_iterations} | Best Cost: {optim.pints_optimiser.f_best()} | Results: {optim.pints_optimiser.x_best()} |\"\n", " )" ] }, diff --git a/pybop/optimisers/base_pints_optimiser.py b/pybop/optimisers/base_pints_optimiser.py index a9058944..8bf856c4 100644 --- a/pybop/optimisers/base_pints_optimiser.py +++ b/pybop/optimisers/base_pints_optimiser.py @@ -505,7 +505,3 @@ def set_threshold(self, threshold=None): self._threshold = None else: self._threshold = float(threshold) - - @property - def iterations(self): - return self._iterations From 868aaabcea8d1f5b48d5c54c95448a52d98cf384 Mon Sep 17 00:00:00 2001 From: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:52:57 +0100 Subject: [PATCH 6/8] Apply suggestions from code review Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> --- CONTRIBUTING.md | 2 ++ pybop/transformation/transformations.py | 2 +- tests/unit/test_transformations.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01c2312c..274ba954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,8 @@ ruff is configured inside the file `pyproject.toml`, allowing us to ignore some When you commit your changes they will be checked against ruff automatically (see [Pre-commit checks](#pre-commit-checks)). If you are having issues getting your commit to pass the linting, it is possible to skip linting for single lines (this should only be done as a **last resort**) by adding a line comment of `#noqa: $ruff_rule` where the `$ruff_rule` is replaced with the rule in question. +It is also possible to skip linting altogether by committing your changes by using the +`--no-verify` command-line flag. These rules can be found in the ruff configuration in `pyproject.toml` or in the failed pre-commit output. Please note the lint skipping in the pull request for reviewers. ### Naming diff --git a/pybop/transformation/transformations.py b/pybop/transformation/transformations.py index 97fed769..e6d6b1d9 100644 --- a/pybop/transformation/transformations.py +++ b/pybop/transformation/transformations.py @@ -109,7 +109,7 @@ def jacobian_S1(self, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def log_jacobian_det(self, q: np.ndarray) -> float: """See :meth:`Transformation.log_jacobian_det()`.""" - return np.sum(np.log(np.abs(self.coefficient))) + return np.log(np.abs(self.coefficient)).sum() def log_jacobian_det_S1(self, q: np.ndarray) -> tuple[float, np.ndarray]: """See :meth:`Transformation.log_jacobian_det_S1()`.""" diff --git a/tests/unit/test_transformations.py b/tests/unit/test_transformations.py index baf4eee3..622f1006 100644 --- a/tests/unit/test_transformations.py +++ b/tests/unit/test_transformations.py @@ -158,7 +158,7 @@ def test_composed_transformation(self, parameters): ) @pytest.mark.unit - def testverify_input(self, parameters): + def test_verify_input(self, parameters): q = np.asarray([5.0]) q_dict = {"Identity": q[0]} transformation = parameters["Scaled"].transformation From d9a4cfe8bbd8032cd5f052bda658dbf037de010a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 7 Aug 2024 09:37:05 +0100 Subject: [PATCH 7/8] fix: remove BaseModel attr setters where currently possible. --- examples/standalone/model.py | 10 +++++----- pybop/models/base_model.py | 20 ++------------------ pybop/models/empirical/base_ecm.py | 8 ++++---- pybop/models/lithium_ion/base_echem.py | 16 ++++++++-------- 4 files changed, 19 insertions(+), 35 deletions(-) diff --git a/examples/standalone/model.py b/examples/standalone/model.py index f5d5a7ab..82a9f2d8 100644 --- a/examples/standalone/model.py +++ b/examples/standalone/model.py @@ -48,11 +48,11 @@ def __init__( self._parameter_set = self.default_parameter_values self._unprocessed_parameter_set = self._parameter_set - self.geometry = self.pybamm_model.default_geometry - self.submesh_types = self.pybamm_model.default_submesh_types - self.var_pts = self.pybamm_model.default_var_pts - self.spatial_methods = self.pybamm_model.default_spatial_methods - self.solver = pybamm.CasadiSolver(mode="fast") + self._geometry = self.pybamm_model.default_geometry + self._submesh_types = self.pybamm_model.default_submesh_types + self._var_pts = self.pybamm_model.default_var_pts + self._spatial_methods = self.pybamm_model.default_spatial_methods + self._solver = pybamm.CasadiSolver(mode="fast") self._model_with_set_params = None self._built_model = None self._built_initial_soc = None diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index e436aa23..c273e8d8 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -1,6 +1,6 @@ import copy from dataclasses import dataclass -from typing import Any, Optional, Union +from typing import Optional, Union import casadi import numpy as np @@ -283,7 +283,7 @@ def classify_and_update_parameters(self, parameters: Parameters): if self.rebuild_parameters: self._parameter_set.update(self.rebuild_parameters) self._unprocessed_parameter_set = self._parameter_set - self.geometry = self.pybamm_model.default_geometry + self._geometry = self.pybamm_model.default_geometry # Update the list of parameter names and number of parameters self._n_parameters = len(self.parameters) @@ -734,20 +734,10 @@ def model_with_set_params(self, model_with_set_params): def geometry(self): return self._geometry - @geometry.setter - def geometry(self, geometry: Optional[pybamm.Geometry]): - self._geometry = geometry.copy() if geometry is not None else None - @property def submesh_types(self): return self._submesh_types - @submesh_types.setter - def submesh_types(self, submesh_types: Optional[dict[str, Any]]): - self._submesh_types = ( - submesh_types.copy() if submesh_types is not None else None - ) - @property def mesh(self): return self._mesh @@ -776,12 +766,6 @@ def var_pts(self, var_pts: Optional[dict[str, int]]): def spatial_methods(self): return self._spatial_methods - @spatial_methods.setter - def spatial_methods(self, spatial_methods: Optional[dict[str, Any]]): - self._spatial_methods = ( - spatial_methods.copy() if spatial_methods is not None else None - ) - @property def solver(self): return self._solver diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py index 82ed3020..87f3d4fe 100644 --- a/pybop/models/empirical/base_ecm.py +++ b/pybop/models/empirical/base_ecm.py @@ -71,10 +71,10 @@ def __init__( self._unprocessed_parameter_set = self._parameter_set # Define model geometry and discretization - self.geometry = geometry or self.pybamm_model.default_geometry - self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types - self.var_pts = var_pts or self.pybamm_model.default_var_pts - self.spatial_methods = ( + self._geometry = geometry or self.pybamm_model.default_geometry + self._submesh_types = submesh_types or self.pybamm_model.default_submesh_types + self._var_pts = var_pts or self.pybamm_model.default_var_pts + self._spatial_methods = ( spatial_methods or self.pybamm_model.default_spatial_methods ) self.solver = solver or self.pybamm_model.default_solver diff --git a/pybop/models/lithium_ion/base_echem.py b/pybop/models/lithium_ion/base_echem.py index 71f803c0..d303a06a 100644 --- a/pybop/models/lithium_ion/base_echem.py +++ b/pybop/models/lithium_ion/base_echem.py @@ -62,18 +62,18 @@ def __init__( self._unprocessed_parameter_set = self._parameter_set # Define model geometry and discretization - self.geometry = geometry or self.pybamm_model.default_geometry - self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types - self.var_pts = var_pts or self.pybamm_model.default_var_pts - self.spatial_methods = ( + self._geometry = geometry or self.pybamm_model.default_geometry + self._submesh_types = submesh_types or self.pybamm_model.default_submesh_types + self._var_pts = var_pts or self.pybamm_model.default_var_pts + self._spatial_methods = ( spatial_methods or self.pybamm_model.default_spatial_methods ) if solver is None: - self.solver = self.pybamm_model.default_solver - self.solver.mode = "fast with events" - self.solver.max_step_decrease_count = 1 + self._solver = self.pybamm_model.default_solver + self._solver.mode = "fast with events" + self._solver.max_step_decrease_count = 1 else: - self.solver = solver + self._solver = solver # Internal attributes for the built model are initialized but not set self._model_with_set_params = None From d68a70d98dba512e6dfed26db33f6d9c1f00732c Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 7 Aug 2024 11:59:09 +0100 Subject: [PATCH 8/8] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6d4f96..c9fb4155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#435](https://github.com/pybop-team/PyBOP/pull/435) - Adds SLF001 linting for private members. - [#418](https://github.com/pybop-team/PyBOP/issues/418) - Wraps the `get_parameter_info` method from PyBaMM to get a dictionary of parameter names and types. - [#413](https://github.com/pybop-team/PyBOP/pull/413) - Adds `DesignCost` functionality to `WeightedCost` class with additional tests. - [#357](https://github.com/pybop-team/PyBOP/pull/357) - Adds `Transformation()` class with `LogTransformation()`, `IdentityTransformation()`, and `ScaledTransformation()`, `ComposedTransformation()` implementations with corresponding examples and tests.