Source code for mergen.sampler

"""
mergen.sampler
==============
Space-filling design via the Stochastic Coordinate Exchange (SCE) engine.

This module exposes four objects:

  - :class:`Sampler`        — configure and run the design construction.
  - :class:`SamplingResult` — container for design + validation + extras.
  - :class:`FocusPoint`     — denser sampling around a critical region.
  - :class:`ExclusionPoint` — repel sampling away from a region.

Quick start
-----------
::

    from mergen.space   import ParameterSpace
    from mergen.sampler import Sampler

    space   = ParameterSpace({'temperature': range(100, 400, 10),
                              'pressure'   : ('continuous', 0.5, 5.0)})
    sampler = Sampler(space)
    sampler.add_prescribed([[200, 2.5]], in_design=True,  in_optim=False)
    sampler.add_focus    ([350, 4.5], spread=1.5,         in_design=True, in_optim=True)
    sampler.add_exclusion([100, 0.5], spread=1.0)
    sampler.set_design(n_samples=30)
    sampler.set_sce(n_restarts=5)
    result  = sampler.run(criteria='umaxpro', seed=44)
    result.summary()

Algorithm
---------
The optimisation is a Stochastic Coordinate Exchange (SCE) outer-inner loop:

  * **Inner loop** — for each restart, repeatedly pick one row of the
    design and propose new values along **one coordinate at a time**,
    accepting changes that improve the criterion.  The 1D nature makes
    the proposal cost O(n) (see :mod:`mergen.criteria`) and reduces
    multi-dimensional feasibility constraints to one-dimensional box
    constraints, which is the key advantage demonstrated by
    Kang (2019).

  * **Outer loop** — Iterated Local Search (Lourenço, Martin & Stützle
    2003): when the inner loop stalls, perturb the current best design
    and run the inner loop again.  The best design across all restarts
    is returned.

The first restart is seeded by a greedy maximin scheme
(Morris & Mitchell 1995); subsequent restarts are seeded by ILS kicks
from the current best (rather than random restart) for variance
reduction across seeds.

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.
You, Y., Jin, G., Pan, Z. & Guo, R. (2021).
    MP-CE method for space-filling design in constrained space with
    multiple types of factors.
    *Mathematics*, 9(24), 3314.
Lourenço, H. R., Martin, O. C. & Stützle, T. (2003).
    Iterated Local Search.  In *Handbook of Metaheuristics*,
    Springer, 320–353.
Morris, M. D. & Mitchell, T. J. (1995).
    Exploratory designs for computational experiments.
    *J. Statist. Plan. Infer.*, 43, 381–402.
Loeppky, J. L., Sacks, J. & Welch, W. J. (2009).
    Choosing the sample size of a computer experiment: A practical
    guide.  *Technometrics*, 51(4), 366–376.
Kennard, R. W. & Stone, L. A. (1969).
    Computer aided design of experiments.
    *Technometrics*, 11(1), 137–148.
"""

from __future__ import annotations

import os
import random
import time
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

import numpy as np
import pandas as pd

from .space    import ParameterSpace, GridSampler
from .criteria import (
    BaseCriterion,
    get_criterion,
    list_criteria,
    nominal_supporting_criteria,
)

# ── Terminal colours ──────────────────────────────────────────────────────
_RED    = "\033[0;31m"
_GREEN  = "\033[0;32m"
_YELLOW = "\033[1;33m"
_CYAN   = "\033[0;36m"
_RESET  = "\033[0m"

# Small numerical floor used to avoid log(0) in score reporting only.
_EPS = 1e-300


def _info(msg: str)  -> None: print(f"  {_CYAN}[INFO]{_RESET}     {msg}")
def _warn(msg: str)  -> None: print(f"  {_YELLOW}[WARNING]{_RESET}  {msg}")
def _ok(msg: str)    -> None: print(f"  {_GREEN}[MERGEN]{_RESET}   {msg}")
def _fatal(msg: str) -> None:
    raise ValueError(f"\n{_RED}[MERGEN ERROR]{_RESET}  {msg}")


# ======================================================================
# Parallelism helpers
# ======================================================================

def _resolve_n_jobs(n_jobs: Optional[int]) -> int:
    """
    Convert a user-supplied ``n_jobs`` into a concrete positive integer
    in the range ``[1, cpu_count]`` following the joblib convention.

    Mapping (with ``cpu = os.cpu_count()``):

    - ``None``         -> 1
    - ``1, 2, ..., cpu`` -> as-is
    - ``-1``           -> cpu
    - ``-2``           -> cpu - 1
    - ``-N``           -> cpu - N + 1

    Invalid inputs (``0``, ``n > cpu``, ``n <= -cpu``, non-int) raise
    a fatal error so the user can correct the call before any heavy
    work starts.
    """
    cpu = os.cpu_count() or 1

    if n_jobs is None:
        return 1
    if not isinstance(n_jobs, (int, np.integer)) or isinstance(n_jobs, bool):
        _fatal(
            f"n_jobs must be an integer or None; got "
            f"{type(n_jobs).__name__}."
        )
    n_jobs = int(n_jobs)

    if n_jobs == 0:
        _fatal(
            "n_jobs=0 is not allowed. Use 1 for sequential, a positive "
            "integer for a fixed number of workers, or -1 to use all "
            f"{cpu} CPU(s)."
        )
    if n_jobs > cpu:
        _fatal(
            f"n_jobs={n_jobs} exceeds the number of available CPUs "
            f"({cpu}). Specify 1 <= n_jobs <= {cpu}, or use n_jobs=-1 "
            f"for all CPUs."
        )
    if n_jobs > 0:
        return n_jobs
    # n_jobs < 0
    if n_jobs <= -cpu:
        _fatal(
            f"n_jobs={n_jobs} is too negative. With {cpu} CPU(s), the "
            f"valid negative range is -{cpu - 1} <= n_jobs <= -1 "
            f"(equivalent positive values 1..{cpu})."
        )
    return cpu + 1 + n_jobs


def _select_repeat(reps, priority, space):
    """
    Pick the representative repeat from a list of OptimisationResults.

    With ``priority=None`` the repeat with the strictly lowest criterion
    score wins (ties: first in spawn order) — the classic best-of-k
    restarts rule.

    With a ``priority`` tuple of metric names, the repeat whose design
    is closest to the Utopia point of those metrics wins (Lu,
    Anderson-Cook & Robinson 2011). The optimiser minimises a smooth
    proxy criterion, but near-optimal proxy scores can correspond to
    designs with very different coverage properties; selecting the final
    design by the *true* objectives (optimise-by-proxy,
    select-by-objective) makes the delivered design stable under
    incidental setting changes such as n_restarts. Raw metric values are
    min-max normalised across the repeats (no Monte Carlo baseline
    needed), each oriented so 1 is best; ties break on the lower
    criterion score, then on spawn order. Deterministic throughout.
    """
    if len(reps) == 1:
        return reps[0]
    if not priority:
        best = reps[0]
        for r in reps[1:]:
            if r.score < best.score:
                best = r
        return best
    from . import metrics as _m
    import numpy as np
    gmins, granges = space.gmins, space.granges
    denom = np.where(granges > 1e-12, granges, 1.0)
    vals = np.empty((len(reps), len(priority)), dtype=float)
    for i, r in enumerate(reps):
        Xn = (np.asarray(r.design, dtype=float) - gmins) / denom
        for j, m in enumerate(priority):
            v = float(_m._compute_metric(m, Xn, space=space))
            vals[i, j] = v if m in _m._HIGHER_BETTER else -v
    lo, hi = vals.min(axis=0), vals.max(axis=0)
    span = np.where((hi - lo) > 1e-15, hi - lo, 1.0)
    norm = (vals - lo) / span                     # 1 = best per metric
    dist = np.linalg.norm(1.0 - norm, axis=1)     # Utopia distance
    order = sorted(range(len(reps)),
                   key=lambda i: (dist[i], reps[i].score, i))
    return reps[order[0]]


def _compare_one_job(sampler, cr, alg, seed, n_repeats):
    """
    Run one (criterion, algorithm) combination for ``compare`` through
    the public :meth:`Sampler.run`, so the two entry points share a
    single optimisation path and a single seed derivation. Returns the
    combination identity together with the list of repeat results (in
    spawn order), from which compare() averages the metrics and keeps the
    best repeat as the representative design.

    Top-level (module-scope) function so joblib's pickling-based
    ``multiprocessing`` backend can serialise the task; the ``Sampler``
    is passed explicitly and is itself picklable.
    """
    import contextlib
    import io
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        res = sampler.run(criteria=cr, algorithm=alg, seed=seed,
                          n_repeats=n_repeats, n_jobs=1, verbose=False)
    return (cr, alg), res._repeats_by_alg[alg]


def _run_one_algorithm_task(
    alg_name:        str,
    params:          dict,
    anchors:         np.ndarray,
    budget:          int,
    initial_reserved: set,
    space:           "ParameterSpace",
    criterion:       "BaseCriterion",
    prescribed_in_pts:           List[np.ndarray],
    focus_in_pts:                List[np.ndarray],
    in_design_sa_prescribed:     List[np.ndarray],
    focus_sa_in_design_sampled:  List[np.ndarray],
    n_dims:          int,
    n_frozen:        int,
    crit_start:      int,
    seed:            int,
    verbose:         bool,
):
    """
    Run a single optimiser end-to-end (initial design + optimise).

    Top-level (module-scope) function so that joblib's pickling-based
    multiprocessing backend can serialise the task. Called sequentially
    from ``Sampler.run`` when ``n_jobs == 1`` and from a joblib
    ``Parallel`` pool when ``n_jobs > 1``.
    """
    from .algorithms import get_optimizer

    opt_cls   = get_optimizer(alg_name)
    optimiser = opt_cls(**params)

    # Per-algorithm initial design — each algorithm chooses its own
    # starting layout via prepare_initial_design().
    alg_reserved = set(initial_reserved)
    seed_design, alg_reserved = optimiser.prepare_initial_design(
        anchors  = anchors,
        budget   = budget,
        space    = space,
        reserved = alg_reserved,
        seed     = seed,
    )

    # Re-assemble the design with criterion-blind frozen rows on top.
    # This mirrors Sampler._assemble_initial_design without needing the
    # Sampler instance itself (which would not pickle cleanly).
    sa_set            = {tuple(p) for p in in_design_sa_prescribed}
    blind_prescribed  = [p for p in prescribed_in_pts
                         if tuple(p) not in sa_set]
    sa_focus_set      = {tuple(p) for p in focus_sa_in_design_sampled}
    blind_focus       = [p for p in focus_in_pts
                         if tuple(p) not in sa_focus_set]

    parts: List[np.ndarray] = []
    if blind_prescribed:
        parts.append(np.array(blind_prescribed, dtype=float))
    if blind_focus:
        parts.append(np.array(blind_focus, dtype=float))
    parts.append(seed_design)
    full_design = (np.vstack(parts) if parts
                   else np.empty((0, n_dims)))

    return optimiser.optimize(
        initial_design = full_design,
        space          = space,
        criterion      = criterion,
        banned         = initial_reserved,
        n_frozen       = n_frozen,
        crit_start     = crit_start,
        seed           = seed,
        verbose        = verbose,
    )


