Skip to content

Commit 2dd06a3

Browse files
committed
Cover corrected operators and handle single-gene swap safely
Add regression coverage for crossover boundaries, random swap pairs, per-gene types, duplicate repair, SBX symmetry, probability gates, and reproducible runs. Return single-gene swap input unchanged, bump the utils submodule version, and document changes to seeded results.
1 parent 5f76539 commit 2dd06a3

4 files changed

Lines changed: 190 additions & 1 deletion

File tree

‎docs/source/utils.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ Applies the single-point crossover. It selects a point randomly at which crossov
166166

167167
Applies the 2 points crossover. It selects the 2 points randomly at which crossover takes place between the pairs of parents.
168168

169+
The two distinct cut points are selected from `0` through `num_genes`, including both ends. Every pair is equally likely, and the segment copied from the second parent can contain between one and all genes. With a single gene, that gene is copied from the second parent.
170+
171+
The corrected two-point crossover, swap mutation, and SBX crossover use different random draws from earlier versions. Runs remain reproducible with the same `random_seed` within this version, but their results can differ from earlier versions.
172+
169173
#### `uniform_crossover()`
170174

171175
Applies the uniform crossover. For each gene, a parent out of the 2 mating parents is selected randomly and the gene is copied from it.
@@ -218,6 +222,8 @@ For each gene, a random value is selected according to the range specified by th
218222

219223
Applies the swap mutation which interchanges the values of 2 randomly selected genes.
220224

225+
Any pair of distinct positions can be selected. An offspring with only one gene is returned unchanged because there is no second gene to swap.
226+
221227
#### `inversion_mutation()`
222228

223229
Applies the inversion mutation which selects a subset of genes and inverts them.

‎pygad/utils/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
from pygad.utils import validation
1010
from pygad.utils import engine
1111

12-
__version__ = "1.5.0"
12+
__version__ = "1.5.1"

‎pygad/utils/mutation.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ def swap_mutation(self, offspring):
364364
"""
365365
Swap the values of two genes inside each offspring. The two
366366
genes are 2 different genes picked at random.
367+
Offspring with fewer than two genes are returned unchanged.
367368
368369
Parameters
369370
----------
@@ -376,6 +377,9 @@ def swap_mutation(self, offspring):
376377
The mutated offspring.
377378
"""
378379

380+
if offspring.shape[1] < 2:
381+
return offspring
382+
379383
for idx in range(offspring.shape[0]):
380384
mutation_gene1, mutation_gene2 = numpy.random.choice(offspring.shape[1], size=2, replace=False)
381385

