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)