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
7 changes: 7 additions & 0 deletions docs/source/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan

No changes yet.

## PyGAD 3.9.1

Release Date: October 16, 2026.

1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) favor the better solutions when fitness values are negative. Before this change, each share was the fitness divided by the total, which flips the order for negative values: the fitness values `-1` and `-9` gave the worse solution 90% of the selections. Negative fitness values are now shifted by the smallest fitness first, so `-1` and `-9` become `8` and `0`. This also covers mixed positive and negative values, and equal negative values give every solution the same chance. Fitness values that are all zero or above keep their exact previous behavior.
2. Regression tests cover negative, mixed-sign, equal negative, multi-objective, and large Python integer fitness, and a minimization run with each selector. The `pygad.utils` submodule version is `1.5.11`.

## PyGAD 3.9.0

Release Date: October 16, 2026.
Expand Down
4 changes: 4 additions & 0 deletions docs/source/utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,17 @@ Selects the parents using the tournament selection technique.

Selects the parents using the roulette wheel selection technique.

Each solution gets a share of the wheel equal to its fitness divided by the total fitness. If any fitness value is negative, all values are first shifted by the smallest fitness, so the worst solution gets no share and the better solutions keep larger shares. For example, the fitness values `-1` and `-9` become `8` and `0`. If all values are equal and negative, every solution gets the same chance. Fitness values that are all zero or above are used without a shift, and a total of zero raises `ZeroDivisionError`.

<!-- sphinx
(stochastic-universal-selection)=
-->
#### `stochastic_universal_selection()`

Selects the parents using the stochastic universal selection technique.

Each solution gets a share of the wheel equal to its fitness divided by the total fitness. If any fitness value is negative, all values are first shifted by the smallest fitness, so the worst solution gets no share and the better solutions keep larger shares. For example, the fitness values `-1` and `-9` become `8` and `0`. If all values are equal and negative, every solution gets the same chance. Fitness values that are all zero or above are used without a shift, and a total of zero raises `ZeroDivisionError`.

`stochastic_universal_selection(fitness, num_parents)` spaces its pointers according to the requested `num_parents`, even when this differs from `num_parents_mating`. It returns the selected parent array and its population indices. Sampling can select the same row more than once; with equal fitness, the evenly spaced pointers distribute selections as evenly as possible. For multiple objectives, the fitness values are summed per solution before constructing the wheel.

#### `nsga2_selection()`
Expand Down
2 changes: 1 addition & 1 deletion pygad/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.9.0"
__version__ = "3.9.1"
2 changes: 1 addition & 1 deletion pygad/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
from pygad.utils import validation
from pygad.utils import engine

__version__ = "1.5.10"
__version__ = "1.5.11"
60 changes: 48 additions & 12 deletions pygad/utils/parent_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,7 @@ def roulette_wheel_selection(self, fitness, num_parents):
pass

# Reaching this step confirms that fitness is a 1D array.
fitness_sum = numpy.sum(fitness, dtype=numpy.result_type(fitness.dtype, float))
if fitness_sum == 0:
self.logger.error("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")
raise ZeroDivisionError("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")

probs = fitness / fitness_sum
probs = self.fitness_selection_probabilities(fitness)

probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(),
num_parents=num_parents)
Expand All @@ -234,6 +229,52 @@ def roulette_wheel_selection(self, fitness, num_parents):

return parents, parents_indices

def fitness_selection_probabilities(self, fitness):
"""
Turn 1D fitness values into the selection probabilities used by
roulette wheel and stochastic universal selection.

Each probability is the fitness divided by the total fitness. When
any fitness value is negative, this division would flip the order
and favor the worst solutions. So the values are first shifted by
the smallest fitness, which makes the worst solution 0 and keeps
the better solutions favored. Fitness values that are all zero or
above are used as they are.

Parameters
----------
fitness : numpy.ndarray
A 1D array of fitness values, one per solution.

Returns
-------
probs : numpy.ndarray
The selection probability of each solution.

Raises
------
ZeroDivisionError
If no fitness value is negative and the sum of fitness values
is zero.
"""
# A wide accumulator avoids overflow. Object arrays of Python
# integers stay exact.
accumulator_dtype = numpy.result_type(fitness.dtype, float)
weights = fitness
if numpy.min(fitness) < 0:
weights = numpy.asarray(fitness, dtype=accumulator_dtype)
weights = weights - numpy.min(weights)
if not numpy.any(weights):
# Every solution has the same fitness, so every solution
# gets the same chance.
return numpy.full(weights.shape[0], 1.0 / weights.shape[0])

fitness_sum = numpy.sum(weights, dtype=accumulator_dtype)
if fitness_sum == 0:
self.logger.error("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")
raise ZeroDivisionError("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")
return weights / fitness_sum

