import warnings
from typing import Any, Dict, List, Tuple, Iterator, Optional, Union, cast
import pandas as pd
from alea.utils import within_limits, clip_limits, evaluate_numpy_scipy_expression
[docs]class Parameter:
"""Represents a single parameter with its properties.
Attributes:
name (str): The name of the parameter.
nominal_value (float, optional (default=None)): The nominal value of the parameter.
fittable (bool, optional (default=True)):
Indicates if the parameter is fittable or always fixed.
ptype (str, optional (default=None)): The ptype of the parameter.
uncertainty (float or str, optional (default=None)): The uncertainty of the parameter.
If a string, it can be evaluated as a numpy or
scipy function to define non-gaussian constraints.
relative_uncertainty (bool, optional (default=None)):
Indicates if the uncertainty is relative to the nominal_value.
blueice_anchors (list, optional (default=None)): Anchors for blueice template morphing.
Blueice will load the template for the provided values and then interpolate
for any value in between.
fit_limits (Tuple[float, float], optional (default=None)):
The limits for fitting the parameter.
parameter_interval_bounds (Tuple[float, float], optional (default=None)):
Limits for computing confidence intervals.
fit_guess (float, optional (default=None)): The initial guess for fitting the parameter.
description (str, optional (default=None)): A description of the parameter.
"""
_uncertainty: Optional[Union[float, str]]
[docs] def __init__(
self,
name: str,
nominal_value: Optional[float] = None,
fittable: bool = True,
ptype: Optional[str] = None,
uncertainty: Optional[Union[float, str]] = None,
relative_uncertainty: Optional[bool] = None,
blueice_anchors: Optional[Union[list, str]] = None,
fit_limits: Optional[Tuple] = None,
parameter_interval_bounds: Optional[Tuple[float, float]] = None,
fit_guess: Optional[float] = None,
description: Optional[str] = None,
):
"""Initialise a parameter."""
self.name = name
self._nominal_value = nominal_value
self.fittable = fittable
self.ptype = ptype
self.relative_uncertainty = relative_uncertainty
self.uncertainty = uncertainty
self.blueice_anchors = blueice_anchors
self.fit_limits = fit_limits
self.parameter_interval_bounds = parameter_interval_bounds
self.fit_guess = fit_guess
self.description = description
self._check_parameter_consistency()
def __repr__(self) -> str:
parameter_str = ", ".join([f"{k}={v}" for k, v in self.__dict__.items() if v is not None])
_repr = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
_repr += f"({parameter_str})"
return _repr
@property
def uncertainty(self) -> Any:
"""Return the uncertainty of the parameter.
If the uncertainty is a string, it will be evaluated as a numpy or scipy function.
"""
if isinstance(self._uncertainty, str):
return evaluate_numpy_scipy_expression(self._uncertainty)
else:
return self._uncertainty
@uncertainty.setter
def uncertainty(self, value: Optional[Union[float, str]]) -> None:
if self.relative_uncertainty and (value is not None):
if value and (not isinstance(value, (float, int))):
raise ValueError(
f"When relative_uncertainty of {self.name} is True, "
f"uncertainty should be float, not {value}."
)
if self.nominal_value is None:
raise ValueError(
f"When relative_uncertainty of {self.name} is True, "
"nominal_value should be set."
)
self._uncertainty = value
@property
def blueice_anchors(self) -> Any:
"""Return the blueice_anchors of the parameter.
If the blueice_anchors is a string, it will be evaluated as a numpy or scipy function.
"""
if isinstance(self._blueice_anchors, str):
return evaluate_numpy_scipy_expression(self._blueice_anchors).tolist()
else:
return self._blueice_anchors
@blueice_anchors.setter
def blueice_anchors(self, value: Optional[Union[list, str]]) -> None:
self._blueice_anchors = value
@property
def fit_guess(self) -> Optional[float]:
"""Return the initial guess for fitting the parameter."""
# make sure to only return fit_guess if fittable
if self._fit_guess is not None and not self.fittable:
raise ValueError(f"Parameter {self.name} is not fittable, but has a fit_guess.")
else:
return self._fit_guess
@fit_guess.setter
def fit_guess(self, value: Optional[float]) -> None:
self._fit_guess = value
@property
def parameter_interval_bounds(self) -> Optional[Tuple[float, float]]:
# make sure to only return parameter_interval_bounds if fittable
if self._parameter_interval_bounds is not None and not self.fittable:
raise ValueError(
f"Parameter {self.name} is not fittable, but has a parameter_interval_bounds."
)
else:
# print warning when value contains None
value = self._parameter_interval_bounds
self._check_parameter_interval_bounds(value)
return clip_limits(value)
@parameter_interval_bounds.setter
def parameter_interval_bounds(self, value: Optional[Tuple[float, float]]) -> None:
self._parameter_interval_bounds = value
@property
def nominal_value(self) -> Optional[float]:
"""Return the nominal value of the parameter."""
return self._nominal_value
@nominal_value.setter
def nominal_value(self, value: Optional[float]) -> None:
if self.needs_reinit and (value != self._nominal_value):
raise ValueError(
f"{self.name} is a parameter that requires re-initialization "
"to change its nominal value "
f"(tried to override nominal value {self._nominal_value} with {value})."
)
self._nominal_value = value
@property
def needs_reinit(self) -> bool:
"""Return True if the parameter needs re-initialization (for ptype ``needs_reinit``)."""
needs_reinit = False
if self.ptype == "needs_reinit":
needs_reinit = True
return needs_reinit
[docs] def __eq__(self, other: object) -> bool:
"""Return True if all attributes are equal."""
if isinstance(other, Parameter):
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
else:
return False
[docs] def value_in_fit_limits(self, value: float) -> bool:
"""Returns True if value is within fit_limits."""
return within_limits(value, self.fit_limits)
[docs] def _check_parameter_interval_bounds(self, value):
"""Check if parameter_interval_bounds is within fit_limits and is not None."""
if (value is None) or (value[0] is None) or (value[1] is None):
warnings.warn(
f"parameter_interval_bounds not completely defined for parameter {self.name}. "
"This may cause numerical overflow when calculating confidential interval."
)
value = clip_limits(value)
if not (self.value_in_fit_limits(value[0]) and self.value_in_fit_limits(value[1])):
raise ValueError(
f"parameter_interval_bounds {value} not within "
f"fit_limits {self.fit_limits} for parameter {self.name}."
)
[docs] def _check_parameter_consistency(self):
"""Check if parameter is consistent."""
if self.fittable and self.needs_reinit:
warnings.warn(
f"Parameter {self.name} is fittable and needs re-initialization. "
"This may cause unexpected behaviour."
)
if (self.blueice_anchors is not None) and self.needs_reinit:
raise ValueError(
f"Parameter {self.name} needs re-initialization but has "
"blueice_anchors defined. "
"This may cause unexpected behaviour."
)
[docs]class ConditionalParameter:
"""A parameter whose properties depend on the value of another (conditioning) parameter.
Each attribute can be a dictionary mapping conditioning parameter values to the
corresponding values of the conditional parameter. Calling the object with the
conditioning parameter value as an argument returns a Parameter object with the
correct values.
Attributes:
name (str): The name of the parameter.
conditioning_parameter_name (str): The name of the conditioning parameter.
"""
def __init__(self, name: str, conditioning_parameter_name: str, **kwargs):
self.name = name
self.conditioning_name = conditioning_parameter_name
self.conditions_dict = self._unpack_conditions(kwargs)
self.conditioning_param: Optional[Parameter] = None
def __repr__(self) -> str:
parameter_str = ", ".join([f"{k}={v}" for k, v in self.__dict__.items() if v is not None])
_repr = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
_repr += f"({parameter_str})"
return _repr
@staticmethod
def _unpack_conditions(kwargs):
# 1) collect all condition keys and check for consistency
all_keys = set()
for value in kwargs.values():
if isinstance(value, dict):
if not all_keys:
all_keys = set(value.keys())
elif all_keys != set(value.keys()):
raise ValueError("Inconsistent condition keys across dictionaries.")
# 2) create the conditions dictionary
conditions_dict = {key: {} for key in all_keys}
for key, value in kwargs.items():
if isinstance(value, dict):
for condition_key, condition_value in value.items():
conditions_dict[condition_key][key] = condition_value
else:
for condition_key in all_keys:
conditions_dict[condition_key][key] = value
return conditions_dict
@property
def uncertainty(self) -> Any:
"""Return the uncertainty of the parameter (nominal condition)"""
return self().uncertainty
@property
def blueice_anchors(self) -> Any:
"""Return the blueice_anchors of the parameter (nominal condition)"""
return self().blueice_anchors
@property
def fit_guess(self) -> Optional[float]:
"""Return the initial guess for fitting the parameter (nominal condition)"""
return self().fit_guess
@property
def parameter_interval_bounds(self) -> Optional[Tuple[float, float]]:
"""Return the parameter_interval_bounds of the parameter (nominal condition)"""
return self().parameter_interval_bounds
@property
def nominal_value(self) -> Optional[float]:
"""Return the nominal value of the parameter (nominal condition)"""
return self().nominal_value
@property
def needs_reinit(self) -> bool:
"""Return True if the parameter needs re-initialization (for ptype ``needs_reinit``)."""
return self().needs_reinit
@property
def fittable(self) -> bool:
"""Return the fittable attribute of the parameter (nominal condition)"""
return self().fittable
@property
def ptype(self) -> Optional[str]:
"""Return the ptype of the parameter (nominal condition)"""
return self().ptype
@property
def relative_uncertainty(self) -> Optional[bool]:
"""Return the relative_uncertainty of the parameter (nominal condition)"""
return self().relative_uncertainty
@property
def fit_limits(self) -> Optional[Tuple[float, float]]:
"""Return the fit_limits of the parameter (nominal condition)"""
return self().fit_limits
[docs] def __eq__(self, other: object) -> bool:
"""Return True if all attributes are equal."""
if isinstance(other, ConditionalParameter):
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
return False
[docs] def value_in_fit_limits(self, value: float) -> bool:
"""Returns True if value under nominal condition is within fit_limits."""
return self().value_in_fit_limits(value)
def __call__(self, **kwargs) -> Parameter:
if self.conditioning_name in kwargs:
cond_val = kwargs[self.conditioning_name]
elif self.conditioning_param is not None:
cond_val = self.conditioning_param.nominal_value
else:
err_msg = (
f"Conditioning parameter '{self.conditioning_name}' is missing. Can't fall back to "
"nominal value because conditioning parameter it is not set. "
)
raise ValueError(err_msg)
# check if the conditioning value is in the conditions dictionary
if cond_val not in self.conditions_dict:
raise ValueError(
f"Conditioning value '{cond_val}' not found in the conditions dictionary."
+ f"Available values are: {sorted(list(self.conditions_dict.keys()))}"
)
return Parameter(name=self.name, **self.conditions_dict[cond_val])
[docs]class Parameters:
"""Represents a collection of parameters.
Attributes:
names (List[str]): A list of parameter names.
fit_guesses (Dict[str, float]): A dictionary of fit guesses.
fit_limits (Dict[str, float]): A dictionary of fit limits.
fittable (List[str]): A list of parameter names which are fittable.
not_fittable (List[str]): A list of parameter names which are not fittable.
uncertainties (Dict[str, float or Any]): A dictionary of parameter uncertainties.
with_uncertainty (Parameters): A Parameters object with parameters with
a not-NaN uncertainty.
nominal_values (Dict[str, float]): A dictionary of parameter nominal values.
parameters (Dict[str, Parameter]): A dictionary to store the parameters,
with parameter name as key.
"""
[docs] def __init__(self):
"""Initialise a collection of parameters."""
self.parameters = cast(Dict[str, Parameter], {})
[docs] def __iter__(self) -> Iterator[Parameter]:
"""Return an iterator over the parameters.
Each iteration return a Parameter object.
"""
return iter(self.parameters.values())
[docs] @classmethod
def from_config(cls, config: Dict[str, dict]):
"""Creates a Parameters object from a configuration dictionary.
Args:
config (dict): A dictionary of parameter configurations.
Returns:
Parameters: The created Parameters object.
"""
parameters = cls()
parameter: Union[Parameter, ConditionalParameter]
for name, param_config in config.items():
if "conditioning_parameter_name" in param_config:
parameter = ConditionalParameter(name, **param_config)
else:
parameter = Parameter(name=name, **param_config)
parameters.add_parameter(parameter)
# set conditioning parameters
for param in parameters:
if isinstance(param, ConditionalParameter):
param.conditioning_param = parameters[param.conditioning_name]
return parameters
[docs] @classmethod
def from_list(cls, names: List[str]):
"""Creates a Parameters object from a list of parameter names.
Everything else is set to default values.
Args:
names (List[str]): List of parameter names.
Returns:
Parameters: The created Parameters object.
"""
parameters = cls()
for name in names:
parameter = Parameter(name)
parameters.add_parameter(parameter)
return parameters
def __repr__(self) -> str:
parameter_str = ", ".join(self.names)
_repr = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
_repr += f"({parameter_str})"
return _repr
[docs] def __str__(self) -> str:
"""Return an overview table of all parameters."""
par_list = []
for p in self:
if isinstance(p, ConditionalParameter):
par_dict = {
"conditioning_name": p.conditioning_name,
"conditions": sorted(p.conditions_dict.keys()),
}
# get nominal-condition parameter
p = p()
else:
par_dict = {}
for k, v in p.__dict__.items():
# replace hidden attributes with non-hidden properties
if k.startswith("_"):
par_dict[k[1:]] = v
else:
par_dict[k] = v
par_list.append(par_dict)
df = pd.DataFrame(par_list)
# make name column the index
df.set_index("name", inplace=True)
df.index.name = None
return df.to_string()
[docs] def add_parameter(self, parameter: Union[Parameter, ConditionalParameter]) -> None:
"""Adds a Parameter object to the Parameters collection.
Args:
parameter (Parameter): The Parameter object to add.
Raises:
ValueError: If the parameter name already exists.
"""
if parameter.name in self.names:
raise ValueError(f"Parameter {parameter.name} already exists.")
self.parameters[parameter.name] = parameter
@property
def names(self) -> List[str]:
"""A list of parameter names."""
return list(self.parameters.keys())
@property
def fit_guesses(self) -> Dict[str, float]:
"""A dictionary of fit guesses."""
return {
name: param.fit_guess
for name, param in self.parameters.items()
if param.fit_guess is not None
}
@property
def fit_limits(self) -> Dict[str, float]:
"""A dictionary of fit limits."""
return {
name: param.fit_limits
for name, param in self.parameters.items()
if param.fit_limits is not None
}
@property
def fittable(self) -> List[str]:
"""A list of parameter names which are fittable."""
return [name for name, param in self.parameters.items() if param.fittable]
@property
def not_fittable(self) -> List[str]:
"""A list of parameter names which are not fittable."""
return [name for name, param in self.parameters.items() if not param.fittable]
@property
def uncertainties(self) -> dict:
"""A dict of uncertainties for all parameters with a not-NaN uncertainty.
Caution: this is not the same as the parameter.uncertainty property.
"""
return {k: i.uncertainty for k, i in self.parameters.items() if i.uncertainty is not None}
@property
def with_uncertainty(self) -> "Parameters":
"""Return parameters with a not-NaN uncertainty.
The parameters are the same objects as in the original Parameters object, not a copy. For
conditional parameters, the parameters under the nominal condition are returned.
"""
param_dict = {k: i for k, i in self.parameters.items() if i.uncertainty is not None}
params = Parameters()
for param in param_dict.values():
params.add_parameter(param)
return params
@property
def nominal_values(self) -> dict:
"""A dict of nominal values for all parameters with a nominal value."""
return {
k: i.nominal_value for k, i in self.parameters.items() if i.nominal_value is not None
}
[docs] def set_nominal_values(self, **nominal_values):
"""Set the nominal values for parameters.
Keyword Args:
nominal_values (dict): A dict of parameter names and values.
"""
for name, value in nominal_values.items():
self.parameters[name].nominal_value = value
[docs] def set_fit_guesses(self, **fit_guesses):
"""Set the fit guesses for parameters.
Keyword Args:
fit_guesses (dict): A dict of parameter names and values.
"""
for name, value in fit_guesses.items():
self.parameters[name].fit_guess = value
def _evaluate_parameter(self, parameter: Parameter, **kwargs):
if isinstance(parameter, ConditionalParameter):
return parameter(**kwargs)
return parameter
[docs] def __call__(
self, return_fittable: Optional[bool] = False, **kwargs: Optional[Dict]
) -> Dict[str, Any]:
"""Return a dictionary of parameter values, optionally filtered to fittable parameters only.
Args:
return_fittable (bool, optional (default=False)):
Indicates if only fittable parameters should be returned.
Keyword Args:
kwargs (dict): Additional keyword arguments to override parameter values.
Raises:
ValueError: If a parameter name is not found.
Returns:
dict: A dictionary of parameter values.
"""
values = {}
# check that all kwargs are valid parameter names
for name in kwargs:
if name not in self.parameters:
raise ValueError(f"Parameter '{name}' not found.")
for name, param in self.parameters.items():
param = self._evaluate_parameter(param, **kwargs)
new_val = kwargs.get(name, None)
if param.needs_reinit and new_val != param.nominal_value and new_val is not None:
raise ValueError(
f"{name} is a parameter that requires re-initialization "
"to override its nominal value "
f"(tried to override nominal value {param.nominal_value} "
f"with {new_val})."
)
if (return_fittable and param.fittable) or (not return_fittable):
values[name] = new_val if new_val is not None else param.nominal_value
if any(i is None for k, i in values.items()):
emptypars = ", ".join([k for k, i in values.items() if i is None])
raise AssertionError(
"All parameters must be set explicitly, or have a nominal value, "
"not satisfied for: " + emptypars
)
return values
[docs] def __getattr__(self, name: str) -> Parameter:
"""Retrieves a Parameter object by attribute access.
Args:
name (str): The name of the parameter.
Raises:
AttributeError: If the attribute is not found.
Returns:
Parameter: The retrieved Parameter object.
"""
try:
return super().__getattribute__("parameters")[name]
except KeyError:
raise AttributeError(f"Attribute '{name}' not found.")
[docs] def __getitem__(self, name: str) -> Parameter:
"""Retrieves a Parameter object by dictionary access.
Args:
name (str): The name of the parameter.
Raises:
KeyError: If the key is not found.
Returns:
Parameter: The retrieved Parameter object.
"""
if name in self.parameters:
return self.parameters[name]
else:
raise KeyError(f"Key '{name}' not found.")
[docs] def __eq__(self, other: object) -> bool:
"""Return True if all parameters are equal."""
if isinstance(other, Parameters):
names = set(self.names + other.names)
return all(getattr(self, n) == getattr(other, n) for n in names)
else:
return False
[docs] def values_in_fit_limits(self, **kwargs: Dict) -> bool:
"""Return True if all values are within the fit limits.
Keyword Args:
kwargs (dict): The parameter values to check.
Returns:
bool: True if all values are within the fit limits.
"""
for name, value in kwargs.items():
param = self._evaluate_parameter(self.parameters[name], **kwargs)
if not param.value_in_fit_limits(value):
return False
return True