Source code for CADETProcess.metric_space.space

"""MetricSpace: output declarations and their problem-level annotations.

``MetricSpace`` collects ``Metric`` declarations and owns the annotations
that give them meaning in a problem context: optimization direction
(``Objective``), operator/bound constraints (``Constraint``), and output
normalization.  Direction and normalization are properties of the
registration, not of the metric itself: the same metric can be an
objective in one problem and a plain output in another.  A metric
without a direction annotation is just an output.

Only nonlinear constraints live here; linear constraints on parameters
stay in ``ParameterSpace``.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any, Literal, Optional

import numpy as np
import numpy.typing as npt

from CADETProcess.metric_space.metric import Metric
from CADETProcess.parameter_space.normalize import NormalizerBase

__all__ = ["MetricSpace", "Objective", "Constraint"]


[docs] class Objective: """Direction annotation on a metric. Parameters ---------- metric : Metric The annotated metric. minimize : bool Optimization direction; True minimizes, False maximizes. """ def __init__(self, metric: Metric, minimize: bool = True) -> None: self.metric = metric self.minimize = bool(minimize) @property def name(self) -> str: """Name of the annotated metric.""" return self.metric.name @property def labels(self) -> list[str]: """Labels of the annotated metric, one per scalar entry.""" return self.metric.labels @property def n_metrics(self) -> int: """Number of scalar entries of the annotated metric.""" return self.metric.n_metrics def __repr__(self) -> str: """Return a readable representation.""" return f"Objective(metric={self.metric!r}, minimize={self.minimize})"
[docs] class Constraint: """Operator/bound annotation on a metric; canonical form is ``g <= 0``. Parameters ---------- metric : Metric The annotated metric. bound : float or array-like Constraint bound. A scalar applies to every entry; an array must match the metric's ``n_metrics``. comparison_operator : {"le", "ge"} Direction of the comparison: ``value <= bound`` or ``value >= bound``. """ def __init__( self, metric: Metric, bound: float | npt.ArrayLike = 0.0, comparison_operator: Literal["le", "ge"] = "le", ) -> None: if comparison_operator not in ("le", "ge"): raise ValueError( f"Constraint on {metric.name!r}: comparison_operator must be " f"'le' or 'ge', got {comparison_operator!r}." ) self.metric = metric self.comparison_operator = comparison_operator try: self.bounds = np.broadcast_to( np.asarray(bound, dtype=float), (metric.n_metrics,) ).copy() except ValueError: raise ValueError( f"Constraint on {metric.name!r}: expected scalar bound or " f"{metric.n_metrics} bounds, got {np.shape(bound)}." ) from None @property def name(self) -> str: """Name of the annotated metric.""" return self.metric.name @property def labels(self) -> list[str]: """Labels of the annotated metric, one per scalar entry.""" return self.metric.labels @property def n_metrics(self) -> int: """Number of scalar entries of the annotated metric.""" return self.metric.n_metrics
[docs] def violation(self, values: Any) -> np.ndarray: """Signed violation in canonical ``g <= 0`` form. Positive entries violate the constraint. Returns a 1-D array of length ``n_metrics``. """ arr = self.metric.validate(values).reshape(-1) if self.comparison_operator == "le": return arr - self.bounds return self.bounds - arr
def __repr__(self) -> str: """Return a readable representation.""" return ( f"Constraint(metric={self.metric!r}, bounds={self.bounds!r}, " f"comparison_operator={self.comparison_operator!r})" )
[docs] class MetricSpace: """Collection of metric declarations with problem-level annotations. Parallel to ``ParameterSpace`` on the output side: owns direction, normalization, and constraint annotations. Purely declarative; it performs no evaluation and holds no reference to an evaluation backend. Typical usage:: metric_space = MetricSpace() metric_space.add_objective(Metric("yield", n_metrics=2), minimize=False) metric_space.add_constraint("purity", bound=0.95, comparison_operator="ge") """ def __init__(self) -> None: self._metrics: list[Metric] = [] self._objectives: list[Objective] = [] self._constraints: list[Constraint] = [] self._normalizers: dict[str, NormalizerBase] = {} # ── Registration ──────────────────────────────────────────────────────────
[docs] def add_metric( self, metric: Metric | str, normalizer: Optional[NormalizerBase] = None, ) -> Metric: """Register a metric declaration without annotations. Parameters ---------- metric : Metric or str The declaration to register. A string is shorthand for a scalar ``Metric(name)``. normalizer : NormalizerBase, optional Output normalizer for this metric. Returns ------- Metric The registered metric. Raises ------ ValueError If a metric with the same name is already registered. """ if isinstance(metric, str): metric = Metric(metric) if metric.name in self.metrics_dict: raise ValueError(f"Metric {metric.name!r} is already registered.") self._metrics.append(metric) if normalizer is not None: self._normalizers[metric.name] = normalizer return metric
def _resolve_metric(self, metric: Metric | str) -> Metric: """Return the registered metric, registering new instances on the fly.""" if isinstance(metric, str): try: return self.metrics_dict[metric] except KeyError: raise ValueError(f"Unknown metric {metric!r}.") from None registered = self.metrics_dict.get(metric.name) if registered is None: return self.add_metric(metric) if registered is not metric: raise ValueError( f"A different metric named {metric.name!r} is already registered." ) return registered
[docs] def add_objective( self, metric: Metric | str, minimize: bool = True, ) -> Objective: """Annotate a metric with an optimization direction. Parameters ---------- metric : Metric or str A new metric (registered on the fly) or the name of a registered one. minimize : bool Optimization direction; True minimizes, False maximizes. Returns ------- Objective The direction annotation. Raises ------ ValueError If the metric already has a direction annotation. """ resolved = self._resolve_metric(metric) if any(objective.metric is resolved for objective in self._objectives): raise ValueError( f"Metric {resolved.name!r} already has a direction annotation." ) objective = Objective(resolved, minimize=minimize) self._objectives.append(objective) return objective
[docs] def add_constraint( self, metric: Metric | str, bound: float | npt.ArrayLike = 0.0, comparison_operator: Literal["le", "ge"] = "le", ) -> Constraint: """Annotate a metric with an operator/bound constraint. Parameters ---------- metric : Metric or str A new metric (registered on the fly) or the name of a registered one. bound : float or array-like Constraint bound; scalar or one per metric entry. comparison_operator : {"le", "ge"} Direction of the comparison. Returns ------- Constraint The constraint annotation. """ resolved = self._resolve_metric(metric) constraint = Constraint( resolved, bound=bound, comparison_operator=comparison_operator ) self._constraints.append(constraint) return constraint
[docs] def set_normalizer(self, metric: Metric | str, normalizer: NormalizerBase) -> None: """Attach an output normalizer to a registered metric.""" resolved = self._resolve_metric(metric) self._normalizers[resolved.name] = normalizer
# ── Metrics ─────────────────────────────────────────────────────────────── @property def metrics(self) -> list[Metric]: """All registered metrics.""" return list(self._metrics) @property def metrics_dict(self) -> dict[str, Metric]: """Registered metrics keyed by name.""" return {metric.name: metric for metric in self._metrics} @property def metric_names(self) -> list[str]: """Names of all registered metrics.""" return [metric.name for metric in self._metrics] @property def n_metrics(self) -> int: """Total number of scalar entries across all registered metrics.""" return sum(metric.n_metrics for metric in self._metrics) @property def labels(self) -> list[str]: """Flattened labels across all registered metrics.""" return [label for metric in self._metrics for label in metric.labels] # ── Objectives ──────────────────────────────────────────────────────────── @property def objectives(self) -> list[Objective]: """All direction annotations.""" return list(self._objectives) @property def objective_names(self) -> list[str]: """Names of all metrics with a direction annotation.""" return [objective.name for objective in self._objectives] @property def objective_labels(self) -> list[str]: """Flattened labels across all objectives.""" return [ label for objective in self._objectives for label in objective.labels ] @property def n_objectives(self) -> int: """Total number of scalar objective entries.""" return sum(objective.n_metrics for objective in self._objectives) @property def minimize(self) -> np.ndarray: """Direction per scalar objective entry; True minimizes.""" return np.array( [ objective.minimize for objective in self._objectives for _ in range(objective.n_metrics) ], dtype=bool, ) # ── Constraints ─────────────────────────────────────────────────────────── @property def constraints(self) -> list[Constraint]: """All constraint annotations.""" return list(self._constraints) @property def constraint_names(self) -> list[str]: """Names of all metrics with a constraint annotation.""" return [constraint.name for constraint in self._constraints] @property def constraint_labels(self) -> list[str]: """Flattened labels across all constraints.""" return [ label for constraint in self._constraints for label in constraint.labels ] @property def n_constraints(self) -> int: """Total number of scalar constraint entries.""" return sum(constraint.n_metrics for constraint in self._constraints) @property def constraints_bounds(self) -> np.ndarray: """Flattened bounds across all constraints.""" if not self._constraints: return np.empty(0) return np.concatenate( [constraint.bounds for constraint in self._constraints] ) # ── Validation and normalization ──────────────────────────────────────────
[docs] def validate(self, results: Mapping[str, Any]) -> dict[str, np.ndarray]: """Validate evaluation results against the declared metrics. Every registered metric must be present with its declared shape. Extra keys (pipeline intermediates) are ignored. Returns ------- dict[str, np.ndarray] Declared metrics in canonical shape, in registration order. Raises ------ ValueError If a declared metric is missing or has the wrong shape. """ missing = [name for name in self.metric_names if name not in results] if missing: raise ValueError(f"Missing metrics in results: {missing}.") return { metric.name: metric.validate(results[metric.name]) for metric in self._metrics }
[docs] def normalize(self, results: Mapping[str, Any]) -> dict[str, np.ndarray]: """Validate results and map metric values to normalized space. Metrics without a registered normalizer pass through unchanged. """ validated = self.validate(results) return { name: ( values if (normalizer := self._normalizers.get(name)) is None else np.asarray(normalizer.normalize(values)) ) for name, values in validated.items() }
[docs] def denormalize(self, results: Mapping[str, Any]) -> dict[str, np.ndarray]: """Validate results and map metric values back to physical space. Metrics without a registered normalizer pass through unchanged. """ validated = self.validate(results) return { name: ( values if (normalizer := self._normalizers.get(name)) is None else np.asarray(normalizer.denormalize(values)) ) for name, values in validated.items() }
def __repr__(self) -> str: """Return a readable representation.""" return ( f"MetricSpace(n_metrics={self.n_metrics}, " f"n_objectives={self.n_objectives}, " f"n_constraints={self.n_constraints})" )