Home

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


A Crowding Distance Experiment

September 20, 2026

The Nondominated Sorting Genetic Algorithm II (NSGA-II) optimization algorithm sorts population members by Pareto front (sometimes called rank) and crowding distance. The crowding distance is meant to quantify how close a Pareto front member is to its neighbors across all dimensions to determine which are more valuable to the optimization. A larger crowding distance indicates a Pareto front member that's far from its neighbors and therefore should be retained. Crowding distances are calculated separately for each Pareto front. The full calculation is implemented in the code below, but in two dimensions it's equal to half the perimeter of a box formed by the neighboring Pareto front members scaled to the span of the data.

For example, the crowding distance of the triangle point below is half the perimeter of the purple dashed box formed by the neighboring Pareto front members, after the data has been scaled to the range [0, 1] on each axis. Pareto front members on the end are given a crowding distance of infinity.

Example Pareto front

The original NSGA-II algorithm ("A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II", 2002, Kalyanmoy Deb, Amrit Pratap, Sameer Agarwal, T. Meyarivan) downselects Pareto fronts by calculating the crowding distance for all the members of a Pareto front and removing the members with the smallest distances. This is called "one-shot crowding distance downselection" in the rest of this post.

What would happen if, instead of removing the desired number of Pareto front members all at once, they were removed one at a time, recalculating the crowding distances after each removal? This is called "iterative crowding distance downselection" in the rest of this post.

Here's a simple two-dimensional Pareto front (a straight line) of 400 random points:

Randomly generated Pareto front

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

Histogram of crowding distances

This is a histogram of the Euclidean distance between all pairs of consecutive Pareto front members:

Histogram of Euclidean distances

The following three images show this Pareto front downselected from 400 points to 100 points using three methods:

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

Pareto front quartered randomly

Pareto front quartered with one-shot crowding distance

Pareto front quartered with iterative crowding distance

The following three images are histograms of the final crowding distances for each of the downselected Pareto fronts above:

Histogram of crowding distances

Histogram of crowding distances

Histogram of crowding distances

The following three images are histograms of the Euclidean distances between all consecutive pairs of points in the downselected Pareto fronts above:

Histogram of Euclidean distances

Histogram of Euclidean distances

Histogram of Euclidean distances

The iterative crowding distance downselection produces a subset that's noticeably more uniform across the Pareto front than the one-shot crowding distance.

Future work:


The example Pareto front image was made in LibreOffice Draw. All other results and images came from this Python script on Linux using Python 3.13.5 and random number generator seed 1162639579025111564. Use this code at your own risk.

#!/usr/bin/env python3

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

import argparse
import collections.abc
import dataclasses
import datetime
import enum
import itertools
import math
import os
import random
import time
import typing


class LineStyle(enum.Enum):
	SOLID  = "-"
	DASHED = "--"


@dataclasses.dataclass(frozen = True, slots = True)
class Line:
	point_a: tuple[float, float]
	point_b: tuple[float, float]
	style:   LineStyle = LineStyle.SOLID
	alpha:   float     = 0.8


@dataclasses.dataclass(frozen = True, slots = True)
class Scatter:
	x:     str
	y:     str
	alpha: float = 0.8


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.prop_cycle":       cycler.cycler(
		color = ["#35b779", "#a878de", "#398fe5", "#e65c64"],
	),
	"figure.facecolor":      "#000000",
	"grid.major.color":      "#433e35",
	"grid.major.linewidth":  0.6,
	"grid.minor.color":      "#282520",
	"grid.minor.linewidth":  0.4,
	"patch.edgecolor":      "#000000", #"#e0cdb0",
	"patch.force_edgecolor": True,
	"patch.linewidth":       0.4,
	"scatter.edgecolors":    "#000000",
	"text.color":            "#e0cdb0",
	"xtick.color":           "#e0cdb0",
	"ytick.color":           "#e0cdb0",
}


def nth_value[T](it: collections.abc.Iterable[T], n: int) -> T:
	try:
		return next(x for i, x in enumerate(it) if i == n)
	except StopIteration:
		if n == 1:
			raise IndexError("no 1st value")
		elif n == 2:
			raise IndexError("no 2nd value")
		elif n == 3:
			raise IndexError("no 3rd value")
		else:
			raise IndexError(f"no {n}th value")


