Source code for mergen.criteria.maxproqq

"""
mergen.criteria.maxproqq
========================
Maximum Projection criterion for mixed quantitative and qualitative
factors (MaxProQQ).

Joseph, Gul & Ba (2020) extend the MaxPro criterion of Joseph, Gul &
Ba (2015) so that a single space-filling objective covers four
factor types:

- **continuous**: normalised to :math:`[0, 1]` with :math:`n` distinct
  levels; contribution :math:`(x_{il}-x_{jl})^{2}`.
- **discrete numeric** and **integer**: :math:`m_k` levels;
  contribution :math:`(|u_{ik}-u_{jk}| + 1/m_k)^{2}` where the
  :math:`1/m_k` term prevents the denominator from vanishing when two
  points fall on the same level.
- **ordinal**: scored to a numeric grid (Joseph 2019 §3) and treated
  as discrete numeric with :math:`m_k = L_k` levels.
- **nominal**: contribution
  :math:`(\\mathbb{1}(v_{ih}\\neq v_{jh}) + 1/L_h)^{2}` where
  :math:`\\mathbb{1}(\\cdot)` is the level-mismatch indicator and
  :math:`L_h` is the number of levels of the :math:`h`-th nominal
  factor.

For a design :math:`D=\\{x_1,\\dots,x_n\\}` with :math:`p_1` continuous,
:math:`p_2` discrete-numeric/ordinal, and :math:`p_3` nominal
factors, MaxProQQ minimises

.. math::

    \\psi(D) = \\left\\{
      \\frac{1}{\\binom{n}{2}}\\sum_{i<j}
        \\frac{1}
             {\\prod_{l=1}^{p_1}(x_{il}-x_{jl})^{2}\\;
              \\prod_{k=1}^{p_2}\\left(|u_{ik}-u_{jk}|+\\tfrac{1}{m_k}\\right)^{2}\\;
              \\prod_{h=1}^{p_3}\\left(\\mathbb{1}(v_{ih}\\neq v_{jh})+\\tfrac{1}{L_h}\\right)^{2}}
    \\right\\}^{1/(p_1+p_2+p_3)}.

When all factors are continuous the criterion reduces exactly to
the original MaxPro objective (Joseph, Gul & Ba 2015), so MaxProQQ
is a strict generalisation. The exponent :math:`1/(p_1+p_2+p_3)`
matches the dimensional homogeneity of MaxPro.

References
----------
Joseph, V. R., Gul, E. & Ba, S. (2019). Designing computer
    experiments with multiple types of factors: The MaxPro approach.
    *Journal of Quality Technology*, 52(4), 343-354.
Joseph, V. R., Gul, E. & Ba, S. (2015). Maximum projection designs
    for computer experiments. *Biometrika*, 102(2), 371-380.
"""
from __future__ import annotations

from typing import TYPE_CHECKING, List, Tuple

import numpy as np

from .base import BaseCriterion, _EPS

if TYPE_CHECKING:
    from ..space import ParameterSpace


# ─────────────────────────────────────────────────────────────────────
# Per-column contribution table
# ─────────────────────────────────────────────────────────────────────
#
# For each column of the normalised design we need to know two things
# to compute (and later update) the criterion:
#
#   kind  : 'continuous' | 'quantitative_discrete' | 'nominal'
#   size  : number of levels of that column (only used for
#           'quantitative_discrete' and 'nominal', to build the
#           1/m and 1/L regularisers).
#
# The Sampler hands us a ParameterSpace whose parameter types are one
# of  {'continuous', 'discrete', 'integer', 'ordinal', 'nominal'}.
# Following Joseph, Gul & Ba (2019) §3 we score ordinal factors as
# discrete numeric ones and merge all non-continuous non-nominal
# columns into the single 'quantitative_discrete' bucket.

_QUANTITATIVE_DISCRETE = ('discrete', 'integer', 'ordinal')