def wheel_cumulative_probs(self, probs, num_parents):
"""
Build the cumulative probability ranges used by the roulette
Expand Down Expand Up @@ -319,12 +360,7 @@ def stochastic_universal_selection(self, fitness, num_parents):
pass

# Reaching this step confirms that fitness is a 1D array.
fitness_sum = numpy.sum(fitness, dtype=numpy.result_type(fitness.dtype, float))
if fitness_sum == 0:
self.logger.error("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")
raise ZeroDivisionError("Cannot proceed because the sum of fitness values is zero. Cannot divide by zero.")

probs = fitness / fitness_sum
probs = self.fitness_selection_probabilities(fitness)

probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(),
num_parents=num_parents)
Expand Down
127 changes: 127 additions & 0 deletions tests/test_probability_selection_negative_fitness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""
Tests for roulette wheel and stochastic universal selection when some
fitness values are zero or negative.

Both selectors give each solution a share of a wheel equal to its fitness
divided by the total fitness. With negative values this division flips the
order: for the fitness values -1 and -9, the worse solution (-9) would get
90% of the wheel. The selectors must shift negative fitness values so the
worst solution starts at zero, which keeps the better solutions favored.
"""

import numpy
import pytest

import pygad


# Number of selection rounds used to measure each share.
NUM_ROUNDS = 2500


def _zero_fitness(ga, solution, solution_idx):
return 0.0


def _make_ga(selection):
return pygad.GA(num_generations=1,
num_parents_mating=4,
fitness_func=_zero_fitness,
initial_population=[[1, 2], [3, 4], [5, 6], [7, 8]],
parent_selection_type=selection,
mutation_type=None,
random_seed=5,
suppress_warnings=True)


def _selection_shares(selection, fitness):
"""Select parents many times and return how often each solution was picked."""
ga = _make_ga(selection)
counts = numpy.zeros(4)
for _ in range(NUM_ROUNDS):
_, indices = ga.select_parents(numpy.asarray(fitness), 4)
counts += numpy.bincount(indices, minlength=4)
return counts / counts.sum()


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_negative_fitness_favors_the_better_solutions(selection):
# Shifting by the smallest value gives the weights 8, 0, 4, 6.
shares = _selection_shares(selection, [-1.0, -9.0, -5.0, -3.0])
expected = numpy.array([8, 0, 4, 6]) / 18
numpy.testing.assert_allclose(shares, expected, atol=0.02)
# The worst solution has no share of the wheel.
assert shares[1] == 0.0


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_mixed_sign_fitness_favors_the_better_solutions(selection):
# Shifting by -3 gives the weights 5, 2, 3.5, 0.
shares = _selection_shares(selection, [2.0, -1.0, 0.5, -3.0])
expected = numpy.array([5, 2, 3.5, 0]) / 10.5
numpy.testing.assert_allclose(shares, expected, atol=0.02)


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_mixed_sign_fitness_with_a_zero_sum_does_not_fail(selection):
# The sum is zero before shifting, but the values differ, so the
# selection is still well defined.
shares = _selection_shares(selection, [1.0, -1.0, 1.0, -1.0])
numpy.testing.assert_allclose(shares, [0.5, 0.0, 0.5, 0.0], atol=0.02)


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_equal_negative_fitness_gives_every_solution_the_same_chance(selection):
shares = _selection_shares(selection, [-5.0, -5.0, -5.0, -5.0])
numpy.testing.assert_allclose(shares, [0.25, 0.25, 0.25, 0.25], atol=0.03)


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_non_negative_fitness_keeps_its_original_shares(selection):
# No value is below zero, so the shares stay fitness / total.
shares = _selection_shares(selection, [1.0, 2.0, 3.0, 4.0])
numpy.testing.assert_allclose(shares, [0.1, 0.2, 0.3, 0.4], atol=0.02)


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_negative_multi_objective_fitness_favors_the_better_solutions(selection):
# Each row is summed first: -2, -18, -10, -6. Shifting gives 16, 0, 8, 12.
fitness = [[-1.0, -1.0], [-9.0, -9.0], [-5.0, -5.0], [-3.0, -3.0]]
shares = _selection_shares(selection, fitness)
expected = numpy.array([16, 0, 8, 12]) / 36
numpy.testing.assert_allclose(shares, expected, atol=0.02)


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_negative_python_integer_fitness_stays_exact(selection):
# Python integers too large for a float must still work after shifting.
big = 2**1500
fitness = numpy.array([-big, -3 * big, -2 * big, -big], dtype=object)
ga = _make_ga(selection)
_, indices = ga.select_parents(fitness, 4)
assert indices.shape == (4,)
# The worst solution (index 1) gets no share of the wheel.
assert 1 not in set(indices.tolist())


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_minimization_with_probability_selection_improves(selection):
# Minimize x**2 by maximizing -x**2. The best value must move toward 0.
def fitness(ga, solution, solution_idx):
return -float(solution[0]) ** 2

ga = pygad.GA(num_generations=30,
num_parents_mating=6,
fitness_func=fitness,
sol_per_pop=12,
num_genes=1,
init_range_low=5.0,
init_range_high=10.0,
parent_selection_type=selection,
keep_elitism=0,
random_seed=2,
suppress_warnings=True)
ga.run()
first_best = ga.best_solutions_fitness[0]
last_best = ga.best_solutions_fitness[-1]
assert last_best > first_best
Loading