Source code for CADETProcess.problem

"""
=========================================
Problem (:mod:`CADETProcess.problem`)
=========================================

.. currentmodule:: CADETProcess.problem

``Problem`` is the general problem description, independent of any
optimizer: a ``ParameterSpace`` (input domain), a ``MetricSpace`` (output
declarations), and an ``EvaluationBackend`` that computes the declared
metrics for a named parameter assignment.  It is directly usable for
sensitivity analysis, design-space exploration, surrogate training, and
emulation; ``OptimizationProblem`` extends it with objective/constraint
policy.

Discipline rule: if a sampler or surrogate would not call it, it does not
go on ``Problem``.  Direction-aware evaluation, ``bad_metrics``
substitution, and callbacks belong to ``OptimizationProblem``.

.. autosummary::
    :toctree: generated/

    Problem
    EvaluationBackend

"""  # noqa

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any, Optional, Protocol, runtime_checkable

import numpy as np

from CADETProcess.evaluation_pipeline import EvaluationFailure
from CADETProcess.metric_space import MetricSpace
from CADETProcess.parameter_space import ParameterSpace

__all__ = ["EvaluationBackend", "Problem"]


[docs] @runtime_checkable class EvaluationBackend(Protocol): """Protocol for anything that evaluates a named parameter assignment. Satisfied by both ``EvaluationPipeline`` and surrogate models. The returned mapping must contain every metric declared in the problem's ``MetricSpace``; additional keys (pipeline intermediates) are allowed. """
[docs] def evaluate( self, assignment: Mapping[str, Any], targets: list[str] | None = None, ) -> dict[str, Any]: """Compute named outputs for a named parameter assignment. Backends may ignore *targets* and compute everything; ``Problem.evaluate`` filters the result either way. """ ...
[docs] class Problem: """General problem description: parameter space, metric space, backend. Concrete and directly usable; samplers and surrogates consume it without any optimizer policy. Parameters ---------- parameter_space : ParameterSpace, optional Input domain. A fresh empty space is created when omitted. metric_space : MetricSpace, optional Output declarations. A fresh empty space is created when omitted. backend : EvaluationBackend, optional Computes the declared metrics for a named assignment. ``evaluate`` raises until a backend is set. name : str, optional Problem name; used in the string representation. Examples -------- >>> problem = Problem(parameter_space, metric_space, backend=pipeline) >>> results = problem.evaluate({"length": 0.5}) >>> surrogate_problem = problem.with_evaluator(surrogate) """ def __init__( self, parameter_space: Optional[ParameterSpace] = None, metric_space: Optional[MetricSpace] = None, backend: Optional[EvaluationBackend] = None, name: Optional[str] = None, ) -> None: if parameter_space is None: parameter_space = ParameterSpace() elif not isinstance(parameter_space, ParameterSpace): raise TypeError( f"Expected ParameterSpace, got {type(parameter_space).__name__}." ) if metric_space is None: metric_space = MetricSpace() elif not isinstance(metric_space, MetricSpace): raise TypeError( f"Expected MetricSpace, got {type(metric_space).__name__}." ) if backend is not None and not isinstance(backend, EvaluationBackend): raise TypeError( f"Backend {type(backend).__name__} does not satisfy " f"EvaluationBackend: it has no evaluate method." ) self._parameter_space = parameter_space self._metric_space = metric_space self._backend = backend self.name = name @property def parameter_space(self) -> ParameterSpace: """Input domain.""" return self._parameter_space @property def metric_space(self) -> MetricSpace: """Output declarations and annotations.""" return self._metric_space @property def backend(self) -> Optional[EvaluationBackend]: """Active evaluation backend; None until one is set.""" return self._backend
[docs] def with_evaluator(self, backend: EvaluationBackend) -> "Problem": """Return a new ``Problem`` with *backend* as evaluation backend. Non-mutating: the original problem keeps its backend and both share the same parameter and metric spaces. Always returns a plain ``Problem``, never a subclass: a surrogate-backed problem carries no optimizer policy. """ return Problem( parameter_space=self._parameter_space, metric_space=self._metric_space, backend=backend, name=self.name, )
[docs] def evaluate( self, assignment: Mapping[str, Any], targets: list[str] | None = None, ) -> dict[str, Any]: """Evaluate the declared metrics for a named parameter assignment. Delegates to the backend, then validates each declared metric against its declared shape. Backend outputs that are not declared in the metric space (pipeline intermediates) are dropped; declared metric names are always passed to the backend as its targets, so undeclared side-effect nodes (callbacks) never execute. ``EvaluationFailure`` values pass through unvalidated; substituting fallback values is optimizer policy and stays out of ``Problem``. A backend evaluating multiple evaluation objects returns per-object lists (the ``EvaluationPipeline`` convention). Such values are reduced to the metric's declared shape here: the entries belonging to the metric's declared ``evaluation_object`` coordinates are selected and stacked object-major. If any selected entry is an ``EvaluationFailure``, the selected per-object list passes through unvalidated instead. Parameters ---------- assignment : Mapping Values for the independent parameters by name, in physical units. targets : list[str], optional Declared metric names to evaluate. `None` evaluates all declared metrics. A pipeline backend only executes the subgraph the requested metrics need. Returns ------- dict[str, Any] Requested metrics in registration order; values are arrays in canonical shape, ``EvaluationFailure``, or a per-object list containing at least one ``EvaluationFailure``. Raises ------ RuntimeError If no backend is set. ValueError If *targets* contains an undeclared name, the backend result misses a declared metric, or a value does not match its declared shape. """ if self._backend is None: raise RuntimeError( "Problem has no evaluation backend. Construct with backend=... " "or use with_evaluator." ) metrics = self._metric_space.metrics if targets is not None: declared = {m.name for m in metrics} unknown = [t for t in targets if t not in declared] if unknown: raise ValueError(f"Unknown metric target(s): {unknown}") requested = set(targets) metrics = [m for m in metrics if m.name in requested] if not metrics: return {} raw = self._backend.evaluate(assignment, targets=[m.name for m in metrics]) results: dict[str, Any] = {} for metric in metrics: if metric.name not in raw: raise ValueError( f"Backend result is missing declared metric {metric.name!r}." ) value = raw[metric.name] has_object_dim = ( metric.coords is not None and "evaluation_object" in metric.coords ) if isinstance(value, EvaluationFailure): results[metric.name] = value elif has_object_dim and isinstance(value, list): results[metric.name] = self._reduce_per_object(metric, value) elif ( isinstance(value, list) and any(isinstance(v, EvaluationFailure) for v in value) ): # Per-object failures for a metric that declares no # evaluation_object dimension: the backend fanned out over # objects the metric does not know about. raise ValueError( f"Metric {metric.name!r} declares no evaluation_object " f"dimension but the backend returned per-object results. " f"This happens for metrics registered with " f"evaluation_objects=None while evaluation objects exist; " f"see the input-handling cleanup note in PROJECT.md." ) else: results[metric.name] = metric.validate(value) return results
[docs] def evaluate_batch( self, assignments: Sequence[Mapping[str, Any]], targets: list[str] | None = None, parallelization_backend: Any = None, ) -> list[dict[str, Any]]: """Evaluate the declared metrics for a batch of named assignments. Uniform batch entry point: optimizers, samplers, and surrogate trainers all dispatch populations through this method. Each assignment is evaluated via ``evaluate``; a row whose evaluation raises yields an ``EvaluationFailure`` for every requested target instead of aborting the batch. Substituting fallback values for failures remains caller policy. Parameters ---------- assignments : Sequence[Mapping] One named parameter assignment per row, in physical units. targets : list[str], optional Declared metric names to evaluate. `None` evaluates all declared metrics. parallelization_backend : ParallelizationBackendBase, optional When provided, rows are dispatched via ``backend.evaluate``. When None, evaluation is sequential. Returns ------- list[dict[str, Any]] One result mapping per assignment, in input order. Raises ------ RuntimeError If no backend is set. ValueError If *targets* contains an undeclared name. """ if self._backend is None: raise RuntimeError( "Problem has no evaluation backend. Construct with backend=... " "or use with_evaluator." ) declared = [m.name for m in self._metric_space.metrics] if targets is not None: unknown = [t for t in targets if t not in declared] if unknown: raise ValueError(f"Unknown metric target(s): {unknown}") names = targets if targets is not None else declared def _evaluate_one(assignment: Mapping[str, Any]) -> dict[str, Any]: try: return self.evaluate(assignment, targets=targets) except Exception as e: return { name: EvaluationFailure(stage=name, reason=str(e), exc=e) for name in names } assignments = list(assignments) if parallelization_backend is None: return [_evaluate_one(assignment) for assignment in assignments] return list(parallelization_backend.evaluate(_evaluate_one, assignments))
def _reduce_per_object(self, metric: Any, values: list[Any]) -> Any: """Reduce a per-object result list to the metric's declared shape. The list is indexed by the parameter space's registered evaluation objects; the metric's ``evaluation_object`` coordinates select the entries it covers. Failures keep per-object granularity so the optimizer layer can substitute fallbacks per object. """ coords = metric.coords["evaluation_object"] obj_names = [str(obj) for obj in self._parameter_space.evaluation_objects] missing = [c for c in coords if c not in obj_names] if missing: raise ValueError( f"Metric {metric.name!r}: declared evaluation object(s) " f"{missing} are not registered." ) selected = [values[obj_names.index(c)] for c in coords] if any(isinstance(v, EvaluationFailure) for v in selected): return selected flat = np.concatenate([ np.atleast_1d(np.asarray(v, dtype=float)) for v in selected ]) return metric.validate(flat.reshape(metric.shape)) def __str__(self) -> str: """Return the problem name, falling back to the class name.""" return self.name if self.name is not None else type(self).__name__ def __repr__(self) -> str: """Return a readable representation.""" return ( f"{type(self).__name__}(name={self.name!r}, " f"n_parameters={self._parameter_space.n_parameters}, " f"n_metrics={self._metric_space.n_metrics}, " f"backend={type(self._backend).__name__ if self._backend else None})" )