Source code for mergen.criteria.base

"""
mergen.criteria.base
====================
Abstract base class and shared constants for space-filling design
criteria. Every criterion in ``mergen.criteria`` subclasses
:class:`BaseCriterion` and lives in its own module.

References
----------
Meyer, R. K. & Nachtsheim, C. J. (1995). The coordinate exchange
    algorithm for constructing exact optimal experimental designs.
    *Technometrics*, 37(1), 60-69.
Kang, L. (2019). Stochastic coordinate-exchange optimal designs
    with complex constraints. *Quality Engineering*, 31(3), 401-416.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Tuple

import numpy as np

# ── small floor to avoid log(0) and division by zero ─────────────────
_EPS = 1e-10


[docs] class BaseCriterion(ABC): """ Abstract base for space-filling design criteria. Subclasses implement :meth:`evaluate` and :meth:`incremental`. Fast 1D coordinate-exchange updates (:meth:`begin_1d`, :meth:`try_1d`) are provided in the base class as a fallback and may be overridden by criteria with separable structure for an O(n) inner loop. Class attributes ---------------- supports_nominal : bool Whether the criterion is mathematically well-defined on parameter spaces containing nominal (unordered categorical) factors. ``False`` for distance- and discrepancy-based criteria whose metric assumes ordered levels; :class:`~mergen.sampler.Sampler` refuses to run such a criterion when the space has any nominal factor. Subclasses designed for mixed factor spaces (e.g. MaxProQQ, QQD) set this to ``True``. """ supports_nominal: bool = False
[docs] @abstractmethod def evaluate(self, X: np.ndarray, space) -> float: """ Compute the criterion value for design *X*. Parameters ---------- X : np.ndarray, shape (n, d) — normalised coordinates in [0, 1]^d space : ParameterSpace — used for dimension metadata if needed Returns ------- float — raw criterion score (not log-transformed) """
[docs] @abstractmethod def incremental( self, X: np.ndarray, i: int, new_pt: np.ndarray, space, current_score: float, ) -> Tuple[float, float]: """ O(n·d) incremental update when point *i* is swapped for *new_pt*. Parameters ---------- X : np.ndarray, shape (n, d) — current normalised design i : int — index of the point being swapped new_pt : np.ndarray, shape (d,) — candidate replacement (normalised) space : ParameterSpace current_score : float — raw criterion score before the swap Returns ------- log_delta : float log(new_score) - log(current_score). Negative → improvement; positive → degradation. new_score : float Raw criterion score after the swap. """
[docs] def begin_1d( self, X: np.ndarray, i: int, current_score: float, ): """ Initialise a per-point cache for fast 1D coordinate-exchange updates. Called once before trying multiple coordinate changes on the same point *i*. The returned cache object is then passed to :meth:`try_1d` for each candidate value. Subclasses with separable structures (UMaxPro, MaxPro, PhiP) override this to precompute per-pair contributions, enabling O(n) per-try updates instead of O(n·d). Default implementation stores ``(X, i, current_score)`` for the base :meth:`try_1d` fallback, which builds the full new point and delegates to :meth:`incremental`. Parameters ---------- X : np.ndarray, shape (n, d) — current normalised design i : int — index of the point to be modified current_score : float — raw criterion score before any change Returns ------- cache : object Opaque structure used by :meth:`try_1d`. Subclass-specific. """ return {'X': X, 'i': i, 'score': current_score}
[docs] def try_1d( self, cache, axis: int, new_value: float, space, ) -> Tuple[float, float, object]: """ Try changing one coordinate of the cached point and return the delta. For separable criteria, this runs in O(n). For non-separable criteria (CD2, StratifiedL2), the base implementation falls back to the full O(n·d) :meth:`incremental` evaluation. The returned cache reflects the *trial* state and may be reused for further trials on the same point. It does not modify the original design array. Parameters ---------- cache : object — returned by :meth:`begin_1d` axis : int — coordinate index being changed (0 ≤ axis < d) new_value : float — new normalised value at coordinate ``axis`` space : ParameterSpace Returns ------- log_delta : float — log(new_score) − log(current_score) new_score : float — raw criterion score after the change new_cache : object — cache updated to reflect the trial change """ X = cache['X'] i = cache['i'] s = cache['score'] new_pt = X[i].copy() new_pt[axis] = new_value log_delta, new_score = self.incremental(X, i, new_pt, space, s) new_cache = {'X': X, 'i': i, 'score': new_score} return log_delta, new_score, new_cache
def __repr__(self) -> str: return f"{self.__class__.__name__}()"