"""Multi-objective quality indicators."""
from __future__ import annotations
import numpy as np
from saealib.comparators import ParetoDominator
from saealib.exceptions import ValidationError
def _objective_array(values: np.ndarray, n_obj: int | None = None) -> np.ndarray:
"""Convert objective values to a two-dimensional floating-point array."""
values = np.asarray(values, dtype=float)
if values.ndim == 1:
if values.size == 0 and n_obj is not None:
values = values.reshape(0, n_obj)
else:
values = values.reshape((0, 0) if values.size == 0 else (1, -1))
if values.ndim != 2:
raise ValueError("objective values must be a one- or two-dimensional array")
if n_obj is not None and values.shape[1] != n_obj:
raise ValueError("objective arrays must have the same number of objectives")
return values
def _pairwise_distances(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Return Euclidean distances between every row of ``a`` and ``b``."""
return np.linalg.norm(a[:, None, :] - b[None, :, :], axis=2)
def _pairwise_manhattan_distances(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Return Manhattan distances between every row of ``a`` and ``b``."""
return np.abs(a[:, None, :] - b[None, :, :]).sum(axis=2)
def _pairwise_plus_distances(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Return minimization positive-part distances between rows of ``a`` and ``b``."""
return np.linalg.norm(np.maximum(a[:, None, :] - b[None, :, :], 0.0), axis=2)
def gd(f: np.ndarray, reference_front: np.ndarray) -> float:
"""Compute the generational distance (GD), using minimization objectives.
GD is the mean distance from each solution to its nearest reference-front
point. An empty obtained front returns ``np.nan``; an empty reference
front raises :class:`~saealib.exceptions.ValidationError`. Singleton and
duplicate points are handled by the same nearest-neighbor formula.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
reference_front : np.ndarray
Reference-front objective matrix, shape (n_ref, n_obj).
Returns
-------
float
Mean nearest-reference Euclidean distance.
References
----------
Li, B., Li, J., Tang, K., & Yao, X. (2015).
Many-objective evolutionary algorithms: A survey.
*ACM Computing Surveys*, 48(1), Article 13.
https://doi.org/10.1145/2792984
"""
f, reference_front = _validated_pair(f, reference_front)
if len(f) == 0:
return np.nan
return float(np.min(_pairwise_distances(f, reference_front), axis=1).mean())
def igd(f: np.ndarray, reference_front: np.ndarray) -> float:
"""Compute inverted generational distance (IGD) for minimization objectives.
IGD is the arithmetic mean of the Euclidean distances from each
reference-front point to its nearest solution. An empty obtained front
returns ``np.nan``; an empty reference front raises
:class:`~saealib.exceptions.ValidationError`. Singleton and duplicate
points use the same nearest-neighbor formula.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
reference_front : np.ndarray
Reference-front objective matrix, shape (n_ref, n_obj).
Returns
-------
float
Mean nearest-solution Euclidean distance.
References
----------
Bosman, P. A. N., & Thierens, D. (2003).
The balance between proximity and diversity in multi-objective
evolutionary algorithms. *IEEE Transactions on Evolutionary
Computation*, 7(2).
"""
f, reference_front = _validated_pair(f, reference_front)
if len(f) == 0:
return np.nan
return float(np.min(_pairwise_distances(reference_front, f), axis=1).mean())
def gd_plus(f: np.ndarray, reference_front: np.ndarray) -> float:
"""Compute generational distance plus (GD+) for minimization objectives.
GD+ is the arithmetic mean of the nearest positive-part distances from
each solution to the reference front. Only objective-wise deterioration of
a solution relative to a reference point contributes. An empty obtained
front returns ``np.nan``; an empty reference front raises
:class:`~saealib.exceptions.ValidationError`.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
reference_front : np.ndarray
Reference-front objective matrix, shape (n_ref, n_obj).
Returns
-------
float
Mean nearest positive-part distance.
References
----------
Ishibuchi, H., Masuda, H., Tanigaki, Y., & Nojima, Y. (2015).
Modified distance calculation in generational distance and inverted
generational distance. *Evolutionary Multi-Criterion Optimization*.
Springer.
"""
f, reference_front = _validated_pair(f, reference_front)
if len(f) == 0:
return np.nan
return float(np.min(_pairwise_plus_distances(f, reference_front), axis=1).mean())
def igd_plus(f: np.ndarray, reference_front: np.ndarray) -> float:
"""Compute inverted generational distance plus (IGD+) for minimization objectives.
IGD+ is the arithmetic mean, over reference-front points, of the nearest
positive-part distance from a solution. Only objective-wise deterioration
contributes. An empty obtained front returns ``np.nan``; an empty
reference front raises :class:`~saealib.exceptions.ValidationError`.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
reference_front : np.ndarray
Reference-front objective matrix, shape (n_ref, n_obj).
Returns
-------
float
Mean nearest positive-part distance.
References
----------
Ishibuchi, H., Masuda, H., Tanigaki, Y., & Nojima, Y. (2015).
Modified distance calculation in generational distance and inverted
generational distance. *Evolutionary Multi-Criterion Optimization*.
Springer.
"""
f, reference_front = _validated_pair(f, reference_front)
if len(f) == 0:
return np.nan
return float(np.min(_pairwise_plus_distances(f, reference_front), axis=0).mean())
def spacing(f: np.ndarray, *, squared: bool = False) -> float:
"""Compute the spacing indicator for an obtained front.
The indicator summarizes how evenly a front is spread, through each
point's nearest other-point Manhattan distance. The default returns the
sample standard deviation of those distances, matching the form jMetal
and pymoo report. ``squared=True`` returns the sample variance instead,
which is the quantity Schott defines, and is the square of the default.
Empty and singleton sets return ``np.nan`` because both forms are
undefined there. Duplicate-only sets with at least two points return
``0.0``; duplicates contribute zero nearest-neighbor distances.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
squared : bool, optional
Return Schott's variance rather than its square root.
Returns
-------
float
Sample variance of the nearest-neighbor Manhattan distances when
*squared* is set, otherwise their sample standard deviation.
References
----------
Schott, J. R. (1995).
*Fault Tolerant Design using Single and Multicriteria Genetic
Algorithm Optimization*. Master's thesis, Massachusetts Institute of
Technology.
"""
f = _objective_array(f)
if len(f) < 2:
return np.nan
distances = _pairwise_manhattan_distances(f, f)
np.fill_diagonal(distances, np.inf)
nearest = np.min(distances, axis=1)
variance = float(np.sum((nearest - nearest.mean()) ** 2) / (len(f) - 1))
return variance if squared else float(np.sqrt(variance))
def spread(f: np.ndarray, reference_front: np.ndarray) -> float:
"""Compute Zhou et al.'s generalized spread indicator.
``f`` and ``reference_front`` use minimization objectives. Follows
Equation (12) of Zhou et al. (2006): each reference-front point is
measured against its nearest obtained solution, and the indicator reports
the deviation of those distances from their mean. The per-point distance
``d(X, S)`` allows a zero self-distance when ``X`` also belongs to ``S``
(the ``Y != X`` exclusion is not applied across sets), so that a well
distributed front covering the extremes yields ``0.0``. This differs from
jMetal-style implementations, which measure nearest-neighbor distances
within the obtained front. An empty obtained front returns ``np.nan``;
an empty reference front raises
:class:`~saealib.exceptions.ValidationError`. Singleton and duplicate-only
obtained sets are evaluated by the generalized formula.
Parameters
----------
f : np.ndarray
Obtained objective matrix, shape (n, n_obj).
reference_front : np.ndarray
Reference-front objective matrix, shape (n_ref, n_obj).
Returns
-------
float
Generalized spread value.
References
----------
Zhou, A., Jin, Y., Zhang, Q., Sendhoff, B., & Tsang, E. P. K. (2006).
Combining model-based and genetics-based offspring generation for
multi-objective optimization using a convergence criterion. *IEEE
Congress on Evolutionary Computation*.
Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. (2002).
A fast and elitist multiobjective genetic algorithm: NSGA-II. *IEEE
Transactions on Evolutionary Computation*, 6(2).
https://doi.org/10.1109/4235.996017
"""
f, reference_front = _validated_pair(f, reference_front)
if len(f) == 0:
return np.nan
nearest = np.min(_pairwise_distances(reference_front, f), axis=1)
mean_distance = nearest.mean()
extreme_points = np.vstack(
[
reference_front[np.argmax(reference_front[:, j])]
for j in range(reference_front.shape[1])
]
)
extremes = np.unique(extreme_points, axis=0)
boundary = np.min(_pairwise_distances(extremes, f), axis=1).sum()
denominator = boundary + len(reference_front) * mean_distance
if denominator == 0.0:
return 0.0
return float((boundary + np.sum(np.abs(nearest - mean_distance))) / denominator)
def _validated_pair(
f: np.ndarray, reference_front: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Normalize and validate a solution/reference-front pair."""
f_raw = np.asarray(f, dtype=float)
reference_raw = np.asarray(reference_front, dtype=float)
if f_raw.ndim == 1 and f_raw.size:
f_raw = f_raw.reshape(1, -1)
if reference_raw.ndim == 1 and reference_raw.size:
reference_raw = reference_raw.reshape(1, -1)
if f_raw.ndim == 1 and f_raw.size == 0 and reference_raw.ndim == 2:
f_raw = f_raw.reshape(0, reference_raw.shape[1])
if reference_raw.ndim == 1 and reference_raw.size == 0 and f_raw.ndim == 2:
reference_raw = reference_raw.reshape(0, f_raw.shape[1])
reference_front = _objective_array(reference_raw)
if len(reference_front) == 0:
raise ValidationError("reference_front must not be empty")
f = _objective_array(f_raw, reference_front.shape[1])
return f, reference_front
[docs]
def hypervolume(f: np.ndarray, reference_point: np.ndarray) -> float:
"""
Compute hypervolume indicator (minimization convention).
The hypervolume (S-metric) indicator was introduced by Zitzler &
Thiele (1998) as a way to measure the quality of a Pareto front
approximation without a priori knowledge of the true front.
Uses a recursive slicing algorithm. Complexity: O(n^(m-1) * n log n),
where n is the number of points and m is the number of objectives.
Parameters
----------
f : np.ndarray
Objective matrix, shape (n, n_obj).
reference_point : np.ndarray
Reference (nadir) point, shape (n_obj,). Each component must be
strictly greater than the corresponding component of at least one
point in f for a non-zero hypervolume to be returned.
Returns
-------
float
Hypervolume value.
"""
f = np.asarray(f, dtype=float)
reference_point = np.asarray(reference_point, dtype=float)
if f.ndim == 1:
f = f.reshape(1, -1)
# Keep only points that are strictly dominated by the reference point
mask = np.all(f < reference_point, axis=1)
f = f[mask]
if len(f) == 0:
return 0.0
return _hv(f, reference_point)
[docs]
def hypervolume_contributions(
f: np.ndarray,
reference_point: np.ndarray | None = None,
direction: np.ndarray | None = None,
margin: float = 0.1,
) -> np.ndarray:
r"""
Compute exclusive hypervolume contributions for each point.
The exclusive contribution of point ``i`` is defined as::
Delta_HV(i) = HV(S) - HV(S \ {i})
i.e. the volume exclusively dominated by point ``i`` relative to the
reference point. Higher is better, consistent with the library-wide
convention. Dominated points and points not strictly better than the
reference point contribute 0.
This function reuses :func:`hypervolume` and therefore inherits the
minimisation convention internally. Use the ``direction`` parameter to
handle maximisation objectives.
.. warning::
Computing hypervolume is exponential in the number of objectives, and
the leave-one-out loop adds a factor of N. For many objectives or
large N this becomes prohibitively expensive. The hypervolume kernel
this function calls already processes objectives rather than points,
the slicing structure of While, Hingston, Barone & Huband (2006), so
the remaining cost is the repetition itself: a faster contribution
algorithm has to avoid recomputing the whole volume once per point.
That work is not to be confused with the WFG benchmark-problem paper
by Huband et al. (2006), a different paper despite the overlapping
author list.
Parameters
----------
f : np.ndarray
Objective matrix, shape ``(N, n_obj)``.
reference_point : np.ndarray or None, optional
Reference point in the *original* objective space, shape
``(n_obj,)``. If ``None``, it is auto-computed from the data with
a fractional padding controlled by ``margin``.
direction : np.ndarray or None, optional
Per-objective optimisation direction: ``+1`` to maximise, ``-1``
to minimise. ``None`` defaults to all-minimise (equivalent to
``np.full(n_obj, -1.0)``). Same convention as
:func:`saealib.comparators.spea2_fitness`.
margin : float, optional
Fractional padding used when auto-computing the reference point.
For each objective axis the auto reference is placed at
``nadir + margin * span``. Ignored when ``reference_point`` is
provided.
Returns
-------
np.ndarray
Exclusive hypervolume contribution per point, shape ``(N,)``.
All values are ``>= 0`` (dominated / out-of-reference points get 0).
References
----------
Beume, N., Naujoks, B., & Emmerich, M. (2007).
SMS-EMOA: Multiobjective selection based on dominated hypervolume.
*European Journal of Operational Research*, 181(3), 1653-1669.
https://doi.org/10.1016/j.ejor.2006.08.008
Zitzler, E., & Thiele, L. (1998).
Multiobjective optimization using evolutionary algorithms - a
comparative case study. *PPSN V*, LNCS 1498, pp. 292-301.
"""
f = np.asarray(f, dtype=float)
if f.ndim == 1:
f = f.reshape(1, -1)
n, n_obj = f.shape
if n == 0:
return np.empty(0)
# Build per-objective sign vector: minimise → s=+1, maximise → s=-1.
# Multiplying f by s converts every objective to a minimisation objective.
s = np.ones(n_obj)
if direction is not None:
direction = np.asarray(direction, dtype=float)
s[direction == 1] = -1.0
g = f * s # transformed objective matrix (always minimise)
# Determine reference point in g-space.
if reference_point is not None:
ref_g = np.asarray(reference_point, dtype=float) * s
else:
nadir = g.max(axis=0)
ideal = g.min(axis=0)
span = nadir - ideal
ref_g = nadir.copy()
for j in range(n_obj):
if span[j] == 0.0:
# Degenerate axis: use absolute pad so ref is strictly > nadir.
ref_g[j] = nadir[j] + max(margin, 1e-12)
else:
ref_g[j] = nadir[j] + margin * span[j]
total = hypervolume(g, ref_g)
contrib = np.empty(n)
for i in range(n):
g_without = np.delete(g, i, axis=0)
hv_without = hypervolume(g_without, ref_g)
contrib[i] = total - hv_without
# Clip floating-point noise to zero.
return np.maximum(contrib, 0.0)
def _hv(f: np.ndarray, ref: np.ndarray) -> float:
n, m = f.shape
if m == 1:
return float(ref[0] - np.min(f[:, 0]))
# Sort ascending by last objective
order = np.argsort(f[:, -1], kind="stable")
f_s = f[order]
hv = 0.0
for i in range(n):
upper = f_s[i + 1, -1] if i + 1 < n else ref[-1]
height = upper - f_s[i, -1]
if height <= 0.0:
continue
proj = _non_dominated(f_s[: i + 1, :-1])
hv += height * _hv(proj, ref[:-1])
return hv
def _non_dominated(f: np.ndarray) -> np.ndarray:
"""Return non-dominated subset (minimization).
Parameters
----------
f : np.ndarray
Objective matrix, shape (n, n_obj). Must contain no NaN values.
Returns
-------
np.ndarray
Rows of ``f`` that are not dominated by any other row.
"""
n = len(f)
if n <= 1:
return f
# dom[i, j] = True iff row i dominates row j; a row is dominated when any
# column of dom is True for it (i.e. dom.any(axis=0)[j] is True).
dom = ParetoDominator().dominance_matrix(f)
dominated = dom.any(axis=0)
return f[~dominated]