Source code for mergen.algorithms.base

"""
mergen.algorithms.base
======================
Abstract base class for space-filling design optimisation algorithms.

This module defines the contract that every optimisation algorithm in
Mergen must satisfy. New algorithms (SA, SCE, ESE, GA, …) are added by
subclassing :class:`BaseOptimizer` and registering them via
:func:`mergen.algorithms.register_optimizer`.

The design follows scikit-learn's template/strategy pattern
(``BaseEstimator``): the constructor only stores hyperparameters, no
work is done until :meth:`BaseOptimizer.optimize` is called. The result
object follows scipy.optimize's :class:`OptimizeResult` convention but is
implemented as a typed dataclass for static analysis support.

References
----------
Buitinck, L. et al. (2013). API design for machine learning software:
    experiences from the scikit-learn project. *ECML PKDD Workshop:
    Languages for Data Mining and Machine Learning*, 108-122.
Virtanen, P. et al. (2020). SciPy 1.0: fundamental algorithms for
    scientific computing in Python. *Nature Methods*, 17, 261-272.
    (:class:`scipy.optimize.OptimizeResult` convention)
"""

from __future__ import annotations

from abc      import ABC, abstractmethod
from dataclasses import dataclass, field
from typing   import TYPE_CHECKING, Any, Dict, Optional

import numpy as np

if TYPE_CHECKING:
    from mergen.space    import ParameterSpace
    from mergen.criteria import BaseCriterion