# ======================================================================
# FocusPoint
# ======================================================================

[docs] class FocusPoint: """ A critical region where denser sampling is desired. Parameters ---------- point : array-like, shape (n_parameters,) Grid coordinates of the focus centre. spread : float Gaussian kernel width in normalised grid steps. n_samples : int or None Number of focus samples to draw. ``None`` → auto: ``max(1, int((2 * d + 1) * spread))``. include_center : bool or None ``True`` → the centre is guaranteed to appear in the design. ``False`` → the centre is excluded from the candidate pool. ``None`` → stochastic (the centre competes with its neighbours through the Gaussian-weighted draw). in_design : bool ``True`` → focus samples count toward the ``n_samples`` budget. ``False`` → focus samples are added on top of ``n_samples`` and the total design size grows. in_optim : bool ``True`` → the optimiser sees these points and pushes optimised points away. ``False`` → the optimiser ignores these points (they are only reserved against re-selection). """ def __init__( self, point: np.ndarray, spread: float = 1.0, n_samples: Optional[int] = None, include_center: Optional[bool] = None, in_design: bool = True, in_optim: bool = True, ) -> None: self.point = np.asarray(point, dtype=float).ravel() self.spread = float(spread) self.n_samples = n_samples self.include_center = include_center self.in_design = bool(in_design) self.in_optim = bool(in_optim) if self.spread <= 0: _fatal(f"FocusPoint spread must be > 0, got {self.spread}.") if self.n_samples is not None and self.n_samples < 1: _fatal(f"FocusPoint n_samples must be >= 1, got {self.n_samples}.")
[docs] def resolve_n_samples(self, n_dims: int) -> None: """Set ``n_samples`` from the auto formula if not user-supplied.""" if self.n_samples is None: self.n_samples = max(1, int((2 * n_dims + 1) * self.spread))
def __repr__(self) -> str: ic = {None: 'stochastic', True: 'guaranteed', False: 'excluded'} return ( f"FocusPoint(point={self.point.tolist()}, spread={self.spread}, " f"n_samples={self.n_samples}, center={ic[self.include_center]}, " f"in_design={self.in_design}, in_optim={self.in_optim})" )
# ====================================================================== # ExclusionPoint # ======================================================================
[docs] class ExclusionPoint: """ A region to avoid: the centre is hard-excluded from the candidate pool and its neighbourhood receives a Gaussian soft-repulsion penalty during coordinate-exchange candidate selection. Parameters ---------- point : array-like, shape (n_parameters,) Grid coordinates of the exclusion centre. spread : float Gaussian repulsion kernel width in normalised grid steps. """ def __init__(self, point: np.ndarray, spread: float = 1.0) -> None: self.point = np.asarray(point, dtype=float).ravel() self.spread = float(spread) if self.spread <= 0: _fatal(f"ExclusionPoint spread must be > 0, got {self.spread}.") def __repr__(self) -> str: return ( f"ExclusionPoint(point={self.point.tolist()}, spread={self.spread})" )
# ====================================================================== # SamplingResult # ======================================================================
[docs] class SamplingResult: """ Container returned by :meth:`Sampler.run`. Attributes ---------- samples : pd.DataFrame Main design (prescribed + focus + optimised points). validation : pd.DataFrame Kennard-Stone validation set. sets : dict[str, pd.DataFrame] Extra named sets (e.g. ``{'test': df, 'holdout': df}``). designs : dict[str, pd.DataFrame] Designs per criterion when multiple criteria are run. space : ParameterSpace """ PRESCRIBED = "Prescribed" FOCUS = "Focus" OPTIMISED = "Optimised" VALIDATION = "Validation" def __init__( self, samples: pd.DataFrame, validation: pd.DataFrame, space: ParameterSpace, ) -> None: self.samples = samples self.validation = validation self.space = space self.sets: Dict[str, pd.DataFrame] = {} self.designs: Dict[str, pd.DataFrame] = {} # Multi-algorithm results — populated by Sampler.run() when # ``algorithm=[...]`` is requested. Keys: algorithm name, # Values: OptimisationResult instance (algorithms/base.py). self.algorithm_results: Dict[str, Any] = {} # The algorithm whose design is exposed as ``self.samples`` (the # lowest-scoring one when multiple were run). self.best_algorithm: Optional[str] = None self.output_dir = "outputs" self._meta: dict = {} # ------------------------------------------------------------------ # # Multi-algorithm convenience # # ------------------------------------------------------------------ # @property def best_design(self) -> pd.DataFrame: """Alias for :attr:`samples` — the design from the best algorithm.""" return self.samples @property def best_score(self) -> Optional[float]: """Criterion score of the best algorithm's design, or None.""" if self.best_algorithm and self.best_algorithm in self.algorithm_results: return float(self.algorithm_results[self.best_algorithm].score) return None # ------------------------------------------------------------------ # # Summary # # ------------------------------------------------------------------ #
[docs] def summary(self) -> None: """Print a concise design summary to stdout.""" vc = self.samples["point_type"].value_counts() W = 52 sep = "─" * W print(f"\n{sep}") print(" MERGEN Design Summary") print(sep) for label in (self.PRESCRIBED, self.FOCUS, self.OPTIMISED): n = vc.get(label, 0) if n: print(f" {label:<16}: {n}") print(f" {'Total design':<16}: {len(self.samples)}") if len(self.validation): print(f" {'Validation':<16}: {len(self.validation)}") for name, df in self.sets.items(): print(f" {name:<16}: {len(df)}") print(sep) print(f" {'Parameters':<16}: {self.space.n_parameters}") print(f" {'Candidates':<16}: {self.space.n_candidates}") if self._meta: crit = self._meta.get('criteria', '?') seed = self._meta.get('seed', '?') print(f" {'Criterion':<16}: {crit}") print(f" {'Seed':<16}: {seed}") # Algorithm reporting: single or multi alg = self._meta.get('algorithm', None) if isinstance(alg, list): print(f" {'Algorithms':<16}: {', '.join(alg)}") print(f" {'Best algorithm':<16}: {self.best_algorithm}") elif alg is not None: print(f" {'Algorithm':<16}: {alg}") # Per-algorithm scores when multiple were run if len(self.algorithm_results) > 1: print(sep) print(" Per-algorithm scores") print(sep) # Sort by score (best first) ranked = sorted( self.algorithm_results.items(), key=lambda kv: kv[1].score, ) for name, res in ranked: mark = " *" if name == self.best_algorithm else " " print(f" {mark} {name:<6} : score={res.score:.6g} " f"elapsed={res.elapsed:.2f}s n_iter={res.n_iter}") print(f"{sep}\n")
[docs] def quality_report( self, metrics: Union[str, list] = 'default', criteria_metrics: Optional[list] = None, mc_samples: int = 300, verbose: bool = True, ) -> dict: """ Compute and print design quality metrics. Delegates to :func:`mergen.metrics.quality_report`. Each metric is reported both as a raw value and as a percentile rank against a Monte Carlo baseline of random designs of the same size, so the table shows not just the value but whether it is good. Pass ``mc_samples=0`` to skip the baseline (values only). Parameters ---------- metrics : ``'default'`` or list of metric names criteria_metrics : list of criterion names to evaluate post-hoc mc_samples : number of random designs for the Monte Carlo baseline (``0`` disables it; default ``300``) verbose : print the metrics table (default ``True``) Returns ------- dict of metric values and (optionally) baseline statistics """ from . import metrics as _metrics return _metrics.quality_report( self, metrics=metrics, criteria_metrics=criteria_metrics, mc_samples=mc_samples, verbose=verbose, )
[docs] def comparison(self) -> pd.DataFrame: """ Compare quality metrics across all criteria in ``self.designs``. Returns a DataFrame (criteria × metrics). """ from . import metrics as _metrics return _metrics.comparison(self)
[docs] def plot(self, kind: str = 'pairplot', **kwargs) -> None: """ Visualise the design. Delegates to :mod:`mergen.output`. Parameters ---------- kind : ``'pairplot'`` | ``'1d'`` | ``'2d'`` | ``'distances'`` | ``'quality'`` | ``'all'`` """ from . import output as _output _output.plot(self, kind=kind, **kwargs)
[docs] def to_csv(self, filename: str = 'design.csv') -> None: from . import output as _output _output.export_csv(self, filename)
[docs] def to_json(self, filename: str = 'design.json') -> None: from . import output as _output _output.export_json(self, filename)
[docs] def to_markdown(self, filename: str = 'design.md') -> None: from . import output as _output _output.export_markdown(self, filename)
[docs] def to_latex(self, filename: str = 'design.tex') -> None: from . import output as _output _output.export_latex(self, filename)
[docs] def to_html(self, filename: str = 'design.html') -> None: from . import output as _output _output.export_html(self, filename)
[docs] def to_excel(self, filename: str = 'design.xlsx') -> None: from . import output as _output _output.export_excel(self, filename)
def _full_df(self) -> pd.DataFrame: """Combine all sets into a single DataFrame.""" frames = [self.samples] if len(self.validation): df = self.validation.copy() df['point_type'] = self.VALIDATION frames.append(df) for name, df in self.sets.items(): frames.append(df.copy()) return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() def __repr__(self) -> str: return ( f"SamplingResult(" f"samples={len(self.samples)}, " f"validation={len(self.validation)}, " f"sets={list(self.sets.keys())})" )
# ====================================================================== # Sampler # ======================================================================
[docs] class ComparisonResult: """ Outcome of :meth:`Sampler.compare`. Attributes ---------- table : pandas.DataFrame One row per (criterion, algorithm) combination, sorted by the priority metrics; columns hold percentile ranks (0-100, higher is better) against the shared Monte Carlo baseline. The winner is flagged in the ``best`` column. results : dict Maps ``(criterion, algorithm)`` to the full :class:`SamplingResult`, so any candidate design can be inspected or exported, not only the winner. best : tuple of (str, str) The winning (criterion, algorithm) pair. priority : tuple of str The metrics that defined the ranking, in order. """ def __init__(self, table, results, best, priority): self.table = table self.results = results self.best = best self.priority = priority self._best_result = None # set by Sampler.compare after ranking @property def best_result(self) -> "SamplingResult": """The full SamplingResult of the winning combination, identical to calling run() for that combination with the same seed and n_repeats.""" return self._best_result
[docs] def summary(self) -> None: """Print the ranked comparison table.""" print() print("═" * 72) print(" MERGEN — Criterion / Algorithm Comparison") print("═" * 72) print(f" Priority: {' > '.join(self.priority)} " f"(percentile vs shared MC baseline, higher is better)") print("─" * 72) with pd.option_context('display.width', 100, 'display.float_format', lambda v: f"{v:6.1f}"): print(self.table.to_string(index=False)) print("─" * 72) print(f" Best: criteria='{self.best[0]}', " f"algorithm='{self.best[1]}'") print("═" * 72)
[docs] def plot(self, save: bool = False, filename: Optional[str] = None, show: bool = True, **kwargs) -> None: """ Heat map of the percentile-rank table. Rows are (criterion, algorithm) combinations, columns are the quality metrics, cell colour encodes the percentile rank; the winning row is starred. Saved under ``outputs/`` when ``save=True``. """ from . import output as _output _output.plot_comparison_matrix( self, save=save, filename=filename, show=show, **kwargs)
[docs] def to_markdown(self, filename: str) -> None: """ Save the ranked comparison table as a Markdown file under the output directory. Unlike calling ``comparison.table.to_markdown(...)`` directly, this writes into ``outputs/`` (creating it if needed), matching where the plots and design exports are saved. """ import os outdir = getattr(self, 'output_dir', 'outputs') os.makedirs(outdir, exist_ok=True) path = filename if os.path.isabs(filename) or os.path.dirname(filename) \ else os.path.join(outdir, filename) md = self.table.to_markdown(index=False) with open(path, 'w', encoding='utf-8') as fh: fh.write(md + "\n") print(f" Saved: {path}")
def __repr__(self) -> str: return (f"ComparisonResult(best={self.best}, " f"n_combinations={len(self.results)}, " f"priority={self.priority})")
[docs] class Sampler: """ Space-filling design sampler using Stochastic Coordinate Exchange (SCE). The sampler is configured fluently — ``add_*`` methods register prescribed/focus/exclusion points, ``set_*`` methods set sizes and optimiser hyperparameters, and :meth:`run` produces the design. Parameters ---------- space : ParameterSpace Examples -------- >>> space = ParameterSpace({'x': range(1, 21), 'y': range(1, 21)}) >>> sampler = Sampler(space) >>> sampler.set_design(n_samples=20) >>> result = sampler.run(seed=44) >>> result.summary() """ def __init__(self, space: ParameterSpace) -> None: if not isinstance(space, ParameterSpace): _fatal(f"space must be a ParameterSpace, got {type(space).__name__}.") if not space.is_valid(): _fatal("ParameterSpace has no parameters or no feasible candidates.") self.space = space # Registered points # Each prescribed entry: (point, in_design, in_optim) self._prescribed: List[Tuple[np.ndarray, bool, bool]] = [] self._focus: List[FocusPoint] = [] self._exclusions: List[ExclusionPoint] = [] # Design settings self._n_samples: Optional[int] = None self._n_validation: Optional[int] = None self._extra_sets: Optional[Dict] = None # User-supplied named sets: {name: (points, colour_or_None)} self._user_sets: Dict[str, Tuple[np.ndarray, Optional[str]]] = {} # Externally supplied design: (points, label, colour_or_None) self._loaded_design: Optional[Tuple[np.ndarray, str, Optional[str]]] = None self._dim_weights: Optional[np.ndarray] = None # Per-optimiser hyperparameter store: {'sa': {...}, 'sce': {...}, ...} # Populated by set_optimizer(name, **kwargs). Empty dict means # use the optimiser's defaults. self._optimizer_params: Dict[str, Dict[str, Any]] = {} # ------------------------------------------------------------------ # # Public: configuration # # ------------------------------------------------------------------ #
[docs] def add_prescribed( self, points: Union[Sequence, np.ndarray], in_design: bool = True, in_optim: bool = False, ) -> "Sampler": """ Add one or more prescribed (fixed) points. Prescribed points are always present in the final design and are never moved by the optimiser. Parameters ---------- points : array-like Single point ``[x1, x2, ...]`` or list of points. All points must lie on the parameter grid. in_design : bool ``True`` → counted within the ``n_samples`` budget. ``False`` → added on top of ``n_samples`` (the total design size grows accordingly). in_optim : bool ``True`` → the optimiser sees these points and pushes optimised points away from them. ``False`` → the optimiser ignores them (the points are only reserved against being re-selected). Returns ------- self """ pts = np.asarray(points, dtype=float) if pts.ndim == 1: pts = pts[np.newaxis, :] if pts.ndim != 2 or pts.shape[1] != self.space.n_parameters: _fatal( f"Each prescribed point must have {self.space.n_parameters} " f"coordinates, got shape {pts.shape}." ) for row in pts: validated = self.space.validate_point(row, label="Prescribed point") self._prescribed.append((validated, bool(in_design), bool(in_optim))) return self
def _encode_points(self, points, context: str) -> np.ndarray: """ Convert a list/array of points to the numeric grid encoding. Nominal factors are stored internally as integer category indices, so string category labels (e.g. 'adam') are mapped to their index before the array is built. Numeric factors pass through unchanged. Accepts a single point or a list of points. """ rows = list(points) if len(rows) and np.ndim(rows[0]) == 0: rows = [rows] # a single point names = self.space.names encoded = [] for row in rows: if len(row) != len(names): _fatal( f"{context}: each point must have {len(names)} " f"coordinates, got {len(row)}." ) vals = [] for name, v in zip(names, row): if self.space.is_nominal(name) and isinstance(v, str): labels = self.space.category_labels(name) if v not in labels: _fatal( f"{context}: '{v}' is not a level of nominal " f"factor '{name}'. Valid levels: {labels}." ) vals.append(float(labels.index(v))) else: vals.append(float(v)) encoded.append(vals) return np.asarray(encoded, dtype=float)
[docs] def add_set( self, name: str, points: Union[Sequence, np.ndarray], color: Optional[str] = None, ) -> "Sampler": """ Add a user-supplied named point set (e.g. an external test set). The points are validated against the parameter grid, reserved so the optimiser cannot re-select them, and carried into the result as ``result.sets[name]``. They appear in plots under ``name`` and in exports as a separate table. Parameters ---------- name : str Label of the set (e.g. ``'test'``). Must not collide with the built-in labels ``'Optimised'``, ``'Validation'``, ``'Prescribed'`` or ``'Focus'``, nor with a generated extra set or a previously added set. points : array-like Single point ``[x1, x2, ...]`` or list of points. All points must lie on the parameter grid. color : str, optional Matplotlib colour used for this set in plots (e.g. ``'#3a86ff'``). If omitted, a neutral fallback colour is assigned by the plotting layer. Returns ------- self """ builtin = {"Optimised", "Validation", "Prescribed", "Focus"} taken = builtin | set(self._user_sets) | set(self._extra_sets or {}) if name in taken: _fatal(f"Set name '{name}' is already in use.") pts = self._encode_points(points, context=f"Set '{name}'") validated = np.vstack([ self.space.validate_point(row, label=f"Set '{name}' point") for row in pts ]) self._user_sets[name] = (validated, color) return self
[docs] def load_design( self, points: Union[Sequence, np.ndarray, pd.DataFrame], name: str = "Existing", color: Optional[str] = None, ) -> "Sampler": """ Load an existing design instead of optimising a new one. The supplied points become the design itself: :meth:`run` skips optimisation entirely and only generates the requested validation set and any extra sets (Kennard-Stone) around them. Use :func:`mergen.sequential.extend` instead if you want to grow the design with new optimised points. Parameters ---------- points : array-like or pandas.DataFrame The existing design. A DataFrame is matched to the parameter space by column names; an array must have one column per parameter in space order. All points must lie on the parameter grid. name : str Label under which the points appear in plots, summaries and exports (default ``'Existing'``). color : str, optional Matplotlib colour for this label in plots. Defaults to the Optimised blue (``'#3a86ff'``). Returns ------- self """ if isinstance(points, pd.DataFrame): missing = [p for p in self.space.names if p not in points.columns] if missing: _fatal( f"load_design: DataFrame is missing parameter " f"column(s) {missing}." ) pts = self._encode_points( points[self.space.names].to_numpy(dtype=object).tolist(), context="load_design") else: pts = self._encode_points(points, context="load_design") if pts.ndim != 2 or pts.shape[1] != self.space.n_parameters: _fatal( f"load_design: each point must have " f"{self.space.n_parameters} coordinates, got shape {pts.shape}." ) validated = np.vstack([ self.space.validate_point(row, label="Loaded design point") for row in pts ]) self._loaded_design = (validated, str(name), color) return self
[docs] def add_focus( self, point: Sequence[float], spread: float = 1.0, n_samples: Optional[int] = None, include_center: Optional[bool] = None, in_design: bool = True, in_optim: bool = True, ) -> "Sampler": """ Add a focus point — denser sampling near a critical region. Parameters ---------- point : grid coordinates of the focus centre spread : Gaussian kernel width in normalised grid steps n_samples : number of focus samples (``None`` → auto) include_center : ``True`` / ``False`` / ``None`` (guaranteed / excluded / stochastic) in_design : ``True`` → within budget; ``False`` → extra in_optim : ``True`` → the optimiser sees these points Returns ------- self """ pt = self.space.validate_point(point, label="Focus point") self._focus.append(FocusPoint( pt, spread=spread, n_samples=n_samples, include_center=include_center, in_design=in_design, in_optim=in_optim, )) return self
[docs] def add_exclusion( self, point: Sequence[float], spread: float = 1.0, ) -> "Sampler": """ Add an exclusion point — sampling avoids this region. The centre is hard-excluded from the candidate pool. Its neighbourhood receives a Gaussian soft-repulsion weight that downweights candidate values during coordinate-exchange selection. Parameters ---------- point : grid coordinates of the exclusion centre spread : Gaussian repulsion kernel width Returns ------- self """ pt = self.space.validate_point(point, label="Exclusion point") self._exclusions.append(ExclusionPoint(pt, spread=spread)) return self
[docs] def set_design( self, n_samples: Optional[int] = None, n_validation: Optional[int] = None, extra_sets: Optional[Dict] = None, ) -> "Sampler": """ Configure design size and holdout sets. Parameters ---------- n_samples : total design size (``None`` → 10 × n_parameters, Loeppky, Sacks & Welch 2009). n_validation : validation set size (``None`` → 20% of ``n_samples``). extra_sets : dict of ``{name: size}`` for additional Kennard-Stone holdout sets, e.g. ``{'test': 10, 'holdout': 5}``. Returns ------- self """ self._n_samples = n_samples self._n_validation = n_validation self._extra_sets = extra_sets return self
[docs] def set_optimizer( self, name: str, **kwargs: Any, ) -> "Sampler": """ Configure hyperparameters for an optimisation algorithm. The named algorithm must be registered in :mod:`mergen.algorithms` (e.g. ``'sa'``, ``'sce'``, ``'ese'``). Hyperparameters are validated against the algorithm's :meth:`~mergen.algorithms.BaseOptimizer.get_default_params` schema; unknown keys raise :class:`ValueError`. Calling this method does *not* run the optimiser — it only stores the parameters for later use by :meth:`run`. You can call it multiple times to configure different algorithms. Parameters ---------- name : str The registered name of the optimiser (e.g. ``'sa'``). **kwargs Hyperparameters specific to the chosen algorithm. See each algorithm's documentation for the full list. Returns ------- self Enables fluent chaining. Raises ------ KeyError If ``name`` is not a registered optimiser. ValueError If any ``kwargs`` key is not a valid hyperparameter for the algorithm. Examples -------- >>> sampler.set_optimizer('sa', n_restarts=5, max_iter=10000) >>> sampler.set_optimizer('sce', n_complexes=4) >>> sampler.set_optimizer('ese', n_outer=20, n_inner=100) """ # Import here to avoid circular imports at module load time from .algorithms import get_optimizer # Normalise the name (registry is case-insensitive on user input) name = name.strip().lower() # Look up the optimiser class (raises KeyError if unknown) opt_cls = get_optimizer(name) # Validate kwargs against the optimiser's parameter schema by # instantiating it; this raises ValueError on unknown keys. opt_cls(**kwargs) # Store the hyperparameters self._optimizer_params[name] = dict(kwargs) return self
[docs] def set_dimension_weights( self, weights: Union[Dict[str, float], List[float]], ) -> "Sampler": """ Set per-dimension importance weights for greedy maximin seeding. Parameters ---------- weights : dict ``{name: weight}`` or list of floats (one weight per parameter, in parameter order). Returns ------- self """ names = self.space.names if isinstance(weights, dict): w = np.array([weights.get(n, 1.0) for n in names], dtype=float) else: w = np.asarray(weights, dtype=float) if len(w) != self.space.n_parameters: _fatal( f"dimension_weights length ({len(w)}) must match " f"n_parameters ({self.space.n_parameters})." ) self._dim_weights = w / w.sum() return self
def __repr__(self) -> str: return ( f"Sampler(" f"space={self.space.n_parameters}D, " f"prescribed={len(self._prescribed)}, " f"focus={len(self._focus)}, " f"exclusions={len(self._exclusions)})" ) # ------------------------------------------------------------------ # # Public: run # # ------------------------------------------------------------------ #
[docs] def run( self, criteria: Union[str, List[str], BaseCriterion] = 'umaxpro', algorithm: Union[str, List[str]] = 'sa', seed: Optional[int] = 44, n_repeats: int = 1, priority: Optional[Tuple[str, ...]] = None, n_jobs: Optional[int] = None, verbose: bool = True, ) -> SamplingResult: """ Generate the space-filling design. Parameters ---------- criteria : str, list of str, or BaseCriterion instance Optimisation criterion (or list of criteria). Available built-in names: ``'umaxpro'``, ``'maxpro'``, ``'phi_p'``, ``'cd2'``, ``'stratified'``. Default ``'umaxpro'``. algorithm : str or list of str, optional Name of the optimiser (or list of names) to run. Each name must be a registered optimiser (see :func:`mergen.list_optimizers`). When a list is given, every algorithm is run independently and the results are collected in :attr:`SamplingResult.algorithm_results`. Default ``'sa'``. seed : int or None Random seed for reproducibility (default 44). n_repeats : int, default 1 Number of independent optimisation repeats. Repeat seeds are derived from ``seed`` via NumPy's SeedSequence.spawn; the representative repeat is returned (see ``priority``). priority : tuple of str, optional Quality-metric names used to pick the representative repeat when ``n_repeats > 1``. ``None`` (default) keeps the repeat with the lowest criterion score (classic best-of-k restarts). When given — e.g. ``('min_distance', 'max_abs_correlation')`` — the repeat whose design lies closest to the Utopia point of these metrics is returned instead (optimise-by-proxy, select-by-objective), which keeps the delivered design stable when near-optimal criterion scores correspond to different coverage trade-offs. Pass the same tuple used in :meth:`compare` to reproduce its ``best_result`` exactly. n_jobs : int or None, optional Number of parallel workers for the multi-algorithm dispatcher. ``None`` (default) and ``1`` run sequentially. Positive integers request that many workers (up to the number of CPUs). Negative integers follow the joblib convention: ``-1`` for all CPUs, ``-2`` for all but one, and so on. Parallelism applies only across distinct algorithms; the per-algorithm restart loop is ILS-based and therefore intentionally serial. When ``n_jobs`` is greater than the number of algorithms, the extra workers are unused. verbose : bool, optional Print progress information. Default ``True``. Returns ------- SamplingResult Contains the (per-algorithm) optimised designs and quality metadata. When a single algorithm is requested, :attr:`SamplingResult.samples` exposes its design directly; when multiple algorithms are requested, use :attr:`SamplingResult.algorithm_results` to access each individually and :meth:`SamplingResult.comparison` to tabulate them. """ if seed is not None: np.random.seed(seed) random.seed(seed) if self._loaded_design is not None: return self._run_loaded(seed=seed, verbose=verbose) # Normalise algorithm into a list (case-insensitive) if isinstance(algorithm, str): algorithm_names = [algorithm.strip().lower()] else: algorithm_names = [a.strip().lower() for a in algorithm] if not algorithm_names: _fatal("'algorithm' cannot be empty.") # ESE is structurally unable to optimise one-dimensional # designs: its within-column swap moves (Jin, Chen & Sudjianto # 2005) permute rows without changing the design as a point # set when d = 1, so it would silently return the initial # design. Fail early with an actionable message instead. if self.space.n_parameters == 1 and 'ese' in algorithm_names: _fatal( "Algorithm 'ese' cannot optimise a 1-parameter design: " "its column-swap moves leave a 1D point set unchanged " "(Jin, Chen & Sudjianto 2005).\n" " Use algorithm='sa' or algorithm='sce' instead." ) # Resolve criterion(ia) if isinstance(criteria, str): crit_list = [get_criterion(criteria)] crit_names = [criteria] elif isinstance(criteria, list): crit_list = [get_criterion(c) if isinstance(c, str) else c for c in criteria] crit_names = [c if isinstance(c, str) else repr(c) for c in criteria] else: crit_list = [criteria] crit_names = [repr(criteria)] # NOTE: multi-criterion support is reserved for a future release. # For now we run the first criterion only. criterion = crit_list[0] crit_name = crit_names[0] # ── Nominal / criterion compatibility check ──────────────── # Nominal factors are unordered categorical: distance- and # discrepancy-based criteria whose kernels assume ordered # levels give meaningless scores on them. Refuse to run # such combinations rather than silently produce a bogus # design. Ordinal factors are OK for every criterion — # their integer scoring preserves an interpretable order. if self.space.has_nominal and not getattr( criterion, 'supports_nominal', False, ): supporters = ', '.join(f"'{c}'" for c in nominal_supporting_criteria()) nominal_cols = self.space.nominal_names _fatal( f"Criterion '{crit_name}' does not support nominal " f"factors.\n" f" Space has nominal column(s): {nominal_cols}.\n" f" Criteria that support nominal factors: " f"{supporters or '(none registered)'}.\n" f" Ordinal factors, however, are accepted by every " f"criterion — declare with " f"('ordinal', [labels]) if the levels are ordered." ) # Space metadata space = self.space n_dims = space.n_parameters gmins = space.gmins granges = space.granges names = space.names gs = space.grid_sampler() # Resolve auto settings on focus points for fp in self._focus: fp.resolve_n_samples(n_dims) # Conflict detection self._check_conflicts() # ── Budget ───────────────────────────────────────────────────── n_prescribed_in = sum(1 for _, in_d, _ in self._prescribed if in_d) n_prescribed_out = sum(1 for _, in_d, _ in self._prescribed if not in_d) n_focus_in = sum(fp.n_samples for fp in self._focus if fp.in_design) n_focus_out = sum(fp.n_samples for fp in self._focus if not fp.in_design) loeppky = 10 * n_dims if self._n_samples is None: n_global = loeppky else: n_global = self._n_samples if n_global < loeppky: _warn( f"n_samples ({n_global}) < recommended 10*n_parameters " f"({loeppky}, Loeppky et al. 2009). Design quality may " f"be reduced." ) n_optimised_slots = n_global - n_prescribed_in - n_focus_in if n_optimised_slots < 1: _fatal( f"No optimiser slots remaining: n_samples={n_global}, " f"prescribed_in={n_prescribed_in}, focus_in={n_focus_in}. " f"Increase n_samples or set in_design=False for some points." ) n_validation = (self._n_validation if self._n_validation is not None else max(1, int(np.ceil(n_global * 0.20)))) n_total_design = n_global + n_prescribed_out + n_focus_out # ── Print configuration ──────────────────────────────────────── print() print("═" * 60) print(" MERGEN — Space-filling Design") print("═" * 60) print(f" Parameters : {n_dims}") print(f" Candidates : {space.n_candidates:,}") print(f" n_samples : {n_global} " f"(prescribed_in={n_prescribed_in}, " f"focus_in={n_focus_in}, " f"optimised_slots={n_optimised_slots})") if n_prescribed_out or n_focus_out: print(f" Extra points : +{n_prescribed_out + n_focus_out} " f"(prescribed_out={n_prescribed_out}, " f"focus_out={n_focus_out})") print(f" Total design : {n_total_design}") print(f" Validation : {n_validation}") print(f" Criterion : {crit_name}") print(f" Algorithm(s) : {', '.join(algorithm_names)}") print("─" * 60) # ── Reserved set + classify points ───────────────────────────── reserved: set = set() prescribed_in_pts: List[np.ndarray] = [] prescribed_out_pts: List[np.ndarray] = [] prescribed_sa_pts: List[np.ndarray] = [] for pt, in_d, in_s in self._prescribed: idx = gs.point_to_index(pt) if idx >= 0: reserved.add(idx) (prescribed_in_pts if in_d else prescribed_out_pts).append(pt) if in_s: prescribed_sa_pts.append(pt) # User-supplied named sets: reserve their grid nodes so the # optimiser cannot re-select them. for _uname, (upts, _ucolor) in self._user_sets.items(): for pt in upts: idx = gs.point_to_index(pt) if idx >= 0: reserved.add(idx) # ── Focus / exclusion sampling ───────────────────────────────── if self._focus or self._exclusions: print(" [MERGEN] Sampling focus / exclusion regions...", flush=True) focus_in_pts, focus_out_pts, focus_sa_pts, repel_weights, reserved = \ self._build_focus_exclusion(gs, gmins, granges, reserved) # ── Assemble the design layout ───────────────────────────────── # Row order in the design matrix: # [ prescribed_in | focus_in | sce_optimised ] # n_frozen = prescribed_in + focus_in (these rows never move) # crit_start = first row that is "visible" to the criterion # # When a prescribed/focus point has in_optim=True, the criterion # includes it; otherwise it is reserved but criterion-blind. # We place all "criterion-visible frozen" rows together at the # start of the criterion view so a single ``crit_start`` index # suffices. # Anchor for greedy maximin seeding = criterion-visible frozen rows anchor_parts: List[np.ndarray] = [] in_design_sa_prescribed = [pt for pt, in_d, in_s in self._prescribed if in_d and in_s] if in_design_sa_prescribed: anchor_parts.append(np.array(in_design_sa_prescribed, dtype=float)) # focus_in_pts contains the *sampled* focus points # (centres + neighbours). For anchoring, the centres alone # would under-utilise the focus structure; instead we anchor on # all sampled in_design + in_optim focus points so the greedy step # truly avoids placing optimised points near them. focus_sa_in_design_sampled = [] if focus_in_pts and any(fp.in_optim for fp in self._focus): sa_indices_in = [k for k, fp in enumerate(self._focus) if fp.in_design and fp.in_optim] cursor = 0 for k, fp in enumerate(self._focus): count = fp.n_samples if fp.in_design else 0 if k in sa_indices_in: focus_sa_in_design_sampled.extend( focus_in_pts[cursor:cursor + count] ) cursor += count if focus_sa_in_design_sampled: anchor_parts.append(np.array(focus_sa_in_design_sampled, dtype=float)) _corner_used = False if anchor_parts: anchor = np.vstack(anchor_parts) else: # No criterion-visible frozen rows → seed from a corner # (Morris & Mitchell 1995 strategy: extreme starting point). corner = np.array([v[0] if i % 2 == 0 else v[-1] for i, v in enumerate(space.values)]) idx = gs.point_to_index(corner) if idx >= 0: reserved.add(idx) anchor = corner[np.newaxis, :] _corner_used = True # ── Initial design: delegated to each optimiser ────────────── # Each optimiser provides its own prepare_initial_design() # method so it can use the type of starting design its # underlying algorithm was designed for. The default in # BaseOptimizer is a balanced LHS (Joseph, Gul & Ba 2018), # which matches what SA, SCE and ESE expect from the # literature; subclasses may override for non-LHS algorithms. if self._prescribed or self._focus: print(" [MERGEN] Preparing anchor points...", flush=True) # Anchors = prescribed_in + focus_in (any explicit user points # that must appear unchanged in every optimiser's design). if _corner_used: # The corner point already lives in ``anchor``; treat it the # same as a prescribed point. anchors = anchor.copy() elif len(anchor) > 0: anchors = anchor.copy() else: anchors = np.empty((0, n_dims)) # Note: full_design is now constructed *per algorithm* inside # the dispatcher loop below, so each optimiser's natural # starting design is used. # n_frozen: the optimiser must not move these rows n_frozen = len(prescribed_in_pts) + len(focus_in_pts) # crit_start: first row visible to the criterion # Rows are: [criterion-blind frozen | criterion-visible frozen | optimised] # The number of criterion-blind frozen rows is the count of # prescribed/focus points that are in_design but NOT in_optim. n_blind = (len(prescribed_in_pts) - len(in_design_sa_prescribed) + len(focus_in_pts) - len(focus_sa_in_design_sampled)) crit_start = n_blind # Snapshot the initial reserved set so each algorithm starts # from the same anchor state. initial_reserved = reserved.copy() # ── Run the requested optimiser(s) ────────────────────────────── from .algorithms import get_optimizer # Independent, reproducible seeds for the repeats, derived from # the base seed via NumPy's SeedSequence.spawn. This is the SAME # derivation compare() relies on, so that run(seed=s, # n_repeats=k) for one (criterion, algorithm) reproduces exactly # what compare(seed=s, n_repeats=k) evaluates for that same # combination — the two entry points never disagree. if n_repeats < 1: _fatal("n_repeats must be >= 1.") _base_seed = seed if seed is not None else 44 _seed_seq = np.random.SeedSequence(_base_seed) rep_seeds = [int(cs.generate_state(1)[0]) for cs in _seed_seq.spawn(n_repeats)] # Resolve n_jobs and decide whether to parallelise. # Parallelism applies across independent (algorithm, repeat) # jobs; the per-algorithm restart loop inside one job is # ILS-based and intentionally serial (each restart kicks from the # previous best — Lourenco, Martin & Stutzle (2003)). n_jobs_resolved = _resolve_n_jobs(n_jobs) n_algos = len(algorithm_names) n_units = n_algos * n_repeats effective_n_jobs = min(n_jobs_resolved, n_units) run_in_parallel = effective_n_jobs > 1 and n_units > 1 # Validate all algorithm names up front (better error than # discovering a typo three workers in). for alg_name in algorithm_names: try: get_optimizer(alg_name) except KeyError: _fatal( f"Optimiser {alg_name!r} is not registered. " f"Available: " f"{sorted(get_optimizer.__globals__.get('_OPTIMIZER_REGISTRY', {}).keys())}" ) raise # ── Progress messages ── if verbose: algos_str = ", ".join(algorithm_names) if run_in_parallel: msg = (f"Optimising in parallel (criterion={crit_name}, " f"algorithms={algos_str}, n_jobs={effective_n_jobs})...") if n_jobs_resolved > effective_n_jobs: extra = n_jobs_resolved - effective_n_jobs msg += (f" [note: {n_jobs_resolved} workers requested, " f"only {effective_n_jobs} needed; " f"{extra} unused]") _ok(msg) else: if n_jobs_resolved > 1 and n_algos == 1: _ok( f"n_jobs={n_jobs_resolved} requested but only one " f"algorithm to dispatch; running sequentially " f"(per-algorithm restart loop is ILS-based and " f"cannot be parallelised)." ) _ok(f"Optimising (criterion={crit_name}, " f"algorithm{'s' if n_algos > 1 else ''}={algos_str})...") t_run_start = time.perf_counter() # Per-algorithm results algorithm_results: Dict[str, Any] = {} # Build the per-algorithm task arguments once; reused for both # the sequential and parallel paths so they share a single code # path for the heavy lifting. budget = n_optimised_slots - (1 if _corner_used else 0) # One task per (algorithm, repeat). Each repeat uses its own # spawned seed; the best repeat per algorithm is selected below. tasks_args = [ dict( alg_name = alg_name, params = self._optimizer_params.get(alg_name, {}), anchors = anchors.copy(), budget = budget, initial_reserved = set(initial_reserved), space = space, criterion = criterion, prescribed_in_pts = prescribed_in_pts, focus_in_pts = focus_in_pts, in_design_sa_prescribed = in_design_sa_prescribed, focus_sa_in_design_sampled = focus_sa_in_design_sampled, n_dims = n_dims, n_frozen = n_frozen, crit_start = crit_start, seed = rep_seed, # In parallel mode, suppress per-task verbose output so # worker streams do not interleave. verbose = (verbose and not run_in_parallel and n_repeats == 1), ) for alg_name in algorithm_names for rep_seed in rep_seeds ] # Parallel index bookkeeping: task i belongs to algorithm # i // n_repeats and repeat i % n_repeats. task_alg = [alg_name for alg_name in algorithm_names for _ in rep_seeds] if run_in_parallel: from joblib import Parallel, delayed # Workers spawn fresh Python processes that re-import mergen; # set MERGEN_SILENT in the child env so each worker does not # print the package banner on import. _prev_silent = os.environ.get('MERGEN_SILENT') os.environ['MERGEN_SILENT'] = '1' try: # See compare() for the rationale: fork-based # 'multiprocessing' where available avoids loky's # per-task overhead; loky elsewhere. The context manager # guarantees the pool is shut down on exit/Ctrl-C. import multiprocessing as _mp _backend = ('multiprocessing' if 'fork' in _mp.get_all_start_methods() else 'loky') with Parallel(n_jobs=effective_n_jobs, backend=_backend) as parallel: results_list = parallel( delayed(_run_one_algorithm_task)(**ta) for ta in tasks_args) finally: if _prev_silent is None: os.environ.pop('MERGEN_SILENT', None) else: os.environ['MERGEN_SILENT'] = _prev_silent else: results_list = [_run_one_algorithm_task(**ta) for ta in tasks_args] # Keep, for each algorithm, the representative repeat: the # lowest-score repeat by default, or — when ``priority`` is given # — the repeat closest to the Utopia point of those metrics (see # _select_repeat). The full repeat list (spawn order) is kept so # compare() can reuse the very same optimisations. Deterministic # in both modes. _repeats_by_alg: Dict[str, list] = {} for alg_name, result in zip(task_alg, results_list): _repeats_by_alg.setdefault(alg_name, []).append(result) for alg_name, reps in _repeats_by_alg.items(): algorithm_results[alg_name] = _select_repeat( reps, priority, space) if verbose: for alg_name in algorithm_names: result = algorithm_results[alg_name] suffix = (f" (best of {n_repeats})" if n_repeats > 1 else "") _ok(f"{alg_name:<6} done -- score={result.score:.4g} " f"(elapsed {self._fmt_time(result.elapsed)}){suffix}") t_elapsed = time.perf_counter() - t_run_start # Pick the best result across all algorithms (lowest score) best_algorithm = min(algorithm_results, key=lambda k: algorithm_results[k].score) best_design = algorithm_results[best_algorithm].design best_score = algorithm_results[best_algorithm].score if verbose and len(algorithm_names) > 1: _ok(f"Best: {best_algorithm} score={best_score:.4g} " f"(total elapsed {self._fmt_time(t_elapsed)})") # Reconstruct reserved set from the final design (used downstream # for validation / extra-set Kennard-Stone selection). Start from # the initial reserved (anchors only) and add the optimiser's # output rows. best_reserved = initial_reserved.copy() for row in best_design[n_frozen:]: idx = gs.point_to_index(row) if idx >= 0: best_reserved.add(idx) final_design = best_design final_reserved = best_reserved # ── Validation set (Kennard-Stone) ───────────────────────────── val_pts = self._kennard_stone( gs, final_reserved, final_design, n_validation, gmins, granges, ) val_reserved = final_reserved.copy() for vp in val_pts: idx = gs.point_to_index(vp) if idx >= 0: val_reserved.add(idx) # ── Extra sets (Kennard-Stone) ───────────────────────────────── extra_dfs: Dict[str, pd.DataFrame] = {} cur_reserved = val_reserved.copy() cur_ref = (np.vstack([final_design, val_pts]) if len(val_pts) else final_design) # User-supplied sets go in first so the generated extra sets # below are pushed away from them as well. for uname, (upts, ucolor) in self._user_sets.items(): df_u = pd.DataFrame(upts, columns=names) df_u['point_type'] = uname if ucolor is not None: df_u['color'] = ucolor df_u.index.name = 'id' extra_dfs[uname] = df_u for up in upts: idx = gs.point_to_index(up) if idx >= 0: cur_reserved.add(idx) cur_ref = np.vstack([cur_ref, upts]) if self._extra_sets: for set_name, set_n in self._extra_sets.items(): set_pts = self._kennard_stone( gs, cur_reserved, cur_ref, set_n, gmins, granges, ) for sp in set_pts: idx = gs.point_to_index(sp) if idx >= 0: cur_reserved.add(idx) cur_ref = (np.vstack([cur_ref, set_pts]) if len(set_pts) else cur_ref) df_set = (pd.DataFrame(set_pts, columns=names) if len(set_pts) else pd.DataFrame(columns=names)) df_set['point_type'] = set_name df_set.index.name = 'id' extra_dfs[set_name] = df_set # ── Append out-of-budget extras ──────────────────────────────── extra_fixed: List[np.ndarray] = [] extra_labels: List[str] = [] for pt in prescribed_out_pts: extra_fixed.append(pt) extra_labels.append(SamplingResult.PRESCRIBED) for pt in focus_out_pts: extra_fixed.append(pt) extra_labels.append(SamplingResult.FOCUS) # ── Build the main DataFrame ─────────────────────────────────── n_p_in = len(prescribed_in_pts) n_f_in = len(focus_in_pts) n_sce = len(final_design) - n_p_in - n_f_in labels = ( [SamplingResult.PRESCRIBED] * n_p_in + [SamplingResult.FOCUS] * n_f_in + [SamplingResult.OPTIMISED]* n_sce ) if extra_fixed: final_with_extra = np.vstack( [final_design, np.array(extra_fixed, dtype=float)] ) labels += extra_labels else: final_with_extra = final_design df_samples = pd.DataFrame(final_with_extra, columns=names) df_samples['point_type'] = labels df_samples.index.name = 'id' df_val = (pd.DataFrame(val_pts, columns=names) if len(val_pts) else pd.DataFrame(columns=names + ['point_type'])) if len(df_val): df_val['point_type'] = SamplingResult.VALIDATION df_val.index.name = 'id' # ── Final status ─────────────────────────────────────────────── print() print("─" * 60) print(" MERGEN — Final Design") print("─" * 60) print(f" Prescribed (in) : {n_p_in}") print(f" Prescribed (out) : {len(prescribed_out_pts)}") print(f" Focus (in) : {n_f_in}") print(f" Focus (out) : {len(focus_out_pts)}") print(f" Optimised : {n_sce}") print(f" Total design : {len(df_samples)}") print(f" Validation : {len(df_val)}") for sn, sdf in extra_dfs.items(): print(f" {sn:<18}: {len(sdf)}") print("═" * 60) result = SamplingResult(df_samples, df_val, space) result.sets = extra_dfs result.designs = {crit_name: df_samples} result.algorithm_results = algorithm_results result.best_algorithm = best_algorithm # All repeat results per algorithm (spawn order), so compare() # can reuse the exact same optimisations it would trigger anyway. result._repeats_by_alg = _repeats_by_alg result._meta = { 'criteria' : crit_name, 'seed' : seed, 'algorithm' : (algorithm_names[0] if len(algorithm_names) == 1 else algorithm_names), 'best_algorithm' : best_algorithm, 'n_parameters' : n_dims, 'n_candidates' : space.n_candidates, 'n_design' : len(df_samples), 'n_validation' : len(df_val), 'best_log_score' : float(np.log(max(best_score, _EPS))), 'elapsed_sec' : float(t_elapsed), } return result
def _run_loaded( self, seed: Optional[int], verbose: bool, ) -> SamplingResult: """ Build a :class:`SamplingResult` from an externally loaded design. No optimisation is performed. The loaded points are reserved on the grid, then the validation set and any extra sets are generated around them with Kennard-Stone selection, exactly as in :meth:`run`. """ if self._prescribed or self._focus or self._exclusions: _fatal( "load_design cannot be combined with add_prescribed, " "add_focus or add_exclusion: the loaded design is fixed. " "Use mergen.sequential.extend to grow a design." ) if self._n_samples is not None: _fatal( "load_design cannot be combined with n_samples: the " "design size is fixed by the loaded points. Use " "mergen.sequential.extend to grow a design." ) t_start = time.perf_counter() space = self.space pts, label, lcolor = self._loaded_design gmins = space.gmins granges = space.granges names = space.names gs = space.grid_sampler() n_dims = space.n_parameters n_validation = (self._n_validation if self._n_validation is not None else max(1, int(np.ceil(len(pts) * 0.20)))) print() print("═" * 60) print(" MERGEN — Loaded Design") print("═" * 60) print(f" Parameters : {n_dims}") print(f" Candidates : {space.n_candidates:,}") print(f" Loaded points : {len(pts)} (label='{label}')") print(f" Validation : {n_validation}") print("─" * 60) # Reserve loaded points so validation / extra sets avoid them. reserved: set = set() for pt in pts: idx = gs.point_to_index(pt) if idx >= 0: reserved.add(idx) for _uname, (upts, _ucolor) in self._user_sets.items(): for pt in upts: idx = gs.point_to_index(pt) if idx >= 0: reserved.add(idx) # ── Validation set (Kennard-Stone) ───────────────────────────── val_pts = self._kennard_stone( gs, reserved, pts, n_validation, gmins, granges, ) cur_reserved = reserved.copy() for vp in val_pts: idx = gs.point_to_index(vp) if idx >= 0: cur_reserved.add(idx) cur_ref = np.vstack([pts, val_pts]) if len(val_pts) else pts # ── Extra sets: user-supplied first, then generated ──────────── extra_dfs: Dict[str, pd.DataFrame] = {} for uname, (upts, ucolor) in self._user_sets.items(): df_u = pd.DataFrame(upts, columns=names) df_u['point_type'] = uname if ucolor is not None: df_u['color'] = ucolor df_u.index.name = 'id' extra_dfs[uname] = df_u cur_ref = np.vstack([cur_ref, upts]) if self._extra_sets: for set_name, set_n in self._extra_sets.items(): set_pts = self._kennard_stone( gs, cur_reserved, cur_ref, set_n, gmins, granges, ) for sp in set_pts: idx = gs.point_to_index(sp) if idx >= 0: cur_reserved.add(idx) cur_ref = (np.vstack([cur_ref, set_pts]) if len(set_pts) else cur_ref) df_set = (pd.DataFrame(set_pts, columns=names) if len(set_pts) else pd.DataFrame(columns=names)) df_set['point_type'] = set_name df_set.index.name = 'id' extra_dfs[set_name] = df_set # ── Assemble result ──────────────────────────────────────────── df_samples = pd.DataFrame(pts, columns=names) df_samples['point_type'] = label if label != SamplingResult.OPTIMISED: df_samples['color'] = lcolor if lcolor is not None else "#3a86ff" df_samples.index.name = 'id' df_val = (pd.DataFrame(val_pts, columns=names) if len(val_pts) else pd.DataFrame(columns=names + ['point_type'])) if len(df_val): df_val['point_type'] = SamplingResult.VALIDATION df_val.index.name = 'id' t_elapsed = time.perf_counter() - t_start print() print("─" * 60) print(" MERGEN — Final Design") print("─" * 60) print(f" {label:<17}: {len(df_samples)}") print(f" Total design : {len(df_samples)}") print(f" Validation : {len(df_val)}") for sn, sdf in extra_dfs.items(): print(f" {sn:<18}: {len(sdf)}") print("═" * 60) result = SamplingResult(df_samples, df_val, space) result.sets = extra_dfs result.designs = {'loaded': df_samples} result.algorithm_results = {} result.best_algorithm = 'loaded' result._meta = { 'criteria' : 'loaded', 'seed' : seed, 'algorithm' : 'loaded', 'best_algorithm' : 'loaded', 'n_parameters' : n_dims, 'n_candidates' : space.n_candidates, 'n_design' : len(df_samples), 'n_validation' : len(df_val), 'best_log_score' : float('nan'), 'elapsed_sec' : float(t_elapsed), } return result
[docs] def compare( self, criteria: Optional[List[str]] = None, algorithms: Optional[List[str]] = None, priority: Tuple[str, ...] = ('min_distance', 'max_abs_correlation'), mc_samples: int = 300, seed: int = 44, n_repeats: int = 5, n_jobs: Optional[int] = None, verbose: bool = True, ) -> "ComparisonResult": """ Run a criterion/algorithm sweep and rank the resulting designs. Every (criterion, algorithm) combination is optimised with the current sampler configuration. Because raw criterion scores are on incomparable scales, designs are ranked on criterion-agnostic quality metrics expressed as percentile ranks against a single shared Monte Carlo baseline of random designs of the same size (Joseph 2016; Pronzato & Mueller 2012). Parameters ---------- criteria : list of str, optional Criteria to sweep. If None, all criteria compatible with the space are used: nominal-supporting criteria when the space contains a nominal factor, all remaining criteria otherwise. An explicit list is used as given (with a warning if it mixes in criteria that do not match the factor types). algorithms : list of str, optional Optimisers to sweep. If None, ``['sa']``. priority : tuple of str Quality metrics treated as competing objectives when selecting the best design. All are on a common 0-100 percentile scale (higher is better) and are honoured jointly via a Pareto/Utopia rule (Lu, Anderson-Cook & Robinson 2011): the non-dominated designs are kept, and the one closest to the Utopia point (every metric = 100) is chosen. No weights are needed and no single metric dominates. Available metrics: min_distance, mean_distance, cv_distances, minimax, max_abs_correlation, projection_cd2. mc_samples : int, default 300 Size of the shared Monte Carlo baseline. seed : int, default 44 Base seed. All randomness (the repeats and the Monte Carlo baseline) is derived from it, so the whole comparison reproduces exactly from this one number. n_repeats : int, default 5 Number of independent optimiser runs per combination. Each run uses a distinct seed derived from ``seed`` via NumPy's SeedSequence.spawn (independent, not consecutive integers); the best-scoring repeat represents the combination, exactly as ``run(seed, n_repeats)`` would deliver it, and the table reports that design's metric percentiles. More repeats therefore improve every combination's delivered design rather than averaging over seed luck. Use ``n_repeats=1`` for a single run. n_jobs : int or None, default None Number of parallel workers for the combination x repeat runs, following the joblib convention (None or 1 -> one worker, -1 -> all cores). The runs are independent, so this scales well; results are identical regardless of n_jobs. verbose : bool, default True Print progress and the final ranking table. Returns ------- ComparisonResult ``.table`` (ranked DataFrame of percentile ranks), ``.results`` (dict mapping (criterion, algorithm) to the full SamplingResult), ``.best`` (winning key) and ``.priority``. """ from . import metrics as _m # ── Resolve the sweep lists ──────────────────────────────────── has_nominal = any(self.space.is_nominal(p) for p in self.space.names) nominal_ok = set(nominal_supporting_criteria()) if criteria is None: if has_nominal: crit_list = sorted(nominal_ok) else: crit_list = sorted(set(list_criteria()) - nominal_ok) else: crit_list = list(criteria) if has_nominal: bad = [cr for cr in crit_list if cr not in nominal_ok] if bad: _warn(f"Criteria {bad} do not support nominal factors; " f"their scores may be meaningless for this space.") else: bad = [cr for cr in crit_list if cr in nominal_ok] if bad: _warn(f"Criteria {bad} target nominal factors, which " f"this space does not contain; their extra terms " f"are dead weight here.") alg_list = list(algorithms) if algorithms is not None else ['sa'] for m in priority: if m not in _m._METRIC_FN: _fatal(f"Unknown priority metric '{m}'. " f"Available: {sorted(_m._METRIC_FN)}") metric_names = list(dict.fromkeys( list(priority) + ['min_distance', 'minimax', 'max_abs_correlation', 'projection_cd2', 'cv_distances', 'mean_distance'])) if n_repeats < 1: _fatal(f"n_repeats must be >= 1; got {n_repeats}") # One job per (criterion, algorithm) combination. Each job calls # Sampler.run(seed, n_repeats), which owns the single seed # derivation (SeedSequence(seed).spawn) and returns every repeat. # compare() therefore triggers exactly the optimisations a # follow-up run() would, so their designs always agree. gs = self.space.grid_sampler() gmins = self.space.gmins granges = self.space.granges combos = [(cr, alg) for cr in crit_list for alg in alg_list] effective_n_jobs = _resolve_n_jobs(n_jobs) # job_results: list of ((cr, alg), [repeat SamplingResults]) job_results = [] if effective_n_jobs > 1 and len(combos) > 1: from joblib import Parallel, delayed _prev_silent = os.environ.get('MERGEN_SILENT') os.environ['MERGEN_SILENT'] = '1' n_combos = len(combos) # The default 'loky' backend pays a large per-task # serialisation/spawn overhead that can make many short jobs # slower than serial (joblib #1443), so where the OS provides # the 'fork' start method (Linux, WSL, macOS) the cheaper # 'multiprocessing' backend is used; elsewhere (Windows) we # fall back to loky. The fork backend cannot stream results, # so progress is shown once the pool finishes; the notice # makes clear the run is working in the meantime. import multiprocessing as _mp _use_fork = 'fork' in _mp.get_all_start_methods() if verbose: print(f" [COMPARE] running {n_combos} combination" f"{'s' if n_combos > 1 else ''} " f"({n_repeats} repeat" f"{'s' if n_repeats > 1 else ''} each) " f"on {effective_n_jobs} workers ...", flush=True) if _use_fork: print(" [COMPARE] progress is reported once the " "workers finish; please wait ...", flush=True) try: if _use_fork: with Parallel(n_jobs=effective_n_jobs, backend='multiprocessing') as parallel: job_results = parallel( delayed(_compare_one_job)(self, cr, alg, seed, n_repeats) for cr, alg in combos) if verbose: for done, ((cr, alg), _reps) in enumerate( job_results, 1): print(f" [COMPARE] [{done}/{n_combos}]" f" {cr} + {alg} done", flush=True) else: # Windows: loky supports streaming, so each # combination is reported as it completes. done = 0 with Parallel(n_jobs=effective_n_jobs, return_as='generator') as parallel: stream = parallel( delayed(_compare_one_job)(self, cr, alg, seed, n_repeats) for cr, alg in combos) for (cr, alg), reps in stream: job_results.append(((cr, alg), reps)) done += 1 if verbose: print(f" [COMPARE] [{done}/{n_combos}]" f" {cr} + {alg} done", flush=True) finally: if _prev_silent is None: os.environ.pop('MERGEN_SILENT', None) else: os.environ['MERGEN_SILENT'] = _prev_silent else: for cr, alg in combos: if verbose: print(f" [COMPARE] {cr} + {alg} " f"({n_repeats} repeat" f"{'s' if n_repeats > 1 else ''}) ...", flush=True) job_results.append( _compare_one_job(self, cr, alg, seed, n_repeats)) # For each combination keep the representative repeat, selected # with the same priority-aware rule run(seed, n_repeats, # priority) applies (see _select_repeat), so the table describes # exactly the design each combination delivers for the user's # stated priorities. results: Dict[Tuple[str, str], SamplingResult] = {} per_combo_designs: Dict[Tuple[str, str], list] = {} for (cr, alg), reps in job_results: per_combo_designs[(cr, alg)] = reps results[(cr, alg)] = _select_repeat(reps, priority, self.space) # ── Shared Monte Carlo baseline ──────────────────────────────── n_ref = len(np.asarray(next(iter(results.values())).design)) if verbose: print(f" [COMPARE] Monte Carlo baseline " f"({mc_samples} random designs, n={n_ref}) ...", flush=True) baseline = _m._mc_baseline( gs, gmins, granges, n_ref, metric_names=metric_names, crit_names=[], mc_samples=mc_samples, seed=seed, space=self.space, ) # ── Percentile ranks of the best repeat, per combination ────── # run(seed, n_repeats) delivers the best-scoring repeat, so the # table describes exactly the design each combination delivers: # its rows match the winner's quality report one-to-one. (A mean # over repeats would describe average seed behaviour instead of # the delivered design and would disagree with best_result.) rows = [] for (cr, alg), designs in per_combo_designs.items(): row = {'criterion': cr, 'algorithm': alg} best_res = results[(cr, alg)] X = np.asarray(best_res.design, dtype=float) X_norm = (X - gmins) / np.where(granges > 1e-12, granges, 1.0) for m in metric_names: val = _m._compute_metric(m, X_norm, space=self.space) row[m] = float(_m._percentile_rank( val, baseline[m], m in _m._HIGHER_BETTER)) rows.append(row) table = pd.DataFrame(rows) # ── Pareto-frontier ranking with a Utopia-point selection ───── # The priority metrics are treated as competing objectives, all # already on a common 0-100 percentile scale (higher is better). # Rather than collapsing them with arbitrary weights (a weighted # sum favours extreme corner solutions and needs weights that are # not natural to set) or with a strict lexicographic order (which # lets the first metric dominate and all but ignores the rest), # designs are ranked with the Pareto/Utopia approach of Lu, # Anderson-Cook & Robinson (2011): keep the non-dominated # (Pareto-optimal) designs, then pick the one closest to the # Utopia point where every metric reaches 100. This honours every # priority metric without a weight choice. obj = list(priority) pts = table[obj].to_numpy(dtype=float) n = len(pts) def _dominates(a, b): # a dominates b: no worse on any objective, better on at least # one (all objectives are "higher is better" here). return bool(np.all(a >= b) and np.any(a > b)) pareto = [] for i in range(n): if not any(_dominates(pts[j], pts[i]) for j in range(n) if j != i): pareto.append(i) # Utopia point = best achievable on each objective (100 for a # percentile). Choose the Pareto design closest to it. utopia = np.full(len(obj), 100.0) dists = {i: float(np.linalg.norm(utopia - pts[i])) for i in pareto} best_pos = min(dists, key=dists.get) best_idx = table.index[best_pos] best_key = (table.loc[best_idx, 'criterion'], table.loc[best_idx, 'algorithm']) # Order rows by production order: criteria as the user listed # them, and within each criterion the algorithms in their listed # order. This keeps the table (and the heat map) grouped and # readable, rather than shuffled by score. crit_order = {cr: i for i, cr in enumerate(crit_list)} alg_order = {al: i for i, al in enumerate(alg_list)} table['_c'] = table['criterion'].map(crit_order) table['_a'] = table['algorithm'].map(alg_order) table = table.sort_values(['_c', '_a'], ignore_index=True) table = table.drop(columns=['_c', '_a']) table.insert(0, 'best', [' *' if (r.criterion, r.algorithm) == best_key else '' for r in table.itertuples()]) cmp = ComparisonResult(table=table, results=results, best=best_key, priority=tuple(priority)) # best_result must be a full SamplingResult, and it must match a # standalone run() exactly. Produce it by calling run() once for # the winning combination with the same seed and n_repeats — the # single seed derivation guarantees identical output. _bc, _ba = best_key cmp._best_result = self.run(criteria=_bc, algorithm=_ba, seed=seed, n_repeats=n_repeats, priority=tuple(priority), n_jobs=1, verbose=False) if verbose: cmp.summary() return cmp
@staticmethod def _fmt_time(seconds: float) -> str: if seconds < 60: return f"{seconds:.1f}s" if seconds < 3600: return f"{seconds / 60:.1f} min" return f"{seconds / 3600:.1f} hr" # ------------------------------------------------------------------ # # Private: layout helpers # # ------------------------------------------------------------------ # def _assemble_initial_design( self, prescribed_in_pts: List[np.ndarray], focus_in_pts: List[np.ndarray], in_design_sa_prescribed: List[np.ndarray], focus_sa_in_design_sampled: List[np.ndarray], seed_design: np.ndarray, n_dims: int, ) -> np.ndarray: """ Build the initial design matrix in canonical row order:: [ criterion-blind frozen rows | criterion-visible frozen rows | optimised rows ] The greedy seed begins with the anchor (criterion-visible frozen) rows and continues with the optimised rows. We prepend the criterion-blind frozen rows so a single ``crit_start`` index suffices in :meth:`_run_sce`. """ # Criterion-blind = in_design but NOT in_optim sa_set = {tuple(p) for p in in_design_sa_prescribed} blind_prescribed = [p for p in prescribed_in_pts if tuple(p) not in sa_set] sa_focus_set = {tuple(p) for p in focus_sa_in_design_sampled} blind_focus = [p for p in focus_in_pts if tuple(p) not in sa_focus_set] parts: List[np.ndarray] = [] if blind_prescribed: parts.append(np.array(blind_prescribed, dtype=float)) if blind_focus: parts.append(np.array(blind_focus, dtype=float)) # seed_design already starts with anchor (criterion-visible # frozen rows) followed by the optimised rows. parts.append(seed_design) out = np.vstack(parts) if parts else np.empty((0, n_dims)) # Sanity check on row count expected = (len(prescribed_in_pts) + len(focus_in_pts) + max(0, len(seed_design) - len(in_design_sa_prescribed) - len(focus_sa_in_design_sampled))) if len(out) != expected: _warn( f"Initial design size mismatch: expected ~{expected}, " f"got {len(out)}. Continuing." ) return out def _check_conflicts(self) -> None: """ Reject configurations where the same grid point appears in more than one role, and reject within-role duplicates. A given point may be prescribed *or* a focus centre *or* an exclusion centre — never two of these at once. """ p_pts = [pt for pt, _, _ in self._prescribed] f_pts = [fp.point for fp in self._focus] e_pts = [ep.point for ep in self._exclusions] def _overlap(a: np.ndarray, lst: List[np.ndarray]) -> bool: return any(np.allclose(a, b) for b in lst) # Cross-role conflicts for fp in f_pts: if _overlap(fp, p_pts): _fatal( f"Point {fp.tolist()} appears in both add_prescribed() " f"and add_focus(). A point cannot be both." ) for ep in e_pts: if _overlap(ep, p_pts): _fatal( f"Point {ep.tolist()} appears in both add_prescribed() " f"and add_exclusion(). A prescribed point cannot be " f"excluded." ) if _overlap(ep, f_pts): _fatal( f"Point {ep.tolist()} appears in both add_focus() and " f"add_exclusion(). A point cannot be both attracted " f"and repelled." ) # Within-role duplicates def _check_dupes(pts: List[np.ndarray], label: str) -> None: for i, a in enumerate(pts): for b in pts[i + 1:]: if np.allclose(a, b): _fatal( f"Point {a.tolist()} appears in {label} more " f"than once. Merge into a single entry." ) _check_dupes(p_pts, "add_prescribed()") _check_dupes(f_pts, "add_focus()") _check_dupes(e_pts, "add_exclusion()") # ------------------------------------------------------------------ # # Private: focus / exclusion sampling # # ------------------------------------------------------------------ # def _build_focus_exclusion( self, gs: GridSampler, gmins: np.ndarray, granges: np.ndarray, reserved: set, ) -> Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray], dict, set]: """ Sample focus neighbours and build exclusion repulsion weights. Returns ------- focus_in_pts : list[np.ndarray] Sampled focus points (centre + neighbours) for entries with ``in_design=True``. focus_out_pts : list[np.ndarray] Sampled focus points for entries with ``in_design=False``. focus_sa_pts : list[np.ndarray] All sampled focus points with ``in_optim=True`` (criterion-visible). repel_weights : dict[int, float] Sparse repulsion weights keyed by grid index, used by the coordinate-exchange candidate selector. reserved : updated set of reserved grid indices """ focus_in_pts: List[np.ndarray] = [] focus_out_pts: List[np.ndarray] = [] focus_sa_pts: List[np.ndarray] = [] repel_weights: Dict[int, float] = {} def _gaussian_weight(cpt: np.ndarray, centre: np.ndarray, spread: float) -> float: """Unnormalised Gaussian weight in normalised space.""" sigma = spread / max(granges.mean(), 1e-9) d2 = float(np.sum(((cpt - centre) / granges) ** 2)) return float(np.exp(-d2 / (2 * sigma ** 2))) # ── Focus points ────────────────────────────────────────────── for fp in self._focus: pt = fp.point self_idx = gs.point_to_index(pt) n_draw = fp.n_samples if fp.include_center is True: # Centre is guaranteed in the design if self_idx >= 0 and self_idx not in reserved: reserved.add(self_idx) (focus_in_pts if fp.in_design else focus_out_pts).append( pt.copy()) if fp.in_optim: focus_sa_pts.append(pt.copy()) n_draw = max(fp.n_samples - 1, 0) elif fp.include_center is False: # Centre is never selected if self_idx >= 0 and self_idx not in reserved: reserved.add(self_idx) # Gaussian-weighted neighbour sampling if n_draw == 0: continue K = min(gs.n_candidates - len(reserved), max(n_draw * 50, 2_000)) candidates: List[Tuple[float, int, np.ndarray]] = [] seen: set = set() for _ in range(K * 3): idx = random.randint(0, gs.n_candidates - 1) if idx in reserved or idx in seen: continue seen.add(idx) cpt = gs.index_to_point(idx) w = _gaussian_weight(cpt, pt, fp.spread) + 1e-12 candidates.append((w, idx, cpt)) if len(candidates) >= K: break if not candidates: _warn(f"FocusPoint {pt.tolist()}: no free candidates found.") continue weights = np.array([c[0] for c in candidates]) weights = weights / weights.sum() n_take = min(n_draw, len(candidates)) if n_take < n_draw: _warn( f"FocusPoint {pt.tolist()}: requested {n_draw} " f"neighbours but only {n_take} available." ) chosen = np.random.choice( len(candidates), size=n_take, replace=False, p=weights, ) for ci in chosen: _, cidx, cpt = candidates[ci] reserved.add(cidx) (focus_in_pts if fp.in_design else focus_out_pts).append(cpt) if fp.in_optim: focus_sa_pts.append(cpt) # ── Exclusion points ────────────────────────────────────────── for ep in self._exclusions: pt = ep.point self_idx = gs.point_to_index(pt) if self_idx >= 0 and self_idx not in reserved: reserved.add(self_idx) _info(f"ExclusionPoint {pt.tolist()}: removed from pool.") # Sample a neighbourhood to estimate repulsion weights K_repel = min(gs.n_candidates, 10_000) seen = set() local_w = [] for _ in range(K_repel * 2): idx = random.randint(0, gs.n_candidates - 1) if idx in seen: continue seen.add(idx) cpt = gs.index_to_point(idx) w = _gaussian_weight(cpt, pt, ep.spread) if w > 1e-6: local_w.append((idx, w)) if len(local_w) >= K_repel: break if local_w: max_w = max(w for _, w in local_w) for idx, w in local_w: repel_weights[idx] = repel_weights.get(idx, 0.0) + w / max_w return focus_in_pts, focus_out_pts, focus_sa_pts, repel_weights, reserved # ------------------------------------------------------------------ # # Private: Stochastic Coordinate Exchange (SCE) core # # ------------------------------------------------------------------ # def _kennard_stone( self, gs: GridSampler, reserved: set, selected: np.ndarray, n: int, gmins: np.ndarray, granges: np.ndarray, K_step: int = 5_000, ) -> np.ndarray: """ Kennard-Stone holdout selection via :class:`GridSampler`. At each step we pick the unreserved grid point farthest (in normalised Euclidean distance) from the current reference set. For small grids every non-reserved index is iterated exactly; for larger grids we sample ``K_step`` candidates per step. References ---------- Kennard, R. W. & Stone, L. A. (1969). *Technometrics*, 11(1), 137–148. """ if n <= 0: return np.empty((0, self.space.n_parameters)) reserved = reserved.copy() ref = selected.copy() chosen: List[np.ndarray] = [] for _ in range(n): best_pt, best_dist, best_idx = None, -1.0, -1 norm_ref = (ref - gmins) / granges if gs.n_candidates <= GridSampler.FULL_GREEDY_THRESHOLD: # Small grid: exhaustive scan for idx in range(gs.n_candidates): if idx in reserved: continue pt = gs.index_to_point(idx) pt_n = (pt - gmins) / granges d = float(np.min( np.linalg.norm(norm_ref - pt_n, axis=1))) if d > best_dist: best_dist, best_pt, best_idx = d, pt, idx else: # Large grid: sample K_step candidates seen: set = set() for _ in range(K_step): idx = random.randint(0, gs.n_candidates - 1) if idx in reserved or idx in seen: continue seen.add(idx) pt = gs.index_to_point(idx) pt_n = (pt - gmins) / granges d = float(np.min( np.linalg.norm(norm_ref - pt_n, axis=1))) if d > best_dist: best_dist, best_pt, best_idx = d, pt, idx if best_pt is None: break chosen.append(best_pt) ref = np.vstack([ref, best_pt]) reserved.add(best_idx) return (np.array(chosen) if chosen else np.empty((0, self.space.n_parameters))) def _snapshot_state(self) -> dict: """Capture the mutable design-spec state for safe restoration.""" return { 'prescribed_len' : len(self._prescribed), 'focus_len' : len(self._focus), 'exclusions_len' : len(self._exclusions), 'n_samples' : self._n_samples, 'n_validation' : self._n_validation, } def _restore_state(self, snap: dict) -> None: """Roll back any state added after ``snap`` was taken.""" del self._prescribed[snap['prescribed_len']:] del self._focus[snap['focus_len']:] del self._exclusions[snap['exclusions_len']:] self._n_samples = snap['n_samples'] self._n_validation = snap['n_validation']