Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Added

- Optional **TypeSafe / Jev System One** pass (`tools/typesafe-pareto`) for Score + Choice judgments over a small Pareto candidate sample. Additive semantic layer — does not replace NSGA-III / U-NSGA-III objectives. Live calls only when `TYPESAFE_API_KEY` is set; CI uses mocks. Metrics append to `metrics/typesafe-runs.jsonl`.
- Opt-in `MatingPoolMode.TwoShuffledPasses`: two shuffled passes of consecutive tournaments (Seada & Deb Algorithm 1 and pymoo 0.6.2 `TournamentSelection`, pressure 2). When N parents are drawn from N members, each member is a contestant twice. The default pool stays N independent tournaments with replacement, which is the published ZDT1 Wilcoxon protocol.
- Opt-in `TournamentMode.Algorithm2`: a same-niche perpendicular-distance tie keeps the second parent. `PymooCompatible` still coin-flips. `RankNicheDistance` is unchanged.

### Fixed

- Odd population sizes no longer skip `parents[N-1]` in SBX. The leftover parent is paired with parent 0. Even-N pair order is unchanged (ZDT1 pop 52, DTLZ2 pop 92).

### Changed

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ var algo = new Unsga3Algorithm(dirs, populationSize: 92, seed: 1,
| `ReferenceDirections.DasDennis` | Structured reference points |
| `SimulatedBinaryCrossover` / `PolynomialMutation` | Variation operators |
| `PerformanceIndicators` | IGD, GD, 2-D hypervolume |
| `TournamentMode` | Default rank→niche vs `PymooCompatible` |
| `TournamentMode` | Default rank→niche, `PymooCompatible` (distance-tie coin flip), or opt-in `Algorithm2` (keep the second parent) |
| `MatingPoolMode` | Default independent tournaments; opt-in `TwoShuffledPasses` (paper / pymoo 0.6.2) |

Built-in problems: ZDT1–4/6, DTLZ1–4/7, Sphere, Ackley, Rosenbrock.

Expand Down
16 changes: 14 additions & 2 deletions docs/EQUIVALENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ ZDT2 **gens=100** is an early-stress snapshot (collapse on Bend, C#, and pymoo),
| Item | This library | pymoo |
|------|--------------|-------|
| Tournament (default `RankNicheDistance`) | rank → niche count → dist, including across niches | not Algorithm 2 |
| Tournament (`PymooCompatible`) | same niche → rank then dist; else random; distance tie is a coin flip | `comp_by_rank_and_ref_line_dist` (paper keeps the second parent on a distance tie) |
| Tournament (`PymooCompatible`) | same niche → rank then dist; else random; distance tie is a coin flip | `comp_by_rank_and_ref_line_dist` |
| Tournament (`Algorithm2`, opt-in) | same rules as `PymooCompatible`, except a same-niche distance tie keeps the second parent | paper Algorithm 2. Not pymoo's coin flip, and not the default |
| SBX p_c | **1.0** (pymoo `SBX(prob=1.0)`) | paper section 4 uses **0.9** |
| Mating pool | N independent tournaments with replacement | two shuffled consecutive-pair passes |
| Mating pool (default) | N independent tournaments with replacement. This is the published ZDT1 Wilcoxon protocol | not the pymoo pool |
| Mating pool (`TwoShuffledPasses`, opt-in) | two shuffled passes of consecutive pairs. When N parents are drawn from N members, each index is a contestant twice | pymoo 0.6.2 `TournamentSelection` (pressure 2) **is** this pool: `n_perms = ceil(2N / N)` permutations, then consecutive pairs |
| Niche ids at mating | `PrepareForSelection` re-normalizes survivors and re-associates | ids written during survival are kept |
| `WithDasDennis(1, 1)` | throws. One objective has a single direction, and N must be ≥ 2 | pass `populationSize` ≥ 2 for the single-objective degeneration |
| Reference layers | single-layer Das–Dennis only. Two-layer directions are absent | many-objective NSGA-III adds an inside layer for larger M |
Expand All @@ -78,6 +80,16 @@ ZDT2 **gens=100** is an early-stress snapshot (collapse on Bend, C#, and pymoo),
| Collapsed nadir | if the span is still ≤ 1e-6, nadir = ideal + 1 | stop at worst-of-population |
| Infeasible points in the hyperplane | ideal and worst from the whole pool, including infeasible points. ASF extremes use the ND index set when supplied | pymoo niching can restrict the normalized set to feasible members |

## Mating pool

The default pool is **N independent tournaments with replacement**, together with `RankNicheDistance`. That combination is the published ZDT1 Wilcoxon protocol. A two-pass run is not that table.

pymoo 0.6.2 `TournamentSelection` (`pressure=2`) **is** the two-shuffled-pass pool, not a third scheme. Selecting N parents from N members uses `n_perms = ceil(2N / N) = 2` permutations and consecutive pairs, so each index is a contestant twice. `MatingPoolMode.TwoShuffledPasses` is that pool, opt-in. It does not change the default comparator. An odd population still uses two passes here. pymoo's mating loop instead asks for `ceil(N/2)` SBX pairs, which can draw a third permutation; that extra pass is not copied.

`TournamentMode.Algorithm2` is the other opt-in: on a same-niche perpendicular-distance tie it keeps the second parent (Seada & Deb Algorithm 2) and does not draw. `PymooCompatible` still coin-flips that tie. Equal constraint violation stays a coin flip in both modes. Either comparator can be used with either pool.

Odd N (Das–Dennis M=2, p=12 → 13; M=3, p=12 → 91): `CreateOffspring` pairs the leftover parent with parent 0, so `parents[N-1]` enters SBX, then the cursor restarts at 0. Even N stays consecutive pairs `(0,1), (2,3), …` and wraps to `(0,1)` with no extra random draw. Published ZDT1 (52) and DTLZ2 (92) are even, so their pair sequence is unchanged.

## DTLZ2 gap history

| Stage | C# pymoo-mode IGD | vs pymoo 0.0035 |
Expand Down
6 changes: 4 additions & 2 deletions docs/RESEARCH-STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ Default dimensions (Deb / pymoo convention):

**pymoo** `comp_by_rank_and_ref_line_dist` follows that same-niche / different-niche split and coin-flips a distance tie. pymoo `NSGA3` builds `SBX(eta=30, prob=1.0)`.

**`TournamentMode.PymooCompatible`** matches that pymoo comparator, including the coin flip. It does not use the paper's second-parent tie break, p_c = 0.9, or the consecutive-pair mating pool.
**`TournamentMode.PymooCompatible`** matches that pymoo comparator, including the coin flip. It does not use the paper's second-parent tie break or p_c = 0.9. The consecutive-pair mating pool is a separate opt-in (`MatingPoolMode.TwoShuffledPasses`), not this mode.

**`TournamentMode.RankNicheDistance`** is the constructor default: rank, then niche count, then perpendicular distance, including when the niches differ. That is a local expansion, not Algorithm 2. The default stays `RankNicheDistance`. ZDT1's published Wilcoxon table uses it.
**`TournamentMode.RankNicheDistance`** is the constructor default: rank, then niche count, then perpendicular distance, including when the niches differ. That is a local expansion, not Algorithm 2. The default stays `RankNicheDistance`. ZDT1's published Wilcoxon table uses it, with the default independent-with-replacement pool.

**`TournamentMode.Algorithm2`** (opt-in) keeps the second parent on a same-niche distance tie. **`MatingPoolMode.TwoShuffledPasses`** (opt-in) is pymoo 0.6.2's permutation tournament: two shuffled passes, consecutive pairs, each member a contestant twice when selecting N parents. Neither replaces the published ZDT1 defaults.

## 3. Performance indicators (what to report)

Expand Down
60 changes: 50 additions & 10 deletions src/Unsga3/Algorithm/Unsga3Algorithm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
private readonly double? _mutationProbability;
private readonly int? _seed;
private readonly TournamentMode _tournamentMode;
private readonly MatingPoolMode _matingPool;
private readonly bool _eliminateDuplicates;

/// <param name="referenceDirections">Das–Dennis (or custom) directions; one weight vector per niche.</param>
Expand All @@ -33,14 +34,23 @@
/// </param>
/// <param name="mutationProbability">Per-variable mutation probability; default 1/nVars at run time.</param>
/// <param name="seed">Optional RNG seed for reproducibility.</param>
/// <param name="tournamentMode">Mating tournament policy; use <see cref="TournamentMode.PymooCompatible"/> for oracle runs.</param>
/// <param name="tournamentMode">
/// Mating tournament policy. Default <see cref="TournamentMode.RankNicheDistance"/>.
/// Use <see cref="TournamentMode.PymooCompatible"/> for oracle runs.
/// <see cref="TournamentMode.Algorithm2"/> keeps the second parent on a same-niche distance tie.
/// </param>
/// <param name="eliminateDuplicates">
/// Drop offspring whose decision vector matches an existing parent or earlier offspring
/// (pymoo <c>eliminate_duplicates=True</c>). Default true. The key is <c>G12</c>
/// (12 significant digits), not 12 digits after the decimal. If mutation cannot
/// produce a new key, attempts are capped and the remaining slots may be duplicates
/// so the loop cannot hang.
/// </param>
/// <param name="matingPool">
/// How parents are drawn. Default <see cref="MatingPoolMode.IndependentWithReplacement"/>
/// (published ZDT1 Wilcoxon). <see cref="MatingPoolMode.TwoShuffledPasses"/> is the
/// Seada &amp; Deb Algorithm 1 / pymoo 0.6.2 permutation tournament and is opt-in.
/// </param>
public Unsga3Algorithm(
double[][] referenceDirections,
int? populationSize = null,
Expand All @@ -50,7 +60,8 @@
double? mutationProbability = null,
int? seed = null,
TournamentMode tournamentMode = TournamentMode.RankNicheDistance,
bool eliminateDuplicates = true)
bool eliminateDuplicates = true,
MatingPoolMode matingPool = MatingPoolMode.IndependentWithReplacement)
{
ArgumentNullException.ThrowIfNull(referenceDirections);
if (referenceDirections.Length < 1)
Expand All @@ -69,6 +80,7 @@
_mutationProbability = mutationProbability;
_seed = seed;
_tournamentMode = tournamentMode;
_matingPool = matingPool;
_eliminateDuplicates = eliminateDuplicates;
}

Expand All @@ -84,10 +96,16 @@
int partitions,
int? populationSize = null,
int? seed = null,
TournamentMode tournamentMode = TournamentMode.RankNicheDistance)
TournamentMode tournamentMode = TournamentMode.RankNicheDistance,
MatingPoolMode matingPool = MatingPoolMode.IndependentWithReplacement)
{
var dirs = ReferenceDirections.DasDennis(numberOfObjectives, partitions);
return new Unsga3Algorithm(dirs, populationSize, seed: seed, tournamentMode: tournamentMode);
return new Unsga3Algorithm(
dirs,
populationSize,
seed: seed,
tournamentMode: tournamentMode,
matingPool: matingPool);
}