def make_new_datetime_directory(*, prefix: str = "", suffix: str = "") -> str:
	while True:
		timestamp: str = (
			datetime
			.datetime
			.now()
			.isoformat(sep = "_", timespec = "seconds")
			.replace(":", "-")
		)
		new_directory_path: str = f"{prefix}{timestamp}{suffix}/"
		if os.path.isdir(new_directory_path):
			time.sleep(1.0)
			continue
		os.makedirs(new_directory_path)
		break
	return new_directory_path


def save_plot(
	*,
	data:        list[dict[str, float]],
	subjects:    list[Line | Scatter],
	x_limits:    tuple[float, float],
	y_limits:    tuple[float, float],
	figure_size: tuple[float, float],
	title:       str,
	file_path:   str,
) -> None:
	with matplotlib.pyplot.rc_context(PLOT_THEME):
		figure = matplotlib.pyplot.figure(
			figsize = figure_size,
			dpi     = 512,
			layout  = "tight",
		)
		axes = figure.add_subplot(1, 1, 1)
		axes.xaxis.set_major_locator(matplotlib.ticker.MultipleLocator(1))
		axes.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(0.5))
		axes.yaxis.set_major_locator(matplotlib.ticker.MultipleLocator(1))
		axes.yaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(0.5))
		axes.set_aspect("equal")
		axes.set_title(title)
		axes.set_xlim(x_limits)
		axes.set_ylim(y_limits)
		for index, subject, style in zip(
			range(len(subjects)),
			subjects,
			PLOT_THEME["axes.prop_cycle"],
		):
			if isinstance(subject, Line):
				axes.axline(
					xy1       = subject.point_a,
					xy2       = subject.point_b,
					linestyle = subject.style.value,
					alpha     = subject.alpha,
					zorder    = index + 1,
					**style,
				)
			elif isinstance(subject, Scatter):
				axes.scatter(
					x         = [row[subject.x] for row in data],
					y         = [row[subject.y] for row in data],
					alpha     = subject.alpha,
					edgecolor = PLOT_THEME["scatter.edgecolors"],
					linewidth = PLOT_THEME["grid.minor.linewidth"],
					zorder    = index + 1,
					**style,
				)
			else:
				raise TypeError("not a valid plot subject")
		figure.savefig(file_path)
		matplotlib.pyplot.close(figure)


def save_histogram(
	*,
	values:        list[float],
	bins:          list[float],
	max_count:     int | None,
	major_x_ticks: float,
	major_y_ticks: int,
	figure_size:   tuple[float, float],
	title:         str,
	file_path:     str,
) -> None:
	x_padding: float = (bins[-1] - bins[0]) * 0.005
	bad_lower_bound: bool = any(v < bins[ 0] for v in values)
	bad_upper_bound: bool = any(v > bins[-1] for v in values)
	bad_color: str = nth_value(PLOT_THEME["axes.prop_cycle"], 1)["color"]
	with matplotlib.pyplot.rc_context(PLOT_THEME):
		figure = matplotlib.pyplot.figure(
			figsize = figure_size,
			dpi     = 512,
			layout  = "tight",
		)
		axes = figure.add_subplot(1, 1, 1)
		axes.xaxis.set_major_locator(matplotlib.ticker.MultipleLocator(major_x_ticks))
		axes.xaxis.set_minor_locator(matplotlib.ticker.FixedLocator(bins))
		axes.yaxis.set_major_locator(matplotlib.ticker.MultipleLocator(major_y_ticks))
		axes.yaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(1))
		axes.set_title(title)
		axes.set_xlim((bins[0] - x_padding, bins[-1] + x_padding))
		if max_count is not None:
			y_padding: float = max_count * 0.005
			axes.set_ylim((-y_padding, max_count + y_padding))
		axes.hist(
			x    = values,
			bins = bins,
		)
		if bad_lower_bound:
			axes.annotate(
				text                = "More data not shown",
				xy                  = (0.01, 0.5),
				xytext              = (0.04, 0.5),
				xycoords            = "axes fraction",
				horizontalalignment = "left",
				verticalalignment   = "center",
				fontweight          = "bold",
				color               = bad_color,
				alpha               = 0.8,
				path_effects = [
					matplotlib.patheffects.withStroke(
						linewidth  = PLOT_THEME["grid.major.linewidth"],
						foreground = PLOT_THEME["scatter.edgecolors"],
					),
				],
				arrowprops = {
					"width":      5.0,
					"headwidth":  12.0,
					"headlength": 12.0,
					"facecolor":  bad_color,
					"edgecolor":  PLOT_THEME["scatter.edgecolors"],
				},
			)
		if bad_upper_bound:
			axes.annotate(
				text                = "More data not shown",
				xy                  = (0.99 , 0.5),
				xytext              = (0.96, 0.5),
				xycoords            = "axes fraction",
				horizontalalignment = "right",
				verticalalignment   = "center",
				fontweight          = "bold",
				color               = bad_color,
				alpha               = 0.8,
				path_effects = [
					matplotlib.patheffects.withStroke(
						linewidth  = PLOT_THEME["grid.major.linewidth"],
						foreground = PLOT_THEME["scatter.edgecolors"],
					),
				],
				arrowprops = {
					"width":      5.0,
					"headwidth":  12.0,
					"headlength": 12.0,
					"facecolor":  bad_color,
					"edgecolor":  PLOT_THEME["scatter.edgecolors"],
				},
			)
		figure.savefig(file_path)
		matplotlib.pyplot.close(figure)


