"""Parameter classes for ParameterSpace.
``ParameterBase`` and its subclasses describe a single optimizable quantity:
its identity, domain, validation rules, and optional normalization. They
carry no reference to evaluation objects and perform no writes. The space
wires mappers and calls them; parameters only validate.
"""
from __future__ import annotations
import math
import numbers
from typing import Any, Literal, Optional
import numpy as np
from CADETProcess.parameter_space.normalize import (
AutoNormalizer,
LinearNormalizer,
LogNormalizer,
NormalizerBase,
NullNormalizer,
)
__all__ = [
"ParameterBase",
"RangedParameter",
"ChoiceParameter",
]
[docs]
class ParameterBase:
"""Base class for an optimizable parameter.
Subclasses override ``validate`` to enforce type and range constraints.
Parameters
----------
name : str
Unique name used to identify this parameter within a ``ParameterSpace``.
"""
def __init__(self, name: str) -> None:
self.name = name
[docs]
def validate(self, value: Any) -> Any:
"""Validate *value* against the parameter's constraints.
Returns *value* unchanged (or canonicalized by subclasses).
Raises ``TypeError`` or ``ValueError`` when *value* is invalid.
The default implementation accepts any value.
"""
return value
def __repr__(self) -> str:
"""Return a readable representation."""
return f"{type(self).__name__}(name={self.name!r})"
[docs]
class RangedParameter(ParameterBase):
"""Scalar parameter bounded by a lower and upper limit.
Parameters
----------
name : str
Unique parameter name.
parameter_type : {int, float}
Scalar domain. ``int`` accepts integral values only (no booleans);
``float`` accepts any real-valued scalar, including integers and NumPy
real scalars.
lb : float
Lower bound (inclusive). Defaults to ``-inf``.
ub : float
Upper bound (inclusive). Defaults to ``+inf``.
normalization : {"auto", "linear", "log"} or None
Normalization scheme. ``None`` means no normalization (identity).
Requires finite bounds when set.
significant_digits : int or None
When set, values are rounded to this many significant digits by
``ParameterSpace.set_values`` before being written to the evaluation
object.
"""
def __init__(
self,
name: str,
parameter_type: type[int] | type[float] = float,
lb: float = -math.inf,
ub: float = math.inf,
normalization: Literal["auto", "linear", "log"] | None = None,
significant_digits: Optional[int] = None,
) -> None:
super().__init__(name)
if parameter_type not in (int, float):
raise TypeError(
f"Parameter {name!r}: parameter_type must be int or float, "
f"got {parameter_type!r}."
)
if lb >= ub:
raise ValueError(
f"Parameter {name!r}: lower bound must be < upper bound, "
f"got lb={lb}, ub={ub}."
)
self.parameter_type = parameter_type
self.lb = lb
self.ub = ub
self.normalization = normalization
self.significant_digits = significant_digits
self.normalizer: NormalizerBase = self._build_normalizer()
def _build_normalizer(self) -> NormalizerBase:
if self.normalization is None:
return NullNormalizer(lb_input=self.lb, ub_input=self.ub)
if np.isinf(self.lb) or np.isinf(self.ub):
raise ValueError(
f"Parameter {self.name!r}: normalization requires finite bounds."
)
if self.normalization == "linear":
return LinearNormalizer(lb_input=self.lb, ub_input=self.ub)
if self.normalization == "log":
return LogNormalizer(lb_input=self.lb, ub_input=self.ub)
if self.normalization == "auto":
return AutoNormalizer(lb_input=self.lb, ub_input=self.ub)
raise ValueError(
f"Parameter {self.name!r}: unknown normalization {self.normalization!r}."
)
[docs]
def validate(self, value: Any) -> Any:
"""Validate type and bounds.
Returns *value* unchanged.
Raises
------
TypeError
If *value* is not numerically compatible with ``parameter_type``
(accepts NumPy scalar types via the ``numbers`` abstract base
classes; rejects ``bool`` when ``parameter_type`` is ``int``).
ValueError
If *value* lies outside ``[lb, ub]``.
"""
if self.parameter_type is int:
ok = (
not isinstance(value, bool)
and isinstance(value, numbers.Real)
and float(value).is_integer()
)
else:
ok = isinstance(value, numbers.Real) and not isinstance(value, bool)
if not ok:
raise TypeError(
f"Parameter {self.name!r}: expected {self.parameter_type.__name__}, "
f"got {type(value).__name__}."
)
if not self.lb <= value <= self.ub:
raise ValueError(
f"Parameter {self.name!r}: value {value} outside [{self.lb}, {self.ub}]."
)
return self.parameter_type(value)
[docs]
def normalize(self, value: float) -> float:
"""Map *value* from ``[lb, ub]`` to the normalized space."""
return self.normalizer.normalize(value)
[docs]
def denormalize(self, value: float) -> float:
"""Map *value* from the normalized space back to ``[lb, ub]``."""
return self.normalizer.denormalize(value)
def __repr__(self) -> str:
"""Return a readable representation."""
return (
f"RangedParameter(name={self.name!r}, "
f"parameter_type={self.parameter_type.__name__}, "
f"lb={self.lb}, ub={self.ub})"
)
[docs]
class ChoiceParameter(ParameterBase):
"""Parameter constrained to a finite set of allowed values.
Parameters
----------
name : str
Unique parameter name.
valid_values : list
Allowed choices. Any value not in this list is rejected.
"""
def __init__(self, name: str, valid_values: list[Any]) -> None:
super().__init__(name)
if not valid_values:
raise ValueError(
f"Parameter {name!r}: valid_values must not be empty."
)
self.valid_values = list(valid_values)
[docs]
def validate(self, value: Any) -> Any:
"""Raise ``ValueError`` when *value* is not in ``valid_values``.
Returns *value* unchanged.
"""
if value not in self.valid_values:
raise ValueError(
f"Parameter {self.name!r}: {value!r} is not a valid choice; "
f"must be one of {self.valid_values!r}."
)
return value
def __repr__(self) -> str:
"""Return a readable representation."""
return f"ChoiceParameter(name={self.name!r}, valid_values={self.valid_values!r})"