def _column_kind(param_type: str) -> str:
    """Map a ParameterSpace type string to the MaxProQQ column kind."""
    if param_type == 'continuous':
        return 'continuous'
    if param_type == 'nominal':
        return 'nominal'
    if param_type in _QUANTITATIVE_DISCRETE:
        return 'quantitative_discrete'
    raise ValueError(
        f"MaxProQQ: unknown parameter type {param_type!r}."
    )


def _column_contribution(
    diff: np.ndarray,
    kind: str,
    size: int,
) -> np.ndarray:
    """
    Compute the per-column pairwise contribution to the MaxProQQ
    denominator.

    Parameters
    ----------
    diff : np.ndarray
        Pairwise differences on the normalised scale, shape
        broadcastable to any ``(...,)``.
    kind : str
        One of ``'continuous'``, ``'quantitative_discrete'``,
        ``'nominal'``.
    size : int
        Number of levels of the column (used only when *kind* is
        ``'quantitative_discrete'`` or ``'nominal'``).

    Returns
    -------
    np.ndarray
        The squared contribution used in the product term.

    Notes
    -----
    Continuous columns are floored at ``_EPS`` following the same
    convention as :class:`~mergen.criteria.MaxPro`: when two rows
    collide on a continuous column the theoretical contribution is
    zero, which would make the pairwise denominator vanish. The
    floor keeps the criterion finite, which lets SA / SCE keep
    exploring past the collision instead of stalling on ``+inf``
    scores.
    """
    if kind == 'continuous':
        return np.maximum(diff * diff, _EPS)
    if kind == 'quantitative_discrete':
        return (np.abs(diff) + 1.0 / size) ** 2
    # kind == 'nominal'
    indicator = (~np.isclose(diff, 0.0)).astype(float)
    return (indicator + 1.0 / size) ** 2