public OptimizationResult Run(IProblem problem, int maxGenerations) =>
Expand All @@ -100,8 +118,8 @@
/// are pad-filled randomly.
/// </param>
public OptimizationResult Run(
IProblem problem,

Check warning on line 121 in src/Unsga3/Algorithm/Unsga3Algorithm.cs

View workflow job for this annotation

GitHub Actions / build-test

Parameter 'problem' has no matching param tag in the XML comment for 'Unsga3Algorithm.Run(IProblem, int, IReadOnlyList<Individual>?)' (but other parameters do)

Check warning on line 121 in src/Unsga3/Algorithm/Unsga3Algorithm.cs

View workflow job for this annotation

GitHub Actions / build-test

Parameter 'problem' has no matching param tag in the XML comment for 'Unsga3Algorithm.Run(IProblem, int, IReadOnlyList<Individual>?)' (but other parameters do)
int maxGenerations,

Check warning on line 122 in src/Unsga3/Algorithm/Unsga3Algorithm.cs

View workflow job for this annotation

GitHub Actions / build-test

Parameter 'maxGenerations' has no matching param tag in the XML comment for 'Unsga3Algorithm.Run(IProblem, int, IReadOnlyList<Individual>?)' (but other parameters do)

Check warning on line 122 in src/Unsga3/Algorithm/Unsga3Algorithm.cs

View workflow job for this annotation

GitHub Actions / build-test

Parameter 'maxGenerations' has no matching param tag in the XML comment for 'Unsga3Algorithm.Run(IProblem, int, IReadOnlyList<Individual>?)' (but other parameters do)
IReadOnlyList<Individual>? initialPopulation) =>
Run(problem, TerminationCriterion.MaxGenerations(maxGenerations), initialPopulation);

