Source code for pybop.parameters.parameter

from __future__ import annotations

from collections import OrderedDict
from collections.abc import Iterator, Sequence
from copy import deepcopy
from dataclasses import dataclass
from typing import Any

import numpy as np
from numpy.typing import NDArray

from pybop.parameters.priors import BasePrior, Uniform
from pybop.transformation.base_transformation import Transformation
from pybop.transformation.transformations import (
    ComposedTransformation,
    IdentityTransformation,
    LogTransformation,
)

# Type aliases
[docs] NumericValue = float | int | np.number
[docs] ArrayLike = Sequence[NumericValue] | NDArray[np.floating]
[docs] BoundsPair = list[float]
[docs] Inputs = dict[str, float]
[docs] class ParameterError(Exception): """Base exception for parameter-related errors.""" pass
[docs] class ParameterValidationError(ParameterError): """Raised when parameter validation fails.""" pass
[docs] class ParameterNotFoundError(ParameterError): """Raised when a parameter is not found.""" pass
@dataclass(frozen=True)
[docs] class Bounds: """ Immutable bounds representation with validation. Attributes ---------- lower : float Lower bound (inclusive) upper : float Upper bound (inclusive) """
[docs] lower: float
[docs] upper: float
[docs] def __post_init__(self) -> None: if self.lower >= self.upper: raise ParameterValidationError( f"Lower bound ({self.lower}) must be less than upper bound ({self.upper})" )
[docs] def contains(self, value: NumericValue) -> bool: """Check if value is within bounds.""" return self.lower <= value <= self.upper
[docs] def contains_array(self, values: ArrayLike) -> bool: """Check if all values in array are within bounds.""" arr = np.asarray(values) return bool(np.all((arr >= self.lower) & (arr <= self.upper)))
[docs] def clip(self, value: NumericValue) -> float: """Clip value to bounds.""" return float(np.clip(value, self.lower, self.upper))
[docs] def clip_array(self, values: ArrayLike) -> NDArray[np.floating]: """Clip array values to bounds.""" return np.clip(values, self.lower, self.upper)
[docs] def width(self) -> float: """Return the width of the bounds.""" return self.upper - self.lower
[docs] class Parameter: """ Represents a parameter within the PyBOP framework. This class encapsulates the definition of a parameter, including its name, prior distribution, initial value, bounds, and a margin to ensure the parameter stays within feasible limits during optimisation or sampling. Parameters ---------- initial_value : NumericValue, optional Initial parameter value bounds : tuple[float, float], optional Parameter bounds as (lower, upper) prior : pybop.BasePrior, optional Prior distribution object transformation : Transformation, optional Parameter transformation margin : float, default=1e-4 Safety margin for bounds sampling """ def __init__( self, *, initial_value: float = None, bounds: BoundsPair | None = None, prior: BasePrior | None = None, transformation: Transformation | None = None, margin: float = 1e-4, ) -> None:
[docs] self._prior = prior
[docs] self._transformation = transformation or IdentityTransformation()
# Set bounds with validation
[docs] self._bounds: Bounds | None = None
if bounds is not None: self._bounds = Bounds(bounds[0], bounds[1]) if self._prior is None and all(np.isfinite(np.asarray(bounds))): self._prior = Uniform(bounds[0], bounds[1]) self._set_margin(margin) # Validate and set values if initial_value is None and self._prior is not None: initial_value = self.sample_from_prior()[0]
[docs] self._initial_value = ( float(initial_value) if initial_value is not None else None )
# Validate initial values are within bounds self._validate_values_within_bounds()
[docs] def sample_from_prior( self, n_samples: int = 1, *, random_state: int | None = None, transformed: bool = False, ) -> NDArray[np.floating] | None: """ Sample from parameter's prior distribution. Parameters ---------- n_samples : int Number of samples to draw (default: 1). random_state : int, optional Random seed for reproducibility. transformed : bool Whether to apply transformation to samples (default: False). Returns ------- NDArray[np.floating] or None Array of samples, or None if no prior exists """ if self._prior is None: return None samples = self._prior.rvs(n_samples, random_state=random_state) samples = np.atleast_1d(samples).astype(float) # Apply bounds clipping if bounds exist if self._bounds is not None: offset = self._margin * self._bounds.width() effective_lower = self._bounds.lower + offset effective_upper = self._bounds.upper - offset samples = np.clip(samples, effective_lower, effective_upper) if transformed: samples = np.array([self._transformation.to_search(s)[0] for s in samples]) return samples
[docs] def update_initial_value(self, value: NumericValue) -> None: """ Update the initial parameter value. Parameters ---------- value : NumericValue New initial value """ self._initial_value = float(value)
[docs] def __repr__(self) -> str: """String representation of the parameter.""" return f"Parameter: Prior: {self.prior} \n Bounds: {self.bounds}"
[docs] def _set_margin(self, margin: float) -> None: """ Set the margin to a specified positive value less than 1. The margin is used to ensure parameter samples are not drawn exactly at the bounds, which may be problematic in some optimization or sampling algorithms. """ if not 0 < margin < 1: raise ParameterValidationError("Margin must be between 0 and 1") self._margin = margin
[docs] def set_bounds(self, bounds: BoundsPair) -> None: """ Set new parameter bounds. Parameters ---------- bounds : tuple[float, float] New bounds as (lower, upper) """ if bounds is None or ( not np.isfinite(bounds[0]) and not np.isfinite(bounds[1]) ): self._bounds = None else: self._bounds = Bounds(bounds[0], bounds[1])
[docs] def _validate_values_within_bounds(self) -> None: """Validate that initial values are within bounds.""" if self._bounds is None or self._initial_value is None: return if not self._bounds.contains(self._initial_value): raise ParameterValidationError( f"Initial value {self._initial_value} is outside bounds {self.bounds}" )
[docs] def get_initial_value_transformed(self) -> NDArray | None: """Get initial value in transformed space.""" if self._initial_value is None: return None return self._transformation.to_search(self._initial_value)[0]
[docs] def __call__(self, *unused_args, **unused_kwargs) -> float: "Return the current value. The unused arguments are to pass pybamm.ParameterValues checks." return self._current_value
@property
[docs] def initial_value(self) -> float: return self._initial_value
@property
[docs] def bounds(self) -> BoundsPair | None: """Parameter bounds as (lower, upper) tuple.""" return ( None if self._bounds is None else [self._bounds.lower, self._bounds.upper] )
@property
[docs] def prior(self) -> Any | None: return self._prior
@property
[docs] def transformation(self) -> Transformation: return self._transformation
[docs] def __hash__(self) -> int: """Hash based on name.""" return hash(self._name)
[docs] class Parameters: """ Container for managing multiple Parameter objects with additional functionality. This class provides a comprehensive interface for parameter management including validation, transformation, serialisation, and bulk operations. """ def __init__(self, parameters: dict | Parameters = None) -> None: if parameters is None: parameters = {} elif not isinstance(parameters, (dict, Parameters)): raise TypeError( "parameters must be either a dictionary or a pybop.Parameters instance" )
[docs] self._parameters = OrderedDict()
for name, param in parameters.items(): self._add(name, param, update_transform=False)
[docs] self._transform = self.construct_transformation()
[docs] def __getitem__(self, name: str) -> Parameter: return self.get(name)
[docs] def __len__(self) -> int: return len(self._parameters)
[docs] def keys(self) -> Iterator[str]: """Iterate over parameter names.""" return iter(self._parameters.keys())
@property
[docs] def names(self) -> list[str]: return list(self._parameters.keys())
[docs] def __iter__(self) -> Iterator[Parameter]: return iter(self._parameters.values())
[docs] def add(self, name: str, parameter: Parameter) -> None: """Add a parameter to the collection.""" self._add(name, parameter)
[docs] def _add( self, name: str, parameter: Parameter, update_transform: bool = True ) -> None: """ Internal method to add a parameter to the collection. Parameters ---------- parameter : Parameter Parameter to add update_transform : bool, optional Whether to update the transformation after adding (default: True) """ if not isinstance(parameter, Parameter): raise TypeError("Expected Parameter instance") if name in self._parameters: raise ParameterError(f"Parameter '{name}' already exists") self._parameters[name] = parameter if update_transform: self._transform = self.construct_transformation()
[docs] def remove(self, name: str) -> Parameter: """Remove parameter and return it.""" if not isinstance(name, str): raise TypeError("The input name is not a string.") if name not in self._parameters: raise ParameterNotFoundError(f"Parameter '{name}' not found") return self._parameters.pop(name)
[docs] def join(self, parameters=None): """ Join two Parameters objects into the first by copying across each Parameter. Parameters ---------- parameters : pybop.Parameters """ for name, param in parameters.items(): if name not in self._parameters.keys(): self.add(name, param) else: print(f"Discarding duplicate {name}.")
[docs] def get(self, name: str) -> Parameter: """Get a parameter by name.""" if name not in self._parameters: raise ParameterNotFoundError(f"Parameter '{name}' not found") return self._parameters[name]
[docs] def get_bounds(self, transformed: bool = False) -> dict: """ Get bounds, for either all or no parameters. Parameters ---------- transformed : bool If True, the transformation is applied to the output (default: False). """ bounds = {"lower": [], "upper": []} for param in self._parameters.values(): lower, upper = param.bounds or (-np.inf, np.inf) if ( transformed and param.bounds is not None and param.transformation is not None ): if isinstance(param.transformation, LogTransformation) and lower == 0: bound_one = -np.inf else: bound_one = float(param.transformation.to_search(lower)[0]) bound_two = float(param.transformation.to_search(upper)[0]) if np.isnan(bound_one) or np.isnan(bound_two): raise ValueError("Transformed bounds resulted in NaN values.") lower = np.minimum(bound_one, bound_two) upper = np.maximum(bound_one, bound_two) bounds["lower"].append(lower) bounds["upper"].append(upper) return bounds
[docs] def get_bounds_array(self, transformed: bool = False) -> np.ndarray: """ Retrieve parameter bounds in numpy format. Returns ------- bounds : numpy.ndarray An array of shape (n_parameters, 2) containing the bounds for each parameter. """ bounds = self.get_bounds(transformed=transformed) return np.column_stack([bounds["lower"], bounds["upper"]])
[docs] def update( self, *, initial_values: ArrayLike | Inputs | None = None, bounds: Sequence[BoundsPair] | dict[str, BoundsPair] | None = None, **individual_updates: dict[str, Any], ) -> None: """ Update multiple parameters efficiently. Parameters ---------- initial_values : array-like or dict, optional New initial values (by position or name) bounds : sequence or dict, optional New bounds (by position or name) **individual_updates : dict Individual parameter updates with parameter names as keys """ # Handle individual parameter updates for param_name, updates in individual_updates.items(): param = self.get(param_name) # Raises if not found if isinstance(updates, dict): if "initial_value" in updates: param.update_initial_value(updates["initial_value"]) if "bounds" in updates: param.set_bounds(updates["bounds"]) # Handle bulk updates if initial_values is not None: self._bulk_update_initial_values(initial_values) if bounds is not None: # Allow conversion from get_bounds output type to Sequence[BoundsPair] type if isinstance(bounds, dict) and "upper" in bounds.keys(): converted_bounds = [] for i in range(len(bounds["lower"])): converted_bounds.append([bounds["lower"][i], bounds["upper"][i]]) bounds = converted_bounds self._bulk_update_bounds(bounds)
[docs] def remove_bounds(self) -> None: for param in self._parameters.values(): param.set_bounds(None)
[docs] def _bulk_update_initial_values(self, values: ArrayLike | Inputs) -> None: """Update initial values in bulk.""" if isinstance(values, dict): for name, value in values.items(): self.get(name).update_initial_value(value) else: values_array = np.atleast_1d(values) param_list = list(self._parameters.values()) if len(values_array) != len(param_list): raise ParameterValidationError( f"Values array length {len(values_array)} doesn't match " f"parameter count {len(param_list)}" ) for param, value in zip(param_list, values_array, strict=False): param.update_initial_value(value)
[docs] def _bulk_update_bounds( self, bounds: Sequence[BoundsPair] | dict[str, BoundsPair] ) -> None: """Update bounds in bulk.""" if isinstance(bounds, dict): for name, bound_pair in bounds.items(): self.get(name).set_bounds(bound_pair) else: param_list = list(self._parameters.values()) if len(bounds) != len(param_list): raise ParameterValidationError( f"Bounds array length {len(bounds)} doesn't match " f"parameter count {len(param_list)}" ) for param, bound_pair in zip(param_list, bounds, strict=False): param.set_bounds(bound_pair)
[docs] def sample_from_priors( self, n_samples: int = 1, *, random_state: int | None = None, transformed: bool = False, ) -> NDArray[np.floating] | None: """ Sample from all parameter priors. Returns ------- NDArray[np.floating] or None Array of shape (n_samples, n_parameters) or None if any prior is missing """ all_samples = [] for param in self._parameters.values(): samples = param.sample_from_prior( n_samples, random_state=random_state, transformed=transformed ) if samples is None: return None all_samples.append(samples) return np.column_stack(all_samples)
[docs] def get_sigma0(self, transformed: bool = False) -> list: """ Get the standard deviation, for either all or no parameters. Parameters ---------- transformed : bool If True, the transformation is applied to the output (default: False). """ sigma0 = [] for param in self._parameters.values(): sig = None if hasattr(param.prior, "sigma"): sig = param.prior.sigma elif param.bounds is not None: lower, upper = param.bounds if np.isfinite(upper - lower): sig = 0.05 * (upper - lower) if transformed and sig is not None and param.transformation is not None: sig = np.ndarray.item( param.transformation.convert_standard_deviation( sig, param.transformation.to_search(param.initial_value)[0] ) ) sigma0.extend([sig or 0.05]) return sigma0
[docs] def priors(self) -> list: """Return the prior distribution of each parameter.""" return [ param.prior for param in self._parameters.values() if param.prior is not None ]
[docs] def get_initial_values(self, *, transformed: bool = False) -> NDArray[np.floating]: """ Get initial values as array. Parameters ---------- transformed : bool, default=False Whether to apply transformations to bounds Returns ------- NDArray[np.floating] Array of initial values """ values = [] for name, param in self._parameters.items(): value = param.initial_value if value is None: # Try to sample from prior if available if param.prior is not None: samples = param.sample_from_prior(1, transformed=transformed) if samples is not None: param.update_initial_value(samples[0]) value = samples[0] if transformed else param.initial_value if value is None: raise ParameterError(f"Parameter '{name}' has no initial value") if transformed: value = param.transformation.to_search(value)[0] values.append(value) return np.asarray(values)
@property
[docs] def transformation(self) -> Transformation: """Get the transformation for the parameters.""" return self._transform
[docs] def construct_transformation(self) -> Transformation: """ Create a ComposedTransformation object from the individual parameter transformations. """ transformations = [] for param in self._parameters.values(): transformations.append(param.transformation) if transformations == []: return None return ComposedTransformation(transformations)
[docs] def get_bounds_for_plotly(self, transformed: bool = False) -> np.ndarray: """ Retrieve parameter bounds in the format expected by Plotly. Returns ------- bounds : numpy.ndarray An array of shape (n_parameters, 2) containing the bounds for each parameter. """ bounds = self.get_bounds(transformed=transformed) # Validate that all parameters have bounds if bounds is None or not np.isfinite(list(bounds.values())).all(): raise ValueError("All parameters require bounds for plot.") return np.asarray(list(bounds.values())).T
[docs] def to_dict(self, values: str | ArrayLike | None = None) -> Inputs: """ Convert to parameter dictionary. Parameters ---------- values : str or array-like, optional Which values to use ('initial') or custom array. Default is "initial". Returns ------- Inputs Dictionary mapping parameter names to values """ if values is None: values = "initial" params = self._parameters.items() if isinstance(values, str) and values == "initial": return {name: param.initial_value for name, param in params} else: # Custom values array values_array = np.atleast_1d(values) if len(values_array) != len(self._parameters): raise ParameterValidationError( f"Values array length {len(values_array)} doesn't match parameter count {len(self._parameters)}" ) return dict(zip(self._parameters.keys(), values_array, strict=False))
[docs] def verify_inputs(self, inputs: Inputs) -> bool: """Check if the inputs are valid parameters.""" valid = True for name, param in self._parameters.items(): if param.bounds is not None: input_value = inputs[name] if input_value < param.bounds[0] or input_value > param.bounds[1]: valid = False return valid
[docs] def __repr__(self) -> str: param_summary = "\n".join( f" {name}: prior= {param.prior}, bounds={param.bounds}" for name, param in self._parameters.items() ) return f"Parameters({len(self)}):\n{param_summary}"
[docs] def to_inputs(self, values: np.ndarray | list[np.ndarray]) -> list[Inputs]: """ Return parameter values as a list of dictionaries, as required for multiprocessing. """ values = np.asarray(values) if values.ndim == 1: return [self.to_dict(values=values)] inputs_list = [] for val in values: inputs_list.append(self.to_dict(values=val)) return inputs_list
[docs] def copy(self) -> Parameters: """Create a deep copy of the Parameters object.""" return deepcopy(self)
[docs] def __contains__(self, name: str) -> bool: return name in self._parameters
[docs] def values(self) -> Iterator[Parameter]: """Iterate over parameters.""" return iter(self._parameters.values())
[docs] def items(self) -> Iterator[tuple[str, Parameter]]: """Iterate over (name, parameter) pairs.""" return iter(self._parameters.items())