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.2

Release Date: October 16, 2026.

1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) no longer fail with fitness values near the float limits. In PyGAD 3.9.1, a fitness of `-sys.float_info.max`, which some libraries give a rejected solution, made the shifted values overflow, and parent selection raised `IndexError`. The values are now scaled before they are shifted or added. `-inf` and `NaN` get no chance, solutions with `+inf` fitness share every chance equally, and a population with no finite fitness raises a clear `ValueError`. Ordinary non-negative fitness keeps exactly the same probabilities.
2. Regression tests cover the most negative float, huge values of both signs, huge positive values, infinite and missing values, and full runs with both selectors. The `pygad.utils` submodule version is `1.5.12`.

## PyGAD 3.9.1

Release Date: October 16, 2026.
Expand Down
4 changes: 2 additions & 2 deletions docs/source/utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ 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`.
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`. Values near the float limits, such as `-sys.float_info.max` for a rejected solution, are scaled before they are shifted or added, so nothing overflows. `-inf` and `NaN` get no chance, and when any fitness is `+inf`, those solutions share every chance equally.

<!-- sphinx
(stochastic-universal-selection)=
Expand All @@ -459,7 +459,7 @@ Each solution gets a share of the wheel equal to its fitness divided by the tota

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`.
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`. Values near the float limits, such as `-sys.float_info.max` for a rejected solution, are scaled before they are shifted or added, so nothing overflows. `-inf` and `NaN` get no chance, and when any fitness is `+inf`, those solutions share every chance equally.