Expand All @@ -126,7 +144,7 @@
// across generations (pymoo HyperplaneNormalization). Survival reuses the same
// instance so tournament prep and environmental selection stay consistent.
var normalization = new Normalization(problem.NumberOfObjectives);
var tournament = new TournamentSelection(_tournamentMode);
var tournament = new TournamentSelection(_tournamentMode, _matingPool);
var survival = new NondominatedSortingSurvival(refs, normalization);
double mutProb = _mutationProbability ?? (1.0 / problem.NumberOfVariables);

Expand Down Expand Up @@ -164,7 +182,7 @@
return new OptimizationResult(population.Members.ToList(), generation, evaluations);
}

private List<Individual> CreateOffspring(
internal List<Individual> CreateOffspring(
IProblem problem,
IReadOnlyList<Individual> currentPop,
IReadOnlyList<Individual> parents,
Expand All @@ -187,10 +205,12 @@
while (offspring.Count < _populationSize && safety < maxAttempts)
{
safety++;
if (pair + 1 >= parents.Count)
pair = 0;
var (c1, c2) = _crossover.Crossover(parents[pair], parents[pair + 1], problem, rng);
pair += 2;
// Even N: (0,1), (2,3), … then wrap to (0,1). That sequence is what the
// published even populations (ZDT1 52, DTLZ2 92) already used. Odd N used
// to wrap before reading the last index, so parents[N-1] never entered SBX.
(int left, int right, int next) = AdvanceMatingPair(pair, parents.Count);
pair = next;
var (c1, c2) = _crossover.Crossover(parents[left], parents[right], problem, rng);
_mutation.Mutate(c1, problem, rng, mutProb);
_mutation.Mutate(c2, problem, rng, mutProb);

Expand Down Expand Up @@ -223,6 +243,26 @@
return offspring;
}

/// <summary>
/// Next SBX pair and the cursor after it.
/// Even <paramref name="parentCount"/> yields consecutive pairs and wraps from
/// <c>parentCount</c> back to <c>(0, 1)</c> with no extra draw.
/// Odd counts pair the leftover last parent with parent 0, then restart at 0.
/// </summary>
internal static (int Left, int Right, int Next) AdvanceMatingPair(int pair, int parentCount)
{
if (parentCount < 2)
throw new ArgumentOutOfRangeException(nameof(parentCount), "Need at least two parents to cross.");

if (pair >= parentCount)
pair = 0;

if (pair + 1 < parentCount)
return (pair, pair + 1, pair + 2);

return (pair, 0, 0);
}

private static void TryAddOffspring(List<Individual> offspring, Individual child, HashSet<string>? seen)
{
if (seen is null)
Expand Down
24 changes: 24 additions & 0 deletions src/Unsga3/Operators/Selection/MatingPoolMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Unsga3.Operators.Selection;

/// <summary>
/// How the mating pool is built before SBX. The comparator is
/// <see cref="TournamentMode"/> and is chosen separately.
/// </summary>
public enum MatingPoolMode
{
/// <summary>
/// N independent binary tournaments with replacement (constructor default).
/// Contestants inside one tournament are distinct. Across tournaments the
/// same member can win many times and others can miss the generation.
/// This is the pool behind the published ZDT1 Wilcoxon table.
/// </summary>
IndependentWithReplacement = 0,

/// <summary>
/// Two shuffled passes of consecutive pairs (Seada &amp; Deb Algorithm 1 and
/// pymoo 0.6.2 <c>TournamentSelection</c> with pressure 2). Opt-in.
/// When the number of parents equals the population size, each member is a
/// contestant twice. This is not the published ZDT1 protocol.
/// </summary>
TwoShuffledPasses = 1,
}
13 changes: 12 additions & 1 deletion src/Unsga3/Operators/Selection/TournamentMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ public enum TournamentMode
/// Same-niche / different-niche split from Seada &amp; Deb Algorithm 2 and from
/// pymoo <c>comp_by_rank_and_ref_line_dist</c>: constraint violation first; if the
/// parents share a niche then rank, then distance-to-niche; otherwise random.
/// A distance tie is a coin flip (pymoo). Algorithm 2 keeps the second parent on that tie.
/// A distance tie is a coin flip (pymoo). Algorithm 2 keeps the second parent on
/// that tie; use <see cref="Algorithm2"/> for the paper rule. This mode's coin flip
/// is unchanged.
/// </summary>
PymooCompatible = 1,

/// <summary>
/// Seada &amp; Deb Algorithm 2 comparator. Same rules as
/// <see cref="PymooCompatible"/>, except a same-niche perpendicular-distance tie
/// keeps the second parent instead of a coin flip. Opt-in. It does not change
/// <see cref="PymooCompatible"/> or <see cref="RankNicheDistance"/>. Usable with
/// either <see cref="MatingPoolMode"/>.
/// </summary>
Algorithm2 = 2,
}
Loading
Loading