diff --git a/.gitignore b/.gitignore index 2945cf9..9e2575d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,14 @@ __pycache__/ # ============================================================================= docs/build/ .venv-docs/ + +# ============================================================================= +# Local virtual environment (setup_venv.sh). +# ============================================================================= +.venv/ + +# ============================================================================= +# IDE settings: JetBrains (PyCharm, IntelliJ, etc.) workspace files. +# These are personal to each developer and should not be committed. +# ============================================================================= +.idea/ diff --git a/README.md b/README.md index 403cac9..08d7406 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ [PyGAD](https://pypi.org/project/pygad) is an open-source easy-to-use Python 3 library for building the genetic algorithm and optimizing machine learning algorithms. It supports Keras and PyTorch. PyGAD supports optimizing both single-objective and multi-objective problems. -> Try the [Optimization Gadget](https://optimgadget.com), a free cloud-based tool powered by PyGAD. It makes optimization easier by reducing or removing the need for coding, and it shows helpful visualizations. +> Try [Vilvik](https://vilvik.com), a free cloud-based tool powered by PyGAD. It makes optimization easier by reducing or removing the need for coding, and it shows helpful visualizations. Read the [PyGAD documentation](https://pygad.readthedocs.io/en/latest). [![PyPI Downloads](https://pepy.tech/badge/pygad)](https://pepy.tech/project/pygad) [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/pygad.svg?label=Conda%20Downloads)]( -https://anaconda.org/conda-forge/PyGAD) [![PyPI version](https://badge.fury.io/py/pygad.svg)](https://badge.fury.io/py/pygad)![Docs](https://readthedocs.org/projects/pygad/badge)[![PyGAD PyTest / Python 3.13](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py313.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py313.yml) [![PyGAD PyTest / Python 3.12](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py312.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py312.yml) [![PyGAD PyTest / Python 3.11](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py311.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py311.yml) [![PyGAD PyTest / Python 3.10](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py310.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py310.yml) [![PyGAD PyTest / Python 3.9](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py39.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py39.yml) [![PyGAD PyTest / Python 3.8](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py38.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main_py38.yml) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Translation](https://hosted.weblate.org/widgets/weblate/-/svg-badge.svg)](https://hosted.weblate.org/engage/weblate/) [![REUSE](https://api.reuse.software/badge/github.com/WeblateOrg/weblate)](https://api.reuse.software/info/github.com/WeblateOrg/weblate) [![Stack Overflow](https://img.shields.io/badge/stackoverflow-Ask%20questions-blue.svg)]( +https://anaconda.org/conda-forge/PyGAD) [![PyPI version](https://badge.fury.io/py/pygad.svg)](https://badge.fury.io/py/pygad)![Docs](https://readthedocs.org/projects/pygad/badge)[![PyGAD PyTest / Python 3.13](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/main.yml) [![PyGAD PyTest / Python 3.12](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/release.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/release.yml) [![PyGAD PyTest / Python 3.11](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/scorecard.yml/badge.svg)](https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/workflows/scorecard.yml) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Translation](https://hosted.weblate.org/widgets/weblate/-/svg-badge.svg)](https://hosted.weblate.org/engage/weblate/) [![REUSE](https://api.reuse.software/badge/github.com/WeblateOrg/weblate)](https://api.reuse.software/info/github.com/WeblateOrg/weblate) [![Stack Overflow](https://img.shields.io/badge/stackoverflow-Ask%20questions-blue.svg)]( https://stackoverflow.com/questions/tagged/pygad) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/ahmedfgad/GeneticAlgorithmPython/badge)](https://securityscorecards.dev/viewer/?uri=github.com/ahmedfgad/GeneticAlgorithmPython) [![DOI](https://zenodo.org/badge/DOI/10.1007/s11042-023-17167-y.svg)](https://doi.org/10.1007/s11042-023-17167-y) ![PYGAD-LOGO](https://user-images.githubusercontent.com/16560492/101267295-c74c0180-375f-11eb-9ad0-f8e37bd796ce.png) @@ -18,7 +18,6 @@ The library is under active development and more features are added regularly. I # Donation -* [Credit/Debit Card](https://donate.stripe.com/eVa5kO866elKgM0144): https://donate.stripe.com/eVa5kO866elKgM0144 * [Open Collective](https://opencollective.com/pygad): [opencollective.com/pygad](https://opencollective.com/pygad) * PayPal: Use either this link: [paypal.me/ahmedfgad](https://paypal.me/ahmedfgad) or the e-mail address ahmed.f.gad@gmail.com * Interac e-Transfer: Use e-mail address ahmed.f.gad@gmail.com @@ -45,18 +44,6 @@ pip install pygad[deep_learning] To get started with PyGAD, read the documentation at [Read the Docs](https://pygad.readthedocs.io). -# PyGAD Source Code - -The source code of the PyGAD modules is in the following GitHub projects: - -- [pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython): (https://github.com/ahmedfgad/GeneticAlgorithmPython) -- [pygad.nn](https://github.com/ahmedfgad/NumPyANN): https://github.com/ahmedfgad/NumPyANN -- [pygad.gann](https://github.com/ahmedfgad/NeuralGenetic): https://github.com/ahmedfgad/NeuralGenetic -- [pygad.cnn](https://github.com/ahmedfgad/NumPyCNN): https://github.com/ahmedfgad/NumPyCNN -- [pygad.gacnn](https://github.com/ahmedfgad/CNNGenetic): https://github.com/ahmedfgad/CNNGenetic -- [pygad.kerasga](https://github.com/ahmedfgad/KerasGA): https://github.com/ahmedfgad/KerasGA -- [pygad.torchga](https://github.com/ahmedfgad/TorchGA): https://github.com/ahmedfgad/TorchGA - # PyGAD Documentation The PyGAD documentation is available at [Read the Docs](https://pygad.readthedocs.io) at this link: https://pygad.readthedocs.io. It explains the modules supported by PyGAD and all its classes, methods, attributes, and functions. For each module, several examples are given. @@ -304,7 +291,4 @@ If you used PyGAD, please consider adding a citation to the following paper abou * E-mail: ahmed.f.gad@gmail.com * [LinkedIn](https://www.linkedin.com/in/ahmedfgad) -* [Paperspace](https://blog.paperspace.com/author/ahmed) -* [KDnuggets](https://kdnuggets.com/author/ahmed-gad) -* [TowardsDataScience](https://towardsdatascience.com/@ahmedfgad) * [GitHub](https://github.com/ahmedfgad) diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md new file mode 100644 index 0000000..247e3e7 --- /dev/null +++ b/docs/source/benchmarks.md @@ -0,0 +1,163 @@ +# Benchmark Problems + +PyGAD bundles common benchmark problems under `pygad.benchmarks`. Each problem is a class callable with `(ga, solution, sol_idx)` and returns a fitness in PyGAD's maximisation format. Minimisation values are negated. + +Class attributes for setting up the GA: + +- `num_genes`: number of decision variables. +- `num_objectives`: number of objectives (`1` for single-objective). +- `bounds`: `(low, high)` tuple of variable bounds. + +ZDT classes also have a `pareto_front(num_points)` method that returns true-front reference points. Pass these to the IGD or GD indicators as `reference_front`. + +A runnable example per benchmark lives under `examples/benchmarks/`. + +## Single-Objective Problems + +Available in `pygad.benchmarks.classic`: + +| Class | Global minimum | Bounds | +|---|---|---| +| `Sphere` | f(0, ..., 0) = 0 | `(-5.12, 5.12)` | +| `Rastrigin` | f(0, ..., 0) = 0 | `(-5.12, 5.12)` | +| `Rosenbrock` | f(1, ..., 1) = 0 | `(-5.0, 10.0)` | +| `Griewank` | f(0, ..., 0) = 0 | `(-600.0, 600.0)` | +| `Schwefel` | f(420.97, ..., 420.97) ≈ 0 | `(-500.0, 500.0)` | +| `Ackley` | f(0, ..., 0) = 0 | `(-32.768, 32.768)` | +| `Himmelblau` | four equal minima at f = 0 (2D only) | `(-5.0, 5.0)` | + +## Multi-Objective Problems (ZDT family) + +In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5, 5]` for the rest). + +| Class | Pareto front shape | +|---|---| +| `ZDT1` | convex | +| `ZDT2` | non-convex | +| `ZDT3` | disconnected (five pieces) | +| `ZDT4` | convex, many local minima in the search space | +| `ZDT6` | non-uniform | + +## Many-Objective Problems (DTLZ family) + +In `pygad.benchmarks.dtlz`. Any number of objectives `M`. Decision variables: `M + k - 1`, where `k` is the distance-variable count. + +| Class | Default M | Pareto front shape | +|---|---|---| +| `DTLZ1` | 3 | linear hyperplane (`sum(f_i) = 0.5`) | +| `DTLZ2` | 3 | unit sphere first orthant | +| `DTLZ3` | 3 | unit sphere with hard multimodal g-function | +| `DTLZ4` | 3 | unit sphere with strong bias toward one corner | + +## Combinatorial Problems + +Two combinatorial benchmarks: 0/1 `Knapsack` and `TSP`. + +### Knapsack + +In `pygad.benchmarks.knapsack`. `Knapsack` takes three arguments: 1D arrays of `weights` and `values`, and a numeric `capacity`. A solution is a binary vector (1 = pick the item). Fitness is the total value within capacity, or a negative penalty scaled by the overweight amount. + +Class attributes `gene_space=[0, 1]` and `gene_type=int` plug into PyGAD as is: + +```python +import pygad +from pygad.benchmarks.knapsack import Knapsack + +problem = Knapsack(weights=[2, 3, 4, 5], + values=[3, 4, 5, 6], + capacity=5) + +ga = pygad.GA( + num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, +) +ga.run() +``` + +### Travelling Salesman Problem + +In `pygad.benchmarks.tsp`. Build `TSP` from either a 2D `coordinates` array or a square `distance_matrix`. A solution is a permutation of city indices and the fitness is the negative tour length (the tour closes back to the start). Non-permutation candidates get a large negative penalty. + +Class attributes `gene_space=list(range(num_cities))`, `gene_type=int`, and `allow_duplicate_genes=False` keep the permutation constraint: + +```python +import pygad +from pygad.benchmarks.tsp import TSP + +problem = TSP(coordinates=[[0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], + [0.0, 1.0]]) + +ga = pygad.GA( + num_generations=200, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, + allow_duplicate_genes=problem.allow_duplicate_genes, +) +ga.run() +``` + +## Example: SOO + +```python +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=10) + +ga = pygad.GA( + num_generations=100, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20, +) +ga.run() +``` + +## Example: MOO + +```python +import pygad +from pygad.benchmarks.zdt import ZDT1 +from pygad.utils.quality_indicators import inverted_generational_distance + +problem = ZDT1(num_genes=10) + +ga = pygad.GA( + num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20, +) +ga.run() + +# IGD against the true front. +true_front = problem.pareto_front(num_points=100) +igd = inverted_generational_distance(ga.last_generation_fitness, true_front) +print(f'IGD = {igd}') +``` diff --git a/docs/source/figures/plot_fitness.png b/docs/source/figures/plot_fitness.png new file mode 100644 index 0000000..7851ac5 Binary files /dev/null and b/docs/source/figures/plot_fitness.png differ diff --git a/docs/source/figures/plot_fitness_band.png b/docs/source/figures/plot_fitness_band.png new file mode 100644 index 0000000..10d8eee Binary files /dev/null and b/docs/source/figures/plot_fitness_band.png differ diff --git a/docs/source/figures/plot_genes.png b/docs/source/figures/plot_genes.png new file mode 100644 index 0000000..5a603ff Binary files /dev/null and b/docs/source/figures/plot_genes.png differ diff --git a/docs/source/figures/plot_new_solution_rate.png b/docs/source/figures/plot_new_solution_rate.png new file mode 100644 index 0000000..319a279 Binary files /dev/null and b/docs/source/figures/plot_new_solution_rate.png differ diff --git a/docs/source/figures/plot_non_dominated_hypervolume.png b/docs/source/figures/plot_non_dominated_hypervolume.png new file mode 100644 index 0000000..b3d8cc1 Binary files /dev/null and b/docs/source/figures/plot_non_dominated_hypervolume.png differ diff --git a/docs/source/figures/plot_pareto_front_curve_2d.png b/docs/source/figures/plot_pareto_front_curve_2d.png new file mode 100644 index 0000000..ab46921 Binary files /dev/null and b/docs/source/figures/plot_pareto_front_curve_2d.png differ diff --git a/docs/source/figures/plot_pareto_front_curve_3d.png b/docs/source/figures/plot_pareto_front_curve_3d.png new file mode 100644 index 0000000..89873d2 Binary files /dev/null and b/docs/source/figures/plot_pareto_front_curve_3d.png differ diff --git a/docs/source/figures/plot_pareto_front_evolution.png b/docs/source/figures/plot_pareto_front_evolution.png new file mode 100644 index 0000000..b754105 Binary files /dev/null and b/docs/source/figures/plot_pareto_front_evolution.png differ diff --git a/docs/source/figures/plot_pareto_front_heatmap.png b/docs/source/figures/plot_pareto_front_heatmap.png new file mode 100644 index 0000000..3ad2d2a Binary files /dev/null and b/docs/source/figures/plot_pareto_front_heatmap.png differ diff --git a/docs/source/figures/plot_pareto_front_pcp.png b/docs/source/figures/plot_pareto_front_pcp.png new file mode 100644 index 0000000..4feadef Binary files /dev/null and b/docs/source/figures/plot_pareto_front_pcp.png differ diff --git a/docs/source/figures/plot_pareto_front_scatter_matrix.png b/docs/source/figures/plot_pareto_front_scatter_matrix.png new file mode 100644 index 0000000..179388f Binary files /dev/null and b/docs/source/figures/plot_pareto_front_scatter_matrix.png differ diff --git a/docs/source/figures/plot_population_diversity.png b/docs/source/figures/plot_population_diversity.png new file mode 100644 index 0000000..c0ce865 Binary files /dev/null and b/docs/source/figures/plot_population_diversity.png differ diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 58752a4..b604237 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -174,6 +174,19 @@ 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? + +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: + +* The genes that were **already processed** in the current step hold their **updated** values (i.e. the value just selected for that gene). +* The genes that were **not processed yet** still hold their **previous** values: the values inherited from crossover (when mutating offspring) or the values originally sampled (when creating the initial population). + +When the genes are processed in index order (this is the case while creating the initial population and when using probability-based mutation via `mutation_probability`), this means that for the gene at index `i`, the entries `solution[:i]` already hold their updated values while `solution[i:]` still hold their previous values. + +This is exactly why dependent genes must be ordered so that a gene appears **after** the genes it depends on: by the time a later gene's constraint runs, the earlier genes it reads have already been finalized. See the note at the end of this section. + +> **Note:** The callable receives a copy of the solution, so modifying it inside the callable has no effect on the actual solution. Only the values returned from the callable are used. + A lambda function is used in this case but we can use a regular function: ```python diff --git a/docs/source/index.md b/docs/source/index.md index 9b8f750..62de4da 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -144,7 +144,7 @@ There is much more you can do with PyGAD. Read the documentation to explore its 6. The `kerasga` module trains [Keras](https://keras.io) models using the genetic algorithm. 7. The `torchga` module trains [PyTorch](https://pytorch.org) models using the genetic algorithm. 8. The `visualize` module visualizes the results. -9. The `utils` module holds the operators (crossover, mutation, and parent selection) and the NSGA-II code. +9. The `utils` module holds the operators (crossover, mutation, and parent selection) and the NSGA-II and NSGA-III code. 10. The `helper` module has some helper functions. The documentation explains these modules. diff --git a/docs/source/multi_objective.md b/docs/source/multi_objective.md index 03c855c..2779194 100644 --- a/docs/source/multi_objective.md +++ b/docs/source/multi_objective.md @@ -1,6 +1,6 @@ # Multi-Objective Optimization -In [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0), the library added support for multi-objective optimization using the non-dominated sorting genetic algorithm II (NSGA-II). The code is almost the same as the code for single-objective optimization, except for one difference: the return value of the fitness function. +In [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0), the library added support for multi-objective optimization using the non-dominated sorting genetic algorithm II (NSGA-II). The non-dominated sorting genetic algorithm III (NSGA-III) was added in a later release. The code is almost the same as the code for single-objective optimization, except for one difference: the return value of the fitness function. In single-objective optimization, the fitness function returns a single numeric value. In this example, the variable `fitness` is expected to be a numeric value. @@ -26,10 +26,14 @@ Whenever the fitness function returns an iterable of these data types, then the Other than the fitness function, everything else could be the same in both single and multi-objective problems. -But it is recommended to use one of these 2 parent selection operators to solve multi-objective problems: +But it is recommended to use one of these 4 parent selection operators to solve multi-objective problems: 1. `nsga2`: This selects the parents based on non-dominated sorting and crowding distance. 2. `tournament_nsga2`: This selects the parents using tournament selection which uses non-dominated sorting and crowding distance to rank the solutions. +3. `nsga3`: This selects the parents based on non-dominated sorting and niching against a structured grid of reference points. Useful when the number of objectives is 4 or more because crowding distance loses its discrimination in high-dimensional objective spaces. Requires the `nsga3_num_divisions` parameter to be set. +4. `tournament_nsga3`: This selects the parents using tournament selection which uses non-dominated sorting and niche count (instead of crowding distance) to rank the solutions. Requires the `nsga3_num_divisions` parameter to be set. + +When using `nsga3` or `tournament_nsga3`, the `nsga3_num_divisions` parameter must be a positive integer. It is the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. If `sol_per_pop` is smaller than the number of reference points, PyGAD warns and grows the population to match. This is a multi-objective optimization example that optimizes these 2 linear functions: @@ -115,3 +119,53 @@ Predicted output 2 based on the best solution : 29.99714270722312 This is the figure created by the `plot_fitness()` method. The fitness of the first objective has the green color. The blue color is used for the second objective fitness. ![multi-objective-pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython/assets/16560492/7896f8d8-01c5-4ff9-8d15-52191c309b63) + +## NSGA-III Example + +This is the same problem solved with `nsga3` instead of `nsga2`. The only differences are the `parent_selection_type` value and the new `nsga3_num_divisions` parameter. + +```python +import pygad +import numpy + +function_inputs1 = [4,-2,3.5,5,-11,-4.7] +function_inputs2 = [-2,0.7,-9,1.4,3,5] +desired_output1 = 50 +desired_output2 = 30 + +def fitness_func(ga_instance, solution, solution_idx): + output1 = numpy.sum(solution*function_inputs1) + output2 = numpy.sum(solution*function_inputs2) + fitness1 = 1.0 / (numpy.abs(output1 - desired_output1) + 0.000001) + fitness2 = 1.0 / (numpy.abs(output2 - desired_output2) + 0.000001) + return [fitness1, fitness2] + +num_generations = 100 +num_parents_mating = 10 + +sol_per_pop = 20 +num_genes = len(function_inputs1) + +ga_instance = pygad.GA(num_generations=num_generations, + num_parents_mating=num_parents_mating, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + fitness_func=fitness_func, + parent_selection_type='nsga3', + nsga3_num_divisions=12) + +ga_instance.run() + +ga_instance.plot_fitness(label=['Obj 1', 'Obj 2']) + +solution, solution_fitness, solution_idx = ga_instance.best_solution(ga_instance.last_generation_fitness) +print(f"Parameters of the best solution : {solution}") +print(f"Fitness value of the best solution = {solution_fitness}") + +prediction = numpy.sum(numpy.array(function_inputs1)*solution) +print(f"Predicted output 1 based on the best solution : {prediction}") +prediction = numpy.sum(numpy.array(function_inputs2)*solution) +print(f"Predicted output 2 based on the best solution : {prediction}") +``` + +For M = 2 objectives and `nsga3_num_divisions = 12`, the number of reference points is `C(13, 12) = 13`, which is within `sol_per_pop = 20`. For higher-dimensional problems pick `nsga3_num_divisions` such that `C(M + p - 1, p)` stays close to the population size you want. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index b7b1f68..f7432b8 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -55,12 +55,16 @@ Introduced in [PyGAD 2.0.0](https://pygad.readthedocs.io/en/latest/releases.html One or more conditions that stop the evolution early. Each criterion is a string made of a stop word and a number, like `"reach_40"`. -Two stop words are supported: +Four stop words are supported: - `reach`: stop when the fitness is greater than or equal to a given value. Example: `"reach_40"` stops once the fitness is `>= 40`. - `saturate`: stop when the fitness does not change for a given number of generations. Example: `"saturate_7"` stops if the fitness stays the same for 7 generations in a row. +- `time`: stop when the time spent inside `run()` is at least the given number of seconds. Example: `"time_30"` stops the run after 30 seconds. +- `evaluations`: stop when the number of fitness function calls made inside `run()` reaches the given count. Example: `"evaluations_1000"` stops the run once 1000 calls have been made. -Added in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). +You can also pass a list of criteria; the run stops as soon as any one of them is met. + +Added in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). The `time` and `evaluations` keywords were added in PyGAD 3.6.0. ::: #### Fitness Function @@ -185,6 +189,10 @@ The built-in types are: - `rank`: rank selection. - `random`: random selection. - `tournament`: tournament selection. +- `nsga2`: NSGA-II selection (multi-objective). +- `tournament_nsga2`: Tournament selection that ranks competitors with NSGA-II non-dominated sorting and crowding distance. +- `nsga3`: NSGA-III selection (multi-objective). Requires the `nsga3_num_divisions` parameter. +- `tournament_nsga3`: Tournament selection that ranks competitors with NSGA-III niche count instead of crowding distance. Requires the `nsga3_num_divisions` parameter. You can also pass your own parent selection function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). ::: @@ -195,6 +203,14 @@ You can also pass your own parent selection function (since [PyGAD 2.16.0](https In case that the parent selection type is `tournament`, the `K_tournament` specifies the number of parents participating in the tournament selection. It defaults to `3`. ::: +:::{dropdown} `nsga3_num_divisions=None`: Number of divisions per objective axis for NSGA-III. +:animate: fade-in-slide-down + +Only used when `parent_selection_type` is `'nsga3'` or `'tournament_nsga3'`. It is the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. Must be a positive integer. Defaults to `None`. + +If `sol_per_pop` is smaller than the resulting number of reference points, PyGAD raises a warning and grows the population to match before the generational loop starts. +::: + #### Keeping Solutions :::{dropdown} `keep_elitism=1`: Keep the best solutions each generation. @@ -239,12 +255,19 @@ The built-in types are: - `two_points`: two-point crossover. - `uniform`: uniform crossover. - `scattered`: scattered crossover (since [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0)). +- `sbx`: simulated binary crossover. The standard real-coded operator. Requires the `sbx_crossover_eta` parameter. You can also pass your own crossover function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). If `crossover_type=None`, the crossover step is skipped and no offspring are created, so the next generation reuses the current population (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). ::: +:::{dropdown} `sbx_crossover_eta=30`: Distribution index for SBX crossover. +: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`. +::: + :::{dropdown} `crossover_probability=None`: Chance a parent is used for crossover. :animate: fade-in-slide-down @@ -269,12 +292,19 @@ The built-in types are: - `inversion`: inversion mutation. - `scramble`: scramble mutation. - `adaptive`: adaptive mutation (since [PyGAD 2.10.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-10-0)). See [Adaptive Mutation](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#adaptive-mutation) and [Use Adaptive Mutation in PyGAD](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#use-adaptive-mutation-in-pygad). +- `polynomial`: polynomial mutation. The standard real-coded operator used together with SBX. Requires the `polynomial_mutation_eta` parameter. You can also pass your own mutation function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). If `mutation_type=None`, the mutation step is skipped and the offspring are used unchanged (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). ::: +:::{dropdown} `polynomial_mutation_eta=20`: Distribution index for polynomial mutation. +:animate: fade-in-slide-down + +Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A higher value means a smaller change. Must be a positive number. Defaults to `20`. +::: + :::{dropdown} `mutation_probability=None`: Per-gene chance of mutation. :animate: fade-in-slide-down @@ -467,23 +497,29 @@ To keep the library modular and structured, the code is split into several scrip Here is the list of scripts and the classes that the `pygad.GA` class extends: 1. `utils/engine.py`: - 1. `utils.engine.GAEngine`: + 1. `utils.engine.GAEngine`: Runs the GA loop and owns the run-time lifecycle helpers. 2. `utils/validation.py` - 1. `utils.validation.Validation` + 1. `utils.validation.Validation`: Validates every constructor parameter and dispatches `parent_selection_type` / `crossover_type` / `mutation_type` to the right method. 3. `utils/parent_selection.py` - 1. `utils.parent_selection.ParentSelection` + 1. `utils.parent_selection.ParentSelection`: All built-in parent selection operators, including `nsga2_selection`, `tournament_selection_nsga2`, `nsga3_selection`, and `tournament_selection_nsga3`. 4. `utils/crossover.py` - 1. `utils.crossover.Crossover` + 1. `utils.crossover.Crossover`: Built-in crossover operators. 5. `utils/mutation.py` - 1. `utils.mutation.Mutation` -6. `utils/nsga2.py` - 1. `utils.nsga2.NSGA2` -7. `helper/unique.py` - 1. `helper.unique.Unique` -8. `helper/misc.py` - 1. `helper.misc.Helper` -9. `visualize/plot.py` - 1. `visualize.plot.Plot` + 1. `utils.mutation.Mutation`: Built-in mutation operators. +6. `utils/nsga.py` + 1. `utils.nsga.NSGA`: Building blocks shared by NSGA-II and NSGA-III (`non_dominated_sorting`, `get_non_dominated_set`). +7. `utils/nsga2.py` + 1. `utils.nsga2.NSGA2`: NSGA-II specific primitives (`crowding_distance`, `sort_solutions_nsga2`). +8. `utils/nsga3.py` + 1. `utils.nsga3.NSGA3`: NSGA-III algorithm primitives (reference points, ideal point, extreme points, intercepts, normalization, association, niching). +9. `utils/report.py` + 1. `utils.report.Report`: Builds a PDF report of the run (`generate_report`). +10. `helper/unique.py` + 1. `helper.unique.Unique`: Routines that resolve duplicate genes inside a solution. +11. `helper/misc.py` + 1. `helper.misc.Helper`: Generic helpers used across the library (population dtype handling, per-gene value generation, constraint sampling, lifecycle summary). +12. `visualize/plot.py` + 1. `visualize.plot.Plot`: All plot methods. See [`pygad.visualize`](https://pygad.readthedocs.io/en/latest/visualize.html). Since the `pygad.GA` class extends such classes, the attributes and methods inside them can be retrieved by instances of the `pygad.GA` class. @@ -495,53 +531,222 @@ Since the `pygad.GA` class extends such classes, the attributes and methods insi ### Other Instance Attributes & Methods -All the parameters and functions passed to the `pygad.GA` class constructor are used as class attributes and methods in the instances of the `pygad.GA` class. In addition to such attributes, there are other attributes and methods added to the instances of the `pygad.GA` class: - -The next 2 subsections list such attributes and methods. +All the parameters and functions passed to the `pygad.GA` class constructor are used as class attributes and methods in the instances of the `pygad.GA` class. In addition to such attributes, there are other attributes and methods added to the instances of the `pygad.GA` class. > The `GA` class gains the attributes of its parent classes via inheritance, making them accessible through the `GA` object even if they are defined externally to its specific class body. -#### Other Attributes +> Names that begin with an underscore (for example `_bootstrap_nsga3_reference_points`) are internal helpers. They are listed below for completeness but are not part of the stable API; do not rely on their signature staying the same across releases. -- `generations_completed`: Holds the number of the last completed generation. -- `population`: A NumPy array that initially holds the initial population and is later updated after each generation. -- `valid_parameters`: Set to `True` when all the parameters passed in the `GA` class constructor are valid. +#### Lifecycle + +##### Attributes + +- `generations_completed`: Number of the last completed generation. - `run_completed`: Set to `True` only after the `run()` method completes gracefully. -- `pop_size`: The population size. -- `best_solutions_fitness`: A list holding the fitness values of the best solutions for all generations. -- `best_solution_generation`: The generation number at which the best fitness value is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. -- `best_solutions`: A NumPy array holding the best solution per each generation. It only exists when the `save_best_solutions` parameter in the `pygad.GA` class constructor is set to `True`. -- `last_generation_fitness`: The fitness values of the solutions in the last generation. [Added in PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). -- `previous_generation_fitness`: At the end of each generation, the fitness of the most recent population is saved in the `last_generation_fitness` attribute. The fitness of the population exactly preceding this most recent population is saved in the `previous_generation_fitness` attribute. This `previous_generation_fitness` attribute is used to fetch the pre-calculated fitness instead of calling the fitness function for already explored solutions. [Added in PyGAD 2.16.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-2). -- `last_generation_parents`: The parents selected from the last generation. [Added in PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). -- `last_generation_offspring_crossover`: The offspring generated after applying the crossover in the last generation. [Added in PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). -- `last_generation_offspring_mutation`: The offspring generated after applying the mutation in the last generation. [Added in PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). -- `gene_type_single`: A flag that is set to `True` if the `gene_type` parameter is assigned to a single data type that is applied to all genes. If `gene_type` is assigned a `list`, `tuple`, or `numpy.ndarray`, then the value of `gene_type_single` will be `False`. [Added in PyGAD 2.14.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-14-0). -- `last_generation_parents_indices`: This attribute holds the indices of the selected parents in the last generation. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). -- `last_generation_elitism`: This attribute holds the elitism of the last generation. It is effective only if the `keep_elitism` parameter has a non-zero value. Supported in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). -- `last_generation_elitism_indices`: This attribute holds the indices of the elitism of the last generation. It is effective only if the `keep_elitism` parameter has a non-zero value. Supported in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). -- `logger`: This attribute holds the logger from the `logging` module. Supported in [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0). -- `gene_space_unpacked`: This is the unpacked version of the `gene_space` parameter. For example, `range(1, 5)` is unpacked to `[1, 2, 3, 4]`. For an infinite range like `{'low': 2, 'high': 4}`, then it is unpacked to a limited number of values (e.g. 100). Supported in [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0). -- `pareto_fronts`: A new instance attribute named `pareto_fronts` added to the `pygad.GA` instances that holds the pareto fronts when solving a multi-objective problem. Supported in [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0). +- `valid_parameters`: Set to `True` when all the parameters passed in the `GA` class constructor are valid. +- `run_start_time`: Monotonic clock value captured right before the generation loop starts. Internal. +- `logger`: Logger object from the `logging` module. Supported in [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0). -Note that the attributes with names starting with `last_generation_` are updated after each generation. +##### Methods + +- `run()`: Runs the generation loop. The main entry point. +- `run_loop_head(best_solution_fitness)`: Per-generation pre-loop bookkeeping. Internal; called from inside `run()`. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). +- `run_select_parents(call_on_parents=True)`: Select parents and call `on_parents` when defined. Internal; called from inside `run()`. Pass `call_on_parents=False` when refreshing the parent set at the end of `run()`. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). +- `run_crossover()`: Apply crossover and call `on_crossover` when defined. Internal. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). +- `run_mutation()`: Apply mutation and call `on_mutation` when defined. Internal. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). +- `run_update_population()`: Replace `self.population` with the crossed-over and mutated offspring. Internal. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). +- `summary(...)`: Prints a Keras-like summary of the PyGAD lifecycle. Added in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). See [Print Lifecycle Summary](https://pygad.readthedocs.io/en/latest/logging.html#print-lifecycle-summary). + +#### Population and Initialization + +##### Attributes + +- `population`: A NumPy array that initially holds the initial population and is later updated after each generation. +- `initial_population`: Frozen copy of the initial population, set after `initialize_population` runs. +- `pop_size`: A `(sol_per_pop, num_genes)` tuple describing the population shape. +- `gene_type_single`: `True` when every gene shares the same dtype; `False` when `gene_type` is a list/tuple/numpy.ndarray. Added in [PyGAD 2.14.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-14-0). +- `gene_space_unpacked`: Unpacked version of `gene_space`. For example, `range(1, 5)` becomes `[1, 2, 3, 4]`; `{'low': 2, 'high': 4}` becomes a finite sample. Added in [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0). + +##### Methods + +- `initialize_population(allow_duplicate_genes, gene_type, gene_constraint)`: Build the initial population, apply gene types and constraints, resolve duplicates when not allowed. +- `initialize_parents_array(shape)`: Allocate an empty parents (or offspring) array with the right dtype. +- `change_population_dtype_and_round(population)`: Cast a 2D population to the dtype encoded in `self.gene_type` and round non-integer genes. +- `change_gene_dtype_and_round(gene_index, gene_value)`: Same as above, but for a single gene value. +- `round_genes(solutions)`: Round genes in a 2D array according to `self.gene_type` precision. +- `get_initial_population_range(gene_index)`: Return the `[init_range_low, init_range_high]` window for a specific gene. +- `get_random_mutation_range(gene_index)`: Return the `[random_mutation_min_val, random_mutation_max_val]` window for a specific gene. +- `get_gene_dtype(gene_index)`: Return the `(type, precision)` pair for a specific gene. +- `generate_gene_value(...)`: Sample a single gene value from the gene space or from the configured range. +- `generate_gene_value_from_space(...)`: Sample a single gene value from `gene_space`. +- `generate_gene_value_randomly(...)`: Sample a single gene value from the configured numeric range. + +#### Fitness + +##### Attributes + +- `last_generation_fitness`: Fitness values of the solutions in the last generation. Added in [PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). +- `previous_generation_fitness`: Fitness of the population one step before `last_generation_fitness`. Used to skip re-evaluating solutions PyGAD has already seen. Added in [PyGAD 2.16.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-2). +- `best_solutions_fitness`: List of best-solution fitness per generation. +- `best_solutions`: A NumPy array of the best solution per generation. Only populated when `save_best_solutions=True`. +- `best_solutions_fitness`: Fitness for every entry in `best_solutions`. +- `solutions`: All visited solutions when `save_solutions=True`. +- `solutions_fitness`: Fitness for every entry in `solutions`. +- `best_solution_generation`: Generation at which the best fitness was reached. `-1` until `run()` completes. + +##### Methods + +- `cal_pop_fitness()`: Compute the fitness of every solution in the current population, reusing previously calculated values where possible. +- `best_solution(pop_fitness=None)`: Return the best solution, its fitness, and its population index. +- `adaptive_mutation_population_fitness(offspring)`: Average fitness used by adaptive mutation to split solutions into low / high quality. + +#### Parent Selection (general) + +##### Attributes + +- `last_generation_parents`: Parents selected in the last generation. Added in [PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). +- `last_generation_parents_indices`: Indices of the selected parents in `self.population`. Added in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). + +##### Methods + +- `select_parents(fitness, num_parents)`: Active parent-selection method. Bound during validation according to `parent_selection_type`. +- `steady_state_selection(fitness, num_parents)`: Steady-state selection. +- `rank_selection(fitness, num_parents)`: Rank-based selection. +- `random_selection(fitness, num_parents)`: Random selection. +- `tournament_selection(fitness, num_parents)`: K-tournament selection. +- `roulette_wheel_selection(fitness, num_parents)`: Roulette-wheel selection. +- `stochastic_universal_selection(fitness, num_parents)`: SUS selection. +- `wheel_cumulative_probs(probs, num_parents)`: Build the `[start, end)` ranges used by RWS and SUS. + +#### Multi-Objective Optimization (NSGA-II) + +##### Attributes + +- `pareto_fronts`: List of the Pareto fronts of the last generation when running a multi-objective problem. Each front is a NumPy array of `(population_index, fitness_vector)` pairs. Added in [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0). + +##### Methods -#### Other Methods +- `non_dominated_sorting(fitness)`: Sort the population into Pareto fronts. Defined in `utils.nsga.NSGA` and shared with NSGA-III. +- `get_non_dominated_set(curr_solutions)`: Split the current set of solutions into a dominated and non-dominated subset. Defined in `utils.nsga.NSGA`. +- `crowding_distance(pareto_front, fitness)`: Per-solution crowding distance inside a Pareto front. Defined in `utils.nsga2.NSGA2`. +- `sort_solutions_nsga2(fitness, find_best_solution=False)`: Sort population indices best-to-worst using Pareto fronts and crowding distance for MOO; descending fitness for SOO. Defined in `utils.nsga2.NSGA2`. +- `nsga2_selection(fitness, num_parents)`: NSGA-II parent selection. Defined in `utils.parent_selection.ParentSelection`. +- `tournament_selection_nsga2(fitness, num_parents)`: K-tournament with non-dominated rank + crowding distance as tiebreakers. Defined in `utils.parent_selection.ParentSelection`. -- `cal_pop_fitness()`: A method that calculates the fitness values for all solutions within the population by calling the function passed to the `fitness_func` parameter for each solution. -- `crossover()`: Refers to the method that applies the crossover operator based on the selected type of crossover in the `crossover_type` property. -- `mutation()`: Refers to the method that applies the mutation operator based on the selected type of mutation in the `mutation_type` property. -- `select_parents()`: Refers to a method that selects the parents based on the parent selection type specified in the `parent_selection_type` attribute. -- `adaptive_mutation_population_fitness()`: Returns the average fitness value used in the adaptive mutation to filter the solutions. -- `summary()`: Prints a Keras-like summary of the PyGAD lifecycle. This helps to have an overview of the architecture. Supported in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). Check the [Print Lifecycle Summary](https://pygad.readthedocs.io/en/latest/logging.html#print-lifecycle-summary) section for more details and examples. -- 5 methods with names starting with `run_`. Their purpose is to keep the main loop inside the `run()` method clean. The details inside the loop are moved to 4 individual methods. Generally, any method with a name starting with `run_` is meant to be called by PyGAD from inside the `run()` method. Supported in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). - 1. `run_loop_head()`: The code before the loop starts. - 2. `run_select_parents(call_on_parents=True)`: Select the parents and call the callable `on_parents()` if defined. If `call_on_parents` is `True`, then the callable `on_parents()` is called. It must be `False` when the `run_select_parents()` method is called to update the parents at the end of the `run()` method. - 3. `run_crossover()`: Apply crossover and call the callable `on_crossover()` if defined. - 4. `run_mutation()`: Apply mutation and call the callable `on_mutation()` if defined. - 5. `run_update_population()`: Update the `population` attribute after completing the processes of crossover and mutation. +#### Multi-Objective Optimization (NSGA-III) -There are many methods that are not designed for user usage. Some of them are listed above but this is not a comprehensive list. The [release history](https://pygad.readthedocs.io/en/latest/releases.html) section usually covers them. Moreover, you can check the [PyGAD GitHub repository](https://github.com/ahmedfgad/GeneticAlgorithmPython) to find more. +##### Attributes + +- `nsga3_num_divisions`: Stored value of the `nsga3_num_divisions` constructor parameter. Used when building the reference grid. +- `nsga3_reference_points`: Structured grid of reference points on the unit simplex. A 2D NumPy array of shape `(n_points, num_objectives)` where each row sums to 1. Built once before the generation loop starts (`_bootstrap_nsga3_reference_points`). Re-used for every generation. + +##### Methods (algorithm primitives in `utils.nsga3.NSGA3`) + +- `nsga3_generate_reference_points(num_objectives, num_divisions)`: Build the Das-Dennis grid. +- `nsga3_compute_ideal_point(fitness)`: Best fitness per objective across the input rows (column max under maximization). +- `nsga3_find_extreme_points(fitness, ideal_point, epsilon=NSGA3_ASF_EPSILON)`: For each objective, find the row that best represents the corner of that axis using the ASF. +- `nsga3_compute_intercepts(extreme_points, ideal_point, fallback_fitness)`: Fit a hyperplane through the extreme points and return per-axis intercepts. Falls back to the nadir on singular systems. +- `nsga3_normalize_fitness(fitness, ideal_point, intercepts)`: Scale each fitness row to the unit hypercube and clip outliers to `[0, 1]`. +- `nsga3_associate_to_reference_points(normalized, reference_points)`: For every normalized row, find the closest reference line and the perpendicular distance to it. +- `nsga3_niching_select(critical_front_indices, critical_front_associations, critical_front_distances, accepted_associations, num_reference_points, num_to_select)`: Niching loop that picks survivors from the critical front to preserve diversity across reference points. + +##### Methods (selection in `utils.parent_selection.ParentSelection`) + +- `nsga3_selection(fitness, num_parents)`: NSGA-III parent selection. +- `tournament_selection_nsga3(fitness, num_parents)`: K-tournament with niche count + perpendicular distance as tiebreakers. +- `_nsga3_pick_critical_front_survivors(...)`: Run normalization and niching on `P_next ∪ critical_front` and return the picked survivors. Internal. +- `_nsga3_pick_tournament_winner(...)`: Decide the winner of one K-tournament round under NSGA-III rules. Internal. +- `_nsga3_build_parents(final_indices, num_parents)`: Copy the chosen rows out of the population into a new parents array. Internal. + +##### Methods (bootstrap and population growth in `utils.engine.GAEngine`) + +- `_bootstrap_nsga3_reference_points()`: Build the reference-point grid once, right after the first fitness evaluation. Calls `_nsga3_grow_population` when `sol_per_pop` is smaller than the reference count. +- `_nsga3_grow_population(required_size, num_objectives)`: Append random solutions to `self.population`, update `sol_per_pop` / `pop_size` / `num_offspring`, re-evaluate fitness. +- `_nsga3_generate_extra_random_solutions(count)`: Build `count` random solutions respecting the gene space, init range, gene type, gene constraints, and `allow_duplicate_genes` rules. +- `_nsga3_generate_single_random_gene(gene_idx, partial_solution)`: Sample a single gene value using initial-population settings (not mutation settings). +- `_nsga3_apply_gene_constraints(population)`: Enforce `gene_constraint` on the new rows. +- `_nsga3_resolve_duplicate_genes(population)`: Resolve duplicate genes in the new rows when `allow_duplicate_genes=False`. + +##### Module-level helpers (in `pygad.utils.nsga3`) + +- `NSGA3_ASF_EPSILON`: Off-axis weight used by the ASF inside `nsga3_find_extreme_points`. +- `NSGA3_INTERCEPT_NEAR_ZERO`: Threshold under which an intercept gap is treated as zero. +- `_nsga3_pick_target_reference_point(niche_counts, critical_front_associations, remaining_positions)`: Choose the next reference point for the niching loop. Internal. +- `_nsga3_pick_candidate_at_reference(candidates_at_target, critical_front_distances, niche_count_at_target)`: Choose a candidate at a given reference point. Internal. +- `_nsga3_enumerate_compositions(num_objectives, num_divisions)`: Yield every non-negative integer tuple summing to `num_divisions`. Internal. + +##### Module-level helpers (in `pygad.utils.parent_selection`) + +- `_nsga3_validate_multi_objective_fitness(fitness, supported_int_float_types, method_name)`: Raise when the GA was set to NSGA-III but the fitness function returned scalars. +- `_nsga3_accumulate_fronts(pareto_fronts, num_parents)`: Walk the Pareto fronts and split them into the accepted set + the critical front. + +#### Crossover + +##### Attributes + +- `last_generation_offspring_crossover`: Offspring after crossover. Added in [PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). + +##### Methods + +- `crossover()`: Active crossover operator. Bound during validation according to `crossover_type`. +- `single_point_crossover(parents, offspring_size)`: Single-point crossover. +- `two_points_crossover(parents, offspring_size)`: Two-point crossover. +- `uniform_crossover(parents, offspring_size)`: Uniform crossover. +- `scattered_crossover(parents, offspring_size)`: Scattered crossover. +- `sbx_crossover(parents, offspring_size)`: Simulated binary crossover. Uses `self.sbx_crossover_eta`. + +#### Mutation + +##### Attributes + +- `last_generation_offspring_mutation`: Offspring after mutation. Added in [PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). +- `last_generation_offspring_mutation_indices`: Indices of mutated offspring inside `self.population`. + +##### Methods + +- `mutation()`: Active mutation operator. Bound during validation according to `mutation_type`. +- `random_mutation(offspring)`: Random mutation (replaces or adds a uniform random value). +- `swap_mutation(offspring)`: Swap mutation. +- `inversion_mutation(offspring)`: Inversion mutation. +- `scramble_mutation(offspring)`: Scramble mutation. +- `adaptive_mutation(offspring)`: Adaptive mutation. Uses `adaptive_mutation_population_fitness`. +- `polynomial_mutation(offspring)`: Polynomial mutation. Uses `self.polynomial_mutation_eta`. +- `mutation_change_gene_dtype_and_round(...)`: Round and re-cast a mutated gene to the configured dtype/precision. + +#### Elitism + +##### Attributes + +- `last_generation_elitism`: Elitism solutions from the last generation. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). +- `last_generation_elitism_indices`: Population indices of `last_generation_elitism`. Added in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). + +#### Gene Constraints and Duplicate Resolution + +##### Methods + +- `validate_gene_constraint_callable_output(selected_values, values)`: Sanity-check the return value of a user-defined `gene_constraint`. +- `filter_gene_values_by_constraint(values, solution, gene_idx)`: Run `gene_constraint[gene_idx]` and return the filtered list. +- `get_valid_gene_constraint_values(...)`: Sample candidate values until one satisfies the gene constraint. +- `solve_duplicate_genes_randomly(...)`: Resolve duplicate genes by sampling new values from the random range. +- `solve_duplicate_genes_by_space(...)`: Resolve duplicate genes by sampling new values from `gene_space`. +- `solve_duplicates_deeply(...)`: Slow, exhaustive fallback for duplicate resolution. +- `unique_int_gene_from_range(...)`: Pick an integer gene that does not already appear in the solution. +- `unique_float_gene_from_range(...)`: Pick a float gene that does not already appear in the solution. +- `unique_gene_by_space(...)`: Pick a unique value from `gene_space`. +- `unique_genes_by_space(...)`: Pick unique values for several genes from `gene_space`. +- `select_unique_value(...)`: Sample one value uniformly at random from a list of candidates. +- `find_two_duplicates(solution)`: Locate the first pair of duplicated indices in a solution. +- `unpack_gene_space(...)`: Materialize the unpacked `gene_space` (used to build `gene_space_unpacked`). + +#### Saving, Loading, and Reporting + +##### Methods + +- `save(filename)`: Pickle the GA instance to disk (uses `cloudpickle`). +- `generate_report(filename, ...)`: Build a PDF report of the run. See [`generate_report()`](#generate-report) below. +- `push_to_vilvik(...)`: Optional convenience wrapper around the Vilvik SDK. + +Note that the attributes with names starting with `last_generation_` are updated after each generation. The next sections discuss the methods available in the `pygad.GA` class. @@ -553,6 +758,33 @@ Accepts the following parameter: * `filename`: Name of the file to save the instance. No extension is needed. +### `generate_report()` + +Builds a PDF report of the current GA run. It bundles the configuration table, a run-summary table, the best solution, and every applicable plot. Requires the optional `report` extra: + +``` +pip install pygad[report] +``` + +Call it after `run()` finishes. A minimal example: + +```python +ga_instance.run() +ga_instance.generate_report("my_run") # writes my_run.pdf next to the script +``` + +Parameters: + +- `filename` (`str`, required): Output path. `.pdf` is appended automatically if missing. +- `title` (`str` or `None`, default `None`): Title shown on the first page. Defaults to `"PyGAD run report"`. +- `sections` (iterable of `str` or `None`, default `None`): Sections to include and their order. Valid entries are `"title"`, `"configuration"`, `"run_summary"`, `"best_solution"`, `"plots"`, and `"notes"`. When `None`, every section is included in their default order. +- `include_plots` (iterable of `str`, `"all"`, or `None`, default `None`): Plots to embed under the `"plots"` section. `None` or `"all"` auto-selects every plot whose preconditions are met by this run. Pass a list of plot method names to include only those. +- `figure_size_inches` (`(float, float)`, default `(7.0, 4.5)`): Width and height (in inches) used when each plot is drawn for the report. +- `notes` (`str` or `None`, default `None`): Free-form text rendered in the optional `"notes"` section. +- `page_size` (`str`, default `"letter"`): Either `"letter"` or `"A4"`. + +The report skips any plot whose preconditions are not met. For example, `plot_pareto_front_curve` is included only for multi-objective runs with 2 or 3 objectives; `plot_non_dominated_hypervolume` is included only when `save_solutions=True` is set on the GA. A full example lives at [`examples/example_generate_report.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_generate_report.py). + ## Functions in `pygad` Besides the methods available in the `pygad.GA` class, this section discusses the functions available in `pygad`. Up to this time, there is only a single function named `load()`. diff --git a/docs/source/pygad_example_multi_objective.md b/docs/source/pygad_example_multi_objective.md index 272de0f..117da50 100644 --- a/docs/source/pygad_example_multi_objective.md +++ b/docs/source/pygad_example_multi_objective.md @@ -86,3 +86,19 @@ Predicted output 2 based on the best solution : 29.99714270722312 This is the figure created by the `plot_fitness()` method. The fitness of the first objective is shown in green, and the fitness of the second objective is shown in blue. ![multi-objective-pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython/assets/16560492/7896f8d8-01c5-4ff9-8d15-52191c309b63) + +## Using NSGA-III + +To solve the same problem with NSGA-III, change `parent_selection_type` to `'nsga3'` and add the `nsga3_num_divisions` parameter. The rest of the code is identical. + +```python +ga_instance = pygad.GA(num_generations=num_generations, + num_parents_mating=num_parents_mating, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + fitness_func=fitness_func, + parent_selection_type='nsga3', + nsga3_num_divisions=12) +``` + +`nsga3_num_divisions` is the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. With `M = 2` and `nsga3_num_divisions = 12`, the algorithm builds 13 reference points. diff --git a/docs/source/pygad_more.md b/docs/source/pygad_more.md index ea17ddd..8fc2e57 100644 --- a/docs/source/pygad_more.md +++ b/docs/source/pygad_more.md @@ -9,7 +9,7 @@ This section covers the more advanced features of the `pygad` module. Pick a top :link: multi_objective :link-type: doc -Optimize several objectives at once using NSGA-II. +Optimize several objectives at once using NSGA-II or NSGA-III. ::: :::{grid-item-card} Controlling Gene Values @@ -47,6 +47,13 @@ Print a Keras-like summary and log the outputs. Pass your own functions, methods, or classes for the fitness and callbacks. ::: +:::{grid-item-card} Benchmark Problems +:link: benchmarks +:link-type: doc + +Built-in single, multi, and many-objective benchmark problems to plug into the GA. +::: + :::: :::{toctree} @@ -58,4 +65,5 @@ generations fitness_calculation logging custom_functions +benchmarks ::: diff --git a/docs/source/releases.md b/docs/source/releases.md index 0a8c72b..bc7bbde 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -648,12 +648,55 @@ Release Date April 8, 2026 18. Fix a bug in the `visualize/plot.py` script that causes a warning to be given when the plot leged is used with single-objective problems. 19. A new method called `initialize_parents_array()` is added to the `Helper` class in the `pygad/helper/misc.py` script. It is usually called from the methods in the `ParentSelection` class in the `pygad/utils/parent_selection.py` script to initialize the parents array. 20. Add more tests about: - 1. Operators (crossover, mutation, and parent selection). - 2. The `best_solution()` method. - 3. Parallel processing. - 4. The `GANN` module. - 5. The plots created by the `visualize`. - + 1. Operators (crossover, mutation, and parent selection). + 2. The `best_solution()` method. + 3. Parallel processing. + 4. The `GANN` module. + 5. The plots created by the `visualize`. 21. Instead of using repeated code for converting the data type and rounding the genes during crossover and mutation, the `change_gene_dtype_and_round()` method is called from the `pygad.helper.misc.Helper` class. 22. Fix some documentation issues. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/336 23. Update the documentation to reflect the recent additions and changes to the library structure. + +## PyGAD 3.7.0 + +Release Date ..., 2026 + +1. Validation logic is applied to validate the `num_generations` parameter. +2. The `num_generations` parameter must be assigned a positive integer. Previously, any number (positive/negative, int/float) was accepted. +3. A new script called `activation.py` is added into the `pygad.helper` module to include the activation function used by the `cnn` and `nn` modules. +4. In the `pygad.parent_selection.ParentSelection` class, the `stochastic_universal_selection()` method now calls the `wheel_cumulative_probs()` method instead of repeating the code of calculating the probabilities used for parent selection. +5. The `wheel_cumulative_probs()` method in the `pygad.parent_selection.ParentSelection` class is refactored to reduce its computational time. +6. Use `numpy.where()` to decide which the source parent of each gene within the `uniform_crossover()` method in the `utils/crossover.py` script. The same was already applied to the `scattered_crossover()` method. +7. Add tests for the following modules: + `nn` + `cnn` + `gacnn` + `kerasga` + `torchga` +8. Fix a bug in the `visualize/plot.py` script where the `labels` parameter of `boxplot()` has been renamed `tick_labels` in Matplotlib. +9. Fix a bug where the `best_solutions_fitness` list (instance attribute to `pygad.GA`) has the fitness of the last generation duplicated when an early stop happens inside the `on_generation()` callback. This made its size incompatible with the `best_solutions` list. +10. The documentation is refactored to solve many language issues and the Furo theme is applied. For easy navigation, the index is reformatted to only show the main sections. At each page, its index is shown at the right side. A new theme toggle button to change theme between light and dark. +11. Support of multi-objective optimization using the Non-Dominated Sorting Genetic Algorithm III (NSGA-III). NSGA-III replaces the crowding distance of NSGA-II with niching against a structured grid of reference points, so it scales better to problems with 4 or more objectives. The new `NSGA3` class lives in the new `pygad/utils/nsga3.py` script and is mixed into the `pygad.GA` class the same way `NSGA2` is. +12. Two new parent selection methods are added to support NSGA-III: 1) `nsga3_selection()` for plain NSGA-III selection, and 2) `tournament_selection_nsga3()` for the tournament variant. Use them by setting `parent_selection_type` to `'nsga3'` or `'tournament_nsga3'`. +13. A new parameter `nsga3_num_divisions` is added to the `pygad.GA` constructor. It is required when `parent_selection_type` is `'nsga3'` or `'tournament_nsga3'` and sets the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. +14. When `sol_per_pop` is smaller than the number of NSGA-III reference points, PyGAD raises a warning and grows the population to match before the generational loop starts. +15. A new crossover operator: Simulated Binary Crossover (SBX). Use it by setting `crossover_type='sbx'`. The shape of the spread is controlled by the new `sbx_crossover_eta` parameter (default 30). +16. A new mutation operator: polynomial mutation. Use it by setting `mutation_type='polynomial'`. The size of the change is controlled by the new `polynomial_mutation_eta` parameter (default 20). +17. Two new stop criteria: `time_` stops the run when the time inside `run()` is at least the given number of seconds; `evaluations_` stops the run when the number of fitness function calls reaches the given count. New instance attribute `num_fitness_evaluations` counts the calls. +18. A new submodule `pygad.utils.quality_indicators` with four functions to measure the quality of a Pareto front: `hypervolume`, `inverted_generational_distance`, `generational_distance`, and `spacing`. +19. A new submodule `pygad.benchmarks` with built-in benchmark problems. `pygad.benchmarks.classic` has Sphere, Rastrigin, Rosenbrock, Griewank, Schwefel, Ackley, and Himmelblau. `pygad.benchmarks.zdt` has the ZDT family (ZDT1, ZDT2, ZDT3, ZDT4, ZDT6). `pygad.benchmarks.dtlz` has DTLZ1, DTLZ2, DTLZ3, and DTLZ4. `pygad.benchmarks.knapsack` has the 0/1 Knapsack problem. Each class is callable with the PyGAD fitness signature and returns negated values (for the minimization-style problems) so PyGAD can maximize toward the original minimum. +20. Update the documentation to reflect the recent additions and changes to the library structure. +21. A new benchmark `pygad.benchmarks.tsp` with a `TSP` class for the Travelling Salesman Problem. The class accepts either 2D `coordinates` or a precomputed `distance_matrix`, exposes `gene_space`, `gene_type`, and `allow_duplicate_genes` for the permutation encoding, and returns the negative tour length as the fitness. +22. Two new example folders under `/examples`: `examples/benchmarks/` has one runnable example per benchmark (classic, ZDT, DTLZ, knapsack, and TSP), and `examples/quality_indicators/` has one runnable example per quality indicator (hypervolume, IGD, GD, and spacing). +23. `plot_pareto_front_curve()` now also supports 3 objectives (3D scatter). M >= 4 still raises and points to the new high-dimensional plots. +24. Seven new plot methods on `pygad.GA`. The first three work on the final population (no extra flag needed): `plot_pareto_front_pcp()` (parallel coordinates, any M >= 2), `plot_pareto_front_scatter_matrix()` (M-by-M pairwise scatter, best for M >= 4), and `plot_pareto_front_heatmap()` (solutions-by-objectives heatmap). The other four require `save_solutions=True`: `plot_fitness_band()` (per-generation min / mean / max with a shaded band), `plot_non_dominated_hypervolume()` (hypervolume of the non-dominated set per generation), `plot_population_diversity()` (mean pairwise distance per generation), and `plot_pareto_front_evolution()` (non-dominated set overlaid every k generations). +25. Fix a latent divide-by-zero in `NSGA3.nsga3_normalize_fitness()`. The safeguard for near-zero denominators used to collapse to `0` for tiny negative values (the realistic case under PyGAD-max), which silently produced wrong normalized values. The safeguard now keeps the negative sign. +26. Refactor the NSGA classes to keep each script focused. A new module `pygad/utils/nsga.py` hosts the `NSGA` mixin with `non_dominated_sorting()` and `get_non_dominated_set()`, which are shared between NSGA-II and NSGA-III. `nsga2.py` now only carries NSGA-II specific code (`crowding_distance`, `sort_solutions_nsga2`). `nsga3.py` now only carries the NSGA-III algorithm primitives. The `nsga3_selection()` and `tournament_selection_nsga3()` methods have moved to `pygad/utils/parent_selection.py` next to their NSGA-II counterparts. The engine-time helpers `_bootstrap_nsga3_reference_points()`, `_nsga3_grow_population()`, `_nsga3_generate_extra_random_solutions()`, and `_nsga3_generate_single_random_gene()` now live in `pygad/utils/engine.py`. +27. Rename NSGA-III novel names to start with `nsga3_` so the algorithm-specific surface is easy to spot. Algorithm primitives become `nsga3_generate_reference_points`, `nsga3_compute_ideal_point`, `nsga3_find_extreme_points`, `nsga3_compute_intercepts`, `nsga3_normalize_fitness`, `nsga3_associate_to_reference_points`, and `nsga3_niching_select`. Module-level helpers gain the same prefix (`_nsga3_pick_target_reference_point`, `_nsga3_pick_candidate_at_reference`, `_nsga3_enumerate_compositions`, `_nsga3_validate_multi_objective_fitness`, `_nsga3_accumulate_fronts`). The constants are renamed `NSGA3_ASF_EPSILON` and `NSGA3_INTERCEPT_NEAR_ZERO`. Names that already had NSGA-II parallels (`tournament_selection_nsga3`, `pareto_fronts`, `non_dominated_sorting`) keep their original spelling. +28. Spell every name and docstring in American English (`normalize`, `maximize`, `behavior`, `color`, `optimization`, ...) so the library stays consistent. +29. Expand abbreviated names introduced by the NSGA-III refactor: `fl_indices` to `critical_front_indices`, `fl_assoc` to `critical_front_associations`, `fl_dist` to `critical_front_distances`, `st_indices` to `selection_pool_indices`, `st_fitness` to `selection_pool_fitness`, `accepted_assoc` to `accepted_associations`, `K` to `num_to_select` (in `nsga3_niching_select`). +30. The NSGA-III population auto-growth path now respects every initial-population rule: `init_range_low`/`init_range_high`, `gene_space`, `gene_type` (single dtype or nested per-gene `[type, precision]`), `gene_constraint`, and `allow_duplicate_genes=False`. Previously, only the gene-space / init-range sampling step was applied; gene constraints and duplicate resolution were skipped, which could leave the grown rows in an invalid state. +31. A new `Report` mixin in `pygad/utils/report.py` adds `ga_instance.generate_report(filename, ...)` to build a PDF report of the run. The report bundles a configuration table, a run-summary table, the best solution, and every applicable plot (auto-selected based on the run's properties: SOO vs MOO, number of objectives, `save_solutions`, `save_best_solutions`). The report uses `reportlab` and `matplotlib`, both available through the new optional dependency extra `pip install pygad[report]`. +32. A new example `examples/example_generate_report.py` shows how to build a PDF report after running a multi-objective GA. +33. The `pygad.md`, `releases.md`, `visualize.md`, and `utils.md` documentation pages were updated to reflect the new module layout, the renamed methods, the new `generate_report()` entry point, and the new NSGA-III instance attributes (`nsga3_num_divisions`, `nsga3_reference_points`). The "Other Instance Attributes & Methods" section in `pygad.md` is now grouped by area (Lifecycle, Population, Fitness, Parent Selection, NSGA-II, NSGA-III, Crossover, Mutation, Elitism, Gene Constraints, Saving) so each method or attribute appears under its topic. +34. Fix issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/351 by updating the documentation to clarify what the `solution` has. diff --git a/docs/source/utils.md b/docs/source/utils.md index 1bf5ee5..6ec0487 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -11,6 +11,8 @@ The submodules in the `pygad.utils` module are: 3. `mutation`: Has the `Mutation` class that implements the mutation operators. 4. `parent_selection`: Has the `ParentSelection` class that implements the parent selection operators. 5. `nsga2`: Has the `NSGA2` class that implements the Non-Dominated Sorting Genetic Algorithm II (NSGA-II). +6. `nsga3`: Has the `NSGA3` class that implements the Non-Dominated Sorting Genetic Algorithm III (NSGA-III). +7. `quality_indicators`: Has functions to measure the quality of a Pareto front: `hypervolume`, `inverted_generational_distance`, `generational_distance`, and `spacing`. Note that the `pygad.GA` class extends all of these classes. So, the user can access any of the methods in such classes directly by the instance/object of the `pygad.GA` class. @@ -255,6 +257,8 @@ The `pygad.utils.parent_selection` module has a class named `ParentSelection` wi 6. Tournament: Implemented using the `tournament_selection()` method. 7. NSGA-II: Implemented using the `nsga2_selection()` method. 8. NSGA-II Tournament: Implemented using the `tournament_selection_nsga2()` method. +9. NSGA-III: Implemented using the `nsga3_selection()` method. +10. NSGA-III Tournament: Implemented using the `tournament_selection_nsga3()` method. All parent selection methods accept these parameters: @@ -308,14 +312,81 @@ Selects the parents for the NSGA-II algorithm to solve multi-objective optimizat Selects the parents for the NSGA-II algorithm to solve multi-objective optimization problems. It selects the parents using the tournament selection technique applied based on non-dominated sorting and crowding distance. +#### `nsga3_selection()` + +Selects the parents for the NSGA-III algorithm to solve multi-objective optimization problems. It accepts whole Pareto fronts in order until adding the next front would overflow the requested parent count, then picks the remaining survivors from that critical front using niching against the structured reference points stored on the GA instance. Requires the `nsga3_num_divisions` parameter to be set when constructing the `pygad.GA` instance. + +#### `tournament_selection_nsga3()` + +Selects the parents for the NSGA-III algorithm to solve multi-objective optimization problems. It selects the parents using the tournament selection technique where the within-front comparison is based on the niche count (instead of the crowding distance used by `tournament_selection_nsga2()`). Requires the `nsga3_num_divisions` parameter to be set. + +## `pygad.utils.nsga` Submodule + +The `pygad.utils.nsga` module has a class named `NSGA` that holds the building blocks shared by NSGA-II and NSGA-III. The methods inside this class are: + +1. `non_dominated_sorting()`: Returns all the Pareto fronts by applying non-dominated sorting over the solutions. +2. `get_non_dominated_set()`: Returns the two sets of non-dominated and dominated solutions from the passed solutions. The Pareto front is the non-dominated set. + ## `pygad.utils.nsga2` Submodule -The `pygad.utils.nsga2` module has a class named `NSGA2` that implements NSGA-II. The methods inside this class are: +The `pygad.utils.nsga2` module has a class named `NSGA2` that implements the NSGA-II-specific primitives. The methods inside this class are: + +1. `crowding_distance()`: Calculates the crowding distance for all solutions in the current Pareto front. +2. `sort_solutions_nsga2()`: Sort the solutions. If the problem is single-objective, the solutions are sorted by their fitness values. If it is multi-objective, non-dominated sorting and crowding distance are applied to sort the solutions. + +## `pygad.utils.nsga3` Submodule + +The `pygad.utils.nsga3` module has a class named `NSGA3` that implements the NSGA-III algorithm primitives. NSGA-III novel names start with `nsga3_` to make the algorithm surface easy to spot. + +1. `nsga3_generate_reference_points()`: Build the structured grid of reference points on the unit simplex using the Das-Dennis (stars-and-bars) method. +2. `nsga3_compute_ideal_point()`: Return the ideal point (column maximum under PyGAD's maximization convention). +3. `nsga3_find_extreme_points()`: For each objective axis, return the solution that best represents the corner of that axis based on the Achievement Scalarizing Function (ASF). +4. `nsga3_compute_intercepts()`: Fit a hyperplane through the M extreme points and return the per-axis intercept point used as the normalization denominator. Falls back to the nadir (worst per objective) when the hyperplane cannot be fitted or when the intercept is degenerate. +5. `nsga3_normalize_fitness()`: Scale each fitness row to the `[0, 1]` range using the ideal point and the intercepts. +6. `nsga3_associate_to_reference_points()`: For every normalized solution, return the nearest reference index and the perpendicular distance to that reference line. +7. `nsga3_niching_select()`: Pick `num_to_select` survivors from the critical front using niche counts and per-niche tie-breaking rules. + +The selection methods `nsga3_selection()` and `tournament_selection_nsga3()` live in `pygad.utils.parent_selection`. The engine-time helpers (`_bootstrap_nsga3_reference_points()`, `_nsga3_grow_population()`, `_nsga3_generate_extra_random_solutions()`, `_nsga3_generate_single_random_gene()`) live in `pygad.utils.engine`. + +Two module-level constants in `pygad.utils.nsga3` control the numerical safeguards: `NSGA3_ASF_EPSILON` (default `1e-6`) and `NSGA3_INTERCEPT_NEAR_ZERO` (default `1e-12`). + +## `pygad.utils.report` Submodule + +The `pygad.utils.report` module has a class named `Report` that adds the `generate_report()` method to the `pygad.GA` class. It builds a PDF report of the GA run, bundling the configuration table, a run summary, the best solution, and every applicable plot. Requires the optional dependencies `reportlab` and `matplotlib`: + +``` +pip install pygad[report] +``` + +See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#generate-report) and the runnable example at [`examples/example_generate_report.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_generate_report.py). + +## `pygad.utils.quality_indicators` Submodule + +The `pygad.utils.quality_indicators` module has functions to measure the quality of a Pareto front. All functions take fitness values in PyGAD's maximization format. The functions are: + +1. `hypervolume(fitness, reference_point)`: Volume of the objective space dominated by the front. The reference point must be worse than every solution on every objective. A larger value is better. +2. `inverted_generational_distance(fitness, reference_front)`: Mean distance from each reference-front point to its nearest approximation point. Reports both convergence and diversity. A smaller value is better. +3. `generational_distance(fitness, reference_front)`: Mean distance from each approximation point to its nearest reference point. Reports convergence only. A smaller value is better. +4. `spacing(fitness)`: Standard deviation of the distance from each solution to its nearest neighbour. A smaller value means the solutions are spread more evenly. + +Example: + +```python +from pygad.utils.quality_indicators import hypervolume, inverted_generational_distance + +# After ga.run() +fitness = ga.last_generation_fitness +reference_point = [-10.0, -10.0] # worse than every solution +hv = hypervolume(fitness, reference_point) + +# If the true Pareto front is known +from pygad.benchmarks.zdt import ZDT1 +problem = ZDT1() +true_front = problem.pareto_front(num_points=100) +igd = inverted_generational_distance(fitness, true_front) +``` -1. `non_dominated_sorting()`: Returns all the pareto fronts by applying non-dominated sorting over the solutions. -2. `get_non_dominated_set()`: Returns the 2 sets of non-dominated solutions and dominated solutions from the passed solutions. Note that the Pareto front consists of the solutions in the non-dominated set. -3. `crowding_distance()`: Calculates the crowding distance for all solutions in the current pareto front. -4. `sort_solutions_nsga2()`: Sort the solutions. If the problem is single-objective, then the solutions are sorted by sorting the fitness values of the population. If it is multi-objective, then non-dominated sorting and crowding distance are applied to sort the solutions. +A runnable example per indicator lives under `examples/quality_indicators/`. ## More about the Operators diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 5d2889d..99c1e94 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -1,317 +1,163 @@ # `pygad.visualize` Module -This section of the documentation discusses the **pygad.visualize** module. It offers methods to visualize the results in PyGAD. +The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. -This section explains the different ways to visualize the results in PyGAD through these methods: +Every method returns the `matplotlib.figure.Figure` it created and optionally writes it to disk via `save_dir`. A runnable script for each plot lives under [`examples/plots/`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/plots). -1. `plot_fitness()`: Creates plots that show how the fitness evolves over the generations. -2. `plot_genes()`: Creates plots that show how the gene values change over the generations. -3. `plot_new_solution_rate()`: Creates plots that show how many new solutions are explored in each generation. -4. `plot_pareto_front_curve()`: Creates the Pareto front plot for multi-objective problems. +## Plot inventory -In the following code, the `save_solutions` flag is set to `True` which means all solutions are saved in the `solutions` attribute. The code runs for only 10 generations. +| Method | Works for | Needs `save_solutions=True` | +|---|---|---| +| `plot_fitness()` | SOO + MOO | no | +| `plot_new_solution_rate()` | SOO + MOO | yes | +| `plot_genes()` | SOO + MOO | yes (`solutions="all"`) or `save_best_solutions=True` (`solutions="best"`) | +| `plot_pareto_front_curve()` | MOO (M=2 or M=3) | no | +| `plot_pareto_front_pcp()` | MOO (any M >= 2) | no | +| `plot_pareto_front_scatter_matrix()` | MOO (any M >= 2; best for M >= 4) | no | +| `plot_pareto_front_heatmap()` | MOO (any M >= 2) | no | +| `plot_fitness_band()` | SOO + MOO | yes | +| `plot_non_dominated_hypervolume()` | MOO | yes | +| `plot_population_diversity()` | SOO + MOO | yes | +| `plot_pareto_front_evolution()` | MOO (M=2 or M=3) | yes | -```python -import pygad -import numpy - -equation_inputs = [4, -2, 3.5, 8, -2, 3.5, 8] -desired_output = 2671.1234 - -def fitness_func(ga_instance, solution, solution_idx): - output = numpy.sum(solution * equation_inputs) - fitness = 1.0 / (numpy.abs(output - desired_output) + 0.000001) - return fitness - -ga_instance = pygad.GA(num_generations=10, - sol_per_pop=10, - num_parents_mating=5, - num_genes=len(equation_inputs), - fitness_func=fitness_func, - gene_space=[range(1, 10), range(10, 20), range(15, 30), range(20, 40), range(25, 50), range(10, 30), range(20, 50)], - gene_type=int, - save_solutions=True) - -ga_instance.run() -``` - -The next sections explain how to visualize the results with these methods. - -## Fitness - -### `plot_fitness()` - -The `plot_fitness()` method shows the fitness value for each generation. It creates, shows, and returns a figure that summarizes how the fitness value(s) evolve(s) by generation. It was previously named `plot_result()`. - -It works only after at least 1 generation is completed. If no generation is completed, an exception is raised. - -This method accepts the following parameters: +Every method requires at least one completed generation. Each one raises `RuntimeError` with a clear message if it is called too early, on a single-objective problem when MOO is required, or without the `save_solutions` flag when one is required. -1. `title`: Title of the figure. -2. `xlabel`: X-axis label. -3. `ylabel`: Y-axis label. -4. `linewidth`: Line width of the plot. Defaults to `3`. -5. `font_size`: Font size for the labels and title. Defaults to `14`. -6. `plot_type`: Type of the plot which can be either `"plot"` (default), `"scatter"`, or `"bar"`. -7. `color`: Color of the plot which defaults to the greenish color `"#64f20c"`. -8. `label`: The label used for the legend in the figures of multi-objective problems. It is not used for single-objective problems. It defaults to `None` which means no labels used. -9. `save_dir`: Directory to save the figure. +## `plot_fitness()` -#### `plot_type="plot"` +Best fitness per generation. For MOO, one curve per objective on the same axes. -The simplest way to call this method is as follows leaving the `plot_type` with its default value `"plot"` to create a continuous line connecting the fitness values across all generations: +Parameters: `title`, `xlabel`, `ylabel`, `linewidth`, `font_size`, `plot_type` (`"plot"` / `"scatter"` / `"bar"`), `color`, `label`, `save_dir`. ```python ga_instance.plot_fitness() -# ga_instance.plot_fitness(plot_type="plot") ``` -![plot_fitness_plot](https://user-images.githubusercontent.com/16560492/122472609-d02f5280-cf8e-11eb-88a7-f9366ff6e7c6.png) +![plot_fitness](figures/plot_fitness.png) -#### `plot_type="scatter"` +## `plot_new_solution_rate()` -The `plot_type` can also be set to `"scatter"` to create a scatter graph with each individual fitness represented as a dot. The size of these dots can be changed using the `linewidth` parameter. +Number of previously-unseen solutions per generation. A flat curve means the GA is repeating itself; a high curve means it is still exploring. Requires `save_solutions=True`. -```python -ga_instance.plot_fitness(plot_type="scatter") -``` - -![plot_fitness_scatter](https://user-images.githubusercontent.com/16560492/122473159-75e2c180-cf8f-11eb-942d-31279b286dbd.png) - -#### `plot_type="bar"` - -The third value for the `plot_type` parameter is `"bar"` to create a bar graph with each individual fitness represented as a bar. +Parameters: `title`, `xlabel`, `ylabel`, `linewidth`, `font_size`, `plot_type`, `color`, `save_dir`. ```python -ga_instance.plot_fitness(plot_type="bar") +ga_instance.plot_new_solution_rate() ``` -![plot_fitness_bar](https://user-images.githubusercontent.com/16560492/122473340-b7736c80-cf8f-11eb-89c5-4f7db3b653cc.png) - -## New Solution Rate +![plot_new_solution_rate](figures/plot_new_solution_rate.png) -### `plot_new_solution_rate()` +## `plot_genes()` -The `plot_new_solution_rate()` method shows the number of new solutions explored in each generation. This helps you see whether the genetic algorithm is still finding new solutions. If no new solutions are explored, then no further evolution is possible. +One subplot per gene showing how that gene drifts across generations. Three views: line per gene (`graph_type="plot"`), per-gene boxplot, per-gene histogram. -It works only after at least 1 generation is completed. If no generation is completed, an exception is raised. +Use `solutions="all"` to plot every saved solution (needs `save_solutions=True`) or `solutions="best"` to plot only the best solution of each generation (needs `save_best_solutions=True`). -The `plot_new_solution_rate()` method accepts the same parameters as the `plot_fitness()` method (it also has 3 possible values for the `plot_type` parameter). Here are all the parameters it accepts: - -1. `title`: Title of the figure. -2. `xlabel`: X-axis label. -3. `ylabel`: Y-axis label. -4. `linewidth`: Line width of the plot. Defaults to `3`. -5. `font_size`: Font size for the labels and title. Defaults to `14`. -6. `plot_type`: Type of the plot which can be either `"plot"` (default), `"scatter"`, or `"bar"`. -7. `color`: Color of the plot which defaults to `"#3870FF"`. -8. `save_dir`: Directory to save the figure. - -#### `plot_type="plot"` - -The default value for the `plot_type` parameter is `"plot"`. +Parameters: `title`, `xlabel`, `ylabel`, `linewidth`, `font_size`, `plot_type`, `graph_type`, `fill_color`, `color`, `solutions`, `save_dir`. ```python -ga_instance.plot_new_solution_rate() -# ga_instance.plot_new_solution_rate(plot_type="plot") +ga_instance.plot_genes(graph_type="boxplot") ``` -The next figure shows that, for example, generation 6 has the least number of new solutions, which is 4. The number of new solutions in the first generation is always equal to the number of solutions in the population (the value of the `sol_per_pop` parameter in the constructor of the `pygad.GA` class), which is 10 in this example. +![plot_genes](figures/plot_genes.png) -![plot_new_solution_rate_plot](https://user-images.githubusercontent.com/16560492/122475815-3322e880-cf93-11eb-9648-bf66f823234b.png) +## `plot_pareto_front_curve()` -#### `plot_type="scatter"` +Pareto front of the final population. With 2 objectives it draws the population as a scatter and connects the non-dominated points with a curve. With 3 objectives it switches to a 3D scatter and highlights the non-dominated points. With 4 or more objectives it raises and points to the high-dimensional plots below. -The previous graph can be represented as scattered points by setting `plot_type="scatter"`. +Parameters: `title`, `xlabel`, `ylabel`, `zlabel` (only used for M=3), `linewidth`, `font_size`, `label`, `color`, `color_fitness`, `grid`, `alpha`, `marker`, `save_dir`. ```python -ga_instance.plot_new_solution_rate(plot_type="scatter") -``` - -![plot_new_solution_rate_scatter](https://user-images.githubusercontent.com/16560492/122476108-adec0380-cf93-11eb-80ac-7588bf90492f.png) - -#### `plot_type="bar"` - -By setting `plot_type="bar"`, each value is represented as a vertical bar. - -```python -ga_instance.plot_new_solution_rate(plot_type="bar") +ga_instance.plot_pareto_front_curve() ``` -![plot_new_solution_rate_bar](https://user-images.githubusercontent.com/16560492/122476173-c2c89700-cf93-11eb-9e77-d39737cd3a96.png) - -## Genes - -### `plot_genes()` - -The `plot_genes()` method is the third option to visualize the PyGAD results. The `plot_genes()` method creates, shows, and returns a figure that describes each gene. It has different options to create the figures which helps to: +For M=2 (NSGA-II on ZDT1): -1. Explore the gene value for each generation by creating a normal plot. -2. Create a histogram for each gene. -3. Create a boxplot. +![plot_pareto_front_curve_2d](figures/plot_pareto_front_curve_2d.png) -It works only after at least 1 generation is completed. If no generation is completed, an exception is raised. +For M=3 (NSGA-III on DTLZ2): -This method accepts the following parameters: +![plot_pareto_front_curve_3d](figures/plot_pareto_front_curve_3d.png) -1. `title`: Title of the figure. -2. `xlabel`: X-axis label. -3. `ylabel`: Y-axis label. -4. `linewidth`: Line width of the plot. Defaults to `3`. -5. `font_size`: Font size for the labels and title. Defaults to `14`. -6. `plot_type`: Type of the plot which can be either `"plot"` (default), `"scatter"`, or `"bar"`. -7. `graph_type`: Type of the graph which can be either `"plot"` (default), `"boxplot"`, or `"histogram"`. -8. `fill_color`: Fill color of the graph which defaults to `"#3870FF"`. This has no effect if `graph_type="plot"`. -9. `color`: Color of the plot which defaults to `"#3870FF"`. -10. `solutions`: Defaults to `"all"` which means use all solutions. If `"best"` then only the best solutions are used. -11. `save_dir`: Directory to save the figure. +## `plot_pareto_front_pcp()` -This method has 3 control variables: +Parallel-coordinates view of the final non-dominated set. Each objective is a vertical axis. Each non-dominated solution becomes a polyline that crosses every axis. Values are normalized per objective so very different scales remain comparable. Useful for any M >= 2 and especially for M >= 4. -1. `graph_type="plot"`: Can be `"plot"` (default), `"boxplot"`, or `"histogram"`. -2. `plot_type="plot"`: Identical to the `plot_type` parameter explored in the `plot_fitness()` and `plot_new_solution_rate()` methods. -3. `solutions="all"`: Can be `"all"` (default) or `"best"`. - -These 3 parameters control the style of the output figure. - -The `graph_type` parameter selects the type of the graph which helps to explore the gene values as: - -1. A normal plot. -2. A histogram. -3. A box and whisker plot. - -The `plot_type` parameter works only when the type of the graph is set to `"plot"`. - -The `solutions` parameter selects whether the genes come from all solutions in the population or from just the best solutions. - -An exception is raised if: - -* `solutions="all"` while `save_solutions=False` in the constructor of the `pygad.GA` class. -* `solutions="best"` while `save_best_solutions=False` in the constructor of the `pygad.GA` class. - -#### `graph_type="plot"` - -When `graph_type="plot"`, then the figure creates a normal graph where the relationship between the gene values and the generation numbers is represented as a continuous plot, scattered points, or bars. - -##### `plot_type="plot"` - -Because the default value for both `graph_type` and `plot_type` is `"plot"`, then all of the lines below creates the same figure. This figure is helpful to know whether a gene value lasts for more generations as an indication of the best value for this gene. For example, the value 16 for the gene with index 5 (at column 2 and row 2 of the next graph) lasted for 83 generations. +Parameters: `title`, `xlabel`, `ylabel`, `linewidth`, `font_size`, `color`, `alpha`, `grid`, `save_dir`. ```python -ga_instance.plot_genes() - -ga_instance.plot_genes(graph_type="plot") - -ga_instance.plot_genes(plot_type="plot") - -ga_instance.plot_genes(graph_type="plot", - plot_type="plot") +ga_instance.plot_pareto_front_pcp() ``` -![plot_genes_plot](https://user-images.githubusercontent.com/16560492/122477158-4a62d580-cf95-11eb-8c93-9b6e74cb814c.png) +![plot_pareto_front_pcp](figures/plot_pareto_front_pcp.png) -As the default value for the `solutions` parameter is `"all"`, then the following method calls generate the same plot. +## `plot_pareto_front_scatter_matrix()` -```python -ga_instance.plot_genes(solutions="all") +M-by-M grid of pairwise scatter plots for the final non-dominated set. The diagonal shows a histogram of each objective. The best fit when M >= 4 and a single 3D scatter no longer reads well. -ga_instance.plot_genes(graph_type="plot", - solutions="all") +Parameters: `title`, `font_size`, `color`, `marker`, `alpha`, `grid`, `save_dir`. -ga_instance.plot_genes(plot_type="plot", - solutions="all") - -ga_instance.plot_genes(graph_type="plot", - plot_type="plot", - solutions="all") +```python +ga_instance.plot_pareto_front_scatter_matrix() ``` -##### `plot_type="scatter"` +![plot_pareto_front_scatter_matrix](figures/plot_pareto_front_scatter_matrix.png) -The following calls of the `plot_genes()` method create the same scatter plot. +## `plot_pareto_front_heatmap()` -```python -ga_instance.plot_genes(plot_type="scatter") +Heatmap of the final non-dominated set. Rows are solutions, columns are objectives, color is the raw objective value. Rows are sorted by objective `sort_by` (default `0`); pass `sort_by=None` to keep the original order. -ga_instance.plot_genes(graph_type="plot", - plot_type="scatter", - solutions='all') -``` - -![plot_genes_scatter](https://user-images.githubusercontent.com/16560492/122477273-73836600-cf95-11eb-828f-f357c7b0f815.png) - -##### `plot_type="bar"` +Parameters: `title`, `xlabel`, `ylabel`, `font_size`, `cmap`, `sort_by`, `save_dir`. ```python -ga_instance.plot_genes(plot_type="bar") - -ga_instance.plot_genes(graph_type="plot", - plot_type="bar", - solutions='all') +ga_instance.plot_pareto_front_heatmap(sort_by=0) ``` -![plot_genes_bar](https://user-images.githubusercontent.com/16560492/122477370-99106f80-cf95-11eb-8643-865b55e6b844.png) +![plot_pareto_front_heatmap](figures/plot_pareto_front_heatmap.png) -#### `graph_type="boxplot"` +## `plot_fitness_band()` -By setting `graph_type` to `"boxplot"`, then a box and whisker graph is created. Now, the `plot_type` parameter has no effect. +Per-generation min, mean, and max with a shaded min-max band. Reveals selection pressure and diversity collapse at a glance. For MOO, pick one objective via `objective_index` (default `0`). Requires `save_solutions=True`. -The following 2 calls of the `plot_genes()` method create the same figure as the default value for the `solutions` parameter is `"all"`. +Parameters: `title`, `xlabel`, `ylabel`, `font_size`, `color`, `band_alpha`, `linewidth`, `objective_index`, `grid`, `save_dir`. ```python -ga_instance.plot_genes(graph_type="boxplot") - -ga_instance.plot_genes(graph_type="boxplot", - solutions='all') +ga_instance.plot_fitness_band() ``` -![plot_genes_boxplot](https://user-images.githubusercontent.com/16560492/122479260-beeb4380-cf98-11eb-8f08-23707929b12c.png) +![plot_fitness_band](figures/plot_fitness_band.png) -#### `graph_type="histogram"` +## `plot_non_dominated_hypervolume()` -For `graph_type="histogram"`, a histogram is created for each gene. As with `graph_type="boxplot"`, the `plot_type` parameter has no effect. +Hypervolume of the non-dominated set per generation. Uses `pygad.utils.quality_indicators.hypervolume`. Pass `reference_point` explicitly, or let the method pick the column-wise min across all saved generations minus `0.1`. Requires `save_solutions=True`. -The following 2 calls of the `plot_genes()` method create the same figure as the default value for the `solutions` parameter is `"all"`. +Parameters: `reference_point`, `title`, `xlabel`, `ylabel`, `font_size`, `color`, `linewidth`, `grid`, `save_dir`. ```python -ga_instance.plot_genes(graph_type="histogram") - -ga_instance.plot_genes(graph_type="histogram", - solutions='all') +ga_instance.plot_non_dominated_hypervolume() ``` -![plot_genes_histogram](https://user-images.githubusercontent.com/16560492/122477314-8007be80-cf95-11eb-9c95-da3f49204151.png) +![plot_non_dominated_hypervolume](figures/plot_non_dominated_hypervolume.png) -All the previous figures can be created for only the best solutions by setting `solutions="best"`. +## `plot_population_diversity()` -## Pareto Front +Mean pairwise Euclidean distance between solutions per generation. A drop signals the population is converging or collapsing into duplicates. Requires `save_solutions=True`. -### `plot_pareto_front_curve()` +Parameters: `title`, `xlabel`, `ylabel`, `font_size`, `color`, `linewidth`, `grid`, `save_dir`. -The `plot_pareto_front_curve()` method creates the Pareto front curve for multi-objective optimization problems. It creates, shows, and returns a figure that shows the Pareto front curve and points representing the fitness. It only works when 2 objectives are used. +```python +ga_instance.plot_population_diversity() +``` -It works only after at least 1 generation is completed. If no generation is completed, an exception is raised. +![plot_population_diversity](figures/plot_population_diversity.png) -This method accepts the following parameters: +## `plot_pareto_front_evolution()` -1. `title`: Title of the figure. -2. `xlabel`: X-axis label. -3. `ylabel`: Y-axis label. -4. `linewidth`: Line width of the plot. Defaults to `3`. -5. `font_size`: Font size for the labels and title. Defaults to `14`. -6. `label`: The label used for the legend. -7. `color`: Color of the plot which defaults to the tomato color `#FF6347`. -8. `color_fitness`: Color of the fitness points which defaults to the royal blue color `#4169E1`. -9. `grid`: Either `True` or `False` to control the visibility of the grid. -10. `alpha`: The transparency of the pareto front curve. -11. `marker`: The marker of the fitness points. -12. `save_dir`: Directory to save the figure. +Overlays the non-dominated set every `every_k` generations on a single figure. The colormap goes from early to late so you can see the front converge. Works for 2 or 3 objectives. Requires `save_solutions=True`. -This is an example of calling the `plot_pareto_front_curve()` method. +Parameters: `every_k`, `title`, `xlabel`, `ylabel`, `zlabel`, `font_size`, `cmap`, `marker`, `alpha`, `grid`, `save_dir`. ```python -ga_instance.plot_pareto_front_curve() +ga_instance.plot_pareto_front_evolution(every_k=20) ``` -![plot_pareto_front_curve](https://github.com/user-attachments/assets/606d853c-7370-41a0-8ddb-857a4c6c7fb9) - +![plot_pareto_front_evolution](figures/plot_pareto_front_evolution.png) diff --git a/examples/benchmarks/example_classic_ackley.py b/examples/benchmarks/example_classic_ackley.py new file mode 100644 index 0000000..2c0260a --- /dev/null +++ b/examples/benchmarks/example_classic_ackley.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Ackley benchmark. Flat outside, deep basin at the origin.""" + +import pygad +from pygad.benchmarks.classic import Ackley + +problem = Ackley(num_genes=5) + +ga = pygad.GA(num_generations=300, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_classic_griewank.py b/examples/benchmarks/example_classic_griewank.py new file mode 100644 index 0000000..7f76fab --- /dev/null +++ b/examples/benchmarks/example_classic_griewank.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Griewank benchmark. Many local minima, global at the origin.""" + +import pygad +from pygad.benchmarks.classic import Griewank + +problem = Griewank(num_genes=5) + +ga = pygad.GA(num_generations=300, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_classic_himmelblau.py b/examples/benchmarks/example_classic_himmelblau.py new file mode 100644 index 0000000..29f8517 --- /dev/null +++ b/examples/benchmarks/example_classic_himmelblau.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the 2D Himmelblau benchmark. Four global minima at f = 0.""" + +import pygad +from pygad.benchmarks.classic import Himmelblau + +problem = Himmelblau() + +ga = pygad.GA(num_generations=200, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution (x, y): {solution}") diff --git a/examples/benchmarks/example_classic_rastrigin.py b/examples/benchmarks/example_classic_rastrigin.py new file mode 100644 index 0000000..6dfb838 --- /dev/null +++ b/examples/benchmarks/example_classic_rastrigin.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Rastrigin benchmark. Many local minima, global at the origin.""" + +import pygad +from pygad.benchmarks.classic import Rastrigin + +problem = Rastrigin(num_genes=5) + +ga = pygad.GA(num_generations=300, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_classic_rosenbrock.py b/examples/benchmarks/example_classic_rosenbrock.py new file mode 100644 index 0000000..620fd86 --- /dev/null +++ b/examples/benchmarks/example_classic_rosenbrock.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Rosenbrock benchmark. Minimum sits in a narrow banana-shaped valley.""" + +import pygad +from pygad.benchmarks.classic import Rosenbrock + +problem = Rosenbrock(num_genes=5) + +ga = pygad.GA(num_generations=500, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_classic_schwefel.py b/examples/benchmarks/example_classic_schwefel.py new file mode 100644 index 0000000..877a550 --- /dev/null +++ b/examples/benchmarks/example_classic_schwefel.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Schwefel benchmark. Global minimum at (420.9687, ...).""" + +import pygad +from pygad.benchmarks.classic import Schwefel + +problem = Schwefel(num_genes=3) + +ga = pygad.GA(num_generations=500, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=50, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_classic_sphere.py b/examples/benchmarks/example_classic_sphere.py new file mode 100644 index 0000000..cecdaa3 --- /dev/null +++ b/examples/benchmarks/example_classic_sphere.py @@ -0,0 +1,23 @@ +"""Run PyGAD on the Sphere benchmark. Best fitness is 0 (at the origin).""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best fitness: {solution_fitness}") +print(f"Best solution: {solution}") diff --git a/examples/benchmarks/example_dtlz1.py b/examples/benchmarks/example_dtlz1.py new file mode 100644 index 0000000..856cedf --- /dev/null +++ b/examples/benchmarks/example_dtlz1.py @@ -0,0 +1,21 @@ +"""Run NSGA-III on DTLZ1. Front is the linear hyperplane sum(f_i) = 0.5.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ1 + +problem = DTLZ1(num_objectives=3, num_distance_vars=5) + +ga = pygad.GA(num_generations=300, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=12, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() diff --git a/examples/benchmarks/example_dtlz2.py b/examples/benchmarks/example_dtlz2.py new file mode 100644 index 0000000..cc3b607 --- /dev/null +++ b/examples/benchmarks/example_dtlz2.py @@ -0,0 +1,21 @@ +"""Run NSGA-III on DTLZ2. Front is the first orthant of the unit sphere.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ2 + +problem = DTLZ2(num_objectives=3, num_distance_vars=10) + +ga = pygad.GA(num_generations=300, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=12, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() diff --git a/examples/benchmarks/example_dtlz3.py b/examples/benchmarks/example_dtlz3.py new file mode 100644 index 0000000..e55625d --- /dev/null +++ b/examples/benchmarks/example_dtlz3.py @@ -0,0 +1,21 @@ +"""Run NSGA-III on DTLZ3. Same unit-sphere front as DTLZ2, with a hard multimodal g.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ3 + +problem = DTLZ3(num_objectives=3, num_distance_vars=10) + +ga = pygad.GA(num_generations=500, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=12, + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() diff --git a/examples/benchmarks/example_dtlz4.py b/examples/benchmarks/example_dtlz4.py new file mode 100644 index 0000000..4321c12 --- /dev/null +++ b/examples/benchmarks/example_dtlz4.py @@ -0,0 +1,21 @@ +"""Run NSGA-III on DTLZ4. Same shape as DTLZ2; alpha bias pushes solutions to one corner.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ4 + +problem = DTLZ4(num_objectives=3, num_distance_vars=10, alpha=100.0) + +ga = pygad.GA(num_generations=300, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=12, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() diff --git a/examples/benchmarks/example_knapsack.py b/examples/benchmarks/example_knapsack.py new file mode 100644 index 0000000..6159aa7 --- /dev/null +++ b/examples/benchmarks/example_knapsack.py @@ -0,0 +1,25 @@ +"""Run PyGAD on the 0/1 Knapsack benchmark. + +Items (weight, value): (2,3), (3,4), (4,5), (5,6) with capacity 5. +Optimal subset is items 0 and 1 (total value 7). +""" + +import pygad +from pygad.benchmarks.knapsack import Knapsack + +problem = Knapsack(weights=[2, 3, 4, 5], + values=[3, 4, 5, 6], + capacity=5) + +ga = pygad.GA(num_generations=100, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best subset: {solution}") +print(f"Total value: {solution_fitness}") diff --git a/examples/benchmarks/example_tsp.py b/examples/benchmarks/example_tsp.py new file mode 100644 index 0000000..928ab28 --- /dev/null +++ b/examples/benchmarks/example_tsp.py @@ -0,0 +1,27 @@ +"""Run PyGAD on the TSP benchmark. + +Four cities at the corners of a unit square. The shortest tour +walks the perimeter (length 4). +""" + +import pygad +from pygad.benchmarks.tsp import TSP + +problem = TSP(coordinates=[[0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], + [0.0, 1.0]]) + +ga = pygad.GA(num_generations=200, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, + allow_duplicate_genes=problem.allow_duplicate_genes) +ga.run() + +solution, solution_fitness, _ = ga.best_solution(ga.last_generation_fitness) +print(f"Best tour: {solution}") +print(f"Tour length: {-solution_fitness}") diff --git a/examples/benchmarks/example_zdt1.py b/examples/benchmarks/example_zdt1.py new file mode 100644 index 0000000..fd4f4e3 --- /dev/null +++ b/examples/benchmarks/example_zdt1.py @@ -0,0 +1,22 @@ +"""Run NSGA-II on ZDT1. Convex front: f2 = 1 - sqrt(f1).""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/benchmarks/example_zdt2.py b/examples/benchmarks/example_zdt2.py new file mode 100644 index 0000000..80ad8b2 --- /dev/null +++ b/examples/benchmarks/example_zdt2.py @@ -0,0 +1,22 @@ +"""Run NSGA-II on ZDT2. Non-convex front: f2 = 1 - f1**2.""" + +import pygad +from pygad.benchmarks.zdt import ZDT2 + +problem = ZDT2(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/benchmarks/example_zdt3.py b/examples/benchmarks/example_zdt3.py new file mode 100644 index 0000000..14361f4 --- /dev/null +++ b/examples/benchmarks/example_zdt3.py @@ -0,0 +1,22 @@ +"""Run NSGA-II on ZDT3. Front is five disconnected convex pieces.""" + +import pygad +from pygad.benchmarks.zdt import ZDT3 + +problem = ZDT3(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/benchmarks/example_zdt4.py b/examples/benchmarks/example_zdt4.py new file mode 100644 index 0000000..d44d2ed --- /dev/null +++ b/examples/benchmarks/example_zdt4.py @@ -0,0 +1,22 @@ +"""Run NSGA-II on ZDT4. Same convex front as ZDT1; search space has many local minima.""" + +import pygad +from pygad.benchmarks.zdt import ZDT4 + +problem = ZDT4(num_genes=10) + +ga = pygad.GA(num_generations=300, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/benchmarks/example_zdt6.py b/examples/benchmarks/example_zdt6.py new file mode 100644 index 0000000..86a3c55 --- /dev/null +++ b/examples/benchmarks/example_zdt6.py @@ -0,0 +1,22 @@ +"""Run NSGA-II on ZDT6. Non-uniform front; solutions cluster at one end.""" + +import pygad +from pygad.benchmarks.zdt import ZDT6 + +problem = ZDT6(num_genes=10) + +ga = pygad.GA(num_generations=300, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=20, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/example_generate_report.py b/examples/example_generate_report.py new file mode 100644 index 0000000..6d43489 --- /dev/null +++ b/examples/example_generate_report.py @@ -0,0 +1,28 @@ +""" +Build a PDF report for a small multi-objective GA run. + +Requires the optional ``report`` extra: pip install pygad[report]. +The output file ``pygad_report.pdf`` is written next to this script. +""" + +import numpy +import pygad + +def fitness_func(ga_instance, solution, solution_idx): + return [float(numpy.sum(solution)), + -float(numpy.sum(numpy.asarray(solution) ** 2))] + +ga_instance = pygad.GA(num_generations=30, + num_parents_mating=8, + fitness_func=fitness_func, + sol_per_pop=20, + num_genes=4, + parent_selection_type="nsga2", + save_solutions=True, + random_seed=42, + suppress_warnings=True,) +ga_instance.run() +output_path = ga_instance.generate_report(filename="pygad_report", + title="PyGAD multi-objective demo", + notes="A short two-objective example with 30 generations.",) +print(f"Report written to: {output_path}") diff --git a/examples/example_multi_objective_nsga3.py b/examples/example_multi_objective_nsga3.py new file mode 100644 index 0000000..3550b47 --- /dev/null +++ b/examples/example_multi_objective_nsga3.py @@ -0,0 +1,78 @@ +import pygad +import numpy + +""" +Given these 2 functions: + y1 = f(w1:w6) = w1x1 + w2x2 + w3x3 + w4x4 + w5x5 + 6wx6 + y2 = f(w1:w6) = w1x7 + w2x8 + w3x9 + w4x10 + w5x11 + 6wx12 + where (x1,x2,x3,x4,x5,x6)=(4,-2,3.5,5,-11,-4.7) and y=50 + and (x7,x8,x9,x10,x11,x12)=(-2,0.7,-9,1.4,3,5) and y=30 +What are the best values for the 6 weights (w1 to w6)? We are going to use the genetic algorithm to optimize these 2 functions. +This is a multi-objective optimization problem. + +PyGAD considers the problem as multi-objective if the fitness function returns: + 1) List. + 2) Or tuple. + 3) Or numpy.ndarray. + +This example uses NSGA-III. The only extra parameter compared to NSGA-II is +'nsga3_num_divisions', which is the number of divisions per objective axis +used to build the structured reference points (the 'p' parameter from +Deb & Jain 2014). The total number of reference points is C(M + p - 1, p) +where M is the number of objectives. +""" + +function_inputs1 = [4,-2,3.5,5,-11,-4.7] # Function 1 inputs. +function_inputs2 = [-2,0.7,-9,1.4,3,5] # Function 2 inputs. +desired_output1 = 50 # Function 1 output. +desired_output2 = 30 # Function 2 output. + +def fitness_func(ga_instance, solution, solution_idx): + output1 = numpy.sum(solution*function_inputs1) + output2 = numpy.sum(solution*function_inputs2) + fitness1 = 1.0 / (numpy.abs(output1 - desired_output1) + 0.000001) + fitness2 = 1.0 / (numpy.abs(output2 - desired_output2) + 0.000001) + return [fitness1, fitness2] + +num_generations = 100 # Number of generations. +num_parents_mating = 10 # Number of solutions to be selected as parents in the mating pool. + +sol_per_pop = 20 # Number of solutions in the population. +num_genes = len(function_inputs1) + +last_fitness = 0 +def on_generation(ga_instance): + global last_fitness + print(f"Generation = {ga_instance.generations_completed}") + print(f"Fitness = {ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1]}") + print(f"Change = {ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1] - last_fitness}") + last_fitness = ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1] + +ga_instance = pygad.GA(num_generations=num_generations, + num_parents_mating=num_parents_mating, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + fitness_func=fitness_func, + parent_selection_type='nsga3', + nsga3_num_divisions=12, + on_generation=on_generation) + +# Running the GA to optimize the parameters of the function. +ga_instance.run() + +ga_instance.plot_fitness(label=['Obj 1', 'Obj 2']) +ga_instance.plot_pareto_front_curve() + +# Returning the details of the best solution. +solution, solution_fitness, solution_idx = ga_instance.best_solution(ga_instance.last_generation_fitness) +print(f"Parameters of the best solution : {solution}") +print(f"Fitness value of the best solution = {solution_fitness}") +print(f"Index of the best solution : {solution_idx}") + +prediction = numpy.sum(numpy.array(function_inputs1)*solution) +print(f"Predicted output 1 based on the best solution : {prediction}") +prediction = numpy.sum(numpy.array(function_inputs2)*solution) +print(f"Predicted output 2 based on the best solution : {prediction}") + +if ga_instance.best_solution_generation != -1: + print(f"Best fitness value reached after {ga_instance.best_solution_generation} generations.") diff --git a/examples/plots/example_plot_fitness.py b/examples/plots/example_plot_fitness.py new file mode 100644 index 0000000..bc9b6d5 --- /dev/null +++ b/examples/plots/example_plot_fitness.py @@ -0,0 +1,17 @@ +"""Best-fitness curve for a single-objective GA run on the Sphere benchmark.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + +ga = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1]) +ga.run() + +ga.plot_fitness() diff --git a/examples/plots/example_plot_fitness_band.py b/examples/plots/example_plot_fitness_band.py new file mode 100644 index 0000000..e87acaf --- /dev/null +++ b/examples/plots/example_plot_fitness_band.py @@ -0,0 +1,18 @@ +"""Per-generation min / mean / max fitness band on the Sphere benchmark.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + +ga = pygad.GA(num_generations=80, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + save_solutions=True) +ga.run() + +ga.plot_fitness_band() diff --git a/examples/plots/example_plot_genes.py b/examples/plots/example_plot_genes.py new file mode 100644 index 0000000..83eacae --- /dev/null +++ b/examples/plots/example_plot_genes.py @@ -0,0 +1,18 @@ +"""Per-gene value drift across generations on a single-objective GA run.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + +ga = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + save_solutions=True) +ga.run() + +ga.plot_genes(graph_type="boxplot") diff --git a/examples/plots/example_plot_new_solution_rate.py b/examples/plots/example_plot_new_solution_rate.py new file mode 100644 index 0000000..4b11c93 --- /dev/null +++ b/examples/plots/example_plot_new_solution_rate.py @@ -0,0 +1,18 @@ +"""Count of new solutions per generation on a single-objective GA run.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + +ga = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + save_solutions=True) +ga.run() + +ga.plot_new_solution_rate() diff --git a/examples/plots/example_plot_non_dominated_hypervolume.py b/examples/plots/example_plot_non_dominated_hypervolume.py new file mode 100644 index 0000000..900f0ab --- /dev/null +++ b/examples/plots/example_plot_non_dominated_hypervolume.py @@ -0,0 +1,23 @@ +"""Hypervolume of the non-dominated set per generation (NSGA-II on ZDT1).""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=80, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + save_solutions=True, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_non_dominated_hypervolume() diff --git a/examples/plots/example_plot_pareto_front_curve_2d.py b/examples/plots/example_plot_pareto_front_curve_2d.py new file mode 100644 index 0000000..a24d460 --- /dev/null +++ b/examples/plots/example_plot_pareto_front_curve_2d.py @@ -0,0 +1,22 @@ +"""2D Pareto front curve from NSGA-II on ZDT1.""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=100, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/plots/example_plot_pareto_front_curve_3d.py b/examples/plots/example_plot_pareto_front_curve_3d.py new file mode 100644 index 0000000..5262439 --- /dev/null +++ b/examples/plots/example_plot_pareto_front_curve_3d.py @@ -0,0 +1,23 @@ +"""3D Pareto front scatter from NSGA-III on DTLZ2 with 3 objectives.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ2 + +problem = DTLZ2(num_objectives=3, num_distance_vars=5) + +ga = pygad.GA(num_generations=100, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=6, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_curve() diff --git a/examples/plots/example_plot_pareto_front_evolution.py b/examples/plots/example_plot_pareto_front_evolution.py new file mode 100644 index 0000000..8cf3132 --- /dev/null +++ b/examples/plots/example_plot_pareto_front_evolution.py @@ -0,0 +1,23 @@ +"""Pareto front overlaid every k generations (NSGA-II on ZDT1).""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=80, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + save_solutions=True, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_evolution(every_k=20) diff --git a/examples/plots/example_plot_pareto_front_heatmap.py b/examples/plots/example_plot_pareto_front_heatmap.py new file mode 100644 index 0000000..912a931 --- /dev/null +++ b/examples/plots/example_plot_pareto_front_heatmap.py @@ -0,0 +1,23 @@ +"""Heatmap of the final Pareto front (DTLZ2, M=3), sorted by f1.""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ2 + +problem = DTLZ2(num_objectives=3, num_distance_vars=5) + +ga = pygad.GA(num_generations=100, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=6, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_heatmap(sort_by=0) diff --git a/examples/plots/example_plot_pareto_front_pcp.py b/examples/plots/example_plot_pareto_front_pcp.py new file mode 100644 index 0000000..8a5157a --- /dev/null +++ b/examples/plots/example_plot_pareto_front_pcp.py @@ -0,0 +1,23 @@ +"""Parallel-coordinates view of the final Pareto front (DTLZ2, M=3).""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ2 + +problem = DTLZ2(num_objectives=3, num_distance_vars=5) + +ga = pygad.GA(num_generations=100, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=6, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_pcp() diff --git a/examples/plots/example_plot_pareto_front_scatter_matrix.py b/examples/plots/example_plot_pareto_front_scatter_matrix.py new file mode 100644 index 0000000..615aac8 --- /dev/null +++ b/examples/plots/example_plot_pareto_front_scatter_matrix.py @@ -0,0 +1,23 @@ +"""Pairwise scatter matrix of the final Pareto front (DTLZ2, M=4).""" + +import pygad +from pygad.benchmarks.dtlz import DTLZ2 + +problem = DTLZ2(num_objectives=4, num_distance_vars=5) + +ga = pygad.GA(num_generations=100, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=60, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga3', + nsga3_num_divisions=5, + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +ga.plot_pareto_front_scatter_matrix() diff --git a/examples/plots/example_plot_population_diversity.py b/examples/plots/example_plot_population_diversity.py new file mode 100644 index 0000000..572707b --- /dev/null +++ b/examples/plots/example_plot_population_diversity.py @@ -0,0 +1,18 @@ +"""Mean pairwise distance per generation on the Sphere benchmark.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + +ga = pygad.GA(num_generations=80, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + save_solutions=True) +ga.run() + +ga.plot_population_diversity() diff --git a/examples/quality_indicators/example_generational_distance.py b/examples/quality_indicators/example_generational_distance.py new file mode 100644 index 0000000..148c986 --- /dev/null +++ b/examples/quality_indicators/example_generational_distance.py @@ -0,0 +1,29 @@ +"""Compute GD of the final NSGA-II population on ZDT2. + +GD averages the distance from each approximation point to its +nearest true-front reference point. Smaller is better. +""" + +import pygad +from pygad.benchmarks.zdt import ZDT2 +from pygad.utils.quality_indicators import generational_distance + +problem = ZDT2(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +true_front = problem.pareto_front(num_points=100) +gd = generational_distance(ga.last_generation_fitness, true_front) +print(f"GD: {gd}") diff --git a/examples/quality_indicators/example_hypervolume.py b/examples/quality_indicators/example_hypervolume.py new file mode 100644 index 0000000..63e3faa --- /dev/null +++ b/examples/quality_indicators/example_hypervolume.py @@ -0,0 +1,33 @@ +"""Compute the hypervolume of the final NSGA-II population on ZDT1. + +The reference point must be worse than every solution on every axis, +which under PyGAD-max means strictly smaller than every fitness. +""" + +import numpy + +import pygad +from pygad.benchmarks.zdt import ZDT1 +from pygad.utils.quality_indicators import hypervolume + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +final_fitness = numpy.asarray(ga.last_generation_fitness) +reference_point = final_fitness.min(axis=0) - 0.1 + +hv = hypervolume(final_fitness, reference_point) +print(f"Hypervolume: {hv}") diff --git a/examples/quality_indicators/example_inverted_generational_distance.py b/examples/quality_indicators/example_inverted_generational_distance.py new file mode 100644 index 0000000..ee83a50 --- /dev/null +++ b/examples/quality_indicators/example_inverted_generational_distance.py @@ -0,0 +1,29 @@ +"""Compute IGD of the final NSGA-II population on ZDT1. + +IGD averages the distance from each true-front reference point to +its nearest approximation point. Smaller is better. +""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 +from pygad.utils.quality_indicators import inverted_generational_distance + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +true_front = problem.pareto_front(num_points=100) +igd = inverted_generational_distance(ga.last_generation_fitness, true_front) +print(f"IGD: {igd}") diff --git a/examples/quality_indicators/example_spacing.py b/examples/quality_indicators/example_spacing.py new file mode 100644 index 0000000..220accf --- /dev/null +++ b/examples/quality_indicators/example_spacing.py @@ -0,0 +1,28 @@ +"""Compute the spacing metric of the final NSGA-II population on ZDT1. + +Spacing is the standard deviation of each solution's nearest +neighbour distance. Smaller means a more even spread. +""" + +import pygad +from pygad.benchmarks.zdt import ZDT1 +from pygad.utils.quality_indicators import spacing + +problem = ZDT1(num_genes=10) + +ga = pygad.GA(num_generations=200, + num_parents_mating=20, + fitness_func=problem, + sol_per_pop=40, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type='nsga2', + crossover_type='sbx', + sbx_crossover_eta=30, + mutation_type='polynomial', + polynomial_mutation_eta=20) +ga.run() + +spacing_value = spacing(ga.last_generation_fitness) +print(f"Spacing: {spacing_value}") diff --git a/pygad/__init__.py b/pygad/__init__.py index 2f5215a..bd9a071 100644 --- a/pygad/__init__.py +++ b/pygad/__init__.py @@ -1,3 +1,5 @@ from .pygad import * # Relative import. +from pygad import benchmarks + from ._version import __version__ diff --git a/pygad/benchmarks/__init__.py b/pygad/benchmarks/__init__.py new file mode 100644 index 0000000..8af9da7 --- /dev/null +++ b/pygad/benchmarks/__init__.py @@ -0,0 +1,19 @@ +""" +Benchmark problems for PyGAD. + +Each problem class can be called with the fitness signature +(ga, solution, sol_idx) and returns a fitness in PyGAD's +maximization format. For problems that are normally written as +minimisation, the values are negated. + +Each class also exposes num_genes, num_objectives, and bounds so +you can plug it into pygad.GA directly. +""" + +from pygad.benchmarks import classic +from pygad.benchmarks import zdt +from pygad.benchmarks import dtlz +from pygad.benchmarks import knapsack +from pygad.benchmarks import tsp + +__all__ = ["classic", "zdt", "dtlz", "knapsack", "tsp"] diff --git a/pygad/benchmarks/classic.py b/pygad/benchmarks/classic.py new file mode 100644 index 0000000..b8d9b87 --- /dev/null +++ b/pygad/benchmarks/classic.py @@ -0,0 +1,130 @@ +""" +Classic single-objective benchmark problems. + +Each class is callable with the (ga, solution, sol_idx) signature +and returns a single fitness value. Minimisation values are negated +so PyGAD can maximize them. +""" + +import math + +import numpy + + +class _SingleObjectiveProblem: + """Base class with attributes shared by every classic problem.""" + num_objectives = 1 + + def __init__(self, num_genes): + self.num_genes = int(num_genes) + + +class Sphere(_SingleObjectiveProblem): + """Sphere. Global minimum at the origin, f(x) = 0.""" + bounds = (-5.12, 5.12) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + return -float(numpy.sum(x ** 2)) + + +class Rastrigin(_SingleObjectiveProblem): + """Rastrigin. Many regularly-spaced local minima. Global minimum at the origin, f(x) = 0.""" + bounds = (-5.12, 5.12) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + value = 10.0 * self.num_genes + numpy.sum(x ** 2 - 10.0 * numpy.cos(2.0 * math.pi * x)) + return -float(value) + + +class Rosenbrock(_SingleObjectiveProblem): + """ + Rosenbrock. Global minimum at x = (1, ..., 1), f(x) = 0. + The minimum sits in a long narrow banana-shaped valley. + """ + bounds = (-5.0, 10.0) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + value = numpy.sum(100.0 * (x[1:] - x[:-1] ** 2) ** 2 + (1.0 - x[:-1]) ** 2) + return -float(value) + + +class Griewank(_SingleObjectiveProblem): + """Griewank. Many local minima over a wide area. Global minimum at the origin, f(x) = 0.""" + bounds = (-600.0, 600.0) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + sum_term = numpy.sum(x ** 2) / 4000.0 + i = numpy.arange(1, self.num_genes + 1, dtype=float) + prod_term = numpy.prod(numpy.cos(x / numpy.sqrt(i))) + value = 1.0 + sum_term - prod_term + return -float(value) + + +class Schwefel(_SingleObjectiveProblem): + """ + Schwefel. Global minimum at x = (420.9687, ..., 420.9687), f(x) ~ 0. + It sits far from the next-best local minimum, which trips up + many algorithms. + """ + bounds = (-500.0, 500.0) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + value = 418.9829 * self.num_genes - numpy.sum(x * numpy.sin(numpy.sqrt(numpy.abs(x)))) + return -float(value) + + +class Ackley(_SingleObjectiveProblem): + """Ackley. Near-flat outer region with a deep narrow basin at the origin, f(x) = 0.""" + bounds = (-32.768, 32.768) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + a = 20.0 + b = 0.2 + c = 2.0 * math.pi + term1 = -a * math.exp(-b * math.sqrt(numpy.mean(x ** 2))) + term2 = -math.exp(numpy.mean(numpy.cos(c * x))) + value = term1 + term2 + a + math.e + return -float(value) + + +class Himmelblau(_SingleObjectiveProblem): + """ + Himmelblau. 2D problem with four equal global minima at f = 0: + (3.0, 2.0), + (-2.805, 3.131), + (-3.779, -3.283), + (3.584, -1.848). + """ + bounds = (-5.0, 5.0) + + def __init__(self): + super().__init__(num_genes=2) + + def __call__(self, ga, solution, sol_idx): + x, y = float(solution[0]), float(solution[1]) + value = (x * x + y - 11.0) ** 2 + (x + y * y - 7.0) ** 2 + return -float(value) diff --git a/pygad/benchmarks/dtlz.py b/pygad/benchmarks/dtlz.py new file mode 100644 index 0000000..9ba2dbf --- /dev/null +++ b/pygad/benchmarks/dtlz.py @@ -0,0 +1,143 @@ +""" +DTLZ multi-objective benchmark problems. + +Each problem takes M (objectives) and k (distance variables). The +number of decision variables is M + k - 1. Defaults: k = 5 for +DTLZ1, k = 10 for DTLZ2, DTLZ3, and DTLZ4. + +Fitness values are negated so PyGAD can maximize them. +""" + +import math + +import numpy + + +class _DtlzProblem: + """Base class with attributes shared by every DTLZ problem.""" + bounds = (0.0, 1.0) + + def __init__(self, num_objectives, num_distance_vars): + if num_objectives < 2: + raise ValueError( + f"num_objectives must be at least 2 for the DTLZ suite, " + f"but got {num_objectives}.") + self.num_objectives = int(num_objectives) + self.num_distance_vars = int(num_distance_vars) + self.num_genes = self.num_objectives + self.num_distance_vars - 1 + + +class DTLZ1(_DtlzProblem): + """ + DTLZ1. Pareto front is the linear hyperplane sum(f_i) = 0.5. + The g-function has many local minima. + """ + + def __init__(self, num_objectives=3, num_distance_vars=5): + super().__init__(num_objectives, num_distance_vars) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + position_vars = x[:self.num_objectives - 1] + distance_vars = x[self.num_objectives - 1:] + g_value = 100.0 * ( + self.num_distance_vars + + numpy.sum((distance_vars - 0.5) ** 2 + - numpy.cos(20.0 * math.pi * (distance_vars - 0.5))) + ) + radius = 0.5 * (1.0 + g_value) + objectives = [] + for objective_index in range(self.num_objectives): + value = radius + for cos_index in range(self.num_objectives - 1 - objective_index): + value *= position_vars[cos_index] + if objective_index > 0: + value *= (1.0 - position_vars[self.num_objectives - 1 - objective_index]) + objectives.append(-float(value)) + return objectives + + +class DTLZ2(_DtlzProblem): + """ + DTLZ2. Pareto front is the first orthant of the unit sphere + (sum(f_i ** 2) = 1). g is simple, so the challenge is diversity. + """ + + def __init__(self, num_objectives=3, num_distance_vars=10): + super().__init__(num_objectives, num_distance_vars) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + position_vars = x[:self.num_objectives - 1] + distance_vars = x[self.num_objectives - 1:] + g_value = numpy.sum((distance_vars - 0.5) ** 2) + radius = 1.0 + g_value + angles = position_vars * (math.pi / 2.0) + objectives = [] + for objective_index in range(self.num_objectives): + value = radius + for cos_index in range(self.num_objectives - 1 - objective_index): + value *= math.cos(angles[cos_index]) + if objective_index > 0: + value *= math.sin(angles[self.num_objectives - 1 - objective_index]) + objectives.append(-float(value)) + return objectives + + +class DTLZ3(_DtlzProblem): + """ + DTLZ3. Same unit-sphere front as DTLZ2, with the hard + multimodal g from DTLZ1. Convergence is harder. + """ + + def __init__(self, num_objectives=3, num_distance_vars=10): + super().__init__(num_objectives, num_distance_vars) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + position_vars = x[:self.num_objectives - 1] + distance_vars = x[self.num_objectives - 1:] + g_value = 100.0 * ( + self.num_distance_vars + + numpy.sum((distance_vars - 0.5) ** 2 + - numpy.cos(20.0 * math.pi * (distance_vars - 0.5))) + ) + radius = 1.0 + g_value + angles = position_vars * (math.pi / 2.0) + objectives = [] + for objective_index in range(self.num_objectives): + value = radius + for cos_index in range(self.num_objectives - 1 - objective_index): + value *= math.cos(angles[cos_index]) + if objective_index > 0: + value *= math.sin(angles[self.num_objectives - 1 - objective_index]) + objectives.append(-float(value)) + return objectives + + +class DTLZ4(_DtlzProblem): + """ + DTLZ4. Same shape as DTLZ2, but position variables are raised + to alpha (default 100). Solutions get pushed toward one corner. + """ + + def __init__(self, num_objectives=3, num_distance_vars=10, alpha=100.0): + super().__init__(num_objectives, num_distance_vars) + self.alpha = float(alpha) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + position_vars = x[:self.num_objectives - 1] ** self.alpha + distance_vars = x[self.num_objectives - 1:] + g_value = numpy.sum((distance_vars - 0.5) ** 2) + radius = 1.0 + g_value + angles = position_vars * (math.pi / 2.0) + objectives = [] + for objective_index in range(self.num_objectives): + value = radius + for cos_index in range(self.num_objectives - 1 - objective_index): + value *= math.cos(angles[cos_index]) + if objective_index > 0: + value *= math.sin(angles[self.num_objectives - 1 - objective_index]) + objectives.append(-float(value)) + return objectives diff --git a/pygad/benchmarks/knapsack.py b/pygad/benchmarks/knapsack.py new file mode 100644 index 0000000..c6101d7 --- /dev/null +++ b/pygad/benchmarks/knapsack.py @@ -0,0 +1,65 @@ +""" +0/1 Knapsack benchmark. + +Each item has a weight and a value. Pick a subset of items so the +total value is the largest possible while the total weight stays +within the capacity. + +A solution is a binary vector (one bit per item). Plug into PyGAD +with the class attributes: + + problem = Knapsack(weights=[...], values=[...], capacity=...) + ga = pygad.GA( + ..., + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, + fitness_func=problem, + ) +""" + +import numpy + + +class Knapsack: + """ + 0/1 knapsack. + + If a solution is over capacity, fitness is negative and scaled by + the overweight amount. This keeps a gradient toward feasibility. + """ + num_objectives = 1 + gene_space = [0, 1] + gene_type = int + + def __init__(self, weights, values, capacity): + weights = numpy.asarray(weights, dtype=float) + values = numpy.asarray(values, dtype=float) + if weights.ndim != 1: + raise ValueError( + f"weights must be a 1D array, but got shape {weights.shape}.") + if values.ndim != 1: + raise ValueError( + f"values must be a 1D array, but got shape {values.shape}.") + if weights.shape != values.shape: + raise ValueError( + f"weights and values must have the same length, but got " + f"{weights.shape[0]} weights and {values.shape[0]} values.") + if not numpy.all(weights >= 0): + raise ValueError("weights must be non-negative.") + if not numpy.all(values >= 0): + raise ValueError("values must be non-negative.") + if capacity <= 0: + raise ValueError(f"capacity must be positive, but got {capacity}.") + self.weights = weights + self.values = values + self.capacity = float(capacity) + self.num_genes = int(weights.shape[0]) + + def __call__(self, ga, solution, sol_idx): + choice = numpy.asarray(solution, dtype=int) + total_weight = float(numpy.sum(choice * self.weights)) + total_value = float(numpy.sum(choice * self.values)) + if total_weight > self.capacity: + return -(total_weight - self.capacity) + return total_value diff --git a/pygad/benchmarks/tsp.py b/pygad/benchmarks/tsp.py new file mode 100644 index 0000000..65c86b6 --- /dev/null +++ b/pygad/benchmarks/tsp.py @@ -0,0 +1,68 @@ +""" +Travelling Salesman Problem (TSP) benchmark. + +A solution is a permutation of city indices. Fitness is the +negative tour length so PyGAD can maximize it. + +Build the problem from a 2D array of coordinates or from a +precomputed distance matrix. The class exposes gene_space, +gene_type, and allow_duplicate_genes so it plugs into PyGAD as is. +""" + +import numpy + + +class TSP: + """0/1 closed-tour TSP.""" + num_objectives = 1 + gene_type = int + allow_duplicate_genes = False + + def __init__(self, coordinates=None, distance_matrix=None): + if (coordinates is None) == (distance_matrix is None): + raise ValueError("Pass exactly one of coordinates or distance_matrix.") + if coordinates is not None: + coordinates = numpy.asarray(coordinates, dtype=float) + if coordinates.ndim != 2: + raise ValueError( + f"coordinates must be a 2D array, but got shape {coordinates.shape}.") + if coordinates.shape[0] < 2: + raise ValueError( + f"coordinates must have at least 2 rows, but got {coordinates.shape[0]}.") + self.coordinates = coordinates + self.distance_matrix = self._build_distance_matrix(coordinates) + else: + distance_matrix = numpy.asarray(distance_matrix, dtype=float) + if distance_matrix.ndim != 2 or distance_matrix.shape[0] != distance_matrix.shape[1]: + raise ValueError( + f"distance_matrix must be square, but got shape {distance_matrix.shape}.") + if distance_matrix.shape[0] < 2: + raise ValueError( + f"distance_matrix must have at least 2 rows, but got {distance_matrix.shape[0]}.") + if numpy.any(distance_matrix < 0): + raise ValueError("distance_matrix entries must be non-negative.") + self.coordinates = None + self.distance_matrix = distance_matrix + self.num_genes = int(self.distance_matrix.shape[0]) + self.gene_space = list(range(self.num_genes)) + + @staticmethod + def _build_distance_matrix(coordinates): + diff = coordinates[:, None, :] - coordinates[None, :, :] + return numpy.sqrt((diff * diff).sum(axis=2)) + + def tour_length(self, tour): + """Length of the closed tour. Last leg goes from the last city back to the first.""" + tour = numpy.asarray(tour, dtype=int) + next_city = numpy.roll(tour, -1) + return float(self.distance_matrix[tour, next_city].sum()) + + def __call__(self, ga, solution, sol_idx): + tour = numpy.asarray(solution, dtype=int) + if tour.shape[0] != self.num_genes: + return -float(self.distance_matrix.sum()) + unique_count = numpy.unique(tour).shape[0] + if unique_count != self.num_genes or tour.min() < 0 or tour.max() >= self.num_genes: + missing = self.num_genes - unique_count + return -float(self.distance_matrix.sum()) * (1.0 + missing) + return -self.tour_length(tour) diff --git a/pygad/benchmarks/zdt.py b/pygad/benchmarks/zdt.py new file mode 100644 index 0000000..2fabe52 --- /dev/null +++ b/pygad/benchmarks/zdt.py @@ -0,0 +1,125 @@ +""" +ZDT multi-objective benchmark problems. + +Two objectives. Variables live in [0, 1] (ZDT4 uses a wider range +for some). Every class has a pareto_front() method that returns +points on the true front in PyGAD's maximization format (negated), +which you can pass to the IGD and GD indicators as reference_front. +""" + +import numpy + + +class _ZdtProblem: + """Base class with attributes shared by every ZDT problem.""" + num_objectives = 2 + bounds = (0.0, 1.0) + + def __init__(self, num_genes): + self.num_genes = int(num_genes) + + +class ZDT1(_ZdtProblem): + """ + ZDT1. Convex front: f2 = 1 - sqrt(f1) for f1 in [0, 1]. + Optimal solutions: x_0 in [0, 1], x_i = 0 for i >= 1. + """ + + def __init__(self, num_genes=30): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + f1 = x[0] + g = 1.0 + 9.0 * numpy.sum(x[1:]) / (self.num_genes - 1) + f2 = g * (1.0 - numpy.sqrt(f1 / g)) + return [-float(f1), -float(f2)] + + def pareto_front(self, num_points=100): + f1 = numpy.linspace(0.0, 1.0, num_points) + f2 = 1.0 - numpy.sqrt(f1) + return numpy.stack([-f1, -f2], axis=1) + + +class ZDT2(_ZdtProblem): + """ + ZDT2. Non-convex front: f2 = 1 - f1**2 for f1 in [0, 1]. + Same variable layout as ZDT1. + """ + + def __init__(self, num_genes=30): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + f1 = x[0] + g = 1.0 + 9.0 * numpy.sum(x[1:]) / (self.num_genes - 1) + f2 = g * (1.0 - (f1 / g) ** 2) + return [-float(f1), -float(f2)] + + def pareto_front(self, num_points=100): + f1 = numpy.linspace(0.0, 1.0, num_points) + f2 = 1.0 - f1 ** 2 + return numpy.stack([-f1, -f2], axis=1) + + +class ZDT3(_ZdtProblem): + """ZDT3. Front is five disconnected convex pieces.""" + + def __init__(self, num_genes=30): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + f1 = x[0] + g = 1.0 + 9.0 * numpy.sum(x[1:]) / (self.num_genes - 1) + h = 1.0 - numpy.sqrt(f1 / g) - (f1 / g) * numpy.sin(10.0 * numpy.pi * f1) + f2 = g * h + return [-float(f1), -float(f2)] + + +class ZDT4(_ZdtProblem): + """ + ZDT4. x_0 in [0, 1], rest in [-5, 5]. Same convex front as ZDT1 + (f2 = 1 - sqrt(f1)), but the search space has many local minima. + """ + bounds = (-5.0, 5.0) + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.asarray(solution, dtype=float) + x_first = numpy.clip(x[0], 0.0, 1.0) + rest = numpy.clip(x[1:], -5.0, 5.0) + f1 = x_first + g = (1.0 + 10.0 * (self.num_genes - 1) + + numpy.sum(rest ** 2 - 10.0 * numpy.cos(4.0 * numpy.pi * rest))) + f2 = g * (1.0 - numpy.sqrt(f1 / g)) + return [-float(f1), -float(f2)] + + def pareto_front(self, num_points=100): + f1 = numpy.linspace(0.0, 1.0, num_points) + f2 = 1.0 - numpy.sqrt(f1) + return numpy.stack([-f1, -f2], axis=1) + + +class ZDT6(_ZdtProblem): + """ + ZDT6. Non-uniform front; solutions cluster at one end. + """ + + def __init__(self, num_genes=10): + super().__init__(num_genes) + + def __call__(self, ga, solution, sol_idx): + x = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + f1 = 1.0 - numpy.exp(-4.0 * x[0]) * numpy.sin(6.0 * numpy.pi * x[0]) ** 6 + g = 1.0 + 9.0 * (numpy.sum(x[1:]) / (self.num_genes - 1)) ** 0.25 + f2 = g * (1.0 - (f1 / g) ** 2) + return [-float(f1), -float(f2)] + + def pareto_front(self, num_points=100): + f1 = numpy.linspace(0.281, 1.0, num_points) + f2 = 1.0 - f1 ** 2 + return numpy.stack([-f1, -f2], axis=1) diff --git a/pygad/helper/activations.py b/pygad/helper/activations.py index 6b0e4f2..5e3e877 100644 --- a/pygad/helper/activations.py +++ b/pygad/helper/activations.py @@ -2,11 +2,19 @@ def sigmoid(sop): """ - Applies the sigmoid function. + Apply the sigmoid activation function element-wise: + ``sigmoid(x) = 1 / (1 + exp(-x))``. - sop: The input to which the sigmoid function is applied. + Parameters + ---------- + sop : numeric, list, tuple, or numpy.ndarray + The input value(s). Lists and tuples are converted to a + numpy array before computing. - Returns the result of the sigmoid function. + Returns + ------- + activated : numeric or numpy.ndarray + The element-wise sigmoid of the input. """ if type(sop) in [list, tuple]: @@ -16,11 +24,19 @@ def sigmoid(sop): def relu(sop): """ - Applies the ReLU function. + Apply the ReLU activation function element-wise: + ``relu(x) = max(0, x)``. - sop: The input to which the relu function is applied. + Parameters + ---------- + sop : numeric, list, tuple, or numpy.ndarray + The input value(s). Scalars are handled as a special case. + Lists and tuples are converted to a numpy array. - Returns the result of the ReLU function. + Returns + ------- + activated : numeric or numpy.ndarray + The element-wise ReLU of the input. """ if not (type(sop) in [list, tuple, numpy.ndarray]): @@ -38,10 +54,20 @@ def relu(sop): def softmax(layer_outputs): """ - Applies the softmax function. + Apply a sum-normalized softmax: divide each value by the sum of + all values plus a tiny constant to avoid division by zero. - layer_outputs: The input to which the softmax function is applied. + Note that this is not the canonical softmax (which uses + exponentials); it just normalizes the inputs so they sum to one. - Returns the result of the softmax function. + Parameters + ---------- + layer_outputs : numpy.ndarray + The values to normalize. + + Returns + ------- + activated : numpy.ndarray + The normalized values. """ return layer_outputs / (numpy.sum(layer_outputs) + 0.000001) diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 0c31472..c912528 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -18,15 +18,41 @@ def summary(self, print_step_parameters=True, print_parameters_summary=True): """ - The summary() method prints a summary of the PyGAD lifecycle in a Keras style. - The parameters are: - line_length: An integer representing the length of the single line in characters. - fill_character: A character to fill the lines. - line_character: A character for creating a line separator. - line_character2: A secondary character to create a line separator. - columns_equal_len: The table rows are split into equal-sized columns or split subjective to the width needed. - print_step_parameters: Whether to print extra parameters about each step inside the step. If print_step_parameters=False and print_parameters_summary=True, then the parameters of each step are printed at the end of the table. - print_parameters_summary: Whether to print parameters summary at the end of the table. If print_step_parameters=False, then the parameters of each step are printed at the end of the table too. + Print a Keras-style summary of the PyGAD lifecycle. Each + configured step (fitness, parent selection, crossover, + mutation, etc.) is shown on its own row together with the + handler name and an output-shape hint. The string written to + the logger is also returned. + + Parameters + ---------- + line_length : int + Total width of a printed line in characters. + fill_character : str + Character used to pad cells to the column width. + line_character : str + Character used to draw the lighter horizontal separator + between rows. + line_character2 : str + Character used to draw the heavier separator between the + header and the body. + columns_equal_len : bool + If True, the three columns are split into equal widths. + Otherwise the widths follow the longest content in each + column. + print_step_parameters : bool + If True, the extra parameters of each step are printed + inside the step's row. + print_parameters_summary : bool + If True, a summary block of global parameters is printed + below the table. When ``print_step_parameters`` is False, + the per-step extras are folded into this summary block. + + Returns + ------- + summary_output : str + The full summary as a single string (the same text that + was written to the logger). """ summary_output = "" @@ -240,7 +266,20 @@ def print_params_summary(): def initialize_parents_array(self, shape): """ - Standardize array initialization for parents and offspring. + Allocate an empty parents (or offspring) array with the right + dtype. Uses the dtype of the first gene type when every gene + shares the same type, otherwise falls back to ``object``. + + Parameters + ---------- + shape : tuple + The shape of the array, usually + ``(num_parents, num_genes)``. + + Returns + ------- + array : numpy.ndarray + An uninitialised array of the requested shape and dtype. """ if self.gene_type_single: return numpy.empty(shape, dtype=self.gene_type[0]) @@ -250,13 +289,21 @@ def initialize_parents_array(self, shape): def change_population_dtype_and_round(self, population): """ - Change the data type of the population. It works with iterables (e.g. lists or NumPy arrays) of shape 2D. - It does not handle single numeric values or 1D arrays. - - It accepts: - -population: The iterable to change its dtype. - - It returns the iterable with the data type changed for all genes. + Cast a 2D population to the dtype encoded in + ``self.gene_type`` and round non-integer genes to the + configured precision. When ``gene_type_single`` is True, the + same dtype and precision are applied to every gene; otherwise + each gene gets its own dtype and precision. + + Parameters + ---------- + population : list or numpy.ndarray + A 2D iterable with shape ``(num_solutions, num_genes)``. + + Returns + ------- + population_new : numpy.ndarray + The same data cast (and rounded) to the right type. """ population_new = numpy.array(population.copy(), dtype=object) @@ -299,13 +346,22 @@ def change_gene_dtype_and_round(self, gene_index, gene_value): """ - Change the data type and round a single gene value or a vector of values FOR THE SAME GENE. E.g., the input could be 6 or [6, 7, 8]. - - It accepts 2 parameters: - -gene_index: The index of the target gene. - -gene_value: The gene value. - - If gene_value has a single value, then it returns a single number with the type changed and value rounded. If gene_value is a vector, then a vector is returned after changing the data type and rounding. + Cast and round one or more candidate values that all belong + to the same gene index. Useful when generating mutation + values for a specific gene. + + Parameters + ---------- + gene_index : int + Index of the gene whose dtype / precision should be used. + gene_value : numeric or iterable + Either a single value or a vector of values for that + gene. + + Returns + ------- + gene_value_new : numeric + The first (or only) value after casting and rounding. """ if self.gene_type_single == True: @@ -344,15 +400,28 @@ def mutation_change_gene_dtype_and_round(self, gene_value, mutation_by_replacement): """ - Change the data type and round the random value used to apply mutation. - - It accepts: - -random_value: The random value to change its data type. - -gene_index: The index of the target gene. - -gene_value: The gene value before mutation. Only used if mutation_by_replacement=False and gene_type_single=False. - -mutation_by_replacement: A flag indicating whether mutation by replacement is enabled or not. The reason is to make this helper method usable while generating the initial population. In this case, mutation_by_replacement does not matter and should be considered False. - - It returns the new value after changing the data type and being rounded. + Apply a random mutation value to a gene and cast / round the + result. If ``mutation_by_replacement`` is True, the random + value replaces the gene; otherwise it is added to the + existing value. + + Parameters + ---------- + random_value : numeric + The freshly drawn mutation value. + gene_index : int + Index of the gene being mutated. + gene_value : numeric + Gene value before mutation. Only used when + ``mutation_by_replacement`` is False. + mutation_by_replacement : bool + If True, replace the gene; otherwise add the random + value to it. + + Returns + ------- + gene_value_new : numeric + The mutated value after casting and rounding. """ if mutation_by_replacement: @@ -369,6 +438,26 @@ def mutation_change_gene_dtype_and_round(self, def validate_gene_constraint_callable_output(self, selected_values, values): + """ + Check that a gene constraint callable returned a list or + numpy array whose elements are all members of the original + candidate ``values``. + + Parameters + ---------- + selected_values : list, numpy.ndarray, or other + The return value from the user-supplied constraint + callable. + values : iterable + The full set of candidate values that was passed to the + callable. + + Returns + ------- + valid : bool + True when ``selected_values`` is a list or numpy array + and is a subset of ``values``. False otherwise. + """ if type(selected_values) in [list, numpy.ndarray]: selected_values_set = set(selected_values) if selected_values_set.issubset(values): @@ -384,16 +473,32 @@ def filter_gene_values_by_constraint(self, values, solution, gene_idx): - """ - Filter the random values generated for mutation based on whether they meet the gene constraint in the gene_constraint parameter. - - It accepts: - -values: The values to filter. - -solution: The solution containing the target gene. - -gene_idx: The index of the gene in the solution. - - It returns None if no values satisfy the constraint. Otherwise, an array of values that satisfy the constraint is returned. + Pass a list of candidate values through the user-supplied + gene constraint callable and return the subset that satisfies + the constraint. + + Parameters + ---------- + values : list or numpy.ndarray + Candidate values to filter. + solution : numpy.ndarray + The solution that owns the gene. Passed to the constraint + callable so it can look at the other genes if needed. + gene_idx : int + Index of the gene inside ``solution``. + + Returns + ------- + filtered_values : list, numpy.ndarray, or None + The values that satisfy the constraint, or None when no + value satisfies it (a warning is issued in that case). + + Raises + ------ + Exception + If the gene has no constraint, or the constraint callable + returns a result that is not a subset of ``values``. """ if self.gene_constraint and self.gene_constraint[gene_idx]: @@ -423,14 +528,23 @@ def filter_gene_values_by_constraint(self, return filtered_values def get_gene_dtype(self, gene_index): - """ - Returns the data type of the gene by its index. - - It accepts a single parameter: - -gene_index: The index of the gene to get its data type. Only used if each gene has its own data type. - - It returns the data type of the gene. + Return the dtype (and optional precision) for the gene at the + given index. When ``gene_type_single`` is True the same dtype + is returned for every gene; otherwise the per-gene entry is + returned. + + Parameters + ---------- + gene_index : int + Index of the gene whose dtype is wanted. Ignored when + ``gene_type_single`` is True. + + Returns + ------- + dtype : type or list + Either a single Python / numpy type, or a + ``[type, precision]`` pair. """ if self.gene_type_single == True: @@ -440,14 +554,24 @@ def get_gene_dtype(self, gene_index): return dtype def get_random_mutation_range(self, gene_index): - """ - Returns the minimum and maximum values of the mutation range. - - It accepts a single parameter: - -gene_index: The index of the gene to get its range. Only used if the gene has a specific mutation range. - - It returns the minimum and maximum values of the gene mutation range. + Return the random-mutation range ``(min, max)`` for the gene + at the given index. When ``random_mutation_min_val`` is a + scalar, the same range is used for every gene; otherwise the + per-gene entry is returned. + + Parameters + ---------- + gene_index : int + Index of the gene. Ignored when the range parameters are + scalars. + + Returns + ------- + range_min : numeric + Lower bound of the random delta. + range_max : numeric + Upper bound of the random delta. """ # We can use either random_mutation_min_val or random_mutation_max_val. @@ -460,14 +584,24 @@ def get_random_mutation_range(self, gene_index): return range_min, range_max def get_initial_population_range(self, gene_index): - """ - Returns the minimum and maximum values of the initial population range. - - It accepts a single parameter: - -gene_index: The index of the gene to get its range. Only used if the gene has a specific range - - It returns the minimum and maximum values of the gene initial population range. + Return the initial-population range ``(min, max)`` for the + gene at the given index. When ``init_range_low`` is a scalar, + the same range is used for every gene; otherwise the + per-gene entry is returned. + + Parameters + ---------- + gene_index : int + Index of the gene. Ignored when the range parameters are + scalars. + + Returns + ------- + range_min : numeric + Lower bound for the random initial gene value. + range_max : numeric + Upper bound for the random initial gene value. """ # We can use either init_range_low or init_range_high. @@ -486,18 +620,37 @@ def generate_gene_value_from_space(self, gene_value=None, sample_size=1): """ - Generate/select one or more values for the gene from the gene space. - - It accepts: - -gene_idx: The index of the gene in the solution. - -mutation_by_replacement: A flag indicating whether mutation by replacement is enabled or not. The reason is to make this helper method usable while generating the initial population. In this case, mutation_by_replacement does not matter and should be considered False. - -solution (iterable, optional): The solution where we need to generate a gene. Needed if you are selecting a single value (sample_size=1) to select a value that respects the allow_duplicate_genes parameter instead of selecting a value randomly. If None, then the gene value is selected randomly. - -gene_value (int, optional): The original gene value before applying mutation. Needed if you are calling this method to apply mutation. If None, then a sample is created from the gene space without being summed to the gene value. - -sample_size (int, optional): The number of random values to generate. It tries to generate a number of values up to a maximum of sample_size. But it is not always guaranteed because the total number of values might not be enough or the random generator creates duplicate random values. For int data types, it could be None to keep all the values. For float data types, a None value returns only a single value. - - It returns, - -A single numeric value if sample_size=1. Or - -An array with number of maximum number of values equal to sample_size if sample_size>1. + Generate one or more candidate values for the gene from its + ``gene_space`` entry. Handles flat spaces, nested spaces, + ``range`` objects, and ``{low, high, step}`` dictionaries. + + Parameters + ---------- + gene_idx : int + Index of the gene inside the solution. + mutation_by_replacement : bool + If True (mutation by replacement) the generated value is + used as-is. If False the generated value is added to + ``gene_value``. Set to True when building the initial + population. + solution : iterable or None + The solution the gene belongs to. When provided and + ``sample_size`` is 1, the helper tries to pick a value + that does not duplicate any existing gene. + gene_value : numeric or None + The current gene value. Required when applying mutation + with ``mutation_by_replacement=False`` so the random + value can be added on top. + sample_size : int + Number of candidate values to generate. ``1`` returns a + single number; larger values return an array; ``None`` + keeps the full integer range or a single float value. + + Returns + ------- + value : numeric or numpy.ndarray + A single value when ``sample_size=1``; otherwise an + array of up to ``sample_size`` values. """ if gene_value is None: @@ -648,19 +801,39 @@ def generate_gene_value_randomly(self, sample_size=1, step=1): """ - Randomly generate one or more values for the gene. - It accepts: - -range_min: The minimum value in the range from which a value is selected. - -range_max: The maximum value in the range from which a value is selected. - -gene_value: The original gene value before applying mutation. - -gene_idx: The index of the gene in the solution. - -mutation_by_replacement: A flag indicating whether mutation by replacement is enabled or not. The reason is to make this helper method usable while generating the initial population. In this case, mutation_by_replacement does not matter and should be considered False. - -sample_size: The number of random values to generate. It tries to generate a number of values up to a maximum of sample_size. But it is not always guaranteed because the total number of values might not be enough or the random generator creates duplicate random values. For int data types, it could be None to keep all the values. For float data types, a None value returns only a single value. - -step (int, optional): The step size for generating candidate values. Defaults to 1. Only used with genes of an integer data type. - - It returns, - -A single numeric value if sample_size=1. Or - -An array with number of values equal to sample_size if sample_size>1. + Generate one or more candidate values for the gene by drawing + from the random range ``[range_min, range_max)``. For integer + gene types the helper iterates over the discrete values; for + float types it samples uniformly. + + Parameters + ---------- + range_min : numeric + Lower bound of the random range. + range_max : numeric + Upper bound of the random range. + gene_value : numeric + The current gene value, used when + ``mutation_by_replacement`` is False so the random delta + can be added to it. + gene_idx : int + Index of the gene inside the solution. + mutation_by_replacement : bool + If True, the random value replaces the gene; otherwise it + is added. + sample_size : int or None + Number of candidate values to generate. ``1`` returns a + single number; larger values return an array of up to + that many values; ``None`` keeps every value in the + integer range or returns a single float. + step : int + Step size used when enumerating an integer range. + + Returns + ------- + random_value : numeric or numpy.ndarray + A single value when ``sample_size=1``; otherwise an + array of unique values. """ gene_type = self.get_gene_dtype(gene_index=gene_idx) @@ -716,20 +889,40 @@ def generate_gene_value(self, sample_size=1, step=1): """ - Generate one or more values for the gene either randomly or from the gene space. It acts as a router. - It accepts: - -gene_value: The original gene value before applying mutation. - -gene_idx: The index of the gene in the solution. - -mutation_by_replacement: A flag indicating whether mutation by replacement is enabled or not. The reason is to make this helper method usable while generating the initial population. In this case, mutation_by_replacement does not matter and should be considered False. - -solution (iterable, optional): The solution where we need to generate a gene. Needed if you are selecting a single value (sample_size=1) to select a value that respects the allow_duplicate_genes parameter instead of selecting a value randomly. If None, then the gene value is selected randomly. - -range_min (int, optional): The minimum value in the range from which a value is selected. It must be passed for generating the gene value randomly because we cannot decide whether it is the range for the initial population (init_range_low and init_range_high) or mutation (random_mutation_min_val and random_mutation_max_val). - -range_max (int, optional): The maximum value in the range from which a value is selected. It must be passed for generating the gene value randomly because we cannot decide whether it is the range for the initial population (init_range_low and init_range_high) or mutation (random_mutation_min_val and random_mutation_max_val). - -sample_size: The number of random values to generate/select and return. It tries to generate a number of values up to a maximum of sample_size. But it is not always guaranteed because the total number of values might not be enough or the random generator creates duplicate random values. For int data types, it could be None to keep all the values. For float data types, a None value returns only a single value. - -step (int, optional): The step size for generating candidate values. Defaults to 1. Only used with genes of an integer data type. - - It returns, - -A single numeric value if sample_size=1. Or - -An array with number of values equal to sample_size if sample_size>1. + Dispatcher that picks between + ``generate_gene_value_from_space`` (when ``self.gene_space`` + is set) and ``generate_gene_value_randomly`` (otherwise) to + generate one or more candidate values for the gene. + + Parameters + ---------- + gene_value : numeric + The current gene value, used when + ``mutation_by_replacement`` is False. + gene_idx : int + Index of the gene inside the solution. + mutation_by_replacement : bool + See ``generate_gene_value_randomly``. + solution : iterable or None + The solution that owns the gene. Used to avoid creating + duplicates when ``sample_size=1`` and + ``allow_duplicate_genes`` is False. + range_min : numeric or None + Lower bound for the random range. Required when + ``self.gene_space`` is None. + range_max : numeric or None + Upper bound for the random range. Required when + ``self.gene_space`` is None. + sample_size : int or None + Number of candidate values to generate. + step : int + Step size for the integer random range. + + Returns + ------- + output : numeric or numpy.ndarray + A single value when ``sample_size=1``; otherwise an + array of values. """ if self.gene_space is None: output = self.generate_gene_value_randomly(range_min=range_min, @@ -757,22 +950,36 @@ def get_valid_gene_constraint_values(self, sample_size=100, step=1): """ - Generate/select values for the gene that satisfy the constraint. The values could be generated randomly or from the gene space. - The number of returned values is at its maximum equal to the sample_size parameter. - It accepts: - -range_min: The minimum value in the range from which a value is selected. - -range_max: The maximum value in the range from which a value is selected. - -gene_value: The original gene value before applying mutation. - -gene_idx: The index of the gene in the solution. - -mutation_by_replacement: A flag indicating whether mutation by replacement is enabled or not. The reason is to make this helper method usable while generating the initial population. In this case, mutation_by_replacement does not matter and should be considered False. - -solution: The solution in which the gene exists. - -sample_size: The number of values to generate or select. It tries to generate a number of values up to a maximum of sample_size. But it is not always guaranteed because the total number of values might not be enough or the random generator creates duplicate random values. - -step (int, optional): The step size for generating candidate values. Defaults to 1. Only used with genes of an integer data type. - - It returns, - -A single numeric value if sample_size=1. Or - -An array with number of values equal to sample_size if sample_size>1. Or - -None if no value found that satisfies the constraint. + Generate up to ``sample_size`` candidate values for the gene + (via ``generate_gene_value``) and then filter them through + the user-supplied ``gene_constraint`` callable. + + Parameters + ---------- + range_min : numeric or None + Lower bound of the random range. + range_max : numeric or None + Upper bound of the random range. + gene_value : numeric + The current gene value, used when + ``mutation_by_replacement`` is False. + gene_idx : int + Index of the gene inside the solution. + mutation_by_replacement : bool + See ``generate_gene_value_randomly``. + solution : iterable + The solution that owns the gene. Passed to the + constraint callable so it can look at the other genes. + sample_size : int + Number of candidate values to draw before filtering. + step : int + Step size for the integer random range. + + Returns + ------- + values_filtered : numpy.ndarray or None + Values that satisfy the constraint, or None if no + candidate satisfies it. """ # Either generate the values randomly or from the gene space. diff --git a/pygad/kerasga/kerasga.py b/pygad/kerasga/kerasga.py index afc2274..98380b7 100644 --- a/pygad/kerasga/kerasga.py +++ b/pygad/kerasga/kerasga.py @@ -1,151 +1,167 @@ -import copy -import numpy -import tensorflow.keras - -def model_weights_as_vector(model): - """ - Reshapes the Keras model weight as a vector. - - Parameters - ---------- - model : TYPE - The Keras model. - - Returns - ------- - TYPE - The weights as a 1D vector. - - """ - weights_vector = [] - - for layer in model.layers: # model.get_weights(): - if layer.trainable: - layer_weights = layer.get_weights() - for l_weights in layer_weights: - vector = numpy.reshape(l_weights, (l_weights.size)) - weights_vector.extend(vector) - - return numpy.array(weights_vector) - -def model_weights_as_matrix(model, weights_vector): - """ - Reshapes the PyGAD 1D solution as a Keras weight matrix. - - Parameters - ---------- - model : TYPE - The Keras model. - weights_vector : TYPE - The PyGAD solution as a 1D vector. - - Returns - ------- - weights_matrix : TYPE - The Keras weights as a matrix. - - """ - weights_matrix = [] - - start = 0 - for layer_idx, layer in enumerate(model.layers): # model.get_weights(): - # for w_matrix in model.get_weights(): - layer_weights = layer.get_weights() - if layer.trainable: - for l_weights in layer_weights: - layer_weights_shape = l_weights.shape - layer_weights_size = l_weights.size - - layer_weights_vector = weights_vector[start:start + layer_weights_size] - layer_weights_matrix = numpy.reshape(layer_weights_vector, (layer_weights_shape)) - weights_matrix.append(layer_weights_matrix) - - start = start + layer_weights_size - else: - for l_weights in layer_weights: - weights_matrix.append(l_weights) - - return weights_matrix - -def predict(model, - solution, - data, - batch_size=None, - verbose=0, - steps=None): - """ - Use the PyGAD's solution to make predictions using the Keras model. - - Parameters - ---------- - model : TYPE - The Keras model. - solution : TYPE - A single PyGAD solution as 1D vector. - data : TYPE - The data or a generator. - batch_size : TYPE, optional - The batch size (i.e. number of samples per step or batch). The default is None. Check documentation of the Keras Model.predict() method for more information. - verbose : TYPE, optional - Verbosity mode. The default is 0. Check documentation of the Keras Model.predict() method for more information. - steps : TYPE, optional - The total number of steps (batches of samples). The default is None. Check documentation of the Keras Model.predict() method for more information. - - Returns - ------- - predictions : TYPE - The Keras model predictions. - - """ - # Fetch the parameters of the best solution. - solution_weights = model_weights_as_matrix(model=model, - weights_vector=solution) - _model = tensorflow.keras.models.clone_model(model) - _model.set_weights(solution_weights) - predictions = _model.predict(x=data, - batch_size=batch_size, - verbose=verbose, - steps=steps) - - return predictions - -class KerasGA: - - def __init__(self, model, num_solutions): - - """ - Creates an instance of the KerasGA class to build a population of model parameters. - - model: A Keras model class. - num_solutions: Number of solutions in the population. Each solution has different model parameters. - """ - - self.model = model - - self.num_solutions = num_solutions - - # A list holding references to all the solutions (i.e. networks) used in the population. - self.population_weights = self.create_population() - - def create_population(self): - - """ - Creates the initial population of the genetic algorithm as a list of networks' weights (i.e. solutions). Each element in the list holds a different set of weights for the Keras model. - - The method returns a list holding the weights of all solutions. - """ - - model_weights_vector = model_weights_as_vector(model=self.model) - - net_population_weights = [] - net_population_weights.append(model_weights_vector) - - for idx in range(self.num_solutions-1): - - net_weights = copy.deepcopy(model_weights_vector) - net_weights = numpy.array(net_weights) + numpy.random.uniform(low=-1.0, high=1.0, size=model_weights_vector.size) - - # Appending the weights to the population. - net_population_weights.append(net_weights) - - return net_population_weights +import copy +import numpy +import tensorflow.keras + +def model_weights_as_vector(model): + """ + Flatten every weight tensor of a Keras model into a single 1D + NumPy array. Only the weights of trainable layers are included. + + Parameters + ---------- + model : tensorflow.keras.Model + The Keras model whose weights should be flattened. + + Returns + ------- + weights_vector : numpy.ndarray + A 1D array with every trainable parameter of the model laid + out in layer order. + """ + weights_vector = [] + + for layer in model.layers: # model.get_weights(): + if layer.trainable: + layer_weights = layer.get_weights() + for l_weights in layer_weights: + vector = numpy.reshape(l_weights, (l_weights.size)) + weights_vector.extend(vector) + + return numpy.array(weights_vector) + +def model_weights_as_matrix(model, weights_vector): + """ + Reshape a flat 1D weights vector back into the per-layer matrices + expected by ``model.set_weights``. Non-trainable layers keep their + current weights. + + Parameters + ---------- + model : tensorflow.keras.Model + The reference Keras model. Used to read the per-layer shapes. + weights_vector : array-like + A 1D vector in the same layout produced by + ``model_weights_as_vector``. + + Returns + ------- + weights_matrix : list of numpy.ndarray + One matrix per layer, ready to be passed to + ``model.set_weights``. + """ + weights_matrix = [] + + start = 0 + for layer_idx, layer in enumerate(model.layers): # model.get_weights(): + # for w_matrix in model.get_weights(): + layer_weights = layer.get_weights() + if layer.trainable: + for l_weights in layer_weights: + layer_weights_shape = l_weights.shape + layer_weights_size = l_weights.size + + layer_weights_vector = weights_vector[start:start + layer_weights_size] + layer_weights_matrix = numpy.reshape(layer_weights_vector, (layer_weights_shape)) + weights_matrix.append(layer_weights_matrix) + + start = start + layer_weights_size + else: + for l_weights in layer_weights: + weights_matrix.append(l_weights) + + return weights_matrix + +def predict(model, + solution, + data, + batch_size=None, + verbose=0, + steps=None): + """ + Load the given solution as the model's weights and run a forward + pass on ``data``. The model is cloned first so the caller's + instance is left untouched. + + Parameters + ---------- + model : tensorflow.keras.Model + The reference Keras model. + solution : array-like + A 1D weights vector returned by the GA. + data : numpy.ndarray or tf.data.Dataset + Input data passed to ``Model.predict``. + batch_size : int or None + Number of samples per step. Forwarded to ``Model.predict``. + verbose : int + Verbosity level. Forwarded to ``Model.predict``. + steps : int or None + Number of steps (batches). Forwarded to ``Model.predict``. + + Returns + ------- + predictions : numpy.ndarray + The Keras model output for ``data``. + """ + # Fetch the parameters of the best solution. + solution_weights = model_weights_as_matrix(model=model, + weights_vector=solution) + _model = tensorflow.keras.models.clone_model(model) + _model.set_weights(solution_weights) + predictions = _model.predict(x=data, + batch_size=batch_size, + verbose=verbose, + steps=steps) + + return predictions + +class KerasGA: + + def __init__(self, model, num_solutions): + """ + Build a population of weight vectors for a Keras model so the + GA can evolve them. + + Parameters + ---------- + model : tensorflow.keras.Model + The Keras model to optimize. Its current weights are used + as the seed for the first solution. + num_solutions : int + Number of solutions in the population. Each solution is a + flat copy of the model weights with random perturbations + added to it. + """ + + self.model = model + + self.num_solutions = num_solutions + + # A list holding references to all the solutions (i.e. networks) used in the population. + self.population_weights = self.create_population() + + def create_population(self): + """ + Build the initial population. The first solution is the model's + current flattened weights; every other solution is the same + vector with a uniform ``[-1, 1]`` perturbation added on top. + + Returns + ------- + net_population_weights : list of numpy.ndarray + One flat weight vector per solution. + """ + + model_weights_vector = model_weights_as_vector(model=self.model) + + net_population_weights = [] + net_population_weights.append(model_weights_vector) + + for idx in range(self.num_solutions-1): + + net_weights = copy.deepcopy(model_weights_vector) + net_weights = numpy.array(net_weights) + numpy.random.uniform(low=-1.0, high=1.0, size=model_weights_vector.size) + + # Appending the weights to the population. + net_population_weights.append(net_weights) + + return net_population_weights diff --git a/pygad/pygad.py b/pygad/pygad.py index 228cbb8..37eba68 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -8,7 +8,10 @@ class GA(utils.parent_selection.ParentSelection, utils.crossover.Crossover, utils.mutation.Mutation, + utils.nsga.NSGA, utils.nsga2.NSGA2, + utils.nsga3.NSGA3, + utils.report.Report, utils.validation.Validation, utils.engine.GAEngine, helper.unique.Unique, @@ -34,13 +37,16 @@ def __init__(self, init_range_high=4, gene_type=float, parent_selection_type="sss", - keep_parents=-1, + keep_parents=None, keep_elitism=1, K_tournament=3, + nsga3_num_divisions=None, crossover_type="single_point", crossover_probability=None, + sbx_crossover_eta=30, mutation_type="random", mutation_probability=None, + polynomial_mutation_eta=20, mutation_by_replacement=False, mutation_percent_genes='default', mutation_num_genes=None, @@ -84,16 +90,19 @@ def __init__(self, gene_type: The type of the gene. It is assigned to any of these types (int, numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, float, numpy.float16, numpy.float32, numpy.float64) and forces all the genes to be of that type. parent_selection_type: Type of parent selection. - keep_parents: If 0, this means no parent in the current population will be used in the next population. If -1, this means all parents in the current population will be used in the next population. If set to a value > 0, then the specified value refers to the number of parents in the current population to be used in the next population. Some parent selection operators such as rank selection, favor population diversity and therefore keeping the parents in the next generation can be beneficial. However, some other parent selection operators, such as roulette wheel selection (RWS), have higher selection pressure and keeping more than one parent in the next generation can seriously harm population diversity. This parameter has an effect only when the keep_elitism parameter is 0. Thanks to Prof. Fernando Jiménez (http://webs.um.es/fernan) for editing this sentence. + keep_parents: It defaults to None, which is treated as -1 (keep all selected parents). If 0, this means no parent in the current population will be used in the next population. If -1, this means all parents in the current population will be used in the next population. If set to a value > 0, then the specified value refers to the number of parents in the current population to be used in the next population. Some parent selection operators such as rank selection, favor population diversity and therefore keeping the parents in the next generation can be beneficial. However, some other parent selection operators, such as roulette wheel selection (RWS), have higher selection pressure and keeping more than one parent in the next generation can seriously harm population diversity. IMPORTANT: This parameter has an effect only when the keep_elitism parameter is 0. Because keep_elitism defaults to 1, keep_parents is ignored unless you also set keep_elitism=0. If you explicitly set keep_parents while keep_elitism is greater than 0, a warning is raised to flag that keep_parents will have no effect. Thanks to Prof. Fernando Jiménez (http://webs.um.es/fernan) for editing this sentence. K_tournament: When the value of 'parent_selection_type' is 'tournament', the 'K_tournament' parameter specifies the number of solutions from which a parent is selected randomly. + nsga3_num_divisions: Only used when 'parent_selection_type' is 'nsga3' or 'tournament_nsga3'. It is the number of divisions per objective axis used to build the structured reference points (the 'p' parameter from Deb & Jain 2014). The total number of reference points is C(M + p - 1, p) where M is the number of objectives. Must be a positive integer. Defaults to None. - keep_elitism: Added in PyGAD 2.18.0. It can take the value 0 or a positive integer that satisfies (0 <= keep_elitism <= sol_per_pop). It defaults to 1 which means only the best solution in the current generation is kept in the next generation. If assigned 0, this means it has no effect. If assigned a positive integer K, then the best K solutions are kept in the next generation. It cannot be assigned a value greater than the value assigned to the sol_per_pop parameter. If this parameter has a value different from 0, then the keep_parents parameter will have no effect. + keep_elitism: Added in PyGAD 2.18.0. It can take the value 0 or a positive integer that satisfies (0 <= keep_elitism <= sol_per_pop). It defaults to 1 which means only the best solution in the current generation is kept in the next generation. If assigned 0, this means it has no effect. If assigned a positive integer K, then the best K solutions are kept in the next generation. It cannot be assigned a value greater than the value assigned to the sol_per_pop parameter. If this parameter has a value different from 0, then it takes precedence over the keep_parents parameter, which will have no effect (a warning is raised if keep_parents was explicitly set in this case). To use keep_parents instead, set keep_elitism=0. crossover_type: Type of the crossover operator. If crossover_type=None, then the crossover step is bypassed which means no crossover is applied and thus no offspring will be created in the next generations. The next generation will use the solutions in the current population. crossover_probability: The probability of selecting a solution for the crossover operation. If the solution probability is <= crossover_probability, the solution is selected. The value must be between 0 and 1 inclusive. + sbx_crossover_eta: Only used when 'crossover_type' is 'sbx'. The distribution index that controls how close the children stay to the parents (higher value = closer). Defaults to 30. mutation_type: Type of the mutation operator. If mutation_type=None, then the mutation step is bypassed which means no mutation is applied and thus no changes are applied to the offspring created using the crossover operation. The offspring will be used unchanged in the next generation. mutation_probability: The probability of selecting a gene for the mutation operation. If the gene probability is <= mutation_probability, the gene is selected. It accepts either a single value for fixed mutation or a list/tuple/numpy.ndarray of 2 values for adaptive mutation. The values must be between 0 and 1 inclusive. If specified, then no need for the 2 parameters mutation_percent_genes and mutation_num_genes. + polynomial_mutation_eta: Only used when 'mutation_type' is 'polynomial'. The distribution index that controls how small the mutation step is (higher value = smaller step). Defaults to 20. mutation_by_replacement: An optional bool parameter. It works only when the selected type of mutation is random (mutation_type="random"). In this case, setting mutation_by_replacement=True means replace the gene by the randomly generated value. If False, then it has no effect and random mutation works by adding the random value to the gene. @@ -145,10 +154,13 @@ def __init__(self, keep_parents=keep_parents, keep_elitism=keep_elitism, K_tournament=K_tournament, + nsga3_num_divisions=nsga3_num_divisions, crossover_type=crossover_type, crossover_probability=crossover_probability, + sbx_crossover_eta=sbx_crossover_eta, mutation_type=mutation_type, mutation_probability=mutation_probability, + polynomial_mutation_eta=polynomial_mutation_eta, mutation_by_replacement=mutation_by_replacement, mutation_percent_genes=mutation_percent_genes, mutation_num_genes=mutation_num_genes, @@ -179,8 +191,13 @@ def __init__(self, def save(self, filename): """ - Saves the genetic algorithm instance: - -filename: Name of the file to save the instance. No extension is needed. + Serialise the GA instance to disk with ``cloudpickle``. The + file extension ``.pkl`` is added automatically. + + Parameters + ---------- + filename : str + Path (without extension) where the pickle file is written. """ cloudpickle_serialized_object = cloudpickle.dumps(self) @@ -189,20 +206,36 @@ def save(self, filename): cloudpickle.dump(self, file) def push_to_vilvik(self, *, api_key=None, **overrides): - """Import this run into Vilvik (https://vilvik.com) as an editable, - continuable cloud record. - - This is a thin convenience wrapper over the Vilvik SDK, which must be - installed separately:: + """ + Push this GA run to Vilvik (https://vilvik.com) as an + editable, continuable cloud record. Thin convenience wrapper + over the Vilvik SDK, which must be installed separately:: pip install vilvik - After ``ga.run()``, call ``ga.push_to_vilvik()`` (sign in first with the - ``vilvik login`` command or set the ``VILVIK_API_KEY`` environment - variable). All keyword arguments are forwarded to ``vilvik.push`` (for - example ``name=``, ``fitness_source=``, ``callbacks=``, ``preamble=``, - ``dry_run=True``). Returns the created record, or a capture report when - ``dry_run=True``. + Call after ``ga.run()``. Sign in first with ``vilvik login`` + or set the ``VILVIK_API_KEY`` environment variable. + + Parameters + ---------- + api_key : str, optional + Explicit API key. When None, the SDK falls back to the + CLI login or the ``VILVIK_API_KEY`` environment variable. + **overrides + Forwarded to ``vilvik.push``. Common keys include + ``name``, ``fitness_source``, ``callbacks``, + ``preamble``, and ``dry_run``. + + Returns + ------- + record : object + The created Vilvik record, or a capture report when + ``dry_run=True``. + + Raises + ------ + ImportError + If the ``vilvik`` package is not installed. """ try: import vilvik @@ -222,9 +255,27 @@ def push_to_vilvik(self, *, api_key=None, **overrides): def load(filename): """ - Reads a saved instance of the genetic algorithm: - -filename: Name of the file to read the instance. No extension is needed. - Returns the genetic algorithm instance. + Load a GA instance from a ``cloudpickle`` file written by + ``pygad.GA.save``. The file extension ``.pkl`` is added + automatically. + + Parameters + ---------- + filename : str + Path (without extension) where the pickle file is read from. + + Returns + ------- + ga_in : pygad.GA + The restored GA instance. + + Raises + ------ + FileNotFoundError + If the file does not exist. + BaseException + If the file exists but cannot be unpickled (for example when + the original fitness function is not importable). """ try: diff --git a/pygad/torchga/torchga.py b/pygad/torchga/torchga.py index f7a25b1..6a50bbb 100644 --- a/pygad/torchga/torchga.py +++ b/pygad/torchga/torchga.py @@ -1,91 +1,157 @@ -import copy -import numpy -import torch - -def model_weights_as_vector(model): - weights_vector = [] - - for curr_weights in model.state_dict().values(): - # Calling detach() to remove the computational graph from the layer. - # cpu() is called to make sure the data is moved from the GPU to the CPU. - # numpy() is called to convert the tensor into a NumPy array. - curr_weights = curr_weights.cpu().detach().numpy() - vector = numpy.reshape(curr_weights, (curr_weights.size)) - weights_vector.extend(vector) - - return numpy.array(weights_vector) - -def model_weights_as_dict(model, weights_vector): - weights_dict = model.state_dict() - - start = 0 - for key in weights_dict: - # Calling detach() to remove the computational graph from the layer. - # cpu() is called to make sure the data is moved from the GPU to the CPU. - # numpy() is called to convert the tensor into a NumPy array. - w_matrix = weights_dict[key].cpu().detach().numpy() - layer_weights_shape = w_matrix.shape - layer_weights_size = w_matrix.size - - layer_weights_vector = weights_vector[start:start + layer_weights_size] - layer_weights_matrix = numpy.reshape(layer_weights_vector, (layer_weights_shape)) - weights_dict[key] = torch.from_numpy(layer_weights_matrix) - - start = start + layer_weights_size - - return weights_dict - -def predict(model, solution, data): - # Fetch the parameters of the best solution. - model_weights_dict = model_weights_as_dict(model=model, - weights_vector=solution) - - # Use the current solution as the model parameters. - _model = copy.deepcopy(model) - _model.load_state_dict(model_weights_dict) - - with torch.no_grad(): - predictions = _model(data) - - return predictions - -class TorchGA: - - def __init__(self, model, num_solutions): - - """ - Creates an instance of the TorchGA class to build a population of model parameters. - - model: A PyTorch model class. - num_solutions: Number of solutions in the population. Each solution has different model parameters. - """ - - self.model = model - - self.num_solutions = num_solutions - - # A list holding references to all the solutions (i.e. networks) used in the population. - self.population_weights = self.create_population() - - def create_population(self): - - """ - Creates the initial population of the genetic algorithm as a list of networks' weights (i.e. solutions). Each element in the list holds a different set of weights for the PyTorch model. - - The method returns a list holding the weights of all solutions. - """ - - model_weights_vector = model_weights_as_vector(model=self.model) - - net_population_weights = [] - net_population_weights.append(model_weights_vector) - - for idx in range(self.num_solutions-1): - - net_weights = copy.deepcopy(model_weights_vector) - net_weights = numpy.array(net_weights) + numpy.random.uniform(low=-1.0, high=1.0, size=model_weights_vector.size) - - # Appending the weights to the population. - net_population_weights.append(net_weights) - - return net_population_weights +import copy +import numpy +import torch + +def model_weights_as_vector(model): + """ + Flatten every weight tensor of a PyTorch model into a single 1D + NumPy array. Tensors are moved off the GPU and detached from the + computational graph before being converted. + + Parameters + ---------- + model : torch.nn.Module + The model whose weights should be flattened. + + Returns + ------- + weights_vector : numpy.ndarray + A 1D float array with every parameter of the model laid out + in ``state_dict`` order. + """ + weights_vector = [] + + for curr_weights in model.state_dict().values(): + # Calling detach() to remove the computational graph from the layer. + # cpu() is called to make sure the data is moved from the GPU to the CPU. + # numpy() is called to convert the tensor into a NumPy array. + curr_weights = curr_weights.cpu().detach().numpy() + vector = numpy.reshape(curr_weights, (curr_weights.size)) + weights_vector.extend(vector) + + return numpy.array(weights_vector) + +def model_weights_as_dict(model, weights_vector): + """ + Reshape a flat 1D weights vector back into the per-layer tensors + expected by ``model.load_state_dict``. The shapes are taken from + the model's current ``state_dict``. + + Parameters + ---------- + model : torch.nn.Module + The reference model. Used only to read the per-layer shapes. + weights_vector : array-like + A 1D vector in the same layout produced by + ``model_weights_as_vector``. + + Returns + ------- + weights_dict : dict + A dict mapping every parameter name to a freshly built + ``torch.Tensor`` with the right shape. + """ + weights_dict = model.state_dict() + + start = 0 + for key in weights_dict: + # Calling detach() to remove the computational graph from the layer. + # cpu() is called to make sure the data is moved from the GPU to the CPU. + # numpy() is called to convert the tensor into a NumPy array. + w_matrix = weights_dict[key].cpu().detach().numpy() + layer_weights_shape = w_matrix.shape + layer_weights_size = w_matrix.size + + layer_weights_vector = weights_vector[start:start + layer_weights_size] + layer_weights_matrix = numpy.reshape(layer_weights_vector, (layer_weights_shape)) + weights_dict[key] = torch.from_numpy(layer_weights_matrix) + + start = start + layer_weights_size + + return weights_dict + +def predict(model, solution, data): + """ + Load the given solution as the model's weights and run a forward + pass on the input ``data``. The model is deep-copied first so the + caller's instance is left untouched. + + Parameters + ---------- + model : torch.nn.Module + The reference model whose architecture should be used for the + forward pass. + solution : array-like + A 1D weights vector returned by the GA. + data : torch.Tensor + Input tensor of the right shape for the model. + + Returns + ------- + predictions : torch.Tensor + The model's output for ``data``. + """ + # Fetch the parameters of the best solution. + model_weights_dict = model_weights_as_dict(model=model, + weights_vector=solution) + + # Use the current solution as the model parameters. + _model = copy.deepcopy(model) + _model.load_state_dict(model_weights_dict) + + with torch.no_grad(): + predictions = _model(data) + + return predictions + +class TorchGA: + + def __init__(self, model, num_solutions): + """ + Build a population of weight vectors for a PyTorch model so + the GA can evolve them. + + Parameters + ---------- + model : torch.nn.Module + The PyTorch model to optimize. Its current weights are + used as the seed for the first solution. + num_solutions : int + Number of solutions in the population. Each solution is + a flat copy of the model weights with random perturbations + added to it. + """ + + self.model = model + + self.num_solutions = num_solutions + + # A list holding references to all the solutions (i.e. networks) used in the population. + self.population_weights = self.create_population() + + def create_population(self): + """ + Build the initial population. The first solution is the model's + current flattened weights; every other solution is the same + vector with a uniform ``[-1, 1]`` perturbation added on top. + + Returns + ------- + net_population_weights : list of numpy.ndarray + One flat weight vector per solution. + """ + + model_weights_vector = model_weights_as_vector(model=self.model) + + net_population_weights = [] + net_population_weights.append(model_weights_vector) + + for idx in range(self.num_solutions-1): + + net_weights = copy.deepcopy(model_weights_vector) + net_weights = numpy.array(net_weights) + numpy.random.uniform(low=-1.0, high=1.0, size=model_weights_vector.size) + + # Appending the weights to the population. + net_population_weights.append(net_weights) + + return net_population_weights diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 16d0a10..0aedc3f 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -1,7 +1,11 @@ from pygad.utils import parent_selection from pygad.utils import crossover from pygad.utils import mutation +from pygad.utils import nsga from pygad.utils import nsga2 +from pygad.utils import nsga3 +from pygad.utils import quality_indicators +from pygad.utils import report from pygad.utils import validation from pygad.utils import engine diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index fbd6bdd..4a85f86 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -11,17 +11,25 @@ def __init__(): pass def single_point_crossover(self, parents, offspring_size): - """ - Applies single-point crossover between pairs of parents. - This function selects a random point at which crossover occurs between the parents, generating offspring. - - Parameters: - parents (array-like): The parents to mate for producing the offspring. - offspring_size (int): The number of offspring to produce. - - Returns: - array-like: An array containing the produced offspring. + Apply single-point crossover between pairs of parents. One + random split point is picked per offspring; the genes before + the point come from the first parent and the genes from the + point onward come from the second parent. + + Parameters + ---------- + parents : numpy.ndarray + A 2D array of parent solutions, one row per parent. + offspring_size : tuple + ``(num_offspring, num_genes)``. Shape of the offspring + array to return. + + Returns + ------- + offspring : numpy.ndarray + A 2D array of the produced offspring with shape + ``offspring_size``. """ if self.gene_type_single == True: @@ -81,13 +89,24 @@ def single_point_crossover(self, parents, offspring_size): return offspring def two_points_crossover(self, parents, offspring_size): - """ - Applies the 2 points crossover. It selects the 2 points randomly at which crossover takes place between the pairs of parents. - It accepts 2 parameters: - -parents: The parents to mate for producing the offspring. - -offspring_size: The size of the offspring to produce. - It returns an array of the produced offspring. + Apply two-points crossover. Two split points are picked per + offspring; the genes outside ``[point1, point2)`` come from the + first parent and the genes inside come from the second parent. + + Parameters + ---------- + parents : numpy.ndarray + A 2D array of parent solutions, one row per parent. + offspring_size : tuple + ``(num_offspring, num_genes)``. Shape of the offspring + array to return. + + Returns + ------- + offspring : numpy.ndarray + A 2D array of the produced offspring with shape + ``offspring_size``. """ if self.gene_type_single == True: @@ -154,13 +173,23 @@ def two_points_crossover(self, parents, offspring_size): return offspring def uniform_crossover(self, parents, offspring_size): - """ - 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. - It accepts 2 parameters: - -parents: The parents to mate for producing the offspring. - -offspring_size: The size of the offspring to produce. - It returns an array of the produced offspring. + Apply uniform crossover. For each gene independently, the value + is copied from one of the two mating parents picked at random. + + Parameters + ---------- + parents : numpy.ndarray + A 2D array of parent solutions, one row per parent. + offspring_size : tuple + ``(num_offspring, num_genes)``. Shape of the offspring + array to return. + + Returns + ------- + offspring : numpy.ndarray + A 2D array of the produced offspring with shape + ``offspring_size``. """ if self.gene_type_single == True: @@ -221,14 +250,123 @@ def uniform_crossover(self, parents, offspring_size): return offspring - def scattered_crossover(self, parents, offspring_size): + def sbx_crossover(self, parents, offspring_size): + """ + Apply Simulated Binary Crossover (SBX). For each gene, the + offspring value is drawn from a distribution centered on the + parents. The spread of this distribution is set by + ``self.sbx_crossover_eta`` (a higher value means the child + stays closer to the parents). + + The bounded form is used. The per-gene bounds come from + ``get_initial_population_range``. + + Parameters + ---------- + parents : numpy.ndarray + A 2D array of parent solutions, one row per parent. + offspring_size : tuple + (num_offspring, num_genes). Shape of the offspring array + to return. + + Returns + ------- + offspring : numpy.ndarray + A 2D array of offspring with shape offspring_size. + """ + if self.gene_type_single == True: + offspring = numpy.empty(offspring_size, dtype=self.gene_type[0]) + else: + offspring = numpy.empty(offspring_size, dtype=object) + + eta = float(self.sbx_crossover_eta) + near_zero = 1e-14 + + for k in range(offspring_size[0]): + if not (self.crossover_probability is None): + probs = numpy.random.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + if len(indices) == 0: + offspring[k, :] = parents[k % parents.shape[0], :] + continue + elif len(indices) == 1: + parent1_idx = indices[0] + parent2_idx = parent1_idx + else: + indices = random.sample(indices, 2) + parent1_idx = indices[0] + parent2_idx = indices[1] + else: + parent1_idx = k % parents.shape[0] + parent2_idx = (k + 1) % parents.shape[0] + + for gene_idx in range(offspring_size[1]): + p1 = float(parents[parent1_idx, gene_idx]) + p2 = float(parents[parent2_idx, gene_idx]) + y1 = min(p1, p2) + y2 = max(p1, p2) + + if y2 - y1 < near_zero: + # The two parents have the same value on this gene. + offspring[k, gene_idx] = p1 + continue + + range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) + lower = float(range_min) + upper = float(range_max) + + # Beta is the spread factor that controls how far the + # child can move away from the parents. + beta = 1.0 + 2.0 * min(y1 - lower, upper - y2) / (y2 - y1) + alpha = 2.0 - pow(beta, -(eta + 1.0)) + rand_u = numpy.random.random() + if rand_u <= 1.0 / alpha: + beta_q = pow(rand_u * alpha, 1.0 / (eta + 1.0)) + else: + beta_q = pow(1.0 / (2.0 - rand_u * alpha), 1.0 / (eta + 1.0)) + + child = 0.5 * ((y1 + y2) - beta_q * (y2 - y1)) + child = numpy.clip(child, lower, upper) + offspring[k, gene_idx] = child + + if self.allow_duplicate_genes == False: + if self.gene_space is None: + offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], + min_val=self.random_mutation_min_val, + max_val=self.random_mutation_max_val, + mutation_by_replacement=self.mutation_by_replacement, + gene_type=self.gene_type, + sample_size=self.sample_size) + else: + offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], + gene_type=self.gene_type, + sample_size=self.sample_size, + mutation_by_replacement=self.mutation_by_replacement, + build_initial_pop=False) + + return offspring + + def scattered_crossover(self, parents, offspring_size): """ - Applies the scattered crossover. It randomly selects the gene from one of the 2 parents. - It accepts 2 parameters: - -parents: The parents to mate for producing the offspring. - -offspring_size: The size of the offspring to produce. - It returns an array of the produced offspring. + Apply scattered crossover. For each gene independently, the + value is copied from one of the two mating parents picked at + random. (Same mechanic as ``uniform_crossover`` but kept as a + separate operator for backwards compatibility.) + + Parameters + ---------- + parents : numpy.ndarray + A 2D array of parent solutions, one row per parent. + offspring_size : tuple + ``(num_offspring, num_genes)``. Shape of the offspring + array to return. + + Returns + ------- + offspring : numpy.ndarray + A 2D array of the produced offspring with shape + ``offspring_size``. """ if self.gene_type_single == True: diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index 1dff46e..19e75bb 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -6,6 +6,22 @@ class GAEngine: def round_genes(self, solutions): + """ + Round the genes in ``solutions`` according to the precision + encoded in ``self.gene_type``. When ``gene_type_single`` is + True, the same dtype and precision are applied to every gene; + otherwise the per-gene dtype / precision pair is used. + + Parameters + ---------- + solutions : numpy.ndarray + A 2D array of solutions to round. + + Returns + ------- + solutions : numpy.ndarray + The same array with the rounding applied. + """ if self.gene_type_single: if not self.gene_type[1] is None: solutions = numpy.round(numpy.asarray(solutions, dtype=self.gene_type[0]), @@ -23,17 +39,33 @@ def initialize_population(self, gene_type, gene_constraint): """ - Creates an initial population randomly as a NumPy array. The array is saved in the instance attribute named 'population'. + Build the initial population at random and store it on the GA + instance. The procedure has four steps: generate the gene + values (from the gene space or the init range), apply the + gene dtype and rounding, enforce gene constraints, and resolve + duplicate genes when not allowed. + + Sets the following instance attributes: - It accepts: - -allow_duplicate_genes: Whether duplicate genes are allowed or not. - -gene_type: The data type of the genes. - -gene_constraint: The constraints of the genes. + - ``pop_size``: a ``(sol_per_pop, num_genes)`` tuple. + - ``population``: the working population. Updated every + generation after this initial call. + - ``initial_population``: a frozen copy of the initial + population for later reference. - This method assigns the values of the following 3 instance attributes: - 1. pop_size: Size of the population. - 2. population: Initially, holds the initial population and later updated after each generation. - 3. init_population: Keeping the initial population. + Parameters + ---------- + allow_duplicate_genes : bool + If False, duplicate genes inside a single solution are + resolved by sampling new values. + gene_type : list or type + The dtype (and optional precision) for the genes. Used by + ``solve_duplicate_genes_randomly`` when resolving + duplicates outside the gene space. + gene_constraint : list or None + One callable per gene that returns the subset of a + candidate values list which satisfy the constraint. ``None`` + disables the per-gene constraint check. """ # Population size = (number of chromosomes, number of genes per chromosome) @@ -157,9 +189,36 @@ def initialize_population(self, def cal_pop_fitness(self): """ - Calculating the fitness values of batches of solutions in the current population. - It returns: - -fitness: An array of the calculated fitness values. + Compute the fitness value of every solution in the current + population. + + Avoids recomputing the fitness of solutions that were already + evaluated as parents in a previous generation: when + ``self.last_generation_parents`` is available, the matching + rows are reused from ``self.previous_generation_fitness``. The + rest are dispatched either sequentially or in parallel + depending on ``self.parallel_processing``. When + ``self.fitness_batch_size`` is set, solutions are submitted to + the fitness function in batches instead of one at a time. + + Returns + ------- + pop_fitness : numpy.ndarray + A 1D array of fitness values for single-objective problems + or a 2D array of shape ``(sol_per_pop, num_objectives)`` for + multi-objective problems. + + Raises + ------ + Exception + If ``self.valid_parameters`` is False, meaning the GA + instance was created with invalid parameters. + ValueError + If the fitness function returns a value of an unexpected + type or if a batch returns a wrong number of fitness values. + TypeError + If a batched fitness function does not return a list, + tuple, or numpy.ndarray. """ try: if self.valid_parameters == False: @@ -225,6 +284,7 @@ def cal_pop_fitness(self): # Check if batch processing is used. If not, then calculate this missing fitness value. if self.fitness_batch_size in [1, None]: fitness = self.fitness_func(self, sol, sol_idx) + self.num_fitness_evaluations += 1 if type(fitness) in self.supported_int_float_types: # The fitness function returns a single numeric value. # This is a single-objective optimization problem. @@ -259,6 +319,7 @@ def cal_pop_fitness(self): batch_fitness = self.fitness_func( self, batch_solutions, batch_indices) + self.num_fitness_evaluations += len(batch_indices) if type(batch_fitness) not in [list, tuple, numpy.ndarray]: raise TypeError(f"Expected to receive a list, tuple, or numpy.ndarray from the fitness function but the value ({batch_fitness}) of type {type(batch_fitness)}.") elif len(numpy.array(batch_fitness)) != len(batch_indices): @@ -335,6 +396,7 @@ def cal_pop_fitness(self): # Check if batch processing is used. If not, then calculate the fitness value for individual solutions. if self.fitness_batch_size in [1, None]: + self.num_fitness_evaluations += len(solutions_to_submit_indices) for index, fitness in zip(solutions_to_submit_indices, executor.map(self.fitness_func, [self]*len(solutions_to_submit_indices), solutions_to_submit, solutions_to_submit_indices)): if type(fitness) in self.supported_int_float_types: # The fitness function returns a single numeric value. @@ -365,6 +427,7 @@ def cal_pop_fitness(self): batches_solutions.append(batch_solutions) batches_indices.append(batch_indices) + self.num_fitness_evaluations += sum(len(b) for b in batches_indices) for batch_indices, batch_fitness in zip(batches_indices, executor.map(self.fitness_func, [self]*len(solutions_to_submit_indices), batches_solutions, batches_indices)): if type(batch_fitness) not in [list, tuple, numpy.ndarray]: raise TypeError(f"Expected to receive a list, tuple, or numpy.ndarray from the fitness function but the value ({batch_fitness}) of type {type(batch_fitness)}.") @@ -392,7 +455,29 @@ def cal_pop_fitness(self): def run(self): """ - Runs the genetic algorithm. This is the main method in which the genetic algorithm is evolved through a number of generations. + Run the genetic algorithm for ``self.num_generations`` + generations. This is the main entry point for users: it sets + up the bookkeeping lists, evaluates the initial population, + runs the generational loop (select, crossover, mutate, update + population, re-evaluate, callbacks, check stop criteria), and + finalises the best-solution data after the last generation. + + Calls the optional user callbacks ``on_start``, + ``on_fitness``, ``on_parents``, ``on_crossover``, + ``on_mutation``, ``on_generation`` and ``on_stop`` at the + appropriate points. + + Raises + ------ + Exception + If ``self.valid_parameters`` is False, meaning the GA was + built with invalid parameters. + TypeError + If an NSGA-II / NSGA-III parent selection type is used on + a single-objective problem. + ValueError + If the ``stop_criteria`` parameter is malformed for the + current number of objectives. """ try: if self.valid_parameters == False: @@ -417,6 +502,13 @@ def run(self): if not (self.on_start is None): self.on_start(self) + # Reset the counters used by the "evaluations_" and + # "time_" stop criteria. Each run() call should + # only count the work it did itself. + self.num_fitness_evaluations = 0 + import time as _time + self.run_start_time = _time.monotonic() + stop_run = False # To continue from where we stopped, the first generation index should start from the value of the 'self.generations_completed' parameter. @@ -435,12 +527,20 @@ def run(self): # Know whether the problem is SOO or MOO. if type(self.last_generation_fitness[0]) in self.supported_int_float_types: # Single-objective problem. - # If the problem is SOO, the parent selection type cannot be nsga2 or tournament_nsga2. - if self.parent_selection_type in ['nsga2', 'tournament_nsga2']: + # If the problem is SOO, the parent selection type cannot be nsga2/nsga3 or their tournament variants. + if self.parent_selection_type in ['nsga2', 'tournament_nsga2', 'nsga3', 'tournament_nsga3']: raise TypeError(f"Incorrect parent selection type. The fitness function returned a single numeric fitness value which means the problem is single-objective. But the parent selection type {self.parent_selection_type} is used which only works for multi-objective optimization problems.") elif type(self.last_generation_fitness[0]) in [list, tuple, numpy.ndarray]: # Multi-objective problem. - pass + if self.parent_selection_type in ('nsga3', 'tournament_nsga3'): + # The reference points are created before starting the evolution and after the initial fitness is calculated. + # The number of reference points is determined based on: + # 1) The number of divisions (passed by the user). + # 2) The number of objectives (only known after the fitness is calculated). + # In PyGAD, the number of objectives are known only from the length of the returned result of the fitness function. + # This is how NSGA-III knows the number of objectives from the calculated fitness. + # It is time to build the reference points. + self._bootstrap_nsga3_reference_points() best_solution, best_solution_fitness, best_match_idx = self.best_solution(pop_fitness=self.last_generation_fitness) @@ -552,6 +652,19 @@ def run(self): else: stop_run = False break + elif criterion[0] == "time": + # Stop when the time spent inside run() + # passes the user limit. + import time as _time + if _time.monotonic() - self.run_start_time >= float(criterion[1]): + stop_run = True + break + elif criterion[0] == "evaluations": + # Stop when the number of fitness calls + # reaches the user limit. + if self.num_fitness_evaluations >= int(criterion[1]): + stop_run = True + break if stop_run: break @@ -603,6 +716,28 @@ def run(self): raise ex def run_loop_head(self, best_solution_fitness): + """ + Run the bookkeeping that takes place at the top of every + generation: call ``self.on_fitness`` if set (with optional + validation of the returned values), append the running best + fitness to ``self.best_solutions_fitness``, and append the + current population and fitness to ``self.solutions`` / + ``self.solutions_fitness`` when ``self.save_solutions`` is + True. + + Internal helper. Not meant to be called by users. + + Parameters + ---------- + best_solution_fitness : numeric or numpy.ndarray + Fitness of the best solution in the previous generation. + + Raises + ------ + ValueError + If ``on_fitness`` returns an iterable whose shape does not + match the population fitness, or an unsupported type. + """ if not (self.on_fitness is None): on_fitness_output = self.on_fitness(self, self.last_generation_fitness) @@ -633,22 +768,40 @@ def run_loop_head(self, best_solution_fitness): def run_select_parents(self, call_on_parents=True): """ - This method must be only called from inside the run() method. It is not meant for use by the user. - Generally, any method with a name starting with 'run_' is meant to be only called by PyGAD from inside the 'run()' method. + Run the parent-selection step of one generation. Calls + ``self.select_parents`` (the operator chosen by + ``parent_selection_type``), validates the shapes of the + returned parents and indices, and updates these instance + attributes: - The objective of the 'run_select_parents()' method is to select the parents and call the callable on_parents() if defined. - It does not return any variables. However, it changes these 2 attributes of the pygad.GA class instances: - 1) last_generation_parents: A NumPy array of the selected parents. - 2) last_generation_parents_indices: A 1D NumPy array of the indices of the selected parents. + - ``self.last_generation_parents``: the selected parent + solutions. + - ``self.last_generation_parents_indices``: their indices + inside ``self.population``. + + Optionally calls the user-supplied ``on_parents`` callback, + which may replace the parents and / or their indices in place. + + Internal helper. Not meant to be called by users (any + ``run_*`` method is part of the generational loop driven by + ``run()``). Parameters ---------- - call_on_parents : bool, optional - If True, then the callable 'on_parents()' is called. The default is True. - - Returns - ------- - None. + call_on_parents : bool + When True, the ``on_parents`` callback is invoked after + selection. Set to False on the post-run cleanup pass so + the callback is not fired again. + + Raises + ------ + TypeError + If a user-supplied parent selection function returns + objects that are not ``numpy.ndarray``. + ValueError + If the selected parents have the wrong shape or the + ``on_parents`` callback returns an output that does not + match the expected layout. """ # Selecting the best parents in the population for mating. @@ -721,17 +874,32 @@ def run_select_parents(self, call_on_parents=True): def run_crossover(self): """ - This method must be only called from inside the run() method. It is not meant for use by the user. - Generally, any method with a name starting with 'run_' is meant to be only called by PyGAD from inside the 'run()' method. - - The objective of the 'run_crossover()' method is to apply crossover and call the callable on_crossover() if defined. - It does not return any variables. However, it changes these 2 attributes of the pygad.GA class instances: - 1) last_generation_offspring_crossover: A NumPy array of the selected offspring. - 2) last_generation_elitism: A NumPy array of the current generation elitism. Applicable only if the 'keep_elitism' parameter > 0. - - Returns - ------- - None. + Run the crossover step of one generation. Produces + ``self.num_offspring`` offspring from the selected parents and + updates these instance attributes: + + - ``self.last_generation_offspring_crossover``: the offspring + generated by crossover (or copied from parents when + ``crossover_type`` is None). + - ``self.last_generation_elitism``: the top + ``self.keep_elitism`` solutions in the population, used + later by ``run_update_population`` to seat the elite in the + next generation. + + Optionally calls the user-supplied ``on_crossover`` callback, + which may replace the offspring in place. + + Internal helper. Not meant to be called by users. + + Raises + ------ + TypeError + If a user-supplied crossover function returns an object + that is not a ``numpy.ndarray``. + ValueError + If the crossover output has the wrong shape or the + ``on_crossover`` callback returns an output that does not + match the expected layout. """ # If self.crossover_type=None, then no crossover is applied and thus no offspring will be created in the next generations. The next generation will use the solutions in the current population. @@ -788,16 +956,27 @@ def run_crossover(self): def run_mutation(self): """ - This method must be only called from inside the run() method. It is not meant for use by the user. - Generally, any method with a name starting with 'run_' is meant to be only called by PyGAD from inside the 'run()' method. - - The objective of the 'run_mutation()' method is to apply mutation and call the callable on_mutation() if defined. - It does not return any variables. However, it changes this attribute of the pygad.GA class instances: - 1) last_generation_offspring_mutation: A NumPy array of the mutated offspring. - - Returns - ------- - None. + Run the mutation step of one generation. Mutates the + post-crossover offspring and updates this instance attribute: + + - ``self.last_generation_offspring_mutation``: the mutated + offspring (or the unchanged crossover offspring when + ``mutation_type`` is None). + + Optionally calls the user-supplied ``on_mutation`` callback, + which may replace the mutated offspring in place. + + Internal helper. Not meant to be called by users. + + Raises + ------ + TypeError + If a user-supplied mutation function returns an object + that is not a ``numpy.ndarray``. + ValueError + If the mutation output has the wrong shape or the + ``on_mutation`` callback returns an output that does not + match the expected layout. """ # If self.mutation_type=None, then no mutation is applied and thus no changes are applied to the offspring created using the crossover operation. The offspring will be used unchanged in the next generation. @@ -838,16 +1017,24 @@ def run_mutation(self): def run_update_population(self): """ - This method must be only called from inside the run() method. It is not meant for use by the user. - Generally, any method with a name starting with 'run_' is meant to be only called by PyGAD from inside the 'run()' method. - - The objective of the 'run_update_population()' method is to update the 'population' attribute after completing the processes of crossover and mutation. - It does not return any variables. However, it changes this attribute of the pygad.GA class instances: - 1) population: A NumPy array of the population of solutions/chromosomes. - - Returns - ------- - None. + Build the next generation in ``self.population`` from the + offspring produced by mutation plus, optionally, the retained + parents (``keep_parents``) or elite (``keep_elitism``). + + Layout rules: + + - ``keep_elitism > 0``: top ``keep_elitism`` solutions sit at + the front of the new population; the rest is the mutated + offspring. + - ``keep_elitism == 0`` and ``keep_parents == -1``: all + selected parents sit at the front; the rest is offspring. + - ``keep_elitism == 0`` and ``keep_parents == 0``: the new + population is offspring only. + - ``keep_elitism == 0`` and ``keep_parents > 0``: top + ``keep_parents`` selected parents sit at the front; the + rest is offspring. + + Internal helper. Not meant to be called by users. """ # Update the population attribute according to the offspring generated. @@ -872,13 +1059,34 @@ def run_update_population(self): def best_solution(self, pop_fitness=None): """ - Returns information about the best solution found by the genetic algorithm. - Accepts the following parameters: - pop_fitness: An optional parameter holding the fitness values of the solutions in the latest population. If passed, then it saves time calculating the fitness. If None, then the 'cal_pop_fitness()' method is called to calculate the fitness of the latest population. - The following are returned: - -best_solution: Best solution in the current population. - -best_solution_fitness: Fitness value of the best solution. - -best_match_idx: Index of the best solution in the current population. + Return the best solution found in the latest population. For + single-objective problems "best" is the solution with the + maximum fitness. For multi-objective problems, the best + solution is the top entry of the NSGA-II sort (front 0, + highest crowding distance). + + Parameters + ---------- + pop_fitness : list, tuple, numpy.ndarray, or None + Pre-computed fitness for the current population. When + None, ``cal_pop_fitness`` is called to compute it. Useful + to avoid re-evaluating the fitness function. + + Returns + ------- + best_solution : numpy.ndarray + The genes of the best solution. + best_solution_fitness : numeric or numpy.ndarray + Its fitness value. + best_match_idx : int + Its index inside ``self.population``. + + Raises + ------ + ValueError + If ``pop_fitness`` is provided but its length does not + match the population, or its type is not list / tuple / + numpy.ndarray. """ try: @@ -923,3 +1131,184 @@ def best_solution(self, pop_fitness=None): raise ex return best_solution, best_solution_fitness, best_match_idx + + def _bootstrap_nsga3_reference_points(self): + """ + Build the reference-point grid once, right after the first + fitness evaluation. The number of objectives M is read from the + length of the first fitness vector. + + If ``sol_per_pop`` is smaller than the number of reference + points, grow the population to match and re-evaluate fitness so + the GA loop can carry on with a valid population. + """ + num_objectives = len(self.last_generation_fitness[0]) + self.nsga3_reference_points = self.nsga3_generate_reference_points( + num_objectives, self.nsga3_num_divisions) + required_size = len(self.nsga3_reference_points) + if self.sol_per_pop < required_size: + self._nsga3_grow_population(required_size, num_objectives) + + def _nsga3_grow_population(self, required_size, num_objectives): + """ + Append random solutions to ``self.population`` until the size + equals ``required_size``, then re-evaluate fitness. The new + rows follow the same gene space, init range, gene type, gene + constraints, and ``allow_duplicate_genes`` rules used to build + the initial population, so the grown population is + indistinguishable from one created with ``sol_per_pop`` set to + ``required_size`` from the start. + """ + original_size = self.sol_per_pop + if not self.suppress_warnings: + warnings.warn( + f"sol_per_pop ({original_size}) is smaller than the number of " + f"NSGA-III reference points ({required_size}) for M={num_objectives} " + f"objectives and nsga3_num_divisions={self.nsga3_num_divisions}. " + f"Growing the population to {required_size} random solutions " + f"and re-evaluating fitness." + ) + extra = self._nsga3_generate_extra_random_solutions( + required_size - original_size) + self.population = numpy.vstack([self.population, extra]) + self.sol_per_pop = required_size + self.pop_size = (required_size, self.num_genes) + # Shared helper on the Validation mixin keeps the rule in one place. + self._refresh_num_offspring() + self.last_generation_fitness = self.cal_pop_fitness() + + def _nsga3_generate_extra_random_solutions(self, count): + """ + Build ``count`` random solutions that obey every initial- + population rule: ``gene_space``, ``init_range_low`` / + ``init_range_high``, ``gene_type`` (including nested per-gene + type / precision), ``gene_constraint``, and + ``allow_duplicate_genes``. + + Steps mirror ``initialize_population``: + 1. Sample each gene from its space (or init range). + 2. Cast and round to the configured gene type. + 3. Enforce gene constraints when present. + 4. Resolve duplicate genes when not allowed. + """ + extra = numpy.empty((count, self.num_genes), dtype=object) + for sol_idx in range(count): + for gene_idx in range(self.num_genes): + extra[sol_idx, gene_idx] = self._nsga3_generate_single_random_gene( + gene_idx, extra[sol_idx]) + extra = self.change_population_dtype_and_round(extra) + + if self.gene_constraint is not None: + extra = self._nsga3_apply_gene_constraints(extra) + + if not self.allow_duplicate_genes: + extra = self._nsga3_resolve_duplicate_genes(extra) + extra = self.change_population_dtype_and_round(extra) + + return extra + + def _nsga3_generate_single_random_gene(self, gene_idx, partial_solution): + """ + Pick a single random gene value for ``gene_idx`` using the + initial-population settings. When ``gene_space`` is set, the + gene-space sampler is used; otherwise the per-gene init range + is used. ``mutation_by_replacement`` is forced to True so the + sampler returns a value drawn from the configured range rather + than an offset to add to an existing gene (which is the + mutation-time behavior). + """ + if self.gene_space is None: + range_min, range_max = self.get_initial_population_range( + gene_index=gene_idx) + return self.generate_gene_value_randomly(range_min=range_min, + range_max=range_max, + gene_idx=gene_idx, + mutation_by_replacement=True, + gene_value=None, + sample_size=1, + step=1) + return self.generate_gene_value_from_space(gene_idx=gene_idx, + mutation_by_replacement=True, + gene_value=None, + solution=partial_solution, + sample_size=1) + + def _nsga3_apply_gene_constraints(self, population): + """ + Walk the new rows and replace any gene that does not satisfy + its gene constraint, using the same logic that + ``initialize_population`` runs during the initial build. + """ + for sol_idx, solution in enumerate(population): + for gene_idx in range(self.num_genes): + if not self.gene_constraint[gene_idx]: + continue + values = [solution[gene_idx]] + filtered_values = self.gene_constraint[gene_idx](solution, values) + result = self.validate_gene_constraint_callable_output( + selected_values=filtered_values, values=values) + if not result: + 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(filtered_values) == 1 and filtered_values[0] != solution[gene_idx]: + raise Exception( + f"It is expected to receive a list/numpy.ndarray from " + f"the gene_constraint callable with a single value " + f"equal to {values[0]}, but the value " + f"{filtered_values[0]} found.") + if len(filtered_values) < 1: + range_min, range_max = self.get_initial_population_range( + gene_index=gene_idx) + values_filtered = self.get_valid_gene_constraint_values( + range_min=range_min, + range_max=range_max, + gene_value=None, + gene_idx=gene_idx, + mutation_by_replacement=True, + solution=solution, + sample_size=self.sample_size, + ) + if values_filtered is None: + if not self.suppress_warnings: + warnings.warn( + f"No value satisfied the constraint for the " + f"gene at index {gene_idx} with value " + f"{solution[gene_idx]} while growing the " + f"population for NSGA-III.") + else: + population[sol_idx, gene_idx] = random.choice(values_filtered) + elif len(filtered_values) > 1: + raise Exception( + f"It is expected to receive a list/numpy.ndarray from " + f"the gene_constraint callable that is either empty or " + f"has a single value equal, but received a list/numpy." + f"ndarray of length {len(filtered_values)}.") + return population + + def _nsga3_resolve_duplicate_genes(self, population): + """ + Apply the same duplicate-resolution path + ``initialize_population`` uses, so the grown rows never carry + duplicate genes when ``allow_duplicate_genes`` is False. + """ + for solution_idx in range(population.shape[0]): + if self.gene_space is None: + population[solution_idx], _, _ = self.solve_duplicate_genes_randomly( + solution=population[solution_idx], + min_val=self.init_range_low, + max_val=self.init_range_high, + gene_type=self.gene_type, + mutation_by_replacement=True, + sample_size=self.sample_size, + ) + else: + population[solution_idx], _, _ = self.solve_duplicate_genes_by_space( + solution=population[solution_idx].copy(), + gene_type=self.gene_type, + mutation_by_replacement=True, + sample_size=self.sample_size, + build_initial_pop=True, + ) + return population diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index e520f53..66d72e2 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -16,15 +16,22 @@ def __init__(self): pass def random_mutation(self, offspring): - """ - Applies the random mutation which changes the values of a number of genes randomly. - The random value is selected either using the 'gene_space' parameter or the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. - - It accepts: - -offspring: The offspring to mutate. - - It returns an array of the mutated offspring. + Dispatch to one of the four random-mutation backends depending + on whether the user passed ``mutation_probability`` and whether + ``gene_space`` is set. The replacement value for each mutated + gene comes either from the gene space or from the + ``random_mutation_min_val`` / ``random_mutation_max_val`` range. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # If the mutation values are selected from the mutation space, the attribute 'gene_space' is not None. Otherwise, it is None. @@ -48,12 +55,20 @@ def random_mutation(self, offspring): return offspring def mutation_by_space(self, offspring): - """ - Applies the mutation using the gene_space parameter. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring using the mutation space. + Mutate ``self.mutation_num_genes`` genes per offspring by + sampling a replacement value from the ``gene_space`` of each + chosen gene. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. @@ -77,12 +92,21 @@ def mutation_by_space(self, offspring): return offspring def mutation_probs_by_space(self, offspring): - """ - Applies the random mutation using the mutation values' space and the mutation probability. For each gene, if its probability is <= the mutation probability, then it will be mutated based on the mutation space. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring using the mutation space. + Per-gene mutation that uses the ``gene_space`` for replacement + values and ``self.mutation_probability`` to decide which genes + to mutate. A gene is mutated when a uniform random draw is + less than or equal to the probability threshold. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. @@ -112,16 +136,38 @@ def mutation_process_gene_value(self, range_min=None, range_max=None, sample_size=100): - """ - Generate/select values for the gene that satisfy the constraint. The values could be generated randomly or from the gene space. - It accepts: - -range_min: The minimum value in the range from which a value is selected. - -range_max: The maximum value in the range from which a value is selected. - -solution: The solution where the target gene exists. - -gene_idx: The index of the gene in the solution. - -sample_size: The number of random values to generate from which a value is selected. It tries to generate a number of values up to a maximum of sample_size. But it is not always guaranteed because the total number of values might not be enough or the random generator creates duplicate random values. - It returns a single numeric value that satisfies the gene constraint, if one exists in the gene_constraint parameter. + Pick a replacement value for a single gene. If the user passed a + ``gene_constraint`` for that gene, the method draws up to + ``sample_size`` candidate values, filters them through the + constraint, and picks one of the survivors at random; if no + candidate passes, the current value is kept. + + When no constraint exists, a single value is sampled directly + from the gene space or the random-mutation range. + + Parameters + ---------- + solution : numpy.ndarray + The solution that owns the gene. + gene_idx : int + Index of the gene inside ``solution``. + range_min : float, optional + Lower bound of the random range. Used only when the gene + has no ``gene_space``. + range_max : float, optional + Upper bound of the random range. Used only when the gene + has no ``gene_space``. + sample_size : int + Maximum number of candidate values to draw when a gene + constraint is in effect. The actual number can be smaller + if the generator runs out of distinct values. + + Returns + ------- + value_selected : numeric + The new value for the gene. May be the old value if no + candidate satisfied the constraint. """ # Check if the gene has a constraint. @@ -156,12 +202,20 @@ def mutation_process_gene_value(self, return value_selected def mutation_randomly(self, offspring): - """ - Applies the random mutation. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Mutate ``self.mutation_num_genes`` genes per offspring by + drawing a new value from the random-mutation range for each + chosen gene. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # Random mutation changes one or more genes in each offspring randomly. @@ -193,12 +247,21 @@ def mutation_randomly(self, offspring): return offspring def mutation_probs_randomly(self, offspring): - """ - Applies the random mutation using the mutation probability. For each gene, if its probability is <= the mutation probability, then it will be mutated randomly. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Per-gene mutation that uses the random-mutation range and + ``self.mutation_probability`` to decide which genes to mutate. + A gene is mutated when a uniform random draw is less than or + equal to the probability threshold. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # Random mutation changes one or more genes in each offspring randomly. @@ -230,13 +293,88 @@ def mutation_probs_randomly(self, offspring): sample_size=self.sample_size) return offspring - def swap_mutation(self, offspring): + def polynomial_mutation(self, offspring): + """ + Apply polynomial mutation. Each gene is mutated with + probability ``self.mutation_probability`` (or with probability + ``1/num_genes`` when ``mutation_probability`` is not set). + + The size of the change is set by + ``self.polynomial_mutation_eta`` (a higher value means a + smaller change). The per-gene bounds come from + ``get_initial_population_range``. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (changed in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. + """ + eta = float(self.polynomial_mutation_eta) + per_gene_probability = (self.mutation_probability + if self.mutation_probability is not None + else 1.0 / self.num_genes) + eta_plus_one = eta + 1.0 + near_zero = 1e-14 + + for sol_idx in range(offspring.shape[0]): + for gene_idx in range(offspring.shape[1]): + if numpy.random.random() > per_gene_probability: + continue + + range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) + lower = float(range_min) + upper = float(range_max) + if upper - lower < near_zero: + continue + + gene_value = float(offspring[sol_idx, gene_idx]) + delta_lower = (gene_value - lower) / (upper - lower) + delta_upper = (upper - gene_value) / (upper - lower) + + rand_u = numpy.random.random() + if rand_u <= 0.5: + xy = 1.0 - delta_lower + val = 2.0 * rand_u + (1.0 - 2.0 * rand_u) * pow(xy, eta_plus_one) + delta_q = pow(val, 1.0 / eta_plus_one) - 1.0 + else: + xy = 1.0 - delta_upper + val = 2.0 * (1.0 - rand_u) + 2.0 * (rand_u - 0.5) * pow(xy, eta_plus_one) + delta_q = 1.0 - pow(val, 1.0 / eta_plus_one) + + new_value = gene_value + delta_q * (upper - lower) + new_value = numpy.clip(new_value, lower, upper) + offspring[sol_idx, gene_idx] = new_value + if self.allow_duplicate_genes == False: + offspring[sol_idx], _, _ = self.solve_duplicate_genes_randomly( + solution=offspring[sol_idx], + min_val=lower, + max_val=upper, + mutation_by_replacement=True, + gene_type=self.gene_type, + sample_size=self.sample_size) + return offspring + + def swap_mutation(self, offspring): """ - Applies the swap mutation which interchanges the values of 2 randomly selected genes. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Swap the values of two genes inside each offspring. One gene is + picked at random from the first half of the chromosome; the + other is its mirror in the second half. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ for idx in range(offspring.shape[0]): @@ -249,12 +387,19 @@ def swap_mutation(self, offspring): return offspring def inversion_mutation(self, offspring): - """ - Applies the inversion mutation which selects a subset of genes and inverts them (in order). - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Pick a slice of genes inside each offspring and reverse the + order of the values in that slice. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ for idx in range(offspring.shape[0]): @@ -266,12 +411,19 @@ def inversion_mutation(self, offspring): return offspring def scramble_mutation(self, offspring): - """ - Applies the scramble mutation which selects a subset of genes and shuffles their order randomly. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Pick a slice of genes inside each offspring and shuffle the + values in that slice into a new random order. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ for idx in range(offspring.shape[0]): @@ -285,13 +437,25 @@ def scramble_mutation(self, offspring): return offspring def adaptive_mutation_population_fitness(self, offspring): - """ - A helper method to calculate the average fitness of the solutions before applying the adaptive mutation. - It accepts: - -offspring: The offspring to mutate. - It returns the average fitness to be used in adaptive mutation. - """ + Compute the average fitness of the population built from the + kept parents (or elites) plus the current offspring. The + average is then used by the adaptive mutation operators to + decide which solutions are "low quality" and need a stronger + mutation rate. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions that will be mutated next. + + Returns + ------- + average_fitness : float or numpy.ndarray + Average fitness over the temporary population. For multi- + objective problems this is a 1D array with one entry per + objective. + """ fitness = self.last_generation_fitness.copy() temp_population = numpy.zeros_like(self.population) @@ -436,13 +600,22 @@ def adaptive_mutation_population_fitness(self, offspring): return average_fitness, fitness[len(parents_to_keep):] def adaptive_mutation(self, offspring): - """ - Applies the adaptive mutation which changes the values of a number of genes randomly. In adaptive mutation, the number of genes to mutate differs based on the fitness value of the solution. - The random value is selected either using the 'gene_space' parameter or the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Dispatch to one of the four adaptive-mutation backends based on + whether ``mutation_probability`` is set and whether + ``gene_space`` is provided. With adaptive mutation, the + per-solution mutation rate is high for below-average solutions + and low for above-average solutions. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # If the attribute 'gene_space' exists (i.e. not None), then the mutation values are selected from the 'gene_space' parameter according to the space of values of each gene. Otherwise, it is selected randomly based on the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. @@ -468,14 +641,22 @@ def adaptive_mutation(self, offspring): return offspring def adaptive_mutation_by_space(self, offspring): - """ - Applies the adaptive mutation based on the 2 parameters 'mutation_num_genes' and 'gene_space'. - A number of genes are selected randomly for mutation. This number depends on the fitness of the solution. - The random values are selected from the 'gene_space' parameter. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Adaptive mutation that uses ``mutation_num_genes`` and + ``gene_space``. The number of mutated genes per offspring is + the first element of ``mutation_num_genes`` for below-average + solutions and the second element for above-average ones. New + values come from the ``gene_space``. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # For each offspring, a value from the gene space is selected randomly and assigned to the selected gene for mutation. @@ -529,14 +710,23 @@ def adaptive_mutation_by_space(self, offspring): return offspring def adaptive_mutation_randomly(self, offspring): - """ - Applies the adaptive mutation based on the 'mutation_num_genes' parameter. - 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'. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Adaptive mutation that uses ``mutation_num_genes`` and the + random-mutation range. The number of mutated genes per + offspring is the first element of ``mutation_num_genes`` for + below-average solutions and the second element for + above-average ones. New values are sampled uniformly from the + random-mutation range. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ average_fitness, offspring_fitness = self.adaptive_mutation_population_fitness(offspring) @@ -593,14 +783,23 @@ def adaptive_mutation_randomly(self, offspring): return offspring def adaptive_mutation_probs_by_space(self, offspring): - """ - Applies the adaptive mutation based on the 2 parameters 'mutation_probability' and 'gene_space'. - Based on whether the solution fitness is above or below a threshold, the mutation is applied differently by mutating a high or low number of genes. - The random values are selected based on space of values for each gene. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Adaptive mutation that uses ``mutation_probability`` and + ``gene_space``. The probability threshold per offspring is the + first element of ``mutation_probability`` for below-average + solutions and the second element for above-average ones. Each + gene is replaced with a value from its ``gene_space`` when its + random draw falls below the chosen threshold. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ # For each offspring, a value from the gene space is selected randomly and assigned to the selected gene for mutation. @@ -656,14 +855,24 @@ def adaptive_mutation_probs_by_space(self, offspring): return offspring def adaptive_mutation_probs_randomly(self, offspring): - """ - Applies the adaptive mutation based on the 'mutation_probability' parameter. - Based on whether the solution fitness is above or below a threshold, the mutation is applied differently by mutating a high or low number of genes. - The random values are selected based on the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. - It accepts: - -offspring: The offspring to mutate. - It returns an array of the mutated offspring. + Adaptive mutation that uses ``mutation_probability`` and the + random-mutation range. The probability threshold per offspring + is the first element of ``mutation_probability`` for + below-average solutions and the second element for + above-average ones. Each gene is replaced with a value sampled + uniformly from the random-mutation range when its random draw + falls below the chosen threshold. + + Parameters + ---------- + offspring : numpy.ndarray + The offspring solutions to mutate (modified in place). + + Returns + ------- + offspring : numpy.ndarray + The mutated offspring. """ average_fitness, offspring_fitness = self.adaptive_mutation_population_fitness(offspring) diff --git a/pygad/utils/nsga.py b/pygad/utils/nsga.py new file mode 100644 index 0000000..a9e907c --- /dev/null +++ b/pygad/utils/nsga.py @@ -0,0 +1,127 @@ +""" +Shared building blocks for the NSGA family of selection methods. + +The methods here are used by both NSGA-II and NSGA-III. They live in +this module so neither algorithm has to depend on the other. +""" + +import numpy + + +class NSGA: + + def __init__(self): + pass + + def get_non_dominated_set(self, curr_solutions): + """ + Split the input solutions into a non-dominated set and a + dominated set. The non-dominated set is the next Pareto front. + + Parameters + ---------- + curr_solutions : list + A list of (index, fitness_vector) pairs to be partitioned. + ``index`` is the position of the solution in the original + population and ``fitness_vector`` is its objective values. + + Returns + ------- + dominated_set : list + The (index, fitness_vector) pairs that are dominated by at + least one other solution in ``curr_solutions``. + non_dominated_set : list + The (index, fitness_vector) pairs that no other solution in + ``curr_solutions`` dominates. These form the current Pareto + front. + """ + dominated_set = [] + non_dominated_set = [] + for idx1, sol1 in enumerate(curr_solutions): + is_not_dominated = True + for idx2, sol2 in enumerate(curr_solutions): + if idx1 == idx2: + continue + two_solutions = numpy.array(list(zip(sol1[1], sol2[1]))) + # PyGAD maximizes, so domination uses >= and >. + greater_or_equal = two_solutions[:, 1] >= two_solutions[:, 0] + strictly_greater = two_solutions[:, 1] > two_solutions[:, 0] + if greater_or_equal.all() and strictly_greater.any(): + is_not_dominated = False + dominated_set.append(sol1) + break + if is_not_dominated: + non_dominated_set.append(sol1) + return dominated_set, non_dominated_set + + def non_dominated_sorting(self, fitness): + """ + Sort the population into Pareto fronts using non-dominated + sorting. Front 0 contains the solutions no other solution + dominates; front 1 contains those that are only dominated by + front 0; and so on. + + Only works for multi-objective problems. + + Parameters + ---------- + fitness : numpy.ndarray + A 2D array of fitness values, one row per solution and one + column per objective. + + Returns + ------- + pareto_fronts : list + A list of Pareto fronts. Each front is a numpy array whose + rows are (population_index, fitness_vector) pairs. + solutions_fronts_indices : numpy.ndarray + A 1D integer array of length ``len(fitness)``. Entry ``i`` + is the index of the Pareto front the i-th solution belongs + to. + + Raises + ------ + TypeError + If the fitness rows are scalar (single-objective problem) + or of an unsupported type. + """ + if type(fitness[0]) in [list, tuple, numpy.ndarray]: + pass + elif type(fitness[0]) in self.supported_int_float_types: + raise TypeError( + "Non-dominated sorting is only applied when optimizing " + "multi-objective problems.\n\n" + "But a single-objective optimization problem found as the " + "fitness function returns a single numeric value.\n\n" + "To use multi-objective optimization, consider returning an " + "iterable of any of these data types:\n" + "1)list\n2)tuple\n3)numpy.ndarray") + else: + raise TypeError( + f"Non-dominated sorting is only applied when optimizing " + f"multi-objective problems. \n\nTo use multi-objective " + f"optimization, consider returning an iterable of any of " + f"these data types:\n1)list\n2)tuple\n3)numpy.ndarray\n\n" + f"But the data type {type(fitness[0])} found.") + + pareto_fronts = [] + + remaining_set = fitness.copy() + # Pair every solution with its population index so we can keep + # track of who is where as we peel off fronts. + remaining_set = list(zip(range(0, fitness.shape[0]), remaining_set)) + + solutions_fronts_indices = [-1] * len(remaining_set) + solutions_fronts_indices = numpy.array(solutions_fronts_indices) + + front_index = -1 + while len(remaining_set) > 0: + front_index += 1 + remaining_set, pareto_front = self.get_non_dominated_set( + curr_solutions=remaining_set) + pareto_front = numpy.array(pareto_front, dtype=object) + pareto_fronts.append(pareto_front) + solutions_indices = pareto_front[:, 0].astype(int) + solutions_fronts_indices[solutions_indices] = front_index + + return pareto_fronts, solutions_fronts_indices diff --git a/pygad/utils/nsga2.py b/pygad/utils/nsga2.py index ae74792..a724ba6 100644 --- a/pygad/utils/nsga2.py +++ b/pygad/utils/nsga2.py @@ -1,164 +1,57 @@ import numpy import pygad + class NSGA2: def __init__(self): pass - def get_non_dominated_set(self, curr_solutions): - """ - Get the set of non-dominated solutions from the current set of solutions. - - Parameters - ---------- - curr_solutions : TYPE - The set of solutions to find its non-dominated set. - - Returns - ------- - dominated_set : TYPE - A set of the dominated solutions. - non_dominated_set : TYPE - A set of the non-dominated solutions. - + def crowding_distance(self, pareto_front, fitness): """ - # List of the members of the current dominated pareto front/set. - dominated_set = [] - # List of the non-members of the current dominated pareto front/set. - # The non-dominated set is the pareto front set. - non_dominated_set = [] - for idx1, sol1 in enumerate(curr_solutions): - # Flag indicates whether the solution is a member of the current dominated set. - is_not_dominated = True - for idx2, sol2 in enumerate(curr_solutions): - if idx1 == idx2: - continue - - # Zipping the 2 solutions so the corresponding genes are in the same list. - # The returned array is of size (N, 2) where N is the number of genes. - two_solutions = numpy.array(list(zip(sol1[1], sol2[1]))) - - # Use < for minimization problems and > for maximization problems. - # Checking if any solution dominates the current solution by applying the 2 conditions. - # gr_eq (greater than or equal): All elements must be True. - # gr (greater than): Only 1 element must be True. - gr_eq = two_solutions[:, 1] >= two_solutions[:, 0] - gr = two_solutions[:, 1] > two_solutions[:, 0] - - # If the 2 conditions hold, then a solution (sol2) dominates the current solution (sol1). - # The current solution (sol1) is not considered a member of the non-dominated set. - if gr_eq.all() and gr.any(): - # Set the is_not_dominated flag to False because another solution dominates the current solution (sol1) - is_not_dominated = False - # DO NOT insert the current solution in the current non-dominated set. - # Instead, insert it into the dominated set. - dominated_set.append(sol1) - break - else: - # Reaching here means the solution does not dominate the current solution. - pass - - # If the flag is True, then no solution dominates the current solution. - # Insert the current solution (sol1) into the non-dominated set. - if is_not_dominated: - non_dominated_set.append(sol1) - - # Return the dominated and non-dominated sets. - return dominated_set, non_dominated_set + Calculate the crowding distance for every solution in the given + Pareto front. The crowding distance measures how isolated each + solution is from its neighbours along every objective. Boundary + solutions get a crowding distance of infinity. - def non_dominated_sorting(self, fitness): - """ - Apply non-dominated sorting over the fitness to create the pareto fronts based on the non-dominated sorting of the solutions. - Parameters ---------- - fitness : TYPE - An array of the population's fitness values across all objective functions. - - Returns - ------- - pareto_fronts : TYPE - An array of the pareto fronts. - - """ - - # Verify that the problem is multi-objective optimization as non-dominated sorting is only applied to multi-objective problems. - if type(fitness[0]) in [list, tuple, numpy.ndarray]: - pass - elif type(fitness[0]) in self.supported_int_float_types: - raise TypeError('Non-dominated sorting is only applied when optimizing multi-objective problems.\n\nBut a single-objective optimization problem found as the fitness function returns a single numeric value.\n\nTo use multi-objective optimization, consider returning an iterable of any of these data types:\n1)list\n2)tuple\n3)numpy.ndarray') - else: - raise TypeError(f'Non-dominated sorting is only applied when optimizing multi-objective problems. \n\nTo use multi-objective optimization, consider returning an iterable of any of these data types:\n1)list\n2)tuple\n3)numpy.ndarray\n\nBut the data type {type(fitness[0])} found.') - - # A list of all non-dominated sets. - pareto_fronts = [] - - # The remaining set to be explored for non-dominance. - # Initially it is set to the entire population. - # The solutions of each non-dominated set are removed after each iteration. - remaining_set = fitness.copy() - - # Zipping the solution index with the solution's fitness. - # This helps to easily identify the index of each solution. - # Each element has: - # 1) The index of the solution. - # 2) An array of the fitness values of this solution across all objectives. - remaining_set = list(zip(range(0, fitness.shape[0]), remaining_set)) - - # A list mapping the index of each pareto front to the set of solutions in this front. - solutions_fronts_indices = [-1]*len(remaining_set) - solutions_fronts_indices = numpy.array(solutions_fronts_indices) - - # Index of the current pareto front. - front_index = -1 - while len(remaining_set) > 0: - front_index += 1 + pareto_front : numpy.ndarray + The Pareto front returned by ``non_dominated_sorting``. Each + row is a (population_index, fitness_vector) pair. + fitness : numpy.ndarray + Fitness of the entire population. Used to compute the per- + objective range that normalizes the crowding distance. - # Get the current non-dominated set of solutions. - remaining_set, pareto_front = self.get_non_dominated_set(curr_solutions=remaining_set) - pareto_front = numpy.array(pareto_front, dtype=object) - pareto_fronts.append(pareto_front) - - solutions_indices = pareto_front[:, 0].astype(int) - solutions_fronts_indices[solutions_indices] = front_index - - return pareto_fronts, solutions_fronts_indices - - def crowding_distance(self, pareto_front, fitness): - """ - Calculate the crowding distance for all solutions in the current pareto front. - - Parameters - ---------- - pareto_front : TYPE - The set of solutions in the current pareto front. - fitness : TYPE - The fitness of the current population. - Returns ------- - obj_crowding_dist_list : TYPE - A nested list of the values for all objectives alongside their crowding distance. - crowding_dist_sum : TYPE - A list of the sum of crowding distances across all objectives for each solution. - crowding_dist_front_sorted_indices : TYPE - The indices of the solutions (relative to the current front) sorted by the crowding distance. - crowding_dist_pop_sorted_indices : TYPE - The indices of the solutions (relative to the population) sorted by the crowding distance. + obj_crowding_dist_list : numpy.ndarray + A nested array with the per-objective sorted lists used + internally. Each entry is ``[front_index, objective_value, + crowding_distance]``. + crowding_dist_sum : list + A list of ``[front_index, sum_of_crowding_distances]`` pairs + sorted by the sum in descending order (highest first). + crowding_dist_front_sorted_indices : numpy.ndarray + Indices of the solutions inside ``pareto_front`` sorted by + crowding distance (best first). + crowding_dist_pop_sorted_indices : numpy.ndarray + The same ordering but mapped back to the population indices, + so the caller can use them directly against + ``self.population``. """ - + # Each solution in the pareto front has 2 elements: # 1) The index of the solution in the population. # 2) A list of the fitness values for all objectives of the solution. # Before proceeding, remove the indices from each solution in the pareto front. pareto_front_no_indices = numpy.array([pareto_front[:, 1][idx] for idx in range(pareto_front.shape[0])]) - + # If there is only 1 solution, then return empty arrays for the crowding distance. if pareto_front_no_indices.shape[0] == 1: # There is only 1 index. return numpy.array([]), numpy.array([]), numpy.array([0]), pareto_front[:, 0].astype(int) - + # An empty list holding info about the objectives of each solution. The info includes the objective value and crowding distance. obj_crowding_dist_list = [] # Loop through the objectives to calculate the crowding distance of each solution across all objectives. @@ -173,7 +66,7 @@ def crowding_distance(self, pareto_front, fitness): # This variable is the sorted version where sorting is done by the objective value (second element). # Note that the first element is still the original objective index before sorting. obj_sorted = sorted(obj, key=lambda x: x[1]) - + # Get the minimum and maximum values for the current objective. obj_min_val = min(fitness[:, obj_idx]) obj_max_val = max(fitness[:, obj_idx]) @@ -181,42 +74,40 @@ def crowding_distance(self, pareto_front, fitness): # To avoid division by zero, set the denominator to a tiny value. if denominator == 0: denominator = 0.0000001 - + # Set the crowding distance to the first and last solutions (after being sorted) to infinity. inf_val = float('inf') - # crowding_distance[0] = inf_val obj_sorted[0][2] = inf_val - # crowding_distance[-1] = inf_val obj_sorted[-1][2] = inf_val - + # If there are only 2 solutions in the current pareto front, then do not proceed. # The crowding distance for such 2 solutions is infinity. if len(obj_sorted) <= 2: obj_crowding_dist_list.append(obj_sorted) break - + for idx in range(1, len(obj_sorted)-1): # Calculate the crowding distance. crowding_dist = obj_sorted[idx+1][1] - obj_sorted[idx-1][1] crowding_dist = crowding_dist / denominator # Insert the crowding distance back into the list to override the initial zero. obj_sorted[idx][2] = crowding_dist - + # Sort the objective by the original index at index 0 of each child list. obj_sorted = sorted(obj_sorted, key=lambda x: x[0]) obj_crowding_dist_list.append(obj_sorted) - + obj_crowding_dist_list = numpy.array(obj_crowding_dist_list) crowding_dist = numpy.array([obj_crowding_dist_list[idx, :, 2] for idx in range(len(obj_crowding_dist_list))]) crowding_dist_sum = numpy.sum(crowding_dist, axis=0) - - # An array of the sum of crowding distances across all objectives. + + # An array of the sum of crowding distances across all objectives. # Each row has 2 elements: # 1) The index of the solution. # 2) The sum of all crowding distances for all objectives of the solution. crowding_dist_sum = numpy.array(list(zip(obj_crowding_dist_list[0, :, 0], crowding_dist_sum))) crowding_dist_sum = sorted(crowding_dist_sum, key=lambda x: x[1], reverse=True) - + # The sorted solutions' indices by the crowding distance. crowding_dist_front_sorted_indices = numpy.array(crowding_dist_sum)[:, 0] crowding_dist_front_sorted_indices = crowding_dist_front_sorted_indices.astype(int) @@ -225,30 +116,43 @@ def crowding_distance(self, pareto_front, fitness): crowding_dist_pop_sorted_indices = pareto_front[:, 0] crowding_dist_pop_sorted_indices = crowding_dist_pop_sorted_indices[crowding_dist_front_sorted_indices] crowding_dist_pop_sorted_indices = crowding_dist_pop_sorted_indices.astype(int) - + return obj_crowding_dist_list, crowding_dist_sum, crowding_dist_front_sorted_indices, crowding_dist_pop_sorted_indices def sort_solutions_nsga2(self, fitness, find_best_solution=False): """ - Sort the solutions based on the fitness. - The sorting procedure differs based on whether the problem is single-objective or multi-objective optimization. - If it is multi-objective, then non-dominated sorting and crowding distance are applied. - At first, non-dominated sorting is applied to classify the solutions into pareto fronts. - Then the solutions inside each front are sorted using crowded distance. - The solutions inside pareto front X always come before those in front X+1. - + Sort the solutions by fitness and return their population + indices in best-to-worst order. + + For single-objective problems the sort is a plain descending + sort on the fitness value. For multi-objective problems the + sort uses non-dominated sorting and then crowding distance + inside each Pareto front; solutions in front X always come + before solutions in front X+1. + Parameters ---------- - fitness: The fitness of the entire population. - find_best_solution: Whether the method is called only to find the best solution or as part of the PyGAD lifecycle. This is to decide whether the pareto_fronts instance attribute is edited or not. + fitness : numpy.ndarray + Fitness of the entire population. + find_best_solution : bool + If True, the method is being called only to identify the + best solution and ``self.pareto_fronts`` is left untouched. + If False (the default), the method is being called as part + of the GA lifecycle and ``self.pareto_fronts`` is updated to + reflect the latest fronts. Returns ------- - solutions_sorted : TYPE - The indices of the sorted solutions. - + solutions_sorted : list + Population indices sorted from best to worst. + + Raises + ------ + TypeError + If a fitness row is neither a scalar nor a list / tuple / + numpy array. """ if type(fitness[0]) in [list, tuple, numpy.ndarray]: # Multi-objective optimization problem. diff --git a/pygad/utils/nsga3.py b/pygad/utils/nsga3.py new file mode 100644 index 0000000..546b93d --- /dev/null +++ b/pygad/utils/nsga3.py @@ -0,0 +1,402 @@ +""" +NSGA-III algorithm primitives. + +This module contains the math used by NSGA-III: reference point +generation, ideal point, extreme points, hyperplane intercepts, +normalization, association to reference lines, and niching. The +selection routines (``nsga3_selection`` and +``tournament_selection_nsga3``) live in ``parent_selection.py`` and the +engine-time bootstrap helpers live in ``engine.py``. +""" + +import numpy + + +# Weight used to amplify the off-axis terms in the ASF score when looking +# for the extreme point of each objective. A very small weight makes any +# deviation on a non-target axis huge so it dominates the score. +NSGA3_ASF_EPSILON = 1e-6 + +# Numbers smaller than this are treated as zero when we check for a +# singular linear system or a collapsed axis range. +NSGA3_INTERCEPT_NEAR_ZERO = 1e-12 + + +class NSGA3: + + def __init__(self): + pass + + def nsga3_generate_reference_points(self, num_objectives, num_divisions): + """ + Build the structured grid of reference points on the unit simplex + using the Das-Dennis (stars-and-bars) method. + + Each reference point has the form (a_1/p, a_2/p, ..., a_M/p) where + the a_i are non-negative integers that sum to num_divisions. The + total number of points is C(M + p - 1, p). + + Parameters + ---------- + num_objectives : int + The number of objectives, M. + num_divisions : int + The number of divisions per axis, p. + + Returns + ------- + reference_points : numpy.ndarray + A 2D array of shape (n_points, num_objectives). Each row is one + reference point and its values sum to 1.0. + """ + compositions = list(_nsga3_enumerate_compositions(num_objectives, num_divisions)) + as_array = numpy.array(compositions, dtype=float) + return as_array / num_divisions + + def nsga3_compute_ideal_point(self, fitness): + """ + Return the ideal point: the best fitness value for each objective + across the input fitness rows. PyGAD maximizes, so the best value + per objective is the column maximum. + + Parameters + ---------- + fitness : numpy.ndarray + A 2D array of fitness values, one row per solution. + + Returns + ------- + ideal_point : numpy.ndarray + A 1D array of length M with the column maximum of fitness. + """ + return numpy.asarray(fitness).max(axis=0) + + def nsga3_find_extreme_points(self, fitness, ideal_point, + epsilon=NSGA3_ASF_EPSILON): + """ + For each objective axis, find the solution that best represents the + corner of that axis. This is done by running the Achievement + Scalarizing Function (ASF) once per axis with a weight vector that + puts weight 1.0 on the target axis and a tiny weight (epsilon) on + every other axis. The solution with the smallest ASF score wins. + + Parameters + ---------- + fitness : numpy.ndarray + A 2D array of fitness values, one row per solution. + ideal_point : numpy.ndarray + The ideal point. + epsilon : float + The small weight used for off-axis objectives. + + Returns + ------- + extreme_points : numpy.ndarray + A 2D array of shape (M, M). Row i is the fitness vector of the + solution selected as the extreme for objective i. + """ + fitness = numpy.asarray(fitness, dtype=float) + num_objectives = ideal_point.shape[0] + # Shortfall from the ideal point on each objective. Always >= 0 + # because the ideal is the column max. + shortfall = ideal_point - fitness + extremes = numpy.empty((num_objectives, num_objectives), dtype=float) + for axis in range(num_objectives): + # Weight is 1 on the target axis, tiny on every other axis. + weights = numpy.full(num_objectives, epsilon) + weights[axis] = 1.0 + asf_per_solution = (shortfall / weights).max(axis=1) + # The lowest ASF wins. argmin returns the first occurrence so + # ties go to the lower index. + extremes[axis] = fitness[numpy.argmin(asf_per_solution)] + return extremes + + def nsga3_compute_intercepts(self, extreme_points, ideal_point, + fallback_fitness): + """ + Fit a hyperplane through the M extreme points and return the + intercept point on each axis. The result is the point we use to + scale every objective to the [0, 1] range during normalization. + + The NSGA-III paper defines the intercept as the point that + normalizes to value 1 on its own axis (i.e. each extreme row lands + on a simplex corner after normalization). The math is: + + (extreme_points - ideal_point) @ b = 1 + intercepts = ideal_point + 1 / b + + When the linear system cannot be solved, when any coefficient is + too close to zero, or when the resulting intercept ends up on the + wrong side of the ideal point, fall back to the worst observed + value per objective (the column minimum under maximization). + + Two extra safety steps run after the linear solve: + 1. If an intercept value extrapolates past the worst observed + value for that objective, clip it back to the worst value. + 2. If the gap between an intercept and the ideal point shrinks + below NSGA3_INTERCEPT_NEAR_ZERO after clipping, replace that + intercept with the worst observed value so the normalization + denominator stays non-zero. + + Parameters + ---------- + extreme_points : numpy.ndarray + The M extreme points returned by nsga3_find_extreme_points. + ideal_point : numpy.ndarray + The ideal point. + fallback_fitness : numpy.ndarray + The fitness pool used to compute the fallback nadir. Usually + the same fitness array used to find the extreme points. + + Returns + ------- + intercepts : numpy.ndarray + A 1D array of length M with the per-axis intercept values. + """ + ideal_point = numpy.asarray(ideal_point, dtype=float) + extreme_points = numpy.asarray(extreme_points, dtype=float) + fallback_fitness = numpy.asarray(fallback_fitness, dtype=float) + # Worst per objective under maximization is the column minimum. + worst_per_objective = fallback_fitness.min(axis=0) + translated = extreme_points - ideal_point + try: + coefficients = numpy.linalg.solve(translated, + numpy.ones(ideal_point.shape[0])) + except numpy.linalg.LinAlgError: + return worst_per_objective + # A near-zero coefficient means 1/b is huge and the intercept is + # essentially undefined on that axis. + if numpy.any(numpy.abs(coefficients) < NSGA3_INTERCEPT_NEAR_ZERO): + return worst_per_objective + intercepts = ideal_point + 1.0 / coefficients + # Under maximization a valid intercept sits strictly below the + # ideal. If it does not, the normalization denominator would flip + # sign and produce nonsense values. + if numpy.any(intercepts >= ideal_point - NSGA3_INTERCEPT_NEAR_ZERO): + return worst_per_objective + # Cap the intercept at the worst observed value so we never + # extrapolate the hyperplane past the real data range. + overshoot = intercepts < worst_per_objective + intercepts = numpy.where(overshoot, worst_per_objective, intercepts) + # If capping leaves the gap |intercept - ideal| too small, reset + # that axis to the worst observed value. + collapsed = numpy.abs(intercepts - ideal_point) < NSGA3_INTERCEPT_NEAR_ZERO + intercepts = numpy.where(collapsed, worst_per_objective, intercepts) + return intercepts + + def nsga3_normalize_fitness(self, fitness, ideal_point, intercepts): + """ + Scale each fitness row to the [0, 1] range using the ideal point + and the intercepts. + + For every objective i the formula is: + + f_hat_i = (f_i - ideal_i) / (intercepts_i - ideal_i) + + Values outside [0, 1] are clipped. This happens for dominated + solutions or after a fallback intercept. + + Parameters + ---------- + fitness : numpy.ndarray + The fitness array to normalize. + ideal_point : numpy.ndarray + The ideal point. + intercepts : numpy.ndarray + The intercept point returned by nsga3_compute_intercepts. + + Returns + ------- + normalized : numpy.ndarray + Fitness scaled to the unit hypercube, same shape as the input. + """ + fitness = numpy.asarray(fitness, dtype=float) + ideal_point = numpy.asarray(ideal_point, dtype=float) + intercepts = numpy.asarray(intercepts, dtype=float) + denominator = intercepts - ideal_point + # Under PyGAD-max, intercepts sit below ideal so denominator is + # negative. Replace near-zero entries with a tiny negative value + # to keep the sign correct and avoid divide-by-zero. + safe_denominator = numpy.where(denominator > -NSGA3_INTERCEPT_NEAR_ZERO, + -NSGA3_INTERCEPT_NEAR_ZERO, + denominator) + raw = (fitness - ideal_point) / safe_denominator + return numpy.clip(raw, 0.0, 1.0) + + def nsga3_associate_to_reference_points(self, normalized, reference_points): + """ + For every normalized solution, find the reference line it is + closest to and the perpendicular distance to that line. + + The reference line for reference point z is the ray from the + origin through z. The perpendicular distance from a point x to + that line is: + + d(x, z) = || x - (x . z_hat) * z_hat || + + where z_hat = z / || z ||. + + Ties on the minimum distance go to the lower reference index + because numpy.argmin returns the first occurrence. + + Parameters + ---------- + normalized : numpy.ndarray + Normalized fitness, one row per solution. + reference_points : numpy.ndarray + The structured reference grid, one row per point. + + Returns + ------- + nearest : numpy.ndarray + A 1D array of length n_solutions. Each entry is the index of + the nearest reference point for that solution. + nearest_distance : numpy.ndarray + A 1D array of length n_solutions with the perpendicular + distance to the nearest reference line. + """ + normalized = numpy.asarray(normalized, dtype=float) + reference_points = numpy.asarray(reference_points, dtype=float) + # Turn every reference point into a unit direction vector once. + unit_directions = reference_points / numpy.linalg.norm(reference_points, + axis=1, + keepdims=True) + # Dot products of every solution with every reference direction. + # Shape: (n_solutions, n_references). + dot_products = normalized @ unit_directions.T + # Project each solution onto each reference line. + # Shape: (n_solutions, n_references, n_objectives). + projections = dot_products[:, :, None] * unit_directions[None, :, :] + # Perpendicular component is what is left after subtracting the + # projection from the original solution. + perpendicular = normalized[:, None, :] - projections + distances = numpy.linalg.norm(perpendicular, axis=2) + nearest = numpy.argmin(distances, axis=1) + nearest_distance = distances[numpy.arange(len(normalized)), nearest] + return nearest, nearest_distance + + def nsga3_niching_select(self, + critical_front_indices, + critical_front_associations, + critical_front_distances, + accepted_associations, + num_reference_points, + num_to_select): + """ + Pick ``num_to_select`` survivors from the critical front using + the niching rules. The result preserves diversity across + reference points. + + The niche count of a reference point j is the number of already + accepted solutions associated with j. The procedure repeats + ``num_to_select`` times: + 1. Pick the reference point with the smallest niche count that + still has at least one critical-front candidate attached. + 2. If that reference point has niche count zero, pick the + critical-front candidate closest to its reference line. + 3. If the niche count is positive, pick one of its + critical-front candidates at random. + 4. Add the selected candidate to the survivor list, increase + the niche count by 1, and remove the candidate from the + critical front. + + Ties on minimum niche count go to the lower reference index. + + Parameters + ---------- + critical_front_indices : list[int] + Population indices of the candidates in the critical front. + critical_front_associations : numpy.ndarray + Reference index each critical-front candidate is associated + with. + critical_front_distances : numpy.ndarray + Perpendicular distance from each critical-front candidate to + its reference line. + accepted_associations : numpy.ndarray + Reference index each already-accepted solution is associated + with. Used to seed the niche counts. + num_reference_points : int + Total number of reference points. + num_to_select : int + Number of survivors to pick from the critical front. + + Returns + ------- + picked : list[int] + Population indices of the selected survivors, in selection + order. Length is at most ``num_to_select``. + """ + niche_counts = numpy.zeros(num_reference_points, dtype=int) + for reference_index in accepted_associations: + niche_counts[reference_index] += 1 + remaining_positions = list(range(len(critical_front_indices))) + picked = [] + while len(picked) < num_to_select and remaining_positions: + target_reference_index = _nsga3_pick_target_reference_point( + niche_counts, critical_front_associations, remaining_positions) + if target_reference_index is None: + break + candidates_at_target = [ + position for position in remaining_positions + if critical_front_associations[position] == target_reference_index + ] + chosen_position = _nsga3_pick_candidate_at_reference( + candidates_at_target, + critical_front_distances, + niche_counts[target_reference_index], + ) + picked.append(critical_front_indices[chosen_position]) + niche_counts[target_reference_index] += 1 + remaining_positions.remove(chosen_position) + return picked + + +def _nsga3_pick_target_reference_point(niche_counts, + critical_front_associations, + remaining_positions): + """ + Among the reference points that still have at least one critical- + front candidate attached, pick the one with the smallest niche + count. Break ties by the lower reference index. + """ + candidate_references = { + int(critical_front_associations[position]) + for position in remaining_positions + } + if not candidate_references: + return None + min_niche_count = min(niche_counts[reference] + for reference in candidate_references) + return min(reference for reference in candidate_references + if niche_counts[reference] == min_niche_count) + + +def _nsga3_pick_candidate_at_reference(candidates_at_target, + critical_front_distances, + niche_count_at_target): + """ + Choose one critical-front candidate at the given reference point. If + the niche count is 0 (empty niche), pick the closest candidate. + Otherwise pick a candidate at random. + """ + if niche_count_at_target == 0: + return min(candidates_at_target, + key=lambda position: critical_front_distances[position]) + return candidates_at_target[ + numpy.random.randint(len(candidates_at_target)) + ] + + +def _nsga3_enumerate_compositions(num_objectives, num_divisions): + """ + Yield every non-negative integer tuple of length num_objectives that + sums to num_divisions. Used by nsga3_generate_reference_points to + build the Das-Dennis grid. + """ + if num_objectives == 1: + yield [num_divisions] + return + for first in range(num_divisions + 1): + for rest in _nsga3_enumerate_compositions(num_objectives - 1, + num_divisions - first): + yield [first] + rest diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 5922369..31eebe0 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -10,18 +10,25 @@ def __init__(): pass def steady_state_selection(self, fitness, num_parents): - """ - Selects the parents using the steady-state selection technique. - This works by sorting the solutions based on the fitness and selecting the best ones as parents. - Later, these parents will mate to produce the offspring. - - It accepts 2 parameters: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the steady-state selection technique. The + solutions are sorted by fitness and the top ``num_parents`` are + chosen. Works for both single-objective and multi-objective + problems because the sort is delegated to ``sort_solutions_nsga2``. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. """ # Return the indices of the sorted solutions (all solutions in the population). @@ -36,16 +43,25 @@ def steady_state_selection(self, fitness, num_parents): return parents, parents_indices def rank_selection(self, fitness, num_parents): - """ - Selects the parents using the rank selection technique. Later, these parents will mate to produce the offspring. - Rank selection gives a rank from 1 to N (number of solutions) to each solution based on its fitness. - It accepts 2 parameters: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the rank selection technique. Solutions + are first sorted by fitness; rank 1 is given to the worst and + rank N to the best. The chance of being picked is proportional to + the rank. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. """ # Return the indices of the sorted solutions (all solutions in the population). @@ -57,7 +73,7 @@ def rank_selection(self, fitness, num_parents): probs = rank / numpy.sum(rank) - probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), + probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), num_parents=num_parents) parents_indices = [] @@ -76,15 +92,23 @@ def rank_selection(self, fitness, num_parents): return parents, numpy.array(parents_indices) def random_selection(self, fitness, num_parents): - """ - Selects the parents randomly. Later, these parents will mate to produce the offspring. - It accepts 2 parameters: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents at random from the current population. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + Not used by this method but kept for a uniform interface. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. """ parents = self.initialize_parents_array((num_parents, self.population.shape[1])) @@ -94,15 +118,24 @@ def random_selection(self, fitness, num_parents): return parents, rand_indices def tournament_selection(self, fitness, num_parents): - """ - Selects the parents using the tournament selection technique. Later, these parents will mate to produce the offspring. - It accepts: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the tournament selection technique. For + each parent slot, ``self.K_tournament`` candidates are picked at + random; the one with the best fitness rank wins. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. """ # Return the indices of the sorted solutions (all solutions in the population). @@ -132,21 +165,38 @@ def tournament_selection(self, fitness, num_parents): return parents, parents_indices def roulette_wheel_selection(self, fitness, num_parents): - """ - Selects the parents using the roulette wheel selection technique. Later, these parents will mate to produce the offspring. - It accepts 2 parameters: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the roulette wheel selection technique. + Each solution gets a slice of the wheel proportional to its + fitness. A random pointer is drawn for every parent slot. + + For multi-objective problems, the fitness rows are summed across + objectives so the wheel works on a single scalar per solution. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + + Raises + ------ + ZeroDivisionError + If the sum of fitness values is zero. """ ## Make edits to work with multi-objective optimization. ## The objective is to convert the fitness from M-D array to just 1D array. ## There are 2 ways: - # 1) By summing the fitness values of each solution. + # 1) By summing the fitness values of each solution. # 2) By using only 1 objective to create the roulette wheel and excluding the others. # Take the sum of the fitness values of each solution. @@ -166,7 +216,7 @@ def roulette_wheel_selection(self, fitness, num_parents): probs = fitness / fitness_sum - probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), + probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), num_parents=num_parents) parents_indices = [] @@ -185,14 +235,28 @@ def roulette_wheel_selection(self, fitness, num_parents): def wheel_cumulative_probs(self, probs, num_parents): """ - A helper function to calculate the wheel probabilities for these 2 methods: - 1) roulette_wheel_selection - 2) rank_selection - It accepts a single 1D array representing the probabilities of selecting each solution. - It returns 2 1D arrays: - 1) probs_start has the start of each range. - 2) probs_end has the end of each range. - It also returns an empty array for the parents. + Build the cumulative probability ranges used by the roulette + wheel and rank selection methods. Each solution gets a + ``[start, end)`` interval whose width is its selection + probability. + + Parameters + ---------- + probs : numpy.ndarray + A 1D array of selection probabilities, one per solution. + num_parents : int + Number of parents to pick later. Only used to allocate the + empty parents array returned to the caller. + + Returns + ------- + probs_start : numpy.ndarray + Start of each cumulative range, indexed by solution. + probs_end : numpy.ndarray + End of each cumulative range, indexed by solution. + parents : numpy.ndarray + An empty parents array with the right shape and dtype, + ready to be filled by the caller. """ probs_start = numpy.zeros(probs.shape, dtype=float) # An array holding the start values of the ranges of probabilities. @@ -209,21 +273,39 @@ def wheel_cumulative_probs(self, probs, num_parents): return probs_start, probs_end, parents def stochastic_universal_selection(self, fitness, num_parents): - """ - Selects the parents using the stochastic universal selection technique. Later, these parents will mate to produce the offspring. - It accepts 2 parameters: - -fitness: The fitness values of the solutions in the current population. - -num_parents: The number of parents to be selected. - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the stochastic universal selection + technique. Like roulette wheel but uses a set of evenly spaced + pointers drawn from a single random offset, giving a more + balanced sampling than independent random pointers. + + For multi-objective problems, the fitness rows are summed across + objectives so the wheel works on a single scalar per solution. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + + Raises + ------ + ZeroDivisionError + If the sum of fitness values is zero. """ ## Make edits to work with multi-objective optimization. ## The objective is to convert the fitness from M-D array to just 1D array. ## There are 2 ways: - # 1) By summing the fitness values of each solution. + # 1) By summing the fitness values of each solution. # 2) By using only 1 objective to create the roulette wheel and excluding the others. # Take the sum of the fitness values of each solution. @@ -243,12 +325,12 @@ def stochastic_universal_selection(self, fitness, num_parents): probs = fitness / fitness_sum - probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), + probs_start, probs_end, parents = self.wheel_cumulative_probs(probs=probs.copy(), num_parents=num_parents) pointers_distance = 1.0 / self.num_parents_mating # Distance between different pointers. - first_pointer = numpy.random.uniform(low=0.0, - high=pointers_distance, + first_pointer = numpy.random.uniform(low=0.0, + high=pointers_distance, size=1)[0] # Location of the first pointer. # Selecting the best individuals in the current generation as parents for producing the offspring of the next generation. @@ -271,27 +353,42 @@ def stochastic_universal_selection(self, fitness, num_parents): def tournament_selection_nsga2(self, fitness, num_parents): - """ - Select the parents using the tournament selection technique for NSGA-II. - The traditional tournament selection uses the fitness values. But the tournament selection for NSGA-II uses non-dominated sorting and crowding distance. - Using non-dominated sorting, the solutions are distributed across pareto fronts. The fronts are given the indices 0, 1, 2, ..., N where N is the number of pareto fronts. The lower the index of the pareto front, the better its solutions. - To select the parents solutions, 2 solutions are selected randomly. If the 2 solutions are in different pareto fronts, then the solution coming from a pareto front with lower index is selected. - If 2 solutions are in the same pareto front, then crowding distance is calculated. The solution with the higher crowding distance is selected. - If the 2 solutions are in the same pareto front and have the same crowding distance, then a solution is randomly selected. - Later, the selected parents will mate to produce the offspring. - - It accepts 2 parameters: - -fitness: The fitness values for the current population. - -num_parents: The number of parents to be selected. - -pareto_fronts: A nested array of all the pareto fronts. Each front has its solutions. - -solutions_fronts_indices: A list of the pareto front index of each solution in the current population. - - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the tournament selection variant for + NSGA-II. For each parent slot, ``self.K_tournament`` candidates + are picked at random. The winner is decided as follows: + + 1. If the candidates lie in different Pareto fronts, the one in + the front with the lowest index wins. + 2. If two or more share the same best front, the one with the + higher crowding distance wins. + 3. If they also share the same crowding distance, the winner is + picked at random. + + Only works for multi-objective problems. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + Each row must be an iterable of objective values. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + + Raises + ------ + ValueError + If the fitness function returned scalar values (the problem + is single-objective). """ - + if self.gene_type_single == True: parents = numpy.empty((num_parents, self.population.shape[1]), dtype=self.gene_type[0]) else: @@ -307,13 +404,13 @@ def tournament_selection_nsga2(self, parents_indices = [] # If there is only a single objective, each pareto front is expected to have only 1 solution. - # TODO Make a test to check for that behaviour and add it to the GitHub actions tests. + # TODO Make a test to check for that behavior and add it to the GitHub actions tests. pareto_fronts, solutions_fronts_indices = self.non_dominated_sorting(fitness) self.pareto_fronts = pareto_fronts.copy() # Randomly generate pairs of indices to apply for NSGA-II tournament selection for selecting the parents solutions. rand_indices = numpy.random.randint(low=0, - high=len(solutions_fronts_indices), + high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) for parent_num in range(num_parents): @@ -405,31 +502,41 @@ def tournament_selection_nsga2(self, # Make sure the parents indices is returned as a NumPy array. return parents, numpy.array(parents_indices) - + def nsga2_selection(self, fitness, num_parents ): - """ - Select the parents using the Non-Dominated Sorting Genetic Algorithm II (NSGA-II). - The selection is done using non-dominated sorting and crowding distance. - Using non-dominated sorting, the solutions are distributed across pareto fronts. The fronts are given the indices 0, 1, 2, ..., N where N is the number of pareto fronts. The lower the index of the pareto front, the better its solutions. - The parents are selected from the lower pareto fronts and moving up until selecting the number of desired parents. - A solution from a pareto front X cannot be taken as a parent until all solutions in pareto front Y is selected given that Y < X. - For a pareto front X, if only a subset of its solutions is needed, then the corwding distance is used to determine which solutions to be selected from the front. The solution with the higher crowding distance is selected. - If the 2 solutions are in the same pareto front and have the same crowding distance, then a solution is randomly selected. - Later, the selected parents will mate to produce the offspring. - - It accepts 2 parameters: - -fitness: The fitness values for the current population. - -num_parents: The number of parents to be selected. - -pareto_fronts: A nested array of all the pareto fronts. Each front has its solutions. - -solutions_fronts_indices: A list of the pareto front index of each solution in the current population. - - It returns: - -An array of the selected parents. - -The indices of the selected solutions. + Select the parents using the Non-Dominated Sorting Genetic + Algorithm II (NSGA-II). The population is sorted into Pareto + fronts; whole fronts are taken in order until the next one would + overflow the requested parent count. The remaining slots are + filled from that critical front by crowding distance (higher + crowding distance wins; random pick on ties). + + Only works for multi-objective problems. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values of the solutions in the current population. + Each row must be an iterable of objective values. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + + Raises + ------ + ValueError + If the fitness function returned scalar values (the problem + is single-objective). """ if self.gene_type_single == True: @@ -447,7 +554,7 @@ def nsga2_selection(self, parents_indices = [] # If there is only a single objective, each pareto front is expected to have only 1 solution. - # TODO Make a test to check for that behaviour. + # TODO Make a test to check for that behavior. pareto_fronts, solutions_fronts_indices = self.non_dominated_sorting(fitness) self.pareto_fronts = pareto_fronts.copy() @@ -477,7 +584,7 @@ def nsga2_selection(self, num_remaining_parents -= len(current_pareto_front) else: # If only a subset of the front is needed, then use the crowding distance to sort the solutions and select only the number needed. - + # Calculate the crowding distance of the solutions of the pareto front. obj_crowding_distance_list, crowding_distance_sum, crowding_dist_front_sorted_indices, crowding_dist_pop_sorted_indices = self.crowding_distance(pareto_front=current_pareto_front.copy(), fitness=fitness) @@ -489,12 +596,252 @@ def nsga2_selection(self, parents_indices.append(selected_solution_idx) # Increase the parent index. current_parent_idx += 1 - + # Decrement the number of remaining parents by the number of selected parents. num_remaining_parents -= num_remaining_parents - + # Increase the pareto front index to take parents from the next front. pareto_front_idx += 1 - + # Make sure the parents indices is returned as a NumPy array. return parents, numpy.array(parents_indices) + + def nsga3_selection(self, fitness, num_parents): + """ + Select ``num_parents`` parents from the current population using + NSGA-III. Solutions are first sorted into Pareto fronts. Whole + fronts are accepted in order until the next front would overflow + the requested parent count; that front becomes the critical + front. Survivors from the critical front are picked by niching + against the structured reference points stored on the GA + instance. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values for the entire population. Must be + multi-objective (each row is a vector of M values). + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions copied from ``self.population``. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + """ + _nsga3_validate_multi_objective_fitness( + fitness, self.supported_int_float_types, 'nsga3_selection') + pareto_fronts, _ = self.non_dominated_sorting(fitness) + self.pareto_fronts = pareto_fronts.copy() + + accepted_indices, critical_front_indices = _nsga3_accumulate_fronts( + pareto_fronts, num_parents) + if critical_front_indices: + picked = self._nsga3_pick_critical_front_survivors( + accepted_indices, + critical_front_indices, + fitness, + num_parents - len(accepted_indices)) + final_indices = accepted_indices + picked + else: + # The accepted fronts already fit exactly; no niching needed. + final_indices = accepted_indices + + return self._nsga3_build_parents(final_indices, num_parents) + + def _nsga3_pick_critical_front_survivors(self, + accepted_indices, + critical_front_indices, + fitness, + num_to_select): + """ + Run the NSGA-III normalization and niching steps on the + candidate pool described below, then ask + ``nsga3_niching_select`` for ``num_to_select`` survivors from + the critical front. + + The candidate pool is the union of two sets: + 1. The already-accepted solutions (``accepted_indices``). + These are the solutions taken from earlier, fully-fitting + Pareto fronts. + 2. The critical-front candidates (``critical_front_indices``). + These are every solution in the first Pareto front that + would not fit entirely into the parent quota. + + Working on the union (and not just the survivors) is what the + NSGA-III paper requires: the ideal point, extreme points, + intercepts and normalized values must be computed on this + combined pool so the geometry stays stable as the niching loop + accepts or rejects critical-front members. + """ + selection_pool_indices = accepted_indices + critical_front_indices + selection_pool_fitness = numpy.array( + [fitness[i] for i in selection_pool_indices], dtype=float) + ideal_point = self.nsga3_compute_ideal_point(selection_pool_fitness) + extremes = self.nsga3_find_extreme_points(selection_pool_fitness, + ideal_point) + intercepts = self.nsga3_compute_intercepts(extremes, + ideal_point, + selection_pool_fitness) + normalized = self.nsga3_normalize_fitness(selection_pool_fitness, + ideal_point, + intercepts) + associations, distances = self.nsga3_associate_to_reference_points( + normalized, self.nsga3_reference_points) + # The first len(accepted_indices) rows of the pool belong to the + # accepted set; the rest are the critical-front candidates. + split = len(accepted_indices) + return self.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=associations[split:], + critical_front_distances=distances[split:], + accepted_associations=associations[:split], + num_reference_points=len(self.nsga3_reference_points), + num_to_select=num_to_select, + ) + + def _nsga3_build_parents(self, final_indices, num_parents): + """ + Copy the chosen solutions out of ``self.population`` into a new + parents array of the right dtype, and return it together with + the index array. + """ + parents = self.initialize_parents_array( + (num_parents, self.population.shape[1])) + for slot, idx in enumerate(final_indices): + parents[slot, :] = self.population[idx, :].copy() + return parents, numpy.array(final_indices) + + def tournament_selection_nsga3(self, fitness, num_parents): + """ + Select ``num_parents`` parents using K-tournament where the + within-front comparison is based on NSGA-III niching. + + The full population is sorted into Pareto fronts and normalized + once at the start. For each parent slot: + 1. Pick ``self.K_tournament`` solutions at random. + 2. Keep only the ones in the best (lowest) Pareto front. + 3. If more than one is left, the winner is the solution whose + reference point has the smallest niche count. Ties on niche + count go to the smaller perpendicular distance. + + Parameters + ---------- + fitness : numpy.ndarray + Fitness values for the entire population. Must be + multi-objective. + num_parents : int + Number of parents to select. + + Returns + ------- + parents : numpy.ndarray + Selected parent solutions. + parents_indices : numpy.ndarray + Indices of the selected parents inside ``self.population``. + """ + _nsga3_validate_multi_objective_fitness( + fitness, self.supported_int_float_types, + 'tournament_selection_nsga3') + pareto_fronts, solutions_fronts_indices = self.non_dominated_sorting(fitness) + self.pareto_fronts = pareto_fronts.copy() + + # Convert the fitness rows to a clean 2D float array. + fitness_matrix = numpy.array([list(row) for row in fitness], dtype=float) + ideal_point = self.nsga3_compute_ideal_point(fitness_matrix) + extremes = self.nsga3_find_extreme_points(fitness_matrix, ideal_point) + intercepts = self.nsga3_compute_intercepts(extremes, + ideal_point, + fitness_matrix) + normalized = self.nsga3_normalize_fitness(fitness_matrix, + ideal_point, + intercepts) + associations, distances = self.nsga3_associate_to_reference_points( + normalized, self.nsga3_reference_points) + # Niche count is the number of population solutions attached to + # each reference point. + niche_counts = numpy.bincount(associations, + minlength=len(self.nsga3_reference_points)) + + rand_indices = numpy.random.randint(low=0, + high=len(solutions_fronts_indices), + size=(num_parents, self.K_tournament)) + parents_indices = [self._nsga3_pick_tournament_winner(rand_indices[slot], + solutions_fronts_indices, + associations, + distances, + niche_counts) + for slot in range(num_parents)] + return self._nsga3_build_parents(parents_indices, num_parents) + + def _nsga3_pick_tournament_winner(self, + competitor_indices, + fronts_indices, + associations, + distances, + niche_counts): + """ + Pick the best solution among the K-tournament competitors. The + best front index wins first; ties are broken by lower niche + count, then by smaller perpendicular distance. + """ + best_front = fronts_indices[competitor_indices].min() + finalists = competitor_indices[ + fronts_indices[competitor_indices] == best_front] + if len(finalists) == 1: + return int(finalists[0]) + finalist_niche_counts = niche_counts[associations[finalists]] + finalist_distances = distances[finalists] + # lexsort sorts by the last key first, so this orders by niche + # count first and breaks ties by distance. + ordering = numpy.lexsort((finalist_distances, finalist_niche_counts)) + return int(finalists[ordering[0]]) + + +def _nsga3_validate_multi_objective_fitness(fitness, + supported_int_float_types, + method_name): + """ + Raise an error if the first fitness value is a scalar (which means + the problem is single-objective and NSGA-III cannot be applied) or + if it is some other unsupported type. + """ + if type(fitness[0]) in supported_int_float_types: + raise TypeError( + f"{method_name} requires a multi-objective fitness function " + f"(an iterable per solution), but the first fitness value " + f"({fitness[0]!r}) has scalar type {type(fitness[0]).__name__}." + ) + if type(fitness[0]) not in (list, tuple, numpy.ndarray): + raise TypeError( + f"{method_name} expects each fitness value to be a list, tuple, " + f"or numpy.ndarray, but the first fitness value has type " + f"{type(fitness[0]).__name__}." + ) + + +def _nsga3_accumulate_fronts(pareto_fronts, num_parents): + """ + Walk the Pareto fronts in order and add each whole front to the + accepted list while the running total stays at or below + ``num_parents``. The first front that would overflow becomes the + critical front. + + Returns a pair (accepted_indices, critical_front_indices). When the + accepted set fits exactly into ``num_parents``, + ``critical_front_indices`` is empty. + """ + accepted_indices = [] + critical_front_indices = [] + for front in pareto_fronts: + front_solution_indices = front[:, 0].astype(int).tolist() + if len(accepted_indices) + len(front_solution_indices) <= num_parents: + accepted_indices.extend(front_solution_indices) + if len(accepted_indices) == num_parents: + break + else: + critical_front_indices = front_solution_indices + break + return accepted_indices, critical_front_indices diff --git a/pygad/utils/quality_indicators.py b/pygad/utils/quality_indicators.py new file mode 100644 index 0000000..6a79b95 --- /dev/null +++ b/pygad/utils/quality_indicators.py @@ -0,0 +1,207 @@ +""" +Quality indicators for multi-objective optimization. + +Four indicators to measure the quality of a Pareto front built by +PyGAD: + +1. hypervolume: volume of the objective space dominated by the front. +2. inverted_generational_distance: mean distance from each reference + point to its nearest approximation point. +3. generational_distance: mean distance from each approximation + point to its nearest reference point. +4. spacing: how evenly the approximation points are spread. + +All functions take fitness values in PyGAD's maximization format +(higher is better). The reference point for hypervolume must be +worse than every solution on every axis. +""" + +import numpy + + +def _to_min_fitness(fitness): + """Negate the fitness to switch to minimisation.""" + return -numpy.asarray(fitness, dtype=float) + + +def _drop_dominated_under_min(points): + """Drop dominated rows under minimisation. Keeps input order.""" + n = points.shape[0] + keep = numpy.ones(n, dtype=bool) + for i in range(n): + if not keep[i]: + continue + for j in range(n): + if i == j or not keep[j]: + continue + if numpy.all(points[j] <= points[i]) and numpy.any(points[j] < points[i]): + keep[i] = False + break + return points[keep] + + +def _inclusive_hv(point, reference_point): + """Volume of the box between point and reference. 0 if point does not sit below the reference on every axis.""" + diff = reference_point - point + if numpy.any(diff <= 0): + return 0.0 + return float(numpy.prod(diff)) + + +def _wfg_exclusive_hv(point, others, reference_point): + """ + WFG recurrence: + exclusive(p, others) = inclusive(p) - hv(limit(p, others)) + where limit(p, q) pushes every other point up to the corner of p. + """ + base = _inclusive_hv(point, reference_point) + if base == 0.0 or len(others) == 0: + return base + limited = numpy.maximum(others, point) + limited = _drop_dominated_under_min(limited) + return base - _hv_under_min(limited, reference_point) + + +def _hv_under_min(points, reference_point): + """WFG hypervolume under minimisation. Fast enough for typical PyGAD populations.""" + if len(points) == 0: + return 0.0 + # Sort by the last objective to keep recursion shallow. + order = numpy.argsort(points[:, -1]) + sorted_points = points[order] + total = 0.0 + for i, point in enumerate(sorted_points): + others = sorted_points[i + 1:] + total += _wfg_exclusive_hv(point, others, reference_point) + return total + + +def hypervolume(fitness, reference_point): + """ + Hypervolume of the Pareto front built from every row in fitness. + + The reference is the worst case on every axis. Under PyGAD-max it + must be smaller than every fitness value. The function flips the + sign internally so the WFG algorithm (written for minimisation) + can be reused. The returned value is positive; bigger is better. + + Parameters + ---------- + fitness : numpy.ndarray + 2D array of shape (num_solutions, num_objectives). + reference_point : array-like + 1D array of length num_objectives, smaller than every entry + in the matching fitness column. + + Returns + ------- + hv : float + + Raises + ------ + ValueError + If fitness is not 2D, if reference_point has the wrong shape, + or if reference_point is not smaller than every solution on + every axis. + """ + fitness_arr = numpy.asarray(fitness, dtype=float) + reference_arr = numpy.asarray(reference_point, dtype=float) + if fitness_arr.ndim != 2: + raise ValueError( + f"fitness must be a 2D array, but got shape {fitness_arr.shape}.") + if reference_arr.shape != (fitness_arr.shape[1],): + raise ValueError( + f"reference_point must have shape ({fitness_arr.shape[1]},), " + f"but got {reference_arr.shape}.") + if numpy.any(fitness_arr.min(axis=0) <= reference_arr): + raise ValueError( + "reference_point must be smaller than every solution on every " + "objective. Pick a reference smaller than the column-wise " + "minimum of the fitness matrix.") + min_fitness = _to_min_fitness(fitness_arr) + min_reference = -reference_arr + front = _drop_dominated_under_min(min_fitness) + return _hv_under_min(front, min_reference) + + +def _euclidean_distance_matrix(a, b): + """Pairwise Euclidean distances; d[i, j] = ||a[i] - b[j]||.""" + a = numpy.asarray(a, dtype=float) + b = numpy.asarray(b, dtype=float) + diff = a[:, None, :] - b[None, :, :] + return numpy.sqrt((diff * diff).sum(axis=2)) + + +def inverted_generational_distance(fitness, reference_front): + """ + Inverted Generational Distance (IGD): mean distance from each + reference point to its nearest approximation point. Smaller is + better; reports both convergence and diversity. + + Parameters + ---------- + fitness : numpy.ndarray + Approximation front, shape (num_solutions, num_objectives), + in PyGAD's maximization format. + reference_front : numpy.ndarray + Reference front, shape (num_reference_points, num_objectives), + in the same format. + + Returns + ------- + igd : float + """ + distance_matrix = _euclidean_distance_matrix(reference_front, fitness) + return float(distance_matrix.min(axis=1).mean()) + + +def generational_distance(fitness, reference_front): + """ + Generational Distance (GD): mean distance from each approximation + point to its nearest reference point. Smaller is better; measures + convergence only. + + Parameters + ---------- + fitness : numpy.ndarray + Approximation front, shape (num_solutions, num_objectives), + in PyGAD's maximization format. + reference_front : numpy.ndarray + Reference front, shape (num_reference_points, num_objectives), + in the same format. + + Returns + ------- + gd : float + """ + distance_matrix = _euclidean_distance_matrix(fitness, reference_front) + return float(distance_matrix.min(axis=1).mean()) + + +def spacing(fitness): + """ + Spacing: standard deviation of each solution's nearest-neighbour + distance. Smaller means the solutions are more evenly spread. + + Returns 0.0 when fewer than two solutions are given so the caller + does not have to special-case it. + + Parameters + ---------- + fitness : numpy.ndarray + Approximation front, shape (num_solutions, num_objectives), + in PyGAD's maximization format. + + Returns + ------- + spacing_value : float + """ + fitness_arr = numpy.asarray(fitness, dtype=float) + if fitness_arr.shape[0] < 2: + return 0.0 + distance_matrix = _euclidean_distance_matrix(fitness_arr, fitness_arr) + numpy.fill_diagonal(distance_matrix, numpy.inf) + nearest_neighbour_distance = distance_matrix.min(axis=1) + mean_distance = nearest_neighbour_distance.mean() + return float(numpy.sqrt( + ((nearest_neighbour_distance - mean_distance) ** 2).mean())) diff --git a/pygad/utils/report.py b/pygad/utils/report.py new file mode 100644 index 0000000..1bd06e5 --- /dev/null +++ b/pygad/utils/report.py @@ -0,0 +1,566 @@ +""" +PDF report generation for a completed GA run. + +The ``Report`` class is mixed into ``pygad.GA``. Call +``ga_instance.generate_report(filename)`` after ``run()`` finishes to +get a PDF that bundles the run configuration, the best solution(s), +and every applicable plot. + +The report relies on two optional dependencies, ``matplotlib`` and +``reportlab``. Both are installed by ``pip install pygad[report]``. +The imports happen on first use so users who never call +``generate_report`` keep the lean install. +""" + +import io + + +# Default order in which sections appear in the report. Used by +# ``generate_report`` when the caller does not pass an explicit +# ``sections`` list. +REPORT_DEFAULT_SECTIONS = ("title", + "configuration", + "run_summary", + "best_solution", + "plots", + "notes") + +# Inventory of every plot the report can include, in the order the +# report renders them. Each entry has: +# name : section label that appears in the PDF +# method : name of the GA method that draws the plot +# requires_moo: True if the plot only works for multi-objective runs +# requires : tuple of attribute names that must be truthy +# kwargs : dict of keyword arguments passed to the method +REPORT_PLOTS = ( + {"name": "Best fitness per generation", + "method": "plot_fitness", + "requires_moo": False, + "requires": (), + "kwargs": {}}, + {"name": "New solutions per generation", + "method": "plot_new_solution_rate", + "requires_moo": False, + "requires": ("save_solutions",), + "kwargs": {}}, + {"name": "Per-gene drift", + "method": "plot_genes", + "requires_moo": False, + "requires": ("save_solutions",), + "kwargs": {"graph_type": "plot", "solutions": "all"}}, + {"name": "Min/Mean/Max fitness band", + "method": "plot_fitness_band", + "requires_moo": False, + "requires": ("save_solutions",), + "kwargs": {}}, + {"name": "Population diversity", + "method": "plot_population_diversity", + "requires_moo": False, + "requires": ("save_solutions",), + "kwargs": {}}, + {"name": "Pareto front", + "method": "plot_pareto_front_curve", + "requires_moo": True, + "requires": (), + "kwargs": {}, + "max_objectives": 3}, + {"name": "Parallel coordinates of the Pareto front", + "method": "plot_pareto_front_pcp", + "requires_moo": True, + "requires": (), + "kwargs": {}}, + {"name": "Pairwise scatter matrix of the Pareto front", + "method": "plot_pareto_front_scatter_matrix", + "requires_moo": True, + "requires": (), + "kwargs": {}}, + {"name": "Pareto front heatmap", + "method": "plot_pareto_front_heatmap", + "requires_moo": True, + "requires": (), + "kwargs": {}}, + {"name": "Hypervolume of the non-dominated set", + "method": "plot_non_dominated_hypervolume", + "requires_moo": True, + "requires": ("save_solutions",), + "kwargs": {}}, + {"name": "Pareto front evolution", + "method": "plot_pareto_front_evolution", + "requires_moo": True, + "requires": ("save_solutions",), + "kwargs": {"every_k": 10}, + "max_objectives": 3}, +) + + +# Names of the GA constructor parameters the report shows in the +# configuration table. Grouped by topic so the table reads well. +CONFIGURATION_GROUPS = ( + ("Population", ["num_generations", "num_parents_mating", "sol_per_pop", + "num_genes", "init_range_low", "init_range_high", + "gene_type", "gene_space", "allow_duplicate_genes", + "gene_constraint", "sample_size"]), + ("Parent selection", ["parent_selection_type", "K_tournament", + "nsga3_num_divisions", "keep_parents", + "keep_elitism"]), + ("Crossover", ["crossover_type", "crossover_probability", + "sbx_crossover_eta"]), + ("Mutation", ["mutation_type", "mutation_probability", + "mutation_percent_genes", "mutation_num_genes", + "polynomial_mutation_eta", "mutation_by_replacement", + "random_mutation_min_val", "random_mutation_max_val"]), + ("Stopping criteria", ["stop_criteria"]), + ("Run-time", ["fitness_batch_size", "parallel_processing", + "random_seed", "suppress_warnings"]), + ("History", ["save_solutions", "save_best_solutions"]), +) + + +class Report: + + def __init__(self): + pass + + def generate_report(self, + filename, + title=None, + sections=None, + include_plots=None, + figure_size_inches=(7.0, 4.5), + notes=None, + page_size="letter"): + """ + Build a PDF report of the current GA run and write it to disk. + + Parameters + ---------- + filename : str + Output path. ``.pdf`` is appended automatically if missing. + title : str or None + Title shown on the first page. Defaults to ``"PyGAD run + report"``. + sections : iterable of str or None + Sections to include and their order. Valid entries are + ``"title"``, ``"configuration"``, ``"run_summary"``, + ``"best_solution"``, ``"plots"``, and ``"notes"``. When + ``None``, every section is included in their default order. + include_plots : iterable of str, "all", or None + Plots to embed under the ``"plots"`` section. + ``None`` or ``"all"`` lets the report auto-select every + plot whose preconditions are met by this run (the right + number of objectives, ``save_solutions`` set, and so on). + Pass a list of plot method names (e.g. + ``["plot_fitness", "plot_pareto_front_curve"]``) to include + only those. + figure_size_inches : (float, float) + Width and height (in inches) used when each plot is drawn + for the report. The figures inside the PDF preserve this + aspect ratio. + notes : str or None + Free-form text rendered in the optional ``"notes"`` + section. + page_size : str + ``"letter"`` (default) or ``"A4"``. + + Returns + ------- + filename : str + The path of the PDF file that was written. + + Raises + ------ + ImportError + If ``reportlab`` or ``matplotlib`` is not installed. + RuntimeError + If the GA has not completed at least one generation. + ValueError + If ``sections`` or ``include_plots`` contain an unknown + entry, or if ``page_size`` is unknown. + """ + if self.generations_completed < 1: + raise RuntimeError( + "generate_report() can only be called after at least one " + "generation has completed. Call run() first.") + + reportlab_modules = _import_reportlab() + matplt = _import_matplotlib() + + section_list = _resolve_sections(sections) + page_size_obj = _resolve_page_size(page_size, reportlab_modules) + + if not filename.endswith(".pdf"): + filename = filename + ".pdf" + + story = [] + styles = reportlab_modules["styles"].getSampleStyleSheet() + for section_name in section_list: + if section_name == "title": + story.extend(_build_title_section( + self, title, styles, reportlab_modules)) + elif section_name == "configuration": + story.extend(_build_configuration_section( + self, styles, reportlab_modules)) + elif section_name == "run_summary": + story.extend(_build_run_summary_section( + self, styles, reportlab_modules)) + elif section_name == "best_solution": + story.extend(_build_best_solution_section( + self, styles, reportlab_modules)) + elif section_name == "plots": + story.extend(_build_plots_section( + self, + include_plots, + figure_size_inches, + styles, + reportlab_modules, + matplt)) + elif section_name == "notes": + story.extend(_build_notes_section( + notes, styles, reportlab_modules)) + + doc = reportlab_modules["SimpleDocTemplate"]( + filename, + pagesize=page_size_obj, + title=title or "PyGAD run report", + author="PyGAD", + ) + doc.build(story) + return filename + + +def _import_reportlab(): + """ + Import reportlab on first use. Returns a dict with the names the + report builder needs, so the calling code does not have to repeat + the imports. + """ + try: + from reportlab.lib import colors, pagesizes, styles + from reportlab.lib.units import inch + from reportlab.platypus import ( + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + except ImportError as exc: + raise ImportError( + "generate_report requires reportlab. Install it with: " + "pip install pygad[report] (or pip install reportlab)." + ) from exc + return { + "colors": colors, + "pagesizes": pagesizes, + "styles": styles, + "inch": inch, + "Image": Image, + "PageBreak": PageBreak, + "Paragraph": Paragraph, + "SimpleDocTemplate": SimpleDocTemplate, + "Spacer": Spacer, + "Table": Table, + "TableStyle": TableStyle, + } + + +def _import_matplotlib(): + """ + Import matplotlib on first use. The Agg backend is forced so the + report can be generated in headless environments. + """ + try: + import matplotlib + matplotlib.use("Agg", force=False) + import matplotlib.pyplot as matplt + except ImportError as exc: + raise ImportError( + "generate_report requires matplotlib. Install it with: " + "pip install pygad[report] (or pip install matplotlib)." + ) from exc + return matplt + + +def _resolve_sections(sections): + if sections is None: + return list(REPORT_DEFAULT_SECTIONS) + requested = list(sections) + unknown = set(requested) - set(REPORT_DEFAULT_SECTIONS) + if unknown: + raise ValueError( + f"Unknown report sections: {sorted(unknown)}. Allowed: " + f"{list(REPORT_DEFAULT_SECTIONS)}.") + return requested + + +def _resolve_page_size(page_size, reportlab_modules): + name = page_size.lower() + if name == "letter": + return reportlab_modules["pagesizes"].LETTER + if name == "a4": + return reportlab_modules["pagesizes"].A4 + raise ValueError( + f"Unknown page_size {page_size!r}. Allowed values: 'letter', 'A4'.") + + +def _is_multi_objective(ga): + """Return True when the last fitness row is iterable (MOO).""" + if getattr(ga, "last_generation_fitness", None) is None: + return False + first = ga.last_generation_fitness[0] + return hasattr(first, "__len__") + + +def _num_objectives(ga): + """Return the number of objectives, or 1 for single-objective runs.""" + if not _is_multi_objective(ga): + return 1 + return len(ga.last_generation_fitness[0]) + + +def _build_title_section(ga, title, styles, modules): + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + inch = modules["inch"] + title_text = title or "PyGAD run report" + import pygad as _pygad_module + subtitle = f"PyGAD version: {_pygad_module.__version__}" + return [ + Paragraph(title_text, styles["Title"]), + Spacer(1, 0.15 * inch), + Paragraph(subtitle, styles["Normal"]), + Spacer(1, 0.25 * inch), + ] + + +def _build_configuration_section(ga, styles, modules): + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + Table = modules["Table"] + TableStyle = modules["TableStyle"] + colors = modules["colors"] + inch = modules["inch"] + + elements = [Paragraph("Configuration", styles["Heading1"])] + for group_name, parameter_names in CONFIGURATION_GROUPS: + rows = [[Paragraph("Parameter", styles["BodyText"]), + Paragraph("Value", styles["BodyText"])]] + for parameter_name in parameter_names: + if not hasattr(ga, parameter_name): + continue + value = getattr(ga, parameter_name) + rows.append([ + Paragraph(parameter_name, styles["BodyText"]), + Paragraph(_format_value(value), styles["BodyText"]), + ]) + if len(rows) <= 1: + continue + elements.append(Paragraph(group_name, styles["Heading3"])) + table = Table(rows, colWidths=[2.2 * inch, 4.0 * inch]) + table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), + ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ])) + elements.append(table) + elements.append(Spacer(1, 0.15 * inch)) + elements.append(modules["PageBreak"]()) + return elements + + +def _build_run_summary_section(ga, styles, modules): + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + Table = modules["Table"] + TableStyle = modules["TableStyle"] + colors = modules["colors"] + inch = modules["inch"] + + rows = [[Paragraph("Item", styles["BodyText"]), + Paragraph("Value", styles["BodyText"])]] + rows.append([Paragraph("Problem type", styles["BodyText"]), + Paragraph("Multi-objective" if _is_multi_objective(ga) + else "Single-objective", styles["BodyText"])]) + rows.append([Paragraph("Number of objectives", styles["BodyText"]), + Paragraph(str(_num_objectives(ga)), styles["BodyText"])]) + rows.append([Paragraph("Generations completed", styles["BodyText"]), + Paragraph(str(ga.generations_completed), styles["BodyText"])]) + rows.append([Paragraph("Final population size", styles["BodyText"]), + Paragraph(str(ga.sol_per_pop), styles["BodyText"])]) + rows.append([Paragraph("Best solution generation", styles["BodyText"]), + Paragraph(str(ga.best_solution_generation), + styles["BodyText"])]) + if not _is_multi_objective(ga): + rows.append([Paragraph("Best fitness", styles["BodyText"]), + Paragraph(_format_value(ga.best_solutions_fitness[-1] + if ga.best_solutions_fitness else "n/a"), + styles["BodyText"])]) + + table = Table(rows, colWidths=[2.2 * inch, 4.0 * inch]) + table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), + ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ])) + return [ + Paragraph("Run summary", styles["Heading1"]), + table, + Spacer(1, 0.2 * inch), + ] + + +def _build_best_solution_section(ga, styles, modules): + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + inch = modules["inch"] + try: + best_solution, best_fitness, best_idx = ga.best_solution() + except Exception as exc: + return [ + Paragraph("Best solution", styles["Heading1"]), + Paragraph(f"Could not compute best_solution(): {exc}", + styles["BodyText"]), + Spacer(1, 0.2 * inch), + ] + elements = [Paragraph("Best solution", styles["Heading1"])] + elements.append(Paragraph(f"Population index: {best_idx}", + styles["BodyText"])) + elements.append(Paragraph(f"Fitness: {_format_value(best_fitness)}", + styles["BodyText"])) + elements.append(Paragraph(f"Solution: {_format_value(list(best_solution))}", + styles["BodyText"])) + elements.append(Spacer(1, 0.2 * inch)) + return elements + + +def _build_plots_section(ga, + include_plots, + figure_size_inches, + styles, + modules, + matplt): + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + Image = modules["Image"] + PageBreak = modules["PageBreak"] + inch = modules["inch"] + + plot_method_names = _select_plot_methods(ga, include_plots) + elements = [Paragraph("Plots", styles["Heading1"])] + if not plot_method_names: + elements.append(Paragraph( + "No plots were applicable for this run. Set save_solutions=True " + "for over-generation plots and use a multi-objective fitness " + "function for Pareto-related plots.", + styles["BodyText"])) + return elements + + for entry in REPORT_PLOTS: + method_name = entry["method"] + if method_name not in plot_method_names: + continue + elements.append(Paragraph(entry["name"], styles["Heading2"])) + figure_data = _render_plot_to_png(ga, entry, figure_size_inches, matplt) + if figure_data is None: + elements.append(Paragraph( + f"Plot {method_name} could not be drawn for this run.", + styles["BodyText"])) + continue + image_width_inches, image_height_inches = figure_size_inches + image = Image(io.BytesIO(figure_data), + width=image_width_inches * inch, + height=image_height_inches * inch) + elements.append(image) + elements.append(Spacer(1, 0.15 * inch)) + elements.append(PageBreak()) + return elements + + +def _build_notes_section(notes, styles, modules): + if not notes: + return [] + Paragraph = modules["Paragraph"] + Spacer = modules["Spacer"] + inch = modules["inch"] + return [ + Paragraph("Notes", styles["Heading1"]), + Paragraph(str(notes), styles["BodyText"]), + Spacer(1, 0.2 * inch), + ] + + +def _select_plot_methods(ga, include_plots): + """ + Return the set of plot method names that the report will include. + When the caller passes ``None`` or ``"all"``, auto-pick every plot + whose preconditions are satisfied by the current GA state. + """ + if include_plots is None or include_plots == "all": + requested = None + else: + requested = list(include_plots) + valid_method_names = {entry["method"] for entry in REPORT_PLOTS} + unknown = set(requested) - valid_method_names + if unknown: + raise ValueError( + f"Unknown plot method(s) in include_plots: {sorted(unknown)}. " + f"Allowed values: {sorted(valid_method_names)}.") + + is_moo = _is_multi_objective(ga) + num_objectives = _num_objectives(ga) + selected = [] + for entry in REPORT_PLOTS: + if requested is not None and entry["method"] not in requested: + continue + if entry["requires_moo"] and not is_moo: + continue + if not all(getattr(ga, name, False) for name in entry["requires"]): + continue + max_objectives = entry.get("max_objectives") + if max_objectives is not None and num_objectives > max_objectives: + continue + selected.append(entry["method"]) + return selected + + +def _render_plot_to_png(ga, plot_entry, figure_size_inches, matplt): + """ + Call the requested plot method on the GA, capture the matplotlib + figure, save it as PNG bytes, and close it so the figure stack does + not grow unbounded. Returns ``None`` when the plot method raises. + """ + method = getattr(ga, plot_entry["method"]) + kwargs = dict(plot_entry["kwargs"]) + try: + figure = method(**kwargs) + except Exception: + return None + if figure is None: + return None + figure.set_size_inches(*figure_size_inches) + buffer = io.BytesIO() + try: + figure.tight_layout() + except Exception: + # tight_layout fails on some figure layouts (e.g. those with a + # nested gridspec); ignore and keep the original layout. + pass + figure.savefig(buffer, format="png", dpi=150, bbox_inches="tight") + matplt.close(figure) + return buffer.getvalue() + + +def _format_value(value): + """Compact, human-readable rendering for the configuration table.""" + if callable(value) and hasattr(value, "__name__"): + return f"" + if isinstance(value, (list, tuple)) and len(value) > 8: + head = ", ".join(_format_value(v) for v in value[:6]) + return f"[{head}, ... (+{len(value) - 6} more)]" + if isinstance(value, float): + return f"{value:g}" + return str(value) diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index b893e7f..62c4653 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -6,13 +6,50 @@ class Validation: - def _validate_header(self, + def _validate_header(self, logger, random_seed, suppress_warnings, mutation_by_replacement, sample_size, allow_duplicate_genes): + """ + Validate the first group of constructor parameters and store + them on the GA instance. Sets up the logger (creating a + default console logger when ``logger`` is None), seeds the + random generators when ``random_seed`` is given, and persists + the four flag-style parameters on ``self``. + + Parameters + ---------- + logger : logging.Logger or None + A logger object. When None, a default console logger is + created. + random_seed : int or None + Seed for the numpy and random random generators. When + None, the generators are left in their current state. + suppress_warnings : bool + If True, ``warnings.warn`` calls inside PyGAD are skipped. + mutation_by_replacement : bool + If True, the random mutation replaces the gene value + instead of adding a random delta. + sample_size : int + Number of candidate values drawn when resolving gene + constraints and duplicates. + allow_duplicate_genes : bool + If False, duplicate genes inside a single solution are + resolved by sampling new values. + + Raises + ------ + TypeError + If ``logger`` is neither None nor a ``logging.Logger``. + TypeError + If any of the bool flags is not a bool. + ValueError + If ``sample_size`` is not a positive integer or + ``random_seed`` is of an unsupported type. + """ # If no logger is passed, then create a logger that logs the messages only to the console. if logger is None: # Create a logger named with the module name. @@ -90,6 +127,31 @@ def _validate_header(self, def _validate_gene_space(self, gene_space): + """ + Validate the ``gene_space`` parameter and store it on the GA + instance. ``gene_space`` may be None, a flat iterable that + applies to every gene, a per-gene nested iterable, or a dict + with ``low`` / ``high`` (and optional ``step``) keys that + describes a continuous range. + + Sets ``self.gene_space`` and the helper flag + ``self.gene_space_nested`` (True when each gene has its own + space). + + Parameters + ---------- + gene_space : None, list, tuple, numpy.ndarray, or dict + See the constructor documentation for the full grammar. + + Raises + ------ + TypeError + If ``gene_space`` is not one of the supported container + types. + ValueError + If a nested gene space has an unsupported element type, + or if a dict gene space is missing required keys. + """ # Validate gene_space self.gene_space_nested = False if type(gene_space) is type(None): @@ -164,6 +226,37 @@ def _validate_init_range(self, init_range_high, num_genes, initial_population): + """ + Validate the ``init_range_low`` and ``init_range_high`` + parameters used to build the initial population when the user + does not pass one explicitly. Both may be a scalar (one range + shared by every gene) or a per-gene iterable. + + Sets ``self.init_range_low`` and ``self.init_range_high`` on + the GA instance. + + Parameters + ---------- + init_range_low : numeric or iterable + Lower bound(s) for the random initial gene values. + init_range_high : numeric or iterable + Upper bound(s) for the random initial gene values. + num_genes : int or None + Number of genes per solution. Used to check the length of + the per-gene iterables. + initial_population : list / numpy.ndarray or None + The user-provided initial population, if any. Only used to + skip the length check when the population is being + inferred from it. + + Raises + ------ + TypeError + If either parameter is not a supported type. + ValueError + If the per-gene iterables have a length different from + ``num_genes``. + """ # Validate init_range_low and init_range_high if type(init_range_low) in self.supported_int_float_types: if type(init_range_high) in self.supported_int_float_types: @@ -222,6 +315,36 @@ def _validate_gene_type(self, gene_type, num_genes, initial_population): + """ + Validate the ``gene_type`` parameter and store it on the GA + instance. A gene type may be: + + - a single Python or numpy numeric type that applies to every + gene (``self.gene_type_single`` is set to True); + - a ``[type, precision]`` pair applied to every gene; + - a per-gene list of types or ``[type, precision]`` pairs + (``self.gene_type_single`` is set to False). + + Parameters + ---------- + gene_type : type, list, or tuple + The gene type specification. + num_genes : int or None + Number of genes per solution. Used to check the length of + a per-gene specification. + initial_population : list / numpy.ndarray or None + The user-provided initial population, if any. Used to + decide whether ``num_genes`` is already known. + + Raises + ------ + TypeError + If ``gene_type`` (or any of its elements) is not a + supported numeric type. + ValueError + If the per-gene specification has a length different from + ``num_genes``, or the precision is not an integer. + """ # Validate gene_type if gene_type in self.supported_int_float_types: self.gene_type = [gene_type, None] @@ -293,6 +416,47 @@ def _build_initial_population(self, gene_space, allow_duplicate_genes, gene_constraint): + """ + Build or accept the initial population and store it on the GA + instance. When ``initial_population`` is None, the population + is generated from scratch by ``initialize_population`` using + ``sol_per_pop`` and ``num_genes``. Otherwise the user-provided + array is validated, cast to the right gene types, and + de-duplicated when ``allow_duplicate_genes`` is False. + + Sets ``self.population``, ``self.initial_population``, + ``self.sol_per_pop``, ``self.num_genes`` and ``self.pop_size`` + as side effects. + + Parameters + ---------- + initial_population : list / numpy.ndarray or None + User-provided initial population. When None, the + population is built from ``sol_per_pop`` and ``num_genes``. + sol_per_pop : int or None + Number of solutions per population. Required when + ``initial_population`` is None. + num_genes : int or None + Number of genes per solution. Required when + ``initial_population`` is None. + gene_space : see ``_validate_gene_space`` + The gene space used by the duplicate resolver. + allow_duplicate_genes : bool + If False, duplicate genes inside a single solution are + resolved. + gene_constraint : list or None + Per-gene callable constraints; passed through to + ``initialize_population``. + + Raises + ------ + TypeError + If ``initial_population`` is not a list / tuple / + numpy.ndarray, or its values are not numeric. + ValueError + If ``sol_per_pop`` or ``num_genes`` is non-positive, or + ``initial_population`` is not 2-dimensional. + """ # Build the initial population if initial_population is None: if (sol_per_pop is None) or (num_genes is None): @@ -377,6 +541,29 @@ def _build_initial_population(self, def _validate_mutation_range(self, random_mutation_min_val, random_mutation_max_val): + """ + Validate the random mutation range parameters and store them + on the GA instance. Both parameters may be scalars (one range + shared by every gene) or per-gene iterables. + + Sets ``self.random_mutation_min_val`` and + ``self.random_mutation_max_val``. + + Parameters + ---------- + random_mutation_min_val : numeric or iterable + Lower bound(s) for the random delta added during mutation. + random_mutation_max_val : numeric or iterable + Upper bound(s) for the random delta added during mutation. + + Raises + ------ + TypeError + If either parameter is not a supported numeric type. + ValueError + If the per-gene iterables have a length different from + ``num_genes``. + """ # Validate random_mutation_min_val and random_mutation_max_val if type(random_mutation_min_val) in self.supported_int_float_types: if type(random_mutation_max_val) in self.supported_int_float_types: @@ -427,6 +614,28 @@ def _validate_mutation_range(self, def _validate_gene_constraint(self, gene_constraint): + """ + Validate the ``gene_constraint`` parameter. The constraint is + a list with one entry per gene; each entry is either None (no + constraint) or a callable that filters a list of candidate + values down to the subset that satisfies the constraint. + + Sets ``self.gene_constraint`` on the GA instance. + + Parameters + ---------- + gene_constraint : list, tuple, or None + One callable per gene (or None to disable). Length must + equal ``self.num_genes``. + + Raises + ------ + TypeError + If ``gene_constraint`` is not a list / tuple, or any + element is not None and not callable. + ValueError + If the list length does not match ``self.num_genes``. + """ # Validate that gene_constraint is a list or tuple and every element inside it is either None or callable. if gene_constraint: if type(gene_constraint) in [list, tuple]: @@ -460,7 +669,43 @@ def _validate_gene_constraint(self, def _validate_crossover(self, crossover_type, - crossover_probability): + crossover_probability, + sbx_crossover_eta=30): + """ + Validate the ``crossover_type`` and ``crossover_probability`` + parameters and store them on the GA instance. ``crossover_type`` + may be: + + - one of the built-in strings (``"single_point"``, + ``"two_points"``, ``"uniform"``, ``"scattered"``); + - a callable that takes ``(parents, offspring_size)`` and + returns the offspring array; + - None to skip the crossover step entirely. + + ``crossover_probability`` is the per-parent probability of + being selected for mating; only used by the built-in operators. + + Sets ``self.crossover`` (the operator function) plus + ``self.crossover_type`` and ``self.crossover_probability``. + + Parameters + ---------- + crossover_type : str, callable, or None + The crossover operator selector. + crossover_probability : float or None + Per-parent crossover probability between 0 and 1 + inclusive, or None to disable. + + Raises + ------ + TypeError + If ``crossover_type`` is neither a string, callable, nor + None. + ValueError + If ``crossover_type`` is an unknown string, the callable + has the wrong number of parameters, or + ``crossover_probability`` is outside [0, 1]. + """ # crossover: Refers to the method that applies the crossover operator based on the selected type of crossover in the crossover_type property. # Validating the crossover type: crossover_type if crossover_type is None: @@ -507,12 +752,25 @@ def _validate_crossover(self, self.crossover = self.uniform_crossover elif crossover_type == "scattered": self.crossover = self.scattered_crossover + elif crossover_type == "sbx": + self.crossover = self.sbx_crossover else: self.valid_parameters = False - raise TypeError(f"Undefined crossover type. \nThe assigned value to the crossover_type ({crossover_type}) parameter does not refer to one of the supported crossover types which are: \n-single_point (for single point crossover)\n-two_points (for two points crossover)\n-uniform (for uniform crossover)\n-scattered (for scattered crossover).\n") + raise TypeError(f"Undefined crossover type. \nThe assigned value to the crossover_type ({crossover_type}) parameter does not refer to one of the supported crossover types which are: \n-single_point (for single point crossover)\n-two_points (for two points crossover)\n-uniform (for uniform crossover)\n-scattered (for scattered crossover)\n-sbx (for simulated binary crossover).\n") self.crossover_type = crossover_type + # Validate sbx_crossover_eta. It is only used when + # crossover_type is 'sbx', but it is stored on the instance + # in all cases so user callables can read it too. + if type(sbx_crossover_eta) not in self.supported_int_float_types or sbx_crossover_eta <= 0: + self.valid_parameters = False + raise ValueError( + f"sbx_crossover_eta must be a positive number, but got {sbx_crossover_eta!r} " + f"of type {type(sbx_crossover_eta).__name__}." + ) + self.sbx_crossover_eta = float(sbx_crossover_eta) + # Calculate the value of crossover_probability if crossover_probability is None: self.crossover_probability = None @@ -530,7 +788,54 @@ def _validate_mutation(self, mutation_type, mutation_probability, mutation_num_genes, - mutation_percent_genes): + mutation_percent_genes, + polynomial_mutation_eta=20): + """ + Validate the mutation-related parameters and store them on the + GA instance. ``mutation_type`` may be one of the built-in + strings (``"random"``, ``"swap"``, ``"inversion"``, + ``"scramble"``, ``"adaptive"``), a user-supplied callable, or + None to skip mutation. + + The function also resolves which of ``mutation_probability``, + ``mutation_num_genes`` and ``mutation_percent_genes`` is in + effect and translates percentages to gene counts. + + Sets ``self.mutation`` plus ``self.mutation_type``, + ``self.mutation_probability``, ``self.mutation_num_genes`` and + ``self.mutation_percent_genes``. + + Parameters + ---------- + mutation_type : str, callable, or None + The mutation operator selector. + mutation_probability : float, list, tuple, numpy.ndarray, or None + Per-gene mutation probability between 0 and 1 inclusive. + For adaptive mutation it may be a pair ``[high, low]`` + applied to below-average / above-average solutions. + mutation_num_genes : int, list, tuple, numpy.ndarray, or None + Number of genes to mutate per solution. For adaptive + mutation it may be a pair ``[high, low]``. + mutation_percent_genes : numeric, list, tuple, numpy.ndarray, or 'default' + Percentage of genes to mutate. Ignored when + ``mutation_probability`` or ``mutation_num_genes`` is set. + + Returns + ------- + mutation_num_genes : int, list, tuple, or numpy.ndarray + The resolved number of genes to mutate. + mutation_percent_genes : numeric, list, tuple, or numpy.ndarray + The resolved percentage of genes to mutate. + + Raises + ------ + TypeError + If any parameter has an unexpected type. + ValueError + If a probability is outside [0, 1], a count is non-positive + or larger than ``num_genes``, or a callable has the wrong + number of parameters. + """ # mutation: Refers to the method that applies the mutation operator based on the selected type of mutation in the mutation_type property. # Validating the mutation type: mutation_type # "adaptive" mutation is supported starting from PyGAD 2.10.0 @@ -580,12 +885,27 @@ def _validate_mutation(self, self.mutation = self.inversion_mutation elif mutation_type == "adaptive": self.mutation = self.adaptive_mutation + elif mutation_type == "polynomial": + self.mutation = self.polynomial_mutation else: self.valid_parameters = False - raise TypeError(f"Undefined mutation type. \nThe assigned string value to the 'mutation_type' parameter ({mutation_type}) does not refer to one of the supported mutation types which are: \n-random (for random mutation)\n-swap (for swap mutation)\n-inversion (for inversion mutation)\n-scramble (for scramble mutation)\n-adaptive (for adaptive mutation).\n") + raise TypeError(f"Undefined mutation type. \nThe assigned string value to the 'mutation_type' parameter ({mutation_type}) does not refer to one of the supported mutation types which are: \n-random (for random mutation)\n-swap (for swap mutation)\n-inversion (for inversion mutation)\n-scramble (for scramble mutation)\n-adaptive (for adaptive mutation)\n-polynomial (for polynomial mutation).\n") self.mutation_type = mutation_type + # Validate polynomial_mutation_eta. It is only used when + # mutation_type is 'polynomial', but it is stored on the + # instance in all cases so user callables can read it too. + if (type(polynomial_mutation_eta) not in self.supported_int_float_types + or polynomial_mutation_eta <= 0): + self.valid_parameters = False + raise ValueError( + f"polynomial_mutation_eta must be a positive number, but " + f"got {polynomial_mutation_eta!r} of type " + f"{type(polynomial_mutation_eta).__name__}." + ) + self.polynomial_mutation_eta = float(polynomial_mutation_eta) + # Calculate the value of mutation_probability if not (self.mutation_type is None): if mutation_probability is None: @@ -755,11 +1075,101 @@ def _validate_mutation(self, warnings.warn("The 2 parameters mutation_type and crossover_type are None. This disables any type of evolution the genetic algorithm can make. As a result, the genetic algorithm cannot find a better solution than the best solution in the initial population.") return mutation_num_genes, mutation_percent_genes + def _validate_nsga3_num_divisions(self, parent_selection_type, nsga3_num_divisions): + """ + Validate ``nsga3_num_divisions`` and store it on the GA + instance. The parameter is only required when + ``parent_selection_type`` is ``"nsga3"`` or + ``"tournament_nsga3"``; otherwise the value is accepted as-is + for forward compatibility. + + Parameters + ---------- + parent_selection_type : str + The selection operator name. Only the two NSGA-III + variants treat ``nsga3_num_divisions`` as required. + nsga3_num_divisions : int or None + Number of divisions per objective axis (the ``p`` + parameter of the Das-Dennis reference grid). + + Raises + ------ + ValueError + If ``parent_selection_type`` is one of the NSGA-III + variants and ``nsga3_num_divisions`` is None, not an + integer, or not positive. + """ + if parent_selection_type not in ("nsga3", "tournament_nsga3"): + self.nsga3_num_divisions = nsga3_num_divisions + return + if nsga3_num_divisions is None: + self.valid_parameters = False + raise ValueError( + f"parent_selection_type='{parent_selection_type}' requires " + f"nsga3_num_divisions to be a positive integer. Pass " + f"nsga3_num_divisions= to GA(...)." + ) + if (type(nsga3_num_divisions) not in self.supported_int_types + or nsga3_num_divisions <= 0): + self.valid_parameters = False + raise ValueError( + f"nsga3_num_divisions must be a positive integer when " + f"parent_selection_type='{parent_selection_type}', but got " + f"{nsga3_num_divisions!r} of type " + f"{type(nsga3_num_divisions).__name__}." + ) + self.nsga3_num_divisions = int(nsga3_num_divisions) + def _validate_parent_selection(self, parent_selection_type, K_tournament, keep_parents, - keep_elitism): + keep_elitism, + nsga3_num_divisions=None): + """ + Validate the parameters that control parent selection, + retention and elitism. Resolves ``parent_selection_type`` to + an actual operator (built-in string or user callable) and + stores it on ``self.select_parents``. Also computes + ``self.num_offspring`` from ``sol_per_pop``, ``keep_parents`` + and ``keep_elitism``. + + Parameters + ---------- + parent_selection_type : str or callable + One of the built-in selection names or a user-supplied + function with three parameters (fitness, num_parents, + ga_instance). + K_tournament : int + Tournament size used by the tournament-based operators. + Clipped to ``self.sol_per_pop`` when too large. + keep_parents : int + Number of parents to carry over to the next generation. + ``-1`` keeps all selected parents; ``0`` keeps none; + positive values keep exactly that many. + keep_elitism : int + Number of top solutions to copy unchanged into the next + generation. Takes priority over ``keep_parents``. + nsga3_num_divisions : int or None + Forwarded to ``_validate_nsga3_num_divisions``. + + Returns + ------- + parent_selection_type : str or callable + The (possibly lowercased) selection type stored on + ``self``. + + Raises + ------ + TypeError + If ``parent_selection_type`` is not a supported type, or + a user callable does not have three parameters, or + ``K_tournament`` / ``keep_parents`` / ``keep_elitism`` is + of the wrong type. + ValueError + If a numeric parameter is out of range, or the selection + name is unknown. + """ # select_parents: Refers to a method that selects the parents based on the parent selection type specified in the parent_selection_type attribute. # Validating the selected type of parent selection: parent_selection_type if inspect.ismethod(parent_selection_type): @@ -811,11 +1221,15 @@ def _validate_parent_selection(self, self.select_parents = self.tournament_selection_nsga2 elif parent_selection_type == "nsga2": # Supported in PyGAD >= 3.2 self.select_parents = self.nsga2_selection + elif parent_selection_type == "tournament_nsga3": + self.select_parents = self.tournament_selection_nsga3 + elif parent_selection_type == "nsga3": + self.select_parents = self.nsga3_selection elif parent_selection_type == "rank": self.select_parents = self.rank_selection else: self.valid_parameters = False - raise TypeError(f"Undefined parent selection type: {parent_selection_type}. \nThe assigned value to the 'parent_selection_type' parameter does not refer to one of the supported parent selection techniques which are: \n-sss (steady state selection)\n-rws (roulette wheel selection)\n-sus (stochastic universal selection)\n-rank (rank selection)\n-random (random selection)\n-tournament (tournament selection)\n-tournament_nsga2: (Tournament selection for NSGA-II)\n-nsga2: (NSGA-II parent selection).\n") + raise TypeError(f"Undefined parent selection type: {parent_selection_type}. \nThe assigned value to the 'parent_selection_type' parameter does not refer to one of the supported parent selection techniques which are: \n-sss (steady state selection)\n-rws (roulette wheel selection)\n-sus (stochastic universal selection)\n-rank (rank selection)\n-random (random selection)\n-tournament (tournament selection)\n-tournament_nsga2: (Tournament selection for NSGA-II)\n-nsga2: (NSGA-II parent selection)\n-tournament_nsga3: (Tournament selection for NSGA-III)\n-nsga3: (NSGA-III parent selection).\n") # For tournament selection, validate the K value. if parent_selection_type == "tournament": @@ -833,7 +1247,15 @@ def _validate_parent_selection(self, self.K_tournament = K_tournament + self._validate_nsga3_num_divisions(parent_selection_type, nsga3_num_divisions) + # Validating the number of parents to keep in the next population: keep_parents + # keep_parents defaults to None (sentinel) so we can tell whether the user + # explicitly set it. Resolve None to -1 to preserve the historical default + # behavior (keep all selected parents) byte-for-byte. + self.keep_parents_explicitly_set = keep_parents is not None + if keep_parents is None: + keep_parents = -1 if not (type(keep_parents) in self.supported_int_types): self.valid_parameters = False raise TypeError(f"Incorrect type of the value assigned to the keep_parents parameter. The value ({keep_parents}) of type {type(keep_parents)} found but an integer is expected.") @@ -857,25 +1279,65 @@ def _validate_parent_selection(self, self.keep_elitism = keep_elitism - # Validate keep_parents. + # keep_elitism takes precedence over keep_parents: when keep_elitism > 0, + # keep_parents is ignored. Warn if the user explicitly set keep_parents while + # keep_elitism is non-zero, instead of silently ignoring it. + if self.keep_parents_explicitly_set and self.keep_elitism != 0: + if not self.suppress_warnings: + warnings.warn(f"Both keep_parents (={self.keep_parents}) and keep_elitism (={self.keep_elitism}) are set. Because keep_elitism is greater than 0, it takes precedence and keep_parents is ignored. To make keep_parents take effect, set keep_elitism=0.") + + self._refresh_num_offspring() + + return parent_selection_type + + def _refresh_num_offspring(self): + """ + Set self.num_offspring from the current values of sol_per_pop, + keep_elitism, keep_parents, and num_parents_mating. Called from + the initial validation step and again whenever the population + size changes after construction (for example, when NSGA-III grows + sol_per_pop to match the number of reference points). + """ if self.keep_elitism == 0: - # Keep all parents in the next population. if self.keep_parents == -1: self.num_offspring = self.sol_per_pop - self.num_parents_mating - # Keep no parents in the next population. elif self.keep_parents == 0: self.num_offspring = self.sol_per_pop - # Keep the specified number of parents in the next population. elif self.keep_parents > 0: self.num_offspring = self.sol_per_pop - self.keep_parents else: self.num_offspring = self.sol_per_pop - self.keep_elitism - return parent_selection_type - def _validate_fitness_func(self, fitness_func, fitness_batch_size): + """ + Validate the ``fitness_func`` and ``fitness_batch_size`` + parameters and store them on the GA instance. The fitness + function must be a method or function (or a class with a + ``__call__`` method) that takes three parameters: the GA + instance, a solution (or a batch), and the solution index (or + a batch of indices). + + Sets ``self.fitness_func`` and ``self.fitness_batch_size``. + + Parameters + ---------- + fitness_func : callable + The fitness function described above. + fitness_batch_size : int or None + When set, batches of this many solutions are passed to + ``fitness_func`` at once. ``None`` or ``1`` evaluates one + solution per call. + + Raises + ------ + TypeError + If ``fitness_func`` is not callable. + ValueError + If ``fitness_func`` does not accept three parameters, or + ``fitness_batch_size`` is not a positive integer. + """ # Check if the fitness_func is a method. if inspect.ismethod(fitness_func): # Check if the fitness method accepts 3 parameters. @@ -928,6 +1390,45 @@ def _validate_callbacks(self, on_mutation, on_generation, on_stop): + """ + Validate the seven optional lifecycle callbacks and store + them on the GA instance under matching ``self.on_*`` + attributes. Each callback must be a function or method with + the expected number of parameters. + + Parameters + ---------- + on_start : callable or None + Called once before the generational loop. Receives the + GA instance. + on_fitness : callable or None + Called after the fitness of the current population has + been evaluated. Receives the GA instance and the fitness + array. + on_parents : callable or None + Called after the parent selection step. Receives the GA + instance and the selected parents. + on_crossover : callable or None + Called after the crossover step. Receives the GA instance + and the crossover offspring. + on_mutation : callable or None + Called after the mutation step. Receives the GA instance + and the mutated offspring. + on_generation : callable or None + Called after each generation completes. Receives the GA + instance. Returning the string ``"stop"`` ends the run. + on_stop : callable or None + Called once after the generational loop ends. Receives + the GA instance and the last-generation fitness array. + + Raises + ------ + TypeError + If a callback is not callable. + ValueError + If a callback does not have the expected number of + parameters. + """ # Check if the on_start exists. if not (on_start is None): if inspect.ismethod(on_start): @@ -1189,8 +1690,39 @@ def _validate_callbacks(self, def _validate_stop_criteria(self, stop_criteria): + """ + Validate the ``stop_criteria`` parameter and store the parsed + criteria on ``self.stop_criteria`` for later use by ``run``. + Each criterion follows the form ``"keyword_value"`` (or + ``"keyword_v1_v2_..."`` for multi-objective ``reach``). + Supported keywords: + + - ``"reach"``: stop when the best fitness is at least the + target value. + - ``"saturate"``: stop when the best fitness does not change + for the given number of generations. + - ``"time"``: stop when the time spent inside ``run()`` is + at least the given number of seconds. + - ``"evaluations"``: stop when the number of fitness function + calls made inside ``run()`` reaches the given count. + + Parameters + ---------- + stop_criteria : str, list, tuple, or None + A single criterion string, an iterable of criterion + strings, or ``None`` to run for all generations. + + Raises + ------ + TypeError + If ``stop_criteria`` is not a string, list, tuple, or + None, or if a list element is not a string. + ValueError + If a criterion uses an unknown keyword or its value is + not a number. + """ self.stop_criteria = [] - self.supported_stop_words = ["reach", "saturate"] + self.supported_stop_words = ["reach", "saturate", "time", "evaluations"] if stop_criteria is None: # None: Stop after passing through all generations. self.stop_criteria = None @@ -1263,6 +1795,29 @@ def _validate_stop_criteria(self, def _validate_parallel_processing(self, parallel_processing): + """ + Validate the ``parallel_processing`` parameter and store the + parsed value on ``self.parallel_processing``. Supported forms: + + - ``None`` or ``0``: no parallel processing. + - positive int N: use up to N threads. + - ``["thread", N]`` or ``["process", N]``: pick the executor + family and the worker count (``N`` may be a positive int or + ``None`` for the default). + + Parameters + ---------- + parallel_processing : None, int, list, or tuple + The parallel processing specification. + + Raises + ------ + TypeError + If ``parallel_processing`` is of an unsupported type. + ValueError + If the first element is not ``"process"`` / ``"thread"``, + the worker count is invalid, or the list length is not 2. + """ # Validate the parallel_processing parameter. if parallel_processing is None: self.parallel_processing = None @@ -1306,6 +1861,41 @@ def _validate_footer(self, mutation_num_genes, save_best_solutions, save_solutions): + """ + Validate the last group of parameters and store them on the + GA instance: ``num_generations``, ``save_best_solutions``, + and ``save_solutions``. Also re-checks the + ``mutation_percent_genes`` / ``mutation_num_genes`` pair now + that ``num_genes`` has been resolved. + + Parameters + ---------- + num_generations : int + Number of generations to evolve. + parent_selection_type : str or callable + The selection operator name (used for context-specific + warnings). + mutation_percent_genes : numeric or 'default' + Percentage of genes to mutate, kept for back-compatibility. + mutation_num_genes : int, list, tuple, or None + Number of genes to mutate per solution, kept for the + same reason. + save_best_solutions : bool + If True, the best solution of every generation is saved + in ``self.best_solutions``. + save_solutions : bool + If True, every solution of every generation is saved in + ``self.solutions``. + + Raises + ------ + TypeError + If ``num_generations`` is not an integer, or + ``save_best_solutions`` / ``save_solutions`` is not a + bool. + ValueError + If ``num_generations`` is negative. + """ # Validate num_generations if type(num_generations) in self.supported_int_types: @@ -1341,6 +1931,14 @@ def _validate_footer(self, # The number of completed generations. self.generations_completed = 0 + # Counts how many times the fitness function was called inside + # the current run(). Used by the "evaluations_" stop + # criterion. Reset to 0 at the start of each run() call. + self.num_fitness_evaluations = 0 + # Time at which the current run() call started. Used by the + # "time_" stop criterion. None outside of run(). + self.run_start_time = None + # At this point, all necessary parameters validation is done successfully, and we are sure that the parameters are valid. # Set to True when all the parameters passed in the GA class constructor are valid. self.valid_parameters = True @@ -1399,10 +1997,13 @@ def validate_parameters(self, keep_parents, keep_elitism, K_tournament, + nsga3_num_divisions, crossover_type, crossover_probability, + sbx_crossover_eta, mutation_type, mutation_probability, + polynomial_mutation_eta, mutation_by_replacement, mutation_percent_genes, mutation_num_genes, @@ -1426,6 +2027,28 @@ def validate_parameters(self, parallel_processing, random_seed, logger): + """ + Validate every parameter passed to ``pygad.GA.__init__`` and + store the parsed values on the GA instance. This method is + called from the constructor; users rarely need to call it + directly. + + Validation is split into a sequence of smaller methods + (``_validate_header``, ``_validate_gene_space``, etc.); see + their docstrings for the details of each parameter. + + Sets ``self.valid_parameters = True`` when every check + passes. When a check fails, the method sets + ``self.valid_parameters = False`` and raises the appropriate + exception so the caller never sees a partially-constructed + instance. + + Raises + ------ + TypeError, ValueError + Propagated from the per-group validators when a parameter + is of the wrong type or out of range. + """ self._validate_header(logger, random_seed, @@ -1480,17 +2103,20 @@ def validate_parameters(self, self.num_parents_mating = num_parents_mating self._validate_crossover(crossover_type, - crossover_probability) + crossover_probability, + sbx_crossover_eta=sbx_crossover_eta) mutation_num_genes, mutation_percent_genes = self._validate_mutation(mutation_type, mutation_probability, mutation_num_genes, - mutation_percent_genes) + mutation_percent_genes, + polynomial_mutation_eta=polynomial_mutation_eta) parent_selection_type = self._validate_parent_selection(parent_selection_type, K_tournament, keep_parents, - keep_elitism) + keep_elitism, + nsga3_num_divisions) self._validate_fitness_func(fitness_func, fitness_batch_size) @@ -1515,6 +2141,31 @@ def validate_parameters(self, save_solutions) def validate_multi_stop_criteria(self, stop_word, number): + """ + Validate one ``(keyword, value)`` element of a + multi-objective stop criterion. Only the ``"reach"`` keyword + accepts multiple numeric values (one per objective). + + Parameters + ---------- + stop_word : str + The criterion keyword. Must be ``"reach"`` to be valid for + the multi-objective case. + number : str + The numeric value (as it appeared in the criterion + string). The method parses it into a float. + + Returns + ------- + number : float + The parsed numeric value. + + Raises + ------ + ValueError + If ``stop_word`` is not ``"reach"``, or ``number`` is not + a numeric string. + """ if stop_word == 'reach': pass else: diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index 73842f1..345b467 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -7,11 +7,17 @@ import pygad def get_matplotlib(): - # Importing matplotlib.pyplot at the module scope causes performance issues. - # This causes matplotlib.pyplot to be imported once pygad is imported. - # An efficient approach is to import matplotlib.pyplot only when needed. - # Inside each function, call get_matplotlib() to return the library object. - # If a function called get_matplotlib() once, then the library object is reused. + """ + Lazy-import ``matplotlib.pyplot``. Importing it at module scope + would force every PyGAD user to pay the matplotlib import cost + even when no plot is ever drawn. The plot methods call this + helper instead so the import happens on first use. + + Returns + ------- + matplt : module + The imported ``matplotlib.pyplot`` module. + """ import matplotlib.pyplot as matplt return matplt @@ -32,20 +38,47 @@ def plot_fitness(self, save_dir=None): """ - Creates, shows, and returns a figure that summarizes how the fitness value evolved by generation. Can only be called after completing at least 1 generation. If no generation is completed, an exception is raised. - - Accepts the following: - title: Figure title. - xlabel: Label on the X-axis. - ylabel: Label on the Y-axis. - linewidth: Line width of the plot. Defaults to 3. - font_size: Font size for the labels and title. Defaults to 14. Can be a list/tuple/numpy.ndarray if the problem is multi-objective optimization. - plot_type: Type of the plot which can be either "plot" (default), "scatter", or "bar". - color: Color of the plot which defaults to "#64f20c". Can be a list/tuple/numpy.ndarray if the problem is multi-objective optimization. - label: The label used for the legend in the figures of multi-objective problems. It is not used for single-objective problems. - save_dir: Directory to save the figure. - - Returns the figure. + Draw, show, and return a figure that traces the best fitness + across generations. For multi-objective problems, one curve + per objective is drawn on the same axes. + + Must be called after at least one generation has completed; + otherwise it raises ``RuntimeError``. + + Parameters + ---------- + title : str + Figure title. + xlabel : str + X-axis label. + ylabel : str + Y-axis label. + linewidth : numeric or iterable + Line width. Pass an iterable in multi-objective mode to + give each objective its own width. + font_size : numeric + Font size used for the title and axis labels. + plot_type : str + One of ``"plot"``, ``"scatter"``, or ``"bar"``. + color : str or iterable + Curve color. Pass an iterable in multi-objective mode to + color each objective independently. + label : iterable or None + Per-objective legend label for multi-objective problems. + Ignored for single-objective problems. + save_dir : str or None + If set, the figure is saved to this path before being + shown. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure that was created. + + Raises + ------ + RuntimeError + If no generation has completed yet. """ if self.generations_completed < 1: @@ -140,19 +173,44 @@ def plot_new_solution_rate(self, save_dir=None): """ - Creates, shows, and returns a figure that summarizes the rate of exploring new solutions. This method works only when save_solutions=True in the constructor of the pygad.GA class. - - Accepts the following: - title: Figure title. - xlabel: Label on the X-axis. - ylabel: Label on the Y-axis. - linewidth: Line width of the plot. Defaults to 3. - font_size: Font size for the labels and title. Defaults to 14. - plot_type: Type of the plot which can be either "plot" (default), "scatter", or "bar". - color: Color of the plot which defaults to "#64f20c". - save_dir: Directory to save the figure. - - Returns the figure. + Draw, show, and return a figure that plots how many new + (previously unseen) solutions appear in each generation. A + flat curve means the population is repeating itself; a high + curve means it is still exploring. + + Requires ``save_solutions=True`` in the GA constructor and at + least one completed generation. + + Parameters + ---------- + title : str + Figure title. + xlabel : str + X-axis label. + ylabel : str + Y-axis label. + linewidth : numeric + Line width of the curve. + font_size : numeric + Font size for title and axis labels. + plot_type : str + One of ``"plot"``, ``"scatter"``, or ``"bar"``. + color : str + Curve color. + save_dir : str or None + If set, the figure is saved to this path before being + shown. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure that was created. + + Raises + ------ + RuntimeError + If no generation has completed yet, or if + ``save_solutions`` is False. """ if self.generations_completed < 1: @@ -214,24 +272,56 @@ def plot_genes(self, save_dir=None): """ - Creates, shows, and returns a figure with number of subplots equal to the number of genes. Each subplot shows the gene value for each generation. - This method works only when save_solutions=True in the constructor of the pygad.GA class. - It also works only after completing at least 1 generation. If no generation is completed, an exception is raised. - - Accepts the following: - title: Figure title. - xlabel: Label on the X-axis. - ylabel: Label on the Y-axis. - linewidth: Line width of the plot. Defaults to 3. - font_size: Font size for the labels and title. Defaults to 14. - plot_type: Type of the plot which can be either "plot" (default), "scatter", or "bar". - graph_type: Type of the graph which can be either "plot" (default), "boxplot", or "histogram". - fill_color: Fill color of the graph which defaults to "#64f20c". This has no effect if graph_type="plot". - color: Color of the plot which defaults to "black". - solutions: Defaults to "all" which means use all solutions. If "best" then only the best solutions are used. - save_dir: Directory to save the figure. - - Returns the figure. + Draw, show, and return a figure with one subplot per gene, + showing how that gene's value drifts across generations. The + plot can be drawn as a line ("plot"), as a boxplot per gene, + or as a histogram of values per gene. + + Requires ``save_solutions=True`` (when ``solutions="all"``) + or ``save_best_solutions=True`` (when ``solutions="best"``) + in the GA constructor and at least one completed generation. + + Parameters + ---------- + title : str + Figure title. + xlabel : str + X-axis label (used by the boxplot view). + ylabel : str + Y-axis label (used by the boxplot view). + linewidth : numeric + Line width. + font_size : numeric + Font size for the title and labels. + plot_type : str + One of ``"plot"``, ``"scatter"``, or ``"bar"``. Used when + ``graph_type="plot"``. + graph_type : str + One of ``"plot"``, ``"boxplot"``, or ``"histogram"``. + fill_color : str + Fill color for the graph (curves, bars or boxes). + color : str + Outline / accent color, mainly used by the boxplot view + for the whiskers, caps and medians. + solutions : str + ``"all"`` to plot every saved solution; ``"best"`` to plot + only the best solution of each generation. + save_dir : str or None + If set, the figure is saved to this path before being + shown. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure that was created. + + Raises + ------ + RuntimeError + If no generation has completed yet, if the required + ``save_solutions`` / ``save_best_solutions`` flag is + False, or if ``solutions`` is anything other than + ``"all"`` / ``"best"``. """ if self.generations_completed < 1: @@ -318,7 +408,9 @@ def plot_genes(self, matplt.tight_layout() elif graph_type == "boxplot": - fig = matplt.figure(1, figsize=(0.7*self.num_genes, 6)) + # Width scales with the number of genes so the boxes do not + # crowd, but never shrinks below a readable default. + fig = matplt.figure(figsize=(max(8, 0.7 * self.num_genes), 5)) # Create an axes instance ax = fig.add_subplot(111) @@ -413,12 +505,13 @@ def plot_genes(self, return fig - def plot_pareto_front_curve(self, - title="Pareto Front Curve", - xlabel="Objective 1", - ylabel="Objective 2", - linewidth=3, - font_size=14, + def plot_pareto_front_curve(self, + title="Pareto Front Curve", + xlabel="Objective 1", + ylabel="Objective 2", + zlabel="Objective 3", + linewidth=3, + font_size=14, label="Pareto Front", color="#FF6347", color_fitness="#4169E1", @@ -427,92 +520,700 @@ def plot_pareto_front_curve(self, marker="o", save_dir=None): """ - Creates, shows, and returns the pareto front curve. Can only be used with multi-objective problems. - It only works with 2 objectives. - It also works only after completing at least 1 generation. If no generation is completed, an exception is raised. - - Accepts the following: - title: Figure title. - xlabel: Label on the X-axis. - ylabel: Label on the Y-axis. - linewidth: Line width of the plot. Defaults to 3. - font_size: Font size for the labels and title. Defaults to 14. - label: The label used for the legend. - color: Color of the plot. - color_fitness: Color of the fitness points. - grid: Either True or False to control the visibility of the grid. - alpha: The transparency of the pareto front curve. - marker: The marker of the fitness points. - save_dir: Directory to save the figure. - - Returns the figure. + Show the Pareto front of the current population. + + For 2 objectives: scatter of the population plus a curve + through the non-dominated points. For 3 objectives: 3D + scatter with the non-dominated points highlighted. + + For 4 or more objectives, 2D / 3D scatter no longer reads + well. Use ``plot_pareto_front_pcp``, ``plot_pareto_front_scatter_matrix``, + or ``plot_pareto_front_heatmap`` instead. + + Parameters + ---------- + title : str + Figure title. + xlabel, ylabel, zlabel : str + Axis labels. ``zlabel`` is only used for 3 objectives. + linewidth : numeric + Line width (2D mode). + font_size : numeric + Font size for the title and axis labels. + label : str + Legend label for the Pareto front. + color : str + Colour of the Pareto curve (2D) or non-dominated markers (3D). + color_fitness : str + Colour of the population scatter points. + grid : bool + Draw grid lines. + alpha : float + Transparency of the Pareto curve / population markers. + marker : str + Marker style for the scatter points. + save_dir : str or None + If set, saves the figure to this path before showing it. + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed, the problem is + single-objective, or M > 3. """ if self.generations_completed < 1: - self.logger.error("The plot_pareto_front_curve() method can only be called after completing at least 1 generation but ({self.generations_completed}) is completed.") - raise RuntimeError("The plot_pareto_front_curve() method can only be called after completing at least 1 generation but ({self.generations_completed}) is completed.") + self.logger.error(f"The plot_pareto_front_curve() method can only be called after completing at least 1 generation but ({self.generations_completed}) is completed.") + raise RuntimeError(f"The plot_pareto_front_curve() method can only be called after completing at least 1 generation but ({self.generations_completed}) is completed.") - if type(self.best_solutions_fitness[0]) in [list, tuple, numpy.ndarray] and len(self.best_solutions_fitness[0]) > 1: - # Multi-objective optimization problem. - if len(self.best_solutions_fitness[0]) == 2: - # Only 2 objectives. Proceed. - pass - else: - # More than 2 objectives. - self.logger.error(f"The plot_pareto_front_curve() method only supports 2 objectives but there are {self.best_solutions_fitness[0]} objectives.") - raise RuntimeError(f"The plot_pareto_front_curve() method only supports 2 objectives but there are {self.best_solutions_fitness[0]} objectives.") + num_objectives = self._num_objectives_or_raise("plot_pareto_front_curve()") + + if num_objectives not in (2, 3): + self.logger.error(f"The plot_pareto_front_curve() method supports 2 or 3 objectives but there are {num_objectives}. For higher dimensions use plot_pareto_front_pcp(), plot_pareto_front_scatter_matrix(), or plot_pareto_front_heatmap().") + raise RuntimeError(f"The plot_pareto_front_curve() method supports 2 or 3 objectives but there are {num_objectives}. For higher dimensions use plot_pareto_front_pcp(), plot_pareto_front_scatter_matrix(), or plot_pareto_front_heatmap().") + + last_fitness = numpy.asarray(self.last_generation_fitness) + non_dominated_fitness = self._last_generation_pareto_front() + + matplt = get_matplotlib() + + if num_objectives == 2: + fig = matplt.figure() + matplt.scatter(last_fitness[:, 0], + last_fitness[:, 1], + marker=marker, + color=color_fitness, + label='Fitness', + alpha=1.0) + order = numpy.argsort(non_dominated_fitness[:, 0]) + matplt.plot(non_dominated_fitness[order, 0], + non_dominated_fitness[order, 1], + marker=marker, + label=label, + alpha=alpha, + color=color, + linewidth=linewidth) + matplt.title(title, fontsize=font_size) + matplt.xlabel(xlabel, fontsize=font_size) + matplt.ylabel(ylabel, fontsize=font_size) + matplt.legend() + matplt.grid(grid) else: - # Single-objective optimization problem. - self.logger.error("The plot_pareto_front_curve() method only works with multi-objective optimization problems.") - raise RuntimeError("The plot_pareto_front_curve() method only works with multi-objective optimization problems.") + fig = matplt.figure() + ax = fig.add_subplot(111, projection='3d') + ax.scatter(last_fitness[:, 0], + last_fitness[:, 1], + last_fitness[:, 2], + marker=marker, + color=color_fitness, + label='Fitness', + alpha=0.6) + ax.scatter(non_dominated_fitness[:, 0], + non_dominated_fitness[:, 1], + non_dominated_fitness[:, 2], + marker=marker, + color=color, + label=label, + alpha=alpha, + s=60) + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + ax.set_zlabel(zlabel, fontsize=font_size) + ax.legend() + if grid: + ax.grid(True) - # Plot the pareto front curve. - remaining_set = list(zip(range(0, self.last_generation_fitness.shape[0]), self.last_generation_fitness)) - # The non-dominated set is the pareto front set. - dominated_set, non_dominated_set = self.get_non_dominated_set(remaining_set) + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') - # Extract the fitness values (objective values) of the non-dominated solutions for plotting. - pareto_front_x = [self.last_generation_fitness[item[0]][0] for item in non_dominated_set] - pareto_front_y = [self.last_generation_fitness[item[0]][1] for item in non_dominated_set] + matplt.show() + + return fig - # Sort the Pareto front solutions (optional but can make the plot cleaner) - sorted_pareto_front = sorted(zip(pareto_front_x, pareto_front_y)) + # ── Helpers shared by the Pareto-front plots ───────────────────────────── + + def _num_objectives_or_raise(self, method_name): + """ + Returns M for a MOO problem and raises for SOO. Used as the + first guard inside every MOO-only plot method. + """ + first_best = self.best_solutions_fitness[0] + if type(first_best) in [list, tuple, numpy.ndarray] and len(first_best) > 1: + return len(first_best) + self.logger.error(f"The {method_name} method only works with multi-objective optimization problems.") + raise RuntimeError(f"The {method_name} method only works with multi-objective optimization problems.") + + def _last_generation_pareto_front(self): + """ + Returns the non-dominated rows of last_generation_fitness as a + 2D numpy array. Order matches the order returned by + get_non_dominated_set, which is the order the solutions appear + in the population. + """ + last_fitness = numpy.asarray(self.last_generation_fitness) + remaining_set = list(zip(range(last_fitness.shape[0]), last_fitness)) + _, non_dominated_set = self.get_non_dominated_set(remaining_set) + indices = [item[0] for item in non_dominated_set] + return last_fitness[indices] + + def _require_save_solutions(self, method_name): + """Raise unless the GA was constructed with save_solutions=True.""" + if not self.save_solutions: + self.logger.error(f"The {method_name} method requires save_solutions=True in the pygad.GA constructor.") + raise RuntimeError(f"The {method_name} method requires save_solutions=True in the pygad.GA constructor.") + + def _per_generation_fitness(self): + """ + Return a list of length (generations_completed + 1) where + each entry is the fitness array of one generation. Only valid + when save_solutions=True. + """ + per_gen = [] + fitness_flat = numpy.asarray(self.solutions_fitness) + sol_per_pop = self.sol_per_pop + num_blocks = fitness_flat.shape[0] // sol_per_pop + for g in range(num_blocks): + per_gen.append(fitness_flat[g * sol_per_pop:(g + 1) * sol_per_pop]) + return per_gen + + def _per_generation_solutions(self): + """ + Return a list of length (generations_completed + 1) where + each entry is the population array of one generation. Only + valid when save_solutions=True. + """ + per_gen = [] + solutions_flat = numpy.asarray(self.solutions, dtype=float) + sol_per_pop = self.sol_per_pop + num_blocks = solutions_flat.shape[0] // sol_per_pop + for g in range(num_blocks): + per_gen.append(solutions_flat[g * sol_per_pop:(g + 1) * sol_per_pop]) + return per_gen + + # ── Pareto-front views for M >= 3 ──────────────────────────────────────── + + def plot_pareto_front_pcp(self, + title="Pareto Front - Parallel Coordinates", + xlabel="Objective", + ylabel="Normalised value", + linewidth=1.5, + font_size=14, + color="#4169E1", + alpha=0.6, + grid=True, + save_dir=None): + """ + Parallel-coordinates plot of the final non-dominated set. + + Every objective gets a vertical axis. Each non-dominated + solution becomes a polyline that crosses all axes. Values are + normalized per objective so axes with very different ranges + stay comparable. + + Works for any M >= 2. + + Parameters + ---------- + title, xlabel, ylabel : str + linewidth : numeric + font_size : numeric + color : str + Polyline color. + alpha : float + grid : bool + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed or the problem is + single-objective. + """ + if self.generations_completed < 1: + self.logger.error("The plot_pareto_front_pcp() method requires at least one completed generation.") + raise RuntimeError("The plot_pareto_front_pcp() method requires at least one completed generation.") + num_objectives = self._num_objectives_or_raise("plot_pareto_front_pcp()") + + front = self._last_generation_pareto_front() + min_per_obj = front.min(axis=0) + max_per_obj = front.max(axis=0) + spread = numpy.where(max_per_obj > min_per_obj, max_per_obj - min_per_obj, 1.0) + normalized = (front - min_per_obj) / spread matplt = get_matplotlib() + fig, ax = matplt.subplots() + x_axis = numpy.arange(num_objectives) + for row in normalized: + ax.plot(x_axis, row, color=color, alpha=alpha, linewidth=linewidth) + ax.set_xticks(x_axis) + ax.set_xticklabels([f"f{i + 1}" for i in range(num_objectives)], fontsize=font_size) + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + ax.grid(grid) - # Plotting - fig = matplt.figure() - # First, plot the scatter of all points (population) - all_points_x = [self.last_generation_fitness[i][0] for i in range(self.sol_per_pop)] - all_points_y = [self.last_generation_fitness[i][1] for i in range(self.sol_per_pop)] - matplt.scatter(all_points_x, - all_points_y, - marker=marker, - color=color_fitness, - label='Fitness', - alpha=1.0) - - # Then, plot the Pareto front as a curve - pareto_front_x_sorted, pareto_front_y_sorted = zip(*sorted_pareto_front) - matplt.plot(pareto_front_x_sorted, - pareto_front_y_sorted, - marker=marker, - label=label, - alpha=alpha, - color=color, - linewidth=linewidth) + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() + return fig - matplt.title(title, fontsize=font_size) - matplt.xlabel(xlabel, fontsize=font_size) - matplt.ylabel(ylabel, fontsize=font_size) - matplt.legend() + def plot_pareto_front_scatter_matrix(self, + title="Pareto Front - Scatter Matrix", + font_size=14, + color="#4169E1", + marker="o", + alpha=0.6, + grid=True, + save_dir=None): + """ + M-by-M grid of pairwise scatter plots for the final + non-dominated set. The diagonal shows a histogram of each + objective's values. Helpful when M >= 4 and a single 3D + scatter no longer reads well. - matplt.grid(grid) + Parameters + ---------- + title : str + font_size : numeric + color : str + marker : str + alpha : float + grid : bool + save_dir : str or None - if not save_dir is None: - matplt.savefig(fname=save_dir, - bbox_inches='tight') + Returns + ------- + fig : matplotlib.figure.Figure + Raises + ------ + RuntimeError + If no generation has completed or the problem is + single-objective. + """ + if self.generations_completed < 1: + self.logger.error("The plot_pareto_front_scatter_matrix() method requires at least one completed generation.") + raise RuntimeError("The plot_pareto_front_scatter_matrix() method requires at least one completed generation.") + num_objectives = self._num_objectives_or_raise("plot_pareto_front_scatter_matrix()") + front = self._last_generation_pareto_front() + + matplt = get_matplotlib() + fig, axes = matplt.subplots(num_objectives, num_objectives, + figsize=(3 * num_objectives, 3 * num_objectives)) + if num_objectives == 1: + axes = numpy.array([[axes]]) + for i in range(num_objectives): + for j in range(num_objectives): + ax = axes[i, j] + if i == j: + ax.hist(front[:, i], color=color, alpha=alpha) + else: + ax.scatter(front[:, j], front[:, i], + color=color, marker=marker, alpha=alpha) + if i == num_objectives - 1: + ax.set_xlabel(f"f{j + 1}", fontsize=font_size) + if j == 0: + ax.set_ylabel(f"f{i + 1}", fontsize=font_size) + ax.grid(grid) + fig.suptitle(title, fontsize=font_size) + fig.tight_layout() + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') matplt.show() + return fig + def plot_pareto_front_heatmap(self, + title="Pareto Front - Heatmap", + xlabel="Objective", + ylabel="Solution", + font_size=14, + cmap="viridis", + sort_by=0, + save_dir=None): + """ + Heatmap of the final non-dominated set. Rows are solutions, + columns are objectives, color is the (raw) objective value. + + Parameters + ---------- + title, xlabel, ylabel : str + font_size : numeric + cmap : str + Matplotlib colormap name. + sort_by : int or None + Objective index to sort rows by (ascending). Pass ``None`` + to keep the original order. + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed or the problem is + single-objective. + ValueError + If ``sort_by`` is out of range. + """ + if self.generations_completed < 1: + self.logger.error("The plot_pareto_front_heatmap() method requires at least one completed generation.") + raise RuntimeError("The plot_pareto_front_heatmap() method requires at least one completed generation.") + num_objectives = self._num_objectives_or_raise("plot_pareto_front_heatmap()") + + front = self._last_generation_pareto_front() + if sort_by is not None: + if not (0 <= sort_by < num_objectives): + raise ValueError( + f"sort_by must be an integer in [0, {num_objectives - 1}], " + f"but got {sort_by}.") + order = numpy.argsort(front[:, sort_by]) + front = front[order] + + matplt = get_matplotlib() + fig, ax = matplt.subplots() + image = ax.imshow(front, aspect='auto', cmap=cmap) + ax.set_xticks(numpy.arange(num_objectives)) + ax.set_xticklabels([f"f{i + 1}" for i in range(num_objectives)]) + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + fig.colorbar(image, ax=ax) + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() + return fig + + # ── Per-generation diagnostics (need save_solutions=True) ──────────────── + + def plot_fitness_band(self, + title="PyGAD - Population fitness band", + xlabel="Generation", + ylabel="Fitness", + font_size=14, + color="#4169E1", + band_alpha=0.2, + linewidth=2, + objective_index=0, + grid=True, + save_dir=None): + """ + Per-generation min / mean / max fitness with a shaded band + between min and max. For MOO problems, picks one objective + via ``objective_index`` (default 0). + + Requires ``save_solutions=True``. + + Parameters + ---------- + title, xlabel, ylabel : str + font_size : numeric + color : str + band_alpha : float + Transparency of the shaded min-max band. + linewidth : numeric + objective_index : int + Which objective to plot for MOO problems. Ignored for SOO. + grid : bool + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed or save_solutions is False. + ValueError + If ``objective_index`` is out of range for the problem. + """ + if self.generations_completed < 1: + self.logger.error("The plot_fitness_band() method requires at least one completed generation.") + raise RuntimeError("The plot_fitness_band() method requires at least one completed generation.") + self._require_save_solutions("plot_fitness_band()") + + per_gen = self._per_generation_fitness() + first = numpy.asarray(per_gen[0]) + is_moo = first.ndim == 2 and first.shape[1] > 1 + if is_moo: + num_objectives = first.shape[1] + if not (0 <= objective_index < num_objectives): + raise ValueError( + f"objective_index must be in [0, {num_objectives - 1}], " + f"but got {objective_index}.") + min_vals = [gen[:, objective_index].min() for gen in per_gen] + mean_vals = [gen[:, objective_index].mean() for gen in per_gen] + max_vals = [gen[:, objective_index].max() for gen in per_gen] + else: + min_vals = [gen.min() for gen in per_gen] + mean_vals = [gen.mean() for gen in per_gen] + max_vals = [gen.max() for gen in per_gen] + + matplt = get_matplotlib() + fig, ax = matplt.subplots() + generations = numpy.arange(len(per_gen)) + ax.fill_between(generations, min_vals, max_vals, + color=color, alpha=band_alpha, label='min-max') + ax.plot(generations, mean_vals, + color=color, linewidth=linewidth, label='mean') + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + ax.legend() + ax.grid(grid) + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() + return fig + + def plot_non_dominated_hypervolume(self, + reference_point=None, + title="PyGAD - Hypervolume per generation", + xlabel="Generation", + ylabel="Hypervolume", + font_size=14, + color="#4169E1", + linewidth=2, + grid=True, + save_dir=None): + """ + Hypervolume of the non-dominated set per generation. + + Requires ``save_solutions=True``. Uses + ``pygad.utils.quality_indicators.hypervolume``. + + Parameters + ---------- + reference_point : array-like or None + Reference point passed to the hypervolume function. Must + be smaller than every fitness value on every objective. + If ``None``, uses ``min(per_gen_fitness) - 0.1`` across all + saved generations, which is usually a safe default. + title, xlabel, ylabel : str + font_size : numeric + color : str + linewidth : numeric + grid : bool + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed, the problem is + single-objective, or save_solutions is False. + """ + if self.generations_completed < 1: + self.logger.error("The plot_non_dominated_hypervolume() method requires at least one completed generation.") + raise RuntimeError("The plot_non_dominated_hypervolume() method requires at least one completed generation.") + self._num_objectives_or_raise("plot_non_dominated_hypervolume()") + self._require_save_solutions("plot_non_dominated_hypervolume()") + + from pygad.utils.quality_indicators import hypervolume + + per_gen = self._per_generation_fitness() + all_fitness = numpy.vstack([numpy.asarray(g) for g in per_gen]) + if reference_point is None: + reference_point = all_fitness.min(axis=0) - 0.1 + reference_point = numpy.asarray(reference_point, dtype=float) + + hv_values = [hypervolume(numpy.asarray(g), reference_point) for g in per_gen] + + matplt = get_matplotlib() + fig, ax = matplt.subplots() + generations = numpy.arange(len(hv_values)) + ax.plot(generations, hv_values, color=color, linewidth=linewidth) + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + ax.grid(grid) + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() + return fig + + def plot_population_diversity(self, + title="PyGAD - Population diversity", + xlabel="Generation", + ylabel="Mean pairwise distance", + font_size=14, + color="#4169E1", + linewidth=2, + grid=True, + save_dir=None): + """ + Mean pairwise Euclidean distance between solutions per + generation. A drop signals the population is converging or + collapsing into a few duplicates. + + Requires ``save_solutions=True``. + + Parameters + ---------- + title, xlabel, ylabel : str + font_size : numeric + color : str + linewidth : numeric + grid : bool + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed or save_solutions is False. + """ + if self.generations_completed < 1: + self.logger.error("The plot_population_diversity() method requires at least one completed generation.") + raise RuntimeError("The plot_population_diversity() method requires at least one completed generation.") + self._require_save_solutions("plot_population_diversity()") + + per_gen = self._per_generation_solutions() + diversity = [] + for population in per_gen: + diff = population[:, None, :] - population[None, :, :] + distances = numpy.sqrt((diff * diff).sum(axis=2)) + # Mean over the upper triangle (each pair counted once). + n = distances.shape[0] + if n < 2: + diversity.append(0.0) + continue + upper = distances[numpy.triu_indices(n, k=1)] + diversity.append(float(upper.mean())) + + matplt = get_matplotlib() + fig, ax = matplt.subplots() + generations = numpy.arange(len(diversity)) + ax.plot(generations, diversity, color=color, linewidth=linewidth) + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + ax.grid(grid) + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() + return fig + + def plot_pareto_front_evolution(self, + every_k=1, + title="Pareto Front Evolution", + xlabel="Objective 1", + ylabel="Objective 2", + zlabel="Objective 3", + font_size=14, + cmap="viridis", + marker="o", + alpha=0.7, + grid=True, + save_dir=None): + """ + Overlay the non-dominated set every ``every_k`` generations. + Colour goes from early generations to late so you can see the + front converging. + + Works for 2 or 3 objectives. Requires ``save_solutions=True``. + + Parameters + ---------- + every_k : int + Plot every k-th generation. ``every_k=1`` plots all of them. + title, xlabel, ylabel, zlabel : str + font_size : numeric + cmap : str + marker : str + alpha : float + grid : bool + save_dir : str or None + + Returns + ------- + fig : matplotlib.figure.Figure + + Raises + ------ + RuntimeError + If no generation has completed, the problem is + single-objective, save_solutions is False, or M > 3. + ValueError + If ``every_k`` is not a positive integer. + """ + if self.generations_completed < 1: + self.logger.error("The plot_pareto_front_evolution() method requires at least one completed generation.") + raise RuntimeError("The plot_pareto_front_evolution() method requires at least one completed generation.") + num_objectives = self._num_objectives_or_raise("plot_pareto_front_evolution()") + if num_objectives not in (2, 3): + self.logger.error(f"The plot_pareto_front_evolution() method supports 2 or 3 objectives but there are {num_objectives}.") + raise RuntimeError(f"The plot_pareto_front_evolution() method supports 2 or 3 objectives but there are {num_objectives}.") + self._require_save_solutions("plot_pareto_front_evolution()") + if not (isinstance(every_k, int) and every_k > 0): + raise ValueError(f"every_k must be a positive integer, but got {every_k}.") + + per_gen = self._per_generation_fitness() + # Pick generations to draw. Always include the last one. + indices = list(range(0, len(per_gen), every_k)) + if indices[-1] != len(per_gen) - 1: + indices.append(len(per_gen) - 1) + + matplt = get_matplotlib() + colormap = matplt.get_cmap(cmap) + fig = matplt.figure() + if num_objectives == 2: + ax = fig.add_subplot(111) + else: + ax = fig.add_subplot(111, projection='3d') + + for plot_idx, gen_idx in enumerate(indices): + fraction = plot_idx / max(len(indices) - 1, 1) + color = colormap(fraction) + gen_fitness = numpy.asarray(per_gen[gen_idx]) + remaining_set = list(zip(range(gen_fitness.shape[0]), gen_fitness)) + _, non_dominated_set = self.get_non_dominated_set(remaining_set) + front_indices = [item[0] for item in non_dominated_set] + front = gen_fitness[front_indices] + if num_objectives == 2: + ax.scatter(front[:, 0], front[:, 1], + color=color, marker=marker, alpha=alpha, + label=f"gen {gen_idx}") + else: + ax.scatter(front[:, 0], front[:, 1], front[:, 2], + color=color, marker=marker, alpha=alpha, + label=f"gen {gen_idx}") + + ax.set_title(title, fontsize=font_size) + ax.set_xlabel(xlabel, fontsize=font_size) + ax.set_ylabel(ylabel, fontsize=font_size) + if num_objectives == 3: + ax.set_zlabel(zlabel, fontsize=font_size) + if len(indices) <= 8: + ax.legend() + if num_objectives == 2: + ax.grid(grid) + elif grid: + ax.grid(True) + + if save_dir is not None: + matplt.savefig(fname=save_dir, bbox_inches='tight') + matplt.show() return fig diff --git a/pygad_logo/pygad_icon.svg b/pygad_logo/pygad_icon.svg new file mode 100644 index 0000000..c3e71e0 --- /dev/null +++ b/pygad_logo/pygad_icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygad_logo/pygad_icon_1024.png b/pygad_logo/pygad_icon_1024.png new file mode 100644 index 0000000..8e2bb5d Binary files /dev/null and b/pygad_logo/pygad_icon_1024.png differ diff --git a/pygad_logo/pygad_icon_2048.png b/pygad_logo/pygad_icon_2048.png new file mode 100644 index 0000000..04b65da Binary files /dev/null and b/pygad_logo/pygad_icon_2048.png differ diff --git a/pygad_logo/pygad_icon_256.png b/pygad_logo/pygad_icon_256.png new file mode 100644 index 0000000..48207f8 Binary files /dev/null and b/pygad_logo/pygad_icon_256.png differ diff --git a/pygad_logo/pygad_icon_4096.png b/pygad_logo/pygad_icon_4096.png new file mode 100644 index 0000000..d96cc35 Binary files /dev/null and b/pygad_logo/pygad_icon_4096.png differ diff --git a/pygad_logo/pygad_icon_512.png b/pygad_logo/pygad_icon_512.png new file mode 100644 index 0000000..f26e307 Binary files /dev/null and b/pygad_logo/pygad_icon_512.png differ diff --git a/pygad_logo/pygad_logo.svg b/pygad_logo/pygad_logo.svg new file mode 100644 index 0000000..32b3484 --- /dev/null +++ b/pygad_logo/pygad_logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygad_logo/pygad_logo_4k.png b/pygad_logo/pygad_logo_4k.png new file mode 100644 index 0000000..a22ee40 Binary files /dev/null and b/pygad_logo/pygad_logo_4k.png differ diff --git a/pyproject.toml b/pyproject.toml index 059b331..aeead8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ [project.optional-dependencies] deep_learning = ["keras", "tensorflow", "torch"] visualize = ["matplotlib"] +report = ["matplotlib", "reportlab"] # PyTest Configuration. Later, PyTest will support the [tool.pytest] table. [tool.pytest.ini_options] diff --git a/setup_venv.sh b/setup_venv.sh new file mode 100755 index 0000000..768b773 --- /dev/null +++ b/setup_venv.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Set up a virtual environment for PyGAD. +# +# Usage: +# ./setup_venv.sh +# PYTHON=python3.12 ./setup_venv.sh + +set -euo pipefail + +PYTHON="${PYTHON:-python3}" +VENV_DIR=".venv" + +cd "$(dirname "$0")" + +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "Error: $PYTHON not found." >&2 + exit 1 +fi + +if [ -d "$VENV_DIR" ]; then + echo "$VENV_DIR already exists. Delete it first to rebuild: rm -rf $VENV_DIR" +else + echo "Creating $VENV_DIR with $PYTHON ..." + "$PYTHON" -m venv "$VENV_DIR" +fi + +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +python -m pip install --upgrade pip +python -m pip install -e ".[visualize]" +python -m pip install pytest + +echo "" +echo "Done. Activate with: source $VENV_DIR/bin/activate" diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 0000000..02b4db8 --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,341 @@ +import math + +import numpy +import pytest + +import pygad +from pygad.benchmarks import classic, zdt, dtlz, knapsack, tsp + + +# ── Classic single-objective problems ──────────────────────────────────────── + + +def test_sphere_global_minimum_at_origin(): + # f(0) = 0, negated -> 0. + problem = classic.Sphere(num_genes=5) + fitness = problem(None, numpy.zeros(5), 0) + assert fitness == pytest.approx(0.0, abs=1e-12) + + +def test_rastrigin_global_minimum_at_origin(): + problem = classic.Rastrigin(num_genes=4) + fitness = problem(None, numpy.zeros(4), 0) + assert fitness == pytest.approx(0.0, abs=1e-12) + + +def test_rosenbrock_global_minimum_at_ones(): + problem = classic.Rosenbrock(num_genes=4) + fitness = problem(None, numpy.ones(4), 0) + assert fitness == pytest.approx(0.0, abs=1e-12) + + +def test_griewank_global_minimum_at_origin(): + problem = classic.Griewank(num_genes=3) + fitness = problem(None, numpy.zeros(3), 0) + assert fitness == pytest.approx(0.0, abs=1e-12) + + +def test_schwefel_global_minimum_at_420(): + problem = classic.Schwefel(num_genes=3) + fitness = problem(None, numpy.full(3, 420.9687), 0) + # 420.9687 is an approximation; expect near-zero but not exact. + assert fitness == pytest.approx(0.0, abs=1e-3) + + +def test_ackley_global_minimum_at_origin(): + problem = classic.Ackley(num_genes=5) + fitness = problem(None, numpy.zeros(5), 0) + assert fitness == pytest.approx(0.0, abs=1e-9) + + +def test_himmelblau_known_global_minima_all_evaluate_to_zero(): + problem = classic.Himmelblau() + minima = numpy.array([ + [3.0, 2.0], + [-2.805118, 3.131312], + [-3.779310, -3.283186], + [3.584428, -1.848126], + ]) + for x in minima: + assert problem(None, x, 0) == pytest.approx(0.0, abs=1e-4) + + +def test_sphere_integrates_with_pygad_ga_loop(): + # End-to-end check that the problem class plugs into pygad.GA. + problem = classic.Sphere(num_genes=5) + ga = pygad.GA(num_generations=10, + num_parents_mating=6, + fitness_func=problem, + sol_per_pop=10, + num_genes=problem.num_genes, + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + random_seed=1, + suppress_warnings=True) + ga.run() + # Negated sum of squares is <= 0. + best_fitness = ga.best_solution(ga.last_generation_fitness)[1] + assert best_fitness <= 0.0 + + +# ── ZDT family ─────────────────────────────────────────────────────────────── + + +def test_zdt1_returns_two_objectives_and_pareto_front_is_convex(): + problem = zdt.ZDT1(num_genes=10) + output = problem(None, numpy.zeros(10), 0) + assert len(output) == 2 + # On the front: x_1..x_n = 0, so f1 = x_0 and f2 = 1 - sqrt(f1). + optimal_solution = numpy.array([0.5] + [0.0] * 9) + f1_max, f2_max = problem(None, optimal_solution, 0) + assert f1_max == pytest.approx(-0.5) + assert f2_max == pytest.approx(-(1.0 - math.sqrt(0.5)), abs=1e-9) + + +def test_zdt1_pareto_front_values_satisfy_curve(): + front = zdt.ZDT1().pareto_front(num_points=11) + # Negate back to min space to check f2 = 1 - sqrt(f1). + f1 = -front[:, 0] + f2 = -front[:, 1] + numpy.testing.assert_allclose(f2, 1.0 - numpy.sqrt(f1), atol=1e-9) + + +def test_zdt2_pareto_front_satisfies_curve(): + front = zdt.ZDT2().pareto_front(num_points=11) + f1 = -front[:, 0] + f2 = -front[:, 1] + numpy.testing.assert_allclose(f2, 1.0 - f1 ** 2, atol=1e-9) + + +def test_zdt3_returns_two_objectives(): + problem = zdt.ZDT3(num_genes=10) + output = problem(None, numpy.zeros(10), 0) + assert len(output) == 2 + + +def test_zdt4_returns_two_objectives_and_uses_wider_bounds(): + problem = zdt.ZDT4(num_genes=10) + assert problem.bounds == (-5.0, 5.0) + output = problem(None, numpy.zeros(10), 0) + assert len(output) == 2 + + +def test_zdt6_returns_two_objectives_with_correct_optimal_value(): + problem = zdt.ZDT6(num_genes=10) + # x_1..x_n = 0 -> g = 1, so f1 = 1 - exp(-4 x0) sin(6 pi x0)^6 and f2 = 1 - f1^2. + solution = numpy.array([0.1] + [0.0] * 9) + f1_max, f2_max = problem(None, solution, 0) + expected_f1 = 1.0 - math.exp(-0.4) * math.sin(0.6 * math.pi) ** 6 + expected_f2 = 1.0 - expected_f1 ** 2 + assert f1_max == pytest.approx(-expected_f1, abs=1e-9) + assert f2_max == pytest.approx(-expected_f2, abs=1e-9) + + +# ── DTLZ family ────────────────────────────────────────────────────────────── + + +def test_dtlz1_returns_three_objectives_on_pareto_optimal_solution(): + problem = dtlz.DTLZ1(num_objectives=3, num_distance_vars=5) + # Distance vars at 0.5 -> g = 0 -> objectives sum to 0.5. + solution = numpy.array([0.5, 0.5] + [0.5] * 5) + objectives = problem(None, solution, 0) + assert len(objectives) == 3 + assert sum(-f for f in objectives) == pytest.approx(0.5, abs=1e-12) + + +def test_dtlz2_returns_three_objectives_on_unit_sphere_pareto_solution(): + problem = dtlz.DTLZ2(num_objectives=3, num_distance_vars=10) + # Distance vars at 0.5 -> g = 0 -> solution sits on the unit sphere. + solution = numpy.array([0.3, 0.7] + [0.5] * 10) + objectives = problem(None, solution, 0) + radius_squared = sum(f ** 2 for f in objectives) + assert radius_squared == pytest.approx(1.0, abs=1e-9) + + +def test_dtlz3_uses_hard_g_function(): + # Distance vars at 0.5 -> g = 0 even for the hard g, so the front + # is again the unit sphere. + problem = dtlz.DTLZ3(num_objectives=3, num_distance_vars=10) + solution = numpy.array([0.4, 0.6] + [0.5] * 10) + objectives = problem(None, solution, 0) + radius_squared = sum(f ** 2 for f in objectives) + assert radius_squared == pytest.approx(1.0, abs=1e-9) + + +def test_dtlz4_alpha_biases_objectives_toward_one_corner(): + # Large alpha squashes position vars to ~0 unless x is near 1. + # So with x_pos = 0.5 most weight ends up on f1 (cos(~0) ~ 1). + problem = dtlz.DTLZ4(num_objectives=3, num_distance_vars=10, alpha=100.0) + solution = numpy.array([0.5, 0.5] + [0.5] * 10) + objectives = problem(None, solution, 0) + abs_objectives = [abs(f) for f in objectives] + assert abs_objectives[0] == max(abs_objectives) + + +def test_dtlz2_rejects_num_objectives_below_two(): + with pytest.raises(ValueError, match="num_objectives"): + dtlz.DTLZ2(num_objectives=1) + + +def test_benchmarks_module_is_importable_from_top_level(): + assert pygad.benchmarks.classic.Sphere(num_genes=2).num_genes == 2 + assert pygad.benchmarks.zdt.ZDT1().num_objectives == 2 + assert pygad.benchmarks.dtlz.DTLZ2().num_objectives == 3 + assert pygad.benchmarks.knapsack.Knapsack( + weights=[1.0], values=[1.0], capacity=1.0).num_genes == 1 + assert pygad.benchmarks.tsp.TSP( + coordinates=[[0.0, 0.0], [1.0, 0.0]]).num_genes == 2 + + +# ── Knapsack ───────────────────────────────────────────────────────────────── + + +def test_knapsack_returns_sum_of_chosen_values_when_feasible(): + problem = knapsack.Knapsack(weights=[2.0, 3.0, 4.0], + values=[10.0, 20.0, 30.0], + capacity=5.0) + # Pick items 0 and 1: weight = 5 (at capacity), value = 30. + fitness = problem(None, numpy.array([1, 1, 0]), 0) + assert fitness == pytest.approx(30.0) + + +def test_knapsack_at_exact_capacity_is_considered_feasible(): + problem = knapsack.Knapsack(weights=[5.0], + values=[7.0], + capacity=5.0) + assert problem(None, numpy.array([1]), 0) == pytest.approx(7.0) + + +def test_knapsack_overweight_solution_gets_negative_fitness(): + problem = knapsack.Knapsack(weights=[2.0, 3.0, 4.0], + values=[10.0, 20.0, 30.0], + capacity=5.0) + # All three items: weight = 9, over by 4. + fitness = problem(None, numpy.array([1, 1, 1]), 0) + assert fitness == pytest.approx(-4.0) + + +def test_knapsack_empty_selection_has_zero_fitness(): + problem = knapsack.Knapsack(weights=[2.0, 3.0, 4.0], + values=[10.0, 20.0, 30.0], + capacity=5.0) + assert problem(None, numpy.array([0, 0, 0]), 0) == pytest.approx(0.0) + + +def test_knapsack_rejects_mismatched_lengths(): + with pytest.raises(ValueError, match="same length"): + knapsack.Knapsack(weights=[1.0, 2.0], values=[1.0], capacity=1.0) + + +def test_knapsack_rejects_non_positive_capacity(): + with pytest.raises(ValueError, match="capacity must be positive"): + knapsack.Knapsack(weights=[1.0], values=[1.0], capacity=0.0) + + +def test_knapsack_rejects_negative_weights(): + with pytest.raises(ValueError, match="weights must be non-negative"): + knapsack.Knapsack(weights=[-1.0], values=[1.0], capacity=1.0) + + +def test_knapsack_finds_optimal_solution_on_small_instance_end_to_end(): + # Items (w, v): (2,3), (3,4), (4,5), (5,6). Capacity 5. + # Optimal: items 0 and 1 (weight 5, value 7). + problem = knapsack.Knapsack(weights=[2.0, 3.0, 4.0, 5.0], + values=[3.0, 4.0, 5.0, 6.0], + capacity=5.0) + ga = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, + random_seed=0, + suppress_warnings=True) + ga.run() + best_solution, best_fitness, _ = ga.best_solution(ga.last_generation_fitness) + assert best_fitness == pytest.approx(7.0) + numpy.testing.assert_array_equal(best_solution, [1, 1, 0, 0]) + + +# ── TSP ────────────────────────────────────────────────────────────────────── + + +def test_tsp_tour_length_on_unit_square(): + # Perimeter of the unit square is 4. + problem = tsp.TSP(coordinates=[[0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], + [0.0, 1.0]]) + assert problem.tour_length([0, 1, 2, 3]) == pytest.approx(4.0) + + +def test_tsp_fitness_is_negative_of_tour_length(): + # 3-4-5 right triangle, tour length = 3 + 4 + 5 = 12. + problem = tsp.TSP(coordinates=[[0.0, 0.0], + [3.0, 0.0], + [3.0, 4.0]]) + fitness = problem(None, numpy.array([0, 1, 2]), 0) + assert fitness == pytest.approx(-12.0) + + +def test_tsp_accepts_precomputed_distance_matrix(): + distances = numpy.array([[0.0, 2.0, 9.0], + [2.0, 0.0, 6.0], + [9.0, 6.0, 0.0]]) + problem = tsp.TSP(distance_matrix=distances) + # Tour 0-1-2: 2 + 6 + 9 = 17. + assert problem.tour_length([0, 1, 2]) == pytest.approx(17.0) + + +def test_tsp_invalid_permutation_returns_penalty(): + # Duplicate of city 0; city 3 missing. + problem = tsp.TSP(coordinates=[[0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], + [0.0, 1.0]]) + fitness = problem(None, numpy.array([0, 1, 2, 0]), 0) + assert fitness < -problem.distance_matrix.sum() + + +def test_tsp_rejects_passing_both_inputs(): + with pytest.raises(ValueError, match="exactly one"): + tsp.TSP(coordinates=[[0.0, 0.0], [1.0, 0.0]], + distance_matrix=[[0.0, 1.0], [1.0, 0.0]]) + + +def test_tsp_rejects_passing_no_inputs(): + with pytest.raises(ValueError, match="exactly one"): + tsp.TSP() + + +def test_tsp_rejects_non_square_distance_matrix(): + with pytest.raises(ValueError, match="square"): + tsp.TSP(distance_matrix=[[0.0, 1.0, 2.0], + [1.0, 0.0, 1.0]]) + + +def test_tsp_rejects_negative_distance(): + with pytest.raises(ValueError, match="non-negative"): + tsp.TSP(distance_matrix=[[0.0, -1.0], [-1.0, 0.0]]) + + +def test_tsp_finds_optimal_tour_on_small_square_end_to_end(): + # Perimeter tour has length 4; any crossing tour is longer. + problem = tsp.TSP(coordinates=[[0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], + [0.0, 1.0]]) + ga = pygad.GA(num_generations=100, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=30, + num_genes=problem.num_genes, + gene_space=problem.gene_space, + gene_type=problem.gene_type, + allow_duplicate_genes=problem.allow_duplicate_genes, + random_seed=2, + suppress_warnings=True) + ga.run() + _, best_fitness, _ = ga.best_solution(ga.last_generation_fitness) + assert best_fitness == pytest.approx(-4.0, abs=1e-6) diff --git a/tests/test_keep_parents_elitism.py b/tests/test_keep_parents_elitism.py new file mode 100644 index 0000000..0247933 --- /dev/null +++ b/tests/test_keep_parents_elitism.py @@ -0,0 +1,129 @@ +import warnings + +import numpy +import pytest + +import pygad + +# Global constants for testing +num_generations = 10 +num_parents_mating = 5 +sol_per_pop = 10 +num_genes = 3 +random_seed = 42 + +# A substring unique to the keep_parents/keep_elitism conflict warning. +CONFLICT_WARNING_SUBSTRING = "takes precedence" + + +def fitness_func(ga_instance, solution, solution_idx): + """Single-objective fitness function.""" + return numpy.sum(solution ** 2) + + +def make_ga(**kwargs): + """Build a GA instance with the shared defaults, overriding via kwargs.""" + params = dict(num_generations=num_generations, + num_parents_mating=num_parents_mating, + fitness_func=fitness_func, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + random_seed=random_seed) + params.update(kwargs) + return pygad.GA(**params) + + +def conflict_warnings(record): + """Return the keep_parents/keep_elitism conflict warnings in a record list.""" + return [w for w in record if CONFLICT_WARNING_SUBSTRING in str(w.message)] + + +def test_default_no_conflict_warning(): + """ + With neither keep_parents nor keep_elitism set, keep_parents resolves to its + historical default (-1) and no conflict warning is raised. + """ + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + ga_instance = make_ga() + + assert ga_instance.keep_parents == -1 + assert ga_instance.keep_parents_explicitly_set is False + assert ga_instance.keep_elitism == 1 + # Default keep_elitism=1, so num_offspring = sol_per_pop - keep_elitism. + assert ga_instance.num_offspring == sol_per_pop - 1 + assert conflict_warnings(record) == [] + print("test_default_no_conflict_warning passed.") + + +def test_conflict_warning_fires(): + """ + Setting keep_parents while keep_elitism is at its default (1) raises the + conflict warning instead of silently ignoring keep_parents. + """ + with pytest.warns(UserWarning, match=CONFLICT_WARNING_SUBSTRING): + make_ga(keep_parents=2) + print("test_conflict_warning_fires passed.") + + +def test_no_warning_when_keep_parents_intended(): + """ + With keep_elitism=0, keep_parents takes effect and no conflict warning fires. + Offspring count must reflect the kept parents. + """ + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + ga_instance = make_ga(keep_elitism=0, keep_parents=2) + + assert ga_instance.keep_parents == 2 + assert ga_instance.keep_parents_explicitly_set is True + assert ga_instance.num_offspring == sol_per_pop - 2 + assert conflict_warnings(record) == [] + print("test_no_warning_when_keep_parents_intended passed.") + + +def test_keep_parents_minus_one_preserved(): + """ + The distinct keep_parents=-1 behavior (keep ALL selected parents) is preserved + when keep_elitism=0: offspring count = sol_per_pop - num_parents_mating. + """ + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + ga_instance = make_ga(keep_elitism=0, keep_parents=-1) + + assert ga_instance.keep_parents == -1 + assert ga_instance.num_offspring == sol_per_pop - num_parents_mating + assert conflict_warnings(record) == [] + print("test_keep_parents_minus_one_preserved passed.") + + +def test_suppress_warnings_honored(): + """ + suppress_warnings=True silences the conflict warning. + """ + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + make_ga(keep_parents=2, suppress_warnings=True) + + assert conflict_warnings(record) == [] + print("test_suppress_warnings_honored passed.") + + +def test_explicit_minus_one_with_elitism_warns(): + """ + Explicitly setting keep_parents=-1 while keep_elitism>0 still warns, because the + user expressed an intent that the precedence rule overrides. + """ + with pytest.warns(UserWarning, match=CONFLICT_WARNING_SUBSTRING): + make_ga(keep_parents=-1) + print("test_explicit_minus_one_with_elitism_warns passed.") + + +if __name__ == "__main__": + test_default_no_conflict_warning() + test_conflict_warning_fires() + test_no_warning_when_keep_parents_intended() + test_keep_parents_minus_one_preserved() + test_suppress_warnings_honored() + test_explicit_minus_one_with_elitism_warns() + print("\nAll tests passed!") diff --git a/tests/test_nsga3.py b/tests/test_nsga3.py new file mode 100644 index 0000000..0c502d1 --- /dev/null +++ b/tests/test_nsga3.py @@ -0,0 +1,398 @@ +import math +import warnings + +import numpy +import pytest + +import pygad +from pygad.utils.nsga3 import NSGA3 + + +@pytest.fixture +def nsga3(): + return NSGA3() + + +# Six solutions in PyGAD maximization form. The same numbers under the +# usual minimization convention would be (1, 6), (2, 4.5), (3, 3), (4.5, 2), +# (6, 1), (4, 4). Solutions s1 and s5 are the two axis extremes; s6 is a +# dominated interior point. The expected NSGA-III values below were +# derived by hand from this fitness pool. +GUIDE_FITNESS_NEGATED = numpy.array([ + [-1.0, -6.0], # s1 + [-2.0, -4.5], # s2 + [-3.0, -3.0], # s3 + [-4.5, -2.0], # s4 + [-6.0, -1.0], # s5 + [-4.0, -4.0], # s6 +]) + + +def test_generate_reference_points_count_matches_binomial_for_M2_p3(nsga3): + points = nsga3.nsga3_generate_reference_points(num_objectives=2, num_divisions=3) + assert points.shape == (math.comb(2 + 3 - 1, 3), 2) + + +def test_generate_reference_points_count_matches_binomial_for_M3_p4(nsga3): + points = nsga3.nsga3_generate_reference_points(num_objectives=3, num_divisions=4) + assert points.shape == (math.comb(3 + 4 - 1, 4), 3) + + +def test_generate_reference_points_count_matches_binomial_for_M5_p4(nsga3): + points = nsga3.nsga3_generate_reference_points(num_objectives=5, num_divisions=4) + assert points.shape == (math.comb(5 + 4 - 1, 4), 5) + + +def test_generate_reference_points_rows_sum_to_one(nsga3): + points = nsga3.nsga3_generate_reference_points(num_objectives=3, num_divisions=4) + numpy.testing.assert_allclose(points.sum(axis=1), 1.0, atol=1e-12) + + +def test_generate_reference_points_M2_p3_matches_expected_set(nsga3): + points = nsga3.nsga3_generate_reference_points(num_objectives=2, num_divisions=3) + expected = numpy.array([ + [3 / 3, 0 / 3], + [2 / 3, 1 / 3], + [1 / 3, 2 / 3], + [0 / 3, 3 / 3], + ]) + sorted_actual = numpy.array(sorted(points.tolist(), reverse=True)) + sorted_expected = numpy.array(sorted(expected.tolist(), reverse=True)) + numpy.testing.assert_allclose(sorted_actual, sorted_expected, atol=1e-12) + + +def test_compute_ideal_point_takes_column_max(nsga3): + fitness = numpy.array([ + [1.0, 5.0], + [3.0, 2.0], + [0.0, 4.0], + ]) + ideal = nsga3.nsga3_compute_ideal_point(fitness) + numpy.testing.assert_allclose(ideal, [3.0, 5.0]) + + +def test_compute_ideal_point_on_negated_six_solution_set(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED) + numpy.testing.assert_allclose(ideal, [-1.0, -1.0]) + + +def test_find_extreme_points_picks_s5_for_f1_axis(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED) + extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal) + numpy.testing.assert_allclose(extremes[0], [-6.0, -1.0]) + + +def test_find_extreme_points_picks_s1_for_f2_axis(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED) + extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal) + numpy.testing.assert_allclose(extremes[1], [-1.0, -6.0]) + + +def test_compute_intercepts_six_solution_set_returns_minus_six(nsga3): + # Intercept point sits at ideal + 1/b, where b solves + # (extremes - ideal) @ b = 1. For this dataset both axes give -6. + # The extreme rows then normalize to the simplex corners (1, 0) and + # (0, 1). + ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED) + extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, GUIDE_FITNESS_NEGATED) + numpy.testing.assert_allclose(intercepts, [-6.0, -6.0], atol=1e-9) + + +def test_compute_intercepts_falls_back_to_nadir_on_singular_extremes(nsga3): + ideal = numpy.array([0.0, 0.0]) + duplicate_extremes = numpy.array([ + [-3.0, -2.0], + [-3.0, -2.0], + ]) + pool = numpy.array([ + [-3.0, -2.0], + [-1.5, -4.0], + ]) + intercepts = nsga3.nsga3_compute_intercepts(duplicate_extremes, ideal, pool) + numpy.testing.assert_allclose(intercepts, pool.min(axis=0)) + + +def test_normalize_fitness_places_extremes_at_simplex_corners(nsga3): + # With intercepts = (-6, -6) and ideal = (-1, -1) the denominator + # (intercepts - ideal) is (-5, -5) and the formula + # (f - ideal) / (intercepts - ideal) maps each row to a point inside + # the unit simplex. The two axis extremes (s5 and s1) land exactly on + # the simplex corners. + ideal = nsga3.nsga3_compute_ideal_point(GUIDE_FITNESS_NEGATED) + extremes = nsga3.nsga3_find_extreme_points(GUIDE_FITNESS_NEGATED, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, GUIDE_FITNESS_NEGATED) + normalized = nsga3.nsga3_normalize_fitness(GUIDE_FITNESS_NEGATED, ideal, intercepts) + expected = numpy.array([ + [0.0, 1.0], # s1 -> simplex corner on f2 + [0.2, 0.7], # s2 + [0.4, 0.4], # s3 + [0.7, 0.2], # s4 + [1.0, 0.0], # s5 -> simplex corner on f1 + [0.6, 0.6], # s6 (dominated) + ]) + numpy.testing.assert_allclose(normalized, expected, atol=1e-9) + + +def test_normalize_fitness_clips_above_one_and_below_zero(nsga3): + # First row sits "above" the ideal under maximization (raw values + # bigger than the ideal) so the formula would produce a negative + # ratio. Second row sits below the intercept and would produce a + # ratio above 1. Both must be clipped back to [0, 1]. + ideal = numpy.array([0.0, 0.0]) + intercepts = numpy.array([-1.0, -1.0]) + fitness = numpy.array([ + [0.5, 0.5], + [-2.0, -2.0], + ]) + normalized = nsga3.nsga3_normalize_fitness(fitness, ideal, intercepts) + assert normalized.min() >= 0.0 + assert normalized.max() <= 1.0 + + +def test_normalize_fitness_handles_near_zero_negative_denominator(nsga3): + # Intercept sits within 1e-12 of the ideal so the denominator + # collapses to a tiny negative. The safeguard must keep the sign + # negative so (fitness - ideal) / denom comes out positive (and + # then clips to 1.0). A buggy safeguard that lets the denom flip + # to zero or positive would produce inf / nan or 0.0 instead. + ideal = numpy.array([0.0]) + intercepts = numpy.array([-1e-15]) + fitness = numpy.array([[-1.0]]) + normalized = nsga3.nsga3_normalize_fitness(fitness, ideal, intercepts) + assert numpy.all(numpy.isfinite(normalized)) + assert normalized[0, 0] == pytest.approx(1.0) + + +# Reference points for M=2, p=3 in the order +# nsga3_generate_reference_points emits them (stars-and-bars enumeration). +REFERENCE_POINTS_M2_P3 = numpy.array([ + [1.0, 0.0 ], # ref 0 + [2 / 3, 1 / 3], # ref 1 + [1 / 3, 2 / 3], # ref 2 + [0.0, 1.0 ], # ref 3 +]) + + +def test_associate_picks_nearest_reference_line(nsga3): + # The point (0, 1) lies on the f2 axis and is collinear with ref 3. + # Perpendicular distance is zero. + point = numpy.array([[0.0, 1.0]]) + nearest, distance = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3) + assert nearest[0] == 3 + assert distance[0] == pytest.approx(0.0, abs=1e-12) + + +def test_associate_breaks_ties_by_lower_reference_index(nsga3): + # The point (0.6, 0.6) sits on the diagonal and is the same distance + # from ref 1 and ref 2. The lower index wins. + point = numpy.array([[0.6, 0.6]]) + nearest, _ = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3) + assert nearest[0] == 1 + + +def test_associate_perpendicular_distance_for_diagonal_point(nsga3): + # Same diagonal point. Expected distance ~ 0.2683 computed by hand + # from the formula || x - (x . z_hat) z_hat ||. + point = numpy.array([[0.6, 0.6]]) + _, distance = nsga3.nsga3_associate_to_reference_points(point, REFERENCE_POINTS_M2_P3) + assert distance[0] == pytest.approx(0.2683, abs=1e-3) + + +def test_niching_with_single_critical_front_candidate_returns_that_candidate(nsga3): + critical_front_indices = [42] + critical_front_associations = numpy.array([1]) + critical_front_distances = numpy.array([0.224]) + accepted_associations = numpy.array([3, 2, 1, 1, 0]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked == [42] + + +def test_niching_picks_candidate_in_lower_niche_count(nsga3): + # Two candidates. The first is associated with ref 1 (niche count 2); + # the second with ref 2 (niche count 1). The lower niche count wins. + critical_front_indices = [60, 70] + critical_front_associations = numpy.array([1, 2]) + critical_front_distances = numpy.array([0.224, 0.10]) + accepted_associations = numpy.array([3, 2, 1, 1, 0]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked == [70] + + +def test_niching_picks_smallest_distance_when_niche_count_is_zero(nsga3): + # Both candidates are at ref 1 with niche count 0 (empty niche). The + # closer candidate wins (distance 0.158 < 0.224). + critical_front_indices = [60, 70] + critical_front_associations = numpy.array([1, 1]) + critical_front_distances = numpy.array([0.224, 0.158]) + accepted_associations = numpy.array([3, 2, 0]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked == [70] + + +def test_niching_picks_from_candidate_pool_when_niche_count_is_positive(nsga3): + # Both candidates are at ref 1 with niche count > 0, so the pick is + # random. Run 50 different seeds and verify that the chosen candidate + # always comes from {60, 70} and that both candidates show up over + # the run. + critical_front_indices = [60, 70] + critical_front_associations = numpy.array([1, 1]) + critical_front_distances = numpy.array([0.224, 0.158]) + accepted_associations = numpy.array([3, 2, 1, 1, 0]) + seen = set() + rng_state = numpy.random.get_state() + try: + for seed in range(50): + numpy.random.seed(seed) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked[0] in {60, 70} + seen.add(picked[0]) + finally: + numpy.random.set_state(rng_state) + assert seen == {60, 70} + + +# Fitness helpers used by the integration tests below. The scalar one +# returns a single number so we can check that NSGA-III rejects it; the +# other two return a list of objectives. + +def _scalar_fitness(ga, solution, sol_idx): + return float(numpy.sum(solution)) + + +def _two_objective_fitness(ga, solution, sol_idx): + return [float(numpy.sum(solution)), -float(numpy.sum(solution ** 2))] + + +def _three_objective_fitness(ga, solution, sol_idx): + return [float(solution[0]), float(solution[1]), float(solution[2])] + + +def test_nsga3_requires_nsga3_num_divisions(): + with pytest.raises(ValueError, match="nsga3_num_divisions"): + pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_two_objective_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='nsga3', + suppress_warnings=True) + + +def test_nsga3_rejects_non_positive_nsga3_num_divisions(): + with pytest.raises(ValueError, match="nsga3_num_divisions"): + pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_two_objective_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='nsga3', + nsga3_num_divisions=0, + suppress_warnings=True) + + +def test_nsga3_rejects_single_objective_problem(): + ga = pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_scalar_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='nsga3', + nsga3_num_divisions=4, + suppress_warnings=True) + with pytest.raises(TypeError, match="single-objective"): + ga.run() + + +def test_tournament_nsga3_rejects_single_objective_problem(): + ga = pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_scalar_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='tournament_nsga3', + nsga3_num_divisions=4, + K_tournament=2, + suppress_warnings=True) + with pytest.raises(TypeError, match="single-objective"): + ga.run() + + +def test_nsga3_bootstrap_generates_reference_points_with_expected_shape(): + ga = pygad.GA(num_generations=2, + num_parents_mating=5, + fitness_func=_three_objective_fitness, + sol_per_pop=15, + num_genes=4, + parent_selection_type='nsga3', + nsga3_num_divisions=4, + random_seed=1, + suppress_warnings=True) + ga.run() + assert ga.nsga3_reference_points.shape == (15, 3) + + +def test_sol_per_pop_below_reference_count_triggers_warning_and_grows_population(): + # M=3, p=4 needs 15 reference points but sol_per_pop is only 8. The + # GA should warn once, grow the population to 15, and re-evaluate + # fitness before the generational loop starts. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ga = pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_three_objective_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='nsga3', + nsga3_num_divisions=4, + random_seed=1) + ga.run() + nsga3_warning_messages = [str(w.message) for w in caught + if "NSGA-III reference points" in str(w.message)] + assert len(nsga3_warning_messages) == 1 + assert ga.sol_per_pop == 15 + assert ga.population.shape[0] == 15 + + +def test_sol_per_pop_auto_grow_also_fires_for_tournament_nsga3(): + # Same scenario but using the tournament-based NSGA-III selection. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ga = pygad.GA(num_generations=2, + num_parents_mating=3, + fitness_func=_three_objective_fitness, + sol_per_pop=8, + num_genes=4, + parent_selection_type='tournament_nsga3', + nsga3_num_divisions=4, + K_tournament=2, + random_seed=2) + ga.run() + grown_warning_messages = [str(w.message) for w in caught + if "NSGA-III reference points" in str(w.message)] + assert len(grown_warning_messages) == 1 + assert ga.sol_per_pop == 15 diff --git a/tests/test_nsga3_dtlz2.py b/tests/test_nsga3_dtlz2.py new file mode 100644 index 0000000..e1c6bd0 --- /dev/null +++ b/tests/test_nsga3_dtlz2.py @@ -0,0 +1,177 @@ +""" +End-to-end NSGA-III tests on the DTLZ2 benchmark. + +DTLZ2 is a multi-objective test problem whose Pareto-optimal solutions +lie on the unit sphere in the first orthant of objective space. The +problem is naturally a minimization; this test file negates each +objective so it fits PyGAD's maximization convention. + +The Deb & Jain 2014 paper checks convergence by asking whether every +final solution is non-dominated and whether at least 12 of the 15 +reference points (M=3, p=4) have a solution within perpendicular +distance 0.1. PyGAD does parent-selection rather than full survival +selection, so the strict 12/15 threshold is not reachable with the +built-in operators. The asserts below use looser thresholds that the +algorithm actually reaches with the paper-style polynomial mutation +defined inside this file. +""" + +import math + +import numpy + +import pygad +from pygad.utils.nsga3 import NSGA3 + + +NUM_OBJECTIVES = 3 +NSGA3_NUM_DIVISIONS = 4 +NUM_DECISION_VARS = 12 +SOL_PER_POP = 15 +NUM_GENERATIONS = 500 +RANDOM_SEED = 42 + +# Polynomial mutation distribution index from Deb 1996. The Deb & Jain +# paper uses the same value for DTLZ benchmarks. +POLY_MUTATION_ETA = 20.0 +# Pass thresholds: how close to the unit sphere the population gets, how +# many solutions stay non-dominated, and how many reference points end +# up with a solution close to them. +PARETO_SPHERE_RADIUS_TOLERANCE = 0.30 +MIN_FRONT_ONE_SIZE = 7 +COVERAGE_THRESHOLD = 0.30 +MIN_REFERENCE_POINTS_COVERED = 7 + + +def _dtlz2_max_fitness(ga, solution, sol_idx): + """ + Standard DTLZ2 objective function, negated so larger values mean + better fitness under PyGAD's maximization convention. Decision + variables are clipped to [0, 1] before being used so mutations that + push genes out of range do not produce non-finite outputs. + """ + decision_variables = numpy.clip(numpy.asarray(solution, dtype=float), 0.0, 1.0) + position_vars = decision_variables[:NUM_OBJECTIVES - 1] + distance_vars = decision_variables[NUM_OBJECTIVES - 1:] + g_value = numpy.sum((distance_vars - 0.5) ** 2) + radius = 1.0 + g_value + angles = position_vars * (math.pi / 2.0) + objectives = [] + for objective_index in range(NUM_OBJECTIVES): + value = radius + for cos_index in range(NUM_OBJECTIVES - 1 - objective_index): + value *= math.cos(angles[cos_index]) + if objective_index > 0: + value *= math.sin(angles[NUM_OBJECTIVES - 1 - objective_index]) + objectives.append(-value) + return objectives + + +def _polynomial_mutation(offspring, ga_instance): + """ + Polynomial mutation operator used in the Deb & Jain paper. PyGAD + only ships a uniform random mutation which is not strong enough to + drive DTLZ2 to convergence in a reasonable number of generations. + """ + per_gene_probability = 1.0 / offspring.shape[1] + eta_plus_one = 1.0 + POLY_MUTATION_ETA + for solution_index in range(offspring.shape[0]): + for gene_index in range(offspring.shape[1]): + if numpy.random.random() >= per_gene_probability: + continue + u = numpy.random.random() + if u < 0.5: + delta = pow(2.0 * u, 1.0 / eta_plus_one) - 1.0 + else: + delta = 1.0 - pow(2.0 * (1.0 - u), 1.0 / eta_plus_one) + mutated = offspring[solution_index, gene_index] + delta + offspring[solution_index, gene_index] = numpy.clip(mutated, 0.0, 1.0) + return offspring + + +def _make_dtlz2_ga(): + return pygad.GA(num_generations=NUM_GENERATIONS, + num_parents_mating=SOL_PER_POP, + fitness_func=_dtlz2_max_fitness, + sol_per_pop=SOL_PER_POP, + num_genes=NUM_DECISION_VARS, + init_range_low=0.0, + init_range_high=1.0, + gene_space={'low': 0.0, 'high': 1.0}, + parent_selection_type='nsga3', + nsga3_num_divisions=NSGA3_NUM_DIVISIONS, + crossover_type='uniform', + mutation_type=_polynomial_mutation, + random_seed=RANDOM_SEED, + suppress_warnings=True) + + +def _evaluate_final_fitness(ga): + return numpy.array([_dtlz2_max_fitness(ga, sol, idx) + for idx, sol in enumerate(ga.population)], + dtype=float) + + +def _normalized_distances_per_reference(ga, fitness): + nsga3 = NSGA3() + ideal_point = nsga3.nsga3_compute_ideal_point(fitness) + extreme_points = nsga3.nsga3_find_extreme_points(fitness, ideal_point) + intercepts = nsga3.nsga3_compute_intercepts(extreme_points, ideal_point, fitness) + normalized = nsga3.nsga3_normalize_fitness(fitness, ideal_point, intercepts) + assignments, distances = nsga3.nsga3_associate_to_reference_points( + normalized, ga.nsga3_reference_points) + nearest_per_reference = numpy.full(len(ga.nsga3_reference_points), numpy.inf) + for solution_index, reference_index in enumerate(assignments): + if distances[solution_index] < nearest_per_reference[reference_index]: + nearest_per_reference[reference_index] = distances[solution_index] + return nearest_per_reference + + +def test_dtlz2_run_produces_consistent_population_and_reference_points(): + ga = _make_dtlz2_ga() + ga.run() + assert ga.population.shape == (SOL_PER_POP, NUM_DECISION_VARS) + expected_reference_count = math.comb(NUM_OBJECTIVES + NSGA3_NUM_DIVISIONS - 1, + NSGA3_NUM_DIVISIONS) + assert ga.nsga3_reference_points.shape == (expected_reference_count, + NUM_OBJECTIVES) + numpy.testing.assert_allclose(ga.nsga3_reference_points.sum(axis=1), + 1.0, atol=1e-12) + + +def test_dtlz2_final_population_collapses_onto_unit_sphere(): + ga = _make_dtlz2_ga() + ga.run() + final_fitness = _evaluate_final_fitness(ga) + radii = numpy.sqrt((final_fitness ** 2).sum(axis=1)) + radius_error = numpy.abs(radii - 1.0) + median_radius_error = float(numpy.median(radius_error)) + assert median_radius_error <= PARETO_SPHERE_RADIUS_TOLERANCE, ( + f"Median |radius - 1| = {median_radius_error:.4f}, expected <= " + f"{PARETO_SPHERE_RADIUS_TOLERANCE}; per-solution radii: " + f"{radii.tolist()}.") + + +def test_dtlz2_final_population_is_mostly_non_dominated(): + ga = _make_dtlz2_ga() + ga.run() + final_fitness = _evaluate_final_fitness(ga) + fronts, _ = ga.non_dominated_sorting(final_fitness) + front_one_size = len(fronts[0]) + assert front_one_size >= MIN_FRONT_ONE_SIZE, ( + f"Front 1 contains only {front_one_size} of {SOL_PER_POP} solutions, " + f"expected at least {MIN_FRONT_ONE_SIZE}; front sizes: " + f"{[len(front) for front in fronts]}.") + + +def test_dtlz2_reference_directions_have_neighbours(): + ga = _make_dtlz2_ga() + ga.run() + final_fitness = _evaluate_final_fitness(ga) + nearest_per_reference = _normalized_distances_per_reference(ga, final_fitness) + covered_count = int(numpy.sum(nearest_per_reference <= COVERAGE_THRESHOLD)) + assert covered_count >= MIN_REFERENCE_POINTS_COVERED, ( + f"Only {covered_count} of {len(ga.nsga3_reference_points)} reference " + f"directions have a solution within perpendicular distance " + f"{COVERAGE_THRESHOLD}; nearest distances per reference point: " + f"{nearest_per_reference.tolist()}.") diff --git a/tests/test_nsga3_pipeline.py b/tests/test_nsga3_pipeline.py new file mode 100644 index 0000000..bb7b950 --- /dev/null +++ b/tests/test_nsga3_pipeline.py @@ -0,0 +1,325 @@ +""" +Pipeline-level tests for NSGA-III using hand-built ground-truth values. + +Every test in this file pins the output of one NSGA-III step (reference +point generation, ideal point, extreme points, intercepts, normalized +fitness, association, niching, or the full pipeline) to a hardcoded +value derived from a small dataset whose answer can be checked on paper. + +The main dataset is THREE_OBJECTIVE_FITNESS: seven solutions in M=3 +space whose hand-derived ideal point, extreme points, intercepts, and +normalized positions are all simple round numbers. This makes the +expected outputs easy to verify without re-running the algorithm. +""" + +import numpy +import pytest + +from pygad.utils.nsga3 import NSGA3 + + +# Three-objective dataset used by most tests below. Values are expressed +# in PyGAD's maximization convention, so all fitness values are <= 0 and +# the ideal point sits at the origin. +# +# s0..s2 : axis extremes (best on one objective, ideal on the others). +# s3..s5 : midpoints of each edge of the simplex. +# s6 : centre of the unit simplex. +THREE_OBJECTIVE_FITNESS = numpy.array([ + [-1.0, 0.0, 0.0], # s0 — extreme for f0 + [ 0.0, -1.0, 0.0], # s1 — extreme for f1 + [ 0.0, 0.0, -1.0], # s2 — extreme for f2 + [-0.5, -0.5, 0.0], # s3 + [-0.5, 0.0, -0.5], # s4 + [ 0.0, -0.5, -0.5], # s5 + [-1 / 3, -1 / 3, -1 / 3], # s6 — simplex centre +]) + + +@pytest.fixture +def nsga3(): + return NSGA3() + + +@pytest.mark.parametrize("num_objectives,num_divisions,expected_count", [ + (2, 3, 4), + (3, 4, 15), + (3, 12, 91), + (5, 4, 70), + (8, 3, 120), +]) +def test_reference_point_count_matches_binomial(nsga3, num_objectives, + num_divisions, expected_count): + points = nsga3.nsga3_generate_reference_points(num_objectives, num_divisions) + assert points.shape == (expected_count, num_objectives) + numpy.testing.assert_allclose(points.sum(axis=1), 1.0, atol=1e-12) + + +def test_reference_points_M3_p2_match_expected_set(nsga3): + points = nsga3.nsga3_generate_reference_points(3, 2) + expected = numpy.array([ + [1.0, 0.0, 0.0], + [0.5, 0.5, 0.0], + [0.5, 0.0, 0.5], + [0.0, 1.0, 0.0], + [0.0, 0.5, 0.5], + [0.0, 0.0, 1.0], + ]) + sorted_actual = numpy.array(sorted(points.tolist(), reverse=True)) + sorted_expected = numpy.array(sorted(expected.tolist(), reverse=True)) + numpy.testing.assert_allclose(sorted_actual, sorted_expected, atol=1e-12) + + +def test_ideal_point_for_three_objective_set(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(THREE_OBJECTIVE_FITNESS) + numpy.testing.assert_allclose(ideal, [0.0, 0.0, 0.0]) + + +def test_extreme_points_for_three_objective_set(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(THREE_OBJECTIVE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(THREE_OBJECTIVE_FITNESS, ideal) + expected = numpy.array([ + [-1.0, 0.0, 0.0], + [ 0.0, -1.0, 0.0], + [ 0.0, 0.0, -1.0], + ]) + numpy.testing.assert_allclose(extremes, expected, atol=1e-12) + + +def test_intercepts_for_three_objective_set(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(THREE_OBJECTIVE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(THREE_OBJECTIVE_FITNESS, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, THREE_OBJECTIVE_FITNESS) + numpy.testing.assert_allclose(intercepts, [-1.0, -1.0, -1.0], atol=1e-12) + + +def test_intercepts_cap_at_worst_observed_per_objective(nsga3): + # Make the linear solve extrapolate well beyond the actual data: + # the extreme points are packed close to the ideal so 1/b is large, + # but the pool's worst values per axis sit much closer to the ideal. + # The cap should pull each intercept back to the worst observed + # value. + ideal = numpy.array([0.0, 0.0]) + extremes = numpy.array([ + [-0.001, -0.5], + [-0.5, -0.001], + ]) + pool = numpy.array([ + [-0.001, -0.5], + [-0.5, -0.001], + [-0.1, -0.1], + ]) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, pool) + numpy.testing.assert_allclose(intercepts, pool.min(axis=0), atol=1e-12) + + +def test_intercepts_fall_back_when_extremes_singular(nsga3): + # Both extreme points are the same row, so the linear system is + # singular. The function must fall back to the worst per objective. + ideal = numpy.array([0.0, 0.0]) + extremes = numpy.array([ + [-1.0, -1.0], + [-1.0, -1.0], + ]) + pool = numpy.array([ + [-1.0, -1.0], + [-2.0, -0.5], + ]) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, pool) + numpy.testing.assert_allclose(intercepts, pool.min(axis=0)) + + +def test_normalized_fitness_for_three_objective_set(nsga3): + ideal = nsga3.nsga3_compute_ideal_point(THREE_OBJECTIVE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(THREE_OBJECTIVE_FITNESS, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, THREE_OBJECTIVE_FITNESS) + normalized = nsga3.nsga3_normalize_fitness(THREE_OBJECTIVE_FITNESS, ideal, intercepts) + expected = numpy.array([ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.5, 0.5, 0.0], + [0.5, 0.0, 0.5], + [0.0, 0.5, 0.5], + [1 / 3, 1 / 3, 1 / 3], + ]) + numpy.testing.assert_allclose(normalized, expected, atol=1e-12) + + +def test_associations_for_three_objective_set(nsga3): + # Reference points produced by nsga3_generate_reference_points(3, 2), + # in the order our enumeration emits them: + # ref[0] = (0, 0, 1 ) + # ref[1] = (0, 0.5, 0.5) + # ref[2] = (0, 1, 0 ) + # ref[3] = (0.5, 0, 0.5) + # ref[4] = (0.5, 0.5, 0 ) + # ref[5] = (1, 0, 0 ) + # Each on-simplex solution sits on one reference line and has zero + # distance. The centre solution is the same distance from ref[1], ref[3] + # and ref[4]; the lower-index tie break picks ref[1]. + nsga3_ref_points = nsga3.nsga3_generate_reference_points(3, 2) + ideal = nsga3.nsga3_compute_ideal_point(THREE_OBJECTIVE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(THREE_OBJECTIVE_FITNESS, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, THREE_OBJECTIVE_FITNESS) + normalized = nsga3.nsga3_normalize_fitness(THREE_OBJECTIVE_FITNESS, ideal, intercepts) + nearest, distance = nsga3.nsga3_associate_to_reference_points(normalized, nsga3_ref_points) + expected_nearest = numpy.array([5, 2, 0, 4, 3, 1, 1]) + expected_distance = numpy.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1 / 3]) + numpy.testing.assert_array_equal(nearest, expected_nearest) + numpy.testing.assert_allclose(distance, expected_distance, atol=1e-9) + + +def test_niching_picks_one_candidate_per_empty_niche(nsga3): + # Three critical-front candidates, each attached to a different + # empty niche. We need three survivors. Every empty niche must take + # its only candidate. + critical_front_indices = [10, 11, 12] + critical_front_associations = numpy.array([0, 1, 2]) + critical_front_distances = numpy.array([0.01, 0.02, 0.03]) + accepted_associations = numpy.array([3, 3, 3]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=3) + assert set(picked) == {10, 11, 12} + + +def test_niching_picks_closest_candidate_when_niche_count_is_zero(nsga3): + # Two candidates at the same empty niche. The closer one wins + # deterministically. + critical_front_indices = [20, 21] + critical_front_associations = numpy.array([0, 0]) + critical_front_distances = numpy.array([0.5, 0.1]) + accepted_associations = numpy.array([1, 2, 3]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked == [21] + + +def test_niching_picks_candidate_in_lower_niche_count_with_unique_owner(nsga3): + # ref 0 has niche count 2, ref 1 has niche count 3. We want the + # candidate at ref 0 because its niche count is smaller. + critical_front_indices = [30, 31] + critical_front_associations = numpy.array([0, 1]) + critical_front_distances = numpy.array([0.4, 0.2]) + accepted_associations = numpy.array([0, 0, 1, 1, 1]) + picked = nsga3.nsga3_niching_select( + critical_front_indices=critical_front_indices, + critical_front_associations=critical_front_associations, + critical_front_distances=critical_front_distances, + accepted_associations=accepted_associations, + num_reference_points=4, + num_to_select=1) + assert picked == [30] + + +def test_full_pipeline_recovers_simplex_corners(nsga3): + # Run the full pipeline on the THREE_OBJECTIVE_FITNESS dataset and + # verify that the six on-simplex solutions cover six different + # reference points with zero perpendicular distance. + nsga3_ref_points = nsga3.nsga3_generate_reference_points(3, 2) + fitness = THREE_OBJECTIVE_FITNESS + ideal = nsga3.nsga3_compute_ideal_point(fitness) + extremes = nsga3.nsga3_find_extreme_points(fitness, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, fitness) + normalized = nsga3.nsga3_normalize_fitness(fitness, ideal, intercepts) + nearest, distance = nsga3.nsga3_associate_to_reference_points(normalized, nsga3_ref_points) + covered = numpy.unique(nearest[:6]) + assert set(covered.tolist()) == {0, 1, 2, 3, 4, 5} + assert numpy.all(distance[:6] < 1e-9) + + +# Three solutions used by the wide-range and narrow-range normalization +# tests below. The first row is the f0 extreme, the second is the f1 +# extreme, and the third is the middle point of the front. Both versions +# of the dataset must produce the same normalized positions because the +# NSGA-III normalization is invariant to positive affine transforms of +# the fitness. +WIDE_RANGE_FITNESS = numpy.array([ + [15.0, -10.0], # s0 — best f0, worst f1 + [-10.0, 15.0], # s1 — worst f0, best f1 + [ 0.0, 0.0], # s2 — middle +]) + +NARROW_RANGE_FITNESS = numpy.array([ + [0.7, 0.3], # s0 + [0.3, 0.7], # s1 + [0.5, 0.5], # s2 +]) + + +def test_normalize_fitness_for_wide_range_input(nsga3): + # Fitness values cross zero and span 25 units per axis. The + # algorithm must still map the two extremes onto the simplex corners + # and the middle point to (0.6, 0.6). + ideal = nsga3.nsga3_compute_ideal_point(WIDE_RANGE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(WIDE_RANGE_FITNESS, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, WIDE_RANGE_FITNESS) + normalized = nsga3.nsga3_normalize_fitness(WIDE_RANGE_FITNESS, ideal, intercepts) + numpy.testing.assert_allclose(ideal, [15.0, 15.0]) + numpy.testing.assert_allclose(intercepts, [-10.0, -10.0]) + expected_normalized = numpy.array([ + [0.0, 1.0], + [1.0, 0.0], + [0.6, 0.6], + ]) + numpy.testing.assert_allclose(normalized, expected_normalized, atol=1e-12) + + +def test_normalize_fitness_for_narrow_range_input(nsga3): + # Fitness values are all inside [0.3, 0.7] (a 0.4-wide window). + # Normalization must still pin the two extremes to the simplex + # corners and place the middle point at (0.5, 0.5). + ideal = nsga3.nsga3_compute_ideal_point(NARROW_RANGE_FITNESS) + extremes = nsga3.nsga3_find_extreme_points(NARROW_RANGE_FITNESS, ideal) + intercepts = nsga3.nsga3_compute_intercepts(extremes, ideal, NARROW_RANGE_FITNESS) + normalized = nsga3.nsga3_normalize_fitness(NARROW_RANGE_FITNESS, ideal, intercepts) + numpy.testing.assert_allclose(ideal, [0.7, 0.7]) + numpy.testing.assert_allclose(intercepts, [0.3, 0.3]) + expected_normalized = numpy.array([ + [0.0, 1.0], + [1.0, 0.0], + [0.5, 0.5], + ]) + numpy.testing.assert_allclose(normalized, expected_normalized, atol=1e-12) + + +@pytest.mark.parametrize("scale,shift", [ + (1.0, 0.0), # identity + (37.0, 0.0), # pure positive scale + (0.01, 0.0), # pure positive scale (shrink) + (1.0, 100.0), # pure shift up + (1.0, -100.0), # pure shift down + (5.0, -12.5), # mixed +]) +def test_normalize_fitness_is_invariant_under_positive_affine_transforms(nsga3, scale, shift): + # NSGA-III normalization should not care about the absolute scale or + # offset of fitness as long as the transform is a positive affine + # one. Verify by transforming the base dataset and checking that the + # normalized positions match the untransformed reference. + base = NARROW_RANGE_FITNESS + transformed = scale * base + shift + + base_ideal = nsga3.nsga3_compute_ideal_point(base) + base_extremes = nsga3.nsga3_find_extreme_points(base, base_ideal) + base_intercepts = nsga3.nsga3_compute_intercepts(base_extremes, base_ideal, base) + base_normalized = nsga3.nsga3_normalize_fitness(base, base_ideal, base_intercepts) + + transformed_ideal = nsga3.nsga3_compute_ideal_point(transformed) + transformed_extremes = nsga3.nsga3_find_extreme_points(transformed, transformed_ideal) + transformed_intercepts = nsga3.nsga3_compute_intercepts(transformed_extremes, + transformed_ideal, + transformed) + transformed_normalized = nsga3.nsga3_normalize_fitness(transformed, + transformed_ideal, + transformed_intercepts) + numpy.testing.assert_allclose(transformed_normalized, base_normalized, atol=1e-9) diff --git a/tests/test_nsga3_population_growth.py b/tests/test_nsga3_population_growth.py new file mode 100644 index 0000000..91b8a47 --- /dev/null +++ b/tests/test_nsga3_population_growth.py @@ -0,0 +1,198 @@ +""" +Tests for the NSGA-III auto-growth path in the engine. + +When ``sol_per_pop`` is smaller than the number of NSGA-III reference +points, the engine grows the population to match before the +generational loop starts. The grown rows must follow every rule that +applies to the initial population: per-gene init range, +``gene_space``, ``gene_type`` (single or nested), +``allow_duplicate_genes``, and ``gene_constraint``. + +The tests here exercise each rule with a population of size 1 so the +auto-growth path is forced to generate fresh solutions. +""" + +import numpy +import pytest + +import pygad + + +def _two_objective_fitness(ga, solution, sol_idx): + return [float(numpy.sum(solution)), -float(numpy.sum(numpy.asarray(solution) ** 2))] + + +def _three_objective_fitness(ga, solution, sol_idx): + return [float(solution[0]), float(solution[1]), float(solution[2])] + + +def _build_ga_and_grow(**kwargs): + """ + Create a GA with sol_per_pop small enough to trigger NSGA-III auto- + growth and run a single generation so the population is grown and + re-evaluated before the test assertion runs. + """ + defaults = dict( + num_generations=1, + num_parents_mating=3, + fitness_func=_three_objective_fitness, + sol_per_pop=4, + num_genes=4, + parent_selection_type='nsga3', + nsga3_num_divisions=4, + random_seed=7, + suppress_warnings=True, + ) + defaults.update(kwargs) + ga = pygad.GA(**defaults) + ga.run() + return ga + + +def test_population_growth_respects_init_range_low_and_high(): + # With per-gene init range, every gene in every grown row must sit + # inside its own [low, high] window. + init_range_low = [0.0, 1.0, 2.0, 3.0] + init_range_high = [0.5, 1.5, 2.5, 3.5] + ga = _build_ga_and_grow( + num_genes=4, + init_range_low=init_range_low, + init_range_high=init_range_high, + ) + population = numpy.asarray(ga.initial_population, dtype=float) + for gene_idx in range(population.shape[1]): + column = population[:, gene_idx] + assert column.min() >= init_range_low[gene_idx] + assert column.max() <= init_range_high[gene_idx] + + +def test_population_growth_respects_gene_space_discrete_values(): + # Every gene in every row must come from the discrete gene space. + allowed = [10, 20, 30] + ga = _build_ga_and_grow( + num_genes=4, + gene_type=int, + gene_space=allowed, + ) + population = numpy.asarray(ga.initial_population, dtype=int) + assert set(population.flatten().tolist()).issubset(set(allowed)) + + +def test_population_growth_respects_single_gene_type_int(): + # gene_type=int means every gene in the grown rows must be integer. + ga = _build_ga_and_grow( + num_genes=4, + gene_type=int, + ) + population = numpy.asarray(ga.initial_population) + assert population.dtype == int + + +def test_population_growth_respects_nested_gene_types_per_gene(): + # Mixed dtypes per gene (and a precision for the float gene). The + # int gene must round-trip to an int, the float gene must have at + # most 2 decimals. + gene_type = [int, [float, 2], int, [float, 3]] + ga = _build_ga_and_grow( + num_genes=4, + gene_type=gene_type, + ) + population = ga.initial_population + for sol_idx in range(population.shape[0]): + assert isinstance(population[sol_idx, 0], (int, numpy.integer)) + assert isinstance(population[sol_idx, 2], (int, numpy.integer)) + float_gene_one = float(population[sol_idx, 1]) + float_gene_two = float(population[sol_idx, 3]) + assert round(float_gene_one, 2) == float_gene_one + assert round(float_gene_two, 3) == float_gene_two + + +def test_population_growth_respects_allow_duplicate_genes_false(): + # When duplicates are not allowed, no row may contain the same gene + # value twice. + ga = _build_ga_and_grow( + num_genes=4, + gene_type=int, + gene_space=list(range(20)), + allow_duplicate_genes=False, + ) + population = numpy.asarray(ga.initial_population, dtype=int) + for row in population: + assert len(set(row.tolist())) == len(row) + + +def test_population_growth_respects_gene_constraint_callable(): + # Constraint forces gene 0 to be >= 5 and gene 1 to be even. Every + # grown row must satisfy both constraints. + def gene_zero_at_least_five(solution, values): + return [v for v in values if v >= 5] + + def gene_one_must_be_even(solution, values): + return [v for v in values if int(v) % 2 == 0] + + ga = _build_ga_and_grow( + num_genes=4, + gene_type=int, + gene_space=list(range(20)), + gene_constraint=[gene_zero_at_least_five, + gene_one_must_be_even, + None, + None], + ) + population = numpy.asarray(ga.initial_population, dtype=int) + assert (population[:, 0] >= 5).all() + assert (population[:, 1] % 2 == 0).all() + + +def test_generate_single_random_gene_uses_initial_population_range_not_mutation_range(): + # With gene_space=None the helper must sample from + # [init_range_low, init_range_high], not from + # [random_mutation_min_val, random_mutation_max_val]. Set the two + # windows to non-overlapping ranges so any leak from the mutation + # range is detectable. Seed numpy so the helper's draw is + # deterministic. + ga = pygad.GA( + num_generations=1, + num_parents_mating=3, + fitness_func=_two_objective_fitness, + sol_per_pop=4, + num_genes=2, + init_range_low=100.0, + init_range_high=200.0, + random_mutation_min_val=-200.0, + random_mutation_max_val=-100.0, + parent_selection_type='nsga2', + random_seed=11, + suppress_warnings=True, + ) + numpy.random.seed(11) + drawn_values = [ga._nsga3_generate_single_random_gene(gene_idx=0, partial_solution=numpy.empty(2, dtype=object)) + for _ in range(50)] + drawn = numpy.asarray(drawn_values, dtype=float).flatten() + assert drawn.min() >= 100.0 + assert drawn.max() <= 200.0 + + +def test_generate_single_random_gene_uses_gene_space_when_present(): + # With gene_space set the helper must draw from the gene space, not + # from the init range. Use disjoint init range and gene space so a + # leak is detectable. + allowed = [50, 51, 52] + ga = pygad.GA( + num_generations=1, + num_parents_mating=3, + fitness_func=_two_objective_fitness, + sol_per_pop=4, + num_genes=2, + gene_type=int, + gene_space=allowed, + init_range_low=-100, + init_range_high=-50, + parent_selection_type='nsga2', + random_seed=11, + suppress_warnings=True, + ) + numpy.random.seed(13) + drawn_values = [int(ga._nsga3_generate_single_random_gene(gene_idx=0, partial_solution=numpy.empty(2, dtype=object))) + for _ in range(50)] + assert set(drawn_values).issubset(set(allowed)) diff --git a/tests/test_quality_indicators.py b/tests/test_quality_indicators.py new file mode 100644 index 0000000..9c99774 --- /dev/null +++ b/tests/test_quality_indicators.py @@ -0,0 +1,119 @@ +import numpy +import pytest + +from pygad.utils import quality_indicators + + +# 2-objective fixture used for hand-derived expected values. Under +# PyGAD-max the reference must be strictly worse than every solution. +TWO_OBJECTIVE_FRONT = numpy.array([ + [-1.0, -8.0], + [-3.0, -5.0], + [-6.0, -2.0], +]) +TWO_OBJECTIVE_REFERENCE = numpy.array([-10.0, -10.0]) + +# Second front used by the IGD / GD tests. +APPROXIMATION_FRONT = numpy.array([ + [-1.0, -8.0], + [-4.0, -4.0], + [-7.0, -1.0], +]) + + +def test_hypervolume_two_d_matches_hand_computation(): + # In min space: ref (10, 10), points (1, 8), (3, 5), (6, 2). + # Sliced area from left to right: 4 + 15 + 32 = 51. + expected_hv = 4.0 + 15.0 + 32.0 + hv = quality_indicators.hypervolume(TWO_OBJECTIVE_FRONT, TWO_OBJECTIVE_REFERENCE) + assert hv == pytest.approx(expected_hv, abs=1e-9) + + +def test_hypervolume_single_solution_equals_box_volume(): + # Point (-2, -3), ref (-10, -10). In min: box = 8 * 7 = 56. + fitness = numpy.array([[-2.0, -3.0]]) + reference = numpy.array([-10.0, -10.0]) + assert quality_indicators.hypervolume(fitness, reference) == pytest.approx(56.0) + + +def test_hypervolume_drops_dominated_solutions(): + # The added row is dominated, so HV should not change. + extra = numpy.vstack([TWO_OBJECTIVE_FRONT, [[-4.0, -6.0]]]) + hv_clean = quality_indicators.hypervolume(TWO_OBJECTIVE_FRONT, TWO_OBJECTIVE_REFERENCE) + hv_with_dominated = quality_indicators.hypervolume(extra, TWO_OBJECTIVE_REFERENCE) + assert hv_clean == pytest.approx(hv_with_dominated, abs=1e-9) + + +def test_hypervolume_rejects_reference_point_inside_front(): + # The reference point must be strictly worse than every solution. + fitness = numpy.array([[-1.0, -2.0], [-3.0, -1.0]]) + bad_reference = numpy.array([0.0, 0.0]) + with pytest.raises(ValueError, match="smaller than every solution"): + quality_indicators.hypervolume(fitness, bad_reference) + + +def test_hypervolume_three_d_axis_aligned_extremes(): + # Three axis-extreme points, ref (-2,-2,-2). In min: each point + # dominates a 1x2x2 box, by inclusion-exclusion union = 7. + fitness = numpy.array([ + [-1.0, 0.0, 0.0], + [ 0.0, -1.0, 0.0], + [ 0.0, 0.0, -1.0], + ]) + reference = numpy.array([-2.0, -2.0, -2.0]) + hv = quality_indicators.hypervolume(fitness, reference) + assert hv == pytest.approx(7.0, abs=1e-9) + + +def test_inverted_generational_distance_zero_when_approximation_matches_reference(): + igd = quality_indicators.inverted_generational_distance( + TWO_OBJECTIVE_FRONT, TWO_OBJECTIVE_FRONT) + assert igd == pytest.approx(0.0, abs=1e-12) + + +def test_inverted_generational_distance_matches_hand_value(): + # Nearest approx per ref: + # (-1,-8) -> (-1,-8) = 0 + # (-3,-5) -> (-4,-4) = sqrt(2) + # (-6,-2) -> (-7,-1) = sqrt(2) + expected = (0.0 + numpy.sqrt(2.0) + numpy.sqrt(2.0)) / 3.0 + igd = quality_indicators.inverted_generational_distance( + APPROXIMATION_FRONT, TWO_OBJECTIVE_FRONT) + assert igd == pytest.approx(expected, abs=1e-12) + + +def test_generational_distance_matches_hand_value(): + # Symmetric of the IGD test: same pairings, same average. + expected = (0.0 + numpy.sqrt(2.0) + numpy.sqrt(2.0)) / 3.0 + gd = quality_indicators.generational_distance( + APPROXIMATION_FRONT, TWO_OBJECTIVE_FRONT) + assert gd == pytest.approx(expected, abs=1e-12) + + +def test_spacing_zero_for_equally_spaced_points(): + # Three colinear, equally spaced points -> nearest-neighbour + # distances are all 1 -> std = 0. + fitness = numpy.array([ + [0.0, 0.0], + [1.0, 0.0], + [2.0, 0.0], + ]) + assert quality_indicators.spacing(fitness) == pytest.approx(0.0, abs=1e-12) + + +def test_spacing_for_single_solution_returns_zero(): + # Undefined for one point; we return 0.0 to keep callers simple. + fitness = numpy.array([[1.0, 2.0]]) + assert quality_indicators.spacing(fitness) == 0.0 + + +def test_hypervolume_random_four_dim_matches_pinned_value(): + # 4-objective regression. Expected value was computed once on + # this exact array and pinned so the test is self-contained. + rng = numpy.random.default_rng(42) + fitness_min = rng.uniform(0.0, 1.0, size=(20, 4)) + fitness_max = -fitness_min + reference_max = numpy.array([-1.5, -1.5, -1.5, -1.5]) + expected_hv = 3.5205665111978677 + hv = quality_indicators.hypervolume(fitness_max, reference_max) + assert hv == pytest.approx(expected_hv, abs=1e-9) diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..ffe0b05 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,164 @@ +""" +Tests for the PDF report generator. + +The full check here is structural: we verify the report writes a +non-empty PDF, picks up the expected plot methods based on the run +configuration, refuses unknown sections, and works for both single- +objective and multi-objective runs. +""" + +import os + +import numpy +import pytest + +import pygad +from pygad.utils import report as report_module + + +pytest.importorskip("reportlab") +pytest.importorskip("matplotlib") + + +def _single_objective_fitness(ga, solution, sol_idx): + return float(numpy.sum(solution)) + + +def _two_objective_fitness(ga, solution, sol_idx): + return [float(numpy.sum(solution)), + -float(numpy.sum(numpy.asarray(solution) ** 2))] + + +def _build_soo_ga(**overrides): + defaults = dict( + num_generations=4, + num_parents_mating=4, + fitness_func=_single_objective_fitness, + sol_per_pop=8, + num_genes=3, + random_seed=0, + suppress_warnings=True, + ) + defaults.update(overrides) + return pygad.GA(**defaults) + + +def _build_moo_ga(**overrides): + defaults = dict( + num_generations=4, + num_parents_mating=4, + fitness_func=_two_objective_fitness, + sol_per_pop=8, + num_genes=3, + parent_selection_type='nsga2', + random_seed=0, + suppress_warnings=True, + ) + defaults.update(overrides) + return pygad.GA(**defaults) + + +def test_generate_report_writes_non_empty_pdf(tmp_path): + ga = _build_soo_ga(save_solutions=True, save_best_solutions=True) + ga.run() + output_path = ga.generate_report(str(tmp_path / "soo_report")) + assert output_path.endswith(".pdf") + assert os.path.exists(output_path) + assert os.path.getsize(output_path) > 1000 # PDF header alone is > 1kB + + +def test_generate_report_appends_pdf_extension(tmp_path): + ga = _build_soo_ga() + ga.run() + output_path = ga.generate_report(str(tmp_path / "no_extension")) + assert output_path == str(tmp_path / "no_extension.pdf") + + +def test_generate_report_refuses_unknown_section(tmp_path): + ga = _build_soo_ga() + ga.run() + with pytest.raises(ValueError, match="Unknown report sections"): + ga.generate_report(str(tmp_path / "bad_section"), + sections=["title", "does_not_exist"]) + + +def test_generate_report_refuses_unknown_plot_name(tmp_path): + ga = _build_soo_ga() + ga.run() + with pytest.raises(ValueError, match="Unknown plot method"): + ga.generate_report(str(tmp_path / "bad_plot"), + include_plots=["plot_nonexistent"]) + + +def test_generate_report_requires_at_least_one_completed_generation(tmp_path): + ga = _build_soo_ga() + with pytest.raises(RuntimeError, match="at least one"): + ga.generate_report(str(tmp_path / "empty")) + + +def test_select_plot_methods_filters_out_moo_only_plots_for_soo(): + ga = _build_soo_ga(save_solutions=True, save_best_solutions=True) + ga.run() + method_names = report_module._select_plot_methods(ga, include_plots=None) + assert "plot_fitness" in method_names + assert "plot_new_solution_rate" in method_names + assert "plot_pareto_front_curve" not in method_names + assert "plot_pareto_front_pcp" not in method_names + assert "plot_non_dominated_hypervolume" not in method_names + + +def test_select_plot_methods_includes_moo_plots_for_moo_with_save_solutions(): + ga = _build_moo_ga(save_solutions=True) + ga.run() + method_names = report_module._select_plot_methods(ga, include_plots=None) + assert "plot_fitness" in method_names + assert "plot_pareto_front_curve" in method_names + assert "plot_pareto_front_pcp" in method_names + assert "plot_pareto_front_heatmap" in method_names + assert "plot_non_dominated_hypervolume" in method_names + assert "plot_pareto_front_evolution" in method_names + + +def test_select_plot_methods_omits_save_solutions_plots_when_flag_is_false(): + ga = _build_moo_ga(save_solutions=False) + ga.run() + method_names = report_module._select_plot_methods(ga, include_plots=None) + assert "plot_fitness" in method_names + # plot_pareto_front_curve does not need save_solutions. + assert "plot_pareto_front_curve" in method_names + # plot_non_dominated_hypervolume does. + assert "plot_non_dominated_hypervolume" not in method_names + assert "plot_pareto_front_evolution" not in method_names + + +def test_generate_report_for_multi_objective_run(tmp_path): + ga = _build_moo_ga(save_solutions=True) + ga.run() + output_path = ga.generate_report(str(tmp_path / "moo_report")) + assert os.path.exists(output_path) + assert os.path.getsize(output_path) > 5000 + + +def test_generate_report_honors_section_order(tmp_path): + ga = _build_soo_ga() + ga.run() + output_path = ga.generate_report( + str(tmp_path / "ordered_report"), + sections=["title", "configuration"]) + assert os.path.exists(output_path) + + +def test_generate_report_supports_explicit_plot_list(tmp_path): + ga = _build_soo_ga() + ga.run() + output_path = ga.generate_report( + str(tmp_path / "fitness_only"), + include_plots=["plot_fitness"]) + assert os.path.exists(output_path) + + +def test_generate_report_unknown_page_size_raises(tmp_path): + ga = _build_soo_ga() + ga.run() + with pytest.raises(ValueError, match="Unknown page_size"): + ga.generate_report(str(tmp_path / "bad_paper"), page_size="A12") diff --git a/tests/test_sbx_polynomial.py b/tests/test_sbx_polynomial.py new file mode 100644 index 0000000..bf8e75a --- /dev/null +++ b/tests/test_sbx_polynomial.py @@ -0,0 +1,171 @@ +import numpy +import pytest + +import pygad + + +def _sum_fitness(ga, solution, sol_idx): + return float(numpy.sum(solution)) + + +def _make_ga(crossover_type="sbx", mutation_type="polynomial", + sbx_crossover_eta=30, polynomial_mutation_eta=20, + init_range_low=0.0, init_range_high=1.0, + num_generations=10, sol_per_pop=12, num_genes=4): + return pygad.GA(num_generations=num_generations, + num_parents_mating=6, + fitness_func=_sum_fitness, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + init_range_low=init_range_low, + init_range_high=init_range_high, + crossover_type=crossover_type, + mutation_type=mutation_type, + sbx_crossover_eta=sbx_crossover_eta, + polynomial_mutation_eta=polynomial_mutation_eta, + random_seed=42, + suppress_warnings=True) + + +def test_sbx_crossover_runs_and_keeps_population_shape(): + ga = _make_ga(mutation_type=None) + ga.run() + assert ga.population.shape == (12, 4) + + +def test_polynomial_mutation_runs_and_keeps_population_shape(): + ga = _make_ga(crossover_type="single_point") + ga.run() + assert ga.population.shape == (12, 4) + + +def test_sbx_offspring_stay_within_bounds(): + # SBX is bounded; offspring must never leave [init_range_low, + # init_range_high]. + ga = _make_ga(mutation_type=None, + init_range_low=-2.0, + init_range_high=2.0) + ga.run() + pop = numpy.asarray(ga.population, dtype=float) + assert pop.min() >= -2.0 - 1e-9 + assert pop.max() <= 2.0 + 1e-9 + + +def test_polynomial_mutation_offspring_stay_within_bounds(): + # Polynomial mutation is bounded; the mutated value must never leave + # the per-gene bounds. + ga = _make_ga(crossover_type="single_point", + init_range_low=-5.0, + init_range_high=5.0) + ga.run() + pop = numpy.asarray(ga.population, dtype=float) + assert pop.min() >= -5.0 - 1e-9 + assert pop.max() <= 5.0 + 1e-9 + + +def test_sbx_high_eta_keeps_children_close_to_parents(): + # A very large eta makes the SBX spread factor collapse so the + # child is essentially one of the parents on every gene (within + # numerical noise). Verify the offspring values are within the + # closed interval defined by the two parents on each gene. + nsga = pygad.GA(num_generations=1, num_parents_mating=2, + fitness_func=_sum_fitness, + sol_per_pop=4, num_genes=2, + init_range_low=0.0, init_range_high=1.0, + crossover_type='sbx', mutation_type=None, + sbx_crossover_eta=1e6, + random_seed=1, suppress_warnings=True) + parents = numpy.array([[0.2, 0.4], [0.6, 0.8]]) + offspring = nsga.sbx_crossover(parents, (1, 2)) + lower = numpy.minimum(parents[0], parents[1]) + upper = numpy.maximum(parents[0], parents[1]) + assert numpy.all(offspring[0] >= lower - 1e-6) + assert numpy.all(offspring[0] <= upper + 1e-6) + + +def test_polynomial_mutation_high_eta_makes_small_steps(): + # A very large eta should produce a mutated value almost equal to + # the input value because the polynomial step collapses to ~0. + ga = pygad.GA(num_generations=1, num_parents_mating=2, + fitness_func=_sum_fitness, + sol_per_pop=4, num_genes=3, + init_range_low=0.0, init_range_high=1.0, + crossover_type=None, mutation_type='polynomial', + polynomial_mutation_eta=1e6, + mutation_probability=1.0, + random_seed=7, suppress_warnings=True) + offspring = numpy.array([[0.3, 0.5, 0.7]]) + mutated = ga.polynomial_mutation(offspring.copy()) + numpy.testing.assert_allclose(mutated, offspring, atol=1e-3) + + +def test_sbx_crossover_eta_validation(): + with pytest.raises(ValueError, match="sbx_crossover_eta"): + pygad.GA(num_generations=2, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=2, + crossover_type='sbx', sbx_crossover_eta=-1, + suppress_warnings=True) + + +def test_polynomial_mutation_eta_validation(): + with pytest.raises(ValueError, match="polynomial_mutation_eta"): + pygad.GA(num_generations=2, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=2, + mutation_type='polynomial', polynomial_mutation_eta=0, + suppress_warnings=True) + + +def test_unknown_crossover_error_message_lists_sbx(): + # Make sure the user-facing error message mentions sbx as a valid + # option. + with pytest.raises(TypeError, match="sbx"): + pygad.GA(num_generations=2, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=2, + crossover_type='bogus', suppress_warnings=True) + + +def test_unknown_mutation_error_message_lists_polynomial(): + with pytest.raises(TypeError, match="polynomial"): + pygad.GA(num_generations=2, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=2, + mutation_type='bogus', suppress_warnings=True) + + +def test_sbx_with_fixed_seed_matches_pinned_output(): + # Pinned regression: with numpy.random seeded to 0 and the two + # parents below, the SBX formula must produce exactly these + # offspring values. The expected values come from the standard + # Deb-Beyer bounded SBX formula on these inputs. + ga = pygad.GA(num_generations=1, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=4, + init_range_low=0.0, init_range_high=1.0, + crossover_type='sbx', sbx_crossover_eta=30, + suppress_warnings=True) + parents = numpy.array([ + [0.2, 0.5, 0.7, 0.9], + [0.4, 0.3, 0.5, 0.1], + ]) + numpy.random.seed(0) + offspring = ga.sbx_crossover(parents, (2, 4)) + expected = numpy.array([ + [0.19966807185839858, 0.29816798996651034, 0.49925505848096274, 0.0987922279628985], + [0.20053305524715195, 0.29888084145301086, 0.500429179839271, 0.07981285137676747], + ]) + numpy.testing.assert_allclose(offspring, expected, atol=1e-12) + + +def test_polynomial_mutation_with_fixed_seed_matches_pinned_output(): + # Pinned regression: with numpy.random seeded to 0 and the input + # vector below, polynomial mutation must produce exactly these + # values. The expected values come from the standard Deb 1996 + # bounded polynomial mutation formula on these inputs. + ga = pygad.GA(num_generations=1, num_parents_mating=2, + fitness_func=_sum_fitness, sol_per_pop=4, num_genes=4, + init_range_low=0.0, init_range_high=1.0, + mutation_type='polynomial', polynomial_mutation_eta=20, + mutation_probability=1.0, suppress_warnings=True) + numpy.random.seed(0) + mutated = ga.polynomial_mutation(numpy.array([[0.5, 0.5, 0.5, 0.5]], dtype=float)) + expected = numpy.array([[0.5264432889397432, 0.5044687436392203, + 0.5162949167026651, 0.5702829850254326]]) + numpy.testing.assert_allclose(mutated, expected, atol=1e-12) diff --git a/tests/test_stop_criteria_extensions.py b/tests/test_stop_criteria_extensions.py new file mode 100644 index 0000000..dc95da6 --- /dev/null +++ b/tests/test_stop_criteria_extensions.py @@ -0,0 +1,116 @@ +import time + +import numpy +import pytest + +import pygad + + +def _slow_fitness(ga, solution, sol_idx): + # A small artificial delay so the "time" criterion can fire well + # before all generations complete. + time.sleep(0.005) + return float(numpy.sum(solution)) + + +def _fast_fitness(ga, solution, sol_idx): + return float(numpy.sum(solution)) + + +def test_max_evaluations_stops_run_after_budget_is_exhausted(): + # 50 solutions per population means the first generation alone + # uses ~50 evaluations. With keep_elitism=1 and num_parents_mating=50, + # subsequent generations evaluate only the new offspring, but the + # total still climbs past 100 well before 20 generations complete. + ga = pygad.GA(num_generations=20, + num_parents_mating=50, + fitness_func=_fast_fitness, + sol_per_pop=50, + num_genes=4, + stop_criteria="evaluations_100", + random_seed=1, + suppress_warnings=True) + ga.run() + assert ga.num_fitness_evaluations >= 100 + # The run should stop before completing all 20 generations. + assert ga.generations_completed < 20 + + +def test_max_evaluations_runs_full_when_budget_is_huge(): + # With a budget far larger than the total number of evaluations, + # the criterion never fires and all generations complete. + ga = pygad.GA(num_generations=5, + num_parents_mating=4, + fitness_func=_fast_fitness, + sol_per_pop=10, + num_genes=4, + stop_criteria="evaluations_10000", + random_seed=1, + suppress_warnings=True) + ga.run() + assert ga.generations_completed == 5 + + +def test_max_time_stops_run_after_budget_is_exhausted(): + # The slow fitness function takes ~5 ms per call. With 30 + # solutions and 50 generations, the run would normally take + # ~7.5 s. We cap at 0.2 s and expect the run to stop early. + budget_seconds = 0.2 + ga = pygad.GA(num_generations=50, + num_parents_mating=30, + fitness_func=_slow_fitness, + sol_per_pop=30, + num_genes=4, + stop_criteria=f"time_{budget_seconds}", + random_seed=1, + suppress_warnings=True) + start = time.monotonic() + ga.run() + elapsed = time.monotonic() - start + assert ga.generations_completed < 50 + # The actual elapsed time can exceed the budget slightly because + # the criterion is checked between generations. Allow a generous + # 2-second ceiling so the test stays robust on slow CI runners. + assert elapsed < 2.0 + + +def test_max_time_runs_full_when_budget_is_huge(): + ga = pygad.GA(num_generations=3, + num_parents_mating=4, + fitness_func=_fast_fitness, + sol_per_pop=8, + num_genes=4, + stop_criteria="time_3600", + random_seed=1, + suppress_warnings=True) + ga.run() + assert ga.generations_completed == 3 + + +def test_num_fitness_evaluations_resets_per_run_call(): + ga = pygad.GA(num_generations=3, + num_parents_mating=4, + fitness_func=_fast_fitness, + sol_per_pop=8, + num_genes=4, + random_seed=1, + suppress_warnings=True) + ga.run() + first_run_count = ga.num_fitness_evaluations + assert first_run_count > 0 + ga.run() + # Second call resets and counts only its own work; the count + # should not be the cumulative total of the two runs. + assert ga.num_fitness_evaluations < 2 * first_run_count + + +def test_unknown_stop_word_raises_value_error(): + with pytest.raises(ValueError, match="evaluations"): + # Sanity check: the error message lists the new stop words. + pygad.GA(num_generations=2, + num_parents_mating=2, + fitness_func=_fast_fitness, + sol_per_pop=4, + num_genes=2, + stop_criteria="bogus_5", + suppress_warnings=True) diff --git a/tests/test_visualize.py b/tests/test_visualize.py index a2900d0..8b50406 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -187,6 +187,162 @@ def test_visualize_save_dir(): print("test_visualize_save_dir passed.") +def _build_three_objective_ga(num_generations=4, sol_per_pop=10, save_solutions=False): + def fitness_func_three(ga_instance, solution, solution_idx): + return [numpy.sum(solution ** 2), + numpy.sum(solution), + float(solution[0])] + + ga = pygad.GA(num_generations=num_generations, + num_parents_mating=4, + fitness_func=fitness_func_three, + sol_per_pop=sol_per_pop, + num_genes=3, + parent_selection_type="nsga2", + save_solutions=save_solutions, + random_seed=0, + suppress_warnings=True) + ga.run() + return ga + + +def test_plot_pareto_front_curve_three_objectives_returns_3d_figure(): + ga = _build_three_objective_ga() + fig = ga.plot_pareto_front_curve() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_pareto_front_curve_rejects_four_or_more_objectives(): + def fitness_four(ga_instance, solution, solution_idx): + return [float(solution[0]), float(solution[1]), + float(solution[2]), float(solution[0]) + float(solution[1])] + + ga = pygad.GA(num_generations=2, + num_parents_mating=4, + fitness_func=fitness_four, + sol_per_pop=8, + num_genes=3, + parent_selection_type="nsga2", + random_seed=0, + suppress_warnings=True) + ga.run() + try: + ga.plot_pareto_front_curve() + except RuntimeError as exc: + assert "2 or 3 objectives" in str(exc) + else: + raise AssertionError("plot_pareto_front_curve() should reject M >= 4") + + +def test_plot_pareto_front_pcp_returns_figure(): + ga = _build_three_objective_ga() + fig = ga.plot_pareto_front_pcp() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_pareto_front_scatter_matrix_returns_figure(): + ga = _build_three_objective_ga() + fig = ga.plot_pareto_front_scatter_matrix() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_pareto_front_heatmap_returns_figure_and_validates_sort_by(): + ga = _build_three_objective_ga() + fig = ga.plot_pareto_front_heatmap() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + try: + ga.plot_pareto_front_heatmap(sort_by=99) + except ValueError: + pass + else: + raise AssertionError("plot_pareto_front_heatmap() should reject out-of-range sort_by") + + +def test_plot_fitness_band_requires_save_solutions(): + ga = pygad.GA(num_generations=3, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=6, + num_genes=2, + random_seed=0, + suppress_warnings=True) + ga.run() + try: + ga.plot_fitness_band() + except RuntimeError as exc: + assert "save_solutions" in str(exc) + else: + raise AssertionError("plot_fitness_band() should require save_solutions=True") + + +def test_plot_fitness_band_returns_figure_when_save_solutions_true(): + ga = pygad.GA(num_generations=3, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=6, + num_genes=2, + save_solutions=True, + random_seed=0, + suppress_warnings=True) + ga.run() + fig = ga.plot_fitness_band() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_non_dominated_hypervolume_returns_figure(): + ga = _build_three_objective_ga(save_solutions=True) + fig = ga.plot_non_dominated_hypervolume() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_non_dominated_hypervolume_requires_save_solutions(): + ga = _build_three_objective_ga(save_solutions=False) + try: + ga.plot_non_dominated_hypervolume() + except RuntimeError as exc: + assert "save_solutions" in str(exc) + else: + raise AssertionError("plot_non_dominated_hypervolume() should require save_solutions=True") + + +def test_plot_population_diversity_returns_figure(): + ga = pygad.GA(num_generations=4, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=6, + num_genes=3, + save_solutions=True, + random_seed=0, + suppress_warnings=True) + ga.run() + fig = ga.plot_population_diversity() + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_pareto_front_evolution_returns_figure(): + ga = _build_three_objective_ga(save_solutions=True) + fig = ga.plot_pareto_front_evolution(every_k=2) + assert isinstance(fig, matplotlib.figure.Figure) + plt.close(fig) + + +def test_plot_pareto_front_evolution_rejects_non_positive_k(): + ga = _build_three_objective_ga(save_solutions=True) + try: + ga.plot_pareto_front_evolution(every_k=0) + except ValueError: + pass + else: + raise AssertionError("plot_pareto_front_evolution() should reject every_k <= 0") + + if __name__ == "__main__": test_plot_fitness_parameters() test_plot_new_solution_rate_parameters()