Source code for openswmm_gymnasium.scoring.hypervolume

"""
Hypervolume indicator.

Plan §5.3: 2-D exact closed-form; Monte-Carlo for higher dimensions.
We deliberately B{do not} vendor C{pymoo} / C{platypus-opt}'s HV
implementations; the metrics live here so the package has no required
dependency on a third-party MOO library.

@author: Caleb Buahin
@copyright: Copyright (c) 2026 Caleb Buahin
@license: MIT
"""

from __future__ import annotations

import numpy as np
from numpy.typing import ArrayLike

from openswmm_gymnasium.scoring.normalization import normalize
from openswmm_gymnasium.scoring.pareto import pareto_front


[docs] def hypervolume( points: ArrayLike, reference: ArrayLike, *, method: str = "auto", mc_samples: int = 100_000, rng: np.random.Generator | None = None, ) -> float: """Hypervolume dominated by C{points} w.r.t. C{reference}. For C{d <= 2} an exact closed-form is used (sweep). For C{d >= 3} Monte-Carlo is used. Setting C{method="mc"} forces Monte-Carlo even in low dimensions (useful for cross-checking). @param points: 2-D array of shape C{(n, d)} in minimisation convention. @type points: array_like @param reference: 1-D array of length C{d} giving the worst-case (nadir) reference point. Points not strictly dominating C{reference} contribute nothing. @type reference: array_like @param method: C{"auto"}, C{"exact"} (only valid for C{d <= 2}), or C{"mc"}. @type method: str @param mc_samples: Number of Monte-Carlo samples when C{method} is C{"mc"} or auto-dispatched for C{d >= 3}. @type mc_samples: int @param rng: Optional pre-seeded generator for reproducible MC. @type rng: numpy.random.Generator or C{None} @return: Hypervolume value, C{0.0} for empty / fully dominated inputs. @rtype: float @raise ValueError: If C{method} is unknown or C{"exact"} is requested for C{d > 2}. """ p = np.asarray(points, dtype=float) ref = np.asarray(reference, dtype=float) if p.size == 0: return 0.0 if p.ndim != 2: raise ValueError("points must be a 2-D array of shape (n, d)") d = p.shape[1] if ref.shape != (d,): raise ValueError(f"reference shape {ref.shape} != ({d},)") # Discard rows not strictly dominating the reference. strict = np.all(p < ref, axis=1) p = p[strict] if p.size == 0: return 0.0 # Take the non-dominated subset only. p = pareto_front(p) if method == "exact" and d > 2: raise ValueError( f"method='exact' only supports d <= 2 (got d={d}); use method='auto' or method='mc'." ) if d == 1: return float(ref[0] - p[:, 0].min()) if d == 2 and method != "mc": return _hv_2d_exact(p, ref) return _hv_monte_carlo(p, ref, mc_samples=mc_samples, rng=rng)
[docs] def normalized_hypervolume( points: ArrayLike, ideal: ArrayLike, reference: ArrayLike, *, method: str = "auto", mc_samples: int = 100_000, rng: np.random.Generator | None = None, ) -> float: """Hypervolume in normalised C{[0, 1]^d} space. Normalises C{points} via L{normalize} so the reference becomes C{(1, ..., 1)} and the ideal becomes C{(0, ..., 0)}; the returned value is therefore in C{[0, 1]} regardless of the original objective scales. @rtype: float """ norm = normalize(points, ideal, reference) one_ref = np.ones(np.asarray(reference).shape[0], dtype=float) return hypervolume(norm, one_ref, method=method, mc_samples=mc_samples, rng=rng)
# --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- def _hv_2d_exact(p: np.ndarray, ref: np.ndarray) -> float: """2-D HV by sweeping the Pareto front sorted on the first axis.""" s = p[np.argsort(p[:, 0])] n = len(s) area = 0.0 for i in range(n): next_f1 = float(s[i + 1, 0]) if i + 1 < n else float(ref[0]) width = next_f1 - float(s[i, 0]) height = float(ref[1]) - float(s[i, 1]) if width > 0 and height > 0: area += width * height return area def _hv_monte_carlo( p: np.ndarray, ref: np.ndarray, *, mc_samples: int, rng: np.random.Generator | None, ) -> float: """Monte-Carlo HV via uniform sampling in C{[min(p, axis=0), ref]}.""" rng = rng if rng is not None else np.random.default_rng() low = p.min(axis=0) box_vol = float(np.prod(ref - low)) if box_vol <= 0: return 0.0 samples = rng.uniform(low=low, high=ref, size=(mc_samples, p.shape[1])) dominated = np.zeros(mc_samples, dtype=bool) for fp in p: dominated |= np.all(samples >= fp, axis=1) return float(box_vol * dominated.mean())