Source code for CADETProcess.parameter_space.transformed_space

"""TransformedSpace: optimizer-facing view of a ParameterSpace in normalized coordinates.

``TransformedSpace`` wraps a ``ParameterSpace`` and presents bounds and constraint
matrices in normalized coordinates.  It has no write method: the single write path
is ``ParameterSpace.set_values``, and vector-coordinate callers compose explicitly,
``space.set_values(ts.decode(space.denormalize(x)))``.  Three spaces are in play:

* **Optimizer space** — the ``n_variables`` independent parameters in normalized
  coordinates; the domain the optimizer directly controls.
* **Physical space** — the full parameter set in original units, owned by
  ``ParameterSpace``.
* **Dependency manifold** — the surface defined by dependency transforms; not
  representable as a linear constraint and resolved only at write time.

Coordinate convention
---------------------
Normalization is per-parameter.  ``RangedParameter`` instances with an active
normalizer (``normalization is not None``) and finite bounds are mapped to ``[0, 1]``.
Parameters without normalization pass through the identity chart (``NullNormalizer``)
and retain their physical-unit value.  The optimizer space is therefore a
**mixed-coordinate product**: some axes are unit-scaled, others are in physical units.
There is no canonical norm or distance in optimizer space; Euclidean geometry does not
apply across axes.

Linear constraint transformation
---------------------------------
Constraints are defined over the optimizer basis (independent parameters only).
Dependent parameters are excluded: lifting constraints through the dependency embedding
is not defined in the linear constraint formalism.  Feasibility of dependent parameters
is enforced after embedding in ``ParameterSpace.set_values`` via parameter validation.

The affine transform ``x_phys = lb + span * x_norm`` (where ``span = ub - lb``) holds
only for parameters with an active affine normalizer.  Parameters without normalization
pass through unchanged.  ``TransformedSpace`` raises at constraint assembly time when a
constrained parameter uses a non-affine normalizer (e.g. ``LogNormalizer``); use
``ParameterSpace`` directly with physical-unit coordinates in that case.

As a result, constraint satisfaction in optimizer space is not equivalent to physical
feasibility when nonlinear normalizers or dependency transforms are involved.  Sampling
must be followed by post-hoc validation via ``ParameterSpace.set_values``.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

import numpy as np
import numpy.typing as npt

from CADETProcess.parameter_space.constraints import (
    LinearConstraint,
    LinearEqualityConstraint,
)
from CADETProcess.parameter_space.parameters import ParameterBase, RangedParameter
from CADETProcess.parameter_space.space import ParameterSpace

__all__ = ["TransformedSpace"]


[docs] class TransformedSpace: """Optimizer-facing view of a ``ParameterSpace`` in normalized coordinates. All bounds and constraint matrices are expressed in the normalized coordinate system. The underlying ``ParameterSpace`` is the source of truth; ``TransformedSpace`` derives everything from it lazily. Every method is a pure function: writing goes through ``ParameterSpace`` exclusively. Parameters ---------- space : ParameterSpace The physical-unit parameter space this view wraps. Examples -------- :: space = ParameterSpace() space.add_evaluation_object(process) space.add_parameter( RangedParameter("length", float, lb=0.1, ub=1.0, normalization="linear"), path="column.length", ) ts = TransformedSpace(space) # 0.5 normalized → 0.55 physical space.set_values(ts.decode(space.denormalize([0.5]))) """ def __init__(self, space: ParameterSpace) -> None: self._space = space @property def space(self) -> ParameterSpace: """The underlying ``ParameterSpace``.""" return self._space # ── Delegation ──────────────────────────────────────────────────────────── @property def evaluation_objects(self) -> list[Any]: """Registered evaluation objects (delegates to the underlying space).""" return self._space.evaluation_objects @property def parameters(self) -> list[ParameterBase]: """All registered parameters (delegates to the underlying space).""" return self._space.parameters @property def independent_parameters(self) -> list[ParameterBase]: """Independent (optimizer-facing) parameters.""" return self._space.independent_parameters @property def n_variables(self) -> int: """Number of independent (optimizer-facing) variables.""" return self._space.n_variables # ── Normalized bounds ───────────────────────────────────────────────────── @property def lower_bounds(self) -> np.ndarray: """Lower bounds in the optimizer coordinate system. Each value is the per-parameter normalization of the physical lower bound. Parameters with an active normalizer and finite bounds return 0; parameters without normalization pass through the identity chart and retain their physical-unit value. The result is a mixed-coordinate vector: axes are not globally comparable. """ return self._space.normalize(self._space.lower_bounds_independent) @property def upper_bounds(self) -> np.ndarray: """Upper bounds in the optimizer coordinate system. Each value is the per-parameter normalization of the physical upper bound. Parameters with an active normalizer and finite bounds return 1; parameters without normalization pass through the identity chart and retain their physical-unit value. The result is a mixed-coordinate vector: axes are not globally comparable. """ return self._space.normalize(self._space.upper_bounds_independent) # ── Vectorization ───────────────────────────────────────────────────────── @property def _numeric_independent_parameters(self) -> list[RangedParameter]: """Independent numeric parameters, in registration order.""" return [ p for p in self._space.independent_parameters if isinstance(p, RangedParameter) ]
[docs] def encode(self, assignment: Mapping[str, Any]) -> np.ndarray: """Project a named assignment onto the numeric parameter vector. The vector spans the independent numeric parameters in registration order. Dependent and categorical parameters have no vector position; their entries are dropped (``encode`` is a lossy projection). Parameters ---------- assignment : Mapping Named values in physical units. Must contain every independent numeric parameter; registered dependent or categorical names are ignored. Returns ------- np.ndarray Values of the independent numeric parameters, in physical units. Raises ------ ValueError If the assignment contains unknown names or misses an independent numeric parameter. """ known = {p.name for p in self._space.parameters} unknown = [name for name in assignment if name not in known] if unknown: raise ValueError(f"Unknown parameter names: {unknown!r}.") numeric = self._numeric_independent_parameters missing = [p.name for p in numeric if p.name not in assignment] if missing: raise ValueError( f"Assignment misses independent numeric parameters: {missing!r}." ) return np.array([float(assignment[p.name]) for p in numeric])
[docs] def decode( self, x_num: npt.ArrayLike, categorical_values: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Embed a numeric parameter vector into a named assignment. The returned assignment carries canonical Python types: ``int`` for integer parameters (rounded), ``float`` for continuous ones, plus the caller-supplied categorical values. When the space contains categorical parameters, the assignment cannot be reconstructed from the vector alone; *categorical_values* must supply every categorical parameter. Parameters ---------- x_num : array-like Values of the independent numeric parameters in **physical** units, in registration order. categorical_values : Mapping, optional Values for the categorical parameters. Required when the space contains categorical parameters; must cover exactly those. Returns ------- dict Named physical assignment of the independent parameters, ordered by registration. Raises ------ ValueError If the vector length does not match the number of independent numeric parameters, if *categorical_values* contains unknown names, or if it misses a categorical parameter (including the case where the space has categorical parameters and *categorical_values* is None). """ numeric = self._numeric_independent_parameters x = np.asarray(x_num, dtype=float).ravel() if x.size != len(numeric): raise ValueError( f"Expected {len(numeric)} numeric values, got {x.size}." ) categorical = self._space.categorical_parameters categorical_names = {p.name for p in categorical} if categorical_values is None: categorical_values = {} unknown = [n for n in categorical_values if n not in categorical_names] if unknown: raise ValueError(f"Unknown categorical parameter names: {unknown!r}.") missing = [p.name for p in categorical if p.name not in categorical_values] if missing: raise ValueError( f"Values for categorical parameters {missing!r} are required; " "they have no position in the numeric vector." ) numeric_values = { p.name: int(np.round(v)) if p.parameter_type is int else float(v) for p, v in zip(numeric, x) } return { p.name: ( categorical_values[p.name] if p.name in categorical_names else numeric_values[p.name] ) for p in self._space.independent_parameters }
# ── Constraint matrices ─────────────────────────────────────────────────── def _assemble_matrices( self, constraints: list[LinearConstraint] | list[LinearEqualityConstraint], ) -> tuple[np.ndarray, np.ndarray]: """Return ``(A, b)`` in normalized coordinates. Parameters ---------- constraints : list List of ``LinearConstraint`` or ``LinearEqualityConstraint`` objects. Returns ------- A : np.ndarray, shape (n_constraints, n_variables) b : np.ndarray, shape (n_constraints,) Raises ------ ValueError If any constrained parameter uses a non-linear normalizer. """ n = self._space.n_variables n_c = len(constraints) if n_c == 0: return np.zeros((0, n)), np.zeros(0) param_index = { p.name: i for i, p in enumerate(self._space.independent_parameters) } lb_phys = self._space.lower_bounds_independent ub_phys = self._space.upper_bounds_independent A = np.zeros((n_c, n)) b = np.empty(n_c) for row_idx, constraint in enumerate(constraints): b_adj = 0.0 for p, coeff in zip(constraint.parameters, constraint.lhs): if p.name not in param_index: # Dependent parameters are not part of the optimizer basis; # feasibility is enforced after embedding in set_values. continue i = param_index[p.name] p_lb = lb_phys[i] p_ub = ub_phys[i] if ( isinstance(p, RangedParameter) and np.isfinite(p_lb) and np.isfinite(p_ub) and p.normalization is not None ): if not p.normalizer.is_linear: raise ValueError( f"Parameter {p.name!r} uses a non-affine normalizer. " "Linear constraints involving non-affinely-normalized " "parameters are not linearly representable in normalized " "coordinates and cannot be assembled by TransformedSpace. " "Use ParameterSpace directly with physical-unit coordinates." ) span = p_ub - p_lb A[row_idx, i] += coeff * span b_adj += coeff * p_lb else: A[row_idx, i] += coeff b[row_idx] = constraint.b - b_adj return A, b @property def A(self) -> np.ndarray: """Inequality constraint matrix in normalized coordinates, shape ``(m, n)``.""" return self._assemble_matrices(self._space.linear_constraints)[0] @property def b(self) -> np.ndarray: """Inequality constraint RHS in normalized coordinates, shape ``(m,)``.""" return self._assemble_matrices(self._space.linear_constraints)[1] @property def A_eq(self) -> np.ndarray: """Equality constraint matrix in normalized coordinates, shape ``(m, n)``.""" return self._assemble_matrices(self._space.linear_equality_constraints)[0] @property def b_eq(self) -> np.ndarray: """Equality constraint RHS in normalized coordinates, shape ``(m,)``.""" return self._assemble_matrices(self._space.linear_equality_constraints)[1] # ── Validate ──────────────────────────────────────────────────────────────
[docs] def get_dependent_values(self, x: npt.ArrayLike) -> np.ndarray: """Expand normalized independent values to the full physical parameter vector. Parameters ---------- x : array-like Values for the ``n_variables`` independent parameters in **normalized** coordinates. Returns ------- np.ndarray Full physical parameter vector of length ``n_parameters``. """ return self._space.get_dependent_values(x, denormalize=True)
[docs] def check_bounds(self, x: npt.ArrayLike, tol: float | npt.ArrayLike = 0.0) -> bool: """Return True when *x* (in normalized coordinates) satisfies physical bounds. Parameters ---------- x : array-like Values for the independent parameters in **normalized** coordinates. tol : float or array-like Per-variable tolerance added to each bound before checking. """ x_phys = self._space.denormalize(x) return self._space.check_bounds(x_phys, tol=tol, resolve_dependencies=True)
[docs] def validate_x( self, x: npt.ArrayLike, tol: float = 0.0, tol_eq: float = 1e-6, ) -> bool | np.ndarray: """Return True if *x* (normalized) satisfies bounds and all linear constraints. Parameters ---------- x : array-like Normalized independent parameter vector, shape ``(n_variables,)`` for a single point or ``(m, n_variables)`` for a population. tol : float Tolerance applied to bounds and inequality constraints (inclusive). tol_eq : float Tolerance applied to equality constraints. Returns ------- bool or np.ndarray of bool Scalar for a single point; 1-D boolean array for a population. """ x_arr = np.asarray(x, dtype=float) population = x_arr.ndim == 2 rows = x_arr if population else x_arr[np.newaxis, :] results = [ self._space.validate_x(self.get_dependent_values(row), tol=tol, tol_eq=tol_eq) for row in rows ] if population: return np.array(results) return results[0]
# ── Misc ────────────────────────────────────────────────────────────────── def __repr__(self) -> str: """Return a readable representation.""" return f"TransformedSpace({self._space!r})"