"""
mergen.criteria.stratified
==========================
Stratified L2-Discrepancy criterion (SD).
Reference
---------
Tian, Y. & Xu, H. (2025). A stratified L2-discrepancy with
application to space-filling designs. *Journal of the Royal
Statistical Society: Series B*, 88(2).
"""
from __future__ import annotations
from typing import Optional, Union
import numpy as np
from .base import BaseCriterion, _EPS
[docs]
class StratifiedL2(BaseCriterion):
"""
Stratified L2-discrepancy (Tian & Xu 2025).
Measures uniformity by aggregating the L2-norm of the local
discrepancy over a hierarchical family of stratified regions. For a
given base ``s`` and maximum depth ``p``, the unit hypercube
:math:`[0,1)^m` is stratified into ``s^u`` equal intervals on each
axis for :math:`u = 0, 1, \\ldots, p`, producing
:math:`(p+1)^m` possible stratifications. The criterion sums the
squared L2 deviations from uniformity across all of these
stratifications, weighted by ``w(u)``.
Closed-form computational expression (Theorem 1, Eq. 13):
.. math::
SD(P)^2 = -\\left[\\sum_{i=0}^p w(i)\\, s^{-2i}\\right]^m
+ \\frac{1}{n^2} \\sum_{a,b=1}^n \\prod_{j=1}^m
\\left[\\sum_{i=0}^p w(i)\\, s^{-i}\\,
\\delta_i(x_{aj}, x_{bj})\\right]
where
:math:`\\delta_i(t, z) = \\mathbf{1}\\{\\lfloor s^i t \\rfloor =
\\lfloor s^i z \\rfloor\\}`.
Note that :math:`\\delta_0(t, z) \\equiv 1`.
Parameters
----------
s : int, default 2
Base number of strata per dimension. Common choices are
``s = 2`` (binary partitions) or ``s = 3`` (ternary).
p : int or None, default None
Maximum stratification depth. ``None`` selects
``floor(log_s(n))`` at evaluation time so that ``s^p ≤ n``
(Tian & Xu's recommendation to avoid over-stratification).
weights : {'constant', 'exponential'} or array-like, default 'auto'
- ``'constant'``: ``w(i) = 1`` for all ``i``, suitable for low
to moderate dimension.
- ``'exponential'``: ``w(i) = y^i`` with ``y = 2/(m+1)``, which
bounds ``SD(P)^2 < e`` regardless of dimension (Corollary 1).
- ``'auto'`` (default): exponential when ``m ≥ 8`` (curse-of-
dimensionality territory), constant otherwise.
- array-like: explicit weights ``[w(0), w(1), …, w(p)]``.
Notes
-----
Computational complexity is :math:`O(n^2 m p)` per evaluation. The
base :meth:`incremental` falls back to full re-evaluation, which
keeps SA's cost at :math:`O(n^2 m p)` per move; this matches CD2
and is acceptable for ``n \\le 200``.
References
----------
Tian, Y. & Xu, H. (2025). A stratified L2-discrepancy with
application to space-filling designs.
*Journal of the Royal Statistical Society, Series B*, 88(2).
"""
def __init__(
self,
s: int = 2,
p: Optional[int] = None,
weights: Union[str, np.ndarray] = 'auto',
) -> None:
if int(s) < 2:
raise ValueError(f"s must be ≥ 2; got {s}")
self.s = int(s)
self.p = (None if p is None else int(p))
self.weights = weights
# ── internal helpers ─────────────────────────────────────────────
def _resolve_p(self, n: int) -> int:
"""Tian & Xu's default: p = floor(log_s n) to cap at s^p ≤ n."""
if self.p is not None:
return self.p
if n <= 1:
return 1
return max(1, int(np.floor(np.log(n) / np.log(self.s))))
def _resolve_weights(self, m: int, p: int) -> np.ndarray:
"""Return the weight vector ``[w(0), w(1), …, w(p)]``."""
w = self.weights
if isinstance(w, str):
kind = w.lower()
if kind == 'auto':
kind = 'exponential' if m >= 8 else 'constant'
if kind == 'constant':
return np.ones(p + 1, dtype=float)
if kind == 'exponential':
y = 2.0 / (m + 1.0)
return np.array([y ** i for i in range(p + 1)], dtype=float)
raise ValueError(
f"weights must be 'constant', 'exponential', 'auto' "
f"or an array; got {w!r}"
)
w = np.asarray(w, dtype=float)
if w.shape != (p + 1,):
raise ValueError(
f"weights array must have shape ({p + 1},); got {w.shape}"
)
return w
@staticmethod
def _stratum_bins(X: np.ndarray, s: int, i: int) -> np.ndarray:
"""
Compute the stratum index ``floor(s^i * x)`` clamped to
``[0, s^i - 1]`` so that x = 1 is handled correctly.
Returns an int array of the same shape as X.
"""
if i == 0:
return np.zeros_like(X, dtype=np.int64)
N = s ** i
bins = np.floor(N * X).astype(np.int64)
np.clip(bins, 0, N - 1, out=bins)
return bins
# ── evaluate ─────────────────────────────────────────────────────
[docs]
def evaluate(self, X: np.ndarray, space) -> float:
X = np.asarray(X, dtype=float)
n, m = X.shape
s = self.s
p = self._resolve_p(n)
w = self._resolve_weights(m, p)
# Term 1: -[Σ_i w(i) · s^(-2i)]^m
coef_t1 = float(np.sum(w * np.array(
[s ** (-2 * i) for i in range(p + 1)], dtype=float)))
term1 = -(coef_t1 ** m)
# Term 2: (1/n²) Σ_{a,b} ∏_j [Σ_i w(i) · s^(-i) · δ_i(x_aj, x_bj)]
# Build, for each axis j, the n×n inner-sum matrix K_axis[a,b,j].
# i = 0 contributes w(0) (δ_0 ≡ 1).
K_axis = np.full((n, n, m), w[0], dtype=float)
for i in range(1, p + 1):
bins = self._stratum_bins(X, s, i) # (n, m)
equal = (bins[:, None, :] == bins[None, :, :]) # (n, n, m)
coef = float(w[i]) * (s ** (-i))
K_axis = K_axis + coef * equal
# Product over j and double sum over (a, b)
K_full = np.prod(K_axis, axis=2)
term2 = float(K_full.sum()) / (n * n)
sd2 = term1 + term2
return float(np.sqrt(max(sd2, 0.0)))
# ── incremental (full recompute, same as CD2) ────────────────────
[docs]
def incremental(self, X, i, new_pt, space, current_score):
X_new = X.copy()
X_new[i] = new_pt
new_score = self.evaluate(X_new, space)
log_delta = np.log(max(new_score, _EPS)) - np.log(max(current_score, _EPS))
return float(log_delta), float(new_score)
def __repr__(self) -> str:
if isinstance(self.weights, str):
w_str = repr(self.weights)
else:
w_str = "array"
return f"StratifiedL2(s={self.s}, p={self.p}, weights={w_str})"