def calculate_euclidean_distances(rows: list[dict[str, float]]) -> list[float]:
	rows.sort(key = lambda row: row["x"])
	return [
		math.sqrt((a["x"] - b["x"]) ** 2 + (a["y"] - b["y"]) ** 2)
		for a, b in itertools.pairwise(rows)
	]


def add_crowding_distance(
	rows:      list[dict[str, float]],
	key:       str,
) -> None:
	if len(rows) <= 2:
		for row in rows:
			row[key] = math.inf
		return
	for row in rows:
		row[key] = 0.0
	for dim in ["x", "y"]:
		rows.sort(key = lambda row: row[dim])
		span: float = rows[-1][dim] - rows[0][dim]
		if span == 0.0:
			continue
		rows[ 0][key] = math.inf
		rows[-1][key] = math.inf
		for i in range(1, len(rows) - 1):
			rows[i][key] += (
				(rows[i + 1][dim] - rows[i - 1][dim])
				/ span
			)


def plot(
	*,
	data:                  list[dict[str, float]],
	limit_histograms:      bool,
	number:                int,
	crowding_distance_key: str,
	title:                 str,
	output_directory_path: str,
) -> None:
	if len(output_directory_path) == 0:
		raise ValueError("plot output directory path must not be empty")
	suffix: str = f"{number}-{crowding_distance_key}"
	crowding_distances: list[float] = [
		row[crowding_distance_key]
		for row in data
		if math.isfinite(row[crowding_distance_key])
	]
	save_plot(
		data = data,
		subjects = [
			Line(
				point_a = (0.0, 1.0),
				point_b = (10.0, 0.0),
				style   = LineStyle.DASHED,
				alpha   = 0.4,
			),
			Scatter("x", "y"),
		],
		x_limits    = (-0.1, 10.1),
		y_limits    = (-0.1,  1.1),
		figure_size = (12.0, 2.5),
		title       = f"Pareto Front\n{title}",
		file_path   = f"{output_directory_path}/front-{suffix}.png",
	)
	save_histogram(
		values        = calculate_euclidean_distances(data),
		bins          = [i / 100.0 for i in range(51)],
		max_count     = 20 if limit_histograms else None,
		major_x_ticks = 0.05,
		major_y_ticks = 10,
		figure_size   = (12.0, 4.0),
		title         = f"Euclidean Distance Histogram\n{title}",
		file_path     = f"{output_directory_path}/euclidean-{suffix}.png",
	)
	save_histogram(
		values        = crowding_distances,
		bins          = [i / 200.0 for i in range(41)],
		max_count     = 30 if limit_histograms else None,
		major_x_ticks = 0.05,
		major_y_ticks = 5,
		figure_size   = (12.0, 4.0),
		title         = f"Crowding Distance Histogram\n{title}",
		file_path     = f"{output_directory_path}/crowding-{suffix}.png",
	)


