Home

If you find any mistakes here or anywhere else on my website, email website@CoryHufford.com.


A Crowding Distance Experiment, Part II

October 31, 2026

Part I

This is the same experiment as in Part I, but in three dimensions. Here's a uniformly distributed example Pareto front of 10,000 points that lies on the surface of an octant of the unit sphere:

Uniformly distributed example Pareto front

This is a histogram of the crowding distances of the Pareto front:

Crowding distance histogram of uniformly distributed example Pareto front

The following three plots are this Pareto front downselected from 10,000 points to 2,500 points using the same three methods used in Part I:

  1. Random downselection
  2. One-shot crowding distance downselection
  3. Iterative crowding distance downselection

Uniformly distributed example Pareto front quartered randomly

Uniformly distributed example Pareto front quartered with one-shot crowding distance

Uniformly distributed example Pareto front quartered with iterative crowding distance

The following three plots are histograms of the crowding distances for each of the three downselected Pareto fronts:

Crowding distance histogram of uniformly distributed example Pareto front quartered randomly

Crowding distance histogram of uniformly distributed example Pareto front quartered with one-shot crowding distance

Crowding distance histogram of uniformly distributed example Pareto front quartered with iterative crowding distance

Here's another example Pareto front of 10,000 points on the same octant of the unit sphere, but normally distributed:

Normally distributed example Pareto front

Here's the histogram of its crowding distances:

Crowding distance histogram of normally distributed example Pareto front

Here's the normally distributed front downselected from 10,000 to 2,500 points using the same three methods:

normally distributed example Pareto front quartered randomly

normally distributed example Pareto front quartered with one-shot crowding distance

normally distributed example Pareto front quartered with iterative crowding distance

Here are the crowding distance histogram for the downselected normal Pareto fronts:

Crowding distance histogram of normally distributed example Pareto front quartered randomly

Crowding distance histogram of normally distributed example Pareto front quartered with one-shot crowding distance

Crowding distance histogram of normally distributed example Pareto front quartered with iterative crowding distance

Here are the four crowding distance histograms (original and three downselections) for the normally distributed Pareto front on a logarithmic scale:

Crowding distance histogram of normally distributed example Pareto front with logarithmic y-axis

Crowding distance histogram of normally distributed example Pareto front quartered randomly with logarithmic y-axis

Crowding distance histogram of normally distributed example Pareto front quartered with one-shot crowding distance with logarithmic y-axis

Crowding distance histogram of normally distributed example Pareto front quartered with iterative crowding distance with logarithmic y-axis


All results cam from this Rust and Python code using random number generator seed 3588435413156785030. Use this code at your own risk.

All results came from this Rust and Python code using Rust edition 2024, Python 3.13.5, and random number generator seed 3588435413156785030. Use this code at your own risk.

Rust Generation Code

Ran with Rust 1.99.1, edition 2024, and dependencies:

[dependencies]
chrono     = { version = "0.4.45", default-features = false, features = ["clock"] }
rand       = { version = "0.10.3", default-features = false, features = ["thread_rng"] }
rand_distr = { version = "0.6.0",  default-features = false }

Lock File

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
 "libc",
]

