Skip to content

Implement NSGA-III for many-objective optimization - #633

Open
luishpmendes wants to merge 42 commits into
esa:masterfrom
luishpmendes:nsga3-finish
Open

Implement NSGA-III for many-objective optimization#633
luishpmendes wants to merge 42 commits into
esa:masterfrom
luishpmendes:nsga3-finish

Conversation

@luishpmendes

Copy link
Copy Markdown

Summary

This PR adds an implementation of the NSGA-III many-objective evolutionary algorithm to pagmo2, following Deb and Jain (2014).

It supersedes #569 and continues the original work by @pmslavin. The implementation has been rebased onto the current master branch, completed, documented, and expanded with correctness fixes and comprehensive tests.

NSGA-III retains the non-dominated sorting mechanism of NSGA-II but replaces crowding-distance-based environmental selection with reference-direction association and niching, making it more suitable for problems with many objectives.

Main changes

  • Add the public pagmo::nsga3 algorithm.
  • Integrate NSGA-III into the pagmo2 build system and umbrella header.
  • Add Das–Dennis reference-direction generation.
  • Support both single-layer and two-layer reference-direction sets.
  • Add reference-direction association and niching selection.
  • Implement objective translation, extreme-point detection, intercept estimation, and normalization.
  • Support continuous and mixed-integer box-bounded problems.
  • Support pagmo batch fitness evaluators through set_bfe().
  • Add configurable random mating, matching the original NSGA-III paper.
  • Add optional NSGA-II-style binary-tournament mating.
  • Add optional inter-generational memory for the ideal and extreme points.
  • Add verbosity, logging, seed management, serialization, and algorithm metadata.
  • Add Doxygen and Sphinx documentation.

Improvements over #569

This version addresses several correctness, robustness, and reproducibility issues present in the original draft:

  • Rebased the implementation onto the current pagmo2 master.
  • Moved NSGA-III implementation details and reference-point utilities into pagmo::detail.
  • Corrected duplicate extreme-point detection to compare complete vectors with a numerical tolerance.
  • Corrected fallback normalization to use maxima in the translated objective space.
  • Replaced the original fragile linear-system solver with Gaussian elimination using partial pivoting and singularity detection.
  • Added safeguards for zero, negative, and non-finite intercepts.
  • Removed dependence on global random state.
  • Routed all stochastic operations through the algorithm-local random engine.
  • Corrected generation numbers in verbosity output and logs.
  • Completed serialization of all behavior-affecting configuration and state, including the random engine and optional memory.
  • Store retained extreme points in the original objective coordinates so they remain valid when the ideal point changes.
  • Generate reference directions once per evolution rather than once per generation.
  • Allow the population size to equal the number of reference directions, as required by the configurations presented in the original paper.
  • Add overflow and excessive-allocation checks for reference-direction generation.
  • Validate constructor arguments, problem properties, population size, and batch-evaluator output.
  • Ensure repeated one-generation evolutions are reproducible with a single multi-generation evolution when the same state is preserved.

Reference directions

The implementation supports the Das–Dennis systematic construction:

[
H = \binom{M + p - 1}{p},
]

where (M) is the number of objectives and (p) is the number of divisions.

For problems with many objectives, the implementation also supports the two-layer construction described by Deb and Jain. This allows configurations such as:

Objectives Outer divisions Inner divisions Directions Population
3 12 0 91 92
5 6 0 210 212
8 3 2 156 156
10 3 2 275 276
15 2 1 135 136

Mating selection

The default behavior follows Section IV-F of the original paper and selects mating parents randomly.

An optional NSGA-II-style binary tournament based on non-domination rank and crowding distance is also available through the random_mating constructor argument. This alternative is documented as a deliberate deviation from the original algorithm.

Tests

Two test executables are added:

  • tests/nsga3.cpp

    • reference-direction generation;
    • two-layer reference directions;
    • normalization and intercept calculation;
    • singular and degenerate normalization cases;
    • reference-direction association and niching;
    • deterministic random behavior;
    • logging and verbosity;
    • serialization and state preservation;
    • memory mode;
    • batch fitness evaluation;
    • constructor, problem, and population validation;
    • continuous and mixed-integer evolution.
  • tests/nsga3_quality.cpp

    • fixed-seed quality regressions on DTLZ1, DTLZ2, and DTLZ4;
    • tests with 3, 5, and 8 objectives;
    • two-layer reference directions;
    • random and tournament mating;
    • population-size and evaluation-count invariants;
    • finite and bounded solutions;
    • non-dominated population fraction;
    • reference-direction coverage;
    • distance to the analytical Pareto front;
    • inverted generational distance;
    • seed reproducibility and sensitivity.

