Source code for saealib.acquisition.base

"""
Acquisition function base module.

This module defines the abstract base class for acquisition (infill criterion)
functions used in surrogate-assisted optimization.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any

import numpy as np

from saealib.core.contracts import (
    MANY,
    ComponentContract,
    DataSpec,
    PortContract,
    PortDirection,
    PortSpec,
    ServiceRequirement,
)
from saealib.exceptions import ValidationError

if TYPE_CHECKING:
    from saealib.context import OptimizationState
    from saealib.population import Archive
    from saealib.surrogate.prediction import SurrogatePrediction


def direction_to_minimize_sign(direction: np.ndarray | None) -> np.ndarray | float:
    """Return the multiplicative sign converting objectives to minimize-space.

    Every direction-sensitive acquisition function's formulas are written
    assuming minimization. Multiplying a raw-objective-space quantity
    (``archive.f``, ``prediction.value``, a user-supplied ``reference``) by
    this sign converts it into minimize-space before the formula runs.
    Uncertainty magnitudes (``prediction.std``, ``sigma``) must never be
    multiplied by this sign.

    Parameters
    ----------
    direction : np.ndarray or None
        Per-objective optimization direction (+1 = maximize, -1 = minimize).
        shape: (n_obj,). ``None`` means already-minimize.

    Returns
    -------
    np.ndarray or float
        ``-direction`` if *direction* is given, else the scalar ``1.0``.
        Both broadcast correctly against ``(n_obj,)`` or ``(n, n_obj)`` arrays.
    """
    return -direction if direction is not None else 1.0


# Sentinel distinct from `None`, since a `prepare()`/`compute_reference()`
# implementation may legitimately return `None` as its prepared reference.
_UNSET = object()


@dataclass
class AcquisitionResult:
    """
    Result of :meth:`AcquisitionFunction.evaluate`.

    Attributes
    ----------
    scores : np.ndarray or None
        Acquisition scores. shape: (n_candidates,), dtype float64. Higher
        values are better.
    artifacts : dict[str, Any]
        Additional per-call information (e.g. joint or set-valued
        acquisition output) not captured by ``scores``.
    """

    scores: np.ndarray | None = None
    artifacts: dict[str, Any] = field(default_factory=dict)


[docs] class AcquisitionFunction(ABC): """ Abstract base class for acquisition functions (infill criteria). An acquisition function converts a SurrogatePrediction (and/or the Archive) into a scalar score per candidate, which is used to rank candidates for true evaluation. It is completely decoupled from Surrogate/SurrogateManager: it knows nothing about how predictions are generated. """ # Optimizer.validate() cross-checks this with surrogate.provides_uncertainty. requires_uncertainty: bool = False # Optimizer._inject_acquisition_directions() only auto-injects # problem.direction into acquisition functions that opt in via this flag. direction_sensitive: bool = True
[docs] def contract(self) -> ComponentContract: """Return the acquisition contract.""" return ComponentContract( required_services=(ServiceRequirement(name="FeatureEncoder"),), ports={ "acquisition": PortContract( inputs=( PortSpec( name="prediction", direction=PortDirection.INPUT, data=DataSpec(kind="SurrogatePrediction"), cardinality=MANY, optional=True, ), ), outputs=( PortSpec( name="scores", direction=PortDirection.OUTPUT, data=DataSpec(kind="RowPredicate"), cardinality=MANY, ), ), ), }, )
[docs] def prepare(self, archive: Archive, ctx: OptimizationState | None = None) -> Any: """ Compute a generation-scoped reference value shared across ``evaluate()`` calls. Concrete hook returning ``None`` by default. :class:`PointwiseAcquisition` overrides it with the ``compute_reference()`` behavior; archive-only acquisitions need not override it. Parameters ---------- archive : Archive Archive of evaluated solutions. ctx : OptimizationState or None, optional Optimization state, used here only for ``ctx.rng``. Returns ------- Any A prepared reference value, passed to ``evaluate(..., prepared=...)``. """ return None
[docs] @abstractmethod def evaluate( self, candidates_x: np.ndarray, prediction: SurrogatePrediction | None, archive: Archive, ctx: OptimizationState | None = None, *, prepared: Any = _UNSET, ) -> AcquisitionResult: """ Compute acquisition scores for a set of candidates. Parameters ---------- candidates_x : np.ndarray Candidate design vectors. shape: (n_candidates, dim). prediction : SurrogatePrediction or None Surrogate predictions for ``candidates_x``, or ``None`` for an archive-only acquisition that needs no prediction. archive : Archive Archive of evaluated solutions. ctx : OptimizationState or None, optional Optimization state, used here only for ``ctx.rng``. prepared : Any, optional A previously computed reference value, as returned by ``prepare()``. The default sentinel ``_UNSET`` means "compute it now via ``prepare(archive, ctx)``"; an explicit ``None`` is a valid prepared value in its own right and skips ``prepare()``. Returns ------- AcquisitionResult ``scores`` is an owned ``(n_candidates,)`` float64 array; higher is better. """ ...
class PointwiseAcquisition(AcquisitionFunction): """ Base for acquisitions using the existing ``compute_reference()``/``score()`` split. Concrete subclasses implement ``compute_reference()`` (once per prepared reference) and ``score()`` (per candidate batch, given that reference). """ def contract(self) -> ComponentContract: """Return the pointwise acquisition contract.""" contract = super().contract() role = contract.ports["acquisition"] return replace( contract, ports={ **contract.ports, "acquisition": replace( role, inputs=tuple(replace(port, optional=False) for port in role.inputs), ), }, ) @abstractmethod def compute_reference( self, archive: Archive, rng: np.random.Generator | None = None, ) -> Any: """ Compute the reference value required by this acquisition function. Each acquisition function derives its appropriate reference from the archive. If ``self.reference`` is set (injected externally at construction or later), implementations should return it instead of computing from the archive, allowing users to supply domain knowledge or a fixed reference point. Parameters ---------- archive : Archive Archive of evaluated solutions. rng : np.random.Generator or None, optional Random number generator from ``ctx.rng``. Implementations that require randomness should prefer this over a stored ``_rng`` so that all randomness flows through the single master RNG. Returns ------- Any Reference value passed to ``score``. Return ``None`` if this acquisition function does not use a reference. """ ... @abstractmethod def score( self, prediction: SurrogatePrediction, reference: Any, rng: np.random.Generator | None = None, ) -> np.ndarray: """ Compute acquisition scores for a set of candidates. Parameters ---------- prediction : SurrogatePrediction Predictions from a surrogate model. reference : Any Reference value produced by ``compute_reference``. rng : np.random.Generator or None, optional Random number generator from ``ctx.rng``. Returns ------- np.ndarray Acquisition scores. shape: (n_samples,) Higher scores indicate more promising candidates. """ ... def prepare(self, archive: Archive, ctx: OptimizationState | None = None) -> Any: """Delegate to ``compute_reference(archive, rng=ctx.rng if ctx else None)``.""" rng = ctx.rng if ctx is not None else None return self.compute_reference(archive, rng=rng) def evaluate( self, candidates_x: np.ndarray, prediction: SurrogatePrediction | None, archive: Archive, ctx: OptimizationState | None = None, *, prepared: Any = _UNSET, ) -> AcquisitionResult: """ Reject ``prediction is None``, resolve the reference, then ``score()``. See :meth:`AcquisitionFunction.evaluate` for the shared parameter contract, including the ``_UNSET`` vs. explicit ``prepared=None`` distinction. """ if len(candidates_x) == 0: return AcquisitionResult(scores=np.empty(0, dtype=np.float64)) if prediction is None: raise TypeError(f"{type(self).__name__} requires a prediction, got None") reference = self.prepare(archive, ctx) if prepared is _UNSET else prepared rng = ctx.rng if ctx is not None else None scores = self.score(prediction, reference, rng=rng) scores = np.array(scores, dtype=np.float64, order="C", copy=True) if scores.shape != (len(candidates_x),): raise ValidationError( f"{type(self).__name__}.score() returned shape {scores.shape}, " f"expected ({len(candidates_x)},)" ) return AcquisitionResult(scores=scores) class CompositeAcquisition(AcquisitionFunction): """ Combine per-channel acquisition scores from a multi-channel prediction. Pairs with :class:`~saealib.surrogate.manager.CompositeSurrogateManager` The manager only composes predictions (one named ``PredictionChannel`` per sub-manager); this acquisition composes the *scores*. It never stores or invokes a ``SurrogateManager`` -- it only projects each configured channel out of an already-produced ``SurrogatePrediction`` (via :meth:`SurrogatePrediction.select_channel`) and hands it to that channel's child ``AcquisitionFunction``. Parameters ---------- acquisitions : dict[str, AcquisitionFunction] Maps a prediction channel name to the acquisition function that scores it. Must be non-empty. Insertion order is the deterministic order of the score arrays passed to ``combine_fn``. combine_fn : callable(list[np.ndarray]) -> np.ndarray Accepts a list of score arrays (each shape ``(n_candidates,)``, in ``acquisitions`` insertion order) and returns a single combined score array of the same shape. Use :func:`~saealib.surrogate.manager.product_combine` for element-wise product (e.g. EI x PoF) or :func:`~saealib.surrogate.manager.rank_weighted_combine` for a rank-normalised weighted average. """ def __init__( self, acquisitions: dict[str, AcquisitionFunction], combine_fn: Callable[[list[np.ndarray]], np.ndarray], ): if not acquisitions: raise ValueError("CompositeAcquisition requires at least one acquisition.") self.acquisitions = acquisitions self.combine_fn = combine_fn def prepare(self, archive: Archive, ctx: OptimizationState | None = None) -> Any: """Prepare every child acquisition; returns ``{name: child_prepared}``.""" return { name: acq.prepare(archive, ctx) for name, acq in self.acquisitions.items() } def evaluate( self, candidates_x: np.ndarray, prediction: SurrogatePrediction | None, archive: Archive, ctx: OptimizationState | None = None, *, prepared: Any = _UNSET, ) -> AcquisitionResult: """ Evaluate every configured child acquisition, then ``combine_fn`` the scores. See :meth:`AcquisitionFunction.evaluate` for the shared parameter contract. Raises ------ TypeError If ``prediction is None``. ValidationError If a configured channel is missing from ``prediction.channels``, a child acquisition returns no scores, a child's or the combined result's score shape is not ``(len(candidates_x),)``. """ if len(candidates_x) == 0: return AcquisitionResult(scores=np.empty(0, dtype=np.float64)) if prediction is None: raise TypeError(f"{type(self).__name__} requires a prediction, got None") n = len(candidates_x) prepared_map = self.prepare(archive, ctx) if prepared is _UNSET else prepared score_list: list[np.ndarray] = [] for name, acq in self.acquisitions.items(): if name not in prediction.channels: raise ValidationError( f"CompositeAcquisition: missing configured channel {name!r}" ) projected = prediction.select_channel(name) child_prepared = ( prepared_map[name] if isinstance(prepared_map, dict) else _UNSET ) result = acq.evaluate( candidates_x, projected, archive, ctx, prepared=child_prepared ) if result.scores is None: raise ValidationError( f"CompositeAcquisition: acquisition {name!r} returned no scores" ) if result.scores.shape != (n,): raise ValidationError( f"CompositeAcquisition: acquisition {name!r} returned scores of " f"shape {result.scores.shape}, expected ({n},)" ) score_list.append(result.scores) combined = np.array(self.combine_fn(score_list), dtype=np.float64, copy=True) if combined.shape != (n,): raise ValidationError( f"CompositeAcquisition: combine_fn returned shape {combined.shape}, " f"expected ({n},)" ) return AcquisitionResult(scores=combined)