Source code for saealib.acquisition.batch

"""Joint batch improvement acquisitions."""

from __future__ import annotations

from dataclasses import replace

import numpy as np

from saealib.acquisition.base import (
    AcquisitionFunction,
    AcquisitionResult,
    direction_to_minimize_sign,
)
from saealib.core.contracts import ComponentContract, StateContract
from saealib.core.state import RUNTIME_RNG
from saealib.exceptions import ValidationError
from saealib.registry import register


def _normal_draws(prediction, ctx, n_draws: int) -> np.ndarray:
    channel = prediction.channels["objective"]
    mean = channel.value
    if mean.shape[1] != 1:
        raise ValidationError("joint expected improvement requires one objective")
    if channel.std is None:
        raise ValidationError("joint improvement requires uncertainty")
    std = channel.std[:, 0]
    covariance = channel.covariance
    if covariance is None:
        covariance = np.diag(std * std)
    covariance = np.asarray(covariance, dtype=np.float64)
    if covariance.shape != (len(mean), len(mean)):
        raise ValidationError("joint covariance must have shape (n, n)")
    rng = ctx.rng if ctx is not None else np.random.default_rng(0)
    return rng.multivariate_normal(mean[:, 0], covariance, size=n_draws)


def _qei_order(values: np.ndarray, best: float) -> tuple[np.ndarray, np.ndarray]:
    selected: list[int] = []
    marginal = np.zeros(values.shape[1], dtype=np.float64)
    current = np.zeros(values.shape[0], dtype=np.float64)
    for _ in range(values.shape[1]):
        utilities = np.empty(values.shape[1], dtype=np.float64)
        for index in range(values.shape[1]):
            if index in selected:
                utilities[index] = -np.inf
                continue
            next_value = (
                np.minimum(current, values[:, index]) if selected else values[:, index]
            )
            utilities[index] = np.maximum(best - next_value, 0.0).mean()
        index = int(np.argmax(utilities))
        selected.append(index)
        next_value = (
            np.minimum(current, values[:, index])
            if len(selected) > 1
            else values[:, index]
        )
        utility = np.maximum(best - next_value, 0.0).mean()
        marginal[index] = utility - (
            np.maximum(best - current, 0.0).mean() if len(selected) > 1 else 0.0
        )
        current = next_value
    return np.asarray(selected, dtype=np.intp), marginal


[docs] @register() class BatchExpectedImprovement(AcquisitionFunction): """Monte Carlo qEI with candidate-joint covariance and greedy set utility.""" requires_uncertainty = True
[docs] def __init__(self, n_draws: int = 4096, direction: np.ndarray | None = None): if n_draws < 1: raise ValidationError("n_draws must be positive") self.n_draws = n_draws self.direction = direction
[docs] def contract(self) -> ComponentContract: """Return the batch expected-improvement 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), ), }, state=StateContract(reads=(RUNTIME_RNG,), writes=(RUNTIME_RNG,)), )
[docs] def evaluate(self, candidates_x, prediction, archive, ctx=None, *, prepared=None): """Return greedy qEI marginal scores from joint normal draws.""" if prediction is None: raise ValidationError("qEI requires a prediction") draws = _normal_draws(prediction, ctx, self.n_draws) direction = float( np.asarray(direction_to_minimize_sign(self.direction)).reshape(-1)[0] ) best = float(np.min(archive.f[:, 0] * direction)) order, marginal = _qei_order(draws * direction, best) scores = np.full(len(order), -np.inf, dtype=np.float64) scores[order] = np.arange(len(order), 0, -1, dtype=np.float64) return AcquisitionResult( scores=scores, artifacts={"joint": True, "order": order, "qei": marginal}, )