From d12725f3f79ce573a042cad6cff27fd23b848e5b Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Sun, 11 Oct 2026 11:29:53 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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"