# ── Optimisation result ──────────────────────────────────────────────────
[docs] @dataclass class OptimisationResult: """ Result of a single optimisation run. Modelled on :class:`scipy.optimize.OptimizeResult` but implemented as a dataclass for type safety and IDE support. Algorithm-specific data (acceptance rates, temperatures, restart counts, etc.) are stored under :attr:`metadata`. Attributes ---------- design : np.ndarray, shape (n, d) Final optimised design in the original parameter space. score : float Final criterion value at the returned design. Lower is better (all criteria are minimised). converged : bool Whether the optimiser believes it converged to a (local) optimum. Algorithm-specific definition; see each algorithm's documentation. n_iter : int Total number of iterations performed (across all restarts if any). elapsed : float Wall-clock time of the optimisation in seconds. metadata : dict Algorithm-specific information. Standard keys (when applicable): - ``"n_accepted"`` : int — accepted moves (SA, SCE, ESE) - ``"n_rejected"`` : int — rejected moves - ``"n_restarts"`` : int — number of restarts performed - ``"score_history"`` : list[float] — best score per restart - ``"T_start"`` : float — initial temperature (SA, ESE) - ``"T_end"`` : float — final temperature - ``"acceptance_rate"`` : float — fraction of accepted moves - ``"algorithm"`` : str — algorithm name (e.g. 'sa', 'sce') - ``"params"`` : dict — hyperparameters used Notes ----- Use ``result.x`` as an alias for ``result.design`` and ``result.fun`` for ``result.score`` to maintain partial compatibility with the ``scipy.optimize.OptimizeResult`` interface. """ design: np.ndarray score: float converged: bool = False n_iter: int = 0 elapsed: float = 0.0 metadata: Dict[str, Any] = field(default_factory=dict) # ── SciPy-compatible aliases ───────────────────────────────────── @property def x(self) -> np.ndarray: """Alias for :attr:`design` (scipy.optimize compatibility).""" return self.design @property def fun(self) -> float: """Alias for :attr:`score` (scipy.optimize compatibility).""" return self.score @property def nit(self) -> int: """Alias for :attr:`n_iter` (scipy.optimize compatibility).""" return self.n_iter @property def success(self) -> bool: """Alias for :attr:`converged` (scipy.optimize compatibility).""" return self.converged def __repr__(self) -> str: n, d = self.design.shape return (f"OptimisationResult(design=(n={n}, d={d}), " f"score={self.score:.6g}, converged={self.converged}, " f"n_iter={self.n_iter}, elapsed={self.elapsed:.2f}s)")
# ── Abstract base class for optimisers ────────────────────────────────────
[docs] class BaseOptimizer(ABC): """ Abstract base class for space-filling design optimisers. Every optimisation algorithm in Mergen (SA, SCE, ESE, …) inherits from this class and implements two methods: 1. :meth:`get_default_params` (classmethod) — return default hyperparams 2. :meth:`optimize` — run the optimisation Hyperparameters are stored as instance attributes; the constructor populates them from the class defaults and any keyword arguments supplied by the user. Validation of parameter *values* (range checks, type checks) happens lazily inside :meth:`optimize`, not in :meth:`__init__`, following the scikit-learn convention. This allows :func:`sklearn.base.clone`-style cloning and grid search over hyperparameters. Subclass requirements --------------------- - Set the class attribute :attr:`name` to a short identifier (``'sa'``, ``'sce'``, ``'ese'``, …). This is the key used in the registry and by ``Sampler.set_optimizer(name, **kwargs)``. - Implement :meth:`get_default_params` to return a ``dict`` of hyperparameter names → default values. Every key here becomes a valid keyword argument to :meth:`__init__` and :meth:`set_params`. - Implement :meth:`optimize` to perform the actual optimisation. Examples -------- >>> class DummyOptimizer(BaseOptimizer): ... name = 'dummy' ... ... @classmethod ... def get_default_params(cls): ... return {'max_iter': 1000, 'seed_offset': 0} ... ... def optimize(self, initial_design, space, criterion, ... n_frozen=0, crit_start=0, seed=44, verbose=True): ... # ... actual algorithm here ... ... return OptimisationResult( ... design=initial_design, ... score=0.0, ... metadata={'algorithm': self.name}, ... ) See Also -------- mergen.algorithms.register_optimizer : Register a subclass. mergen.algorithms.get_optimizer : Look up a registered algorithm. """ # Subclasses MUST override this with their short identifier. name: str = "" # ── Initialisation ─────────────────────────────────────────────── def __init__(self, **kwargs: Any) -> None: """ Initialise the optimiser with default + user-supplied parameters. Parameters ---------- **kwargs Hyperparameter overrides. Only keys present in :meth:`get_default_params` are accepted; unknown keys raise :class:`ValueError`. Value validation is deferred to :meth:`optimize`. Raises ------ ValueError If ``kwargs`` contains an unknown hyperparameter. """ defaults = self.get_default_params() # Populate from defaults first for key, value in defaults.items(): setattr(self, key, value) # Apply user overrides for key, value in kwargs.items(): if key not in defaults: raise ValueError( f"{self.__class__.__name__}: unknown parameter " f"{key!r}. Valid parameters: {sorted(defaults)}" ) setattr(self, key, value) # ── Class-level: default parameters ──────────────────────────────
[docs] @classmethod @abstractmethod def get_default_params(cls) -> Dict[str, Any]: """ Return default hyperparameters for this optimiser. Subclasses override this to declare their hyperparameter schema. The dict keys define the legal keyword arguments to :meth:`__init__` and :meth:`set_params`; the values are used when the user does not supply an override. Returns ------- dict Mapping from parameter name to default value. """
# ── Instance-level: parameter management ─────────────────────────
[docs] def set_params(self, **kwargs: Any) -> 'BaseOptimizer': """ Set hyperparameters on this instance (scikit-learn convention). Parameters ---------- **kwargs Hyperparameter values to update. Unknown keys raise :class:`ValueError`. Returns ------- self Enables fluent chaining: ``opt.set_params(max_iter=1000).optimize(...)``. Raises ------ ValueError If any key is not a valid hyperparameter for this optimiser. """ defaults = self.get_default_params() for key, value in kwargs.items(): if key not in defaults: raise ValueError( f"{self.__class__.__name__}: unknown parameter " f"{key!r}. Valid parameters: {sorted(defaults)}" ) setattr(self, key, value) return self
[docs] def get_params(self) -> Dict[str, Any]: """ Return current hyperparameter values (scikit-learn convention). Returns ------- dict Snapshot of current hyperparameters. """ return {k: getattr(self, k) for k in self.get_default_params()}
# ── The actual work: subclasses implement this ───────────────────
[docs] @abstractmethod def optimize( self, initial_design: np.ndarray, space: 'ParameterSpace', criterion: 'BaseCriterion', n_frozen: int = 0, crit_start: int = 0, seed: int = 44, verbose: bool = True, banned: Optional[set] = None, ) -> OptimisationResult: """ Run the optimisation. ``banned`` is an optional set of grid indices that the swap operators must never select (used for user-supplied sets and externally loaded designs). Parameters ---------- initial_design : np.ndarray, shape (n, d) Starting design in the original parameter space. Built by :meth:`prepare_initial_design` (typically a balanced LHS plus the prescribed / focus anchor rows). space : ParameterSpace The parameter space — provides grid, constraints, parameter names, and value ranges. criterion : BaseCriterion The criterion to minimise. Algorithms call ``criterion.evaluate``, ``criterion.incremental``, ``criterion.begin_1d`` / ``criterion.try_1d`` as appropriate. n_frozen : int, optional The first ``n_frozen`` rows of ``initial_design`` are **fixed** and must not be altered. These are typically prescribed points the user requires in the final design. Default: 0. crit_start : int, optional The criterion is evaluated on ``initial_design[crit_start:]``. Rows ``[0, crit_start)`` participate in optimisation moves (they may be swapped) but are excluded from the criterion. Used for prescribed-but- not-criterion-included points. Default: 0. seed : int, optional Random seed for reproducibility. Default: 44. verbose : bool, optional Print progress information. Default: True. Returns ------- OptimisationResult The optimised design and metadata. Notes ----- Subclasses must: - Treat the first ``n_frozen`` rows as immutable. - Evaluate the criterion only on rows ``[crit_start:]``. - Respect any constraints in ``space._constraints``. - Populate ``metadata`` with at least the algorithm name and hyperparameters used. """
# ── Initial-design factory (overridable per algorithm) ─────────────
[docs] def prepare_initial_design( self, anchors: np.ndarray, budget: int, space: 'ParameterSpace', reserved: set, seed: int = 44, ) -> 'tuple[np.ndarray, set]': """ Build the starting design for this optimiser. The default implementation returns a **balanced Latin Hypercube** seed on top of the supplied ``anchors``. This matches the literature convention for SA (Morris & Mitchell 1995), SCE (Kang 2019), and ESE (Jin et al. 2005), which all expect an LHS as input. Subclasses may override this method if their algorithm requires a different initial design (e.g. a greedy maximin seed, a Sobol sequence, or a user-supplied design). Parameters ---------- anchors : np.ndarray, shape (k, d) Rows that must appear in the output unchanged (prescribed and focus points placed by the Sampler). budget : int Number of *additional* rows to add on top of the anchors. The returned design has ``len(anchors) + budget`` rows. space : ParameterSpace The parameter space. reserved : set Grid indices already in use; updated in place. seed : int, default 44 RNG seed for the LHS shuffles. Returns ------- design : np.ndarray, shape (len(anchors) + budget, d) reserved : set (updated) """ import numpy as np gs = space.grid_sampler() if budget <= 0: return anchors.copy(), reserved.copy() rng = np.random.default_rng(int(seed)) return gs.balanced_lhs_seed( selected = anchors, budget = budget, reserved = reserved, rng = rng, )
# ── Pretty printing ────────────────────────────────────────────── def __repr__(self) -> str: params = ", ".join( f"{k}={v!r}" for k, v in self.get_params().items() ) return f"{self.__class__.__name__}({params})"