‎tests/test_operator_regressions.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Edge cases and invariants for the crossover and swap fixes."""
2+
3+
import itertools
4+
5+
import numpy
6+
import pytest
7+
8+
import pygad
9+
10+
11+
def _fitness(ga, solution, index):
12+
return float(numpy.sum(solution))
13+
14+
15+
def _make_ga(num_genes=5, **options):
16+
parameters = dict(num_generations=5, num_parents_mating=2,
17+
fitness_func=_fitness, sol_per_pop=6,
18+
num_genes=num_genes, mutation_type=None,
19+
random_seed=17, suppress_warnings=True)
20+
parameters.update(options)
21+
return pygad.GA(**parameters)
22+
23+
24+
@pytest.mark.parametrize("num_genes", [1, 2, 3, 5])
25+
@pytest.mark.parametrize("gene_type", [int, [int, float]])
26+
def test_two_points_crossover_keeps_input_and_gene_types(num_genes, gene_type):
27+
if isinstance(gene_type, list):
28+
gene_type = [gene_type[index % 2] for index in range(num_genes)]
29+
ga = _make_ga(num_genes, gene_type=gene_type, crossover_type="two_points")
30+
parents = ga.population[:2].copy()
31+
before = parents.copy()
32+
children = ga.two_points_crossover(parents, (20, num_genes))
33+
34+
numpy.testing.assert_array_equal(parents, before)
35+
assert children.shape == (20, num_genes)
36+
assert children.dtype == parents.dtype
37+
for index in range(num_genes):
38+
assert numpy.all(numpy.isin(children[:, index], parents[:, index]))
39+
if not ga.gene_type_single:
40+
assert all(type(value) is type(parents[0, index])
41+
for value in children[:, index])
42+
if num_genes == 1:
43+
numpy.testing.assert_array_equal(children[:, 0],
44+
parents[(numpy.arange(20) + 1) % 2, 0])
45+
46+
47+
@pytest.mark.parametrize("crossover_type", ["two_points", "sbx"])
48+
@pytest.mark.parametrize("probability", [0.0, 1.0])
49+
def test_crossover_probability_keeps_or_crosses_parents(crossover_type, probability):
50+
ga = _make_ga(3, crossover_type=crossover_type,
51+
crossover_probability=probability,
52+
init_range_low=0.0, init_range_high=1.0)
53+
parents = numpy.array([[0.2, 0.2, 0.2], [0.8, 0.8, 0.8]])
54+
before = parents.copy()
55+
children = ga.crossover(parents, (100, 3))
56+
57+
numpy.testing.assert_array_equal(parents, before)
58+
if probability == 0.0:
59+
numpy.testing.assert_array_equal(children, parents[numpy.arange(100) % 2])
60+
else:
61+
assert numpy.any(children != parents[numpy.arange(100) % 2])
62+
assert numpy.all((children >= 0.0) & (children <= 1.0))
63+
64+
65+
def test_two_points_crossover_preserves_permutations_with_duplicate_repair():
66+
ga = _make_ga(5, crossover_type="two_points", gene_type=int,
67+
gene_space=range(5), allow_duplicate_genes=False)
68+
parents = numpy.array([[0, 1, 2, 3, 4], [4, 3, 2, 1, 0]])
69+
children = ga.two_points_crossover(parents, (100, 5))
70+
71+
numpy.testing.assert_array_equal(numpy.sort(children, axis=1),
72+
numpy.tile(numpy.arange(5), (100, 1)))
73+
74+
75+
def test_two_points_crossover_reaches_every_cut_pair():
76+
ga = _make_ga(5, gene_type=int, crossover_type="two_points")
77+
parents = numpy.array([[0] * 5, [1] * 5])
78+
children = ga.two_points_crossover(parents, (1000, 5))
79+
# Mark the segment inherited from the second parent, for either mating order.
80+
segments = children.copy()
81+
segments[1::2] = 1 - segments[1::2]
82+
observed = set()
83+
for segment in segments:
84+
indices = numpy.flatnonzero(segment)
85+
assert len(indices) > 0
86+
assert numpy.all(numpy.diff(indices) == 1)
87+
observed.add((indices[0], indices[-1] + 1))
88+
assert observed == set(itertools.combinations(range(6), 2))
89+
90+
91+
@pytest.mark.parametrize("num_genes", [2, 3, 5, 6])
92+
def test_swap_mutation_reaches_every_pair_and_preserves_permutations(num_genes):
93+
ga = _make_ga(num_genes, gene_type=int, mutation_type="swap",
94+
gene_space=range(num_genes), allow_duplicate_genes=False)
95+
original = numpy.tile(numpy.arange(num_genes), (1000, 1))
96+
children = original.copy()
97+
result = ga.swap_mutation(children)
98+
99+
assert result is children
100+
numpy.testing.assert_array_equal(numpy.sort(children, axis=1), original)
101+
changed = children != original
102+
assert numpy.all(numpy.count_nonzero(changed, axis=1) == 2)
103+
pairs = {tuple(numpy.flatnonzero(row)) for row in changed}
104+
assert pairs == set(itertools.combinations(range(num_genes), 2))
105+
106+
107+
@pytest.mark.parametrize("num_offspring", [0, 1, 4])
108+
def test_single_gene_swap_is_a_noop(num_offspring):
109+
ga = _make_ga(1, gene_type=int, mutation_type="swap")
110+
children = numpy.full((num_offspring, 1), 7, dtype=int)
111+
before = children.copy()
112+
113+
assert ga.swap_mutation(children) is children
114+
numpy.testing.assert_array_equal(children, before)
115+
116+
117+
@pytest.mark.parametrize("crossover_type", ["two_points", "sbx"])
118+
def test_crossover_accepts_empty_offspring(crossover_type):
119+
ga = _make_ga(crossover_type=crossover_type)
120+
parents = ga.population[:2].copy()
121+
assert ga.crossover(parents, (0, 5)).shape == (0, 5)
122+
123+
124+
@pytest.mark.parametrize("parents", [(0.2, 0.8), (0.0, 0.7),
125+
(0.3, 1.0), (0.0, 1.0)])
126+
@pytest.mark.parametrize("quantile", [0.1, 0.9])
127+
def test_sbx_can_select_both_symmetric_children_at_boundaries(monkeypatch, parents,
128+
quantile):
129+
ga = _make_ga(1, crossover_type="sbx", init_range_low=0.0,
130+
init_range_high=1.0)
131+
parents = numpy.array(parents).reshape(2, 1)
132+
# The same spread draw with opposite child choices must straddle the mean.
133+
draws = iter([quantile, 0.0, quantile, 0.99])
134+
monkeypatch.setattr(numpy.random, "random", lambda: next(draws))
135+
children = ga.sbx_crossover(parents, (2, 1))[:, 0]
136+
137+
assert children[0] < parents.mean() < children[1]
138+
assert children.sum() == pytest.approx(parents.sum(), abs=1e-12)
139+
assert numpy.all((children >= 0.0) & (children <= 1.0))
140+
141+
142+
def test_sbx_equal_parents_do_not_draw_random_values(monkeypatch):
143+
ga = _make_ga(2, crossover_type="sbx")
144+
parents = numpy.array([[0.25, 0.75], [0.25, 0.75]])
145+
146+
def unexpected_draw(*args, **kwargs):
147+
pytest.fail("Equal parents should be copied without a random draw")
148+
149+
monkeypatch.setattr(numpy.random, "random", unexpected_draw)
150+
numpy.testing.assert_array_equal(ga.sbx_crossover(parents, (3, 2)),
151+
numpy.tile(parents[0], (3, 1)))
152+
153+
154+
def test_sbx_respects_distinct_bounds_for_each_gene():
155+
low = numpy.array([-5.0, 10.0, 100.0])
156+
high = numpy.array([-1.0, 12.0, 200.0])
157+
ga = _make_ga(3, crossover_type="sbx",
158+
init_range_low=low.tolist(), init_range_high=high.tolist())
159+
parents = numpy.array([low + 0.2 * (high - low),
160+
low + 0.8 * (high - low)])
161+
children = ga.sbx_crossover(parents, (1000, 3))
162+
163+
assert numpy.all(children >= low)
164+
assert numpy.all(children <= high)
165+
share_above = numpy.mean(children > parents.mean(axis=0), axis=0)
166+
assert numpy.all((share_above > 0.4) & (share_above < 0.6))
167+
168+
169+
@pytest.mark.parametrize("crossover_type,mutation_type", [
170+
("two_points", "swap"), ("sbx", "polynomial")])
171+
def test_seeded_runs_are_reproducible_with_corrected_operators(crossover_type,
172+
mutation_type):
173+
populations = []
174+
for _ in range(2):
175+
ga = _make_ga(crossover_type=crossover_type, mutation_type=mutation_type)
176+
ga.run()
177+
assert numpy.isfinite(ga.population).all()
178+
populations.append(ga.population.copy())
179+
numpy.testing.assert_array_equal(*populations)

0 commit comments

Comments
 (0)