[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"

[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"

[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
 "find-msvc-tools",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"

[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "rand_core",
]

[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
 "iana-time-zone",
 "num-traits",
 "windows-link",
]

[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"

[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
 "libc",
]

[[package]]
name = "crowd"
version = "0.1.0"
dependencies = [
 "chrono",
 "rand",
 "rand_distr",
]

[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"

[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"

[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"

[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
 "futures-core",
 "futures-task",
 "pin-project-lite",
 "slab",
]

[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
 "cfg-if",
 "libc",
 "r-efi",
 "rand_core",
]

[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
 "android_system_properties",
 "core-foundation-sys",
 "iana-time-zone-haiku",
 "js-sys",
 "log",
 "wasm-bindgen",
 "windows-core",
]

[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
 "cc",
]

[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
 "cfg-if",
 "futures-util",
 "wasm-bindgen",
]

[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"

[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"

[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"

[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
 "autocfg",
 "libm",
]

[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"

[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"

[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
 "proc-macro2",
]

[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"

[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
 "chacha20",
 "getrandom",
 "rand_core",
]

[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"

[[package]]
name = "rand_distr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
 "num-traits",
 "rand",
]

[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"

[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"

[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"

[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"

[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
 "bumpalo",
 "proc-macro2",
 "quote",
 "syn 3.0.9",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
 "windows-implement",
 "windows-interface",
 "windows-link",
 "windows-result",
 "windows-strings",
]

[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.119",
]

[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.119",
]

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
 "windows-link",
]

Code

#[must_use]
fn format_duration(duration: std::time::Duration) -> String {
	let seconds = duration.as_secs_f64();
	let (value, unit) = if seconds >= 3600.0 {
		(seconds / 3600.0, "hr")
	} else if seconds >= 60.0 {
		(seconds / 60.0, "min")
	} else if seconds < 1.0e-3 {
		(seconds * 1.0e6, "us")
	} else if seconds < 1.0 {
		(seconds * 1.0e3, "ms")
	} else {
		(seconds, "s")
	};
	let precision = if value >= 100.0 {0} else if value >= 10.0 {1} else {2};
	format!("{value:.precision$} {unit}")
}


mod euclidean {

	#[must_use]
	pub fn norm<const N: usize>(a: &[f64; N]) -> f64 {
		debug_assert!(a.iter().all(|x| x.is_finite()));
		a.iter().map(|x| x.powi(2)).sum::<f64>().sqrt()
	}

}


struct TimedStatus {
	start_time: std::time::Instant,
}

impl TimedStatus {
	pub fn new(message: &str) -> Self {
		use std::io::Write;
		print!("{message}");
		std::io::stdout().flush().unwrap();
		Self{start_time: std::time::Instant::now()}
	}
}

impl Drop for TimedStatus {
	fn drop(&mut self) {
		use std::io::Write;
		// Shouldn't panic in drop, but it'll be fine
		println!(" ({})", format_duration(self.start_time.elapsed()));
		std::io::stdout().flush().unwrap();
	}
}


struct Dtlz2<const NUM_PARAMETERS: usize, const NUM_METRICS: usize> {
}

impl<
const NUM_PARAMETERS: usize,
const NUM_METRICS:    usize
> Dtlz2<NUM_PARAMETERS, NUM_METRICS> {

	#[must_use]
	pub fn new() -> Self {
		const {
			assert!(NUM_METRICS >= 2);
			assert!(NUM_PARAMETERS >= NUM_METRICS);
		}
		Self{}
	}

	#[allow(dead_code)]
	#[must_use]
	pub fn evaluate(
		&self,
		arguments: [f64; NUM_PARAMETERS],
	) -> [f64; NUM_METRICS] {
		const HALF_PI: f64 = 0.5 * std::f64::consts::PI;
		debug_assert!(arguments.iter().all(|f| f.is_finite()));
		if arguments.iter().any(|&a| a < 0.0 || a > 1.0) {
			return std::array::repeat(f64::INFINITY);
		}
		let m = NUM_METRICS - 1;
		let g_plus_one =
			arguments[m..].iter().map(|x| (x - 0.5).powi(2)).sum::<f64>()
			+ 1.0;
		std::array::from_fn(|i| {
			let j = m - i;
			g_plus_one
			* arguments[..j].iter().map(|x| (HALF_PI * x).cos()).product::<f64>()
			* if i == 0 { 1.0 } else { (HALF_PI * arguments[j]).sin() }
		})
	}

	#[must_use]
	pub fn first_dominates_second(
		&self,
		measurements_a: [f64; NUM_METRICS],
		measurements_b: [f64; NUM_METRICS],
	) -> bool {
		debug_assert!(measurements_a.iter().all(|x| x.is_finite()));
		debug_assert!(measurements_b.iter().all(|x| x.is_finite()));
		let mut one_better = false;
		for (a, b) in measurements_a.into_iter().zip(measurements_b) {
			if b < a { return false; }
			if a < b { one_better = true; }
		}
		one_better
	}

	/// If `members` is empty, returns `None`. Otherwise, returns `true` if all
	/// pairs of elements in `members` are not equal and non-dominating, or
	/// `false` otherwise.
	#[must_use]
	pub fn is_one_pareto_front(
		&self,
		members: &[[f64; NUM_METRICS]],
	) -> Option<bool> {
		if members.is_empty() {
			return None;
		}
		for i in 0..(members.len() - 1) {
			for j in (i + 1)..members.len() {
				let a = members[i];
				let b = members[j];
				if
					a == b
					|| self.first_dominates_second(a, b)
					|| self.first_dominates_second(b, a)
					{
						return Some(false);
					}
			}
		}
		Some(true)
	}

	#[must_use]
	pub fn random_optimal_uniform_measurements(
		&self,
		rng: &mut rand::rngs::Xoshiro256PlusPlus,
	) -> [f64; NUM_METRICS] {
		use rand::RngExt;
		let distribution = rand_distr::StandardNormal;
		loop {
			let measurements = std::array::from_fn(|_| {
				rng.sample::<f64, _>(distribution).abs()
			});
			let norm = euclidean::norm(&measurements);
			if norm > 1.0e-15 {  // Arbitrary threshold
				return measurements.map(|m| m / norm);
			}
		}
	}

	#[must_use]
	pub fn random_optimal_normal_measurements(
		&self,
		rng: &mut rand::rngs::Xoshiro256PlusPlus,
	) -> [f64; NUM_METRICS] {
		use rand::RngExt;
		let distribution = rand_distr::StandardNormal;
		let center = 1.0 / 3.0_f64.sqrt();
		let width = 0.2;
		loop {
			let measurements = std::array::from_fn(|_| {
				(center + width * rng.sample::<f64, _>(distribution)).abs()
			});
			let norm = euclidean::norm(&measurements);
			if norm > 1.0e-15 {  // Arbitrary threshold
				return measurements.map(|m| m / norm);
			}
		}
	}

}


#[must_use]
fn calculate_crowding_distances<const N: usize>(
	points: &[[f64; N]],
) -> Vec<f64> {
	debug_assert!(points.iter().all(|p| p.iter().all(|f| f.is_finite())));
	if points.len() <= 2 {
		return (0..points.len()).map(|_| f64::INFINITY).collect();
	}
	let mut distances: Vec<_> = (0..points.len()).map(|i| (i, 0.0)).collect();
	for i in 0..N {
		distances.sort_by(|a, b| points[a.0][i].total_cmp(&points[b.0][i]));
		let span =
			points[distances.last().unwrap().0][i]
			- points[distances.first().unwrap().0][i];
		if span == 0.0 {
			continue;
		}
		distances.first_mut().unwrap().1 = f64::INFINITY;
		distances.last_mut().unwrap().1 = f64::INFINITY;
		for j in 1..(distances.len() - 1) {
			let ai = points[distances[j - 1].0][i];
			let bi = points[distances[j + 1].0][i];
			distances[j].1 += (bi - ai) / span;
		}
	}
	distances.sort_unstable_by_key(|(i, _)| *i);
	distances.into_iter().map(|(_, d)| d).collect()
}


fn create_new_datetime_directory(suffix: &str) -> Result<String, String> {
	loop {
		let directory_path = format!(
			"{}_{suffix}/",
			chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"),
		);
		let directory_exists =
			std::fs::exists(&directory_path)
			.map_err(|e| format!(
				"couldn't check for existing datetime directory: {e}"
			))?;
		if directory_exists {
			std::thread::sleep(std::time::Duration::from_secs(1));
			continue;
		}
		std::fs::create_dir(&directory_path).map_err(|e| format!(
			"couldn't create datetime directory \"{directory_path}\": {e}"
		))?;
		return Ok(directory_path);
	}
}


fn save_points_and_distances(
	points:    &[[f64; 3]],
	file_path: &str,
) -> Result<(), String> {
	use std::io::Write;
	let crowding_distances  = calculate_crowding_distances(points);
	let file =
		std::fs::File::create(file_path)
		.map_err(|e| format!(
			"couldn't create points and distances file \"{file_path}\": {e}"
		))?;
	let mut writer = std::io::BufWriter::new(file);
	writeln!(writer, "x,y,z,crowding distance")
		.map_err(|e| format!(
			"couldn't write header to points and distances file \
			\"{file_path}\": {e}"
		))?;
	for ([x, y, z], crowding) in points.iter().zip(crowding_distances) {
		writeln!(writer, "{x},{y},{z},{crowding}").map_err(|e| format!(
			"couldn't write line to points and distances file \"{file_path}\": \
			{e}"
		))?;
	}
	writer.flush().map_err(|e| format!(
		"couldn't flush points and distances file \"{file_path}\": {e}"
	))
}


fn run_test(
	original_pareto_front: &[[f64; 3]],
	downselection_size:    usize,
	output_directory_path: &str,
	output_file_prefix:    &str,
	rng:                   &mut rand::rngs::Xoshiro256PlusPlus,
) -> Result<(), String> {
	let random_subset: Vec<[f64; 3]> = {
		use rand::prelude::SliceRandom;
		let _status = TimedStatus::new("    creating random subset");
		let mut v = original_pareto_front.to_vec();
		v.shuffle(rng);
		v.truncate(downselection_size);
		v
	};
	let one_shot_crowding_subset: Vec<[f64; 3]> = {
		let _status = TimedStatus::new("    creating one-shot subset");
		let mut v: Vec<_> =
			original_pareto_front
			.iter()
			.zip(calculate_crowding_distances(original_pareto_front))
			.collect();
		v.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
		v.truncate(downselection_size);
		v.into_iter().map(|(p, _)| *p).collect()
	};
	let iterative_crowding_subset: Vec<[f64; 3]> = {
		let _status = TimedStatus::new("    creating iterative subset");
		let mut subset = original_pareto_front.to_vec();
		while subset.len() > downselection_size {
			subset.remove(
				calculate_crowding_distances(&subset)
				.into_iter()
				.enumerate()
				.min_by(|a, b| a.1.total_cmp(&b.1))
				.unwrap()
				.0
			);
		}
		subset
	};
	for (index, (front, name)) in [
		(original_pareto_front,      "original"),
		(&random_subset,             "random"),
		(&one_shot_crowding_subset,  "one-shot"),
		(&iterative_crowding_subset, "iterative"),
	].into_iter().enumerate() {
		assert!(!front.is_empty(), "{name}");
		let _status = TimedStatus::new(&format!("    saving {name}"));
		save_points_and_distances(
			front,
			&format!(
				"{output_directory_path}/\
				{output_file_prefix}_{index}-{name}.csv"
			),
		)?;
	}
	Ok(())
}


fn main() {
	use rand::SeedableRng;

	const LARGE_SIZE: usize = 10_000;
	const SMALL_SIZE: usize = LARGE_SIZE / 4;
	const {
		assert!(SMALL_SIZE < LARGE_SIZE);
		assert!(0 < SMALL_SIZE);
	}

	let usage = format!("{} [rng-seed]", env!("CARGO_BIN_NAME"));
	let args: Vec<String> = std::env::args().collect();
	if args.iter().any(|a| a == "-h" || a == "--help") {
		println!("usage: {usage}");
		std::process::exit(0);
	}
	if args.len() < 1 {
		eprintln!("error: too few arguments\nusage: {usage}");
		std::process::exit(1);
	}
	if args.len() > 2 {
		eprintln!("error: too many arguments\nusage: {usage}");
		std::process::exit(2);
	}

	let rng_seed = args.get(1).map_or_else(
		rand::random::<u64>,
		|s| match s.parse::<u64>() {
			Ok(u) => u,
			Err(e) => {
				eprintln!(
					"error: invalid random number generator seed: {e}\nusage: \
					{usage}"
				);
				std::process::exit(3);
			},
		},
	);
	let mut rng = rand::rngs::Xoshiro256PlusPlus::seed_from_u64(rng_seed);

	let output_directory_path = match create_new_datetime_directory(
		&rng_seed.to_string()
	) {
		Ok(s) => s,
		Err(e) => {
			eprintln!("error: couldn't create output directory: {e}");
			std::process::exit(4);
		},
	};

	let problem = Dtlz2::<3, 3>::new();
	let uniform_front: Vec<[f64; 3]> = {
		let _status = TimedStatus::new("creating uniform front");
		let front: Vec<[f64; 3]> =
			(0..LARGE_SIZE)
			.map(|_| problem.random_optimal_uniform_measurements(&mut rng))
			.collect();
		assert_eq!(problem.is_one_pareto_front(&front), Some(true));
		front
	};
	let normal_front: Vec<[f64; 3]> = {
		let _status = TimedStatus::new("creating normal front");
		let front: Vec<[f64; 3]> =
			(0..LARGE_SIZE)
			.map(|_| problem.random_optimal_normal_measurements(&mut rng))
			.collect();
		assert_eq!(problem.is_one_pareto_front(&front), Some(true));
		front
	};
	for (i, (name, front)) in [
		("uniform", uniform_front),
		("normal",  normal_front),
	].into_iter().enumerate() {
		use std::io::Write;
		println!("running {name} test");
		std::io::stdout().flush().unwrap();
		if let Err(e) = run_test(
			&front,
			SMALL_SIZE,
			&output_directory_path,
			name,
			&mut rng,
		) {
			eprintln!("error: {e}");
			std::process::exit(5 + i as i32);
		}
	}
}

Python Plotting Code

Ran with Python 3.13.5.

#!/usr/bin/env python3

import cycler
import matplotlib.pyplot
import matplotlib.ticker
import matplotlib.typing

import argparse
import collections.abc
import math
import os
import subprocess
import time
import types
import typing


PLOT_THEME: dict[matplotlib.typing.RcKeyType, typing.Any] = {
	"axes.axisbelow":         True,
	"axes.edgecolor":         "#e0cdb0",
	"axes.facecolor":         "#000000",
	"axes.grid":              True,
	"axes.grid.which":        "both",
	"axes.labelcolor":        "#e0cdb0",
	"axes.prop_cycle":        cycler.cycler(
		color = ["#35b779", "#a878de", "#398fe5", "#e65c64"],
	),
	"axes3d.xaxis.panecolor": "#000000",
	"axes3d.yaxis.panecolor": "#000000",
	"axes3d.zaxis.panecolor": "#000000",
	"figure.facecolor":       "#000000",
	"grid.major.color":       "#706658",
	"grid.major.linewidth":   0.8,
	"grid.minor.color":       "#4e483e",
	"grid.minor.linewidth":   0.5,
	"patch.edgecolor":        "#000000",
	"patch.force_edgecolor":  True,
	"patch.linewidth":        0.4,
	"scatter.edgecolors":     "#000000",
	"text.color":             "#e0cdb0",
	"xtick.color":            "#e0cdb0",
	"ytick.color":            "#e0cdb0",
}


def format_duration(seconds: float) -> str:
	value: float = seconds
	unit: str = "s"
	if value >= 3600.0:
		value /= 3600.0
		unit = "hr"
	elif value >= 60.0:
		value /= 60.0
		unit = "min"
	elif value < 1.0e-3:
		value *= 1.0e6
		unit = "us"
	elif value < 1.0:
		value *= 1.0e3
		unit = "ms"
	num_decimal_places: int = 2
	if value >= 100.0:
		num_decimal_places = 0
	elif value >= 10.0:
		num_decimal_places = 1
	return f"{value:.{num_decimal_places}f} {unit}"


class TimedStatus:

	def __init__(self, message: str) -> None:
		self.__message: str = message

	def __enter__(self) -> "TimedStatus":
		print(self.__message, end = "", flush = True)
		self.__start_time: float = time.monotonic()
		return self

	def __exit__(
		self,
		exception_type:  type[BaseException] | None,
		exception_value: BaseException       | None,
		traceback:       types.TracebackType | None,
	) -> None:
		elapsed: str = format_duration(time.monotonic() - self.__start_time)
		print(f" ({elapsed})", flush = True)


def read_dsv_file_rowwise(
	file_path: str,
	*,
	delimiter: str = ",",
	column_types: collections.abc.Mapping[
		str,
		collections.abc.Callable[[str], typing.Any]
	] = {},
) -> list[dict[str, typing.Any]]:
	def split_str(s: str) -> list[str]:
		return [sub.strip() for sub in s.split(delimiter)]
	with open(file_path, "r") as file:
		column_names: list[str] = split_str(file.readline().rstrip("\r\n"))
		rows: list[dict[str, typing.Any]] = []
		while line := file.readline():
			if len(line := line.rstrip("\r\n")) == 0:
				continue
			rows.append({
				name: column_types.get(name, lambda x: x)(part)
				for name, part in zip(column_names, split_str(line))
			})
	return rows


def compress_png(oxipng_path: str, png_file_path: str) -> None:
	subprocess.run(
		[oxipng_path, "--opt", "max", "--strip", "safe", png_file_path],
		capture_output = True,
		check          = True,
	)


def main(
	*,
	directory_path: str,
	oxipng_path:    str | None,
) -> None:
	file_names: list[str] = []
	for name in os.listdir(directory_path):
		if name.endswith(".csv"):
			file_names.append(name)
	file_names.sort()
	print(f"found {len(file_names)} files", flush = True)

	if oxipng_path == "":
		oxipng_path = "oxipng"

	plot_file_paths: list[str] = []
	for i, file_name in enumerate(file_names, start = 1):
		print(f"\n{i} of {len(file_names)}: {file_name}")
		with TimedStatus(f"    parsing"):
			rows: list[dict[str, typing.Any]] = read_dsv_file_rowwise(
				file_path    = f"{directory_path}/{file_name}",
				column_types = {
					"x": float,
					"y": float,
					"z": float,
					"crowding distance": float,
				},
			)
		with matplotlib.pyplot.rc_context(PLOT_THEME):
			with TimedStatus("    plotting front"):
				figure = matplotlib.pyplot.figure(
					figsize = (12.0, 12.0),
					dpi     = 512,
					layout  = "tight",
				)
				axes = figure.add_subplot(1, 1, 1, projection = "3d")
				axes.set_xlabel("x")
				axes.set_ylabel("y")
				axes.set_zlabel("z")
				axes.set_xlim((-0.05, 1.05))
				axes.set_ylim((-0.05, 1.05))
				axes.set_zlim((-0.05, 1.05))
				axes.view_init(20.0, 30.0, 0.0)
				axes.scatter(
					[row["x"] for row in rows],
					[row["y"] for row in rows],
					[row["z"] for row in rows],
				)
				plot_file_paths.append(
					f"{directory_path}/plot_front_{file_name}.png"
				)
				title: str = "Pareto Front"
				if file_name.startswith("normal"):
					title = "Normally Distributed " + title
				elif file_name.startswith("uniform"):
					title = "Uniformly Distributed " + title
				if "0-original" in file_name:
					title += "\nOriginal"
				elif "1-random" in file_name:
					title += "\nQuartered Randomly"
				elif "2-one-shot" in file_name:
					title += "\nQuartered with One-Shot Crowding Distance"
				elif "3-iterative" in file_name:
					title += "\nQuartered with Iterative Crowding Distance"
				axes.set_title(title, y = 0.95)
				figure.savefig(
					plot_file_paths[-1],
					bbox_inches = figure.bbox_inches.from_bounds(
						x0     =  0.0,
						y0     =  0.75,
						width  = 12.0,
						height = 10.5,
					)
				)
				matplotlib.pyplot.close(figure)
			with TimedStatus("    plotting crowding"):
				values: list[float] = [
					d
					for row in rows
					if math.isfinite(d := row["crowding distance"])
				]
				figure = matplotlib.pyplot.figure(
					figsize = (12.0, 10.0),
					dpi     = 512,
					layout  = "tight",
				)
				axes = figure.add_subplot(1, 1, 1)
				title: str = "Crowding Distance Histogram"
				if file_name.startswith("normal"):
					title += "\nNormally Distributed Pareto Front"
					bins: list[float] = [x / 1000.0 for x in range(51)]
					padding_x: float = 0.005 * (bins[-1] - bins[0])
					assert min(values) >= bins[ 0]
					assert max(values) <= bins[-1]
					axes.hist(x = values, bins = bins)
					axes.set_xlim((bins[0] - padding_x, bins[-1] + padding_x))
					if "0-original" in file_name:
						title += "\nOriginal"
						axes.set_ylim((0, 9000))
					else:
						axes.set_ylim((0, 1400))
						if "1-random" in file_name:
							title += "\nQuartered Randomly"
						elif "2-one-shot" in file_name:
							title += "\nQuartered with One-Shot Crowding Distance"
						elif "3-iterative" in file_name:
							title += "\nQuartered with Iterative Crowding Distance"
					axes.xaxis.set_minor_locator(
						matplotlib.ticker.MultipleLocator(0.002)
					)
					axes.yaxis.set_major_locator(
						matplotlib.ticker.MultipleLocator(200)
					)
					axes.yaxis.set_minor_locator(
						matplotlib.ticker.MultipleLocator(100)
					)
				elif file_name.startswith("uniform"):
					title += "\nUniformly Distributed Pareto Front"
					bins: list[float] = [x / 5000.0 for x in range(41)]
					padding_x: float = 0.005 * (bins[-1] - bins[0])
					assert min(values) >= bins[ 0]
					assert max(values) <= bins[-1]
					axes.hist(x = values, bins = bins)
					axes.set_xlim((bins[0] - padding_x, bins[-1] + padding_x))
					if "0-original" in file_name:
						title += "\nOriginal"
						axes.set_ylim((0, 3500))
					else:
						axes.set_ylim((0, 600))
						if "1-random" in file_name:
							title += "\nQuartered Randomly"
						elif "2-one-shot" in file_name:
							title += "\nQuartered with One-Shot Crowding Distance"
						elif "3-iterative" in file_name:
							title += "\nQuartered with Iterative Crowding Distance"
					axes.xaxis.set_major_locator(
						matplotlib.ticker.MultipleLocator(0.001)
					)
					axes.xaxis.set_minor_locator(
						matplotlib.ticker.MultipleLocator(0.0005)
					)
					axes.yaxis.set_major_locator(
						matplotlib.ticker.MultipleLocator(100)
					)
					axes.yaxis.set_minor_locator(
						matplotlib.ticker.MultipleLocator(50)
					)
				else:
					axes.hist(x = values)
				axes.set_title(title)
				plot_file_paths.append(
					f"{directory_path}/plot_crowding_{file_name}.png"
				)
				figure.savefig(plot_file_paths[-1])
				matplotlib.pyplot.close(figure)
			if file_name.startswith("normal"):
				with TimedStatus("    plotting crowding log"):
					figure = matplotlib.pyplot.figure(
						figsize = (12.0, 12.0),
						dpi     = 512,
						layout  = "tight",
					)
					axes = figure.add_subplot(1, 1, 1)
					axes.hist(x = values, bins = bins)
					axes.set_yscale("log")
					axes.set_ylim((0.9, 10 ** 4))
					axes.set_xlim((bins[0] - padding_x, bins[-1] + padding_x))
					axes.xaxis.set_minor_locator(
						matplotlib.ticker.MultipleLocator(0.002)
					)
					plot_file_paths.append(
						f"{directory_path}/plot_crowding-log_{file_name}.png"
					)
					axes.set_title(title)
					figure.savefig(plot_file_paths[-1])
					matplotlib.pyplot.close(figure)
		print()
	if oxipng_path is not None:
		print("\ncompressing")
		starting_bytes: int = sum(os.path.getsize(f) for f in plot_file_paths)
		for i, file_path in enumerate(sorted(plot_file_paths), start = 1):
			file_name: str = file_path.split("/")[-1]
			with TimedStatus(f"    {i} of {len(plot_file_paths)}: {file_name}"):
				compress_png(oxipng_path, file_path)
		ending_bytes: int = sum(os.path.getsize(f) for f in plot_file_paths)
		compression_percent: float = (
			100.0 * (starting_bytes - ending_bytes) / starting_bytes
		)
		print(
			f"\n    {starting_bytes:,} bytes -> {ending_bytes:,} bytes "
			f"({compression_percent:.1f}% smaller)\n"
		)


def make_cli() -> argparse.ArgumentParser:
	class FixedWidthFormatter(argparse.ArgumentDefaultsHelpFormatter):
		def __init__(self, prog: str) -> None:
			super().__init__(prog, width = 80)
	parser = argparse.ArgumentParser(formatter_class = FixedWidthFormatter)
	parser.add_argument(
		"-c",
		"--compress",
		metavar = "oxipng-path",
		dest    = "oxipng_path",
		nargs   = "?",
		const   = "",
		help    = (
			"Compress the plot PNGs with Oxipng."
		)
	)
	parser.add_argument(
		"directory_path",
		metavar = "<directory-path>",
		help    = (
			"Path to directory containing results .csv files from the "
			"accompanying Rust program."
		),
	)
	return parser


if __name__ == "__main__":
	main(**vars(make_cli().parse_args()))

Back to top

Home