Source code for mergen.criteria.umaxpro

"""
mergen.criteria.umaxpro
=======================
Uniform Maximum Projection (UMaxPro) criterion.

Reference
---------
Vorechovsky, M. & Masek, J. (2026). Uniform Maximum Projection
    Designs for Computer Experiments. *Computers & Structures*.
"""

from __future__ import annotations


import numpy as np

from .base import BaseCriterion, _EPS


[docs] class UMaxPro(BaseCriterion): """ Uniform MaxPro criterion with periodic (toroidal) distance. Defines the per-axis squared periodic distance:: δ²_v(i, j) = min(|x_iv − x_jv|, 1 − |x_iv − x_jv|)² The criterion to minimise is then:: uMaxPro(X) = Σ_{i<j} 1 / prod_v δ²_v(i, j) The toroidal wrap removes the boundary bias of plain MaxPro: corner regions are no longer artificially far from each other, so the resulting design is statistically uniform across the entire hypercube including its boundaries. References ---------- Vorechovsky, M. & Masek, J. (2026). *Computers & Structures*. [uMaxPro] Joseph, V. R., Gul, E. & Ba, S. (2015). *Biometrika*, 102(2), 371–380. [original MaxPro] """ @staticmethod def _periodic_sq(a: np.ndarray, b: np.ndarray) -> np.ndarray: """Periodic squared distance element-wise: min(|a-b|, 1-|a-b|)².""" d = np.abs(a - b) return np.minimum(d, 1.0 - d) ** 2
[docs] def evaluate(self, X: np.ndarray, space) -> float: n = len(X) score = 0.0 for i in range(n - 1): sq = np.maximum(self._periodic_sq(X[i], X[i + 1:]), _EPS) score += np.sum(1.0 / np.prod(sq, axis=1)) return max(score, _EPS)
[docs] def incremental(self, X, i, new_pt, space, current_score): mask = np.ones(len(X), dtype=bool) mask[i] = False others = X[mask] sq_old = np.maximum(self._periodic_sq(X[i], others), _EPS) sq_new = np.maximum(self._periodic_sq(new_pt, others), _EPS) old_contrib = np.sum(1.0 / np.prod(sq_old, axis=1)) new_contrib = np.sum(1.0 / np.prod(sq_new, axis=1)) new_score = max(current_score - old_contrib + new_contrib, _EPS) log_delta = np.log(new_score) - np.log(max(current_score, _EPS)) return float(log_delta), float(new_score)
[docs] def begin_1d(self, X, i, current_score): """ Precompute per-pair contributions and per-axis squared periodic distances for fast 1D coordinate-exchange trials. Complexity ---------- O(n · d) once per point. Each subsequent :meth:`try_1d` call then costs O(n), so the total cost for trying all d axes on the same point is O(n · d) — the same as a single full :meth:`incremental`, but split across d independent trials. """ mask = np.ones(len(X), dtype=bool) mask[i] = False others = X[mask] # (n-1, d) sq = np.maximum(self._periodic_sq(X[i], others), _EPS) # (n-1, d) # Per-pair full contribution: 1 / prod_v δ_v² contribs = 1.0 / np.prod(sq, axis=1) # (n-1,) return { 'X' : X, 'i' : i, 'others' : others, 'sq' : sq, 'contribs': contribs, 'score' : current_score, 'point' : X[i].copy(), # current (possibly modified) point i }
[docs] def try_1d(self, cache, axis, new_value, space): """ O(n) update: change one coordinate and rescale the cached contributions by the ratio of squared periodic distances. Mathematics ----------- For each j ≠ i, the pairwise contribution to uMaxPro is c_j = 1 / prod_v δ_v(i, j)² Changing only coordinate ``axis`` of point i replaces a single factor in the product:: c_j_new = c_j_old · ( δ_axis_old² / δ_axis_new² ) and the total score updates as S_new = S_old − Σ_j c_j_old + Σ_j c_j_new. """ others_axis = cache['others'][:, axis] sq_old_axis = cache['sq'][:, axis] # (n-1,) # New squared periodic distance on this axis d_new = np.abs(new_value - others_axis) sq_new_axis = np.maximum(np.minimum(d_new, 1.0 - d_new) ** 2, _EPS) # Rescale per-pair contributions: divide out old δ², multiply new new_contribs = cache['contribs'] * (sq_old_axis / sq_new_axis) old_sum = float(np.sum(cache['contribs'])) new_sum = float(np.sum(new_contribs)) new_score = max(cache['score'] - old_sum + new_sum, _EPS) log_delta = np.log(new_score) - np.log(max(cache['score'], _EPS)) # Build a trial cache reflecting the proposed change new_sq = cache['sq'].copy() new_sq[:, axis] = sq_new_axis new_point = cache['point'].copy() new_point[axis] = new_value new_cache = { 'X' : cache['X'], 'i' : cache['i'], 'others' : cache['others'], 'sq' : new_sq, 'contribs': new_contribs, 'score' : new_score, 'point' : new_point, } return float(log_delta), float(new_score), new_cache