The quality-test thresholds are intentionally conservative and are intended to detect algorithmic regressions rather than small seed-dependent variations.

Scope

This PR is limited to the C++ pagmo2 implementation of standard NSGA-III.

The following are intentionally outside its scope:

  • pygmo Python bindings;
  • U-NSGA-III;
  • externally supplied arbitrary reference directions;
  • support for constrained or stochastic problems.

Python bindings can be addressed in a separate pygmo PR after the C++ API has been reviewed and accepted.

Reference

K. Deb and H. Jain, “An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based Nondominated Sorting Approach, Part I: Solving Problems With Box Constraints,” IEEE Transactions on Evolutionary Computation, vol. 18, no. 4, pp. 577–601, 2014.

DOI: 10.1109/TEVC.2013.2281535

pmslavin and others added 30 commits December 12, 2023 21:18
The solver had no pivoting and no dimension checks, and threw
std::invalid_argument on a zero leading pivot. An ordinary degenerate
front therefore terminated evolve() instead of taking the documented
nadir fallback.

Dimension errors still throw; singularity is now reported by returning
an empty optional, detected with a tolerance that scales with the
magnitude of the matrix entries. find_intercepts falls back on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The constructor reseeded the global pagmo::random_device and
choose_random_element drew from it, so constructing or evolving one
nsga3 perturbed every other pagmo object in the process. Results were
neither reproducible nor independent between instances.

Reference point niching now takes the instance engine m_reng, and
choose_random_element draws a single index from the engine it is given.
While here: remove_candidate stops at the match it has just erased,
get_coeffs returns a reference, and associate_with_reference_points
takes its arguments by const reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nadir fallback returned worst values in original objective
coordinates while normalize_objectives divides translated ones, so
normalization was skewed whenever the ideal point was not the origin.
Translation preserves the dominance relation, so nadir(translated_objs)
is exactly worst - ideal.

The helpers now take an explicit ideal point instead of a population,
which is what makes the coordinate system unambiguous; compute_ideal
owns the running-ideal update. Zero, negative and non-finite intercepts
fall back to 1.0, leaving a degenerate objective at zero rather than
NaN, and normalize_objectives no longer reads translated_objs[1].

The running v_nadir is dropped from NSGA3Memory: its only consumer was
the old fallback, and it made normalization depend on worst-case values
that may no longer be attainable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two extreme points were declared duplicates as soon as a single
coordinate matched. The canonical NSGA-III extreme point matrix
{{1,0,0},{0,1,0},{0,0,1}} shares zeros pairwise, so the solver was
almost never reached and normalization silently degraded to the nadir
fallback on well conditioned fronts.

They now coincide only when every coordinate matches, within a
tolerance relative to the magnitude of the coordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extreme points were stored in the translated coordinates of the
generation that produced them, then compared against the next
generation's translated values. Once the ideal point moved, the two
coordinate systems no longer agreed.

They are now stored in original objective coordinates and translated by
the current ideal point on each use. The zero vectors used to prime the
memory are gone too: their ASF is zero, which is minimal over the
non-negative translated objectives, so they won every comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parents were drawn with std::sample from a freshly constructed
std::mt19937 per mating, which left shuffle1 and shuffle2 unused and
built a throwaway engine 2*NP times per generation.

Mating now uses detail::mo_tournament_selection_impl on the
non-domination rank and the crowding distance, exactly as nsga2 does,
driven by the shuffled index lists and the instance engine. Deb & Jain
select parents at random; Seada & Deb identify that as a weakness of
NSGA-III, so the deviation is deliberate and noted in the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verbosity test, the printed column and the log entry all used
m_gen, the total generation count, instead of gen, the current one.
Every log line therefore claimed the same generation number, and the
verbosity test either logged every generation or none of them
regardless of the interval requested.

The log entry also moves inside the interval test, so verbosity n
records one line every n generations, as in nsga2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only 8 of the 13 members were archived: m_divisions, m_use_memory,
m_memory and m_reng were all lost. A deserialised instance could not
continue an evolution, and with the default divisions restored it threw
std::invalid_argument on populations the original had accepted.

Adds get_extra_info, which every other UDA provides, so that the
algorithm's stream representation reflects its configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaussian_elimination, achievement, perpendicular_distance and
choose_random_element were added to the public utils/multi_objective
header alongside genuinely general utilities such as ideal, nadir and
fast_non_dominated_sorting. None of them is an established pagmo public
utility: the solver exists only for the NSGA-III intercept system,
achievement and perpendicular_distance are its ASF and association
measures, and choose_random_element is a two line helper.

