Source code for pybop._result

import pickle
import types

import numpy as np

from pybop import plot
from pybop._logging import Logger
from pybop._utils import load_data_dict, save_data_dict
from pybop.problems.problem import Problem


[docs] class Result: """ Stores the result produced by an optimiser or sampler. Attributes ---------- problem : pybop.Problem The optimisation problem used to generate the results. logger : pybop.Logger The log of the optimisation or sampling process. time : float The time taken. method_name : str The name of the optimiser or sampler. message : str The reason for stopping given by the optimiser or sampler. """ def __init__( self, problem: Problem, logger: Logger, time: float, method_name: str | None = None, message: str | None = None, scipy_result=None, ): self._problem = problem self._minimising = problem.minimising self.method_name = method_name self.n_runs = 1 self._best_run = None self._x = [logger.x_model_best] self._x_model = [logger.x_model] self._x0 = [logger.x0] self._best_cost = [logger.cost_best] self._cost = [logger.cost_convergence] self._initial_cost = [logger.cost[0]] self._n_iterations = [logger.iteration] self._iteration_number = [logger.iteration_number] self._n_evaluations = [logger.evaluations] self._message = [message] self._scipy_result = [scipy_result] self._time = [time] self._validate()
[docs] @staticmethod def combine(results: list["Result"]) -> "Result": """ Combine multiple Result objects into a single one. Parameters ---------- results : list[Result] List of Result objects to combine. Returns ------- Result Combined Result object. """ if len(results) == 0: raise ValueError("No results to combine.") ret = results[0] ret._x = [x for result in results for x in result._x] # noqa: SLF001 ret._x_model = [x for result in results for x in result._x_model] # noqa: SLF001 ret._x0 = [x for result in results for x in result._x0] # noqa: SLF001 ret._best_cost = [ # noqa: SLF001 x for result in results for x in result._best_cost # noqa: SLF001 ] ret._cost = [x for result in results for x in result._cost] # noqa: SLF001 ret._initial_cost = [ # noqa: SLF001 x for result in results for x in result._initial_cost # noqa: SLF001 ] ret._n_iterations = [ # noqa: SLF001 x for result in results for x in result._n_iterations # noqa: SLF001 ] ret._iteration_number = [ # noqa: SLF001 x for result in results for x in result._iteration_number # noqa: SLF001 ] ret._n_evaluations = [ # noqa: SLF001 x for result in results for x in result._n_evaluations # noqa: SLF001 ] ret._message = [ # noqa: SLF001 x for result in results for x in result._message # noqa: SLF001 ] ret._scipy_result = [ # noqa: SLF001 x for result in results for x in result._scipy_result # noqa: SLF001 ] ret._time = [x for result in results for x in result._time] # noqa: SLF001 ret._best_run = None # noqa: SLF001 ret.n_runs = len(results) ret._validate() # noqa: SLF001 return ret
[docs] def _validate(self): """Check that there is a finite cost and update best run.""" self._check_for_finite_cost() if self._minimising: self._best_run = self._best_cost.index(min(self._best_cost)) else: self._best_run = self._best_cost.index(max(self._best_cost))
[docs] def _check_for_finite_cost(self) -> None: """ Validate the optimised parameters and ensure they produce a finite cost value. Raises: ValueError: If the optimised parameters do not produce a finite cost value. """ if not any(np.isfinite(self._best_cost)): raise ValueError( f"Optimised parameters {self._problem.parameters.to_dict(self._x[-1])} do not produce a finite cost value." )
[docs] def __str__(self) -> str: """ A string representation of the Result object. Returns: str: A formatted string containing optimisation result information. """ return ( f"Result:\n" f" Best result from {self.n_runs} run(s).\n" f" Initial parameters: {self.x0}\n" f" Optimised parameters: {self.x}\n" f" Best cost: {self.best_cost}\n" f" Optimisation time: {self.time} seconds\n" f" Number of iterations: {self.total_iterations()}\n" f" Number of evaluations: {self.total_evaluations()}\n" f" Reason for stopping: {self.message}" )
[docs] def total_iterations(self) -> np.floating | None: """Calculates the total number of iterations across all runs.""" return np.sum(self._n_iterations) if len(self._n_iterations) > 0 else None
[docs] def total_evaluations(self) -> np.floating | None: """Calculates the total number of evaluations across all runs.""" return np.sum(self._n_evaluations) if len(self._n_evaluations) > 0 else None
[docs] def total_runtime(self) -> np.floating | None: """Calculates the total runtime across all runs.""" return np.sum(self._time) if len(self._time) > 0 else None
[docs] def _get_single_or_all(self, attr): value = getattr(self, attr) if len(value) > 1: return value[self._best_run] return value[0]
@property def x(self) -> np.ndarray: """The best parameter values (in model space).""" return self._get_single_or_all("_x") @property def x_model(self) -> np.ndarray: """The log of the evaluated parameters (in model space).""" return self._get_single_or_all("_x_model") @property def x0(self) -> np.ndarray: """The initial parameter values.""" return self._get_single_or_all("_x0") @property def best_inputs(self) -> dict[str, np.ndarray]: """The best parameters as a dictionary.""" return self._problem.parameters.to_dict(self.x) @property def best_cost(self) -> float: """The best cost value(s).""" return self._get_single_or_all("_best_cost") @property def cost(self) -> np.ndarray: """The log of the cost values.""" return self._get_single_or_all("_cost") @property def initial_cost(self) -> float: """The initial cost value(s).""" return self._get_single_or_all("_initial_cost") @property def n_iterations(self) -> int: """The number of iterations.""" return self._get_single_or_all("_n_iterations") @property def iteration_number(self) -> np.ndarray | None: """The number of iterations.""" return self._get_single_or_all("_iteration_number") @property def n_evaluations(self) -> int: """The number of evaluations.""" return self._get_single_or_all("_n_evaluations") @property def problem(self) -> Problem: """The optimisation problem.""" return self._problem @property def minimising(self) -> bool: """Whether the cost was minimised (or maximised).""" return self._minimising @property def message(self) -> str | None: """The optimisation termination message(s).""" return self._get_single_or_all("_message") @property def scipy_result(self): """The SciPy result.""" return self._get_single_or_all("_scipy_result") @property def time(self) -> float | None: """The optimisation time(s).""" return self.total_runtime()
[docs] def plot_convergence(self, **kwargs): """ Plot the evolution of the best cost during the optimisation. Parameters ---------- show : bool, optional If True, the figure is shown upon creation (default: True). **layout_kwargs : optional Valid Plotly layout keys and their values. """ return plot.convergence(result=self, **kwargs)
[docs] def plot_parameters(self, **kwargs): """ Plot the evolution of parameter values during the optimisation. Parameters ---------- show : bool, optional If True, the figure is shown upon creation (default: True). **layout_kwargs : optional Valid Plotly layout keys and their values. """ return plot.parameters(result=self, **kwargs)
[docs] def plot_surface(self, **kwargs): """ Plot a 2D representation of the Voronoi diagram with color-coded regions. Parameters ---------- bounds : numpy.ndarray, optional A 2x2 array specifying the [min, max] bounds for each parameter. normalise : bool, optional If True, the voronoi regions are computed using the Euclidean distance between points normalised with respect to the bounds (default: True). resolution : int, optional Resolution of the plot (default: 500). show : bool, optional If True, the figure is shown upon creation (default: True). **layout_kwargs : optional Valid Plotly layout keys and their values. """ return plot.surface(result=self, **kwargs)
[docs] def plot_contour(self, **kwargs): """ Generate and plot a 2D visualisation of the cost landscape with the optimisation trace. Parameters ---------- gradient : bool, optional If True, gradient plots are also generated (default: False). bounds : numpy.ndarray | list[list[float]], optional A 2x2 array specifying the [min, max] bounds for each parameter. transformed : bool, optional Uses the transformed parameter values, as seen by the optimiser (default: False). steps : int, optional The number of grid points to divide the parameter space into along each dimension (default: 10). show : bool, optional If True, the figure is shown upon creation (default: True). **layout_kwargs : optional Valid Plotly layout keys and their values. """ return plot.contour(call_object=self, **kwargs)
[docs] def save(self, filename) -> None: """Save the whole result using pickle""" with open(filename, "wb") as f: pickle.dump(self, f, pickle.HIGHEST_PROTOCOL)
[docs] @staticmethod def load(filename: str) -> "Result": """Load a saved Result.""" with open(filename, "rb") as f: result = pickle.load(f) return result
[docs] def data_dict(self) -> dict: """return result data as dictionary for saving to file""" return { "minimising": self._minimising, "method_name": self.method_name, "n_runs": self.n_runs, "best_run": self._best_run, "x": self._x, "x_model": self._x_model, "x0": self._x0, "best_cost": self._best_cost, "cost": self._cost, "initial_cost": self._initial_cost, "n_iterations": self._n_iterations, "iteration_number": self._iteration_number, "n_evaluations": self._n_evaluations, "message": self._message, "scipy_result": [0 if x is None else x for x in self._scipy_result], "time": self._time, }
[docs] def save_data( self, filename=None, to_format="pickle", ) -> str | None: """ Save result data Based on pybamm.Solution.save_data Parameters ---------- filename : str, optional The name of the file to save data to. If None, then a str is returned for json format or an error is thrown for pickle/matlab. to_format : str, optional The format to save to. Options are: - 'pickle' (default): creates a pickle file with the data dictionary - 'matlab': creates a .mat file, for loading in matlab - 'json': creates a json file Returns ------- data : str, optional str if 'json' is chosen and filename is None, otherwise None """ data = self.data_dict() return save_data_dict(data, filename=filename, to_format=to_format)
[docs] @staticmethod def load_data(filename: str, file_format: str = "pickle") -> dict: """ Load results data as dictionary from a given file. Restores data saved with save_data. Calls load_data_dict defined in _utils.py and provides the keys of data that is 0-d and 1-d to ensure consistent data dimensions. Parameters ---------- filename : str The name of the file containing the data. file_format : str, optional The format the data was save to. Options are: - 'pickle' (default) - 'matlab' - 'csv' - 'json' Returns ------- data_dict : python dictionary containing the data in the file. """ data = load_data_dict( filename, file_format=file_format, data_keys_0d=["_minimising", "n_runs", "best_run"], data_keys_1d=[ "method_name", "best_cost", "initial_cost", "n_iterations", "n_evaluations", "message", "scipy_result", "time", ], ) # Create a dummy problem problem = types.SimpleNamespace() problem.minimising = data["minimising"] # Create one logging result for each run n_runs = data["n_runs"] list_of_results = [] for i in range(n_runs): # Create a dummy logger logger = types.SimpleNamespace() for logger_key, result_key in [ ("x_model_best", "x"), ("x_model", "x_model"), ("x0", "x0"), ("cost_best", "best_cost"), ("cost_convergence", "cost"), ("iteration", "n_iterations"), ("iteration_number", "iteration_number"), ("evaluations", "n_evaluations"), ]: setattr(logger, logger_key, data[result_key][i]) logger.cost = [data["initial_cost"][i]] list_of_results.append( Result( problem=problem, logger=logger, time=data["time"][i], method_name=data["method_name"], message=data["message"][i], scipy_result=data["scipy_result"][i] if data["scipy_result"][i] != 0 else None, ) ) return Result.combine(results=list_of_results)