"""
Sequential and augmented design utilities for Mergen.
This module collects design-time operations that work on **existing**
designs: extending them with new points, filling in around reference
locations, sub-selecting space-filling subsets from a pool, and
assigning execution orders.
All functions in this module are *surrogate-free* — they look only at
parameter-space geometry, never at simulation outputs. Adaptive
sampling, active learning, expected improvement, and batch Bayesian
optimisation are intentionally out of scope; they belong in a
surrogate-modelling package.
Public API
----------
extend(sampler, existing_design, n_new, ...)
Append n_new space-filling points to an existing design while
preserving every input row (Wang 2003; Qian 2009).
fill_around(sampler, reference_points, n_new, ...)
Generate n_new points that space-fill *around* a set of reference
points; the reference points are criterion-visible but excluded
from the output.
subsample(sampler, pool, n_select, anchor='center')
Pick n_select space-filling points from an arbitrary candidate
pool via Kennard-Stone selection in normalised coordinates
(Kennard & Stone 1969).
run_order(sampler, design, anchor='center', column='run_order')
Assign an execution rank so that every cumulative prefix of the
design remains space-filling — useful for serial execution,
early stopping, interim analyses.
Notes
-----
The first argument of every public function is a ``Sampler``. The
sampler supplies the parameter-space metadata (axis ranges,
parameter names, constraints) but its existing state — prescribed
points, focus regions, exclusions, ``n_samples``, ``n_validation``
— is preserved across the call via the private snapshot/restore
hooks ``_snapshot_state`` / ``_restore_state``.
References
----------
Kennard, R. W. & Stone, L. A. (1969). Computer aided design of
experiments. *Technometrics*, 11(1), 137-148.
Wang, G. G. (2003). Adaptive response surface method using
inherited Latin hypercube design points. *Journal of Mechanical
Design*, 125(2), 210-220.
Qian, P. Z. G. (2009). Nested Latin hypercube designs.
*Biometrika*, 96(4), 957-970.
"""
from __future__ import annotations
from typing import List, Optional, Tuple, TYPE_CHECKING, Union
import numpy as np
import pandas as pd
from .sampler import _fatal
from .distances import heom_squared
if TYPE_CHECKING:
from .sampler import Sampler, SamplingResult
# ─────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────
def _coerce_design(
sampler: "Sampler",
design: Union[np.ndarray, pd.DataFrame, list, tuple],
) -> Tuple[np.ndarray, Optional[pd.DataFrame]]:
"""
Convert *design* to an ``(n, d)`` float array on the registered
parameters.
Returns
-------
arr : np.ndarray, shape (n, d)
df : pd.DataFrame or None
The original DataFrame when one was passed in (so the caller
can preserve its index and extra columns); ``None`` otherwise.
Raises
------
Exits the process via ``_fatal`` if columns are missing, sizes
mismatch, or types are incompatible.
"""
space = sampler.space
if isinstance(design, pd.DataFrame):
missing = [c for c in space.names if c not in design.columns]
if missing:
_fatal(
f"DataFrame is missing parameter column(s): {missing}.\n"
f" Expected columns: {space.names}"
)
arr = design[space.names].to_numpy(dtype=float, copy=True)
return arr, design
if isinstance(design, (list, tuple)):
design = np.asarray(design, dtype=float)
arr = np.asarray(design, dtype=float)
if arr.ndim != 2 or arr.shape[1] != space.n_parameters:
_fatal(
f"design must have shape (n, {space.n_parameters}); "
f"got {arr.shape}."
)
return arr, None
def _kennard_stone_array(
sampler: "Sampler",
pool: np.ndarray,
n_select: int,
anchor: Union[str, int, None] = 'center',
excluded: Optional[set] = None,
) -> List[int]:
"""
Array-based Kennard-Stone selection in normalised coordinates.
Picks ``n_select`` indices from ``pool`` so that each chosen point
is as far as possible (in normalised Euclidean distance) from the
set of already-chosen points.
Parameters
----------
sampler : Sampler
Provides ``sampler.space.gmins`` / ``granges`` /
``n_parameters`` for normalisation.
pool : np.ndarray, shape (N, d)
Candidate points in the original parameter space.
n_select : int
Number of points to pick (``1 ≤ n_select ≤ N``).
anchor : {'center', 'maximin', int, None}, default 'center'
Selection strategy for the first point.
- ``'center'``: the pool point closest to the hypercube
centre ``[0.5, …, 0.5]`` in normalised space. Recommended
for run-ordering — keeps the first run in a well-defined
interior location.
- ``'maximin'`` (Kennard-Stone original): the two farthest
pool points first, then continue. Counts as the first two
selections.
- ``int``: pool index ``anchor`` first.
- ``None``: equivalent to ``'center'``.
excluded : set of int, optional
Pool indices that must not be picked.
Returns
-------
list of int
Selected pool indices, in selection order.
References
----------
Kennard, R. W. & Stone, L. A. (1969). *Technometrics*, 11(1),
137-148.
"""
space = sampler.space
gmins, granges = space.gmins, space.granges
norm_pool = (pool - gmins) / granges
N = len(pool)
n_select = int(n_select)
# When the space has any nominal factor the ordinary Euclidean
# distance mixes ordered and unordered dimensions, which is not
# meaningful. Route through HEOM (Wilson & Martinez 1997), which
# applies the 0/1 overlap indicator on nominal columns and the
# normalised Euclidean term on numerical ones. For all-numerical
# spaces HEOM reduces to squared Euclidean, so the flag defaults
# to ``None`` (no space handed to the distance helper) and the
# existing behaviour is preserved bit-for-bit.
dist_space = space if space.has_nominal else None
if n_select <= 0:
return []
if n_select > N:
_fatal(f"n_select ({n_select}) exceeds pool size ({N}).")
excluded = set() if excluded is None else set(excluded)
available = set(range(N)) - excluded
if len(available) < n_select:
_fatal(
f"Only {len(available)} pool points are available after "
f"exclusions; cannot pick {n_select}."
)
chosen: List[int] = []
# ── First-point selection ──────────────────────────────────────
if anchor is None or anchor == 'center':
centre = np.full(space.n_parameters, 0.5)
avail = np.fromiter(available, dtype=int)
d_to_c = np.linalg.norm(norm_pool[avail] - centre, axis=1)
first = int(avail[int(np.argmin(d_to_c))])
chosen.append(first)
available.discard(first)
elif anchor == 'maximin':
avail = np.fromiter(available, dtype=int)
sub = norm_pool[avail]
# (N_avail, N_avail) squared HEOM distance between all pairs.
d2 = heom_squared(sub[:, None, :], sub[None, :, :],
space=dist_space)
i, j = np.unravel_index(int(np.argmax(d2)), d2.shape)
chosen.extend([int(avail[i]), int(avail[j])])
available.difference_update(chosen)
elif isinstance(anchor, (int, np.integer)):
anchor = int(anchor)
if anchor not in available:
_fatal(
f"anchor index {anchor} is not available "
f"(out of range or excluded)."
)
chosen.append(anchor)
available.discard(anchor)
else:
_fatal(
f"anchor must be 'center', 'maximin', an int, or None; "
f"got {anchor!r}."
)
# ── Sequential maximin selection ───────────────────────────────
while len(chosen) < n_select and available:
avail = np.fromiter(available, dtype=int)
ref = norm_pool[chosen]
cand = norm_pool[avail]
# Broadcasting gives an (N_cand, N_ref) squared HEOM matrix
# in a single call; take sqrt to match the Euclidean scale
# used by the original comparison. sqrt is monotone so the
# argmax below is unchanged either way; keeping it makes the
# code line up with Kennard & Stone (1969) as written.
d2 = heom_squared(cand[:, None, :], ref[None, :, :],
space=dist_space)
d_min = np.sqrt(d2).min(axis=1)
best = int(avail[int(np.argmax(d_min))])
chosen.append(best)
available.discard(best)
return chosen
# ─────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────
[docs]
def extend(
sampler: "Sampler",
existing_design: Union[np.ndarray, pd.DataFrame, list, tuple],
n_new: int,
criteria: str = 'cd2',
algorithm: Union[str, List[str]] = 'sa',
n_validation: int = 0,
seed: Optional[int] = 44,
verbose: bool = True,
) -> "SamplingResult":
"""
Extend an existing design by ``n_new`` space-filling points.
Every row of ``existing_design`` is treated as a fixed,
criterion-visible anchor; ``n_new`` additional points are then
optimised on top of them. The final design has shape
``(len(existing_design) + n_new, d)`` and the first
``len(existing_design)`` rows are exactly the input rows.
This is the surrogate-free augmentation strategy of Wang (2003)
and Qian (2009): preserve the historical runs, add new runs that
maximise space-filling of the *combined* design.
Parameters
----------
sampler : Sampler
Parameter space, constraints, and any pre-set design state
(focus regions, exclusions, etc.). The sampler is not
permanently modified; any state added during this call is
rolled back on exit.
existing_design : np.ndarray or DataFrame or list
Points that must appear in the final design unchanged.
DataFrame columns must include all parameter names.
n_new : int
Number of new points to add.
criteria, algorithm, n_validation, seed, verbose
Passed through to :meth:`Sampler.run`. Defaults match the
one-shot workflow.
Returns
-------
SamplingResult
References
----------
Wang, G. G. (2003). *Journal of Mechanical Design*, 125(2).
Qian, P. Z. G. (2009). *Biometrika*, 96(4).
"""
arr, _ = _coerce_design(sampler, existing_design)
if int(n_new) < 1:
_fatal(f"n_new must be ≥ 1; got {n_new}.")
snap = sampler._snapshot_state()
try:
for row in arr:
sampler.add_prescribed([row.tolist()],
in_design=True, in_optim=True)
sampler.set_design(
n_samples = len(arr) + int(n_new),
n_validation = int(n_validation),
)
return sampler.run(criteria=criteria, algorithm=algorithm,
seed=seed, verbose=verbose)
finally:
sampler._restore_state(snap)
[docs]
def fill_around(
sampler: "Sampler",
reference_points: Union[np.ndarray, pd.DataFrame, list, tuple],
n_new: int,
criteria: str = 'cd2',
algorithm: Union[str, List[str]] = 'sa',
n_validation: int = 0,
seed: Optional[int] = 44,
verbose: bool = True,
) -> "SamplingResult":
"""
Generate ``n_new`` points that space-fill the region *around* a
set of reference points.
Reference points are visible to the criterion (so new points keep
their distance from them) but are *not* included in the final
design. Use this when you have e.g. literature or pilot points
that you do not want to re-run, and you want subsequent runs to
cover the rest of the space efficiently.
Parameters
----------
sampler : Sampler
reference_points : np.ndarray or DataFrame or list
Coordinates of the points to fill around. Not included in the
output.
n_new : int
Number of new points to generate.
criteria, algorithm, n_validation, seed, verbose
See :meth:`Sampler.run`.
Returns
-------
SamplingResult
Contains exactly ``n_new`` design points.
"""
arr, _ = _coerce_design(sampler, reference_points)
if int(n_new) < 1:
_fatal(f"n_new must be ≥ 1; got {n_new}.")
snap = sampler._snapshot_state()
try:
for row in arr:
sampler.add_prescribed([row.tolist()],
in_design=False, in_optim=True)
sampler.set_design(
n_samples = int(n_new),
n_validation = int(n_validation),
)
result = sampler.run(criteria=criteria, algorithm=algorithm,
seed=seed, verbose=verbose)
finally:
sampler._restore_state(snap)
# Sampler.run() includes ``in_design=False`` prescribed rows in
# result.samples (with point_type 'Prescribed'). Drop them so the
# caller receives exactly ``n_new`` rows — the new points whose
# coordinates do not match any reference row.
sample_pts = result.samples[sampler.space.names].to_numpy()
keep = np.ones(len(sample_pts), dtype=bool)
for ref in arr:
keep &= ~np.all(np.isclose(sample_pts, ref), axis=1)
result.samples = result.samples[keep].reset_index(drop=True)
return result
[docs]
def subsample(
sampler: "Sampler",
pool: Union[np.ndarray, pd.DataFrame],
n_select: int,
anchor: Union[str, int, None] = 'center',
) -> Union[np.ndarray, pd.DataFrame]:
"""
Pick ``n_select`` space-filling points from a candidate pool.
Uses Kennard-Stone selection in normalised parameter coordinates:
each pick maximises the minimum distance to the already-picked
points.
Parameters
----------
sampler : Sampler
Used only for parameter-space metadata (axis ranges, names).
The sampler is not modified.
pool : np.ndarray or DataFrame
Candidate pool. DataFrame columns must include all parameter
names; the DataFrame index and extra columns are preserved on
the output. ndarrays return a plain ndarray.
n_select : int
Number of points to select. ``1 ≤ n_select ≤ len(pool)``.
anchor : {'center', 'maximin', int, None}, default 'center'
How to seed the selection. See ``_kennard_stone_array`` for
details. ``'center'`` is the most common choice; ``'maximin'``
reproduces the original Kennard-Stone (1969) behaviour.
Returns
-------
np.ndarray or DataFrame
The selected rows, in selection order. Type matches the input.
References
----------
Kennard, R. W. & Stone, L. A. (1969). *Technometrics*, 11(1),
137-148.
"""
arr, df = _coerce_design(sampler, pool)
idx = _kennard_stone_array(sampler, arr, n_select, anchor=anchor)
if df is not None:
return df.iloc[idx].copy()
return arr[idx].copy()
[docs]
def run_order(
sampler: "Sampler",
design: Union[np.ndarray, pd.DataFrame],
anchor: Union[str, int, None] = 'center',
column: str = 'run_order',
) -> pd.DataFrame:
"""
Assign an execution order so that every cumulative prefix of the
design remains space-filling.
Implements a sequential Kennard-Stone reordering: row 0 is the
chosen anchor; row k (for ``k > 0``) is the design point most
distant (in normalised Euclidean distance) from rows ``0, …,
k-1``. Stopping the run after any prefix yields a balanced
sub-design rather than a clustered one.
Use this when:
- Runs are executed serially (wetlab, paid HPC time, single-node
simulations)
- You may stop early or hit a budget cut
- Interim analyses or progress reporting may happen before
completion
Parameters
----------
sampler : Sampler
Provides parameter-space metadata for normalisation.
design : np.ndarray or DataFrame
Design to reorder. DataFrame columns must include all
parameter names; existing DataFrame columns are preserved.
anchor : {'center', 'maximin', int, None}, default 'center'
How to pick the first run. See :func:`subsample`.
column : str, default 'run_order'
Name of the column added to the returned DataFrame holding
the execution rank (``0`` = first to run).
Returns
-------
DataFrame
A copy of ``design`` reordered by execution rank, with the new
``column`` column. The original DataFrame index is preserved.
References
----------
Kennard, R. W. & Stone, L. A. (1969). *Technometrics*, 11(1),
137-148.
"""
arr, df = _coerce_design(sampler, design)
order = _kennard_stone_array(sampler, arr, len(arr), anchor=anchor)
if df is None:
out = pd.DataFrame(arr[order], columns=sampler.space.names)
else:
out = df.iloc[order].copy()
out[column] = range(len(out))
return out
[docs]
def k_fold_split(
sampler: "Sampler",
design: Union[np.ndarray, pd.DataFrame],
k: int,
anchor: Union[str, int, None] = 'center',
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""
Split a design into ``k`` space-filling folds for cross-validation.
Returns ``k`` ``(train_indices, test_indices)`` pairs in the same
format as :class:`sklearn.model_selection.KFold`. Within each pair,
the test fold and the training set are *both* space-filling — every
fold covers the parameter space rather than clustering in a
sub-region, so cross-validation estimates are not biased by an
unlucky split.
Algorithm
---------
The design is first reordered with :func:`run_order` (sequential
Kennard-Stone). Then a round-robin assignment distributes rows to
folds: row 0 → fold 0, row 1 → fold 1, …, row k-1 → fold k-1,
row k → fold 0, and so on. Because consecutive rows of the
KS-ordering are placed as far apart as possible, each fold ends up
with one representative from every region of the space.
Parameters
----------
sampler : Sampler
Provides parameter-space metadata for the normalisation used
inside :func:`run_order`.
design : np.ndarray or DataFrame
The full design to split. DataFrame columns must include all
parameter names.
k : int
Number of folds. ``2 ≤ k ≤ len(design)``. Folds are as
balanced as possible — the first ``len(design) mod k`` folds
receive one extra row each.
anchor : {'center', 'maximin', int, None}, default 'center'
First-point strategy for the underlying KS ordering. See
:func:`subsample`.
Returns
-------
list of (train_indices, test_indices)
Position-based indices (``0 ≤ idx < len(design)``) into the
input ``design``. Compatible with
``design.iloc[idx]`` for DataFrames and ``design[idx]`` for
ndarrays.
References
----------
Kennard, R. W. & Stone, L. A. (1969). *Technometrics*, 11(1),
137-148.
Qian, P. Z. G. (2012). Sliced Latin hypercube designs.
*Journal of the American Statistical Association*, 107(497),
393-399. [Related concept of slicing a design into
space-filling sub-designs.]
"""
arr, _ = _coerce_design(sampler, design)
n = len(arr)
k = int(k)
if k < 2:
_fatal(f"k must be ≥ 2; got {k}.")
if k > n:
_fatal(f"k ({k}) cannot exceed design size ({n}).")
# KS-sequential ordering of position indices (0..n-1)
order = _kennard_stone_array(sampler, arr, n, anchor=anchor)
order = np.asarray(order, dtype=int)
# Round-robin distribution → fold membership for every position
fold_of_pos = np.empty(n, dtype=int)
for rank, pos in enumerate(order):
fold_of_pos[pos] = rank % k
# Build (train_idx, test_idx) pairs
all_idx = np.arange(n)
return [(all_idx[fold_of_pos != f], all_idx[fold_of_pos == f])
for f in range(k)]
[docs]
def nested(
sampler: "Sampler",
n_outer: int,
n_inner: int,
criteria: str = 'cd2',
algorithm: Union[str, List[str]] = 'sa',
seed: Optional[int] = 44,
verbose: bool = True,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""
Build a nested pair of designs: a small *inner* design that is a
subset of a larger *outer* design, both space-filling.
Useful for multi-fidelity computer experiments, hierarchical
sensitivity studies, and any setting where two budget tiers run
on the same parameter space (e.g. fast/slow simulation, pilot vs.
main wetlab study). Every row of ``inner`` appears verbatim in
``outer``.
Algorithm
---------
1. Build the outer design via :meth:`Sampler.run` on the current
Sampler state (``n_samples = n_outer``).
2. Pick the inner design as a maximin Kennard-Stone subsample of
the outer (:func:`subsample` with ``anchor='maximin'``).
The resulting inner design is space-filling within outer, and
every outer point is space-filling in the full parameter space.
The classical He & Qian (2011) construction enforces a strict
LHS-preserving nesting; this pragmatic implementation does not
guarantee that, which is acceptable for the typical use case of
multi-fidelity computer experiments (where space-fillingness
matters more than the LHS marginals of the inner design). A
strict LHS-nested variant is planned for a future release.
Parameters
----------
sampler : Sampler
n_outer : int
Size of the outer (larger) design.
n_inner : int
Size of the inner (smaller) design. ``1 ≤ n_inner < n_outer``.
criteria, algorithm, seed, verbose
Passed to :meth:`Sampler.run` when building the outer design.
Returns
-------
outer : DataFrame, shape (n_outer, d + 1)
The outer design with one extra boolean column ``'in_inner'``
flagging the rows that belong to the inner design.
inner : DataFrame, shape (n_inner, d + 1)
The inner design (subset of outer, in KS selection order).
Same columns as ``outer``; the ``'in_inner'`` column is
constant ``True``.
References
----------
He, X. & Qian, P. Z. G. (2011). Nested orthogonal array-based
Latin hypercube designs. *Biometrika*, 98(3), 721-731.
Qian, P. Z. G. (2009). Nested Latin hypercube designs.
*Biometrika*, 96(4), 957-970.
"""
n_outer = int(n_outer)
n_inner = int(n_inner)
if n_inner < 1:
_fatal(f"n_inner must be ≥ 1; got {n_inner}.")
if n_outer <= n_inner:
_fatal(
f"n_outer ({n_outer}) must be strictly greater than "
f"n_inner ({n_inner})."
)
# ── Step 1: outer design via Sampler.run ────────────────────────
snap = sampler._snapshot_state()
try:
sampler.set_design(n_samples=n_outer, n_validation=0)
outer_result = sampler.run(criteria=criteria, algorithm=algorithm,
seed=seed, verbose=verbose)
finally:
sampler._restore_state(snap)
outer = outer_result.samples.copy()
pts = outer[sampler.space.names].to_numpy()
# ── Step 2: inner = maximin KS subsample of outer ───────────────
inner_pos = _kennard_stone_array(sampler, pts, n_inner, anchor='maximin')
inner_pos = sorted(inner_pos) # stable order in outer.index
mask = np.zeros(len(outer), dtype=bool)
mask[inner_pos] = True
outer['in_inner'] = mask
inner = outer.iloc[inner_pos].copy()
return outer, inner