Implement NSGA-III for many-objective optimization - #633
Open
luishpmendes wants to merge 42 commits into
Open
Conversation
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>
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>
There was a problem hiding this comment.
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::nsga3algorithm 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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
masterbranch, 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
pagmo::nsga3algorithm.set_bfe().Improvements over #569
This version addresses several correctness, robustness, and reproducibility issues present in the original draft:
master.pagmo::detail.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:
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_matingconstructor argument. This alternative is documented as a deliberate deviation from the original algorithm.Tests
Two test executables are added:
tests/nsga3.cpptests/nsga3_quality.cppThe 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:
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