From c95e31479bd6a031fae65ad8639b22698cf88789 Mon Sep 17 00:00:00 2001 From: Rutherther Date: Sun, 16 Nov 2025 19:56:00 +0100 Subject: [PATCH] feat: add multi objective; NSGA --- codes/Cargo.lock | 1 + codes/eoa_lib/Cargo.toml | 3 + codes/eoa_lib/src/lib.rs | 1 + .../eoa_lib/src/multi_objective_evolution.rs | 465 ++++++++++++++++++ codes/eoa_lib/tests/multi_objective_1.txt | 4 + codes/eoa_lib/tests/multi_objective_2.txt | 5 + codes/eoa_lib/tests/multi_objective_3.txt | 5 + codes/eoa_lib/tests/multi_objective_4.txt | 8 + codes/eoa_lib/tests/multi_objective_5.txt | 8 + codes/eoa_lib/tests/multi_objective_6.txt | 11 + codes/eoa_lib/tests/multi_objective_7.txt | 8 + codes/eoa_lib/tests/multi_objective_8.txt | 22 + codes/eoa_lib/tests/multi_objective_9.txt | 14 + 13 files changed, 555 insertions(+) create mode 100644 codes/eoa_lib/src/multi_objective_evolution.rs create mode 100644 codes/eoa_lib/tests/multi_objective_1.txt create mode 100644 codes/eoa_lib/tests/multi_objective_2.txt create mode 100644 codes/eoa_lib/tests/multi_objective_3.txt create mode 100644 codes/eoa_lib/tests/multi_objective_4.txt create mode 100644 codes/eoa_lib/tests/multi_objective_5.txt create mode 100644 codes/eoa_lib/tests/multi_objective_6.txt create mode 100644 codes/eoa_lib/tests/multi_objective_7.txt create mode 100644 codes/eoa_lib/tests/multi_objective_8.txt create mode 100644 codes/eoa_lib/tests/multi_objective_9.txt diff --git a/codes/Cargo.lock b/codes/Cargo.lock index d988f072cbc29e9cc404080859f0a36e4845dd40..241364418c533815d06b7342428ebdadf9c75a26 100644 --- a/codes/Cargo.lock +++ b/codes/Cargo.lock @@ -231,6 +231,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" name = "eoa_lib" version = "0.1.0" dependencies = [ + "approx", "nalgebra", "plotters", "rand", diff --git a/codes/eoa_lib/Cargo.toml b/codes/eoa_lib/Cargo.toml index d73d7b0a5e75ea65dfe05a608fd202d48133a1f5..af5e455913d1bcf906d5bf81e29091016e70a823 100644 --- a/codes/eoa_lib/Cargo.toml +++ b/codes/eoa_lib/Cargo.toml @@ -9,3 +9,6 @@ plotters = "0.3.7" rand = "0.9.2" rand_distr = "0.5.1" thiserror = "2.0.17" + +[dev-dependencies] +approx = "0.5.1" diff --git a/codes/eoa_lib/src/lib.rs b/codes/eoa_lib/src/lib.rs index c02b947853f1192047a439b0236a1677a17d6ea3..b23761ce3d539371233105bfbb7d097bd5ce299b 100644 --- a/codes/eoa_lib/src/lib.rs +++ b/codes/eoa_lib/src/lib.rs @@ -1,4 +1,5 @@ pub mod fitness; +pub mod multi_objective_evolution; pub mod pairing; pub mod population; pub mod evolution; diff --git a/codes/eoa_lib/src/multi_objective_evolution.rs b/codes/eoa_lib/src/multi_objective_evolution.rs new file mode 100644 index 0000000000000000000000000000000000000000..218591089055247fa8dfc3b98ef0dc7994e247b6 --- /dev/null +++ b/codes/eoa_lib/src/multi_objective_evolution.rs @@ -0,0 +1,465 @@ +use std::ops::{AddAssign, Sub}; +use std::{cmp::Ordering, error::Error}; +use std::fmt::Debug; + +use rand::RngCore; + +use crate::population::EvaluatedChromosome; +use crate::{comparison::{BetterThanOperator, MinimizingOperator}, crossover::Crossover, evolution::{evolution_algorithm, EvolutionResult}, fitness::FitnessFunction, pairing::Pairing, perturbation::PerturbationOperator, population::Population, replacement::BestReplacement, selection::TournamentSelection}; + +// Multi objective fitness function, returning a list of evaluations +pub struct MOFitness<'a, + const OBJECTIVES: usize, + TChromosome, + TOut, + TErr: Error> { + objectives: [Box + 'a>; OBJECTIVES] +} + +pub fn a_dominates_b( + a: &[TOut; OBJECTIVES], b: &[TOut; OBJECTIVES], + better_than: &(impl BetterThanOperator + ?Sized) +) -> bool { + a.iter().zip(b.iter()).all(|(a, b)| better_than.better_than(a, b) || better_than.equal(a, b)) +} + +pub fn a_dominated_by_b( + a: &[TOut; OBJECTIVES], + b: &[TOut; OBJECTIVES], + better_than: &(impl BetterThanOperator + ?Sized) +) -> bool { + a_dominates_b(b, a, better_than) +} + +impl<'a, + const OBJECTIVES: usize, + TChromosome, + TOut, + TErr: Error> + MOFitness<'a, OBJECTIVES, TChromosome, TOut, TErr> +{ + pub fn new(objectives: [Box + 'a>; OBJECTIVES]) -> Self { + Self { objectives } + } +} + +impl<'a, + const OBJECTIVES: usize, + TChromosome, + TOut: Default + Copy, + TErr: Error + 'static> + FitnessFunction for MOFitness<'a, OBJECTIVES, TChromosome, TOut, TErr> { + type In = TChromosome; + type Out = [TOut; OBJECTIVES]; + type Err = TErr; + + fn fit(&self, inp: &Self::In) -> Result { + let mut evaluations = [Default::default(); OBJECTIVES]; + + for (i, objective) in self.objectives.iter().enumerate() { + evaluations[i] = objective.fit(inp)?; + } + + Ok(evaluations) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NSGAEvaluation { + evaluations: [TOut; OBJECTIVES], + nondominated_front: usize, + crowding_distance: f64 +} + +impl + PartialOrd for NSGAEvaluation { + fn partial_cmp(&self, other: &Self) -> Option { + match self.nondominated_front.partial_cmp(&other.nondominated_front) { + Some(core::cmp::Ordering::Equal) => {} + ord => return ord, + } + + other.crowding_distance.partial_cmp(&self.crowding_distance) + } +} + +pub struct NSGAFitness<'a, const OBJECTIVES: usize, TChromosome, TOut, TErr: Error> { + mo_fitness: MOFitness<'a, OBJECTIVES, TChromosome, TOut, TErr>, + better_than: &'a dyn BetterThanOperator +} + +pub fn get_nondominated_fronts( + fitted: &[EvaluatedChromosome], + better_than: &(impl BetterThanOperator + ?Sized) +) -> (Vec, Vec>) { + let mut remaining_indices = (0..fitted.len()).collect::>(); + let mut current_front = 0; + let mut fronts = vec![0; fitted.len()]; + let mut front_indices = vec![]; + let mut current_for_removal = vec![]; + + while !remaining_indices.is_empty() { + let mut current_front_indices = vec![]; + 'outer: for (i, ¤t) in remaining_indices.iter().enumerate() { + let current_eval = &fitted[current].evaluation; + for &i in remaining_indices.iter() { + let i_eval = &fitted[i].evaluation; + + if i == current { + continue; + } + + if current_eval + .iter() + .zip(i_eval.iter()) + .all(|(a, b)| better_than.equal(a, b)) { + continue; + } + + if a_dominated_by_b(current_eval, i_eval, better_than) { + // At least one dominates current... + continue 'outer; + } + } + + // None dominates current... + fronts[current] = current_front; + current_front_indices.push(current); + current_for_removal.push(i); + } + + front_indices.push(current_front_indices); + + for &for_removal in current_for_removal.iter().rev() { + remaining_indices.swap_remove(for_removal); + } + current_for_removal.clear(); + + current_front += 1; + } + + // current_front is counting from backwards, so reverse it + (fronts, front_indices) +} + +pub fn get_front_crowding_distances>( + fitted: &[EvaluatedChromosome], + indices: &[usize], + better_than: &(impl BetterThanOperator + ?Sized), + crowding_distances: &mut [f64] +) { + let mut min_maxs_init = [(Default::default(), Default::default()); OBJECTIVES]; + for objective in 0..OBJECTIVES { + min_maxs_init[objective].0 = fitted[indices[0]].evaluation[objective].into(); + min_maxs_init[objective].1 = fitted[indices[0]].evaluation[objective].into(); + } + + // For each objective, find minimum and maximum + let min_maxs = indices + .iter() + .map(|&i| &fitted[i]) + .fold( + min_maxs_init, + |mut min_maxs, current| { + for objective in 0..OBJECTIVES { + let mut min_max = min_maxs[objective]; + let evaluation = current.evaluation[objective].into(); + + if evaluation > min_max.1 { + min_max.1 = evaluation; + } + if evaluation < min_max.0 { + min_max.0 = evaluation; + } + + min_maxs[objective] = min_max; + } + + min_maxs + }); + + let crowding_orderings = (0..OBJECTIVES) + .map(|objective| { + let mut indices = indices.iter().copied().collect::>(); + indices.sort_unstable_by(|&a, &b| better_than.ordering( + &fitted[a].evaluation[objective], + &fitted[b].evaluation[objective] + )); + indices + }) + .collect::>>(); + + for objective in 0..OBJECTIVES { + let crowding_orderings = &crowding_orderings[objective]; + let mut greaters = vec![f64::INFINITY; fitted.len()]; + let mut lessers = vec![-f64::INFINITY; fitted.len()]; + + let mut greaters_index = 0; + for i in 1..indices.len() { + let current = fitted[crowding_orderings[i]].evaluation[objective].into(); + let previous = fitted[crowding_orderings[i - 1]].evaluation[objective].into(); + + if current > previous { + lessers[i] = previous; + + while greaters_index < i { + greaters[greaters_index] = current; + greaters_index += 1; + } + } else { + lessers[i] = lessers[i - 1]; + } + } + + for (i, ¤t) in crowding_orderings.iter().enumerate() { + crowding_distances[current] += + (greaters[i] - lessers[i]).abs() / + if NORMALIZE { (min_maxs[objective].1 - min_maxs[objective].0).abs() } else { 1.0 }; + } + } +} + +pub fn get_crowding_distances>( + fitted: &[EvaluatedChromosome], + front_indices: &[Vec], + better_than: &(impl BetterThanOperator + ?Sized) +) -> Vec { + let mut crowding_distances = vec![0.0; fitted.len()]; + + for indices in front_indices { + get_front_crowding_distances::( + fitted, + &indices, + better_than, + &mut crowding_distances + ); + } + + crowding_distances +} + +impl<'a, + const OBJECTIVES: usize, + TChromosome, + TOut: Copy + Into, + TErr: Error> + NSGAFitness<'a, OBJECTIVES, TChromosome, TOut, TErr> { + pub fn new( + objectives: [Box + 'a>; OBJECTIVES], + better_than: &'a impl BetterThanOperator + ) -> Self { + Self { + mo_fitness: MOFitness::<'a, OBJECTIVES, _, _, _>::new(objectives), + better_than + } + } +} + +impl<'a, + const OBJECTIVES: usize, + TChromosome: Clone, + TOut: PartialEq + Default + Copy + PartialOrd + Into, + TErr: Error + 'static> + FitnessFunction for NSGAFitness<'a, OBJECTIVES, TChromosome, TOut, TErr> { + type In = TChromosome; + type Out = NSGAEvaluation; + type Err = TErr; + + fn fit(&self, _: &Self::In) -> Result { + panic!("NSGA fitness does not implement single fit. To get all the non dominated fronts informations, fit_population needs to be called."); + } + + fn fit_population(&self, inp: Vec) -> Result>, Self::Err> { + let fitted = self.mo_fitness.fit_population(inp)?; + + let (fronts, front_indices) = get_nondominated_fronts(&fitted, self.better_than); + let cd = get_crowding_distances + ::(&fitted, &front_indices, self.better_than); + + Ok(fitted + .into_iter() + .zip(fronts.into_iter()) + .zip(cd.into_iter()) + .map(|((individual, nondominated_front), crowding_distance)| { + EvaluatedChromosome { + chromosome: individual.chromosome, + evaluation: NSGAEvaluation { + evaluations: individual.evaluation, + nondominated_front, + crowding_distance + } + } + }) + .collect()) + } +} + +pub fn nsga_2, + TErr: Error + 'static, + const DParents: usize, + TFitness: FitnessFunction, + TPairing: Pairing>, + TCrossover: Crossover>, + TPerturbation: PerturbationOperator> ( + initial_population: Population, + parents_count: usize, + // TODO: possibility for objectives with different outputs? + objectives: [Box + '_>; OBJECTIVES], + pairing: &mut TPairing, + crossover: &mut TCrossover, + perturbation: &mut TPerturbation, + // TODO: possibility for better_than different for different fitness functions + better_than: &impl BetterThanOperator, + // TODO: termination condition + iterations: usize, + rng: &mut dyn RngCore +) -> Result, Box> { + let result = evolution_algorithm( + initial_population, + parents_count, + &mut NSGAFitness::new(objectives, better_than), + &mut TournamentSelection::new(5, 1.0), + pairing, + crossover, + perturbation, + &mut BestReplacement::new(), + &MinimizingOperator::new(), + iterations, + rng, + |_, _, _, _, _, _, _, _, _| { + } + )?; + + Ok(result.map(|res| { + res.evaluations + })) +} + +#[cfg(test)] +pub mod test { + use std::str::FromStr; + + use approx::assert_abs_diff_eq; + + use crate::{comparison::MinimizingOperator, multi_objective_evolution::{get_crowding_distances, get_nondominated_fronts}, population::EvaluatedChromosome, test_infra::load_test_file}; + + #[derive(Debug)] + pub struct EvaluationResult { + front: usize, + crowding_distance: f64 + } + + impl FromStr for EvaluationResult { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + let mut splitted = s.split(' '); + + let front: f64 = splitted.next().unwrap().parse()?; + let crowding_distance: f64 = splitted.next().unwrap().parse()?; + + Ok(EvaluationResult { + front: front as usize, + crowding_distance + }) + } + } + + pub fn perform_test(file: &str) { + let data = load_test_file::(file); + + let evaluations = data + .iter() + .map(|vec| [vec.inp[0], vec.inp[1]]) + .collect::>(); + + println!("{:?}", evaluations); + + let chromosomes = evaluations + .into_iter() + .enumerate() + .map(|(i, evaluation)| EvaluatedChromosome { + chromosome: i, + evaluation + }) + .collect::>(); + + let (fronts, front_indices) = get_nondominated_fronts(&chromosomes, &MinimizingOperator::new()); + let crowding_distances = get_crowding_distances + ::(&chromosomes, &front_indices, &MinimizingOperator::new()); + + assert_eq!(fronts.len(), chromosomes.len()); + assert_eq!(crowding_distances.len(), chromosomes.len()); + + println!("{:?}", fronts); + println!("{:?}", crowding_distances); + + for (i, test) in data.iter().enumerate() { + println!("Checking index {}", i); + let actual_front = fronts[i]; + let actual_crowding_distance = crowding_distances[i]; + + assert_eq!(actual_front + 1, test.out.front); + if actual_crowding_distance.is_finite() { + assert_abs_diff_eq!(actual_crowding_distance, test.out.crowding_distance, epsilon = 0.0001); + } else { + assert_eq!(actual_crowding_distance, test.out.crowding_distance); + } + } + + } + + #[test] + pub fn test_01() { + perform_test("tests/multi_objective_1.txt"); + } + + #[test] + pub fn test_02() { + perform_test("tests/multi_objective_2.txt"); + } + + #[test] + pub fn test_03() { + perform_test("tests/multi_objective_3.txt"); + } + + #[test] + pub fn test_04() { + perform_test("tests/multi_objective_4.txt"); + } + + #[test] + pub fn test_5() { + perform_test("tests/multi_objective_5.txt"); + } + + #[test] + pub fn test_6() { + perform_test("tests/multi_objective_6.txt"); + } + + #[test] + pub fn test_7() { + perform_test("tests/multi_objective_7.txt"); + } + + #[test] + pub fn test_8() { + perform_test("tests/multi_objective_8.txt"); + } + + #[test] + pub fn test_9() { + perform_test("tests/multi_objective_9.txt"); + } +} diff --git a/codes/eoa_lib/tests/multi_objective_1.txt b/codes/eoa_lib/tests/multi_objective_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..f4ce7d4b92e15fec2a293a38a4b9b3dfb5d10a3e --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_1.txt @@ -0,0 +1,4 @@ +# Optimum is in minimum. +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.0 0.0:1 inf diff --git a/codes/eoa_lib/tests/multi_objective_2.txt b/codes/eoa_lib/tests/multi_objective_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..edb5232e24a64736def1da8deb3e3b488b54f9ee --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_2.txt @@ -0,0 +1,5 @@ +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.0 0.0:1 inf +1.0 0.0:2 inf +0.0 1.0:2 inf diff --git a/codes/eoa_lib/tests/multi_objective_3.txt b/codes/eoa_lib/tests/multi_objective_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..6d24d2c630cbe0181295537e26036c2071ddc8b7 --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_3.txt @@ -0,0 +1,5 @@ +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.5 0.5:1 2.0 +1.0 0.0:1 inf +0.0 1.0:1 inf diff --git a/codes/eoa_lib/tests/multi_objective_4.txt b/codes/eoa_lib/tests/multi_objective_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..7781992e097dbb968b41de57455f2ed3bfebcc53 --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_4.txt @@ -0,0 +1,8 @@ +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.0 0.0:1 inf +1.0 0.0:2 inf +0.0 1.0:2 inf +1.0 1.0:3 4.0 +2.0 0.0:3 inf +0.0 2.0:3 inf diff --git a/codes/eoa_lib/tests/multi_objective_5.txt b/codes/eoa_lib/tests/multi_objective_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..a15d85c92e7a068d9157f15031bb310b4d17899d --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_5.txt @@ -0,0 +1,8 @@ +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.0 2.0:3 inf +0.0 0.0:1 inf +1.0 1.0:3 4.0 +1.0 0.0:2 inf +0.0 1.0:2 inf +2.0 0.0:3 inf diff --git a/codes/eoa_lib/tests/multi_objective_6.txt b/codes/eoa_lib/tests/multi_objective_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..8ddae7e14c034bc88cd1e9fb6f88be5597efe866 --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_6.txt @@ -0,0 +1,11 @@ +# ---------------2D--------------------- +# f1,f2,front,c_dist +0.0 0.0:1 inf +0.5 0.5:2 2.0 +1.0 0.0:2 inf +0.0 1.0:2 inf +1.0 1.0:3 2.0 +0.5 1.5:3 2.0 +1.5 0.5:3 2.0 +2.0 0.0:3 inf +0.0 2.0:3 inf diff --git a/codes/eoa_lib/tests/multi_objective_7.txt b/codes/eoa_lib/tests/multi_objective_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..502a0a4af1fe63c484847587737dfde0e5de4e96 --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_7.txt @@ -0,0 +1,8 @@ +# ---------------2D--------------------- #parabola +# f1,f2,front,c_dist +0.50 2.00:1 inf +0.75 1.33:1 1.50 +1.00 1.00:1 1.03 +1.25 0.80:1 0.83 +1.50 0.67:1 0.73 +1.75 0.57:1 inf diff --git a/codes/eoa_lib/tests/multi_objective_8.txt b/codes/eoa_lib/tests/multi_objective_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..8dd4401a48d830c0a92926bdef52d6d99b399dfa --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_8.txt @@ -0,0 +1,22 @@ +# ---------------2D--------------------- #parabola +# f1,f2,front,c_dist +0.100 10.000:1 inf +0.600 1.670:1 10.090 +1.100 0.910:1 2.050 +1.600 0.620:1 1.430 +2.100 0.480:1 1.240 +2.600 0.380:1 1.160 +3.100 0.320:1 1.100 +3.600 0.280:1 1.080 +4.100 0.240:1 1.060 +4.600 0.220:1 1.040 +5.100 0.200:1 1.040 +5.600 0.180:1 1.040 +6.100 0.160:1 1.030 +6.600 0.150:1 1.020 +7.100 0.140:1 1.020 +7.600 0.130:1 1.020 +8.100 0.120:1 1.520 +8.600 0.120:2 inf +9.100 0.110:1 1.520 +9.600 0.100:1 inf diff --git a/codes/eoa_lib/tests/multi_objective_9.txt b/codes/eoa_lib/tests/multi_objective_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..3456c1b0528876fccae6ce5a5e20866f4557d0ea --- /dev/null +++ b/codes/eoa_lib/tests/multi_objective_9.txt @@ -0,0 +1,14 @@ +# ---------------2D--------------------- #2 parabolas (shifted) +# f1,f2,front,c_dist +0.500 2.000:1 inf +0.750 1.330:1 1.500 +1.000 1.000:1 1.030 +1.250 0.800:1 0.830 +1.500 0.670:1 0.730 +1.750 0.570:1 inf +0.500 2.670:2 inf +0.750 2.570:2 0.670 +1.000 2.500:2 0.630 +1.250 2.440:2 0.600 +1.500 2.400:2 0.580 +1.750 2.360:2 inf