# ─────────────────────────────────────────────────────────────────────
# MaxProQQ
# ─────────────────────────────────────────────────────────────────────
[docs] class MaxProQQ(BaseCriterion): """ Maximum Projection criterion for quantitative and qualitative factors (Joseph, Gul & Ba 2019). Extends :class:`~mergen.criteria.MaxPro` to spaces that mix continuous, discrete-numeric / integer / ordinal, and nominal factors. When the space contains only continuous columns, the criterion reduces exactly to plain MaxPro. Parameters ---------- delta : float, default 0.0 Additive floor for the pairwise denominator. Guards against division by zero when two rows share the same value on a continuous column (which does not happen for a valid LHD but can arise in constrained or coarse-grid searches). Joseph et al. (2019) do not use a floor; the default of ``0.0`` reproduces the paper exactly. Set to a small positive value (e.g. ``1e-12``) for extra numerical safety. Attributes ---------- supports_nominal : bool ``True`` — the criterion is defined on spaces containing nominal factors. Notes ----- The design matrix passed to :meth:`evaluate` and :meth:`incremental` is the same normalised ``X`` that every other Mergen criterion sees; nominal columns arrive as their integer level indices divided by ``L_h - 1`` (so distinct levels stay distinct after normalisation). The indicator :math:`\\mathbb{1}(v_i \\neq v_j)` is therefore evaluated by an ``np.isclose`` comparison rather than by looking at the raw labels. References ---------- Joseph, V. R., Gul, E. & Ba, S. (2020). *Journal of Quality Technology*, 52(4), 343-354. """ supports_nominal: bool = True def __init__(self, delta: float = 0.0) -> None: if delta < 0.0: raise ValueError(f"delta must be >= 0; got {delta}.") self.delta = float(delta) # ── internal helper ──────────────────────────────────────────── def _column_info( self, space: "ParameterSpace", ) -> Tuple[List[str], List[int]]: """Return per-column ``(kind, level_count)`` lists.""" kinds = [_column_kind(space.param_types[n]) for n in space.names] sizes = list(space.n_levels) return kinds, sizes # ── evaluate ───────────────────────────────────────────────────
[docs] def evaluate(self, X: np.ndarray, space) -> float: """ Compute the MaxProQQ score on the normalised design *X*. To stay consistent with Mergen's :class:`MaxPro` — which reports the inner pairwise sum rather than the outer :math:`(\\cdot)^{1/d}` root — this method also returns the raw sum .. math:: \\text{score}(D) = \\sum_{i<j} \\frac{1}{\\prod_{k} c_k(x_{ik}, x_{jk}) + \\delta}, where the per-column term :math:`c_k` follows Joseph, Gul & Ba (2019) Eq. 9. The Mergen optimisers rank designs by ``log(score)``, so the monotone :math:`1/d` power is redundant and would only shrink dynamic range for the SA/SCE acceptance rules. Users who want the paper's :math:`\\psi(D)` can recover it as ``(score / (n*(n-1)/2)) ** (1/d)``. Parameters ---------- X : np.ndarray, shape (n, d) — normalised coordinates in :math:`[0, 1]^d`. space : ParameterSpace — supplies the per-column factor type and level count. Returns ------- float — the raw pairwise score (smaller is better). """ n, d = X.shape if n < 2: return _EPS kinds, sizes = self._column_info(space) # Pairwise differences: (n, n, d) diff = X[:, None, :] - X[None, :, :] # Per-column contribution: same shape contrib = np.empty_like(diff) for k in range(d): contrib[..., k] = _column_contribution( diff[..., k], kinds[k], sizes[k], ) # Product across columns, then upper triangle prod = np.prod(contrib, axis=-1) # (n, n) iu, ju = np.triu_indices(n, k=1) pair_prod = prod[iu, ju] # Continuous columns are floored at _EPS in # _column_contribution, so pair_prod is strictly positive # even when two rows collide on every column. The optional # delta adds an extra safety cushion. denom = pair_prod + self.delta return float(np.sum(1.0 / denom))
# ── incremental (single-point swap) ────────────────────────────
[docs] def incremental( self, X: np.ndarray, i: int, new_pt: np.ndarray, space, current_score: float, ) -> Tuple[float, float]: """ O(n*d) update when point *i* is replaced by *new_pt*. The MaxProQQ objective is a sum over pairs, so replacing a single row changes only the ``n-1`` pairs that involve that row. We subtract those pairs' old contributions and add the new ones without recomputing the full :math:`O(n^2)` sum. Parameters ---------- X : np.ndarray, shape (n, d) i : int — row being replaced new_pt : np.ndarray, shape (d,) space : ParameterSpace current_score : float — raw pairwise sum before the swap Returns ------- log_delta : float ``log(new_score) - log(current_score)``; negative values mark an improvement. new_score : float Raw pairwise sum after the swap. """ n, d = X.shape if n < 2: return 0.0, current_score kinds, sizes = self._column_info(space) # Pair contributions involving row i (old and prospective). others_mask = np.arange(n) != i others = X[others_mask] # (n-1, d) old_diff = others - X[i] # (n-1, d) new_diff = others - new_pt old_contrib_cols = np.empty_like(old_diff) new_contrib_cols = np.empty_like(new_diff) for k in range(d): old_contrib_cols[:, k] = _column_contribution( old_diff[:, k], kinds[k], sizes[k], ) new_contrib_cols[:, k] = _column_contribution( new_diff[:, k], kinds[k], sizes[k], ) old_prod = np.prod(old_contrib_cols, axis=-1) new_prod = np.prod(new_contrib_cols, axis=-1) # Continuous contributions are floored at _EPS in # _column_contribution, so both denom vectors are strictly # positive. Optional delta adds an extra safety cushion. old_denom = old_prod + self.delta new_denom = new_prod + self.delta old_contrib = float(np.sum(1.0 / old_denom)) new_contrib = float(np.sum(1.0 / new_denom)) new_score = current_score - old_contrib + new_contrib if new_score <= 0.0: new_score = _EPS log_delta = float(np.log(new_score) - np.log(max(current_score, _EPS))) return log_delta, new_score