Skip to content

Commit d12725f

Browse files
committed
Clip mutated values to NumPy integer limits
Fixes #379.
1 parent 75de35f commit d12725f

6 files changed

Lines changed: 163 additions & 7 deletions

File tree

‎docs/source/gene_values.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ Let us look at some examples.
633633
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`.
634634

635635
- 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.
636+
- 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.
636637
- 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.
637638
- 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`.
638639
- 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`.

‎docs/source/pygad.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,8 @@ A bool that controls how `random` and `adaptive` mutation change a gene when dra
381381
- `True`: replace the gene with the randomly generated value.
382382
- `False` (default): add the random value to the gene.
383383

384+
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).
385+
384386
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.
385387

386388
</details>

‎docs/source/releases.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan
66

77
## Unreleased
88

9-
No changes yet.
9+
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`.
1010

1111
## PyGAD 3.8.0
1212

‎pygad/helper/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from pygad.helper import unique
22
from pygad.helper import misc
33

4-
__version__ = "1.4.4"
4+
__version__ = "1.4.5"

‎pygad/helper/misc.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,12 +397,13 @@ def mutation_change_gene_dtype_and_round(self,
397397
Apply a random mutation value to a gene and cast / round the
398398
result. If ``mutation_by_replacement`` is True, the random
399399
value replaces the gene; otherwise it is added to the
400-
existing value.
400+
existing value. NumPy integer results are clipped to the
401+
destination type's limits before casting.
401402
402403
Parameters
403404
----------
404-
random_value : numeric
405-
The freshly drawn mutation value.
405+
random_value : numeric or numpy.ndarray
406+
The freshly drawn mutation value or array of candidates.
406407
gene_index : int
407408
Index of the gene being mutated.
408409
gene_value : numeric
@@ -414,8 +415,9 @@ def mutation_change_gene_dtype_and_round(self,
414415
415416
Returns
416417
-------
417-
gene_value_new : numeric
418-
The mutated value after casting and rounding.
418+
gene_value_new : numeric or numpy.ndarray
419+
The mutated value or candidates after clipping NumPy
420+
integer values, casting, and rounding.
419421
"""
420422

421423
if mutation_by_replacement:
@@ -431,6 +433,14 @@ def mutation_change_gene_dtype_and_round(self,
431433
mutated_value = gene_value + random_value
432434
else:
433435
mutated_value = gene_value + numpy.asarray(random_value, dtype=object)
436+
gene_dtype = self.get_gene_dtype(gene_index)[0]
437+
if gene_dtype is not int and numpy.issubdtype(numpy.dtype(gene_dtype), numpy.integer):
438+
type_limits = numpy.iinfo(gene_dtype)
439+
# Object storage and Python bounds keep 64-bit integers exact.
440+
# Clip the final sum or replacement, so an unsigned gene can
441+
# still use negative offsets without wrapping or overflowing.
442+
mutated_value = numpy.clip(numpy.asarray(mutated_value, dtype=object),
443+
int(type_limits.min), int(type_limits.max))
434444
return self.change_gene_dtype_and_round(gene_index, mutated_value)
435445

436446
def validate_gene_constraint_callable_output(self,
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Random mutation respects NumPy integer limits before converting candidates."""
2+
3+
import numpy
4+
import pytest
5+
6+
import pygad
7+
8+
9+
def fitness_func(ga_instance, solution, solution_index):
10+
return float(sum(int(value) for value in solution))
11+
12+
13+
def make_ga(**options):
14+
parameters = dict(num_generations=2, num_parents_mating=2,
15+
fitness_func=fitness_func, sol_per_pop=4, num_genes=3,
16+
gene_type=numpy.uint8, init_range_low=0, init_range_high=3,
17+
random_seed=7, suppress_warnings=True)
18+
parameters.update(options)
19+
return pygad.GA(**parameters)
20+
21+
22+
integer_types = [numpy.int8, numpy.int16, numpy.int32, numpy.int64,
23+
numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64]
24+
25+
26+
@pytest.mark.parametrize("gene_type", integer_types)
27+
@pytest.mark.parametrize("mutation_by_replacement", [False, True])
28+
def test_mutation_clamps_scalar_and_array_values_at_both_integer_limits(gene_type, mutation_by_replacement):
29+
ga_instance = make_ga(gene_type=gene_type)
30+
type_limits = numpy.iinfo(gene_type)
31+
for boundary, offset in [(int(type_limits.min), -1), (int(type_limits.max), 1)]:
32+
random_value = boundary + offset if mutation_by_replacement else offset
33+
converted = ga_instance.mutation_change_gene_dtype_and_round(
34+
random_value, 0, gene_type(boundary), mutation_by_replacement)
35+
assert type(converted) is gene_type
36+
assert int(converted) == boundary
37+
38+
random_values = numpy.array([[random_value, boundary if mutation_by_replacement else 0]], dtype=object)
39+
original_values = random_values.copy()
40+
converted = ga_instance.mutation_change_gene_dtype_and_round(
41+
random_values, 0, gene_type(boundary), mutation_by_replacement)
42+
assert converted.dtype == numpy.dtype(gene_type)
43+
numpy.testing.assert_array_equal(converted, [[boundary, boundary]])
44+
numpy.testing.assert_array_equal(random_values, original_values)
45+
46+
47+
@pytest.mark.parametrize("random_values", [numpy.array(-1), numpy.array([-1, 0], dtype=object),
48+
numpy.empty((0, 2), dtype=object)])
49+
def test_unsigned_mutation_preserves_candidate_shape(random_values):
50+
ga_instance = make_ga()
51+
converted = ga_instance.mutation_change_gene_dtype_and_round(
52+
random_values, 0, numpy.uint8(0), False)
53+
assert numpy.shape(converted) == random_values.shape
54+
numpy.testing.assert_array_equal(converted, numpy.zeros(random_values.shape, dtype=numpy.uint8))
55+
56+
57+
@pytest.mark.parametrize("gene_type", integer_types)
58+
def test_fractional_offsets_are_added_before_integer_truncation(gene_type):
59+
ga_instance = make_ga(gene_type=gene_type)
60+
converted = ga_instance.mutation_change_gene_dtype_and_round(
61+
[-0.2, 0.2, 1.9], 0, gene_type(1), False)
62+
numpy.testing.assert_array_equal(converted, [0, 1, 2])
63+
64+
65+
@pytest.mark.parametrize("gene_type", [int, float, object, numpy.float32])
66+
@pytest.mark.parametrize("mutation_by_replacement", [False, True])
67+
def test_other_gene_types_keep_values_outside_narrow_integer_limits(gene_type, mutation_by_replacement):
68+
ga_instance = make_ga(gene_type=gene_type)
69+
random_values = [-2**20, 2**20]
70+
converted = ga_instance.mutation_change_gene_dtype_and_round(
71+
random_values, 0, 0, mutation_by_replacement)
72+
numpy.testing.assert_array_equal(converted, random_values)
73+
74+
75+
@pytest.mark.parametrize("gene_type", [int, object])
76+
def test_python_integer_mutation_keeps_unbounded_scalar_values(gene_type):
77+
ga_instance = make_ga(gene_type=gene_type)
78+
converted = ga_instance.mutation_change_gene_dtype_and_round(1, 0, 2**80, False)
79+
assert converted == 2**80 + 1
80+
81+
82+
@pytest.mark.parametrize("random_seed", range(20))
83+
def test_reported_unsigned_population_runs_with_default_mutation(random_seed):
84+
ga_instance = make_ga(num_generations=30, num_parents_mating=4,
85+
sol_per_pop=10, num_genes=6, random_seed=random_seed)
86+
ga_instance.run()
87+
assert ga_instance.generations_completed == 30
88+
assert ga_instance.population.dtype == numpy.dtype(numpy.uint8)
89+
90+
91+
@pytest.mark.parametrize("mutation_type", ['random', 'adaptive'])
92+
@pytest.mark.parametrize("use_probability", [False, True])
93+
@pytest.mark.parametrize("gene_space", [None, [None, None, None], [[None, 1], [None, 1], [None, 1]]])
94+
@pytest.mark.parametrize("mutation_by_replacement", [False, True])
95+
def test_mutation_backends_share_integer_limits(mutation_type, use_probability, gene_space, mutation_by_replacement):
96+
parameters = dict(mutation_type=mutation_type, gene_space=gene_space,
97+
mutation_by_replacement=mutation_by_replacement,
98+
gene_type=[numpy.uint8, numpy.int8, numpy.uint64],
99+
initial_population=[[0, 127, 2**64 - 1]] * 4,
100+
random_mutation_min_val=[-2, 1, 1],
101+
random_mutation_max_val=[-1, 2, 2],
102+
crossover_type=None, keep_elitism=0, keep_parents=0)
103+
if use_probability:
104+
parameters['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0
105+
else:
106+
parameters['mutation_num_genes'] = [3, 3] if mutation_type == 'adaptive' else 3
107+
ga_instance = make_ga(**parameters)
108+
ga_instance.run()
109+
for solution in ga_instance.population:
110+
for gene_index, gene_type in enumerate([numpy.uint8, numpy.int8, numpy.uint64]):
111+
assert type(solution[gene_index]) is gene_type
112+
assert int(solution[0]) in [0, 1]
113+
if gene_space is None:
114+
numpy.testing.assert_array_equal(solution, [0, 1, 1] if mutation_by_replacement else [0, 127, 2**64 - 1])
115+
116+
117+
def test_constraints_receive_clamped_mutation_candidates():
118+
candidate_batches = []
119+
120+
def gene_constraint(solution, values):
121+
candidate_batches.append(values.copy())
122+
assert all(type(value) is numpy.uint8 and int(value) == 0 for value in values)
123+
return values
124+
125+
ga_instance = make_ga(initial_population=[[0, 0, 0]] * 4,
126+
gene_constraint=[gene_constraint] * 3,
127+
random_mutation_min_val=-2, random_mutation_max_val=-1,
128+
mutation_num_genes=3, keep_elitism=0, keep_parents=0)
129+
ga_instance.run()
130+
assert candidate_batches
131+
numpy.testing.assert_array_equal(ga_instance.population, 0)
132+
133+
134+
@pytest.mark.parametrize("mutation_by_replacement", [False, True])
135+
def test_duplicate_repair_uses_representable_mutation_candidates(mutation_by_replacement):
136+
ga_instance = make_ga(allow_duplicate_genes=False)
137+
solution, duplicate_indices, num_unsolved_duplicates = ga_instance.solve_duplicate_genes_randomly(
138+
numpy.array([0, 0, 255], dtype=numpy.uint8), -2, 2,
139+
mutation_by_replacement=mutation_by_replacement, gene_type=ga_instance.gene_type)
140+
assert not duplicate_indices
141+
assert num_unsolved_duplicates == 0
142+
assert solution.dtype == numpy.dtype(numpy.uint8)
143+
numpy.testing.assert_array_equal(numpy.sort(solution), [0, 1, 255])

0 commit comments

Comments
 (0)