diff --git a/.github/workflows/pr-change-chart.yml b/.github/workflows/pr-change-chart.yml index be56807..8353c05 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 }); } diff --git a/docs/source/adaptive_mutation.md b/docs/source/adaptive_mutation.md index 9ab5883..921a8dd 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 247e3e7..dc84ae5 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 b604237..0cbe256 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 7aaa9ec..294ed9b 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 f2eb03a..16032fd 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 fec9065..007ba33 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 f1051e7..fba9e31 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 184750f..7c26a75 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -74,12 +74,23 @@ 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) + 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, + swapped_genes=swapped_genes) + 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) @@ -112,13 +123,24 @@ 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, 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, + swapped_genes=swapped_genes) + 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 +223,107 @@ 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, + 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 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 + ---------- + solution : numpy.ndarray + 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 + ------- + 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 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 = [] + 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, 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): """ Mutate ``self.mutation_num_genes`` genes per offspring by @@ -694,12 +817,23 @@ 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) + 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, + swapped_genes=swapped_genes) + 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) @@ -837,14 +971,25 @@ 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], 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, + swapped_genes=swapped_genes) + 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 493c322..1a0f92e 100644 --- a/tests/test_crossover_mutation.py +++ b/tests/test_crossover_mutation.py @@ -241,6 +241,42 @@ 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) + + def test_two_points_crossover_manual_call(): # Both points are random: the genes between them form 1 segment of any length from 1 to num_genes. num_genes = 10 @@ -333,6 +369,15 @@ def test_swap_mutation_manual_call(): 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() + test_two_points_crossover_manual_call() print() diff --git a/tests/test_permutation_mutation.py b/tests/test_permutation_mutation.py new file mode 100644 index 0000000..ffdd51a --- /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)