"""The parameter class."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import asteval
import numpy as np
from numpy.typing._array_like import _SupportsArray
from glotaran.utils.ipython import MarkdownStr
from glotaran.utils.sanitize import sanitize_parameter_list
if TYPE_CHECKING:
from typing import Any
from glotaran.parameter import ParameterGroup
RESERVED_LABELS: list[str] = list(asteval.make_symbol_table().keys()) + ["group"]
[docs]class Keys:
"""Keys for parameter options."""
EXPR = "expr"
MAX = "max"
MIN = "min"
NON_NEG = "non-negative"
STD_ERR = "standard-error"
VARY = "vary"
PARAMETER_EXPRESION_REGEX = re.compile(r"\$(?P<parameter_expression>[\w\d\.]+)((?![\w\d\.]+)|$)")
"""A regexpression to find and replace parameter names in expressions."""
VALID_LABEL_REGEX = re.compile(r"\W", flags=re.ASCII)
"""A regexpression to validate labels."""
[docs]class Parameter(_SupportsArray):
"""A parameter for optimization."""
def __init__(
self,
label: str = None,
full_label: str = None,
expression: str | None = None,
maximum: float = np.inf,
minimum: float = -np.inf,
non_negative: bool = False,
standard_error: float = np.nan,
value: float = np.nan,
vary: bool = True,
):
"""Optimization Parameter supporting numpy array operations.
Parameters
----------
label : str
The label of the parameter., by default None
full_label : str
The label of the parameter with its path in a parameter group prepended.
, by default None
expression : str | None
Expression to calculate the parameters value from,
e.g. if used in relation to another parameter. , by default None
maximum : float
Upper boundary for the parameter to be varied to., by default np.inf
minimum : float
Lower boundary for the parameter to be varied to., by default -np.inf
non_negative : bool
Whether the parameter should always be bigger than zero., by default False
standard_error: float
The standard error of the parameter. , by default ``np.nan``
value : float
Value of the parameter, by default np.nan
vary : bool
Whether the parameter should be changed during optimization or not.
, by default True
"""
self.label = label
self.full_label = full_label or ""
self.expression = expression
self.maximum = maximum
self.minimum = minimum
self.non_negative = non_negative
self.standard_error = standard_error
self.value = value
self.vary = vary
self._transformed_expression: str | None = None
[docs] @staticmethod
def valid_label(label: str) -> bool:
"""Check if a label is a valid label for :class:`Parameter`.
Parameters
----------
label : str
The label to validate.
Returns
-------
bool
Whether the label is valid.
"""
return VALID_LABEL_REGEX.search(label) is None and label not in RESERVED_LABELS
[docs] @classmethod
def from_list_or_value(
cls,
value: int | float | list,
default_options: dict = None,
label: str = None,
) -> Parameter:
"""Create a parameter from a list or numeric value.
Parameters
----------
value : int | float | list
The list or numeric value.
default_options : dict
A dictionary of default options.
label : str
The label of the parameter.
Returns
-------
Parameter
The created :class:`Parameter`.
"""
param = cls(label=label)
options = None
if not isinstance(value, list):
param.value = value
else:
values = sanitize_parameter_list(value)
param.label = _retrieve_item_from_list_by_type(values, str, label)
param.value = float(_retrieve_item_from_list_by_type(values, (int, float), 0))
options = _retrieve_item_from_list_by_type(values, dict, None)
if default_options:
param._set_options_from_dict(default_options)
if options:
param._set_options_from_dict(options)
return param
[docs] @classmethod
def from_dict(cls, parameter_dict: dict[str, Any]) -> Parameter:
"""Create a :class:`Parameter` from a dictionary.
Expects a dictionary created by :method:`Parameter.as_dict`.
Parameters
----------
parameter_dict : dict[str, Any]
The source dictionary.
Returns
-------
Parameter
The created :class:`Parameter`
"""
parameter_dict = {k.replace("-", "_"): v for k, v in parameter_dict.items()}
parameter_dict["full_label"] = parameter_dict["label"]
parameter_dict["label"] = parameter_dict["label"].split(".")[-1]
return cls(**parameter_dict)
[docs] def as_dict(self, as_optimized: bool = True) -> dict[str, Any]:
"""Create a dictionary containing the parameter properties.
Note:
-----
Intended for internal use.
Parameters
----------
as_optimized : bool
Whether to include properties which are the result of optimization.
Returns
-------
dict[str, Any]
The created dictionary.
"""
parameter_dict = {
"label": self.full_label,
"value": self.value,
"expression": self.expression,
"minimum": self.minimum,
"maximum": self.maximum,
"non-negative": self.non_negative,
"vary": self.vary,
}
if as_optimized:
parameter_dict["standard-error"] = self.standard_error
return parameter_dict
[docs] def set_from_group(self, group: ParameterGroup):
"""Set all values of the parameter to the values of the corresponding parameter in the group.
Notes
-----
For internal use.
Parameters
----------
group : ParameterGroup
The :class:`glotaran.parameter.ParameterGroup`.
"""
p = group.get(self.full_label)
self.expression = p.expression
self.maximum = p.maximum
self.minimum = p.minimum
self.non_negative = p.non_negative
self.standard_error = p.standard_error
self.value = p.value
self.vary = p.vary
def _set_options_from_dict(self, options: dict[str, Any]):
"""Set the parameter's options from a dictionary.
Parameters
----------
options : dict[str, Any]
A dictionary containing parameter options.
"""
if Keys.EXPR in options:
self.expression = options[Keys.EXPR]
if Keys.NON_NEG in options:
self.non_negative = options[Keys.NON_NEG]
if Keys.MAX in options:
self.maximum = options[Keys.MAX]
if Keys.MIN in options:
self.minimum = options[Keys.MIN]
if Keys.VARY in options:
self.vary = options[Keys.VARY]
if Keys.STD_ERR in options:
self.standard_error = options[Keys.STD_ERR]
@property
def label(self) -> str | None:
"""Label of the parameter.
Returns
-------
str
The label.
"""
return self._label
@label.setter
def label(self, label: str | None):
# ensure that label is str | None even if an int is passed
label = None if label is None else str(label)
if label is not None and not Parameter.valid_label(label):
raise ValueError(f"'{label}' is not a valid group label.")
self._label = label
@property
def full_label(self) -> str:
"""Label of the parameter with its path in a parameter group prepended.
Returns
-------
str
The full label.
"""
return self._full_label
@full_label.setter
def full_label(self, full_label: str):
self._full_label = str(full_label)
@property
def non_negative(self) -> bool:
r"""Indicate if the parameter is non-negative.
If true, the parameter will be transformed with :math:`p' = \log{p}` and
:math:`p = \exp{p'}`.
Notes
-----
Always `False` if `expression` is not `None`.
Returns
-------
bool
Whether the parameter is non-negative.
"""
return self._non_negative if self.expression is None else False
@non_negative.setter
def non_negative(self, non_negative: bool):
self._non_negative = non_negative
@property
def vary(self) -> bool:
"""Indicate if the parameter should be optimized.
Notes
-----
Always `False` if `expression` is not `None`.
Returns
-------
bool
Whether the parameter should be optimized.
"""
return self._vary if self.expression is None else False
@vary.setter
def vary(self, vary: bool):
self._vary = vary
@property
def maximum(self) -> float:
"""Upper bound of the parameter.
Returns
-------
float
The upper bound of the parameter.
"""
return self._maximum
@maximum.setter
def maximum(self, maximum: int | float):
if not isinstance(maximum, float):
try:
maximum = float(maximum)
except Exception:
raise TypeError(
"Parameter maximum must be numeric."
+ f"'{self.full_label}' has maximum '{maximum}' of type '{type(maximum)}'"
)
self._maximum = maximum
@property
def minimum(self) -> float:
"""Lower bound of the parameter.
Returns
-------
float
The lower bound of the parameter.
"""
return self._minimum
@minimum.setter
def minimum(self, minimum: int | float):
if not isinstance(minimum, float):
try:
minimum = float(minimum)
except Exception:
raise TypeError(
"Parameter minimum must be numeric."
+ f"'{self.full_label}' has minimum '{minimum}' of type '{type(minimum)}'"
)
self._minimum = minimum
@property
def expression(self) -> str | None:
"""Expression to calculate the parameters value from.
This can used to set a relation to another parameter.
Returns
-------
str | None
The expression.
"""
return self._expression
@expression.setter
def expression(self, expression: str | None):
self._expression = expression
self._transformed_expression = None
@property
def transformed_expression(self) -> str | None:
"""Expression of the parameter transformed for evaluation within a `ParameterGroup`.
Returns
-------
str | None
The transformed expression.
"""
if self.expression is not None and self._transformed_expression is None:
self._transformed_expression = PARAMETER_EXPRESION_REGEX.sub(
r"group.get('\g<parameter_expression>').value", self.expression
)
return self._transformed_expression
@property
def standard_error(self) -> float:
"""Standard error of the optimized parameter.
Returns
-------
float
The standard error of the parameter.
""" # noqa: D401
return self._stderr
@standard_error.setter
def standard_error(self, standard_error: float):
self._stderr = standard_error
@property
def value(self) -> float:
"""Value of the parameter.
Returns
-------
float
The value of the parameter.
"""
return self._value
@value.setter
def value(self, value: int | float):
if not isinstance(value, float) and value is not np.nan:
try:
value = float(value)
except Exception:
raise TypeError(
"Parameter value must be numeric."
+ f"'{self.full_label}' has value '{value}' of type '{type(value)}'"
)
self._value = value
[docs] def get_value_and_bounds_for_optimization(self) -> tuple[float, float, float]:
"""Get the parameter value and bounds with expression and non-negative constraints applied.
Returns
-------
tuple[float, float, float]
A tuple containing the value, the lower and the upper bound.
"""
value = self.value
minimum = self.minimum
maximum = self.maximum
if self.non_negative:
value = _log_value(value)
minimum = _log_value(minimum)
maximum = _log_value(maximum)
return value, minimum, maximum
[docs] def set_value_from_optimization(self, value: float):
"""Set the value from an optimization result and reverses non-negative transformation.
Parameters
----------
value : float
Value from optimization.
"""
self.value = np.exp(value) if self.non_negative else value
[docs] def markdown(
self,
all_parameters: ParameterGroup | None = None,
initial_parameters: ParameterGroup | None = None,
) -> MarkdownStr:
"""Get a markdown representation of the parameter.
Parameters
----------
all_parameters : ParameterGroup | None
A parameter group containing the whole parameter set (used for expression lookup).
initial_parameters : ParameterGroup | None
The initial parameter.
Returns
-------
MarkdownStr
The parameter as markdown string.
"""
md = f"{self.full_label}"
parameter = self if all_parameters is None else all_parameters.get(self.full_label)
value = f"{parameter.value:.2e}"
if parameter.vary:
if parameter.standard_error is not np.nan:
value += f"±{parameter.standard_error:.2e}"
if initial_parameters is not None:
initial_value = initial_parameters.get(parameter.full_label).value
value += f", initial: {initial_value:.2e}"
md += f"({value})"
elif parameter.expression is not None:
expression = parameter.expression
if all_parameters is not None:
for match in PARAMETER_EXPRESION_REGEX.findall(expression):
label = match[0]
parameter = all_parameters.get(label)
expression = expression.replace(
"$" + label, f"_{parameter.markdown(all_parameters=all_parameters)}_"
)
md += f"({value}={expression})"
else:
md += f"({value}, fixed)"
return MarkdownStr(md)
def __getstate__(self):
"""Get state for pickle."""
return (
self.label,
self.full_label,
self.expression,
self.maximum,
self.minimum,
self.non_negative,
self.standard_error,
self.value,
self.vary,
)
def __setstate__(self, state):
"""Set state from pickle."""
(
self.label,
self.full_label,
self.expression,
self.maximum,
self.minimum,
self.non_negative,
self.standard_error,
self.value,
self.vary,
) = state
def __repr__(self):
"""Representation used by repl and tracebacks."""
return (
f"{type(self).__name__}(label={self.label!r}, value={self.value!r},"
f" expression={self.expression!r}, vary={self.vary!r})"
)
def __array__(self):
"""array""" # noqa: D400, D403
return np.array(float(self._value), dtype=float)
def __str__(self) -> str:
"""Representation used by print and str."""
return (
f"__{self.label}__: _Value_: {self.value}, _StdErr_: {self.standard_error}, _Min_:"
f" {self.minimum}, _Max_: {self.maximum}, _Vary_: {self.vary},"
f" _Non-Negative_: {self.non_negative}, _Expr_: {self.expression}"
)
def __abs__(self):
"""abs""" # noqa: D400, D403
return abs(self._value)
def __neg__(self):
"""neg""" # noqa: D400, D403
return -self._value
def __pos__(self):
"""positive""" # noqa: D400, D403
return +self._value
def __int__(self):
"""int""" # noqa: D400, D403
return int(self._value)
def __float__(self):
"""float""" # noqa: D400, D403
return float(self._value)
def __trunc__(self):
"""trunc""" # noqa: D400, D403
return self._value.__trunc__()
def __add__(self, other):
"""+""" # noqa: D400
return self._value + other
def __sub__(self, other):
"""-""" # noqa: D400
return self._value - other
def __truediv__(self, other):
"""/""" # noqa: D400
return self._value / other
def __floordiv__(self, other):
"""//""" # noqa: D400
return self._value // other
def __divmod__(self, other):
"""divmod""" # noqa: D400, D403
return divmod(self._value, other)
def __mod__(self, other):
"""%""" # noqa: D400
return self._value % other
def __mul__(self, other):
"""*""" # noqa: D400
return self._value * other
def __pow__(self, other):
"""**""" # noqa: D400
return self._value ** other
def __gt__(self, other):
""">""" # noqa: D400
return self._value > other
def __ge__(self, other):
""">=""" # noqa: D400
return self._value >= other
def __le__(self, other):
"""<=""" # noqa: D400
return self._value <= other
def __lt__(self, other):
"""<""" # noqa: D400
return self._value < other
def __eq__(self, other):
"""==""" # noqa: D400
return self._value == other
def __ne__(self, other):
"""!=""" # noqa: D400
return self._value != other
def __radd__(self, other):
"""+ (right)""" # noqa: D400
return other + self._value
def __rtruediv__(self, other):
"""/ (right)""" # noqa: D400
return other / self._value
def __rdivmod__(self, other):
"""divmod (right)""" # noqa: D400, D403
return divmod(other, self._value)
def __rfloordiv__(self, other):
"""// (right)""" # noqa: D400
return other // self._value
def __rmod__(self, other):
"""% (right)""" # noqa: D400
return other % self._value
def __rmul__(self, other):
"""* (right)""" # noqa: D400
return other * self._value
def __rpow__(self, other):
"""** (right)""" # noqa: D400
return other ** self._value
def __rsub__(self, other):
"""- (right)""" # noqa: D400
return other - self._value
def _log_value(value: float) -> float:
"""Get the logarithm of a value.
Performs a check for edge cases and migitates numerical issues.
Parameters
----------
value : float
The initial value.
Returns
-------
float
The logarithm of the value.
"""
if not np.isfinite(value):
return value
if value == 1:
value += 1e-10
return np.log(value)
def _retrieve_item_from_list_by_type(
item_list: list, item_type: type | tuple[type, ...], default: Any
) -> Any:
"""Retrieve an item from list which matches a given type.
Parameters
----------
item_list : list
The list to retrieve from.
item_type : type | tuple[type, ...]
The item type or tuple of types to match.
default : Any
Returned if no item matches.
Returns
-------
Any
"""
tmp = list(filter(lambda x: isinstance(x, item_type), item_list))
if not tmp:
return default
item_list.remove(tmp[0])
return tmp[0]