diff --git a/docs/source/releases.md b/docs/source/releases.md index 3884fbe..6b6e310 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -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. diff --git a/docs/source/utils.md b/docs/source/utils.md index 42c95df..6ade59c 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -450,6 +450,8 @@ 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`. + @@ -457,6 +459,8 @@ Selects the parents using the roulette wheel selection technique. 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()` diff --git a/pygad/_version.py b/pygad/_version.py index fcd7ddb..3e9a639 100644 --- a/pygad/_version.py +++ b/pygad/_version.py @@ -1 +1 @@ -__version__ = "3.9.0" +__version__ = "3.9.1" diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 31eb4ad..713e7d6 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.10" +__version__ = "1.5.11" diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 94b1daf..d69a231 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -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) @@ -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 @@ -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) diff --git a/tests/test_probability_selection_negative_fitness.py b/tests/test_probability_selection_negative_fitness.py new file mode 100644 index 0000000..9b51e60 --- /dev/null +++ b/tests/test_probability_selection_negative_fitness.py @@ -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