def main(
	*,
	rng_seed:              int | None,
	begin_size:            int,
	end_size:              int,
	output_directory_path: str | None,
) -> None:
	if rng_seed is None:
		rng_seed = int.from_bytes(os.getrandom(8), signed = False)
	rng = random.Random(rng_seed)
	print(f"seed {rng_seed}", flush = True)

	if output_directory_path is None:
		output_directory_path = make_new_datetime_directory(
			suffix = f"_{rng_seed}"
		)
	elif len(output_directory_path) == 0:
		raise ValueError("output directory path must not be empty")
	else:
		os.makedirs(output_directory_path, exist_ok = True)
	print(f"output \"{output_directory_path}\"")

	data: list[dict[str, float]] = []
	for _ in range(begin_size):
		x: float = rng.uniform(0.0, 10.0)
		y: float = -0.1 * x + 1.0
		data.append({"x": x, "y": y})

	add_crowding_distance(data, "2cd")
	plot(
		data                  = data,
		limit_histograms      = False,
		number                = 0,
		crowding_distance_key = "2cd",
		title                 = "Original",
		output_directory_path = output_directory_path,
	)

	rng.shuffle(data)
	subset_a: list[dict[str, float]] = data[:end_size]
	add_crowding_distance(subset_a, "2cd")
	plot(
		data                  = subset_a,
		limit_histograms      = True,
		number                = 1,
		crowding_distance_key = "2cd",
		title                 = "Quartered Randomly",
		output_directory_path = output_directory_path,
	)

	add_crowding_distance(data, "2cd")
	data.sort(key = lambda row: row["2cd"], reverse = True)
	subset_b: list[dict[str, float]] = data[:end_size]
	add_crowding_distance(subset_b, "2cd")
	plot(
		data                  = subset_b,
		limit_histograms      = True,
		number                = 2,
		crowding_distance_key = "2cd",
		title                 = "Quartered with One-Shot Crowding Distance",
		output_directory_path = output_directory_path,
	)

	add_crowding_distance(data, "2cd")
	while len(data) > end_size:
		i: int = min(range(len(data)), key = lambda i: data[i]["2cd"])
		data.pop(i)
		add_crowding_distance(data, "2cd")
	plot(
		data                  = data,
		limit_histograms      = True,
		number                = 3,
		crowding_distance_key = "2cd",
		title                 = "Quartered with Iterative Crowding Distance",
		output_directory_path = output_directory_path,
	)


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(
		"-b",
		"--begin-size",
		metavar = "<int>",
		dest    = "begin_size",
		type    = int,
		default = 400,
		help    = (
			"Initial Pareto front size. Must be nonnegative and greater than "
			"or equal to --end-size."
		),
	)
	parser.add_argument(
		"-e",
		"--end-size",
		metavar = "<int>",
		dest    = "end_size",
		type    = int,
		default = 100,
		help    = (
			"Final Pareto front size after downselecting from --begin-size. "
			"Must be nonnegative and less than or equal to --begin-size."
		),
	)
	parser.add_argument(
		"-o",
		"--output-directory",
		metavar = "<directory>",
		dest    = "output_directory_path",
		help    = (
			"Directory in which to save plots. Must not be the empty string. "
			"Will be created if needed. Defaults to a new directory in the "
			"current working directory with a name in the format "
			"YYYY-MM-DD_HH-MM-SS_RRRRRR... (year, month, day, hour, minute, "
			"second, random number seed)."
		)
	)
	parser.add_argument(
		"-r",
		"--rng-seed",
		metavar = "<int>",
		dest    = "rng_seed",
		type    = int,
		help    = (
			"Random Number Generator (RNG) seed as an integer. Defaults to a "
			"random seed taken from system random sources."
		),
	)
	return parser


if __name__ == "__main__":
	args: dict[str, typing.Any] = vars(make_cli().parse_args())
	main(**args)

Back to top

Home