`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.

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.1"
__version__ = "3.9.2"
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.11"
__version__ = "1.5.12"
90 changes: 81 additions & 9 deletions pygad/utils/parent_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ def fitness_selection_probabilities(self, fitness):
the better solutions favored. Fitness values that are all zero or
above are used as they are.

Values near the float limits, such as the most negative float that
some libraries give an invalid solution, are scaled down before
they are shifted or added, so nothing overflows. -inf and NaN get
no chance. When any fitness is +inf, those solutions share every
chance equally.

Parameters
----------
fitness : numpy.ndarray
Expand All @@ -254,22 +260,88 @@ def fitness_selection_probabilities(self, fitness):
Raises
------
ZeroDivisionError
If no fitness value is negative and the sum of fitness values
is zero.
If every fitness value is zero.
ValueError
If no fitness value is finite or +inf.
"""
if fitness.dtype == object:
# Python integers may be too large for a float, so they keep
# exact arithmetic.
return self._exact_selection_probabilities(fitness)

values = numpy.asarray(fitness, dtype=numpy.result_type(fitness.dtype, float))
positive_infinite = numpy.isposinf(values)
if positive_infinite.any():
return positive_infinite / numpy.count_nonzero(positive_infinite)
usable = numpy.isfinite(values)
if not usable.any():
message = "Cannot select parents because no fitness value is finite: every value is -inf or NaN."
self.logger.error(message)
raise ValueError(message)
finite_values = values[usable]
lowest = finite_values.min()
highest = finite_values.max()

if lowest >= 0 and usable.all():
# An overflow here is expected for values near the float limit
# and is handled by the scaled path below, so it is not warned.
with numpy.errstate(over="ignore"):
total = numpy.sum(values)
if numpy.isfinite(total):
# The usual case: the fitness divided by the total, exactly
# as before.
if total == 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 values / total

# Divide by the largest magnitude first, so every weight lies in the
# range 0 to 2 and neither the shift nor the sum can overflow.
scale = max(abs(lowest), abs(highest))
weights = numpy.zeros_like(values)
if lowest < 0:
weights[usable] = finite_values / scale - lowest / scale
else:
weights[usable] = finite_values / scale
total = weights.sum()
if total == 0:
if usable.all() and lowest == 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.")
# Every usable solution has the same fitness, so each gets the
# same chance.
weights[usable] = 1.0
total = weights.sum()
return weights / total

def _exact_selection_probabilities(self, fitness):
"""
Selection probabilities for an object array of exact numbers, such
as Python integers too large for a float.

Parameters
----------
fitness : numpy.ndarray
A 1D object array of fitness values.

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

Raises
------
ZeroDivisionError
If every fitness value 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)
weights = fitness - numpy.min(fitness)
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)
fitness_sum = numpy.sum(weights)
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.")
Expand Down
110 changes: 110 additions & 0 deletions tests/test_probability_selection_extreme_fitness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Tests for roulette wheel and stochastic universal selection with extreme
fitness values.

Libraries built on PyGAD often score an invalid solution with the most
negative float, -sys.float_info.max, so it can never win. Shifting by that
value, or adding fitness values close to the float limits, overflows to
infinity, and every selection chance became zero or undefined. These tests
check that the chances stay finite and meaningful at the float limits and
with infinite or missing values.
"""

import sys

import numpy
import pytest

import pygad

FLOAT_MAX = sys.float_info.max


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


def _make_ga(selection="rws"):
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 _probabilities(fitness):
return _make_ga().fitness_selection_probabilities(numpy.asarray(fitness, dtype=float))


def test_the_most_negative_float_gets_no_chance_and_the_rest_stay_proportional():
# Shifted by the most negative value, the other three are almost
# exactly equal, so each gets about a third.
probabilities = _probabilities([-FLOAT_MAX, 1.0, 2.0, 3.0])
assert numpy.all(numpy.isfinite(probabilities))
assert probabilities[0] == 0.0
numpy.testing.assert_allclose(probabilities[1:], [1 / 3] * 3, rtol=1e-12)


def test_huge_values_of_both_signs_do_not_overflow():
probabilities = _probabilities([1e308, -1e308, 0.0, 5.0])
assert numpy.all(numpy.isfinite(probabilities))
assert probabilities.sum() == pytest.approx(1.0)
# Shifted: 2e308, 0, 1e308, about 1e308, so the shares are 1/2, 0, 1/4, 1/4.
numpy.testing.assert_allclose(probabilities, [0.5, 0.0, 0.25, 0.25], rtol=1e-12)


def test_huge_positive_values_keep_their_proportions():
# Their sum, 5.2e308, is beyond the float range, so the expected shares
# are written out: 1, 1.5, 1, and 1.7 parts out of 5.2.
probabilities = _probabilities([1e308, 1.5e308, 1e308, 1.7e308])
numpy.testing.assert_allclose(probabilities, numpy.array([1, 1.5, 1, 1.7]) / 5.2, rtol=1e-12)


def test_minus_infinity_and_nan_get_no_chance():
probabilities = _probabilities([-numpy.inf, 1.0, numpy.nan, 3.0])
assert numpy.all(numpy.isfinite(probabilities))
assert probabilities[0] == 0.0
assert probabilities[2] == 0.0
numpy.testing.assert_allclose(probabilities[[1, 3]], [0.25, 0.75], rtol=1e-12)


def test_plus_infinity_takes_every_chance_split_evenly():
probabilities = _probabilities([numpy.inf, 1.0, numpy.inf, 3.0])
numpy.testing.assert_allclose(probabilities, [0.5, 0.0, 0.5, 0.0])


def test_ordinary_non_negative_fitness_keeps_its_exact_previous_values():
fitness = numpy.array([0.3, 1.7, 2.2, 0.8])
probabilities = _probabilities(fitness)
numpy.testing.assert_array_equal(probabilities, fitness / numpy.sum(fitness))


def test_no_usable_fitness_raises_a_clear_error():
with pytest.raises(ValueError, match="no fitness value is finite"):
_probabilities([-numpy.inf, numpy.nan, -numpy.inf, numpy.nan])


@pytest.mark.parametrize("selection", ["rws", "sus"])
def test_a_run_with_the_most_negative_float_completes(selection):
# Every solution whose first gene is above 5 is invalid and gets the
# most negative float, the way libraries mark rejected solutions.
def fitness(ga, solution, solution_idx):
if solution[0] > 5:
return -FLOAT_MAX
return float(solution[0])

ga = pygad.GA(num_generations=5,
num_parents_mating=4,
fitness_func=fitness,
sol_per_pop=10,
num_genes=2,
init_range_low=0,
init_range_high=10,
parent_selection_type=selection,
random_seed=4,
suppress_warnings=True)
ga.run()
assert ga.generations_completed == 5
Loading