From dc7becb6ff99859c27859863f55571dbe6f78b2d Mon Sep 17 00:00:00 2001 From: tachsin Date: Mon, 28 Sep 2026 19:59:11 +0300 Subject: [PATCH 1/3] Swap genes when the random mutation finds no free value With allow_duplicate_genes=False and a gene space of num_genes values, as for permutations, every value of the space is used, so a mutated gene kept its value and the mutation never changed a solution. In the 4 mutations by space, swap the gene with another gene whose value is in its space and whose space holds its value instead. Fixes #372 --- pygad/utils/mutation.py | 75 ++++++++++++++++++++++++++++++++ tests/test_crossover_mutation.py | 44 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 66d72e29..0b6b8f6c 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -80,6 +80,12 @@ def mutation_by_space(self, offspring): gene_idx=gene_idx, sample_size=self.sample_size) + if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: + # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. + offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], + gene_idx=gene_idx) + continue + # Before assigning the selected value from the space to the gene, change its data type and round it. offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) @@ -119,6 +125,12 @@ def mutation_probs_by_space(self, offspring): gene_idx=gene_idx, sample_size=self.sample_size) + if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: + # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. + offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], + gene_idx=gene_idx) + continue + # Assigning the selected value from the space to the gene. offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) @@ -201,6 +213,57 @@ def mutation_process_gene_value(self, # Even though its name is singular, it might hold multiple values. return value_selected + def swap_gene_by_space(self, + solution, + gene_idx): + """ + With ``allow_duplicate_genes=False``, a gene cannot take a new + value from its space when all of them are used by other genes, + as in a permutation (``gene_space=range(num_genes)``). To still + change the solution, swap the gene's value with the value of + another gene, picked at random among the genes whose value is + in this gene's space and whose space holds this gene's value. + The swap keeps the genes unique. Genes with a + ``gene_constraint`` are not swapped. + + Parameters + ---------- + solution : numpy.ndarray + The solution that owns the gene (modified in place). + gene_idx : int + Index of the gene inside ``solution``. + + Returns + ------- + solution : numpy.ndarray + The solution after the swap, unchanged if no gene qualifies. + """ + + def gene_space_values(idx): + if self.gene_space_nested or not self.gene_type_single: + return self.gene_space_unpacked[idx] + else: + return self.gene_space_unpacked + + def has_constraint(idx): + return bool(self.gene_constraint and self.gene_constraint[idx]) + + if has_constraint(gene_idx): + return solution + + gene_value = solution[gene_idx] + candidates = [other_idx for other_idx in range(len(solution)) + if solution[other_idx] != gene_value + and not has_constraint(other_idx) + and solution[other_idx] in gene_space_values(gene_idx) + and gene_value in gene_space_values(other_idx)] + + if len(candidates) > 0: + other_idx = random.choice(candidates) + solution[gene_idx] = solution[other_idx] + solution[other_idx] = gene_value + return solution + def mutation_randomly(self, offspring): """ Mutate ``self.mutation_num_genes`` genes per offspring by @@ -698,6 +761,12 @@ def adaptive_mutation_by_space(self, offspring): gene_idx=gene_idx, sample_size=self.sample_size) + if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: + # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. + offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], + gene_idx=gene_idx) + continue + # Assigning the selected value from the space to the gene. offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) @@ -843,6 +912,12 @@ def adaptive_mutation_probs_by_space(self, offspring): gene_idx=gene_idx, sample_size=self.sample_size) + if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: + # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. + offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], + gene_idx=gene_idx) + continue + # Assigning the selected value from the space to the gene. offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) diff --git a/tests/test_crossover_mutation.py b/tests/test_crossover_mutation.py index acc38942..27d35d39 100644 --- a/tests/test_crossover_mutation.py +++ b/tests/test_crossover_mutation.py @@ -241,6 +241,41 @@ def test_random_mutation_manual_call4(): for value in comp_sorted: assert value in value_space +def random_mutation_permutation(gene_space, mutation_probability=None): + # Each solution is a permutation of range(num_genes): no value of the gene space is free. + num_genes = 8 + ga_instance = pygad.GA(num_generations=num_generations, + num_parents_mating=2, + fitness_func=lambda ga, solution, idx: random.random(), + sol_per_pop=4, + num_genes=num_genes, + gene_space=gene_space, + gene_type=int, + allow_duplicate_genes=False, + mutation_type="random", + mutation_probability=mutation_probability, + suppress_warnings=True, + random_seed=1) + + temp_offspring = numpy.array([numpy.random.permutation(num_genes) for _ in range(100)]) + offspring = ga_instance.random_mutation(offspring=temp_offspring.copy()) + + for solution in offspring: + # The mutation keeps the permutation. + assert sorted(solution) == list(range(num_genes)) + # The mutation changes the solutions. + assert numpy.all(numpy.any(offspring != temp_offspring, axis=1)) + +def test_random_mutation_permutation(): + random_mutation_permutation(gene_space=list(range(8))) + +def test_random_mutation_permutation_probability(): + random_mutation_permutation(gene_space=list(range(8)), + mutation_probability=1.0) + +def test_random_mutation_permutation_nested_gene_space(): + random_mutation_permutation(gene_space=[list(range(8))] * 8) + if __name__ == "__main__": #### Single-objective print() @@ -285,3 +320,12 @@ def test_random_mutation_manual_call4(): test_random_mutation_manual_call4() print() + + test_random_mutation_permutation() + print() + + test_random_mutation_permutation_probability() + print() + + test_random_mutation_permutation_nested_gene_space() + print() From 78c7e79bd4b57073251a90a160090c84c207d6c3 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 01:39:12 -0400 Subject: [PATCH 2/3] Preserve types and constraints in permutation mutation swaps Track swapped positions per mutation pass to avoid cancelling two-gene swaps. Validate destination conversions, spaces, and dependent constraints before changing the solution. Add regression coverage and document the recent operator fixes. --- docs/source/adaptive_mutation.md | 2 + docs/source/benchmarks.md | 2 + docs/source/gene_values.md | 4 +- docs/source/pygad.md | 5 + docs/source/releases.md | 12 ++ docs/source/utils.md | 19 ++- pygad/utils/__init__.py | 2 +- pygad/utils/mutation.py | 106 +++++++++++--- tests/test_permutation_mutation.py | 228 +++++++++++++++++++++++++++++ 9 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 tests/test_permutation_mutation.py diff --git a/docs/source/adaptive_mutation.md b/docs/source/adaptive_mutation.md index 9ab58836..921a8dd6 100644 --- a/docs/source/adaptive_mutation.md +++ b/docs/source/adaptive_mutation.md @@ -34,6 +34,8 @@ In [PyGAD 2.10.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-1 1. In the constructor of the `pygad.GA` class, set `mutation_type="adaptive"` to specify that the type of mutation is adaptive. 2. Specify the mutation rates for the low and high quality solutions using one of these 3 parameters according to your preference: `mutation_probability`, `mutation_num_genes`, and `mutation_percent_genes`. Please check the [documentation of each of these parameters](https://pygad.readthedocs.io/en/latest/pygad.html#init) for more information. +For permutations with `allow_duplicate_genes=False` and no unused values in `gene_space`, both adaptive mutation controls use a compatible-swap fallback. The configured rates select the genes that can initiate mutation. A fallback swap changes two positions and can involve a partner that was not selected by the mutation rate. Each gene participates in at most one fallback swap per mutation pass. See {ref}`Mutation Methods ` for the type, gene-space, and constraint checks. + When adaptive mutation is used, then the value assigned to any of the 3 parameters can be of any of these data types: 1. `list` diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 247e3e79..dc84ae59 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -85,6 +85,8 @@ In `pygad.benchmarks.tsp`. Build `TSP` from either a 2D `coordinates` array or a Class attributes `gene_space=list(range(num_cities))`, `gene_type=int`, and `allow_duplicate_genes=False` keep the permutation constraint: +Every city index is already present in a valid tour, so random mutation has no unused replacement value. Its compatible-swap fallback exchanges two city positions instead, keeping the tour valid. Adaptive mutation uses the same fallback. Each position can be swapped at most once in a mutation pass, preventing a second swap from immediately undoing the first. + ```python import pygad from pygad.benchmarks.tsp import TSP diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index b604237d..0cbe2566 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -174,7 +174,9 @@ lambda solution,values: [val for val in values if val<5] The first parameter is the solution where the target gene exists. It is passed just in case you would like to compare the gene value with other genes. The second parameter is the list of candidate values for the gene. The objective of the lambda function is to filter the values and return only the valid values that are less than 5. -#### What does the `solution` parameter hold? +For the permutation mutation fallback, genes with their own constraints are excluded from swaps. Constraints on other genes are checked against a candidate solution containing both proposed swapped values. The swap is committed only if those constraints still hold, so a constraint that depends on another gene is not invalidated by the fallback. + +### What does the `solution` parameter hold? The `solution` passed to the callable is **not** a fixed snapshot taken before the operation started. PyGAD processes the genes one at a time and writes each gene's chosen value back into the solution *in place* before moving on to the next gene. So the `solution` is updated incrementally, and the state it is in when a given gene's constraint runs depends on which genes were already processed in the current step: diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 7aaa9ec6..294ed9b0 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -162,6 +162,8 @@ The upper value of the random range from which the gene values in the initial po :animate: fade-in-slide-down Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. + +For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See {ref}`Mutation Methods `. ::: :::{dropdown} `sample_size=100`: Sample size used when searching for a valid value. @@ -266,6 +268,8 @@ If `crossover_type=None`, the crossover step is skipped and no offspring are cre :animate: fade-in-slide-down Only used when `crossover_type` is `'sbx'`. Sets how close the children stay to the parents. A higher value means children stay closer. Must be a positive number. Defaults to `30`. + +Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds are taken from `init_range_low` and `init_range_high`. ::: :::{dropdown} `crossover_probability=None`: Chance a parent is used for crossover. @@ -706,6 +710,7 @@ All the parameters and functions passed to the `pygad.GA` class constructor are - `mutation()`: Active mutation operator. Bound during validation according to `mutation_type`. - `random_mutation(offspring)`: Random mutation (replaces or adds a uniform random value). +- `swap_gene_by_space(solution, gene_idx, swapped_genes=None)`: Compatible-swap fallback when a unique replacement cannot be selected from the gene space. Modifies the solution in place and optionally records the swapped positions for the current mutation pass. - `swap_mutation(offspring)`: Swap mutation. - `inversion_mutation(offspring)`: Inversion mutation. - `scramble_mutation(offspring)`: Scramble mutation. diff --git a/docs/source/releases.md b/docs/source/releases.md index f2eb03af..16032fdc 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -720,3 +720,15 @@ Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). 36. The PDF report built by `generate_report()` now shows the PyGAD logo on its title page. The logo image ships with the package, so no network access is needed. If the image file is missing, the report is built without it. 37. Two private helper functions are added to the `pygad/utils/report.py` script for the logo. `_pdf_report_read_logo_bytes()` reads the bundled logo file and returns its bytes, or `None` if the file is missing. `_pdf_report_build_logo_image()` builds the image that is placed on the title page, or returns `None` so the report still builds without the logo. 38. The private helper functions in the `pygad/utils/report.py` script are renamed to start with the `_pdf_report_` prefix so their purpose is clear from the name. For example, `_build_title_section()` becomes `_pdf_report_build_title_section()` and `_render_plot_to_png()` becomes `_pdf_report_render_plot_to_png()`. + +## Unreleased + +These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. + +1. Two-point crossover selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). +2. Swap mutation can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). +3. SBX crossover selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). +4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). +5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. + +The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/utils.md b/docs/source/utils.md index fec9065f..007ba336 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -131,6 +131,7 @@ The `pygad.utils.crossover` module has a class named `Crossover` with the suppor 2. Two points: Implemented using the `two_points_crossover()` method. 3. Uniform: Implemented using the `uniform_crossover()` method. 4. Scattered: Implemented using the `scattered_crossover()` method. +5. Simulated binary: Implemented using the `sbx_crossover()` method. Crossover takes two parents and builds a child by mixing their genes. The next figure shows how single-point, two-point, and uniform crossover do this. @@ -168,7 +169,7 @@ Applies the 2 points crossover. It selects the 2 points randomly at which crosso 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. -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. +The corrected two-point crossover, swap mutation, SBX crossover, and permutation mutation fallback use different random draws from earlier versions. Runs remain reproducible with the same `random_seed` within the same version and environment, but their results can differ from earlier versions. #### `uniform_crossover()` @@ -178,6 +179,12 @@ Applies the uniform crossover. For each gene, a parent out of the 2 mating paren Applies the scattered crossover. It randomly selects the gene from one of the 2 parents. +#### `sbx_crossover()` + +Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` parameter controls the spread: larger values keep children closer to their parents. Bounds come from `init_range_low` and `init_range_high`, which can specify a separate range for each gene. + +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. + ## `pygad.utils.mutation` Submodule The `pygad.utils.mutation` module has a class named `Mutation` with the supported mutation operations: @@ -202,6 +209,7 @@ All mutation methods accept this parameter: 1. `offspring`: The offspring to mutate. +(mutation-methods)= ### Mutation Methods The `Mutation` class in the `pygad.utils.mutation` module supports several methods for applying mutation. All of these methods accept the same parameter which is: @@ -216,6 +224,12 @@ The next subsections list the supported methods for mutation. Applies the random mutation which changes the values of some genes randomly. The number of genes is specified according to either the `mutation_num_genes` or the `mutation_percent_genes` attributes. +When `allow_duplicate_genes=False` and every value in a gene's space is already used, a replacement cannot be selected. Random mutation then tries a compatible swap instead. This allows permutation encodings, such as TSP tours, to change even when there are no unused values. + +Both swapped values must keep their numeric values after conversion to their destination gene types and rounding, and must belong to both destination gene spaces. Genes with a `gene_constraint` are excluded from swaps, and any constraints on other genes must remain satisfied. If no compatible partner exists, the gene stays unchanged. + +Each gene participates in at most one fallback swap per offspring per mutation pass, so selecting both genes of a two-gene permutation does not undo the swap. A fallback swap changes two positions; `mutation_num_genes` or `mutation_probability` selects the genes that can initiate mutation, rather than guaranteeing the number of changed positions. A swap partner can be outside the selected mutation indices. + For each gene, a random value is selected according to the range specified by the 2 attributes `random_mutation_min_val` and `random_mutation_max_val`. The random value is added to the selected gene. #### `swap_mutation()` @@ -236,6 +250,8 @@ Applies the scramble mutation which selects a subset of genes and shuffles their Applies the adaptive mutation, which selects the number/percentage of genes to mutate based on the solution's fitness. If the fitness is high (the solution quality is high), then a smaller number/percentage of genes is mutated compared to a solution with low fitness. +The count-based and probability-based adaptive mutation methods use the same compatible-swap fallback for permutations as random mutation. Their fitness-based controls select which genes can initiate a mutation; swapped partners are not mutated again in the same pass. + ### Mutation Helper Methods The `pygad.utils.mutation` module has some helper methods to assist applying the mutation operation: @@ -250,6 +266,7 @@ The `pygad.utils.mutation` module has some helper methods to assist applying the 8. `adaptive_mutation_probs_by_space()`: Uses the mutation probabilities to decide which genes to apply the adaptive mutation by space. 9. `adaptive_mutation_randomly()`: Applies the adaptive mutation randomly. A number of genes are selected randomly for mutation. This number depends on the fitness of the solution. The random values are selected based on the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. 10. `adaptive_mutation_probs_randomly()`: Uses the mutation probabilities to decide which genes to apply the adaptive mutation randomly. +11. `swap_gene_by_space(solution, gene_idx, swapped_genes=None)`: Swap one gene with a compatible partner while preserving gene types, numeric values, gene spaces, uniqueness, and constraints. The solution is modified in place. The optional `swapped_genes` set tracks both positions already swapped in the same offspring's mutation pass; start with a new set for each pass. ## `pygad.utils.parent_selection` Submodule diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index f1051e78..fba9e31f 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.1" +__version__ = "1.5.2" diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index dcf5c53d..7c26a75d 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -74,8 +74,12 @@ def mutation_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): mutation_indices = numpy.array(random.sample(range(0, self.num_genes), self.mutation_num_genes)) + swapped_genes = set() for gene_idx in mutation_indices: + if gene_idx in swapped_genes: + continue + value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, sample_size=self.sample_size) @@ -83,7 +87,8 @@ def mutation_by_space(self, offspring): if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], - gene_idx=gene_idx) + gene_idx=gene_idx, + swapped_genes=swapped_genes) continue # Before assigning the selected value from the space to the gene, change its data type and round it. @@ -118,8 +123,12 @@ def mutation_probs_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): probs = numpy.random.random(size=offspring.shape[1]) + swapped_genes = set() for gene_idx in range(offspring.shape[1]): + if gene_idx in swapped_genes: + continue + if probs[gene_idx] <= self.mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, @@ -128,7 +137,8 @@ def mutation_probs_by_space(self, offspring): if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], - gene_idx=gene_idx) + gene_idx=gene_idx, + swapped_genes=swapped_genes) continue # Assigning the selected value from the space to the gene. @@ -215,16 +225,20 @@ def mutation_process_gene_value(self, def swap_gene_by_space(self, solution, - gene_idx): + gene_idx, + swapped_genes=None): """ With ``allow_duplicate_genes=False``, a gene cannot take a new value from its space when all of them are used by other genes, as in a permutation (``gene_space=range(num_genes)``). To still change the solution, swap the gene's value with the value of - another gene, picked at random among the genes whose value is - in this gene's space and whose space holds this gene's value. - The swap keeps the genes unique. Genes with a - ``gene_constraint`` are not swapped. + another compatible gene, picked at random. Both values are + cast and rounded for their destination genes. A swap is only + allowed if this preserves their numeric values and both gene + spaces, so it also preserves uniqueness. Genes with a + ``gene_constraint`` are not swapped, and any constraints on + other genes must still hold after the swap. Each gene + participates in at most one fallback swap per mutation pass. Parameters ---------- @@ -232,6 +246,10 @@ def swap_gene_by_space(self, The solution that owns the gene (modified in place). gene_idx : int Index of the gene inside ``solution``. + swapped_genes : set, optional + Indices already swapped in this offspring's mutation + pass. Updated in place after a successful swap. Omit it + when calling this helper independently. Returns ------- @@ -248,20 +266,62 @@ def gene_space_values(idx): def has_constraint(idx): return bool(self.gene_constraint and self.gene_constraint[idx]) - if has_constraint(gene_idx): + if swapped_genes is None: + swapped_genes = set() + + if gene_idx in swapped_genes or has_constraint(gene_idx): return solution gene_value = solution[gene_idx] - candidates = [other_idx for other_idx in range(len(solution)) - if solution[other_idx] != gene_value - and not has_constraint(other_idx) - and solution[other_idx] in gene_space_values(gene_idx) - and gene_value in gene_space_values(other_idx)] + candidates = [] + for other_idx, other_value in enumerate(solution): + if (other_idx in swapped_genes or other_value == gene_value + or has_constraint(other_idx)): + continue + + try: + new_gene_value = self.change_gene_dtype_and_round(gene_idx, other_value) + new_other_value = self.change_gene_dtype_and_round(other_idx, gene_value) + except (OverflowError, ValueError, TypeError): + # A value that cannot be represented by the destination + # dtype cannot be part of a permutation-preserving swap. + continue + + # Preserve the original set of numeric values. In particular, + # casting or rounding must not turn a value into a duplicate. + if new_gene_value != other_value or new_other_value != gene_value: + continue + if (new_gene_value not in gene_space_values(gene_idx) + or new_other_value not in gene_space_values(other_idx)): + continue + + # A constraint on a different gene may depend on either + # swapped position. Validate the complete candidate solution. + if self.gene_constraint: + candidate_solution = solution.copy() + candidate_solution[gene_idx] = new_gene_value + candidate_solution[other_idx] = new_other_value + constraints_satisfied = True + for idx, constraint in enumerate(self.gene_constraint): + if constraint is None: + continue + values = numpy.array([candidate_solution[idx]]) + selected_values = constraint(candidate_solution.copy(), values.copy()) + if not self.validate_gene_constraint_callable_output(selected_values, values): + raise Exception("The output from the gene_constraint callable/function must be a list or NumPy array that is a subset of the passed values (second argument).") + if len(selected_values) == 0: + constraints_satisfied = False + break + if not constraints_satisfied: + continue + + candidates.append((other_idx, new_gene_value, new_other_value)) if len(candidates) > 0: - other_idx = random.choice(candidates) - solution[gene_idx] = solution[other_idx] - solution[other_idx] = gene_value + other_idx, new_gene_value, new_other_value = random.choice(candidates) + solution[gene_idx] = new_gene_value + solution[other_idx] = new_other_value + swapped_genes.update((gene_idx, other_idx)) return solution def mutation_randomly(self, offspring): @@ -757,8 +817,12 @@ def adaptive_mutation_by_space(self, offspring): adaptive_mutation_num_genes = self.mutation_num_genes[1] mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) + swapped_genes = set() for gene_idx in mutation_indices: + if gene_idx in swapped_genes: + continue + value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, sample_size=self.sample_size) @@ -766,7 +830,8 @@ def adaptive_mutation_by_space(self, offspring): if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], - gene_idx=gene_idx) + gene_idx=gene_idx, + swapped_genes=swapped_genes) continue # Assigning the selected value from the space to the gene. @@ -906,8 +971,12 @@ def adaptive_mutation_probs_by_space(self, offspring): adaptive_mutation_probability = self.mutation_probability[1] probs = numpy.random.random(size=offspring.shape[1]) + swapped_genes = set() for gene_idx in range(offspring.shape[1]): + if gene_idx in swapped_genes: + continue + if probs[gene_idx] <= adaptive_mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], @@ -917,7 +986,8 @@ def adaptive_mutation_probs_by_space(self, offspring): if self.allow_duplicate_genes == False and value_from_space == offspring[offspring_idx, gene_idx]: # No value of the gene space is free (e.g. a permutation): swap the gene with another one instead. offspring[offspring_idx] = self.swap_gene_by_space(solution=offspring[offspring_idx], - gene_idx=gene_idx) + gene_idx=gene_idx, + swapped_genes=swapped_genes) continue # Assigning the selected value from the space to the gene. diff --git a/tests/test_permutation_mutation.py b/tests/test_permutation_mutation.py new file mode 100644 index 00000000..ffdd51a3 --- /dev/null +++ b/tests/test_permutation_mutation.py @@ -0,0 +1,228 @@ +"""Regression coverage for random and adaptive permutation mutation.""" + +import copy + +import numpy +import pytest + +import pygad + + +SPACE_MUTATIONS = ["mutation_by_space", "mutation_probs_by_space", + "adaptive_mutation_by_space", "adaptive_mutation_probs_by_space"] + + +def _fitness(ga, solution, index): + return float(numpy.sum(solution)) + + +def _make_ga(method="mutation_by_space", num_genes=2, **options): + adaptive = method.startswith("adaptive") + parameters = dict(num_generations=3, num_parents_mating=2, + fitness_func=_fitness, sol_per_pop=4, + num_genes=num_genes, gene_type=int, + gene_space=list(range(num_genes)), + allow_duplicate_genes=False, crossover_type=None, + mutation_type="adaptive" if adaptive else "random", + random_seed=1, suppress_warnings=True) + if "probs" in method: + parameters["mutation_probability"] = [1.0, 1.0] if adaptive else 1.0 + else: + parameters["mutation_num_genes"] = [num_genes, num_genes] if adaptive else num_genes + parameters.update(copy.deepcopy(options)) + return pygad.GA(**parameters) + + +def _mutate(ga, method, offspring, monkeypatch, multi_objective=False): + if method.startswith("adaptive"): + fitness = numpy.resize([0.0, 2.0], len(offspring)) + average = 1.0 + if multi_objective: + fitness = numpy.column_stack([fitness, fitness]) + average = numpy.array([1.0, 1.0]) + monkeypatch.setattr(ga, "adaptive_mutation_population_fitness", + lambda population: (average, fitness)) + return getattr(ga, method)(offspring) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +@pytest.mark.parametrize("multi_objective", [False, True]) +def test_two_gene_permutation_mutation_is_not_undone(method, multi_objective, + monkeypatch): + ga = _make_ga(method) + offspring = numpy.array([[0, 1], [1, 0]]) + before = offspring.copy() + result = _mutate(ga, method, offspring, monkeypatch, multi_objective) + + assert result is offspring + numpy.testing.assert_array_equal(result, before[:, ::-1]) + + +@pytest.mark.parametrize("gene_types", [[int, float], [float, int], + [numpy.int16, numpy.float32]]) +def test_swap_fallback_preserves_destination_gene_types(gene_types): + ga = _make_ga(gene_type=gene_types) + solution = numpy.array([gene_types[0](0), gene_types[1](1)], dtype=object) + result = ga.swap_gene_by_space(solution, 0) + + assert result is solution + assert result.tolist() == [1, 0] + for value, dtype in zip(result, gene_types): + assert numpy.asarray(value).dtype == numpy.dtype(dtype) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +def test_mutation_preserves_mixed_gene_types_and_uniqueness(method, monkeypatch): + ga = _make_ga(method, gene_type=[numpy.int16, numpy.float32]) + offspring = numpy.array([[numpy.int16(0), numpy.float32(1)], + [numpy.int16(1), numpy.float32(0)]], dtype=object) + before = offspring.copy() + result = _mutate(ga, method, offspring, monkeypatch) + + numpy.testing.assert_array_equal(result, before[:, ::-1]) + for row in result: + assert isinstance(row[0], numpy.int16) + assert isinstance(row[1], numpy.float32) + assert len(set(row)) == len(row) + + +@pytest.mark.parametrize("num_genes", [3, 8]) +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +def test_full_mutation_changes_permutations_in_flat_and_nested_spaces(method, + num_genes, + monkeypatch): + space = list(range(num_genes)) + ga = _make_ga(method, num_genes, gene_space=[space] * num_genes) + offspring = numpy.array([numpy.random.permutation(num_genes) for _ in range(20)]) + before = offspring.copy() + result = _mutate(ga, method, offspring, monkeypatch) + + numpy.testing.assert_array_equal(numpy.sort(result, axis=1), + numpy.tile(space, (len(result), 1))) + assert numpy.all(numpy.any(result != before, axis=1)) + + +def test_swap_fallback_requires_both_destination_spaces_to_allow_the_swap(): + ga = _make_ga(gene_space=[[0, 1], [1]]) + solution = numpy.array([0, 1]) + numpy.testing.assert_array_equal(ga.swap_gene_by_space(solution, 0), [0, 1]) + + +def test_swap_fallback_skips_casts_that_would_create_duplicates(): + ga = _make_ga(num_genes=3, gene_type=[int, float, int], + gene_space=[[0, 1], [0.0, 1.9], [1]]) + solution = numpy.array([0, 1.9, 1], dtype=object) + before = solution.copy() + ga.swap_gene_by_space(solution, 0) + numpy.testing.assert_array_equal(solution, before) + assert len(set(solution)) == len(solution) + + +def test_swap_fallback_skips_casts_that_would_change_permutation_values(): + ga = _make_ga(gene_type=[int, float], gene_space=[[0, 1], [0.0, 1.9]]) + solution = numpy.array([0, 1.9], dtype=object) + before = solution.copy() + ga.swap_gene_by_space(solution, 0) + numpy.testing.assert_array_equal(solution, before) + + +def test_swap_fallback_skips_rounding_that_would_change_values(): + ga = _make_ga(gene_type=[[float, 1], [float, 2]], + gene_space=[[0.0, 0.1], [0.0, 0.14]]) + solution = numpy.array([0.0, 0.14], dtype=object) + before = solution.copy() + ga.swap_gene_by_space(solution, 0) + numpy.testing.assert_array_equal(solution, before) + + +@pytest.mark.parametrize("constrained_gene", [0, 1]) +def test_swap_fallback_does_not_swap_constrained_genes(constrained_gene): + constraints = [None, None] + constraints[constrained_gene] = lambda solution, values: values + ga = _make_ga(gene_constraint=constraints) + solution = numpy.array([0, 1]) + numpy.testing.assert_array_equal(ga.swap_gene_by_space(solution, 0), [0, 1]) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +def test_swap_fallback_preserves_constraints_depending_on_other_genes(method, + monkeypatch): + constraints = [None, None, + lambda solution, values: [value for value in values + if value > solution[0]]] + ga = _make_ga(method, 3, gene_constraint=constraints) + # The only possible swap for gene 0 would invalidate gene 2's constraint. + solution = numpy.array([[0, 2, 1], [0, 2, 1]]) + before = solution.copy() + numpy.testing.assert_array_equal(_mutate(ga, method, solution, monkeypatch), before) + + +def test_swap_fallback_allows_swaps_that_preserve_other_genes_constraints(): + ga = _make_ga(num_genes=3, gene_constraint=[None, None, + lambda solution, values: [value for value in values + if value > solution[0]]]) + solution = numpy.array([0, 1, 2]) + numpy.testing.assert_array_equal(ga.swap_gene_by_space(solution, 0), [1, 0, 2]) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +@pytest.mark.parametrize("allow_duplicates", [False, True]) +def test_available_values_and_duplicate_allowed_mutations_do_not_use_fallback( + method, allow_duplicates, monkeypatch): + ga = _make_ga(method, gene_space=[0, 1, 2], + allow_duplicate_genes=allow_duplicates) + + def unexpected_swap(*args, **kwargs): + pytest.fail("Fallback should not run when a replacement can be chosen") + + monkeypatch.setattr(ga, "swap_gene_by_space", unexpected_swap) + solution = numpy.array([[0, 1], [1, 0]]) + result = _mutate(ga, method, solution, monkeypatch) + assert numpy.all(numpy.isin(result, [0, 1, 2])) + if not allow_duplicates: + assert numpy.all(result[:, 0] != result[:, 1]) + + +def test_swap_fallback_tracks_swaps_within_one_pass_only(): + ga = _make_ga() + solution = numpy.array([0, 1]) + swapped_genes = set() + ga.swap_gene_by_space(solution, 0, swapped_genes=swapped_genes) + assert swapped_genes == {0, 1} + ga.swap_gene_by_space(solution, 1, swapped_genes=swapped_genes) + numpy.testing.assert_array_equal(solution, [1, 0]) + ga.swap_gene_by_space(solution, 1, swapped_genes=set()) + numpy.testing.assert_array_equal(solution, [0, 1]) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +def test_single_gene_permutation_stays_unchanged(method, monkeypatch): + ga = _make_ga(method, 1) + solution = numpy.zeros((2, 1), dtype=int) + numpy.testing.assert_array_equal(_mutate(ga, method, solution, monkeypatch), + numpy.zeros((2, 1), dtype=int)) + + +@pytest.mark.parametrize("method", ["mutation_probs_by_space", + "adaptive_mutation_probs_by_space"]) +def test_zero_probability_does_not_trigger_swaps(method, monkeypatch): + probability = [0.0, 0.0] if method.startswith("adaptive") else 0.0 + ga = _make_ga(method, mutation_probability=probability) + solution = numpy.array([[0, 1], [1, 0]]) + before = solution.copy() + numpy.testing.assert_array_equal(_mutate(ga, method, solution, monkeypatch), before) + + +@pytest.mark.parametrize("method", SPACE_MUTATIONS) +def test_full_ga_run_keeps_permutations_with_correct_gene_types(method): + populations = [] + for _ in range(2): + ga = _make_ga(method, gene_type=[numpy.int16, numpy.float32], + keep_elitism=0, keep_parents=0) + ga.run() + for solution in ga.population: + assert sorted(solution) == [0, 1] + assert isinstance(solution[0], numpy.int16) + assert isinstance(solution[1], numpy.float32) + populations.append(ga.population.copy()) + numpy.testing.assert_array_equal(*populations) From fdc0cae437bd3be7fc5bf27db536b3ee875f30d4 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 01:42:02 -0400 Subject: [PATCH 3/3] Allow PR change summaries for fork contributions Run the metadata-only chart job with the trusted base workflow. Add a manual trigger to verify and refresh summaries without checking out contributor code. --- .github/workflows/pr-change-chart.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-change-chart.yml b/.github/workflows/pr-change-chart.yml index be568077..8353c057 100644 --- a/.github/workflows/pr-change-chart.yml +++ b/.github/workflows/pr-change-chart.yml @@ -6,8 +6,16 @@ name: PR change chart # the PR API, so there is no checkout, no build, and no third-party action. on: - pull_request: + # Use the trusted base workflow so fork PRs can receive a summary comment. + # This job must never check out or execute code from the pull request. + pull_request_target: types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pull_request_number: + description: Pull request to summarize + required: true + type: string permissions: contents: read @@ -26,11 +34,15 @@ jobs: const MARKER = ''; const TOP = 15; // cap rows so the chart/table stay readable const { owner, repo } = context.repo; - const pr = context.payload.pull_request; + const pullNumber = Number(context.payload.pull_request?.number + ?? context.payload.inputs?.pull_request_number); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error('A positive pull request number is required.'); + } // Per-file additions/deletions come straight from the PR API. const files = await github.paginate(github.rest.pulls.listFiles, { - owner, repo, pull_number: pr.number, per_page: 100, + owner, repo, pull_number: pullNumber, per_page: 100, }); let totalAdd = 0, totalDel = 0; @@ -86,11 +98,11 @@ jobs: // Upsert: update our marked comment if it exists, else create it. const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: pr.number, per_page: 100, + owner, repo, issue_number: pullNumber, per_page: 100, }); const mine = comments.find(c => c.body && c.body.includes(MARKER)); if (mine) { await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); } else { - await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); }