They move verbatim to a new detail/nsga3_impl unit, which restores
utils/multi_objective to its exact contents on master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luishpmendes and others added 11 commits August 2, 2026 12:07
utils/reference_point.hpp was a public header exposing a type spelled in
CamelCase, unlike every other pagmo type, together with four free
helpers that carried no export macro at all. None of it is usable
outside NSGA-III.

The unit moves under detail/ and the type is renamed reference_point;
the free functions gain PAGMO_DLL_PUBLIC so that they can be reached
from the test suite. nsga3::generate_uniform_reference_points joins them
as a free function, which also retires the m_refpoints member: it was
cleared and rebuilt on every call and never read back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every helper the implementation needed had been made public, because
that was the only way the tests could reach it: eleven implementation
members plus the NSGA3Memory struct, against nsga2's nine member
surface.

selection() and the memory struct become private, the latter renamed
nsga3_memory for snake_case consistency, and the adaptive normalisation
pipeline moves to detail/nsga3_impl. has_memory() is dropped: no other
UDA exposes its constructor arguments, and the flag is still visible
through get_extra_info().

The pipeline helpers take the retained ideal point and extreme points as
pointers, a null one meaning the quantity is not retained across
generations. That keeps the memory struct private while leaving every
helper directly unit-testable, and reproduces the previous branching
exactly. The archives written by serialize() are unchanged.

The NSGA-III files also pick up the pagmo copyright header they were
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
algorithms/nsga3.hpp closes with PAGMO_S11N_ALGORITHM_EXPORT_KEY but never
included the header that defines it, so anything including it as its first
pagmo header failed to compile: the macro was left as an unparenthesised
expression at namespace scope.

nsga2.hpp has always included pagmo/algorithm.hpp; nsga3.hpp has been
missing it since its first commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nsga3::selection() was private after the API reduction, so the selection
loop could not be tested directly and a golden test would have had to
reimplement the niching loop rather than exercise it.

Its body was already a pure function of the objective vectors: it reached
the population only for get_f() and get_nobj(). It moves to detail as
nsga3_selection, taking the objective vectors, the target size, the
divisions and the two memory pointers, which completes the split started
when the normalisation pipeline moved. evolve() calls it directly and the
private member is gone.

Behaviour-preserving: the test suite output is byte-identical, so no
random draw moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing cases were written to pin the correctness fixes, so whole
areas had no coverage at all: association, niching, perpendicular
distance, the reference point candidate API and environmental selection.
nsga3_evolve_population ran an evolve and asserted nothing.

Adds exact Das and Dennis reference point sets for the small cases,
n_choose_k, perpendicular distance and achievement scalarization against
hand computed values, the candidate and niching primitives, a golden
association case, seeded tie-breaking, and golden environmental selection.
nsga3_evolve_population now checks the population size, the bounds, finite
objectives and the evaluation count.

Golden indices are only asserted where the random draw cannot vary, that
is on a singleton choice or the nearest-candidate path.
std::uniform_int_distribution is not specified to map engine output to
values identically across standard libraries, and CI builds against both
libstdc++ and libc++, so an index drawn from a set of two or more is
pinned by structure and same-seed equality instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing checked that NSGA-III actually converges: the only end to end
cases were an evolve with no assertions and a zdt5 integrality check.

Adds a separate target covering DTLZ1, DTLZ2 at 3, 5 and 8 objectives and
DTLZ4, each asserting finite objectives, in-bounds decision vectors, a
preserved population size, the evaluation count, a nondominated fraction,
reference direction coverage, pagmo's p_distance and the distance to the
analytic front.

Every bound was measured over four seeds and then loosened well past the
worst of them, rather than tuned to the value the fixed seed happens to
produce. DTLZ4 gets no coverage bound on purpose: its bias collapses the
population onto part of the front, and coverage ranged from 0.14 to 1.00
across seeds. The 8 objective case uses 2 divisions because reference
points are generated on a single layer, so the population has to exceed
the number of directions.

The suite also serves as a compile-time guard that algorithms/nsga3.hpp
stays usable as the first pagmo header in a translation unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five cases evolved a population, ran one stage of the normalisation
pipeline and then asserted only a container size and BOOST_CHECK_NO_THROW,
so a stage could return entirely wrong numbers and still pass.

