From d12725f3f79ce573a042cad6cff27fd23b848e5b Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Sun, 11 Oct 2026 11:29:53 -0400 Subject: [PATCH 01/12] Clip mutated values to NumPy integer limits Fixes #379. --- docs/source/gene_values.md | 1 + docs/source/pygad.md | 2 + docs/source/releases.md | 2 +- pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 20 +++- tests/test_mutation_integer_limits.py | 143 ++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/test_mutation_integer_limits.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 342d898..8a2c88e 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -633,6 +633,7 @@ Let us look at some examples. PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. - Integer conversion truncates the fractional part towards zero. For example, `1.9` becomes `1` and `-1.9` becomes `-1`. Additive mutation adds the random value before converting the result. +- Random and adaptive mutation clip generated values to the limits of a NumPy integer gene type before casting. This applies to both additive and replacement mutation, including random candidates from `None` gene-space entries, constraint checks, and duplicate repair. For example, a `numpy.uint8` gene at `0` with an offset of `-1` stays at `0`, and a `numpy.int8` gene at `127` with an offset of `1` stays at `127`. Values within the type's limits keep the same conversion behavior. Python `int`, floating-point, and `object` genes keep their existing behavior. - Floating-point precision rounds before casting to the requested type. This avoids rounding in a narrow type such as `numpy.float16` before the final conversion. - Precision must be an integer or `None`. `None` keeps the value unrounded, `0` rounds to whole numbers, and a negative precision rounds to positions before the decimal point. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. - Rounding uses NumPy's nearest-even rule at halfway points: `0.5` rounds to `0.0` and `1.5` rounds to `2.0` with precision `0`. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 66d0927..40768fe 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -381,6 +381,8 @@ A bool that controls how `random` and `adaptive` mutation change a gene when dra - `True`: replace the gene with the randomly generated value. - `False` (default): add the random value to the gene. +For NumPy integer gene types, both modes clip the resulting value to the type's limits before casting. For example, a `numpy.uint8` gene at `0` stays at `0` when adding `-1`. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). + Supported in [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) and higher. See the [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) release notes for an example. diff --git a/docs/source/releases.md b/docs/source/releases.md index 962b371..3ae3a16 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,7 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -No changes yet. +1. Random and adaptive mutation clip generated values to NumPy integer type limits before casting, fixing overflow errors and wraparound with types such as `numpy.uint8` and `numpy.int8`. The same rule covers additive and replacement mutation, `None` gene-space entries, constraint candidates, and duplicate repair. Python `int`, floating-point, and `object` genes retain their existing behavior. See [issue #379](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/379) and [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). The `pygad.helper` submodule version is `1.4.5`. ## PyGAD 3.8.0 diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index 2fdad67..43c44ec 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.4" +__version__ = "1.4.5" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 5adad37..2cd0bb9 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -397,12 +397,13 @@ def mutation_change_gene_dtype_and_round(self, Apply a random mutation value to a gene and cast / round the result. If ``mutation_by_replacement`` is True, the random value replaces the gene; otherwise it is added to the - existing value. + existing value. NumPy integer results are clipped to the + destination type's limits before casting. Parameters ---------- - random_value : numeric - The freshly drawn mutation value. + random_value : numeric or numpy.ndarray + The freshly drawn mutation value or array of candidates. gene_index : int Index of the gene being mutated. gene_value : numeric @@ -414,8 +415,9 @@ def mutation_change_gene_dtype_and_round(self, Returns ------- - gene_value_new : numeric - The mutated value after casting and rounding. + gene_value_new : numeric or numpy.ndarray + The mutated value or candidates after clipping NumPy + integer values, casting, and rounding. """ if mutation_by_replacement: @@ -431,6 +433,14 @@ def mutation_change_gene_dtype_and_round(self, mutated_value = gene_value + random_value else: mutated_value = gene_value + numpy.asarray(random_value, dtype=object) + gene_dtype = self.get_gene_dtype(gene_index)[0] + if gene_dtype is not int and numpy.issubdtype(numpy.dtype(gene_dtype), numpy.integer): + type_limits = numpy.iinfo(gene_dtype) + # Object storage and Python bounds keep 64-bit integers exact. + # Clip the final sum or replacement, so an unsigned gene can + # still use negative offsets without wrapping or overflowing. + mutated_value = numpy.clip(numpy.asarray(mutated_value, dtype=object), + int(type_limits.min), int(type_limits.max)) return self.change_gene_dtype_and_round(gene_index, mutated_value) def validate_gene_constraint_callable_output(self, diff --git a/tests/test_mutation_integer_limits.py b/tests/test_mutation_integer_limits.py new file mode 100644 index 0000000..3375707 --- /dev/null +++ b/tests/test_mutation_integer_limits.py @@ -0,0 +1,143 @@ +"""Random mutation respects NumPy integer limits before converting candidates.""" + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(int(value) for value in solution)) + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + gene_type=numpy.uint8, init_range_low=0, init_range_high=3, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +integer_types = [numpy.int8, numpy.int16, numpy.int32, numpy.int64, + numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64] + + +@pytest.mark.parametrize("gene_type", integer_types) +@pytest.mark.parametrize("mutation_by_replacement", [False, True]) +def test_mutation_clamps_scalar_and_array_values_at_both_integer_limits(gene_type, mutation_by_replacement): + ga_instance = make_ga(gene_type=gene_type) + type_limits = numpy.iinfo(gene_type) + for boundary, offset in [(int(type_limits.min), -1), (int(type_limits.max), 1)]: + random_value = boundary + offset if mutation_by_replacement else offset + converted = ga_instance.mutation_change_gene_dtype_and_round( + random_value, 0, gene_type(boundary), mutation_by_replacement) + assert type(converted) is gene_type + assert int(converted) == boundary + + random_values = numpy.array([[random_value, boundary if mutation_by_replacement else 0]], dtype=object) + original_values = random_values.copy() + converted = ga_instance.mutation_change_gene_dtype_and_round( + random_values, 0, gene_type(boundary), mutation_by_replacement) + assert converted.dtype == numpy.dtype(gene_type) + numpy.testing.assert_array_equal(converted, [[boundary, boundary]]) + numpy.testing.assert_array_equal(random_values, original_values) + + +@pytest.mark.parametrize("random_values", [numpy.array(-1), numpy.array([-1, 0], dtype=object), + numpy.empty((0, 2), dtype=object)]) +def test_unsigned_mutation_preserves_candidate_shape(random_values): + ga_instance = make_ga() + converted = ga_instance.mutation_change_gene_dtype_and_round( + random_values, 0, numpy.uint8(0), False) + assert numpy.shape(converted) == random_values.shape + numpy.testing.assert_array_equal(converted, numpy.zeros(random_values.shape, dtype=numpy.uint8)) + + +@pytest.mark.parametrize("gene_type", integer_types) +def test_fractional_offsets_are_added_before_integer_truncation(gene_type): + ga_instance = make_ga(gene_type=gene_type) + converted = ga_instance.mutation_change_gene_dtype_and_round( + [-0.2, 0.2, 1.9], 0, gene_type(1), False) + numpy.testing.assert_array_equal(converted, [0, 1, 2]) + + +@pytest.mark.parametrize("gene_type", [int, float, object, numpy.float32]) +@pytest.mark.parametrize("mutation_by_replacement", [False, True]) +def test_other_gene_types_keep_values_outside_narrow_integer_limits(gene_type, mutation_by_replacement): + ga_instance = make_ga(gene_type=gene_type) + random_values = [-2**20, 2**20] + converted = ga_instance.mutation_change_gene_dtype_and_round( + random_values, 0, 0, mutation_by_replacement) + numpy.testing.assert_array_equal(converted, random_values) + + +@pytest.mark.parametrize("gene_type", [int, object]) +def test_python_integer_mutation_keeps_unbounded_scalar_values(gene_type): + ga_instance = make_ga(gene_type=gene_type) + converted = ga_instance.mutation_change_gene_dtype_and_round(1, 0, 2**80, False) + assert converted == 2**80 + 1 + + +@pytest.mark.parametrize("random_seed", range(20)) +def test_reported_unsigned_population_runs_with_default_mutation(random_seed): + ga_instance = make_ga(num_generations=30, num_parents_mating=4, + sol_per_pop=10, num_genes=6, random_seed=random_seed) + ga_instance.run() + assert ga_instance.generations_completed == 30 + assert ga_instance.population.dtype == numpy.dtype(numpy.uint8) + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive']) +@pytest.mark.parametrize("use_probability", [False, True]) +@pytest.mark.parametrize("gene_space", [None, [None, None, None], [[None, 1], [None, 1], [None, 1]]]) +@pytest.mark.parametrize("mutation_by_replacement", [False, True]) +def test_mutation_backends_share_integer_limits(mutation_type, use_probability, gene_space, mutation_by_replacement): + parameters = dict(mutation_type=mutation_type, gene_space=gene_space, + mutation_by_replacement=mutation_by_replacement, + gene_type=[numpy.uint8, numpy.int8, numpy.uint64], + initial_population=[[0, 127, 2**64 - 1]] * 4, + random_mutation_min_val=[-2, 1, 1], + random_mutation_max_val=[-1, 2, 2], + crossover_type=None, keep_elitism=0, keep_parents=0) + if use_probability: + parameters['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + else: + parameters['mutation_num_genes'] = [3, 3] if mutation_type == 'adaptive' else 3 + ga_instance = make_ga(**parameters) + ga_instance.run() + for solution in ga_instance.population: + for gene_index, gene_type in enumerate([numpy.uint8, numpy.int8, numpy.uint64]): + assert type(solution[gene_index]) is gene_type + assert int(solution[0]) in [0, 1] + if gene_space is None: + numpy.testing.assert_array_equal(solution, [0, 1, 1] if mutation_by_replacement else [0, 127, 2**64 - 1]) + + +def test_constraints_receive_clamped_mutation_candidates(): + candidate_batches = [] + + def gene_constraint(solution, values): + candidate_batches.append(values.copy()) + assert all(type(value) is numpy.uint8 and int(value) == 0 for value in values) + return values + + ga_instance = make_ga(initial_population=[[0, 0, 0]] * 4, + gene_constraint=[gene_constraint] * 3, + random_mutation_min_val=-2, random_mutation_max_val=-1, + mutation_num_genes=3, keep_elitism=0, keep_parents=0) + ga_instance.run() + assert candidate_batches + numpy.testing.assert_array_equal(ga_instance.population, 0) + + +@pytest.mark.parametrize("mutation_by_replacement", [False, True]) +def test_duplicate_repair_uses_representable_mutation_candidates(mutation_by_replacement): + ga_instance = make_ga(allow_duplicate_genes=False) + solution, duplicate_indices, num_unsolved_duplicates = ga_instance.solve_duplicate_genes_randomly( + numpy.array([0, 0, 255], dtype=numpy.uint8), -2, 2, + mutation_by_replacement=mutation_by_replacement, gene_type=ga_instance.gene_type) + assert not duplicate_indices + assert num_unsolved_duplicates == 0 + assert solution.dtype == numpy.dtype(numpy.uint8) + numpy.testing.assert_array_equal(numpy.sort(solution), [0, 1, 255]) From abd409e9aef1440f13890c834797a795f4875374 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Sun, 11 Oct 2026 11:53:31 -0400 Subject: [PATCH 02/12] Use consistent 64-bit storage for Python int genes Refs #379. --- .github/workflows/main.yml | 45 ++++++++ docs/source/gene_values.md | 2 + docs/source/pygad.md | 2 +- docs/source/releases.md | 1 + pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 31 ++++-- pygad/utils/__init__.py | 2 +- pygad/utils/crossover.py | 25 +---- pygad/utils/parent_selection.py | 10 +- tests/test_nsga3_population_growth.py | 2 +- tests/test_python_int_storage.py | 149 ++++++++++++++++++++++++++ 11 files changed, 229 insertions(+), 42 deletions(-) create mode 100644 tests/test_python_int_storage.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 034a875..8326b74 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,6 +44,51 @@ on: workflow_call: jobs: + numpy-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.9" + numpy-requirement: "numpy==1.24.4" + numpy-label: "NumPy 1.24.4" + - python-version: "3.14" + numpy-requirement: "numpy" + numpy-label: "Latest NumPy" + name: Windows / Python ${{ matrix.python-version }} / ${{ matrix.numpy-label }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install PyGAD and NumPy + run: python -m pip install . pytest "${{ matrix.numpy-requirement }}" + + - name: Verify Installed Versions + working-directory: ${{ runner.temp }} + run: python -c "import numpy, pygad; print('NumPy:', numpy.__version__); print('Testing installed package:', pygad.__file__)" + + # Windows used 32-bit storage for dtype=int before NumPy 2.0. + # Run the same regression tests on older and current NumPy releases. + - name: Test Gene Storage and Integer Mutation + working-directory: ${{ runner.temp }} + run: >- + python -m pytest + "${{ github.workspace }}/tests/test_python_int_storage.py" + "${{ github.workspace }}/tests/test_gene_type_conversion.py" + "${{ github.workspace }}/tests/test_initial_population.py" + "${{ github.workspace }}/tests/test_mutation_integer_limits.py" + "${{ github.workspace }}/tests/test_nsga3_population_growth.py" + "${{ github.workspace }}/tests/test_sbx_polynomial.py" + "${{ github.workspace }}/tests/test_parent_selection_regressions.py" + "${{ github.workspace }}/tests/test_permutation_mutation.py" + -q -W error::DeprecationWarning + pytest: # Use the latest Ubuntu runner for all jobs. runs-on: ubuntu-latest diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 8a2c88e..0f9dc94 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -638,6 +638,8 @@ PyGAD applies the same conversion rules to generated and supplied initial popula - Precision must be an integer or `None`. `None` keeps the value unrounded, `0` rounds to whole numbers, and a negative precision rounds to positions before the decimal point. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. - Rounding uses NumPy's nearest-even rule at halfway points: `0.5` rounds to `0.0` and `1.5` rounds to `2.0` with precision `0`. +Python `int` conversion of candidate arrays and population columns uses explicit signed 64-bit storage (`numpy.int64`), with values from `-2**63` through `2**63 - 1`. Initialization ranges and bounded operators use these same limits. This avoids the 32-bit default used by NumPy 1.x on Windows. A population with one shared Python `int` type has `dtype=numpy.int64`; per-gene type specifications still use object arrays with Python `int` elements in those columns. Scalar conversion still returns a Python `int`, and explicitly requested NumPy types keep their own widths and limits. Supplied Python integers outside the signed 64-bit range raise `OverflowError` during array conversion; use `gene_type=object` to retain larger Python integers. + Floating-point types use binary representations, so a stored value can differ slightly from its decimal representation. Requested precision does not increase the accuracy or range of the selected type. For unusually large values or precisions where NumPy's intermediate decimal scaling overflows, PyGAD uses scalar rounding to preserve finite values before the final cast. When types are specified per gene, population arrays use `dtype=object` so each column can retain its requested Python or NumPy scalar type. Saved best solutions retain these types too. This also preserves large integers when other genes are floating-point values. A NumPy array constructed without `dtype=object` can already lose integer precision through conversion to a shared floating-point type; use a list or an object array for mixed input values that must remain exact. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 40768fe..f1573ef 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -136,7 +136,7 @@ You can set it to: Integer conversion truncates towards zero. Floating-point values are rounded before casting, using nearest-even rounding at halfway points. Precision can be `None` to leave values unrounded, or an integer including `0` and negative values. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. -The same rules apply to initialization, mutation, custom operators, and the outputs of `on_parents`, `on_crossover`, and `on_mutation`, independently of `allow_duplicate_genes`. Per-gene type specifications and saved best solutions preserve mixed scalar types using object arrays. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). +The same rules apply to initialization, mutation, custom operators, and the outputs of `on_parents`, `on_crossover`, and `on_mutation`, independently of `allow_duplicate_genes`. Python `int` uses signed 64-bit conversion for candidate arrays and population columns on every platform, including Windows with NumPy 1.x. Explicit NumPy types keep their requested widths. Per-gene type specifications and saved best solutions preserve mixed scalar types using object arrays. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules) for storage limits and handling larger Python integers. Version history: diff --git a/docs/source/releases.md b/docs/source/releases.md index 3ae3a16..bf81487 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -7,6 +7,7 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased 1. Random and adaptive mutation clip generated values to NumPy integer type limits before casting, fixing overflow errors and wraparound with types such as `numpy.uint8` and `numpy.int8`. The same rule covers additive and replacement mutation, `None` gene-space entries, constraint candidates, and duplicate repair. Python `int`, floating-point, and `object` genes retain their existing behavior. See [issue #379](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/379) and [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). The `pygad.helper` submodule version is `1.4.5`. +2. Python `int` conversion of candidate arrays and population columns uses explicit signed 64-bit storage across NumPy versions and platforms, fixing large-integer conversion and initialization failures on Windows with NumPy 1.x. Integer range sampling and bounded operators use the same storage limits. Parent selection and crossover share array allocation, preserving large integers throughout the lifecycle. Per-gene Python `int` elements keep their scalar type, and explicitly requested NumPy types keep their widths. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). The `pygad.helper` and `pygad.utils` submodule versions are `1.4.6` and `1.5.8`. ## PyGAD 3.8.0 diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index 43c44ec..90f0727 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.5" +__version__ = "1.4.6" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 2cd0bb9..c32c313 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -267,8 +267,9 @@ def print_params_summary(): def initialize_parents_array(self, shape): """ Allocate an empty parents (or offspring) array with the right - dtype. Uses the dtype of the first gene type when every gene - shares the same type, otherwise falls back to ``object``. + dtype. Uses the gene's storage dtype when every gene shares + the same type, otherwise falls back to ``object``. Python + ``int`` uses signed 64-bit array storage. Parameters ---------- @@ -282,10 +283,20 @@ def initialize_parents_array(self, shape): An uninitialised array of the requested shape and dtype. """ if self.gene_type_single: - return numpy.empty(shape, dtype=self.gene_type[0]) + return numpy.empty(shape, dtype=self._get_gene_storage_dtype(self.gene_type[0])) else: return numpy.empty(shape, dtype=object) + def _get_gene_storage_dtype(self, gene_dtype): + """Return the array storage type for a declared gene type. + + Python int uses explicit signed 64-bit storage so array conversion, + allocation, and range sampling agree across NumPy versions and + platforms. Explicit NumPy types keep their requested widths. This + does not change scalar conversion or the declared gene_type. + """ + return numpy.int64 if gene_dtype is int else gene_dtype + def change_population_dtype_and_round(self, population): """ @@ -359,7 +370,7 @@ def _convert_gene_values(self, values, gene_type): if precision is None: if numpy.isscalar(values): return values if dtype is object else dtype(values) - converted_values = numpy.array(values, dtype=dtype, copy=True) + converted_values = numpy.array(values, dtype=self._get_gene_storage_dtype(dtype), copy=True) else: rounded_values = self._round_gene_values(numpy.asarray(values, dtype=float), precision) converted_values = numpy.asarray(rounded_values, dtype=dtype) @@ -656,7 +667,7 @@ def convert_bounded_operator_gene_value(self, gene_index, value): dtype = self.get_gene_dtype(gene_index)[0] if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): value = value.item() if isinstance(value, numpy.generic) else value - type_limits = numpy.iinfo(dtype) + type_limits = numpy.iinfo(self._get_gene_storage_dtype(dtype)) first = max(math.ceil(lower), int(type_limits.min)) last = min(math.floor(upper), int(type_limits.max)) if first > last: @@ -890,15 +901,15 @@ def _initial_population_range_values(self, gene_index, lower, upper, num_values, if the gene type and precision cannot represent any valid value. """ lower, upper = sorted([lower, upper]) - dtype = self.get_gene_dtype(gene_index)[0] + dtype = self._get_gene_storage_dtype(self.get_gene_dtype(gene_index)[0]) if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) if first_value > last_value: raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type for the gene at index {gene_index}.") if all_integer_values: return numpy.arange(first_value, last_value + 1, dtype=dtype) - # RandomState interprets the Python int type as C long on - # Windows. Use NumPy's resolved dtype to match the population. + # Pass the explicit storage type to RandomState so its integer + # draws use the same width as conversion and allocation. return self.numpy_random_generator.randint(first_value, last_value + 1, size=num_values, dtype=numpy.dtype(dtype).type) @@ -912,7 +923,7 @@ def _initial_population_integer_bounds(self, gene_index, lower, upper): lower = lower.item() if isinstance(lower, numpy.generic) else lower upper = upper.item() if isinstance(upper, numpy.generic) else upper lower, upper = sorted([lower, upper]) - type_limits = numpy.iinfo(self.get_gene_dtype(gene_index)[0]) + type_limits = numpy.iinfo(self._get_gene_storage_dtype(self.get_gene_dtype(gene_index)[0])) first_value = max(math.ceil(lower), int(type_limits.min)) last_value = min(math.ceil(upper) - 1, int(type_limits.max)) if lower == upper and lower == first_value: @@ -921,7 +932,7 @@ def _initial_population_integer_bounds(self, gene_index, lower, upper): def _initial_population_range_snapshot(self, gene_index, lower, upper, sample_size): """Create an inspection sample without allocating a range or drawing random values.""" - dtype = self.get_gene_dtype(gene_index)[0] + dtype = self._get_gene_storage_dtype(self.get_gene_dtype(gene_index)[0]) if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) count = min(sample_size, last_value - first_value + 1) diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 67dd220..d919162 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.7" +__version__ = "1.5.8" diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index 9c7d979..6ac0365 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -31,10 +31,7 @@ def single_point_crossover(self, parents, offspring_size): ``offspring_size``. """ - if self.gene_type_single == True: - offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) - else: - offspring = numpy.empty(offspring_size, dtype=object) + offspring = self.initialize_parents_array(offspring_size) # Randomly generate all the K points at which crossover takes place between each two parents. The point does not have to be always at the center of the solutions. # This saves time by calling the self.numpy_random_generator.randint() function only once. @@ -96,10 +93,7 @@ def two_points_crossover(self, parents, offspring_size): ``offspring_size``. """ - if self.gene_type_single == True: - offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) - else: - offspring = numpy.empty(offspring_size, dtype=object) + offspring = self.initialize_parents_array(offspring_size) # Randomly generate all the K pairs of points at which crossover takes place between each two parents. # This saves time by calling the self.numpy_random_generator.randint() function only twice. @@ -172,10 +166,7 @@ def uniform_crossover(self, parents, offspring_size): ``offspring_size``. """ - if self.gene_type_single == True: - offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) - else: - offspring = numpy.empty(offspring_size, dtype=object) + offspring = self.initialize_parents_array(offspring_size) # Randomly generate all the genes sources at which crossover takes place between each two parents. # This saves time by calling the self.numpy_random_generator.randint() function only once. @@ -242,10 +233,7 @@ def sbx_crossover(self, parents, offspring_size): offspring : numpy.ndarray A 2D array of offspring with shape offspring_size. """ - if self.gene_type_single == True: - offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) - else: - offspring = numpy.empty(offspring_size, dtype=object) + offspring = self.initialize_parents_array(offspring_size) eta = float(self.sbx_crossover_eta) near_zero = 1e-14 @@ -327,10 +315,7 @@ def scattered_crossover(self, parents, offspring_size): ``offspring_size``. """ - if self.gene_type_single == True: - offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) - else: - offspring = numpy.empty(offspring_size, dtype=object) + offspring = self.initialize_parents_array(offspring_size) # Randomly generate all the genes sources at which crossover takes place between each two parents. # This saves time by calling the self.numpy_random_generator.randint() function only once. diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 3d7174d..6237fdf 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -392,10 +392,7 @@ def tournament_selection_nsga2(self, is single-objective). """ - if self.gene_type_single == True: - parents = numpy.empty((num_parents, self.population.shape[1]), dtype=self.gene_type[0]) - else: - parents = numpy.empty((num_parents, self.population.shape[1]), dtype=object) + parents = self.initialize_parents_array((num_parents, self.population.shape[1])) # Verify that the problem is multi-objective optimization as the tournament NSGA-II selection is only applied to multi-objective problems. if type(fitness[0]) in [list, tuple, numpy.ndarray]: @@ -542,10 +539,7 @@ def nsga2_selection(self, is single-objective). """ - if self.gene_type_single == True: - parents = numpy.empty((num_parents, self.population.shape[1]), dtype=self.gene_type[0]) - else: - parents = numpy.empty((num_parents, self.population.shape[1]), dtype=object) + parents = self.initialize_parents_array((num_parents, self.population.shape[1])) # Verify that the problem is multi-objective optimization as the NSGA-II selection is only applied to multi-objective problems. if type(fitness[0]) in [list, tuple, numpy.ndarray]: diff --git a/tests/test_nsga3_population_growth.py b/tests/test_nsga3_population_growth.py index 91b8a47..467240f 100644 --- a/tests/test_nsga3_population_growth.py +++ b/tests/test_nsga3_population_growth.py @@ -85,7 +85,7 @@ def test_population_growth_respects_single_gene_type_int(): gene_type=int, ) population = numpy.asarray(ga.initial_population) - assert population.dtype == int + assert population.dtype == numpy.dtype(numpy.int64) def test_population_growth_respects_nested_gene_types_per_gene(): diff --git a/tests/test_python_int_storage.py b/tests/test_python_int_storage.py new file mode 100644 index 0000000..0c8a9f6 --- /dev/null +++ b/tests/test_python_int_storage.py @@ -0,0 +1,149 @@ +"""Python int array storage is consistent across NumPy versions and platforms.""" + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(int(value) for value in solution)) + + +def multi_objective_fitness_func(ga_instance, solution, solution_index): + return [float(solution[0]), float(solution[1])] + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + gene_type=int, mutation_type=None, crossover_type=None, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("gene_type", [int, [int, None]]) +def test_python_integer_conversion_uses_int64_arrays_and_keeps_scalar_type(gene_type): + ga_instance = make_ga(gene_type=gene_type) + values = numpy.array([[-2**63, -(2**53 + 1), 2**53 + 1, 2**63 - 1]], dtype=object) + converted = ga_instance.change_gene_dtype_and_round(0, values) + assert converted.dtype == numpy.dtype(numpy.int64) + numpy.testing.assert_array_equal(converted, values) + assert not numpy.shares_memory(converted, values) + for value in values.flat: + scalar = ga_instance.change_gene_dtype_and_round(0, value) + assert type(scalar) is int + assert scalar == value + assert ga_instance.change_gene_dtype_and_round(0, numpy.empty((0, 2))).shape == (0, 2) + + +@pytest.mark.parametrize("gene_space", [None, [None, None, None], [[None], [None], [None]], + {'low': 2**53 + 1, 'high': 2**53 + 4}, + {'low': 2**53 + 1, 'high': 2**53 + 4, 'step': 1}, + range(2**53 + 1, 2**53 + 4), + [2**53 + 1, 2**53 + 3], [None, 2**53 + 1]]) +def test_generated_python_integer_populations_and_candidates_keep_large_values(gene_space): + ga_instance = make_ga(gene_space=gene_space, init_range_low=2**53 + 1, + init_range_high=2**53 + 4) + assert ga_instance.population.dtype == numpy.dtype(numpy.int64) + assert all(2**53 + 1 <= int(value) < 2**53 + 4 for value in ga_instance.population.flat) + candidates = ga_instance.get_initial_population_gene_candidates(0, 3) + assert candidates.dtype == numpy.dtype(numpy.int64) + assert all(2**53 + 1 <= int(value) < 2**53 + 4 for value in candidates) + + +@pytest.mark.parametrize("lower,upper", [(-2**63, -2**63 + 3), (2**63 - 3, 2**63), + (2**63 - 1, 2**63 - 1)]) +def test_python_integer_initialization_uses_signed_64_bit_bounds(lower, upper): + ga_instance = make_ga(init_range_low=lower, init_range_high=upper) + expected = [lower] if lower == upper else list(range(lower, upper)) + candidates = ga_instance.get_initial_population_gene_candidates(0, 3) + numpy.testing.assert_array_equal(candidates, expected) + assert all(int(value) in expected for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("gene_type", [numpy.int8, numpy.int16, numpy.int32, numpy.int64, + numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64]) +def test_explicit_numpy_integer_types_keep_their_storage_width(gene_type): + ga_instance = make_ga(gene_type=gene_type, init_range_low=0, init_range_high=4) + assert ga_instance.population.dtype == numpy.dtype(gene_type) + assert ga_instance.initialize_parents_array((2, 3)).dtype == numpy.dtype(gene_type) + assert ga_instance.change_gene_dtype_and_round(0, [1, 2]).dtype == numpy.dtype(gene_type) + assert type(ga_instance.change_gene_dtype_and_round(0, 1)) is gene_type + + +@pytest.mark.parametrize("value", [-2**63 - 1, 2**63]) +@pytest.mark.parametrize("gene_type", [int, [int, float, numpy.int32]]) +def test_population_values_outside_python_integer_array_limits_raise(gene_type, value): + # Supplied values are converted, not clipped to a different integer. + with pytest.raises(OverflowError): + make_ga(gene_type=gene_type, initial_population=[[value, 1, 2]] * 4) + + +def test_object_storage_keeps_integers_beyond_int64_without_losing_precision(): + values = [[2**80 + 1, -(2**80 + 1), 0]] * 4 + ga_instance = make_ga(gene_type=object, initial_population=values) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, values) + assert all(type(value) is int for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("value,expected", [(2**53, 2**53 + 1), (2**53 + 2, 2**53 + 2), + (2**63, 2**53 + 4)]) +def test_bounded_operators_use_python_integer_storage_limits(value, expected): + ga_instance = make_ga(init_range_low=2**53 + 1, init_range_high=2**53 + 4) + converted = ga_instance.convert_bounded_operator_gene_value(0, value) + assert type(converted) is int + assert converted == expected + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive']) +@pytest.mark.parametrize("use_probability", [False, True]) +def test_random_mutation_keeps_large_python_integer_addition_exact(mutation_type, use_probability): + value = 2**53 + 1 + parameters = dict(initial_population=[[value] * 3] * 4, + mutation_type=mutation_type, random_mutation_min_val=1, + random_mutation_max_val=2, keep_elitism=0, keep_parents=0) + if use_probability: + parameters['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + else: + parameters['mutation_num_genes'] = [3, 3] if mutation_type == 'adaptive' else 3 + ga_instance = make_ga(**parameters) + ga_instance.run() + assert ga_instance.population.dtype == numpy.dtype(numpy.int64) + numpy.testing.assert_array_equal(ga_instance.population, [[value + 2] * 3] * 4) + + +@pytest.mark.parametrize("parent_selection_type", ['sss', 'rws', 'sus', 'rank', 'random', + 'tournament', 'nsga2', 'tournament_nsga2', 'nsga3']) +@pytest.mark.parametrize("crossover_type", ['single_point', 'two_points', 'uniform', 'scattered', 'sbx']) +def test_parent_selection_and_crossover_keep_python_integer_storage(parent_selection_type, crossover_type): + allowed_values = [2**53 + 1, 2**53 + 3] + population = [[allowed_values[0], allowed_values[1], allowed_values[0]], + [allowed_values[1], allowed_values[0], allowed_values[1]]] * 2 + ga_instance = make_ga(initial_population=population, gene_space=allowed_values, + fitness_func=multi_objective_fitness_func, + parent_selection_type=parent_selection_type, + crossover_type=crossover_type, + # SBX uses real arithmetic; zero probability checks + # its allocation without changing the parent values. + crossover_probability=0.0 if crossover_type == 'sbx' else 1.0, + nsga3_num_divisions=1, keep_elitism=0, keep_parents=0, + save_best_solutions=True) + ga_instance.run() + for values in [ga_instance.population, ga_instance.last_generation_parents, + ga_instance.last_generation_offspring_crossover, ga_instance.best_solutions]: + assert values.dtype == numpy.dtype(numpy.int64) + assert set(values.flat).issubset(allowed_values) + + +def test_nsga3_population_growth_keeps_large_python_integers(): + ga_instance = make_ga(initial_population=[[2**53 + 1] * 3] * 2, + init_range_low=2**53 + 1, init_range_high=2**53 + 4, + parent_selection_type='nsga3', fitness_func=multi_objective_fitness_func, + nsga3_num_divisions=4) + ga_instance.run() + assert ga_instance.population.shape[0] == 5 + assert ga_instance.population.dtype == numpy.dtype(numpy.int64) + assert all(2**53 + 1 <= int(value) < 2**53 + 4 for value in ga_instance.population.flat) From ce68b72c6f215d3c1e6db2a885c63c223038692b Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Sun, 11 Oct 2026 12:16:54 -0400 Subject: [PATCH 03/12] Prepare PyGAD 3.8.1 Refs #379. --- README.md | 2 +- docs/source/index.md | 6 ++++-- docs/source/releases.md | 11 +++++++++-- pygad/_version.py | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a2275db..8aa7897 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The library is under active development and more features are added regularly. I # Installation -The current release is [PyGAD 3.8.0](https://github.com/ahmedfgad/GeneticAlgorithmPython/releases/tag/3.8.0), dated October 9, 2026. Read the [release notes](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/3.8.0/docs/source/releases.md#pygad-380) for its new features, fixes, and compatibility changes. PyGAD requires Python 3.8 or newer. +The current release is [PyGAD 3.8.1](https://github.com/ahmedfgad/GeneticAlgorithmPython/releases/tag/3.8.1), dated October 11, 2026. Read the [release notes](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/3.8.1/docs/source/releases.md#pygad-381) for its fixes and compatibility changes. PyGAD requires Python 3.8 or newer. Watch the [PyGAD 3.8.0 release video on YouTube](https://youtu.be/8pdIiMAMLUM). diff --git a/docs/source/index.md b/docs/source/index.md index 44b753b..a02b22e 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -2,8 +2,10 @@ [PyGAD](https://github.com/ahmedfgad/GeneticAlgorithmPython) is an open-source Python library for building the genetic algorithm and optimizing machine learning algorithms. It works with [Keras](https://keras.io) and [PyTorch](https://pytorch.org). -Watch the [PyGAD 3.8.0 release video on YouTube](https://youtu.be/8pdIiMAMLUM) -and read the [release notes](releases.md#pygad-380). +The current release is PyGAD 3.8.1, dated October 11, 2026. +Read the [release notes](releases.md#pygad-381). + +Watch the [PyGAD 3.8.0 release video on YouTube](https://youtu.be/8pdIiMAMLUM). ![PYGAD-LOGO](images/101267295-c74c0180-375f-11eb-9ad0-f8e37bd796ce.png) diff --git a/docs/source/releases.md b/docs/source/releases.md index bf81487..72ed469 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,8 +6,15 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -1. Random and adaptive mutation clip generated values to NumPy integer type limits before casting, fixing overflow errors and wraparound with types such as `numpy.uint8` and `numpy.int8`. The same rule covers additive and replacement mutation, `None` gene-space entries, constraint candidates, and duplicate repair. Python `int`, floating-point, and `object` genes retain their existing behavior. See [issue #379](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/379) and [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). The `pygad.helper` submodule version is `1.4.5`. -2. Python `int` conversion of candidate arrays and population columns uses explicit signed 64-bit storage across NumPy versions and platforms, fixing large-integer conversion and initialization failures on Windows with NumPy 1.x. Integer range sampling and bounded operators use the same storage limits. Parent selection and crossover share array allocation, preserving large integers throughout the lifecycle. Per-gene Python `int` elements keep their scalar type, and explicitly requested NumPy types keep their widths. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). The `pygad.helper` and `pygad.utils` submodule versions are `1.4.6` and `1.5.8`. +No changes yet. + +## PyGAD 3.8.1 + +Release Date: October 11, 2026. + +1. Random and adaptive mutation clip generated values to NumPy integer type limits before casting, fixing overflow errors and wraparound with types such as `numpy.uint8` and `numpy.int8`. The same rule covers additive and replacement mutation, `None` gene-space entries, constraint candidates, and duplicate repair. Clipping applies only to explicitly requested NumPy integer types. See [issue #379](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/379) and [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). +2. Python `int` conversion of candidate arrays and population columns uses explicit signed 64-bit storage across NumPy versions and platforms, fixing large-integer conversion and initialization failures on Windows with NumPy 1.x. Integer range sampling and bounded operators use the same storage limits. Parent selection and crossover share array allocation, preserving large integers throughout the lifecycle. Per-gene Python `int` elements keep their scalar type, and explicitly requested NumPy types keep their widths. Supplied Python `int` population values must fit in signed 64-bit storage; use `gene_type=object` for larger supplied integers. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). +3. Regression tests cover integer mutation limits, exact large-integer storage, initialization, constraints, duplicate repair, parent selection, crossover, and NSGA-III population growth. Windows CI checks both NumPy 1.24.4 and the latest NumPy release without version-specific implementation branches. The 16 pre-existing Windows failures with NumPy 1.24.4 are fixed, and no higher NumPy minimum is required. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.6` and `1.5.8`. ## PyGAD 3.8.0 diff --git a/pygad/_version.py b/pygad/_version.py index 32a7819..e4e78c0 100644 --- a/pygad/_version.py +++ b/pygad/_version.py @@ -1 +1 @@ -__version__ = "3.8.0" +__version__ = "3.8.1" From df199b64910800fe16c33c4735c601325b0b6d28 Mon Sep 17 00:00:00 2001 From: ktz03 <2484593937@qq.com> Date: Thu, 15 Oct 2026 17:31:11 +0800 Subject: [PATCH 04/12] Avoid overflow when summing probability-selection fitness --- pygad/utils/parent_selection.py | 8 +-- tests/test_parent_selection_regressions.py | 62 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 6237fdf..94b1daf 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -204,13 +204,13 @@ def roulette_wheel_selection(self, fitness, num_parents): if len(fitness.shape) > 1: # Multi-objective optimization problem. # Sum the fitness values of each solution to reduce the fitness from M-D array to just 1D array. - fitness = numpy.sum(fitness, axis=1) + fitness = numpy.sum(fitness, axis=1, dtype=numpy.result_type(fitness.dtype, float)) else: # Single-objective optimization problem. pass # Reaching this step confirms that fitness is a 1D array. - fitness_sum = numpy.sum(fitness) + 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.") @@ -313,13 +313,13 @@ def stochastic_universal_selection(self, fitness, num_parents): if len(fitness.shape) > 1: # Multi-objective optimization problem. # Sum the fitness values of each solution to reduce the fitness from M-D array to just 1D array. - fitness = numpy.sum(fitness, axis=1) + fitness = numpy.sum(fitness, axis=1, dtype=numpy.result_type(fitness.dtype, float)) else: # Single-objective optimization problem. pass # Reaching this step confirms that fitness is a 1D array. - fitness_sum = numpy.sum(fitness) + 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.") diff --git a/tests/test_parent_selection_regressions.py b/tests/test_parent_selection_regressions.py index 3bffd65..65ebe98 100644 --- a/tests/test_parent_selection_regressions.py +++ b/tests/test_parent_selection_regressions.py @@ -150,3 +150,65 @@ def test_sus_selected_parents_do_not_alias_the_population(): parents[:] = -1 numpy.testing.assert_array_equal(ga_instance.population, original) + + +@pytest.mark.parametrize("selection", ["rws", "sus"]) +@pytest.mark.parametrize("dtype,value", [ + (numpy.int64, 2**62), + (numpy.uint64, 2**63), + (numpy.float16, 40000), + (numpy.float32, 2e38), +]) +@pytest.mark.parametrize("multi_objective", [False, True]) +def test_fitness_sum_does_not_overflow_during_selection( + selection, dtype, value, multi_objective): + def large_fitness(ga_instance, solution, solution_idx): + fitness = dtype(value) + return [fitness, fitness] if multi_objective else fitness + + ga_instance = pygad.GA(num_generations=1, + num_parents_mating=2, + fitness_func=large_fitness, + initial_population=[[1, 2], [3, 4], [5, 6], [7, 8]], + parent_selection_type=selection, + mutation_type=None, + random_seed=17, + suppress_warnings=True) + ga_instance.run() + + assert ga_instance.generations_completed == 1 + assert ga_instance.last_generation_parents_indices.shape == (2,) + numpy.testing.assert_array_equal( + ga_instance.last_generation_parents, + ga_instance.population[ga_instance.last_generation_parents_indices]) + assert numpy.all(ga_instance.last_generation_fitness > 0) + + +@pytest.mark.parametrize("selection", ["rws", "sus"]) +def test_probability_selection_still_rejects_zero_sum_fitness(selection): + ga_instance = pygad.GA(num_generations=1, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=[[1, 2], [3, 4], [5, 6], [7, 8]], + parent_selection_type=selection, + mutation_type=None, + suppress_warnings=True) + with pytest.raises(ZeroDivisionError, match="sum of fitness values is zero"): + ga_instance.select_parents(numpy.zeros(4), 2) + + +@pytest.mark.parametrize("selection", ["rws", "sus"]) +def test_probability_selection_preserves_python_integer_fitness(selection): + ga_instance = pygad.GA(num_generations=1, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=[[1, 2], [3, 4], [5, 6], [7, 8]], + parent_selection_type=selection, + mutation_type=None, + random_seed=17, + suppress_warnings=True) + fitness = numpy.array([2**1500] * 4, dtype=object) + parents, indices = ga_instance.select_parents(fitness, 2) + + assert indices.shape == (2,) + numpy.testing.assert_array_equal(parents, ga_instance.population[indices]) From 70c992da37ea9aae43be765ccf1bf88c95b2100f Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 15 Oct 2026 23:46:16 -0400 Subject: [PATCH 05/12] Bump pygad.utils version for the selection overflow fix --- docs/source/releases.md | 2 +- pygad/utils/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/releases.md b/docs/source/releases.md index 72ed469..c5f8340 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,7 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -No changes yet. +1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381). The `pygad.utils` submodule version is `1.5.9`. ## PyGAD 3.8.1 diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index d919162..70f6cdb 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.8" +__version__ = "1.5.9" From 8693c196dcd77a12de92582b94f36212ce9b80dd Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 15 Oct 2026 23:41:32 -0400 Subject: [PATCH 06/12] Round SBX and polynomial results to the nearest gene value --- docs/source/gene_values.md | 1 + docs/source/releases.md | 2 + docs/source/utils.md | 4 + pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 53 ++++++++- tests/test_bounded_operator_rounding.py | 152 ++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 tests/test_bounded_operator_rounding.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 0f9dc94..6b4f37e 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -633,6 +633,7 @@ Let us look at some examples. PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. - Integer conversion truncates the fractional part towards zero. For example, `1.9` becomes `1` and `-1.9` becomes `-1`. Additive mutation adds the random value before converting the result. +- SBX crossover and polynomial mutation are the exception. Their results are rounded to the nearest integer, so `1.9` becomes `2` and `-1.9` becomes `-2`. For a gene with a finite gene space, the result moves to the nearest allowed value. Truncation would make the highest value of the gene almost impossible to reach and would pull values toward zero. - Random and adaptive mutation clip generated values to the limits of a NumPy integer gene type before casting. This applies to both additive and replacement mutation, including random candidates from `None` gene-space entries, constraint checks, and duplicate repair. For example, a `numpy.uint8` gene at `0` with an offset of `-1` stays at `0`, and a `numpy.int8` gene at `127` with an offset of `1` stays at `127`. Values within the type's limits keep the same conversion behavior. Python `int`, floating-point, and `object` genes keep their existing behavior. - Floating-point precision rounds before casting to the requested type. This avoids rounding in a narrow type such as `numpy.float16` before the final conversion. - Precision must be an integer or `None`. `None` keeps the value unrounded, `0` rounds to whole numbers, and a negative precision rounds to positions before the decimal point. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. diff --git a/docs/source/releases.md b/docs/source/releases.md index c5f8340..2bd7c33 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -7,6 +7,8 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased 1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381). The `pygad.utils` submodule version is `1.5.9`. +2. [SBX crossover](utils.md#sbx_crossover) and [polynomial mutation](utils.md#polynomial_mutationoffspring) round integer results to the nearest integer instead of truncating them. For a gene with a finite gene space, the result moves to the nearest allowed value. Before this change, the highest value of an integer gene was almost never produced, values were pulled toward zero, and SBX could not keep a parent's highest value. Results for integer genes change for the same random seed. Float genes are not affected. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). +3. Regression tests cover reaching both ends of integer and finite gene spaces, balanced steps up and down, negative ranges, and stepped and uneven spaces. The `pygad.helper` submodule version is `1.4.7`. ## PyGAD 3.8.1 diff --git a/docs/source/utils.md b/docs/source/utils.md index 635dedf..cacaf84 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -266,6 +266,8 @@ Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` pa For each crossed gene, SBX produces two possible values on opposite sides of the parents' midpoint. PyGAD selects either value with probability `0.5`, avoiding the downward bias from always selecting the lower child. Equal parent values are copied unchanged. +For an integer gene, the child value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. + ## `pygad.utils.mutation` Submodule The `pygad.utils.mutation` module has a class named `Mutation` with the supported mutation operations: @@ -351,6 +353,8 @@ The count-based and probability-based adaptive mutation methods use the same com Applies polynomial mutation to the passed two-dimensional offspring array in place and returns it. Each gene is selected with `mutation_probability`, or with probability `1 / num_genes` when that parameter is `None`. `polynomial_mutation_eta` controls the size of the change; higher values favor smaller changes. Bounds come from `init_range_low` and `init_range_high` for each gene, and mutated values are clipped to those bounds. Genes whose range has effectively zero width are skipped. When `allow_duplicate_genes=False`, the existing random duplicate-resolution helper is applied after changing a gene. +For an integer gene, the new value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. + ### Mutation Helper Methods The `pygad.utils.mutation` module has some helper methods to assist applying the mutation operation: diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index 90f0727..99e094f 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.6" +__version__ = "1.4.7" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index c32c313..6410622 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -665,6 +665,12 @@ def convert_bounded_operator_gene_value(self, gene_index, value): lower = lower.item() if isinstance(lower, numpy.generic) else lower upper = upper.item() if isinstance(upper, numpy.generic) else upper dtype = self.get_gene_dtype(gene_index)[0] + # When the gene has a finite list of allowed values, move the + # generated decimal straight to the nearest allowed value. Rounding + # it to an integer first would round twice and favor lower values. + nearest_allowed_value = self._nearest_finite_gene_space_value(gene_index, value) + if nearest_allowed_value is not None: + value = nearest_allowed_value if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): value = value.item() if isinstance(value, numpy.generic) else value type_limits = numpy.iinfo(self._get_gene_storage_dtype(dtype)) @@ -672,10 +678,13 @@ def convert_bounded_operator_gene_value(self, gene_index, value): last = min(math.floor(upper), int(type_limits.max)) if first > last: raise ValueError(f'The operator bounds have no value representable by gene_type for the gene at index {gene_index}.') + # Round to the nearest integer. Dropping the fraction instead + # would never reach the highest value and would pull every gene + # toward zero. # Real-coded arithmetic may round an integer type's maximum # up to the next power of two. Clip as Python integers before # casting so a valid bounded proposal does not overflow. - value = min(max(math.trunc(value), first), last) + value = min(max(math.floor(value + 0.5), first), last) converted = self.change_gene_dtype_and_round(gene_index, value) if not lower <= float(converted) <= upper: if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): @@ -708,6 +717,48 @@ def convert_bounded_operator_gene_value(self, gene_index, value): converted = min(candidates, key=lambda candidate: abs(float(candidate) - float(converted))) return converted if dtype is object else dtype(converted) + def _nearest_finite_gene_space_value(self, gene_index, value): + """ + Return the allowed value of a finite gene space that is nearest + to a value generated by SBX crossover or polynomial mutation. + + A gene space is finite when it is a range, a dictionary with a + ``step``, or a list of numbers. Lists that contain ``None`` are + not finite because ``None`` stands for any value in the + initialization range. + + Parameters + ---------- + gene_index : int + Index of the gene whose space is used. + value : float + The decimal value generated by the operator. + + Returns + ------- + number or None + The nearest allowed value, or None when the gene has no + finite gene space. + """ + if self.gene_space is None: + return None + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + value = float(value) + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + # The values are evenly spaced, so the nearest one is found + # from its position without listing all the values. + count = self._finite_gene_space_length(space) + low = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + position = math.floor((value - low) / step + 0.5) + position = min(max(position, 0), count - 1) + return self._finite_gene_space_value(space, position) + if isinstance(space, (list, tuple, numpy.ndarray)): + if any(item is None for item in space): + return None + return min(space, key=lambda allowed_value: abs(float(allowed_value) - value)) + return None + def is_bounded_operator_gene_value_in_space(self, gene_index, value): """Check a bounded operator against the original space, including None entries.""" if self.gene_space is None: diff --git a/tests/test_bounded_operator_rounding.py b/tests/test_bounded_operator_rounding.py new file mode 100644 index 0000000..a9f8e80 --- /dev/null +++ b/tests/test_bounded_operator_rounding.py @@ -0,0 +1,152 @@ +""" +Tests for how SBX crossover and polynomial mutation convert their real +valued results back into integer and finite gene values. + +Both operators compute a decimal number between the gene bounds. The +decimal must then become a value the gene accepts. These tests check +that this conversion picks the nearest accepted value. Chopping the +fraction instead makes the highest value unreachable and pulls every +gene toward zero. +""" + +import numpy + +import pygad + + +# Number of offspring rows used to measure each distribution. It is large +# enough to make the shares below stable for a fixed seed. +NUM_TRIALS = 3000 + + +def _zero_fitness(ga, solution, solution_idx): + return 0.0 + + +def _make_one_gene_ga(gene_space, gene_type=int, **operator_settings): + """Build a GA with a single gene so each operator can be called directly.""" + return pygad.GA(num_generations=1, + num_parents_mating=2, + fitness_func=_zero_fitness, + sol_per_pop=4, + num_genes=1, + gene_space=[gene_space], + gene_type=gene_type, + mutation_num_genes=1, + random_seed=1, + suppress_warnings=True, + **operator_settings) + + +def _mutate_from(ga, start_value): + """Apply polynomial mutation to many copies of one value and return the results.""" + offspring = numpy.full((NUM_TRIALS, 1), start_value) + return ga.polynomial_mutation(offspring)[:, 0].astype(float) + + +def _share(values, condition): + return float(numpy.mean(condition(values))) + + +def test_polynomial_mutation_reaches_the_highest_integer_value(): + # A gene with the values 0 to 4 starting at 3 must sometimes move to 4. + ga = _make_one_gene_ga({"low": 0, "high": 5, "step": 1}, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 3) + assert _share(values, lambda v: v == 4) > 0.05 + + +def test_polynomial_mutation_reaches_the_last_value_of_a_list_space(): + # The same rule applies to a gene space given as a list. + ga = _make_one_gene_ga([0, 1, 2, 3, 4], + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 3) + assert _share(values, lambda v: v == 4) > 0.05 + + +def test_polynomial_mutation_reaches_the_highest_value_of_a_continuous_integer_space(): + # A continuous gene space used with an integer gene type. The 'high' + # value is excluded, so the allowed integers are 0 to 3. + ga = _make_one_gene_ga({"low": 0, "high": 4}, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 2) + assert _share(values, lambda v: v == 3) > 0.05 + + +def test_polynomial_mutation_moves_up_and_down_equally_often(): + # From the middle value, moving down and moving up must be about + # equally likely. Chopping the fraction makes moving down far more + # likely than moving up. + ga = _make_one_gene_ga({"low": 0, "high": 5, "step": 1}, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 2) + moved_down = _share(values, lambda v: v < 2) + moved_up = _share(values, lambda v: v > 2) + assert abs(moved_down - moved_up) < 0.05 + + +def test_polynomial_mutation_is_balanced_on_a_negative_range(): + # Chopping the fraction pulls values toward zero, which is upward for + # negative numbers. Starting at -2 in the range -4 to 0 checks that. + ga = _make_one_gene_ga({"low": -4, "high": 1, "step": 1}, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, -2) + moved_down = _share(values, lambda v: v < -2) + moved_up = _share(values, lambda v: v > -2) + assert abs(moved_down - moved_up) < 0.05 + + +def test_polynomial_mutation_is_balanced_on_a_stepped_space(): + # With the values 0, 2, 4, 6, 8 the decimal result must go to the + # nearest allowed value. Rounding it to an integer first would send + # results such as 5.2 to 4 instead of 6. + ga = _make_one_gene_ga({"low": 0, "high": 9, "step": 2}, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 4) + moved_down = _share(values, lambda v: v < 4) + moved_up = _share(values, lambda v: v > 4) + assert abs(moved_down - moved_up) < 0.05 + + +def test_polynomial_mutation_keeps_values_inside_the_gene_space(): + # Rounding must never produce a value outside the allowed values. + spaces = [{"low": 0, "high": 5, "step": 1}, + {"low": 0, "high": 9, "step": 2}, + {"low": -4, "high": 1, "step": 1}, + [16, 32, 64]] + for space in spaces: + ga = _make_one_gene_ga(space, + mutation_type="polynomial", + polynomial_mutation_eta=5) + low, high = ga.get_bounded_operator_gene_range(0) + for start_value in (low, high): + for value in _mutate_from(ga, start_value): + assert ga.is_gene_value_in_space(0, value, value), (space, value) + + +def test_sbx_crossover_can_keep_the_highest_parent_value(): + # With parents 1 and 4 in the range 0 to 4, a child must sometimes + # keep the value 4. Chopping the fraction turns it into 3 every time. + ga = _make_one_gene_ga({"low": 0, "high": 5, "step": 1}, + crossover_type="sbx", + sbx_crossover_eta=5) + parents = numpy.array([[1], [4]]) + children = ga.sbx_crossover(parents, (NUM_TRIALS, 1))[:, 0].astype(float) + assert _share(children, lambda v: v == 4) > 0.3 + assert _share(children, lambda v: v == 1) > 0.3 + + +def test_float_genes_are_not_rounded(): + # Float genes keep their decimal values. + ga = _make_one_gene_ga({"low": 0.0, "high": 4.0}, + gene_type=float, + mutation_type="polynomial", + polynomial_mutation_eta=5) + values = _mutate_from(ga, 2.0) + assert _share(values, lambda v: v != numpy.round(v)) > 0.9 From a5f5445c9c003aa2aad6ea852e4070429a599709 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 15 Oct 2026 23:42:35 -0400 Subject: [PATCH 07/12] Add gene_categorical for genes with unordered values --- docs/source/gene_values.md | 44 +++++++ docs/source/releases.md | 3 +- docs/source/utils.md | 4 +- pygad/helper/misc.py | 76 +++++++++++ pygad/pygad.py | 7 +- pygad/utils/__init__.py | 2 +- pygad/utils/crossover.py | 8 ++ pygad/utils/mutation.py | 5 + pygad/utils/report.py | 2 +- pygad/utils/validation.py | 63 +++++++++- tests/test_categorical_genes.py | 215 ++++++++++++++++++++++++++++++++ 11 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 tests/test_categorical_genes.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 6b4f37e..3712d71 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -262,6 +262,50 @@ python examples/example_gene_space.py +## Categorical Genes + +PyGAD 3.9.0 adds the `gene_categorical` parameter to the constructor of the `pygad.GA` class. It marks the genes whose values are categories with no order. + +For example, an activation function can be stored as `0` for `relu`, `1` for `tanh`, and `2` for `sigmoid`. The numbers are only labels. `tanh` is not between `relu` and `sigmoid`. + +SBX crossover and polynomial mutation treat a gene value as a point on a number line. They move it to nearby values. This is right for a learning rate or a number of neurons, but it has no meaning for a category. For a categorical gene, these two operators work differently: + +1. Polynomial mutation replaces the value with one of the other allowed values. Each of them is equally likely. `polynomial_mutation_eta` is not used for this gene. +2. SBX crossover gives the child the value of one of the two parents. Each parent is equally likely. + +The other crossover and mutation types already copy or pick allowed values, so `gene_categorical` does not change them. + +The `gene_categorical` parameter accepts: + +1. `None`: The default. Every gene is ordered, which is the behavior before PyGAD 3.9.0. +2. A single `bool`: Applies to all genes. +3. A list or tuple with one `bool` per gene. + +A categorical gene must have a finite gene space: a list of numbers, a `range`, or a dictionary with a `step`. A list that has `None` is not accepted because `None` stands for any value in the initialization range. Otherwise, PyGAD raises a `ValueError`. + +This example has a continuous learning rate, an integer number of neurons, and a categorical activation function. + +```python +import pygad + +def fitness_func(ga_instance, solution, solution_idx): + learning_rate, num_neurons, activation = solution + ... + +ga_instance = pygad.GA(num_generations=20, + num_parents_mating=4, + sol_per_pop=10, + num_genes=3, + fitness_func=fitness_func, + gene_space=[{'low': 0.0001, 'high': 0.1}, + {'low': 16, 'high': 129, 'step': 1}, + [0, 1, 2]], + gene_type=[float, int, int], + gene_categorical=[False, False, True], + crossover_type="sbx", + mutation_type="polynomial") +``` + diff --git a/docs/source/releases.md b/docs/source/releases.md index 2bd7c33..5b713ed 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -8,7 +8,8 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan 1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381). The `pygad.utils` submodule version is `1.5.9`. 2. [SBX crossover](utils.md#sbx_crossover) and [polynomial mutation](utils.md#polynomial_mutationoffspring) round integer results to the nearest integer instead of truncating them. For a gene with a finite gene space, the result moves to the nearest allowed value. Before this change, the highest value of an integer gene was almost never produced, values were pulled toward zero, and SBX could not keep a parent's highest value. Results for integer genes change for the same random seed. Float genes are not affected. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). -3. Regression tests cover reaching both ends of integer and finite gene spaces, balanced steps up and down, negative ranges, and stepped and uneven spaces. The `pygad.helper` submodule version is `1.4.7`. +3. A new `gene_categorical` parameter marks genes whose values are categories with no order. For these genes, polynomial mutation picks one of the other allowed values at random and SBX crossover copies the value of one parent. The default `None` keeps the previous behavior. See [Categorical Genes](gene_values.md#categorical-genes). +4. Regression tests cover reaching both ends of integer and finite gene spaces, balanced steps up and down, negative ranges, stepped and uneven spaces, and every `gene_categorical` validation rule and operator behavior. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.7` and `1.5.10`. ## PyGAD 3.8.1 diff --git a/docs/source/utils.md b/docs/source/utils.md index cacaf84..42c95df 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -266,7 +266,7 @@ Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` pa For each crossed gene, SBX produces two possible values on opposite sides of the parents' midpoint. PyGAD selects either value with probability `0.5`, avoiding the downward bias from always selecting the lower child. Equal parent values are copied unchanged. -For an integer gene, the child value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. +For an integer gene, the child value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. For a gene marked in [`gene_categorical`](gene_values.md#categorical-genes), the child copies the value of one parent, each with probability `0.5`. ## `pygad.utils.mutation` Submodule @@ -353,7 +353,7 @@ The count-based and probability-based adaptive mutation methods use the same com Applies polynomial mutation to the passed two-dimensional offspring array in place and returns it. Each gene is selected with `mutation_probability`, or with probability `1 / num_genes` when that parameter is `None`. `polynomial_mutation_eta` controls the size of the change; higher values favor smaller changes. Bounds come from `init_range_low` and `init_range_high` for each gene, and mutated values are clipped to those bounds. Genes whose range has effectively zero width are skipped. When `allow_duplicate_genes=False`, the existing random duplicate-resolution helper is applied after changing a gene. -For an integer gene, the new value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. +For an integer gene, the new value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. For a gene marked in [`gene_categorical`](gene_values.md#categorical-genes), the new value is one of the other allowed values, each equally likely, and `polynomial_mutation_eta` is not used. ### Mutation Helper Methods diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 6410622..280c850 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -759,6 +759,82 @@ def _nearest_finite_gene_space_value(self, gene_index, value): return min(space, key=lambda allowed_value: abs(float(allowed_value) - value)) return None + def is_categorical_gene(self, gene_index): + """ + Check whether a gene is marked as categorical in ``gene_categorical``. + + Parameters + ---------- + gene_index : int + Index of the gene. + + Returns + ------- + bool + True when the gene is categorical. A GA instance saved by an + older PyGAD version has no ``gene_categorical`` attribute, so + its genes are treated as ordered. + """ + gene_categorical = getattr(self, 'gene_categorical', None) + if gene_categorical is None: + return False + return bool(gene_categorical[gene_index]) + + def pick_other_categorical_value(self, gene_index, current_value): + """ + Pick one allowed value of a categorical gene, other than its + current value, with every other value equally likely. + + Parameters + ---------- + gene_index : int + Index of the categorical gene. Its gene space is finite, which + ``gene_categorical`` validation guarantees. + current_value : number + The value the gene has now. + + Returns + ------- + number + The new value. When the gene has only one allowed value, that + value is returned. When the current value is not one of the + allowed values, any allowed value can be returned. + """ + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if isinstance(space, range) or isinstance(space, dict): + # Evenly spaced values: work with positions so a long range is + # never listed in full. + count = self._finite_gene_space_length(space) + low = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + current_position = int(round((float(current_value) - low) / step)) + if not (0 <= current_position < count + and math.isclose(float(self._finite_gene_space_value(space, current_position)), float(current_value))): + current_position = None + allowed_value_at = lambda position: self._finite_gene_space_value(space, position) + else: + count = len(space) + current_position = None + for position, allowed_value in enumerate(space): + if float(allowed_value) == float(current_value): + current_position = position + break + allowed_value_at = lambda position: space[position] + + if count == 1: + new_position = 0 + elif current_position is None: + new_position = int(self.numpy_random_generator.randint(0, count)) + else: + # Draw from the other count - 1 positions, then skip over the + # current position so it can never be chosen. + new_position = int(self.numpy_random_generator.randint(0, count - 1)) + if new_position >= current_position: + new_position += 1 + # The chosen value is already allowed. The conversion only applies + # the gene type, for example turning 2.0 into 2 for an int gene. + return self.convert_bounded_operator_gene_value(gene_index, allowed_value_at(new_position)) + def is_bounded_operator_gene_value_in_space(self, gene_index, value): """Check a bounded operator against the original space, including None entries.""" if self.gene_space is None: diff --git a/pygad/pygad.py b/pygad/pygad.py index 497d876..6978d5f 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -69,7 +69,8 @@ def __init__(self, stop_criteria=None, parallel_processing=None, random_seed=None, - logger=None): + logger=None, + gene_categorical=None): """ The constructor of the GA class accepts all parameters required to create an instance of the GA class. It validates such parameters. @@ -114,6 +115,7 @@ def __init__(self, gene_space: It accepts a list of all possible values of the gene. This list is used in the mutation step. It should be used only if the gene space is a set of discrete values. No need for the 2 parameters (random_mutation_min_val and random_mutation_max_val) if the parameter gene_space exists. Added in PyGAD 2.5.0. In PyGAD 2.11.0, the gene_space can be assigned a dict. gene_constraint: It accepts a list of constraints for the genes. Each constraint is a Python function. Added in PyGAD 3.5.0. + gene_categorical: Marks the genes whose values are categories with no order, such as 0 for relu, 1 for tanh, and 2 for sigmoid. It accepts None (the default, every gene is ordered), a single bool applied to all genes, or a list with one bool per gene. A categorical gene must have a finite gene_space (a list of numbers, a range, or a dict with a step). For a categorical gene, polynomial mutation picks one of the other allowed values at random and SBX crossover copies the value of one parent. The other operators are not affected. Added in PyGAD 3.9.0. sample_size: To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either allow_duplicate_genes or gene_constraint is used. Added in PyGAD 3.5.0. on_start: Accepts a function/method to be called only once before the genetic algorithm starts its evolution. If function, then it must accept a single parameter representing the instance of the genetic algorithm. If method, then it must accept 2 parameters where the second one refers to the method's object. Added in PyGAD 2.6.0. @@ -183,7 +185,8 @@ def __init__(self, stop_criteria=stop_criteria, parallel_processing=parallel_processing, random_seed=random_seed, - logger=logger) + logger=logger, + gene_categorical=gene_categorical) except Exception as e: if hasattr(self, 'logger'): self.logger.exception(e) diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 70f6cdb..31eb4ad 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.9" +__version__ = "1.5.10" diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index 6ac0365..bfb6fb1 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -258,6 +258,14 @@ def sbx_crossover(self, parents, offspring_size): parent2_idx = (k + 1) % parents.shape[0] for gene_idx in range(offspring_size[1]): + if self.is_categorical_gene(gene_idx): + # Blending two categories has no meaning, so the child + # takes the value of one parent, each with equal chance. + if self.numpy_random_generator.random() < 0.5: + offspring[k, gene_idx] = parents[parent1_idx, gene_idx] + else: + offspring[k, gene_idx] = parents[parent2_idx, gene_idx] + continue range_min, range_max = self.get_bounded_operator_gene_range(gene_idx) lower, upper = float(range_min), float(range_max) p1 = float(numpy.clip(parents[parent1_idx, gene_idx], lower, upper)) diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 8333e55..8b59cb2 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -416,6 +416,11 @@ def polynomial_mutation(self, offspring): selected_any_gene = False for gene_index in gene_indices: selected_any_gene = True + if self.is_categorical_gene(gene_index): + # A categorical value has no neighbors on a number line, + # so pick one of the other allowed values at random. + solution[gene_index] = self.pick_other_categorical_value(gene_index, solution[gene_index]) + continue lower, upper = map(float, self.get_bounded_operator_gene_range(gene_index)) if upper - lower < 1e-14: solution[gene_index] = self.convert_bounded_operator_gene_value(gene_index, lower) diff --git a/pygad/utils/report.py b/pygad/utils/report.py index 5064ea0..c1f2ba2 100644 --- a/pygad/utils/report.py +++ b/pygad/utils/report.py @@ -109,7 +109,7 @@ ("Population", ["num_generations", "num_parents_mating", "sol_per_pop", "num_genes", "init_range_low", "init_range_high", "gene_type", "gene_space", "allow_duplicate_genes", - "gene_constraint", "sample_size"]), + "gene_constraint", "gene_categorical", "sample_size"]), ("Parent selection", ["parent_selection_type", "K_tournament", "nsga3_num_divisions", "keep_parents", "keep_elitism"]), diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 6abef7b..de926fb 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -349,6 +349,64 @@ def _validate_gene_constraint(self, gene_constraint): self._validate_callable_parameter(constraint, f'gene_constraint at index {index}', 2) for index, constraint in enumerate(gene_constraint)] + def _validate_gene_categorical(self, gene_categorical): + """ + Validate the ``gene_categorical`` parameter and store one bool per + gene in ``self.gene_categorical``. + + A categorical gene holds a choice whose values have no order. It + needs a finite gene space so the operators can pick another + allowed value. + + Parameters + ---------- + gene_categorical : None, bool, list, or tuple + None marks every gene as ordered. A single bool applies to all + genes. A list or tuple gives one bool per gene. + + Raises + ------ + TypeError + If the value, or one of its entries, is not a bool. + ValueError + If the list length differs from the number of genes, or a + categorical gene has no finite gene space. + """ + boolean_types = (bool, numpy.bool_) + if gene_categorical is None: + self.gene_categorical = [False] * self.num_genes + return + if type(gene_categorical) in boolean_types: + gene_categorical = [bool(gene_categorical)] * self.num_genes + elif isinstance(gene_categorical, (list, tuple)): + if len(gene_categorical) != self.num_genes: + self.valid_parameters = False + raise ValueError(f"The length of gene_categorical ({len(gene_categorical)}) must equal the number of genes ({self.num_genes}).") + for index, entry in enumerate(gene_categorical): + if type(entry) not in boolean_types: + self.valid_parameters = False + raise TypeError(f"Each entry in gene_categorical must be a bool, but the entry at index {index} is {entry!r} of type {type(entry)}.") + gene_categorical = [bool(entry) for entry in gene_categorical] + else: + self.valid_parameters = False + raise TypeError(f"gene_categorical must be None, a bool, or a list or tuple of bools, but {type(gene_categorical)} found.") + + # Every categorical gene needs a fixed list of allowed values. + for gene_index, is_categorical in enumerate(gene_categorical): + if not is_categorical: + continue + space = None + if self.gene_space is not None: + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + is_finite = (isinstance(space, range) + or (isinstance(space, dict) and 'step' in space) + or (isinstance(space, (list, tuple, numpy.ndarray)) + and not any(item is None for item in space))) + if not is_finite: + self.valid_parameters = False + raise ValueError(f"The gene at index {gene_index} is marked as categorical in gene_categorical, so its gene_space must be a list of numbers, a range, or a dict with a 'step'. Found: {space!r}.") + self.gene_categorical = gene_categorical + def _validate_crossover(self, crossover_type, crossover_probability, sbx_crossover_eta=30): """Resolve crossover and validate its probability and finite distribution index.""" operators = {'single_point': 'single_point_crossover', 'two_points': 'two_points_crossover', @@ -717,7 +775,8 @@ def validate_parameters(self, stop_criteria, parallel_processing, random_seed, - logger): + logger, + gene_categorical=None): """ Validate every parameter passed to ``pygad.GA.__init__`` and store the parsed values on the GA instance. This method is @@ -759,6 +818,8 @@ def validate_parameters(self, if self.gene_space_nested and len(gene_space) != self.num_genes: self.valid_parameters = False raise ValueError(f"When gene_space is nested, its length ({len(gene_space)}) must equal the number of genes ({self.num_genes}).") + # Needs the validated gene_space, so it comes after the length check. + self._validate_gene_categorical(gene_categorical) self._validate_mutation_range(random_mutation_min_val, random_mutation_max_val) self.num_parents_mating = self._validate_integer_parameter( diff --git a/tests/test_categorical_genes.py b/tests/test_categorical_genes.py new file mode 100644 index 0000000..fa9d0dd --- /dev/null +++ b/tests/test_categorical_genes.py @@ -0,0 +1,215 @@ +""" +Tests for the gene_categorical parameter. + +A categorical gene holds a choice from a list where the order has no +meaning, such as an activation function stored as 0 for relu, 1 for tanh +and 2 for sigmoid. SBX crossover and polynomial mutation normally treat +the gene value as a point on a number line. For a categorical gene, they +must treat it as a choice instead: + +1. Polynomial mutation picks one of the other allowed values at random. +2. SBX crossover gives the child the value of one of the two parents. +""" + +import numpy +import pytest + +import pygad + + +# Number of offspring rows used to measure each distribution. +NUM_TRIALS = 3000 + + +def _zero_fitness(ga, solution, solution_idx): + return 0.0 + + +def _make_ga(gene_space, gene_categorical, gene_type=int, num_genes=1, **other_settings): + """Build a small GA whose operators can be called directly.""" + settings = dict(num_generations=1, + num_parents_mating=2, + fitness_func=_zero_fitness, + sol_per_pop=4, + num_genes=num_genes, + gene_space=gene_space, + gene_type=gene_type, + gene_categorical=gene_categorical, + mutation_type="polynomial", + polynomial_mutation_eta=5, + mutation_num_genes=1, + random_seed=1, + suppress_warnings=True) + settings.update(other_settings) + return pygad.GA(**settings) + + +def _shares(values, allowed_values): + """Return the share of each allowed value among the given values.""" + values = numpy.asarray(values, dtype=float) + return {allowed: float(numpy.mean(values == allowed)) for allowed in allowed_values} + + +# Validation of the parameter. + +def test_default_marks_every_gene_as_ordered(): + ga = _make_ga(gene_space=[[0, 1, 2], [0, 1, 2]], gene_categorical=None, num_genes=2) + assert ga.gene_categorical == [False, False] + + +def test_single_boolean_applies_to_every_gene(): + ga = _make_ga(gene_space=[[0, 1, 2], [5, 6]], gene_categorical=True, num_genes=2) + assert ga.gene_categorical == [True, True] + + +def test_list_marks_each_gene_separately(): + ga = _make_ga(gene_space=[{"low": 0.0, "high": 1.0}, [0, 1, 2]], + gene_categorical=[False, True], + gene_type=[float, int], + num_genes=2) + assert ga.gene_categorical == [False, True] + + +def test_rejects_a_value_that_is_not_a_boolean_or_a_list(): + with pytest.raises(TypeError, match="gene_categorical"): + _make_ga(gene_space=[[0, 1, 2]], gene_categorical="yes") + + +def test_rejects_a_list_with_a_non_boolean_entry(): + with pytest.raises(TypeError, match="gene_categorical"): + _make_ga(gene_space=[[0, 1, 2]], gene_categorical=[1]) + + +def test_rejects_a_list_with_the_wrong_length(): + with pytest.raises(ValueError, match="gene_categorical"): + _make_ga(gene_space=[[0, 1, 2], [0, 1, 2]], gene_categorical=[True], num_genes=2) + + +def test_rejects_a_categorical_gene_without_a_gene_space(): + with pytest.raises(ValueError, match="gene_categorical"): + _make_ga(gene_space=None, gene_categorical=True) + + +def test_rejects_a_categorical_gene_with_a_continuous_range(): + with pytest.raises(ValueError, match="gene_categorical"): + _make_ga(gene_space=[{"low": 0, "high": 5}], gene_categorical=True) + + +def test_rejects_a_categorical_gene_whose_list_contains_none(): + # None in a gene space stands for any value in the initialization + # range, so the allowed values are not a fixed list. + with pytest.raises(ValueError, match="gene_categorical"): + _make_ga(gene_space=[[0, 1, None]], gene_categorical=True) + + +def test_accepts_a_categorical_gene_with_a_stepped_range(): + ga = _make_ga(gene_space=[{"low": 0, "high": 5, "step": 1}], gene_categorical=True) + assert ga.gene_categorical == [True] + + +# Polynomial mutation. + +def test_polynomial_mutation_picks_every_other_category_equally_often(): + ga = _make_ga(gene_space=[[0, 1, 2, 3, 4]], gene_categorical=True) + offspring = numpy.full((NUM_TRIALS, 1), 0) + values = ga.polynomial_mutation(offspring)[:, 0] + shares = _shares(values, [0, 1, 2, 3, 4]) + # The mutated gene must change, and each other category is equally likely. + assert shares[0] == 0.0 + for category in (1, 2, 3, 4): + assert 0.2 < shares[category] < 0.3, shares + + +def test_polynomial_mutation_uses_only_allowed_values_of_an_uneven_list(): + ga = _make_ga(gene_space=[[16, 32, 64]], gene_categorical=True) + offspring = numpy.full((NUM_TRIALS, 1), 32) + values = ga.polynomial_mutation(offspring)[:, 0] + shares = _shares(values, [16, 32, 64]) + assert shares[32] == 0.0 + assert 0.45 < shares[16] < 0.55 + assert 0.45 < shares[64] < 0.55 + + +def test_polynomial_mutation_keeps_the_only_allowed_value(): + ga = _make_ga(gene_space=[[7]], gene_categorical=True) + offspring = numpy.full((10, 1), 7) + values = ga.polynomial_mutation(offspring)[:, 0] + assert numpy.all(values == 7) + + +def test_polynomial_mutation_still_moves_ordered_genes_on_the_number_line(): + # The float gene is ordered and must still get small decimal steps, + # while the categorical gene only takes values from its list. + ga = _make_ga(gene_space=[{"low": 0.0, "high": 10.0}, [0, 1, 2]], + gene_categorical=[False, True], + gene_type=[float, int], + num_genes=2, + mutation_num_genes=2) + offspring = numpy.array([[5.0, 0]] * NUM_TRIALS, dtype=object) + result = ga.polynomial_mutation(offspring) + float_gene = result[:, 0].astype(float) + categorical_gene = result[:, 1].astype(float) + assert numpy.mean(float_gene != numpy.round(float_gene)) > 0.9 + assert set(categorical_gene) == {1.0, 2.0} + + +# SBX crossover. + +def test_sbx_crossover_copies_one_parent_value_for_a_categorical_gene(): + ga = _make_ga(gene_space=[[0, 1, 2, 3, 4]], + gene_categorical=True, + crossover_type="sbx", + sbx_crossover_eta=5) + parents = numpy.array([[1], [4]]) + children = ga.sbx_crossover(parents, (NUM_TRIALS, 1))[:, 0] + shares = _shares(children, [0, 1, 2, 3, 4]) + assert shares[0] == shares[2] == shares[3] == 0.0 + assert 0.45 < shares[1] < 0.55 + assert 0.45 < shares[4] < 0.55 + + +# Interaction with other gene rules and with a full run. + +def test_categorical_mutation_respects_a_gene_constraint(): + # The constraint forbids the value 2. + ga = _make_ga(gene_space=[[0, 1, 2, 3]], + gene_categorical=True, + gene_constraint=[lambda solution, values: [value for value in values if value != 2]]) + offspring = numpy.full((NUM_TRIALS, 1), 0) + values = ga.polynomial_mutation(offspring)[:, 0] + assert 2 not in set(values.astype(int)) + + +def test_categorical_mutation_respects_allow_duplicate_genes(): + ga = _make_ga(gene_space=[0, 1, 2, 3, 4], + gene_categorical=True, + num_genes=2, + allow_duplicate_genes=False) + offspring = numpy.array([[0, 1]] * NUM_TRIALS) + result = ga.polynomial_mutation(offspring) + assert numpy.all(result[:, 0] != result[:, 1]) + + +def test_full_run_with_mixed_genes_keeps_every_value_allowed(): + def fitness(ga, solution, solution_idx): + return float(solution[0]) - float(solution[1]) + + ga = pygad.GA(num_generations=15, + num_parents_mating=4, + fitness_func=fitness, + sol_per_pop=10, + num_genes=3, + gene_space=[{"low": 0.0, "high": 1.0}, + {"low": 16, "high": 129, "step": 1}, + [0, 1, 2]], + gene_type=[float, int, int], + gene_categorical=[False, False, True], + crossover_type="sbx", + mutation_type="polynomial", + random_seed=3, + suppress_warnings=True) + ga.run() + population = numpy.asarray(ga.population, dtype=float) + assert numpy.all((population[:, 0] >= 0.0) & (population[:, 0] <= 1.0)) + assert numpy.all((population[:, 1] >= 16) & (population[:, 1] <= 128)) + assert set(population[:, 2]) <= {0.0, 1.0, 2.0} From 8d1ad9ee9c666d113cab094d16322bbb7176dee8 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 15 Oct 2026 23:52:57 -0400 Subject: [PATCH 08/12] Prepare PyGAD 3.9.0 --- docs/source/releases.md | 8 +++++++- pygad/_version.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/source/releases.md b/docs/source/releases.md index 5b713ed..3884fbe 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,13 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381). The `pygad.utils` submodule version is `1.5.9`. +No changes yet. + +## PyGAD 3.9.0 + +Release Date: October 16, 2026. + +1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381). 2. [SBX crossover](utils.md#sbx_crossover) and [polynomial mutation](utils.md#polynomial_mutationoffspring) round integer results to the nearest integer instead of truncating them. For a gene with a finite gene space, the result moves to the nearest allowed value. Before this change, the highest value of an integer gene was almost never produced, values were pulled toward zero, and SBX could not keep a parent's highest value. Results for integer genes change for the same random seed. Float genes are not affected. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules). 3. A new `gene_categorical` parameter marks genes whose values are categories with no order. For these genes, polynomial mutation picks one of the other allowed values at random and SBX crossover copies the value of one parent. The default `None` keeps the previous behavior. See [Categorical Genes](gene_values.md#categorical-genes). 4. Regression tests cover reaching both ends of integer and finite gene spaces, balanced steps up and down, negative ranges, stepped and uneven spaces, and every `gene_categorical` validation rule and operator behavior. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.7` and `1.5.10`. diff --git a/pygad/_version.py b/pygad/_version.py index e4e78c0..fcd7ddb 100644 --- a/pygad/_version.py +++ b/pygad/_version.py @@ -1 +1 @@ -__version__ = "3.8.1" +__version__ = "3.9.0" From b565589b9898bd8724addef797eb454764162b39 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 16 Oct 2026 02:09:19 -0400 Subject: [PATCH 09/12] Favor better solutions with negative fitness in roulette and stochastic universal selection --- docs/source/releases.md | 3 +- docs/source/utils.md | 4 + pygad/utils/__init__.py | 2 +- pygad/utils/parent_selection.py | 60 +++++++-- ..._probability_selection_negative_fitness.py | 127 ++++++++++++++++++ 5 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 tests/test_probability_selection_negative_fitness.py diff --git a/docs/source/releases.md b/docs/source/releases.md index 3884fbe..93eddd8 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,8 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -No changes yet. +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 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/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 From 81157f5e6ab2338b5fb7c6a593d9fb38508ce75d Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 16 Oct 2026 02:15:37 -0400 Subject: [PATCH 10/12] Prepare PyGAD 3.9.1 --- docs/source/releases.md | 6 ++++++ pygad/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/releases.md b/docs/source/releases.md index 93eddd8..6b6e310 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,6 +6,12 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased +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`. 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" From bbf7bf46fb59d0f79359d241290e4795ec0a24e0 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 16 Oct 2026 12:15:16 -0400 Subject: [PATCH 11/12] Keep roulette and stochastic universal selection finite at the float limits --- docs/source/releases.md | 3 +- docs/source/utils.md | 4 +- pygad/utils/__init__.py | 2 +- pygad/utils/parent_selection.py | 90 ++++++++++++-- ...t_probability_selection_extreme_fitness.py | 110 ++++++++++++++++++ 5 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 tests/test_probability_selection_extreme_fitness.py diff --git a/docs/source/releases.md b/docs/source/releases.md index 6b6e310..88938e8 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,8 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -No changes yet. +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 diff --git a/docs/source/utils.md b/docs/source/utils.md index 6ade59c..f4e0662 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -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.