They now assert values: translation is an exact shift by the ideal point,
which is itself the componentwise minimum, and leaves every objective
attaining zero; each extreme point minimises an independently recomputed
achievement scalarization over the whole first front and is a real member
of it; normalization is exactly the translated objective over its own
intercept, and the intercept vector maps to all ones; zdt5 keeps its
integrality check and gains size, finiteness and nondominance.

The hyperplane identity sum_j ext[j]/intercept[j] == 1 is asserted on a
synthetic case rather than on the evolved population, because the nadir
fallback legitimately breaks it whenever the extreme points coincide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 5 and 8 objective regressions used lighter settings than the ones Deb
and Jain report: 4 divisions over 5 objectives and 2 over 8, well below
the published benchmark.

They now use 6 divisions for 5 objectives, 210 directions and 212
individuals, straight from Table I. Table I's 8 objective entry is the two
layer scheme p = 3 + 2 giving 156 directions, which single layer
generation cannot express, so 3 divisions with 120 directions and 124
individuals is the closest analogue.

The existing bounds already hold at the new sizing, measured over four
seeds: p_distance 0.018 for 5 objectives and 0.032 for 8, against a bound
of 0.2, with full coverage against a bound of 0.8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
p_distance works from the decision vectors and the analytic front error is
a per-individual distance, so neither notices a population that has
converged onto only part of the front. DTLZ4 does exactly that: at seed 32
its p_distance is 0.0016, which looks excellent, while the population
covers a seventh of the reference directions.

Adds the inverted generational distance, the mean over a sampled front of
the distance to the nearest individual, which degrades under that
collapse: the same run scores 0.53. The reference set is the analytic
front sampled at the structured reference directions, the construction Deb
and Jain use, computed in-test so no data file or runtime dependency is
introduced.

Bounds were measured over four seeds per problem and then loosened. DTLZ4
keeps a deliberately loose one, since its bias collapsing the population is
the problem behaving as designed rather than a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Different-seed behaviour was covered only by nsga3_instance_independence,
which asserts that two runs produce different objectives. That alone would
still pass for an algorithm that had stopped converging, since two broken
runs also differ.

Runs one DTLZ2 configuration at three seeds and holds each of them to the
same convergence, nondominance, coverage and IGD bounds independently,
then requires the populations to differ pairwise. Differing is necessary
but the bounds are what make it meaningful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 2, 2026 21:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces NSGA-III as a new many-objective evolutionary algorithm in pagmo2, including its reference-direction machinery, normalization pipeline, build integration, documentation, and a comprehensive test suite.

Changes:

  • Add the public pagmo::nsga3 algorithm implementation, including logging, serialization, optional memory mode, and optional batch fitness evaluation support.
  • Add NSGA-III internals under pagmo::detail (reference-direction generation, association/niching, normalization utilities).
  • Add extensive unit + quality regression tests, plus Sphinx/Doxygen documentation and CMake integration.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/nsga3.cpp Adds a broad unit test suite for NSGA-III internals and algorithm behavior (reproducibility, selection, normalization, serialization, BFE).
tests/nsga3_quality.cpp Adds deterministic quality/regression tests over DTLZ problems with bounds on convergence/spread metrics.
tests/CMakeLists.txt Registers the new nsga3 and nsga3_quality test targets.
src/detail/reference_point.cpp Implements reference-direction generation (Das–Dennis, two-layer), and reference-point association/niching utilities.
src/detail/nsga3_impl.cpp Implements Gaussian elimination + normalization pipeline + NSGA-III environmental selection.
src/algorithms/nsga3.cpp Implements the pagmo::nsga3 algorithm evolve loop (mating modes, BFE path, memory mode, logging, validation).
include/pagmo/pagmo.hpp Exposes NSGA-III through the umbrella header.
include/pagmo/detail/reference_point.hpp Declares reference-point data structure and reference-direction generation APIs (detail).
include/pagmo/detail/nsga3_impl.hpp Declares NSGA-III detail utilities (normalization, selection, numeric helpers).
include/pagmo/algorithms/nsga3.hpp Declares the public pagmo::nsga3 API and provides detailed Doxygen documentation.
doc/sphinx/docs/cpp/cpp_docs.rst Adds NSGA-III to the Sphinx C++ algorithm index.
doc/sphinx/docs/cpp/algorithms/nsga3.rst Adds a Sphinx page that hooks the Doxygen class docs for pagmo::nsga3.
CMakeLists.txt Adds new NSGA-III source files to the library build.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/detail/nsga3_impl.cpp Outdated
Comment thread src/detail/nsga3_impl.cpp
Comment thread src/algorithms/nsga3.cpp
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants