From 6dbbe6c8955bde41f5830ab6535271a453a0fc74 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 15:22:26 -0400 Subject: [PATCH 01/22] Add configurable PyGAD lifecycle charts --- docs/source/figures/plot_lifecycle.png | Bin 0 -> 214427 bytes docs/source/lifecycle.md | 20 ++ docs/source/logging.md | 11 + docs/source/pygad.md | 3 + docs/source/releases.md | 1 + docs/source/visualize.md | 38 ++- examples/plots/example_plot_lifecycle.py | 49 +++ pygad/visualize/lifecycle.py | 418 +++++++++++++++++++++++ pygad/visualize/plot.py | 71 ++++ tests/test_plot_lifecycle.py | 344 +++++++++++++++++++ 10 files changed, 953 insertions(+), 2 deletions(-) create mode 100644 docs/source/figures/plot_lifecycle.png create mode 100644 examples/plots/example_plot_lifecycle.py create mode 100644 pygad/visualize/lifecycle.py create mode 100644 tests/test_plot_lifecycle.py diff --git a/docs/source/figures/plot_lifecycle.png b/docs/source/figures/plot_lifecycle.png new file mode 100644 index 0000000000000000000000000000000000000000..50ce7b757659ab7506d206caa339fee313df3577 GIT binary patch literal 214427 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%0D-g6@*#<8coV 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%0Db_ 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 I_wJMb0*1d_4gdfE literal 0 HcmV?d00001 diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index b066b344..80c48ab4 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -20,6 +20,26 @@ The next figure shows the same life cycle in more detail, including the callback The PyGAD life cycle in detail, including the callback functions called at each stage. ::: +## Plotting the Configured Lifecycle + +Call `plot_lifecycle()` to create a chart adjusted to the operators, callbacks, gene settings, and stopping conditions of a GA instance. It can be called before or after `run()`. + +```python +ga_instance.plot_lifecycle() +``` + +The chart shows the generation loop and exit paths. It includes population replacement and fitness evaluation before `on_generation`, and marks disabled crossover or mutation as bypassed. Configured callbacks still appear at their execution points. + +Use `show_parameters=False` for a compact chart, or save the figure by passing `save_dir`. The filename extension selects the output format. + +```python +ga_instance.plot_lifecycle(title="PyGAD - My Optimization Problem", + save_dir="lifecycle.svg", + show=False) +``` + +Drawing the chart does not run the GA or call user functions. See {ref}`plot_lifecycle() ` for the parameters, a sample chart, and a runnable example. To print a text description, use {ref}`summary() `. + ## Reporting Progress Use `on_generation` to report progress once a generation has completed. There is no need to change the fitness function or the GA operators: diff --git a/docs/source/logging.md b/docs/source/logging.md index f70ff3b7..a3bdfe39 100644 --- a/docs/source/logging.md +++ b/docs/source/logging.md @@ -2,6 +2,7 @@ This page covers how to see what PyGAD is doing: printing a lifecycle summary and logging the outputs. +(print-lifecycle-summary)= ## Print Lifecycle Summary In [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0), a new method called `summary()` is supported. It prints a Keras-like summary of the PyGAD lifecycle showing the steps, callback functions, parameters, etc. @@ -121,6 +122,16 @@ On Generation on_gen() None ====================================================================== ``` +## Plot Lifecycle Chart + +Use `plot_lifecycle()` to draw the configured lifecycle as a flowchart with operators, callbacks, population replacement, and stopping decisions. It works before or after `run()`. + +```python +ga_instance.plot_lifecycle(save_dir="lifecycle.svg") +``` + +The method returns a matplotlib figure. Use `show_parameters=False` for a compact chart or `show=False` to save without displaying it. See {ref}`plot_lifecycle() ` for the full description and an example. + ## Logging Outputs In [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0), the `print()` statement is no longer used and the outputs are printed using the [logging](https://docs.python.org/3/library/logging.html) module. A new parameter called `logger` is supported to accept a user-defined logger. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index b660accc..6499f17e 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -530,6 +530,8 @@ Here is the list of scripts and the classes that the `pygad.GA` class extends: 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). +13. `visualize/lifecycle.py` + 1. Internal helpers that describe the configured lifecycle and draw its flowchart without running the GA or calling user functions. `utils.engine.GAEngine` also extends `utils.parallel.FitnessEvaluation`, so `pygad.GA` indirectly inherits its fitness dispatch and serialization methods. See the {ref}`pygad.utils.parallel reference ` for all of its methods, the process-worker function, and runtime attributes. @@ -568,6 +570,7 @@ Constructor settings and user callables are stored as instance attributes, with - `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). +- `plot_lifecycle(title="PyGAD - Lifecycle", font_size=11, show_parameters=True, save_dir=None, show=True)`: Draws the configured lifecycle with operators, callbacks, population replacement, and stopping decisions. Works before or after `run()` and returns a matplotlib figure. See {ref}`plot_lifecycle() `. #### Population and Initialization diff --git a/docs/source/releases.md b/docs/source/releases.md index 301efedc..aead10cb 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -738,5 +738,6 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). 12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). 13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 99c1e943..bfe0a4aa 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -1,6 +1,6 @@ # `pygad.visualize` Module -The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. +The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. `plot_lifecycle()` can also be called before `run()` because it draws the configured execution flow. 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). @@ -8,6 +8,7 @@ Every method returns the `matplotlib.figure.Figure` it created and optionally wr | Method | Works for | Needs `save_solutions=True` | |---|---|---| +| `plot_lifecycle()` | SOO + MOO, before or after `run()` | no | | `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"`) | @@ -20,7 +21,40 @@ Every method returns the `matplotlib.figure.Figure` it created and optionally wr | `plot_population_diversity()` | SOO + MOO | yes | | `plot_pareto_front_evolution()` | MOO (M=2 or M=3) | yes | -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. +Except for `plot_lifecycle()`, 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. + +(plot-lifecycle)= +## `plot_lifecycle()` + +Draw the lifecycle configured for a GA instance: initial fitness evaluation, parent selection, crossover, mutation, population update, fitness reevaluation, and the generation loop. The chart includes the configured callbacks at their execution points, a generation-limit decision, and early stopping when a stopping criterion is set or `on_generation` can return `"stop"`. + +```python +ga_instance.plot_lifecycle() +``` + +![plot_lifecycle](figures/plot_lifecycle.png) + +Operator cards show handler names, relevant probabilities, and parent or offspring shapes. The population update shows the effective retention policy: `keep_elitism` takes precedence over `keep_parents`. The configuration panel shows population size, generations per `run()`, gene types and precision, gene space or initialization range, constraints, and saving settings. Fitness batching and parallel processing appear when configured. Long gene configurations are abbreviated to keep the chart readable. + +Callbacks appear only when supplied. If `crossover_type=None` or `mutation_type=None`, the corresponding card is marked as bypassed. Configured `on_crossover` and `on_mutation` callbacks still appear because they run even when the operator is disabled. Adaptive mutation includes its additional offspring fitness evaluation, and NSGA-III includes reference-point preparation. NSGA-III may grow the population during this preparation; shapes in a chart drawn before `run()` describe the current configuration. + +Parameters: `title` (default `"PyGAD - Lifecycle"`), `font_size` (default `11`, finite and positive), `show_parameters` (default `True`), `save_dir` (default `None`), `show` (default `True`). + +Use `show_parameters=False` for a compact chart that keeps handler names and control flow. Set `show=False` to create or save a chart without displaying it. The method always returns the figure, so it can be customized further. + +```python +# Save a detailed chart. The filename extension selects SVG, PNG, or PDF. +fig = ga_instance.plot_lifecycle(title="PyGAD - Scheduling Optimization", + save_dir="lifecycle.svg", + show=False) + +# Display a compact chart. +ga_instance.plot_lifecycle(show_parameters=False) +``` + +The method reads the current GA configuration without evaluating fitness, calling operators or callbacks, or changing GA state. It describes the configured flow rather than recording the path taken during a run. Before fitness is available, the objective count is marked as unknown. After a run, the chart can show the known objective count and fitness shape. Each `run()` call uses the configured generation count, including when continuing a previous run. + +Install the optional plotting dependency with `pip install pygad[visualize]`. A complete example is available at [`examples/plots/example_plot_lifecycle.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/plots/example_plot_lifecycle.py). ## `plot_fitness()` diff --git a/examples/plots/example_plot_lifecycle.py b/examples/plots/example_plot_lifecycle.py new file mode 100644 index 00000000..28f08e1d --- /dev/null +++ b/examples/plots/example_plot_lifecycle.py @@ -0,0 +1,49 @@ +"""Configured lifecycle for a single-objective GA on the Sphere benchmark.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + + +def report_generation(ga_instance): + """Report progress using fitness already calculated by the GA.""" + solution, fitness, solution_idx = ga_instance.best_solution( + pop_fitness=ga_instance.last_generation_fitness) + print(f"Generation {ga_instance.generations_completed}: best fitness = {fitness}") + + +ga_instance = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + gene_type=[float, 3], + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type="tournament", + K_tournament=3, + crossover_type="uniform", + crossover_probability=0.8, + mutation_type="adaptive", + mutation_probability=[0.2, 0.05], + keep_elitism=2, + on_generation=report_generation, + stop_criteria="saturate_10", + random_seed=42) + +# The configuration is enough to draw the chart. No fitness function +# or callback is called by plot_lifecycle(). SVG stays sharp when resized. +ga_instance.plot_lifecycle(title="PyGAD - Sphere Optimization", + save_dir="lifecycle.svg") + +ga_instance.run() + +# After run(), the chart also shows the known number of objectives +# and the fitness shape. show=False saves it without opening a window. +ga_instance.plot_lifecycle(title="PyGAD - Sphere Optimization", + save_dir="lifecycle_after_run.png", + show=False) + +# A compact view keeps operator and callback names and the control flow. +ga_instance.plot_lifecycle(show_parameters=False) diff --git a/pygad/visualize/lifecycle.py b/pygad/visualize/lifecycle.py new file mode 100644 index 00000000..9fd1f059 --- /dev/null +++ b/pygad/visualize/lifecycle.py @@ -0,0 +1,418 @@ +""" +Internal helpers for describing and drawing a configured PyGAD lifecycle. + +The description reads GA settings and already available fitness only. +It never runs user code. Matplotlib is imported only when drawing. +""" + +import reprlib +import textwrap + +import numpy + + +def _lifecycle_handler_name(handler): + """Return a readable function, method, or callable-instance name.""" + return getattr(handler, "__name__", type(handler).__name__) + "()" + + +def _lifecycle_parameter_text(value): + """Abbreviate large settings so gene lists cannot fill the chart.""" + if callable(value): + return getattr(value, "__name__", type(value).__name__) + if isinstance(value, numpy.ndarray): + # Do not change NumPy's global print options to format a chart. + array_text = numpy.array2string(value, threshold=6, edgeitems=2, + max_line_width=60).replace("\n", " ") + return array_text if len(array_text) <= 100 else array_text[:97] + "..." + formatter = reprlib.Repr() + formatter.maxlist = 4 + formatter.maxtuple = 4 + formatter.maxdict = 3 + formatter.maxstring = 65 + formatter.maxother = 65 + return formatter.repr(value) + + +def _describe_lifecycle(ga_instance, show_parameters=True): + """ + Build stages, connections, and configuration for one GA instance. + + Stage identifiers describe execution points rather than operator + names. Both fitness evaluations therefore remain distinct, even + though they call the same fitness function. Connections include + the zero-generation exit and the return to the loop's entry. + + Returns + ------- + lifecycle : dict + ``stages`` is an ordered list of stage dictionaries. + ``connections`` lists source, target, label, and route. + ``configuration`` lists labels and abbreviated setting values. + All helpers in this module are internal. + """ + stages = [] + + def add_stage(identifier, title, kind="operation", handler=None, parameters=None): + """Append a stage, keeping handler names in the compact view.""" + details = [] + if handler is not None: + details.append(_lifecycle_handler_name(handler)) + if show_parameters and parameters: + details.extend(parameters) + stages.append({"id": identifier, "title": title, + "kind": kind, "details": details}) + + def add_callback(name): + """Only show callbacks that the GA instance actually uses.""" + callback = getattr(ga_instance, name) + if callback is not None: + add_stage(name, name + "()", "callback", handler=callback) + + population_shape = f"({ga_instance.sol_per_pop}, {ga_instance.num_genes})" + offspring_shape = f"({ga_instance.num_offspring}, {ga_instance.num_genes})" + fitness_parameters = [] + if ga_instance.last_generation_fitness is not None: + fitness_shape = numpy.shape(ga_instance.last_generation_fitness) + fitness_parameters.append(f"Population fitness: {fitness_shape}") + if ga_instance.fitness_batch_size is not None and ga_instance.fitness_batch_size > 1: + fitness_parameters.append(f"Batch size: {ga_instance.fitness_batch_size}") + fitness_parameters.append("Reuse available fitness where applicable") + + add_stage("population", "Population ready", "population", + parameters=[f"Shape: {population_shape}", "Prepared before run()"]) + add_callback("on_start") + add_stage("initial_fitness", "Evaluate initial fitness", handler=ga_instance.fitness_func, + parameters=fitness_parameters) + if ga_instance.parent_selection_type in ("nsga3", "tournament_nsga3"): + add_stage("reference_points", "Prepare NSGA-III reference points", + parameters=[f"Divisions: {ga_instance.nsga3_num_divisions}", + "Grow population and evaluate added solutions if needed"]) + + add_stage("generation_check", "Generations remaining?", "decision", + parameters=[f"{ga_instance.num_generations} generations per run()"]) + add_callback("on_fitness") + + selection_parameters = [f"Parents: ({ga_instance.num_parents_mating}, {ga_instance.num_genes})"] + if ga_instance.parent_selection_type in ("tournament", "tournament_nsga2", "tournament_nsga3"): + selection_parameters.append(f"Tournament size: {ga_instance.K_tournament}") + add_stage("selection", "Select parents", handler=ga_instance.select_parents, + parameters=selection_parameters) + add_callback("on_parents") + + crossover_parameters = [f"Offspring: {offspring_shape}"] + if ga_instance.crossover_type is None: + add_stage("crossover", "Crossover bypassed", "bypass", + parameters=["Copy existing solutions", f"Offspring: {offspring_shape}"]) + else: + if not callable(ga_instance.crossover_type): + if ga_instance.crossover_probability is not None: + crossover_parameters.append(f"Probability: {ga_instance.crossover_probability}") + if ga_instance.crossover_type == "sbx": + crossover_parameters.append(f"Distribution index: {ga_instance.sbx_crossover_eta}") + add_stage("crossover", "Crossover", handler=ga_instance.crossover, + parameters=crossover_parameters) + # These callbacks run even when the corresponding operator is None. + add_callback("on_crossover") + + mutation_parameters = [f"Offspring: {offspring_shape}"] + if ga_instance.mutation_type is None: + add_stage("mutation", "Mutation bypassed", "bypass", + parameters=["Keep offspring unchanged", f"Offspring: {offspring_shape}"]) + else: + if ga_instance.mutation_type in ("random", "adaptive", "polynomial"): + if ga_instance.mutation_type == "polynomial": + probability = ga_instance.mutation_probability + if probability is None: + probability = 1.0 / ga_instance.num_genes + mutation_parameters.extend([f"Probability per gene: {probability}", + f"Distribution index: {ga_instance.polynomial_mutation_eta}"]) + elif ga_instance.mutation_probability is not None: + mutation_parameters.append("Probability per gene: " + + _lifecycle_parameter_text(ga_instance.mutation_probability)) + else: + mutation_parameters.append("Genes to mutate: " + + _lifecycle_parameter_text(ga_instance.mutation_num_genes)) + if ga_instance.mutation_type in ("random", "adaptive"): + if ga_instance.gene_space is None: + mutation_parameters.append("Random range: " + _lifecycle_parameter_text( + (ga_instance.random_mutation_min_val, ga_instance.random_mutation_max_val))) + else: + mutation_parameters.append("Use configured gene space") + mutation_parameters.append("Replace gene values" if ga_instance.mutation_by_replacement + else "Add random values when no gene space is set") + if ga_instance.mutation_type == "adaptive": + mutation_parameters.append("Evaluate offspring fitness to choose mutation amount") + mutation_parameters.append("Control values: low-quality, high-quality offspring") + add_stage("mutation", "Mutation", handler=ga_instance.mutation, + parameters=mutation_parameters) + add_callback("on_mutation") + + # Elitism overrides keep_parents; display the effective policy only. + if ga_instance.keep_elitism > 0: + retention_text = f"Keep {ga_instance.keep_elitism} elite solution(s)" + elif ga_instance.keep_parents == -1: + retention_text = f"Keep all {ga_instance.num_parents_mating} selected parents" + elif ga_instance.keep_parents > 0: + retention_text = f"Keep {ga_instance.keep_parents} parent(s)" + else: + retention_text = "Keep no parents or elite solutions" + add_stage("update_population", "Update population", + parameters=[retention_text, f"Add {ga_instance.num_offspring} offspring", + f"Population: {population_shape}"]) + add_stage("generation_fitness", "Evaluate updated population", handler=ga_instance.fitness_func, + parameters=fitness_parameters) + add_callback("on_generation") + + stopping_parameters = [] + if ga_instance.on_generation is not None: + stopping_parameters.append('on_generation() returns "stop"') + if ga_instance.stop_criteria is not None: + for criterion in ga_instance.stop_criteria: + stopping_parameters.append("_".join(str(value) for value in criterion)) + if stopping_parameters: + add_stage("early_stop", "Stop early?", "decision", + parameters=stopping_parameters) + last_generation_stage = stages[-1]["id"] + + add_stage("finalize", "Finalize results", + parameters=["Refresh final parents and elitism", "Record best solution fitness"]) + add_callback("on_stop") + add_stage("complete", "Run complete", "end") + + connections = [] + for source, target in zip(stages, stages[1:]): + # The body returns to the generation check, rather than falling + # through to finalization after the first generation. + if source["id"] == last_generation_stage: + continue + label = "Yes" if source["id"] == "generation_check" else "" + connections.append({"source": source["id"], "target": target["id"], + "label": label, "route": "forward"}) + connections.append({"source": "generation_check", "target": "finalize", + "label": "No", "route": "finish"}) + connections.append({"source": last_generation_stage, "target": "generation_check", + "label": "No" if stopping_parameters else "Next generation", + "route": "repeat"}) + if stopping_parameters: + connections.append({"source": "early_stop", "target": "finalize", + "label": "Yes", "route": "finish"}) + + configuration = [] + if show_parameters: + if ga_instance.gene_type_single: + gene_types = [ga_instance.gene_type] + else: + gene_types = ga_instance.gene_type + gene_type_names = [] + for gene_type, precision in gene_types: + name = getattr(gene_type, "__name__", str(gene_type)) + if precision is not None: + name += f" ({precision} decimal places)" + gene_type_names.append(name) + gene_type_text = (gene_type_names[0] if ga_instance.gene_type_single + else "Per gene: " + _lifecycle_parameter_text(gene_type_names)) + configuration.extend([("Population", population_shape), + ("Generations per run", str(ga_instance.num_generations)), + ("Gene type", gene_type_text)]) + if ga_instance.stop_criteria is not None: + criteria_text = ["_".join(str(value) for value in criterion) + for criterion in ga_instance.stop_criteria] + configuration.append(("Stop when any criterion is met", ", ".join(criteria_text))) + if ga_instance.on_generation is not None: + configuration.append(("Callback stop", 'on_generation() returns "stop"')) + if ga_instance.last_generation_fitness is not None: + first_fitness = ga_instance.last_generation_fitness[0] + objectives = len(first_fitness) if isinstance(first_fitness, (list, tuple, numpy.ndarray)) else 1 + configuration.append(("Objectives", str(objectives))) + else: + configuration.append(("Objectives", "Known after fitness evaluation")) + if ga_instance.gene_space is not None: + configuration.append(("Gene space", _lifecycle_parameter_text(ga_instance.gene_space))) + else: + configuration.append(("Initial population range", _lifecycle_parameter_text( + (ga_instance.init_range_low, ga_instance.init_range_high)))) + if ga_instance.gene_constraint is not None: + constraint_count = sum(constraint is not None for constraint in ga_instance.gene_constraint) + configuration.append(("Gene constraints", f"{constraint_count} constrained gene(s)")) + configuration.append(("Allow duplicate genes", str(ga_instance.allow_duplicate_genes))) + if ga_instance.parallel_processing is not None: + configuration.append(("Parallel fitness evaluation", _lifecycle_parameter_text(ga_instance.parallel_processing))) + if ga_instance.random_seed is not None: + configuration.append(("Random seed", str(ga_instance.random_seed))) + configuration.extend([("Save solutions", str(ga_instance.save_solutions)), + ("Save best solutions", str(ga_instance.save_best_solutions))]) + + return {"stages": stages, "connections": connections, + "configuration": configuration} + + +def _draw_lifecycle(lifecycle, matplt, title, font_size): + """ + Render a lifecycle description as a vertically arranged flowchart. + + Card heights follow wrapped text lengths. The repeat connection + runs on the left and exit connections run on the right, keeping + arrows outside the cards. Coordinate units correspond to inches + at the default font size; scaling the figure keeps text readable. + """ + from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon + from matplotlib.path import Path + from matplotlib.font_manager import FontProperties + from matplotlib.textpath import TextToPath + + text_measurement = TextToPath() + + def wrap_text(text, width_inches, text_font_size, weight="normal"): + """Wrap using font measurements, including long handler names.""" + font_properties = FontProperties(size=text_font_size, weight=weight) + # Coordinates scale with font_size, so compare widths at the + # corresponding figure scale. Math characters remain literal, + # just as they do in the rendered labels (parse_math=False). + available_width = width_inches * 72 * font_size / 11 + approximate_columns = max(1, int(available_width / (text_font_size * 0.55))) + wrapped_lines = [] + for line in textwrap.wrap(text, width=approximate_columns): + while line: + fitted_length = len(line) + while fitted_length > 1: + measured_width, _, _ = text_measurement.get_text_width_height_descent( + line[:fitted_length], font_properties, ismath=False) + if measured_width <= available_width: + break + fitted_length -= 1 + if fitted_length < len(line): + last_space = line.rfind(" ", 0, fitted_length + 1) + if last_space > 0: + fitted_length = last_space + wrapped_lines.append(line[:fitted_length].strip()) + line = line[fitted_length:].lstrip() + return wrapped_lines + + colors = {"operation": ("#eef4ff", "#4773ba"), + "population": ("#e8eef9", "#4773ba"), + "callback": ("#e6f5ef", "#26866c"), + "decision": ("#fff4da", "#b38325"), + "bypass": ("#f1f3f5", "#8492a3"), + "end": ("#253e65", "#253e65")} + text_color = "#25354b" + arrow_color = "#718198" + stage_center = 3.4 + stage_width = 4.5 + stage_gap = 0.34 + stage_positions = {} + wrapped_stages = [] + figure_width = 10.4 if lifecycle["configuration"] else 7.0 + title_lines = wrap_text(title, figure_width - 1.4, font_size * 1.4, "bold") or [""] + current_top = 0.82 + 0.27 * len(title_lines) + + for stage in lifecycle["stages"]: + title_text = wrap_text(stage["title"], stage_width - 0.4, font_size, "bold") + detail_lines = [] + # Decision settings live in the configuration panel. Keeping + # just the question in each diamond avoids crowded corners. + if stage["kind"] != "decision": + for detail in stage["details"]: + detail_lines.extend(wrap_text(detail, stage_width - 0.4, font_size * 0.88)) + stage_height = 0.30 + 0.20 * len(title_text) + 0.17 * len(detail_lines) + if stage["kind"] == "decision": + # A broad diamond keeps its text inside the sloping edges. + stage_height = max(0.95, stage_height + 0.45) + stage_positions[stage["id"]] = {"top": current_top, "bottom": current_top + stage_height, + "center": current_top + stage_height / 2, + "height": stage_height} + wrapped_stages.append((stage, title_text, detail_lines)) + current_top += stage_height + stage_gap + + configuration_lines = [] + for label, value in lifecycle["configuration"]: + configuration_lines.append((wrap_text(label, 2.65, font_size * 0.86, "bold"), + wrap_text(value, 2.65, font_size * 0.84))) + configuration_height = 0.65 + sum(0.20 * len(label_lines) + 0.17 * len(value_lines) + 0.17 + for label_lines, value_lines in configuration_lines) + figure_height = max(current_top + 0.55, configuration_height + 2.0) + figure_scale = font_size / 11 + fig, axes = matplt.subplots(figsize=(figure_width * figure_scale, figure_height * figure_scale)) + fig.patch.set_facecolor("#ffffff") + fig.subplots_adjust(left=0, right=1, bottom=0, top=1) + axes.set_xlim(0, figure_width) + axes.set_ylim(figure_height, 0) + axes.axis("off") + axes.text(0.7, 0.28, "\n".join(title_lines), fontsize=font_size * 1.4, + weight="bold", color=text_color, va="top", parse_math=False) + axes.text(0.7, current_top + 0.12, "Configured lifecycle | Operators / callbacks / decisions", + fontsize=font_size * 0.82, color=arrow_color, va="top", parse_math=False) + + for stage, title_text, detail_lines in wrapped_stages: + position = stage_positions[stage["id"]] + fill_color, border_color = colors[stage["kind"]] + if stage["kind"] == "decision": + card = Polygon([(stage_center, position["top"]), + (stage_center + stage_width / 2, position["center"]), + (stage_center, position["bottom"]), + (stage_center - stage_width / 2, position["center"])], + facecolor=fill_color, edgecolor=border_color, linewidth=1.2) + else: + card = FancyBboxPatch((stage_center - stage_width / 2, position["top"]), + stage_width, position["height"], + boxstyle="round,pad=0,rounding_size=0.10", + facecolor=fill_color, edgecolor=border_color, linewidth=1.2, + linestyle="--" if stage["kind"] == "bypass" else "-") + axes.add_patch(card) + text_top = position["center"] - (0.20 * len(title_text) + 0.17 * len(detail_lines)) / 2 + stage_text_color = "#ffffff" if stage["kind"] == "end" else text_color + axes.text(stage_center, text_top, "\n".join(title_text), ha="center", va="top", + fontsize=font_size, weight="bold", color=stage_text_color, + linespacing=1.2, parse_math=False) + if detail_lines: + axes.text(stage_center, text_top + 0.20 * len(title_text) + 0.03, + "\n".join(detail_lines), ha="center", va="top", + fontsize=font_size * 0.88, color=stage_text_color, + linespacing=1.2, parse_math=False) + + for connection in lifecycle["connections"]: + source = stage_positions[connection["source"]] + target = stage_positions[connection["target"]] + if connection["route"] == "forward": + points = [(stage_center, source["bottom"]), (stage_center, target["top"])] + label_position = (stage_center + 0.15, (source["bottom"] + target["top"]) / 2) + elif connection["route"] == "finish": + exit_column = 6.25 + points = [(stage_center + stage_width / 2, source["center"]), + (exit_column, source["center"]), (exit_column, target["center"]), + (stage_center + stage_width / 2, target["center"])] + label_position = (5.83, source["center"] - 0.13) + else: + repeat_column = 0.52 + points = [(stage_center - stage_width / 2, source["center"]), + (repeat_column, source["center"]), (repeat_column, target["center"]), + (stage_center - stage_width / 2, target["center"])] + label_position = (repeat_column - 0.20, (source["center"] + target["center"]) / 2) + arrow_path = Path(points, [Path.MOVETO] + [Path.LINETO] * (len(points) - 1)) + axes.add_patch(FancyArrowPatch(path=arrow_path, arrowstyle="-|>", + mutation_scale=11 * figure_scale, + color=arrow_color, linewidth=1.2, zorder=0)) + if connection["label"]: + axes.text(*label_position, connection["label"], fontsize=font_size * 0.82, + color=arrow_color, va="center", + rotation=90 if connection["route"] == "repeat" else 0, + parse_math=False) + + if configuration_lines: + panel_left = 6.85 + panel_top = stage_positions["population"]["top"] + axes.add_patch(FancyBboxPatch((panel_left, panel_top), 3.15, configuration_height, + boxstyle="round,pad=0,rounding_size=0.10", + facecolor="#f7f9fc", edgecolor="#dce3ed")) + axes.text(panel_left + 0.22, panel_top + 0.22, "Configuration", weight="bold", + fontsize=font_size * 1.05, color=text_color, va="top", parse_math=False) + configuration_top = panel_top + 0.65 + for label_lines, value_lines in configuration_lines: + axes.text(panel_left + 0.22, configuration_top, "\n".join(label_lines), weight="bold", + fontsize=font_size * 0.86, color=text_color, va="top", parse_math=False) + axes.text(panel_left + 0.22, configuration_top + 0.20 * len(label_lines), "\n".join(value_lines), + fontsize=font_size * 0.84, color=text_color, va="top", + linespacing=1.2, parse_math=False) + configuration_top += 0.20 * len(label_lines) + 0.17 * len(value_lines) + 0.17 + + return fig diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index e0a756e0..e2b6266d 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -26,6 +26,77 @@ class Plot: def __init__(): pass + def plot_lifecycle(self, + title="PyGAD - Lifecycle", + font_size=11, + show_parameters=True, + save_dir=None, + show=True): + """ + Draw the configured lifecycle, including active operators, + callbacks, the generation loop, and stopping conditions. + + Can be called before or after ``run()``. Drawing the chart + does not evaluate fitness, call callbacks, or change GA state. + Objective counts are shown only when fitness is already known. + + Parameters + ---------- + title : str + Figure title. Use a problem name to identify the chart. + font_size : numeric + Positive font size. The figure scales with the font size. + show_parameters : bool + If True, include stage parameters and a configuration + panel. If False, show a compact chart with handler names. + save_dir : str or None + If set, save the figure to this path. The extension + determines the format, for example SVG, PNG, or PDF. + show : bool + If True, display the figure. Set to False when saving + charts in scripts, notebooks, or reports without showing + a window. The figure is returned in either case. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure that was created. + + Raises + ------ + TypeError + If a parameter has an unsupported type. + ValueError + If ``font_size`` is not finite and positive. + ImportError + If the optional matplotlib dependency is not installed. + """ + if not isinstance(title, str): + raise TypeError("The title parameter must be a string.") + if isinstance(font_size, bool) or not isinstance(font_size, (int, float, numpy.integer, numpy.floating)): + raise TypeError("The font_size parameter must be a positive number.") + if not numpy.isfinite(font_size) or font_size <= 0: + raise ValueError("The font_size parameter must be finite and greater than 0.") + if not isinstance(show_parameters, bool) or not isinstance(show, bool): + raise TypeError("The show_parameters and show parameters must be bool values.") + + # Keep chart construction separate from rendering so its flow + # can be checked without importing matplotlib or running a GA. + from pygad.visualize.lifecycle import _describe_lifecycle, _draw_lifecycle + lifecycle = _describe_lifecycle(self, show_parameters=show_parameters) + try: + matplt = get_matplotlib() + except ImportError as exc: + raise ImportError("plot_lifecycle requires matplotlib. Install it with: " + "pip install pygad[visualize] (or pip install matplotlib).") from exc + + fig = _draw_lifecycle(lifecycle, matplt, title, font_size) + if save_dir is not None: + fig.savefig(fname=save_dir, bbox_inches="tight") + if show: + matplt.show() + return fig + def plot_fitness(self, title="PyGAD - Generation vs. Fitness", xlabel="Generation", diff --git a/tests/test_plot_lifecycle.py b/tests/test_plot_lifecycle.py new file mode 100644 index 00000000..14da0a9f --- /dev/null +++ b/tests/test_plot_lifecycle.py @@ -0,0 +1,344 @@ +"""Tests for configuration charts without executing the configured GA.""" + +import random +import subprocess +import sys + +import numpy +import pytest + +import pygad +from pygad.visualize.lifecycle import _describe_lifecycle + +matplotlib = pytest.importorskip("matplotlib") +matplotlib.use("Agg") +import matplotlib.pyplot as matplt + + +def fitness_func(ga_instance, solution, solution_idx): + return float(numpy.sum(solution)) + + +def create_ga_instance(**parameters): + """Create a small GA, allowing each test to choose its configuration.""" + configuration = dict(num_generations=2, num_parents_mating=3, + sol_per_pop=8, num_genes=4, + fitness_func=fitness_func, random_seed=17, + suppress_warnings=True) + configuration.update(parameters) + return pygad.GA(**configuration) + + +def figure_text(fig): + """Read the visible labels rather than inspecting rendering details.""" + return "\n".join(text.get_text().replace("\n", " ") + for axes in fig.axes for text in axes.texts) + + +def test_plot_lifecycle_before_run_has_no_execution_side_effects(): + calls = [] + + def fitness_func_not_called(ga_instance, solution, solution_idx): + calls.append("fitness") + return 1.0 + + def on_start(ga_instance): + calls.append("on_start") + + def on_generation(ga_instance): + calls.append("on_generation") + + ga_instance = create_ga_instance(fitness_func=fitness_func_not_called, + on_start=on_start, on_generation=on_generation) + original_population = ga_instance.population.copy() + original_numpy_random_state = numpy.random.get_state() + original_python_random_state = random.getstate() + original_attribute_names = set(vars(ga_instance)) + fig = ga_instance.plot_lifecycle(show=False) + try: + assert isinstance(fig, matplotlib.figure.Figure) + assert "Known after fitness evaluation" in figure_text(fig) + assert calls == [] + assert ga_instance.generations_completed == 0 + assert ga_instance.last_generation_fitness is None + assert set(vars(ga_instance)) == original_attribute_names + numpy.testing.assert_array_equal(ga_instance.population, original_population) + current_numpy_random_state = numpy.random.get_state() + assert current_numpy_random_state[0] == original_numpy_random_state[0] + numpy.testing.assert_array_equal(current_numpy_random_state[1], original_numpy_random_state[1]) + assert current_numpy_random_state[2:] == original_numpy_random_state[2:] + assert random.getstate() == original_python_random_state + finally: + matplt.close(fig) + + +def test_lifecycle_callback_order_matches_execution_with_bypassed_operators(): + events = [] + + def fitness_func_recorded(ga_instance, solution, solution_idx): + events.append("fitness") + return 1.0 + + def on_start(ga_instance): + events.append("on_start") + + def on_fitness(ga_instance, population_fitness): + events.append("on_fitness") + + def on_parents(ga_instance, parents): + events.append("on_parents") + + def on_crossover(ga_instance, offspring): + events.append("on_crossover") + + def on_mutation(ga_instance, offspring): + events.append("on_mutation") + + def on_generation(ga_instance): + events.append("on_generation") + return "stop" + + def on_stop(ga_instance, population_fitness): + events.append("on_stop") + + ga_instance = create_ga_instance(fitness_func=fitness_func_recorded, + crossover_type=None, mutation_type=None, + keep_elitism=0, keep_parents=0, + on_start=on_start, on_fitness=on_fitness, + on_parents=on_parents, on_crossover=on_crossover, + on_mutation=on_mutation, + on_generation=on_generation, on_stop=on_stop) + lifecycle = _describe_lifecycle(ga_instance) + stages = {stage["id"]: stage for stage in lifecycle["stages"]} + assert stages["crossover"]["kind"] == "bypass" + assert stages["mutation"]["kind"] == "bypass" + assert stages["generation_fitness"]["details"][0] == "fitness_func_recorded()" + + ga_instance.run() + # Fitness executes once per solution in each evaluation; group + # those calls to compare lifecycle stages to the observed order. + observed_order = [] + for event in events: + if event != "fitness" or not observed_order or observed_order[-1] != "fitness": + observed_order.append(event) + chart_order = [] + for stage in lifecycle["stages"]: + if stage["kind"] == "callback": + chart_order.append(stage["id"]) + elif stage["id"] in ("initial_fitness", "generation_fitness"): + chart_order.append("fitness") + assert chart_order == observed_order + assert ga_instance.generations_completed == 1 + assert any(connection["source"] == "early_stop" and connection["target"] == "finalize" + and connection["label"] == "Yes" for connection in lifecycle["connections"]) + + +@pytest.mark.parametrize("num_generations", [0, 2]) +def test_lifecycle_generation_loop_and_exit(num_generations): + ga_instance = create_ga_instance(num_generations=num_generations) + lifecycle = _describe_lifecycle(ga_instance) + connections = lifecycle["connections"] + assert any(connection["source"] == "generation_check" and connection["target"] == "finalize" + and connection["label"] == "No" for connection in connections) + assert any(connection["source"] == "generation_fitness" and connection["target"] == "generation_check" + and connection["route"] == "repeat" for connection in connections) + assert not any(connection["source"] == "generation_fitness" and connection["target"] == "finalize" + for connection in connections) + assert not any(stage["kind"] == "callback" for stage in lifecycle["stages"]) + ga_instance.run() + assert ga_instance.generations_completed == num_generations + + +@pytest.mark.parametrize("keep_elitism,keep_parents,retention_text,offspring_count", [ + (2, 0, "Keep 2 elite solution(s)", 6), + (0, -1, "Keep all 3 selected parents", 5), + (0, 2, "Keep 2 parent(s)", 6), + (0, 0, "Keep no parents or elite solutions", 8), +]) +def test_lifecycle_effective_population_retention(keep_elitism, keep_parents, retention_text, offspring_count): + ga_instance = create_ga_instance(keep_elitism=keep_elitism, keep_parents=keep_parents) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert retention_text in labels + assert f"Add {offspring_count} offspring" in labels + assert f"Offspring: ({offspring_count}, 4)" in labels + finally: + matplt.close(fig) + + +def test_lifecycle_custom_operators_compact_view(): + def select_custom_parents(fitness, num_parents, ga_instance): + raise AssertionError("Drawing must not call the parent selector.") + + def create_custom_offspring(parents, offspring_size, ga_instance): + raise AssertionError("Drawing must not call crossover.") + + def mutate_custom_offspring(offspring, ga_instance): + raise AssertionError("Drawing must not call mutation.") + + ga_instance = create_ga_instance(parent_selection_type=select_custom_parents, + crossover_type=create_custom_offspring, + mutation_type=mutate_custom_offspring) + fig = ga_instance.plot_lifecycle(show_parameters=False, show=False) + try: + labels = figure_text(fig) + assert "select_custom_parents()" in labels + assert "create_custom_offspring()" in labels + assert "mutate_custom_offspring()" in labels + assert "Configuration" not in labels + assert "Offspring:" not in labels + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("parent_selection_type", ["nsga2", "nsga3"]) +def test_lifecycle_multi_objective_after_run(parent_selection_type): + def fitness_func_multi(ga_instance, solution, solution_idx): + return [float(numpy.sum(solution)), -float(numpy.sum(solution ** 2))] + + parameters = dict(fitness_func=fitness_func_multi, parent_selection_type=parent_selection_type) + if parent_selection_type == "nsga3": + parameters["nsga3_num_divisions"] = 2 + ga_instance = create_ga_instance(**parameters) + ga_instance.run() + original_evaluation_count = ga_instance.num_fitness_evaluations + original_fitness = ga_instance.last_generation_fitness.copy() + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Population fitness: (8, 2)" in labels + assert "Known after fitness evaluation" not in labels + assert ("Prepare NSGA-III reference points" in labels) == (parent_selection_type == "nsga3") + assert ga_instance.num_fitness_evaluations == original_evaluation_count + numpy.testing.assert_array_equal(ga_instance.last_generation_fitness, original_fitness) + finally: + matplt.close(fig) + + +def test_lifecycle_mixed_genes_batching_constraints_and_stopping(): + def fitness_func_batch(ga_instance, solutions, solution_indices): + raise AssertionError("Drawing must not evaluate a fitness batch.") + + def positive_gene_values(solution, values): + return [value for value in values if value >= 0] + + ga_instance = create_ga_instance(fitness_func=fitness_func_batch, fitness_batch_size=3, + gene_type=[int, [float, 2], int, float], + gene_space=[range(1000), {"low": 0, "high": 10}, range(1000), None], + gene_constraint=[positive_gene_values, None, None, None], + parent_selection_type="tournament", K_tournament=4, + mutation_type="adaptive", mutation_probability=[0.8, 0.2], + parallel_processing=["thread", 2], + stop_criteria=["reach_20", "saturate_3", "time_10", "evaluations_100"]) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Batch size: 3" in labels + assert "Tournament size: 4" in labels + assert "Probability per gene: [0.8, 0.2]" in labels + assert "Evaluate offspring fitness to choose mutation" in labels + assert "decimal places" in labels + assert "1 constrained gene(s)" in labels + assert "['thread', 2]" in labels + for criterion in ["reach_20.0", "saturate_3.0", "time_10.0", "evaluations_100.0"]: + assert criterion in labels + finally: + matplt.close(fig) + + +def test_lifecycle_polynomial_mutation_and_sbx_parameters(): + ga_instance = create_ga_instance(crossover_type="sbx", sbx_crossover_eta=15, + mutation_type="polynomial", polynomial_mutation_eta=25) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Distribution index: 15" in labels + assert "Distribution index: 25" in labels + # Polynomial mutation uses 1 / num_genes when no probability + # is supplied, rather than mutation_num_genes. + assert "Probability per gene: 0.25" in labels + assert "Genes to mutate:" not in labels + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("show_parameters,font_size", [(True, 11), (False, 16)]) +def test_lifecycle_long_names_fit_in_the_chart(show_parameters, font_size): + def fitness_func_with_long_name(ga_instance, solution, solution_idx): + return 1.0 + + # Wide glyphs expose clipping that a character-count limit misses. + fitness_func_with_long_name.__name__ = "W" * 120 + ga_instance = create_ga_instance(fitness_func=fitness_func_with_long_name) + fig = ga_instance.plot_lifecycle(title="Wide lifecycle " + "W" * 100, + show_parameters=show_parameters, + font_size=font_size, show=False) + try: + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + for axes in fig.axes: + for text in axes.texts: + text_bounds = text.get_window_extent(renderer) + assert fig.bbox.contains(*text_bounds.get_points()[0]), text.get_text() + assert fig.bbox.contains(*text_bounds.get_points()[1]), text.get_text() + if text.get_text().replace("\n", "").startswith("W" * 20): + # Handler labels must fit inside a card as well as + # inside the figure's outer boundary. + assert any(card.get_window_extent(renderer).contains(*text_bounds.get_points()[0]) + and card.get_window_extent(renderer).contains(*text_bounds.get_points()[1]) + for card in axes.patches), text.get_text() + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("extension", ["svg", "png", "pdf"]) +def test_lifecycle_export_and_display_control(tmp_path, monkeypatch, extension): + shown_figures = [] + monkeypatch.setattr(matplt, "show", lambda: shown_figures.append(True)) + output_path = tmp_path / ("lifecycle." + extension) + ga_instance = create_ga_instance() + fig = ga_instance.plot_lifecycle(title="Scheduling $problem$", save_dir=output_path, show=False) + try: + assert shown_figures == [] + assert output_path.stat().st_size > 1000 + assert "Scheduling $problem$" in figure_text(fig) + finally: + matplt.close(fig) + fig = ga_instance.plot_lifecycle() + assert shown_figures == [True] + matplt.close(fig) + + +@pytest.mark.parametrize("parameters,error", [ + ({"title": None}, TypeError), + ({"font_size": "large"}, TypeError), + ({"font_size": True}, TypeError), + ({"font_size": 0}, ValueError), + ({"font_size": -1}, ValueError), + ({"font_size": numpy.nan}, ValueError), + ({"font_size": numpy.inf}, ValueError), + ({"show_parameters": "yes"}, TypeError), + ({"show": None}, TypeError), +]) +def test_lifecycle_parameter_validation(parameters, error): + with pytest.raises(error): + create_ga_instance().plot_lifecycle(**parameters) + + +def test_lifecycle_optional_matplotlib_import(monkeypatch): + # Importing PyGAD and building its lifecycle description stay usable + # without the plotting extra installed. + result = subprocess.run([sys.executable, "-c", + "import sys; import pygad; " + "from pygad.visualize.lifecycle import _describe_lifecycle; " + "assert 'matplotlib.pyplot' not in sys.modules"], + capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + def missing_matplotlib(): + raise ImportError("matplotlib is unavailable") + + monkeypatch.setattr(pygad.visualize.plot, "get_matplotlib", missing_matplotlib) + with pytest.raises(ImportError, match=r"pip install pygad\[visualize\]"): + create_ga_instance().plot_lifecycle(show=False) From 773b2de58d7cd441b5adaf0640d729790e113b15 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 15:32:42 -0400 Subject: [PATCH 02/22] Clarify lifecycle chart titles and conditional stages --- docs/source/figures/plot_lifecycle.png | Bin 214427 -> 233328 bytes docs/source/lifecycle.md | 2 +- docs/source/visualize.md | 4 +- pygad/visualize/lifecycle.py | 81 +++++++++------------ pygad/visualize/plot.py | 6 +- tests/test_plot_lifecycle.py | 95 +++++++++++++++++++++++-- 6 files changed, 132 insertions(+), 56 deletions(-) diff --git a/docs/source/figures/plot_lifecycle.png b/docs/source/figures/plot_lifecycle.png index 50ce7b757659ab7506d206caa339fee313df3577..cf294d0303063da95544130e7fbbe94e200f0f48 100644 GIT binary patch literal 233328 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 z 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

<&iIVh?vcb3 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 b#=0GPBnzd7pHt}Be=-j=_3xM6vy1o-bV%(x literal 214427 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%0D-g6@*#<8coV z!lX%m#HhV{@$T?d>t_XAk)6h0=3Plp->!p-9T{s9a`Ip6&#(1HQG21L>Bv>U3(qA(JB3JcRvXTI%X+lk0= zRg4$2MDWzV-mH9A{Fe0cA!0d-yV7-QG$FxXJx*)efWXN-hx*9p0b))*uC0zv2R{Bz~Pr29vJsg_V8&BDj` zs8N^97Y%S7Ah#MaGBOhJP3?!bVds$AwszX1R~btnUd#wBY^pjh6a;v+OuipkZ||P|2yFusZ$L4No?6RTn>^v(6Iruya1w zW>xA{M#i$9kx_9$Or%_B&MEud(um%?Zy z!@1Pu)C$&02~)_)={T@)eDg()8s;AnpKMTEO)c~(XK=RA*flzY#Ej0;OfSyThH7!3 z;Vr7c)r?||%@`S58Cq>!nwpMj2s^b`D_@5((Dd-KOzBqt|sdd_Efcxx_2 z^XM0D1MHykPXDCs;NBA%6pIwxtuaF2fI^~od^Y=@|N0%q%Tv-iXR9wjy)AgM9#=T^ z=``89GaNJY3F8~VN^g!eW_8T_mIVq=jC>bXxn2wDKl%s#P}_Xos6V4k8l~i&Ep*i? z@1k)?>Nj`z$_N1+gz{zUW@j+ztTg=J2*uXNj z=6m)jMCPb%r(d{L_8kaQlwaRs>3=BD=p1d6!nEim zSX0G`tJ;|sl^Cod-)9!T28Of&tZH=Ne^FXzpY01YB>`*r=Va5w2b(l33^Dsv-C)O zat&;2cvzI`i?JLzB@rXmDV@FbC>1d3YZgoBM z^|I7=0O6^-uJhV{+LT!SQaU^hGFF7pab67hE-bI6Tg97yC8@vb0Q?(+)}u5?wB;LWTTi1VSJGNiE1t{2)%RiD=_p_KxRCf?z<73ZY}=O7JBN)*4oM zoe${&*7$doBxM7K`K!VrP9b$*!PoSgLl-~pI4wQt)Wycfj{xyY%eT$}?Nb^f&WAJp z3|n^^+*~i&_cuO706d$)HJLj^PvWdK@{BL}u625{!V+KUThk{V)bn;?mh+Z*)NuW| z;jtQzQ<`rnFMk8uri+ECcct|Y-TS-T#0vdh>m+qkgy$Mq82>!&ESRW^=f6|gjUMsr z_(h$!EM)h)7g2GasV6H=9Ra2CNBAa+M)tsSoHMB52e9cBY-~8BR>cII-*ZJj#A$Bx z?6v@K2@dv)4AOVmM@Fm`SB5v26CxK*t@M>jz?m|(dBAf8$7ZGHN740ceo=of>{oN) z0xi%YU6#Ux=3bRF?hAWk9mf^7juBTQV2S`=g>h;Q>pvELCiEIq6xq;1_^S$aUron} z*l5d*tA(nHIncEsDOreRoxFK{GLBYhsy)jzEI;OX9~o^h*4T=2#p+f%7mG-Ui~ zyO!)L#qta&(t{!Y7TUQ9?C{7)%uB-)vyothSd1x~&0`Ukq6gaN8+iz+LF<_sY^d{kxC_|Aj{ z-jn)h-ox)JaA@AQ#?wm>ajoDwUJpf^z&=olLX1ILKxgFhfanFo_36ukgXYMR<@Vsr z(T_!_SZOUaSWPV#5Tv0}9ZWyJ0KKa|0Ds-Syg0cnO8^AiCtBY~cG+zEuq!c9-8D}M z>==$c?|wMa0-)gH#Qc5R#0U+=>(_+OMXoe3t4By9%ye0lKS%mOxw*${96QN^gJru4 zKziwNc_KQr5(?N0*d!#Ol(Yj~=otT-O{S;>^GNt&M?kC24M|rqhLp z2Me|8Qs)R#h}Mafv#(i2O1(s_e`<`ALl z`}X2SRH!aFs=L7HqvD-3WMRI@<;|535m>ZgDtd8H8{igE4of}BhkuAlm1!Fuen_VD z5{={CyGo^Xr+(XmE?I4x6*7W{&J#NYSF;En?r8hT?vOfW(TJP#mO{f+E-}K-%^ZJl z*p^itxbP)1dQ5og&s5%RS!t=uf9L~}2o7Kz18>+IKBVAO)34=)kKpU2b z@ZNHHCl@cxd~o~jApA*_AvTSqzn199t!9nA630jkXs_-WadFhh9$S`#lsX+Okc91KqAz4n(Vx|wUdYb1>6_1GU<+IZMeYVk`Ci(pHr0$maTgw zTM#wpCG&}5Z#F`?H4I1=exb>8Nusde5%RFX|f!- zY&(?rRo*mVXEb2wAJWdXOmV-q`q0!j8h0t|FKBFO8Z&DUt86!=b`1O%5Y9YJBoIQ# zN#K>B`DJSYI`)R$uo!YXwe`a<<*KBzuXwhhNI0U)LP!3J9af+sDinWZuG&wKLp!Kl zDFjh=o~X_}}$xdo$!K^vnPKazU# zJA<-NkzWGqSSXz!YbFmw#m6{6j7i>|#rJF1PyxLH6RF@%Kwb?y7u(Ha3bLwWi>B0y zQBGd|$$V1&M0Iryg1tbU$qY-=lsI_e3^f-Q<`?4e4!oie+saHo9Inu$i;#Y@Q0v zd2v`P9%c8K_D5OU?2M|v6!cJm^~>sJlHqyiVQW3Z#m5NG>xvd>(3aF`|f4OL@s1H65BAO$rhr1Dvl4%}|zXFfWLr(f0sC!jPsUKKXncn{byTorO~(TaB8 zEb=nd*bpP2^9qg`ec^lC#qejKO60t)x1DRS+*Q_K4X}@gF-<~)zre1wGR>t{LtiqW zDUeFg2Up$KV!eLNu9+NMtX}1(m3?b>KS{5~7z*32%2S_bM`ckfo_EXIdc`LU^d+LQ zx+O0sy7$=l`OL)VH-BX{W2wZ(>I%+i{XV`tGr!cEElH?f$rVJE77K>~N#7{DzOqP# zr`{{4eSx3{o^=HiehnQbK-8k*|DJY0>o+DWI^fV`KwAtzQ!vJ3o1iL8x#O}*AgERC zK;=9<;a18#4PrcV;8OZ~a*@jmbR!MKV zm9D4>iJLwHN6g{Zq$D3YpxA$8^VN&Z_ zuDCdqQGj%F`2c%sjA^c>_8=UCWhAW|xP@JxQlgwm>R^&h;#-Gbzkg%_Sgy+bT}F6y z-cLz8ua^V1NtatGX-9txYZ4PrPH7Zu6-feg{@s!`mQ)h^F9+I$nVe?aZoIA%{r zo`hkNe6}j6kjVOQAUtVp5_r+f$3;9t)hs_nXZlWsLpda+J2$Q*ad0I8kuC9+cgu8L z3?$SVvw!Es>+ay7Cr#hcqK1)x2hn0nylt1mI}c&jfKo?UB_vBfdN1Je0~nl!IHVN2 zuyOLxgA5~kGDcUBeNR_BCDdfIuAaesg3ZnvMT5G2CoCe3PNoBhacEbbDefp>4mNhe zZ-!_W+xiCQVAEu*9zIFtp1_Kq;ANj)92kKtQ{Mc|r#94Eg~LPEA4oS+w(LhvYG>ZQ zd8HWPopA4;^xA6F&&=H1anpz@mWthe%GZP4l{3eS51$2#6cry|8pAC+H%U%sKIrr9 z!C}}Rc$u@9K82TNGi$F?miORIiAWnjz@`s=zt1OC+|}S#6<;_KGcK?I>4o5?n|Q?= zwMCsjZa!c4nw0uy^7*Os`%(`kuPaw#ggRk+H-Ass;!T=Wjms)OzzgEp+8&zclwUc* zmouHE7c&Rf7h=hmp*5VFyXgdcch6TQ_OA%%nN`Fe6%{fnSF3V)u##uGx>*6-mHyy- zV5!X|Kkz2zkq18Sd@4G!&WSmr$!Yf8SS@-&O2Y63&<(0_{kU71tv(n#mp>f1*3Fwx z0P3f*4{M$3#ud>|^7ioxRXihalP{JzUy3V zjEPKrDtibC`butfmy4bAFgsc%=qcO#&c5Nf4|qIi(Ts(cq2Gcw%%ZS~!ICV$N#gKd z8&AGDMed0l1!~C_YkA*>w%A*qJMCN?;xxZczv*gtr&@+@{#+06veF^%xQ^k};%wpqNd{*2-`@|N{6IC;R)R@G#Xkmz3fQXHLAiOVGy>2p z8)C9tfL>_CLdO%QEK>AeFH`oY{oew-(4_;8kWp4=7!<$Tru6$)W#F_I!z6YGjH|Y} zN(x1I6HPdwPyL-JJ#SRvXx8~Cu+8G06$9(jC$N}tKWY`lE0NnBEBktQG?YGp(U#Tq z9o;<0_x_h>UApZsnXESr4^Y>$>NitSWIT+e@eJNW*aZ~=UiI!n=EU|m9Rh*C*5UqDca4dgY!1rH~qsmzKS=K?0)xzPrc5>0mOA^=4I%`=J|6$r9u;Nd75%KSk+?`3dL*lNM{9e(M+lY^ngUzK zHY3byJ87a4F-5>C;hQQkoJq&VJ@bLYo*2I-&b+XI#_pgP#hyW$6p~@`2_t>@Y@QsP z1fbR%qKv|#w=znGOJ4W8co=@l*>t*6umWZtY>!(JuzeM9Yk%EHA?risUd{fkEHEg9 z1lCRHfvLCoAQD-!Z1D~Za)Nq$ts&AZhRN{Hso}ZM*l(bKF7XzT4d_8J(yYPf0(=g3 zonbmJ)73BoY9t?NL&?JTbS5(|`3#;<*2i%ZEE#XFv;&9DN#ECb{~QwP%gqhVNVB~abpP_FX9`~RB@f;GF3unpPH}aDZjmO4uAnXpdMcGyth~<17 z{8Esi{bwKi_gOLmdGVtYa2C;`v7`p5xAG(z*v^E3(2f{NGh1JzUcvbB8wWg!O8JNm zi$h*h7+62@{!`B7nNf%X!koVrmR9Y6Z<{_DTddVi%oUzAh}Pc>uvtKnyHM%Q?U+V0 zl4a>`!bQKnAhp30Rl|Zy_I7kn0BdPu!HN8mLxlvCRV6AKo6xQQ`)5amNs_u@}HNkD z2#JF8fg>khZkThv%JwKAGSZe@1W1XdvVav=>Xf=J%j^S*3mT~UeT_@{Ru&b>wll-9 z^^k%#ji}U-0n5PP&J{sJIVoJwb&`0CcKOP9AEM6W<#?6%*F%ou!ktMkE0%qN<B z8J_R|g=jpEtxpkn1cM&q5QaoFMI3xTX_9z=KX$-zNs>9((<%)lCpjz-SVEHKu`*&% zh1$VY4uT($lI6Rv%!lNHYU6PzfO|2$S_x#GA;gVA$-eHuS#{7(2CcOI9s{hC(S>`;Gh4gmwpTU=1D` zVdj#&dN#}YI5=Z}K5qW$-|g?>il!zD6+Aznr>9gM3?YEKAP#eR#W(Fsd76d?T&U#w z6(O!iH=?&x>$7oPZL>b3^jEn0cW0FyD9ar_%o0pS6&ym37GltL%ORHWT^{t@;CF*2 zz{aP^2YUGe=T9&~5aan5uo*&*<`0k$@LwfXBB0zUR*_WOZ$nd!4w|3krDFwh@H{tK z^+{gbB}#Joxo7%q4i6%^8G)UV`+eu|T<84+ zcYx^fF2bo>)1b>#w^^$#movDKgBG&`q-|wr5u6a_>>Mge-~{zG&&YGHMhFXgM5WV` zP{t(Ksm8jx5;_yXxd}SL1opcguFE&(+bU8_O6!aH9tqN9<^a*TSH>B#p zoAhxu?n>oYnL0fyQrr)_3*BH|nL;@O&f50=tRHk-e60%?UH1ao)R9~AP&AsHHNgPH zbOrqm26o6G603O19CdU=EB*zU|6{_XHSZ^S^ve9ftoQba^IyS}@Jq3c3f%%#nE0em zKIjX$T%fuc7cxMB_i+}=75VP!p6TzSxq{(8H~kUJ3f6K%tTUp5r9&h$hMBK^J2#=vkvB5BAPW_!3{ zp;g0_!t8HG90WXRs)IgpJZ`#616MUkqoZPViXcPFTqC2&_Y5sataa-Ib{r|Ttnv%u zJ!^ztoa>uB_iu1}DHbyAHom`m*0)-tfx)?NgXy^&n1M8*za>xg zKn0MrfAC)`Wh*w1CN0^SJpI~A<#W&b@Hs{kd>leIaqSW#L&Jx_f{W5$u&dR3R4I;l z=U)%OCG579uiT{#5262VSfm5aF?e|taq^qX^wJ^WzBJ={O zR0v#y=}JdR7cp@>t_uUU6W`Oq6geV4iKnTtdiusNgzNHzajL-+AAus`zn{MHKR7(< zvYjPTtS=Z@v_AO-;8Q|np5N*Xxz<=TGY8V9nlFQAS%BmWcshboWsgMXYb8i3O{qKP zq-*YheQR^hvLATpmcKy|coT%7bqa4d^LuCH3$iZ<-+SH0BmisC9!Q!&*RrPfE609P zlAejq*axfKKBTu2CqeoK%LnaOx@8voqBG#IWV^cowM43MYV5$R86Q%t&@E_P4mulK zqwTZQ^J($zok|7(gFALfJ+CN!%RlB=-F<7epB$9Oi}1Am3P66ix2lmR$-|*~LE`)B zwcrAyPcbPJSk0nKfYi}_0<1u}7=B8NC4vcooc$yT02dNU1yBH`Mx(7(LL2O48KiYp z6Tp2K4VW-41}la$HI8c9P7bBMf%Oc4sniKbvId>S%>Zq*|J^cF%5`cl0==0rW<78H zcy~-p0y=}ZHS}j$x~KB}G#Uz=AQRa+G?e@+>}`8nr}9r~3&n!!*PSiA1}0wsw=y(n zWkns5GR5oK-qls}l?-gM%s)YcV#Y$HRSx1qqr$mrq^|!uTh#;Lw@nrz9o~6pW!!}qcp&3g zWgX#YQJK5pQWCn7^(AJ169T z#gu|V=o#arJ5)7PH$@oXo&wf_)c!oVsKgnl_c!?(SG$6>`2V_g=z8ZB^#58qxGJ5f z^IdEh^NyTQgJ+z*MN?I9Q0iB(iM=^JC)Yk;H$UjD3mE|Y5H% ztgY|X{Wb+P0r(R+WEB6rEz(UB=y0dJn%T+ReE{(ZZx2Y?#sE<0pIF;ozpMq$^>%{P z#tiO&=XP{=R-h6h8i_1h`$xpmQMJ8}t8`;Jf6K$p?upX3+Lm}y99+;$0}zGJ$p3aT zrdeKd@5C)KT0Jnqaaq;!K()w`(aj(R(*2X$X**6w1dR%zZvejb zcbH70UZH^aCi&K>Ii2jpqTb>eoP;pf@-brt<1cijfs$GuJ< zr2I*L{YjuMnl+SB4Q0@I^d%P9QGp0PK$-a}LSXsrc6908uB$fdv;Ss&I5()pO3|vJ zP~$Bn^TGiwvQ=%2t>*)>K(!sP*@J3(h-Imi#0h8QZ!nneVGCL{9@?k-LnZwD?u#LN z9*HW{7+&0+=M7=|ICe3!g(d?^PwD6Yz&HOoCWL~p7%%N>Ua_@uF26^k&{2gqDAu)Y zN$M^R3f;r$3+2_U7giGI4~|QkmjR6NyLomPoRK1<4Y;onKE8#;1sDvh^SSy3qn~Gt z^;rgR)M^7CA3$a3z*Hp11iIU3uD#GDVPXmi%&&$6BxMn7j`vgUpiL021Jg5)JWVzN zi{`5=_B>d506ZcBP}_}J?qnxkY6ciGDeSKrSQ-VN6(xVT|D0Ku7Mre(2*PTw%?hqO zaB%i-^gP=~d)5Ik)XDrJD`RtTJ8AicB!qj^=Ft2g&wEnH;s9_iJ789Ww#QCut=A^sOQVrI3Bo* zpNCsCMt<@$)BpSn8>xx2%0LSOhYkoCQ?>Y0!q0IachRJxYQ?iEqCm)Y_L^Te-FOApu`GVD4g^u)tFHG{hS7xcGqc|ocQ%54yPv~M zsp#WIIOk|6=%0Ye3GBAflm&OETFS+e!#3CU=UE>|QTPr}E*yDPZsa68zfKhPO{P)) z5$z@05zsGltog48nKO?jq|O;#W`t8>OjU#LY83n+irhm=s^d|eHCSrafEMdmnBFjw z>3S0qT(63=&dJ#kqL{0@eeOfF75j%LO{wiDjT_!1%AWbBx3Mlm?fj|wI0rZPFjv0L z(JUu|u#9;e^@LU)9i1!IGyaU}H^9GYT%$+AWeb330_w<=~McKYB3i?)GA3I&CJvhBlX zI6}<-dm(T0df)=2omFh*Tu$HkqEQYNXr6Htfg#EHpWv<8yF?F))Fx*z^XSX?5Lli* z-7+hd4~Y=-!>~OT@n^-QOOZ&p=R>rdrilBNX!N4|9nTcSpX*a%h*6Bi(+LlMAVJsU zfhn%UG_e4#jh?ZYS}7JF*@a}oEO?gVi;}vj40-KPI{cB?E}wrPGxmsRy`)L4godz7>4P1SagvUqjw;VA>+;a=y$9ov_f+ zRW%fh&yCX52?@puvI>ZGxCGVTE1TmPHs9r_#8)#gU6Eiuj|@nTx)x*%~SIduZ`DcDs9r9MEMOYqt#`}JR`ra6KcU<>jX zY@-pclS#z&IMj~vUAwmf5XhHss43i}5L^0xUl|_}9F>gD9~-&ztkz?yprAvj)eOiM z9tUGjB!2xoeEj#@2xTJ1PlcpssQoH#wapi&VG?wm1`Ml#j~i|5v`QVUf5d-dL&}ieyaHy5k4AwL_JhA zYhU`KLRrN9Xol;i>HM+SzI0pL3`UbqF~O0X2|+mz3gT=*B3r;?C5TjWc5Bsk>&CHg z_6I6ZjW6oFr$RZ~{X}R#o&lMqcPRs`MZiKlCcD=^T@xah%tfNe)GytN*LG>L6o-~T zi9q9|#^aVb4HI#^85AH!1uX1AN_Em=sOz@Ps_{Q4r<(r`eM&+k9zYOJOw*LA)2@rA1L)k;NdyNzuAh{^C|12H&|CN^&F zfU>ekeireOtU<{7IOdPtP^#^4mr4e!nI}K}3#tz%K1WZMtv|lw$wJ-vSaey44GQX8 z6Zzrqu6gj!tgyktW4X{A+)b}M1hugVo}u7N3)Jul5HPM8C+#z3p*azXu;4O-?y$VS zB7X7QB8M2_5`#)0E~ku#AcCF?FhUjsztjWBHFEknyvU zXiW3Lr29q%oHGgl>r<_tis)hqQzAZILJ}EliYa<{NHoX6(>Ac+Im+4Q*WEf&DiBVv%XGFgM{$R|t=NnzH$uII0;`q2_flHAfZ zQ>c6%{r62no+1YM*U!dMnH%RN{kZKxt%h`g`35~l7xx;$MIms6;2dRyAdVsg4b2aX z^s0-yx`=ha-;#lZAz})Gunnh6Hlog`o(J!t|D7LjJrOr~NTgkMLwON@lsRSXzqwyB zhzPq3eVTeWr7H2?--I~*osy3K*AJBbj~^)5HvqPE>L#2LOHF##6>&%u89M-4AdQ!n z;6sToR(9_Iyx#^mB=LR;HHM(npBoT(RXrH!{={86h%WwPp@N=;5sI~%1!vJ-xLd52 zo|#qKGGd~61oR@Sf6_q^ktgFnfdl^V1DRfcZc)XfVdZ9Df>49;G08VGd)z=4F*?et zRYwvm)QJQ$9W4!E(3m(BYp1sY2rqDx03ISFweWr$CGKN=;q`dAk)^euT0hdNCkfzi z{QM10bzTdYqR5%aAI0zcEiWX#df9{>)=29!%AP0hdSV3&kxm1@bc72+uIxx22VA`W|_{n6#=j#!i>buB7$!SHl+*N8KG(LBz?p`T6!XhM{?< zJ|F!HO|u5-DSa&mz;#mq#{Z-62HIZt@&@T%_sRwn#H_dqfN&?u!=0w9;RWDP5AjZj zJGEVaAm{{QD~;-NnX8Q&bLG#Y*xj)IR;a-xa-t0EResW6;BKsc-?nWrvO%ra{cI6E z-M>))oPm=tQ0u}vJBHJo8!1zyPZ+-sxUT8JN z6L;sJqbv}W1frA#=0I4|Lv`J4t2bY-_|$=o`*c)9eyWq*xqHiFdJV7|9H(QCC7=z6 z9I=ovS(RMov$+4{h~cB6I}#BVxMI<|sZg~H1zza>E0SErfGfO zezyn9e~%}^eDkC0<&U2~sS=VcysE)syOZ-1DR34jrX(dN9ms(HE%d0bpYzdbaCg3c zBZGu_!U#Z+7EmH)$jReD?~C={wv!r~=@x;i1VV)(3JRRwCUUSyu$KTF?4t289Xvbw zST_Ct;*pb&WkdJp8-Z$N)hWA6HF+d(>LZ=8#+fv8c`R8cA0613(u4U9O4Ra3Us6KuDN> ziW2UI1%g*afxO{&t+qXaXp8LcmjP&72sSjlO*@W znxO{j)$of8WM2NMOr0V)fsn&LpvHiiicfx?-Pk-o&f@3&rgZP@Kb5etOMvTOb?$!4 z>u~Y{Tonr-?+V4yJkRrxVKHTWT~J_Zz0x8u?!d+w){Z8=@1MVXupQo93RHSP3mgRE zHvtMHQ!6oQW4@l4zrrkOms>9Oq&1+wnUbAeqY)2ey5{hsXfRBC?0`EaBM`DvT=dJ8 zUMwGe3Xl~{16(sgn4WhGz!zI(_nr%MKH~fBa|0-#=mYBBzu=%G0@lX%iAk;_h zM}=l&tYs3~FYK6N5nC|$&7imcNb`a$_JD8`lH0_0N-Gqy0Yl_6kJ)doLEp6|91}dp>a_D*TBMOK1!d+C2x0;sn@6A4b8!hjFbBQ?UXL4$ai&BxclT*6 zVF8J^WM}`b2QwZ{{I8s!Pod>BEqpc?D@cJETo%x`DbDt`s^YnZn}C1((%bk7)!N|R zT10;Y%f3|aSM`O_s`Hi`ngEFZSt$#!x^2>ngm-NIt_!ZS0!2Zn4MnW1$|K6D3RAy% z9>xw}PxuX27k5mViK|_IXD7$*_5R=y_Z_Py&x+F7VJ>Dfg@_C*Md#u=?tK1r!u1&H zmRmj*zt@#VT)L@HUDXK5{5QA>UWr!8+0f)9LBX2$X8>sGJmMP}Gpg0G@}61OO7oLq zSRYhAFJGU)-Kz6ytXtT(6hWj6y{~qlakA9O*|x3Rjc8P_(la;r0V3$YevtuDMBayE zr&8)W@%f*CsWz5h9bZ3{gX}C+Z{+D&bZ3;z%sEwhqj@?N^=f#yO^f7RZOVpvN66o2 zI|aRsiV+3biqY%Ms)+|ISs|P$bJoL5yrD!`Ack zWe|Xw7>rFZsfXu&ZONbRwKlFzd!Ea(vc-lYRD@VxfEVPmz0CcGVlw0|)}kA`qrOMD z^dOx@yoYM$E4DPT3sr8G;8E3x<_n{AGKJMnl*;LmGl^$_HMr#>TN*=*pq>pjsed03 z0Ib1}`OiRTg{t^^#k4z7sy*<#`1aZ!q7m?)A^Ay5Tk3pY){@K>#At1GF|HmH``=9q;s_%VEPkcziAX>CtIu*yFtVv9 zF9=gtHuvBG)Q!SNvz>DhBIh>+Nm|KCNlVot_o@@!O)|Kle(&sb&fW%;RGQs=tMddv zB!_KS5p>{#dwj`U3m66E^pCH{KB^Nxfhm-P*ea5ZQ-cWE0-`tfO%~q8XRs?iG_x6| z_O-0f6+5AveFF*s8oy|%D7fFjRGhMChk+#krfk2r1jFr^}Ew36Tt-tc?<{ESLC7<#|Fd?>abZT{e{IQ=FF1;wB zkGg@>6H>16i*Z|ZIGL*m)@%?YDo$e6Y20-iPopV!3I; z`EP=cs!(bLu8y6%DE;DgcG5?AnUljXKKM|TAU;-7`mSC;&pCQwroNfY$V9( zPITf41Sm!QDxdp-)S7LMnW3%&oJ* z^H_1Z`ID-)+w!k?RK1;_SZv6e6`9-Z@Sp;3@+aR1a7j zEJKwnlM7m8glBq7InbDYP~Lt7I;6q?Ofc4YA<3?~-Z3oKY;KePaGlmEJYZRrJRc}J zZ1uybo2CVMyYvvMhA6XH>bywxMMNMwAWg$@dlzpHDZeHp^yVTV$_P*^fso%>g6Nl7_sV5K+7tm9H z`0{HCj$BzX+D%l4<#7^Rr|#opkf-sZFE4JM>RS@XRokiw`RPZcKzN@6pK^nOXv@|b z2+~-gon6@MtTwCV5y9-PN)h$SDeaebYUGm>n_v9`4*-kuA;^*;ufV2v%TV!Z?SA^XfuHxWzcFv9+An9PtT@F-@ zNyI5L9Khx+?|rS!X2{;nWTd!qv4WJVJx}dke=9uq&D!tf+*9H*_k9s_#%7OX<))8j zC%dyGmKvP>W4CLL#DU>2JL`0-7Is_}Wt%bq1`Z1U1QFNm+_cAMb?jjDJteN%N;)jZ z$`g(v*P2QLa()pIb=4Y@HokmD;teb}i?Q);3%}L{h@ht32aU)0ER-j6o?{IoJ;TpH zMx8`8^X{QXF389Xj1LrOO1vXC?$-``XEM+}(TOg?c&9UqhfNTMD^k?n+TMxdwoh7E zSP1%tFbua!GWs}{7VZJv zI=FEP2M>oNh#4Ddd}nd8Gu{OJC3#&48NjcQ%GlVw24sqo6IUD<0}R^F?0*Jj;HSZ( z>`-dUW|+Vz7I8dl&wub{;XZ2()x<3;?Mb@sKf5Xh0&3Q_ud^bkM;bJ zcHDuCn+P>6ZTflW;I>aD)AW7UiHp9&HR+3&aIus=svRQXGi?b8BJSlF1J)z&Xv~}! zN^&7>;H%g!{Dyd({y{Z;U*Z(E3*uRIYqrVq>R)0L_^}xG!}L@dr?~7+U7D|){s(7o z6;#)@MT+X$@FHC{nT z4rB&c3TU3!f0&=GQe#OuTzMi?43RFOiJ>1#VpYbVb*iqezuac{TmbK3WU9HMv!rr4 zy4XbnWH5h-40sFv?nYF|(CU_^PuMiZ+h)*VdJ|Uv)iOkN2$vX~{ddUsd4)!}9K`{z zSB6?QBKXzX!ha69l4U3yV>M(d_|{X`&NUu7J3b`)(To;oTg=YS=S$|H`0D!UzSW(b zRWCB9#hJcdy3ct7AJH0N;oWAzaP5=ud8?HUMm8KN{^m;D#oaaEe`8$nczt(eN07Ac zI5!M8A1-qaKDHotL&;>YlR<#K4|P?-bh5<3{g3JV8w+;J_P>S{QAvt+Ly9K_WS+1xtcU%F(U?cDUS?D75!J&e0R?f~4|G#t+^ zxP0|x`uh4nS5T$~yXAZ^nM2F>fTW^CovZ5<_epNl*^A?em$9m`)beICg>|@VWct0~ zDem?N;b-+>1UygU55)-jcY15;OPkdtiM!Bnq+N@lxIm@JJT*e(vM`3x`oh++xzFKp zYQ4q&&_7XSV{~kN?{R5K3#i5VunE|7Pg!s%Uq(MB!&TVXnX6*pIK|EN9EY*w6aG4p zu4a(DjTz-jh1(oRmo3@jJXukg^4!A)P_XCpmL8Mt4&_)5+xH|;T-#AvQWCCP`kryYYPr^xr{`{9pZy{r+!#sj zO!JaD27z9!L`wndz%&39=UOB#soZ|iuS`O2!`OSMZ z&&HBl=V)6#kupPccEt#cPZ?VW)XEH7e~y{&{Y5477|r)0xHw)wE2STKxdo3Y_KmlGWbc(X*tl5w4>p0Jb6QdPfRsRO^%iGGol zHf8SxfTsuxLramtafTGKS&CiuFfx?{_y=Q6Qn1>#*#yC4LYW`Re->gEq|ZH`Q?m_H zoy-5p-q^rDW++?usUtZ4LF3-xFrN0sCN^C8BN=-#mQtpgLlQf9%e^q#J3fvYAqa@3l0(GufQIg; z@#8`nvh%x4Wpi$7pF>3?dJywJcs&l(`>iV&;2TemtbeS>gb}@e5%M#i&qP>o>`fPF zRWx$PywBC%$GmYpXUAX;O+9%Ixj29^5rc(_6;I)DoX#|RPnK8LS5>CX(UdLh%vO_4 zTaFH{RyvHVQ0JQ^EaTs$35h9ZS(;_jvC1_1PTo4U+!wxJGJB<6DqWyO0H1(yxPOcV zL@=2Rld64s!D)fGZ|^l4+2UifT9VC=SewK#)~I-x@)}sZ?(K2=7p1`R}T>B8Ka6Z9*oOXR@jMMS>Uw zy|;SvajwlRjg~@!MZ{$-K41e>jx&b1JWy?DvX1=_&9pwm1@KHEw}8ViiKE892skI< z^MU>cyon4TT5h@&bZO`}z$&1o&lDRDTHMv>Ii1)<}|J<)igqN0T@lljieLdsu5 z&EL>Vc~w+Yi{`*>PRa|-J^r8qHaM^?({cn8<>hZMR{MT^Hf#A+hqMU2`@rC+#xM&| z80bfIDK@PGcs2L*--?N0tc-+Zc98PK1$hPP#O?eHVUsaAU-_0f4@Iy`42>o916k&Qs z5H4{S{I#`jL7kQ$`=!OPZ6Pyzp1+|k`8vi+?nB9p&QaKwOzH$5CRgcMW6|BimQu$u zu%*8c|4?hQ^x&w~l2F5dMcJY2O(dB1{fQ;MD>^Wr5hv%AnQHz-!MYAjqe*t) z+}ViL;F8O8o3Eumt`Mcrua4f08UEsz1!nVO|$d) zF?ZPiZL2q{H{FQw1_7ySo2dD`^?%SAQ*EjK60Z8i;UvDdC}bbnH3UM3V1Rdx0-JOo zln~m+E${?^^;$Z`0!~Fakf#`i^#FaFi%;t?{w$vSgNtNEOx|<2zWdVBp>`fN-J@3r z9{d(4vVfgLGh@>>HTRZ>-IUs=3i$Sg5luevSQl?s^RbpeNkEDi`r)8sC=s+Gu09*sB z&yOu<5>6xab0!vSn*E!e9s`!{khZuuf4!wYtMnFZ@Rm}w>SgD|Ju|oQ9cboH<@CHl zeZ1c%@?FhWneZ=klkmTfxf;OE1@&$0|MNF_+}3D;wR_f!xWyZXkrs11`>*mHlcGpo zIKZ9m79!niTQxiD!X{;=18hLk58-X{30+jEW4rQ+%hD}(fA!ihRa6viXYvCj_9?lO z-cuH6oIU!_5JI%x$+8F&3V#EAl}vKW-mu}r#10^`KzKx7Yqw9|tb6kk3Y~yf!naBj z_esTiyAdt#+35)xocb2rbuXzg7%!W~DA(0q=%YlcKTe1uVDqEksd~WM$X$CZ{IhDh z*1kJ^zyc0eGT}MidbZ6=SU@HT>H!2D5ddR(9-ct$FWPv{6rRGJ*Wj@;QnjayD`+9{ zazA)eGRtD+FQ@IzD^QkAVi@Vpwc@F;?c(;}@U%}2NH$&$;AW%Fx}(V|zlvL#JgK?2 z1^7Uyr!%)d@<9kW;=U12Gy^{|lkQPS;EvsYtGJmx))qy!FCLmSqQtDOGvz(fckbvv&e14_95iqe$uMezu1)t*T^+8v# zaf7U|(I0Md;KKzJVEzrhYTyh|*eaS9kR(;=#SZ#{{-P+@4j3BCI~lQ8+H99~76YkI zKo8jnh>k{wien@0O}ZRd0as>1(jIRgr~@o8kL+_Ic)MV|f~edBHi~lSs{$jlXZoU! z8>(^gMl^^`w_7N*IAI{mUEN{(9kLtmWG1=_52rs0l_{-XRAeK6#FgAPcviGghSe6CP zg-3If^;336Ek1lnlnF!Y;2^rAg=*g=py&L@4iLQci`o8Y>hA#hHiUwmY%;PRcA$Mx zs!xp=Oci#v&W@k5AXSV8db;Ww>O0f$2yjDZzZse$QAmQCp72$N3Y{(0v+2ujjd^}4 zDV`irB#ufH0r#}{t7U9QFYun?&*q$OtR4h0RE;S(FZh>JxY6|gqL}B_P-6^06&do? zoBvp+l%I%Wxb5EUoX>IEuSv%~OfAm<^aF@)loe(gjg5OF2!`u#R}bl1S;ef|eIRjD z{2zYTjLlR|#D9P9-niIM1Q%FI`n!NG^PkwF8G&na3R7D=W@x%1O=%ehC` zh)qNFKlfrJ84TSJHa-|N1q*gd#2I(PT7ghWB?9U*l#T&S*qKQ$5=0x|=;z)nGh^V6q$ z$igu2%*~r$!zi*lJyYQwC=0+*TImQ`;>PIp6z%CW81f&&9<9kcs<%#A_`7$h$!UMD z{C$xEWo#nXCSsWK@48>6`Q=)fLW`^;x(bj$7Y<;Tfp_N!4E*{JR(nr;`ga0WX-uiY zV-6Nczy-hMc=07HtovlX>SyPaoE)Hj0$f@;g$bSp zbFh78g`v|gB#y~x5{)3tpU68lSp z{R#w#tY*qYqyf!H7Db`Cv6U<9J)!aoH-`~RjBIROcn^2!awFEWe;i?BpURQOn3zl_ zDsj5Q2y?&aj{Ty}n1hd7zeqS|WMcXhk~{L(JIlYF-P0fY@UcvX9gF45aNBK&n*8_- z-g-BJSjnCWV)%lIu4fsh-A-9R071j`W0t>3A1x;{jSOHLPOVd_CYgeD%&yI8*Lhyw z<8(X-J7BKVo{yF`-2w5E5FD26wHSWBN#*915#3-arIuFY5VpS{4GL{voq@E?^`U$s z&QPy5r{llBu<+nI^z=QHdNU4kLl4N`4+l@z0vO+V4SzKYt#^lh)RSifSj`VbFS5}x zHRnqf4x6R66Q(F-Q)b70mrhCf-=?qm6PVC+rP&rAVJDh}Bdk+SAv6s7yMo^kG%;JmI?e>R3k_frl3$GEI67ulil}&51TH*{*O%eTuwPVG z7F<_nH9^Cq;lX%Dm>a*5#=18$fx-m-_Jv#T@JLe4`a)gEsd*pE@0HCv7_;C9@Cw15Ty=-AbUx@e#XL| zki2~tdHD5s{FC!%pRCzgPU-s<@ui2{>}-SfXtNXKH$Q6P^6HO#gA5%7NGG02eLLHF zA}_KPy3&*?j3#g9Q%ot-&C8u{enpowL+ck4lT-gV8#FO{cl{h_NlfuC>kv}6{xl~g zjw5?j4-kXWGqFa9J;NWHCBYPDJE!(<#94NG2d$M~*zJx7{^C{e=I)D5{^(W&9)vXyJCnFyFdmWEv(q;U!#QLK;7)KOz zRA|z~{3e_vD^q24=kHN2z0_2s@~^&gGjjtUkj|5mk%_#HPMDil2Qiekiraq$H(6%k0L3+BQ=?f=HG^) z*kq|}FPn~6^N2P+looc^trO{$)4f&s0{{$_XUZj#BHE_ehm{D|iMmdsZH(t)SB2Ke&o*lLSb_?Z_Xn%VS|OaI2hfB(Ovo$it$pU%0R zm?81@2c|}T#nEwpnvn?@<4=etn+Yax8$%@sCd=GS0W+{Rlf|ki0v$^o@I;NC%8y}h z)*eIR%1|&da~H{Tuc!n`KPh_#txE9f%4;PhB<6d9L=OGr>h?xy8(6@AXdzF(3kBt& zsTL{{d+*mQW4jF=`_`t8m>g_;mrTzuz#erl23pLO9&@nBb^w~_2&e7Q2% zL2P(686&Gt>3KyVAYjOpyGE+?j>?DFJ37Saf0nkBAwWA8%U{XNn(t|9^fW5_&^Au8 ziJW$K{TK#{ZoTO}5Gm5b9mBjE{LF~7H4U+Ai{pZwuZ~~6%!~i6xkq8>nkzFlv!*ZW zLZ^7?n@?rv^X4Tj9T3gB4`|+rKud{_&5rdK^%+?QP^GxBp&wHREM<*n^DRzLSLcV< z)G;(Uo)dAKsjs zNC#phzRj$Ojwhsa@jUs|eW9DT_OR1}avaedxO^&kk2f|xsf>r-En>H9P?(ZhXIqnaVupy##yLe5l`p@SrjpH9xPrQnIC^) zv&=4x5yXi$7^(!%7NNG4ck~s)x8Sc1PnX zjb3-MuGX1cbuQ9l$Gh(KU!0@Ul4@!q`X}pm#<^$v+pwxmL%nP7qrzB_w^AlVssj1n>C^&k6L?@7&#j0W z$O!QgoL0pwmofGwzqHWb?MFO2Mc5q&sy=A;yH`L%jwVel*NAEGb1gnz^^qWX#o2r$ zT4jN~0aZK$a`n+-fZxD$y8QRXT8~5W(v|uo)1KqWT&{VieF{ALkU~Ri8EQ7mZGE?VAkCq> z69}3b>Xu6(?MMN-c_g&7_;IB=aWsWNyT*HueDU2o0w2OBG8~np(;9?jxdxMHV4-RM zd0eUfR`%1+RhUDnp%d^j=i;cj^RDSDB_$iN{v{jUPyJ>rGj z{mp8M5uMe|B96*m{~(neU1;ASB@R3?vY0wP)+u(^beR|hHjZZrJ8IETkeNMIXc(6k z^lMy<=r>WLC@@buw7yxazx#M-SYTCLVKPvt>m}=5KZr9i5F)5m)C^rzC=c+^;GWD- z=waZ4l&oOA045T2 z3d2UEwhO#}#=}nQ>ziU#yv^@@i_5Y_*S1bE63LuHmB>~cl8d;1KWT!pH14Vt$)Sei zs+l|jYBlAKOQ@z6W)Gw8fM^NQ>N4o}@D!6|0c(U4E-FNl}0Bg1>5djZMW{psz%N)FNMlJW&Na+A|H&u zAWCg=@pF|@ZR_oQS5>G2IUONM1ltUyO&qN%HhgSJ8(;}~?cDr(BH6)Udm3{#wXh&n za}smqQ46;motWzKMl)GGo!IV;q}xXh)0MYnytur046Z;%M(#O!2pB7pa*sJ2*p`cH zn;YV}UPB0XIuxLNS<`3eFQ}_a0lv{Y60;DE3=O5zjJLlb9l zv)8O=MLL6&L^=3GY@d%|#h7!5oCZ6N=1S>;E;GJqezSu^JrGF(6q5`z!9hoMuo9p^ zuR-Hf**drLVI26_(%6R|!YhOd5>aGIFm)06%>7X`urFS`sCXJ>VhtI4RX zAc<|1G!Mf<9_}+8Cpx_5)kFC5bZrXHsQ;&I9kg*$q0R%xRr8ff3HN1pQ$U0Qqb^Wc zq2pOlrk<$nRIaDS=ua1&g6p>eP@>B6^f!NmU#x7b&9}>GyzW5=yAzM2Eod!rkK+24 z*_Cu6aE7iAyHVag;l?&5G#zE&K<;H)c36D_6OJ9grPQ5n`u7F(-Iu|is?FZeHAe#B z0#Q(4bH~K~OG)K>+!r}gh7l6FZdc$d>#a{~&pO|5YAr3I;Np^Bk4=pQIVKx0wTI!= z9A^LyXQ1*crDv7KLkKTDJ$XH^6v;SIo4%uu=Q+x!&eQ~oPV-z7`1tv81!K{_qqzr` zVz_a#-KC+yeG3a>InOVT6-yMkV(xN+v0{lA`Y$yNUyJ3EQ>`AJpJJsEqo8!nWX2>_ z7+qqz-Yxqz*;(Hy{W<1(!Ewaj@;b9wwDKhTOWKsnJO~ zrsJdC0{fL&&O%AC(OXnWzaKw5&Kno1%t?yUg-3F3rBwUObB7N#y5aPiDL6j}TfM_1 zBBu)dOY|;BKfC5eH4rfE_mU=2hTs?^t|pwv zZA3T5FY@s!RL1X711viF;)MY0-`>9SGpAw8gwxei2VVDEQB`6pY7^AG{!1S9DmyGf ze%J3KpW4KYt%Bf;gEK7}$nF2aIFBIbE0;AbI?vC3&)3L6w_b4%WnT*Dw}P#q{|=sM z@k%u^@PPPHT8c!Eznp)s6D*N@zA-m&a?xcmtNuCNVS(KIx@f)}O>2=?zCpuP8heal z7%p{Z0aF^!uS)Z3b=fzMkxk}1W|(=V_Bmw5<7qFqFz9$n1<&d4>@K{mlUJu&tzO&TsuDap-5uTd|OV@Z0jG_a`giJIb8|xGk-S^Nxdz9|E~IUD(E^^ zqnTvW%#pbfSX2lCjaMj-_ltattOc`PdH z%R=_%x?dfWs^g~QS4dRT(I$>cwOYt}ijIGdRoXdU_^$^GAma=P!C5U_z; zJt0wgSs5(y2r~0CiJjRWetua>X$s8Zu9w|7-J<@`baK0Fd95TWWU*&%ieSciDZoD$ zW<2kAK!OBZ-LVe_9w@MgZmQ2Ov!FEAOiP1M! z%SFrL704l>GTIl~*kdW>XCdc2&bu&MblOA~0!+hn4G6K%Ra{(NF=tl=6g9xcmM#ZB z&&n;}bJ}ixYMVJm`}2qObb9V}7WuGJvyFg&_n!miH{klQ-+ywZ-bC6QXDFFYSp|A% zsn;5yu^)22ah7Aqb}09Cl~ihUTmP|cLQl5y`@JMRlmT#o#$9bi92c0@V#N0K>mLOW z=S0)0z5$CKa^%p(d`W{f*t!<8&*ZhI^~2wfIHn3C7;_l;Jo?L9?uf^xdcEoUoE;{; zTFX(-%Zuk5gGYft(D+?Yb$h@$>Pj(R$a4iJ5ZOXgOubQ3e~(rPs!IHl6Wp} zs#JdO0>!1@p4ZRuLovCB;%k(3LYQuBOa)vQfQ{05UPRADT?inRtIshMn#4KftCT1f zms8{x-(;vG>^NWI_w`5RDlrog{XDwcIdfv({v*E zr60g(a%LM%uOqKo&3ZUX6qI4+yN5lRm2&VjlkF+M+2kBWEa9sWv+jwYFy?sOrsUcn z(PUoPFJ3%M?M4BC74~zwwKwhvS}KahU~eC-V6oe|;lk&2!t|`K7LBWxG3pA>#5KRl z*6*i&f7-o~jC7Ym>8M&-W~ojYF{C>Tlh& zF&~((SiCyo3+1-)tCN};&FHkrrS_|=JdU>&iH3mcsw(^XL~Pagzj&1 z5!WE9tyAI6iCh(UmFg<<`Hwnl21)(o!{@=n^ZkmYdroT?t}(0PKZJt6?PvN*uzN@ZeX|nloBPiFAG2hu;Lhr;!{C@XQI%$q z(sjy};ZZ2O@--O3$lz~N@~9L4!aK=hH9YxS}X z7+?!TPjM1?3VL%!?Vfg0umv_ds%I;`KLQ&Z7OZwz6?g6|k~nncDVEAn0~ zH$8n&wVf=N#c)j_Q*zs6SI|a(a4~>*9d0cd5*!adP@L6k6|{3Y@78|iW1HiG)}9b{+5#kwe$ z`&3{laW^1&SOxQ0EyE3}mrGurKJ=iW-i`tYqnT-jD2OP{yy&!WlXIY+BACHA4idQK(=x8mn?6V zEEnjE^hp_Jdyk3w2{LsWv6`1*>Us6iN)JHk(Oay=#lyQOGXK z3&EdbUZ2Q&79$Vh;M%-`w=(-`J^ogAMqP?&8E61;CP-ds=-bs}qHzqy=qJq+4SJVp z7VyYyiTdE!Yz*QnPRAoS4*^d_*_8doTqaaO>08tul!+H2;!YQnBA+YPHx7a$UpDlw z_A*Jq%xz2jurzRsjYlPHVwNNpg5-MI)km&>aGd-qu>JqnZgR3w_|rk-Loz2!Fq@NOh5)<4cc`# za7$75q-$2X`2i$<^+qzH&Q_$(WnV_}hov0s?(7|r36-CAxDV(_y|RIgNckQXlY>AY zB4quEbrCZI2c=-G5cErf5luNUDQuJeO9Vcjm%Kf-u?{~5gCp}v+Czn2_l1;6*rzcc zSEJVX;f&;5`^ww!&PS;)VUbg;1|rNT z><9-_I;EYMN)4EZ@-3=l@d>%DJSJ+l_$WU-28x4(BDrHQ_`Q*0X@)O%$0g@}Q#1>2 zj#P53kL75i8Ht-P_%dUuIU7kIe`CE;z^FkJy%0HVzfI4}!AWHFT;&b#8{>d+rdT(K8l9UFTvH2GU;@qy^I!Xas{Z&1OPOjOBa zsUB5CLly4LzFo>B12tMr1Q-~Y${IymTgJ1kuhE8v`l;nzg2He9*v);#@yMyH=+rb( z^EkPR;S23-N$|O~?|+cQpnLBtiXz+OLg&W({MO#2Ct0GySjm}0F$bk!M@tl&&9DUD z@ATz@Eo<|v^Vr)ECxK55d*S(78~Y5X!rneVSEf_Y#@!NVF~|fGMeS4jXtaZ&>16|bFfAR+LKHDHaY1mnUH!&S%nD2&sQrZH zs6G|rFIcszYc;sa`70ZAab$W2F5b7c0if_=279Q<{=LMC+T(sF z1X{zHX^ot%wa>65kpvS?K(tt$-F-}B&QScGlQj2TFr&oIObJ^+%nkz-8~x>{HJ2s1 z@fslDXWX9LurY@cpDxheAyRei5u$5O59<0h=t@NQ%3-sq8*9c6;*932U@g{NzatC5 zfY5D=fC!YK_a{9fvx#&_9>a34=xQcJoE6t*s+K(%Q&PRdf&}sa1sR`L2DIIi71{1B zioAmH@;988-dSkMlFTw@TC%!n1(n@(3kKlC{iS2Ki6(X=jr^EF_cw|=GGPahkY;oq zgKKm_#PwO{^x&2T8y7cSva51uuCAm~?8n+YMFdXpu0z~vNfb+^(crhg zzHX$R8nB!D@R{6V;#P)Xe43MS$pY@DaOaq1(Ms)|Tyw$A=ybS!*BB0kp!M{6v}wh* zs;ABk@~sgG89DCwv^6s71ak7JG0(bPv&~*qv~;NKesu5O zzl$;%76YL|WdA&zlT*Q_)AlvJdYv_Vy`v?aP&9x94E7F0;+gECJ$NZ8u@f~C!S_tD zJi3|Q`3@G7(MqV0iz2YwdCP-jPv+$M8Fmj6W;U^|r?ZMqo^ z{CaCFDOzGl>XWeS`y>7QIjX#6G5Elyx5 zkfuUUp{5v$XHYXxh-bDSmQ8K?CTpeHtIIN?-RJ}GO8u#|&$ffVByhZf3cd!H?`n2# zzeB+ZLMCdgFzw==UC0%U^to-mgSPUMWNtufWMuATIxR8!oi~xbMpNPPjNXKw5q|?7 z;KlAT( zS!6WR1M)@}<&XXAAQ~{dkLEI+sbm_I&T>Nx3N9jkaHq^W4cZKVjYxb3Lg(3BSwC9c z+_)U6yTbx6ej?A!vuYOdhX84u@jff2o)6q(YLt*u)zSr8nq*b_WUAv92e$~%fN=fs zos4|>YNJU`^g-C|>(WYr$ozIxR#w&w46EX1jo%iS4)iso~-5aGN7&77gkoYACo=K3EE;XKf2& zbyh2FhNfc~6p}!B9u;F>?;v^(T>S^*7zTa38kwzx*_YrKxAp};q^_!~nUhg{e0)w; zPnDt>lmuZ^f~BCDhR8_K%yIMx%jk^STiZ@_wzliUm)^{@|3r~6XlYHAM9vUf{m$RG(gUY84HSu^;@$_T;W_@429^>(~A;*5TC3KYd4D%UmC zsD?bXKj*B9uF{&Wsk(WgT>)<@gkO(jZdx!oozViUqEE8)lV?NUvalUWjLefv@^?43 zqD;5Vv;MG~ayP2&K0ZlZQ1JnW==H2D?|DfS@>-%=?{ccg6nJt($6 z=i}p}P2nMv7@q1KedknNaJoE2ClM|OoiZ03P@-#Js_6+W?uWDnTM^k!RAIl7F3<}L zK|C4cQ0koez3k&^%N;qO-!1MTTjR5OKbn#{kI8r#uq?K= z?9<}^c8|Pka@(TS_>QRQ+}F$xn!Wud+-HP&n$=56X3`@KDwuJ?cXs#s_Y!|&mFk_v zXPjEY(IvXz;oJF^gh& zb+kfhXR-7(uYk)yh|f-4G=11hKuQTF24oJ$37FnrR4<|6>|BW28>F|Q5wcdB&6ynI zKtG{nZhL2^?{}@+IyhzfDOMsH8uGLxrkUS%Wxr`<5S;N@h zC^X1~Giw`s%d9NFGn!jxX+(F0Ml1zNuqVJHNf_Q)IO|J=;4EZ_2c)<3Nsib1V<^62 zGSboO+XGQ(@Fudq2Bso#1+=ZvzRUI*_+_MgdnSEBkISA;0s89$=$iss0SCgpx;VHh z&6J%E!dIeIBSP=Y;Kq)@p!klDN9;8(Qfh{bErA&S>Abm6l0DSU zYV-u@L}*%tx^a>LnS$Tw=%{eY5h!#^Z1+qv{1dn$fwYg4>(aGUH{4 z`lp@(Wd5Acf;1S>8;R3p;e{BIZOhXz1(wBYm&qmWNFZ|ILD-vm4gjUdxFFKI)};yf zxY8xrGY9Z3{-^h#`@X?2_m#l*SQJ(*(EJKkHqWf&tNbc)d$1X@2i=HR1hUt&&yQ-)hVmrEqeA95jFaA>m1CP&(%gTyQntStKf6th8@)D!m(0>bH72cfE_~*Kd%JzE?yN3$TGw*Egl9 zousdSST`I^U0#U=gF-UHJ59)^{)3NCgU1MQ2s++d7-`yYcE*H_ zTJhut*SSCjyWWaXc3GIuXS$Oyfmd?Nx3+)4B_8$G8s=vNguR^c_I}5Zfyes53j~L> zbIxXa_luD0HUL^QmUhtp(%DrTGT{7AOK1e_<|`^68e(W&TfVL!mg+pmX9D*w@bf0T z`Jz?E%OvF38r(-_CwR}b)1G&bEW@`P%5Ar{agD6j^EHD*^Z#ohrzh{AY)$noNX-DXBZW3A_#=mine z$pTeOFCrktq>u@|$Xld{ado`ElXPmlE)OW~!%eYRd;~JsI6J-LE;2CU;Lih5bY|4= zpY-3{;0Ldc1~RM+eJn-zLaF%U*mJusy%*= zRrc}58_Bmumr*Pl5Kn@3_RWd?GLR}Ya5IhQEuwp^Uc#gCg+rey84h^p9i2plRZ_W; zSbm`KQbA}$feZp4np&l!6LGk*=bQ$I(iPwj^M{p-E*bjn>0rd7^D`gQH;Jl*yP-RrnWjg4H9Hv1w^o@f`@CC|N(3e>_W!oDe0-KXtUeT- zX0Lz%Ii1zN(XSb0^g{t^ZU=b2kIetQYf6QoR8|D10xR21;Nf<}p<&(Nb=SDURHb^0t^XvoYEtm(U}Pmq#o&= z(aPuGur6AZlio(1XDdT~YW)b#Hl+G`ZqvD1b`ADYzXW*I_0dy@(TNF>66(#-1}Ujq z{xy;-?HXg`Wv&K5a6`b{qB3q}Pd9O&U6}9nOx9kkFx@2AYO=%PCwkD+*Y}6Z3CS?Y z!vtDt%7pv7=QHY+Ux4bkXQ15UcWCt%SEWDQVPAA~Y)s*(=F5b&}BVIXcUXZLL6LmI({?%|zo zM=7v!jAmENfWkw}N{et!#MjZPFaZrAh_Ied1-^Ayy;w$^*U$hgX<<f)9)HKha( zmg*%&MVYTQHCN8j-jMqa=sF2cX~y2?N^-()4PuGYH#L1`L!>*XSx-2`gZS%{d@#D` z31roGD#@vDm6{&F>}}P!l$(aET1n`5wO{0ZWBSE-%%`Lv2l8NdAYi` z74%4&`{Blcl*9n=Z=r*%(5r)jCa*9^FIRDH%~Y$b@az;+hO=~rf*IK)h>rsT0-HLJuV)gZqx_rENVIeURQ83^Vd-m58=t25gIB3`bXoJAlCepi(povzsou& zCiz4BK~%a8l&NjckA%>BqlCD~uc`~mtu8Hb)PwBMfT&;eyZ^Jwg7%1CN1=C9sucpp zR%Sb@A9$0iTPXpRh2*rnh4$hn7iZy)jUY$u9Tf-5CB?oW?ph27pU?ePb$KL>q5&c7 zN21C=7hJe-Rk&aYwQ<()NGG4aXD5Ju{%n(<($>Zcg2umb znkrF=;vUl(dAB<;-@`B3pPw#CoRd&c$)Wf)b1yiMlIeflal&x`-R(v?B+L6A#3+6N zqrvOI$`66`jf(F~m*Q~OScL-ihUuK~Pe3ve-a;@lMmvO-vxCWKq#SViJELjbF)f!o z)t8sOFGB4L6XVy&0{xfAio#GsI4Mq-=W8s}7pJS9OttErU;+I$t}T+YCe9|=h>_ju z;G-*jaI>@lu2%O2QvbCrs5JsL8U4Q(3XCja+$*gkpE&>3CWhfjg@k5vEB!-~013A_ zNGIjxCcC4dpzsCX|0a)wQC~FDrX*%}!MU0p*mFP_hYsj!pUm-S=9I*XeVlGKTycs` z%|%s??gPV1@cLJGqP(A(a~vb#S-@{9m+Ta((E5cfAomug?VhU|^ekrq9ulwIg~8qo z-xYdA=gHQR`_~;lMOig8rCs3&5(UQ(fo8^ndF)}fkao5kh^Ju5mnHD}2m}QM|85@I~PMss)AaEcv)M+H;U!hLChkOYQuS!!d^qTT{Yya7;qU?=|@WfvGYkR}KVx|gQLkdhDO}xo` zEm8IfCwn{JhHcpFs+yR;;V#EMfOBK1!~e3XC?=pT;jK=Koj)i%0p6(25ow}6ysJHT zf9@>C0H*Kv@6UA_-2#BX-^S2xAiuT-l{*7lvC?bLhHC$+94>y?*L~!R6FrEMzd%pj z6MLBWX-o8nu*3>god}|M))v-xC=b@lL;`)g&)VA^?=~GHrmHrpGQJZkCVCwbe*&5R z`ii5%3tvIdF83Dmi|FXgrqN51%Q<1Y*CG!884+4KtKx2kJaV-_GVs>bFLqg*C zkH-7|QbN`G|Nemx2`x}Nd~PC1a0ko6fhj%mm2N4)A-|MD8LE6_Rhak+)jKiF$p-B_%B-Al)ckg5*X(x;q8w?ssjj-~GJr{mwJ<%sun$Kdx)W8Mk|%`#g{1 zSnK=!e%9+pZbKhe2CJy;O*_wO6adap|Guk)t}#_Hhd+lcRS9!|HaR90zJ9L_g=Zz^ zbjuu{hC6rgrrEX*Q~kF$oyAw4LRS!qW4xf>Sf~tb0R@i61%d(?HCG`sv|0 z2DA0wGDvyS!1ddv$jpMhzR=0xnNnnbFC17dZeG13Llfn8lH6Vmm|YCE;s{GWwNG(H zBaCETg^8bkjt&R0)>t;>_WXx^5o2R2W}TYd@owa24Qs?~$-^wCeUIPYPe248^So+S z@nnE*!MXfsTTFpc z_3AIjH#<9SYv(A}*93ZI$P8Q0kK_asaAv#J9;8(1qvbPpDW=NyQVDTGWn>2vhw^mzx??A&Qq8@-*!+ZSz_7EGrEbqp5OslNzksnzUX9%q7;e) z-&5~lu&A=_!rXp*Bj!0fZ>dRZx6`h2Ig@%lCb`gw!NM17DWh0T>jXA0+yhcxzeR_( zMmC4rp1^+gx(2n596TrsWfAYu|1F*Mzmo4KtzYu)L778M`Qi3Sd*Y)4L^+&{pypPz zPT{biJ0KPG)4=v`>f@)b6+EI%u`n~+(&~n}dC1?(L+MBCO#6$lOJ(Gzz5U@taeDNc zFtUjD3Sz23?^r85Z_Bpa{MV~`kkAeFdcFVae>{{1emOo<67``(YFGF#++TUUW)a;R z50Dt`ovbN7tVjZ&@;MJ&SGbf{um5pLq^1H*SYu&f;Sskjg6_ly-`Jw*M_UdL8N0e7 zmG@dx9Ly$`Qkl?(L>eeu8?-oLj zyI=%*W9wSQ=8J|mPJ5f98iH@;4+LB%Abddako5swm+Xs&dStGNd#_2O+R*Qzx0VhP z+Z{BSRJyr{$jIEM>-rVW>2O;)PZ`}r1~>Nf_~gW38{t&C)3N4*0iiH`g9xlp4fa&e z0`vA1?%16;`AwZ-)1O?>6T^qF9J1=O)OeDvkzP%!TKR5bx#JtYS${@Rtxl!B;I9-x z-D7vX28C?Z2I{7tKMiaWi1@gfW5<6s{VZ|l5r(pg$Yz?ClAq%GpT3cF>#p2vwLE$d zIilKVUlwN8%7a9w5h-cZorVkDEVjSvFO7Z%9iLTPh3=x7uQq{CMp@nTHry7D5bKv*T~PC+jG``;9fR|xfm&PXNe6gKC( z2jqf<6D%AYsD;)WJK*W1^G=84Lp!Uqu&?_2?v&YuzZ5@7#clT>O|eJ9gZ2J>iVA&h z;Z!9POI>xjWas03lNqYF+~D~2g~(BdK4M@%Y=cFF5tHaH(wAPZdud>_$d=}Iaz>rm zts5YzkZiCpBUsY94yU5(1Jc`iLJgaq|F(joT&}*7_m7^G?Wq=UcG?t@Y9_DGuZ) z-U>?*{F%G2W%8rUY?KJBb~j&>{52NuhxW9wlp_poA1GAR5Zr{hM~Fl9vX{!X-<^g9 zP0Xmeg_Y$4SF@wnxH`9_eD%^!a+S9!=#3W0AH0h)%_Qh6^8bsu_-B6>Md)ANmGsqA zgTNotLeCcKGcsIjVd>C+=i1-KkQd?GHGbHh`zY18L}aD6s-RZq>kCg_6&YU<3fs3^ zD}ksv-^Pso#;vE@pn(9`)t*~#QrnGVR zw}pY%ME-$6SM%kEcB8;um*oWthP>vzQtVgV=^lBR*_tJcG5OjoVGKF{D0%|hlp|BZ+!GN!QnJoL$d5VpH6w};G}*h04Z%R zv*@L(WKI3<8}RkTQ5?7M2FGCpm`zDk& z+k(w(xTVI3hob34%b!$usTE4}=!q>BI+JcxYzT&*hc`!+L#vqHr^s9{HzaK;HhAY? z<@uArQ2)3CyvXE2xR;QpthDJXWIe?L6;aTLqWT9n1YLZ+k9V8bs+Q=|bFDokV5myO zBM+iPUS~qE$G!g+oQJC4ya+Vn5nmLK6p<)>64){gwWU}6OGU=mPlD-&upx+BEV=|HMCx6O<{2c#DUSK^3SAg+g zm|<>w_VrR2CNV0R&+)h^dL^xhBJ)e!7)FE-G{O)!4D!w?(G($+91^9Q(vLv<%cbn)(3-SR zqg8szwSs;ZE!rc9Ou+MT1wK<0hH0jrXbQ(cc`3d0Nco$)5YG(TpYrkcHni%0_7^&< z@Jjm%qhSMRBRFid6$)LJRyQ80U9%W25Yptx3HwFZ2OCPbw!r$22<$laHzUqrD)q8= z8P&@rKF9)qGO{accLBj1Ruq>R^E5ido=7W8*9Q(i5@0a9&cJa0=Iz^a`w2x#^&+C8 zi%#cwq>6=(ca1^(QpH=^x;Ro)CB2&_8F9z6d5@z-`ZcJ_Z01cIgx-NaR-6Cq+Uv>5 z$-&eKX;*i)mI8<@v1eC1Y^K_~l}RtEOvS3}$YGM8k#Sxt_{uPxM9CuIh9(lDjGlF_DcC-s)Wi%6A7J56ck`v<|LS7_)qZG!@vR6K{s1 z{<~31Uu~2TW&w3}E$)ALZIwSTu}s%S&jzm;fj}n!A!MdJZ!*Ea&xm~{w6&q|p3zDx zZZdw)AgJuB5E_l{RlS+wM!fs7sdBp#JP^%GZg9CpuBQhgZ|7_KmV3*RZCqW8IGm|k zZ9|oIr)0WyuKr=<9?{VS~!)8b=(Bi_+{pWtjP9-jJMu$0$S_}d4k z7qwE2>dRVbX2Nlw<9-bHC`_@a*`15Pjh9}Q2_9|HqE zdHZ44F5TK@_(V7+^YaH2A|l+=2Nl+{;A8W*{O8HNRk~e!p3Zg zeN%f#b@h)0iJGYsfQL1p&R@QY6%C?P~oqVV9~)3Negjj_)PlRl8WDN}EQ;7m=^???2mmI@Ug`APoDmJrV1mKS94wY0BAYx7jy77-{H6IDdTaWpMy2WEY{#jUuPGq`MMe5E$)1UMg zx$45mL*0(kxZT_yb!Da7k7rl;Mv=|#f^g;#oy27ZU6{PU#N$54&(AOPOiPQHYbd5Y zn(0X^+GfGT=6}!cu;b*t$fT_gv|g=XN*e% zz3o?tuEmR@1DA^(Ci&!=>!7kBE!CnYj^3&7>)p@#%Er~JO^K0>`Y$v3aS8DhGMSDm zL-jd3E@DycC2jw?jvP8Vq^=&9y-!F4?cg9hiR@y!X%XWv#YtAB9JYnAZ#_^N6)UC$ zH)Zj6>rS$d@zpOjl0q%b9A1nN;;o3 zz@DX>#5V4b%#~X8U=qgEI=qR#77`NehpKfzE>JMB3}qU!6J0n_$*{>clJRM|`pqsG zq~NepQ&WYwP4zO<{;Y_dC#UrN5}U%ur=;9Z9gx%$`o6+{XZ(=NZyCkrYnehw`0p2q zV8{Pp?o7@fxSt5d=Gng)YmgSanG)tX;xTg*G7Qw@*oSZz2f$zJctI!n6ot2NnR)UD zoVLAI&oO%&-ot&>>pWmJMkb?Ls5XUhAEOOc-WOH!4mfmb*bp?GA-KqZ#ML|VeJu(| z#!Y!`ucFZpnR_c-Tr`}*2#<@WOhkj9hHa9xS&e_mD%<;zy?BZfu5pZ1`>l~b% za8__7RKClw6x!BZwrlISY){B|?6DIYEoJAr%S?|$_wpe(IJW~PiScIoBD&NCoxSZ- z;1VE)@f!PPtvZXH=S5IgdgTQVjz(oP3Ze*LS@^TdC*S^)5>~&pf9fj= zk9-g*W_8@>ukVtRnYjvhA2Mi8@YhDA`aR4IZ{Fi%BhALQn{1?o`t{n#!~lO*oVhad zjT;^Uu6#x(97x5cR?uB}O2H^aQ&o4+e25fO>2S8B8=y!rOfz6&f=ZaS+5TB$AHTu5 zd(+_c>-Ug?J=+@zl*Sh~+pd9Hxn0izR|)MxCCwQmax&cprzSY{Z%-Fr(1#6Yzq|$( z4N?Cgxzvn|{qgR|fE63%T=i?Nm|_n~^p7P-cwKcru)?8Hr0S(OtJQ_H3APp6e&??N zvD+d+f>LRR;c3zmvZGkSfpLX+I3y~hnmk_2yyr}hTUL#FV}HGPYm<{a zzE=T>w=*I2i+L#*$DHLCXt&Ad_#Jft=jlF)FI%K(>suiJcI_phW`OC?YCtY!3r=}Iej#Ff z-$L9fm=G^8h^B>u#SZhm=|D{ZPg7f|tR=MO}W<8)Sm zbG(Owr8MnZ=U?whv-AtkdEQKBH)==I*VNNfu?JVKVX${N>;#i;Yj>F^41ZSu<)&wA zj)U5Q6`Rv~>-y=m0F4t!wk9m;OK28`J6`7{ua8j&@-R7Dhpoj)BLhBhtHKEIQkoOo zK`3EE)`nX{1yD?W6u;`ES9d@5VW|e=p3%=Col5)l%*3Y5ha#AmTT%471Uh3C%?SWh zs5MN=pZU{YAez6GZda#iFH~aQ0qu+ha;yF&Mv=TsG=2RqVez}M1{2_q>KWZ(ar05elC@!ZY$9T)KDLlqd*20`c&@-eLS4CGqnhq6ZHG0B@CU z72FCc?=8NX0XQN*SYp>DN`xLaacMiz~75@-(*fzVky!H6;Zc4{wzXxvm-|3fk zEcF_cr;1*bQvt|=Dj_sT5I;yd705>ElFQ`?b0+w;*fyrWJpo)(@#}HxJu(g~z9AN$ zdd~rAwkE*!K>q%F+HMiSV&N+?0H&=xY;fU`?DgARPt>Hioo8r=i!E))Qmvm zTRikjDnJ8sPqc@xSWl|RaeC9EUL#56(f~R`&V)D7N`uXpt*|{AK9@~tc&U*?nOUtu ztHU@ppHb|(EL=4gZ?=^ zO=L(`F|TreZAhYUV+nLXHQI0Ar2>a-(oZ-|IzK7adulf;v#^n+ZN8XCKi4;jz&a#v zNtSqQEE{{fXo_ckpeoO7qOj;}L+hLEr!kKN@LCC=C?JZ>eq`rJE$gG;J1e+fkWeL9 z9bEz_ay`z)l^%&!a}<|szGzNK$^j?!+;pky0bw?Wn*bp15ds@-l!cc*X zoAdD2ARZ4>1UyjceCudgsD0w`q6mG*|GmucM*HtH>0>N#S_(Yw*2c2nX|X?4K}3hA zMy07K?2#*o{h){`&@1uuPqcsBgtL9aH>50MqQY>r=#i3a+V(dyiSxfL7PaoX^75qk zXgLX-*^flYQ|*tjSf3mCst`MOnA309i{XCau^xUS7bRq!O-Y}7`@k|RWc+acr+-+n zj7i9ABNW4#?APXP-KXg+WC9*^rx?H$RE*YQ3(WCIleXB-?Ses&9}+bm*E_fH1Un#R z2~f)@mf{QSJ!mk_lzsg; z&j4#~Hu6=rYEEJ)s$TYsOsXX&UFtl$drOzqs3q0(cmR}uKHJE6MyQiDx?Xw9@un`;PQI~>7)1&?3w&dKKDSqDQ zXnn&VozTA;CJl5gQO|UAbQaoU#0wp4cF7YKa{h^C;F6HbPX z&cgue<*YDb5(~G;w}S&4Le?%eu7Sa+sqayFu!!uD6@zYljb3Uk;vpULBp*j)Y@8-{ z0VNBv%g;UJ35{WceeV%m35u^TD;l!hi8!-QxqIjem*ok1C3SJcGI~c0>grt?!4PwG zEom}&#Iw>13QC(~+})36&>%uhKmJQe;ucBTo{m$^s;7C+Y^P)H$~bO=`3HB6rx#jh zXXhp=A&aq#9`h8QWgp2HU0o%UEdCxEe%K1QGnyc!EZCLAg&rJJ6s0^3Mq zV>jOG{t=@w-w5?@b_t2}G$QH$L9wxNu!hg4TB9~|VQSJT)2rQ+QMC?lkPeo}0pynC zrdj`myT`dLFkGUtvrkZ}9tfafixChc8KTaA-l3sKh=?=@bzgN~E}w$hu-Ahn986mg zy3rro8WY&n)TAvx8`M2cLw_?6cgj8eI&BH)_?^#aP0erZiraAi+zDisYiKHCx;*F#)9u;D^HnxWaDOg z{-`MZP$x)!ujN4-4YJ6W-lzmc`23rg$*%-w#V%6ZPv#l8S>%|ti$;YM^!{@X?!Tt*Ikk~+yhW?E-4wEm?aS)5^gL22Y zI_d297CJ*Y}d#PM!cQ|Q* zq=qCofg<#{!=lV$@MZzuVPimvl49Ol>Z;taPtjoQwML6>9cg%3fnKnn%vRzZZ??yj z*$`O&%Da=d#TL^Bqi5A-sb}nGuE!@KsDGP}@1)sPnvDrK?uj{Wj@*9xwi=lT0((uW z>OO*gSqTPKF>02m%B!pmi}?oCiT36 zX;sJl1a;v!zyt_{GY7T)_kW3ha?nd#Nukb^cCsG6UUNtuBlAnGKph>p<&9Aq_p#2) z#6(v>iRr8t@{tIQ?N9v7ksZRU*Ys&R6*zNo0#1xE1yk=L<}-ooq`(!)^`zjqYZ)MA zI)%R9tPVZ%r)#m_HBqxm{+WtS}w5>yo@Mr<} zrzQG7=(%AAzP5J2EBrCQ8*EHVr7D_mH2t6oOWtv@GwMhFvCQILy?sW#ypobK8q;S1 zjoXeC+9yCy^vRvzEv0ne&#eWH^N0A|7)0wBnA;HR$iDS33V9SW=3o>El+d{4?GN}U zSVR#|4B^CDG9gy;yXMyqbR3(~CKY}cR#VdB+s%YO=oO-*Dkt^%Y(HjI>ooz((uUGh3|g zQZISqkQlZ(Rf`2t5zjxo=OG_MPm zaV}ZB`~wq!Rj2|2Itv}qq@xAX}r%@KeY-)T+qP-QSU7RP;CH4f{|2_ImM1h64+9OaC$p6t;eequol?75Oa94_4-1DbKf5MPt; zv+MfIi>=C-c#W7LZ~EkTvi|TNFhw@)1*S)h!w>^Fwoa$IM0m3YD7AG>-l)4BBC8 zlBv;fD=sVR@7QtVD)}QF)*gh%G;f1{M#imNW!lJvc{Hjo>fuZ)$dBx2GLHJdOf z#+j=uHe4_IyF!vC?(>$AA^3{CL(91snO^2vYBe~q9FRS|MT5^xKuq+-_WcCGNS**B z4srw}pkZvvtR4bQ!N#^>4A8R`OJo&M*T*#k$p{Gp_q7fU#*=^A?rwSOm=gm*3Oi&? zb>3fEEb8Xfd7uKgCtm^Gm5bY%`_T^|<6yH3g#-UAFm&u!H6rXeqr=EShd$n2h^nxe z^mtf=8>T1V@ei~0M%*`BUMPVBW@a9IEiD)Ec{_o?7#O*MKqW zSD@`zm2V$*IMWq6v}z4W4@mff0=DjG_(ejx$n=*MMGx;IsCMlx`cz~|g(!xgs{m#e z$L~&%_Rg9xwZu^|0kcOu)#YOcRk6ba&SnfUA>s75WnWTLG1s;s>)r^nQ=K;iKnsl{<1$&gPIv@7 zWE_bPAM6{Di7YzJUePGK$XApcj5w4i`w>cGQ@p%CdwPUay^@iF4#cP3L1~4QFss2B zi7c_uOOu9(`K(l^ze{bxs^aO~-8}~DQ+R__5xH4(^B+2;b6-2)bxa9sJb4urW;0Pv z3@AF0w(_nB0Si+SUc1|CT$=$-i9iUXgy84X2&E!7gN!_`b}~t( z&jX_1%*A8WK|^uV!l4gG5lT}kYTN=sB0VwY@4;u_)le2xveQaj3(-16t_Rc+dEA?6 zQW=jCr=z9D;R2yJf32)o^YvbFY;rsx_c+%#HOE>bDBK{ETGRKx?&{Efxh|iCkRIhY zb$G!#WIg3`+%J(T^?m|jRPVX=v5OA_DY-~rM?Rn$yZfh9qW2ZiaPWyOWRCCWJk0bb zBqBnT9NSqcjK9(mCraSvgks_AM{xV%Dzz0Myx;ej2Yuby}p{Coe+<(r5%t7G*2iT0w15UY-xc6pa=? zkh&bV-c1p_^^k%+w{)PTOUcSsxkmGruM2=sSnt4Xit3LDOu5&*}7jAsC z!fEV{2)i-IC-07d4bV8G-8V`M6mMDvbFp`d+0S&AicqGCl{Ebx`27PQ zn=0PvhP|I1yn!Ie25u_Az2Sa!Z(s|1LvDA1pMQR$ZStoKbzDGE z;3Zlsj1*?`n8HGKD%}1;oEaB+zTJrIlPzP*zT@`tD{CtXIeGNDp-S;lfW|rHb(~&b z&7&=9R<G#Yb-&YB z{00~4iSN;?6VKdxZHyZkoQDE-#I`W+fEE0!jOXXLv8X2rl*}+E7kUqEs}6fJy-IKr zcx1Cda#yfCu9fr6(LcU^{+*uBpiA2b!n3XZspcXFYf4z|Ad8`%Vybb(ODAz6s-~js zpg{{kPv!NnJ+@HK=i7HD&(3dnu2dP~Rzs|SPLxtOABQG0kGH1sQAzU|KQN=)^}%4} z8S?!=F;Ay(vJ_v1A*!i=xv1EE{lG@>)nrT^jDGhqTH0f$dTRR@%!B*-35a_iKHN4N zt|c>N(PcPfiV}*|eka1wj>k}3W}$l-Tgw9B^o3KIX|UYR6Ak7eH}%<(j&Jni;=E@& zoWv$&3V!U=UPni1L9}|wiO7CY;uN)a>PlnONT%)f4kuu_AA%c1%93kHb*C<%Ua~i- z!ExO`lt$G7Td5nT|2CG$Uew#|)iP8Bj@X^Wy{TI{R&yKNxO!RLx>0qX`m)ZSPAahJ zC~(ZcPoqZ(iXFjF1$Oy{pzm8IH^cLC4}yuTpWG>wO{oCv`y#$9j7-#ly~F!fzXXDu z(U*)=>xor`RqVL>xKW{b!e8-*1^-p{w;1HqknFTC1bl~xxG`y%Jw`}TSac~ED(%KU&Lt$--M7%&~(o) z{+bQw5f*k?+avqa|CF`@;Mp_1{YCu`0I>SV4UceTEggb+#3m#Z%;4Zt$Ce^)=ujt%}(~$3~v0bb_`zz*^9+xuS|B6M0 zC=R)}#IuT=J4^K6=3JnzcEdlQw;9hUSFD;Yeq-W#9Fw`9*9pEuY5}AX@E@e%C2^Fr z1c1g6QdWy;U4}Dn=aRx@>MHpp%OG>!geO=80jlvUPS6?i zOz_qhv0iWJ_r3b$@z^l%tx$tPsonQ%%UtzxDx!fLW@05t8r7_0UFrz4sidJsGPS}< zWuVCf*#P@Zaca73v$z(Lm#Vj)rS+3{F2BSegOd-6qF75{?7?G zDa{M_j^11G5b6D`S@9LGN%^AVB&p22voC}ixU%fD#>bJ1(HHQ(!HEYYlmNx+@)=`1 zA9Z7oPR(mRKyrGk1e-qYs2Y4dhMqKDE(Vi7Jx|mrw6T_J9DW|EeWW}|_&-K{j}zE1 z6<==cZO1b3jcHtt1pV}S>go0XlbRBFf`&$X1cN_i^XBcj@)bKORqj(vH1lBdj3k$% zqa4JlNucna!oBvTgbx3SZwbMQ@}?o01Jl%)vaa# z#^!L-$1v=Rwqe%=!;hQQx5ZZyV4m;8H$;sn0q`$ER~064D@4s-Rc6%qus{cRMz%O$ z33C2@6?h>e#7@1gB^^uaEu;gi=FxY^G$5EcC7!%L{nsiF&l5XxW+E*a5-kn98-z^n zPT7k*JFSn&9N4<#wO((}z&EvMS2S6>+uw%OIqba3d6aM(pwiR4##`qcxVztS4!sWg zO`E9_d?25k|Jgkq;w{^lK*^vV76mS?9qisN0-NpI{br*j_y}c-cVlv624j{bPj%)L z60VG1(uF(_eW1P8pOmRUa!X8NZeNQAnh5ANQ$Y8g)A&7b=4O8cEF+BhMFw3ME4=k675M@IS>rogGmr!%V|S_DsCKZY~A$L>Co$@;Tq zDP%3x<}j4~AMBLCnk*Vqd7`A1Wf(dKyEh5)^b@pssc-(jB}$vC;b3=V?E_7^a+?!E za`j=A?wi}q-|fbkp{i;El8o^`YJdZ-_1s0zRZSgp72($@8FP}%49aiEPV zyIHp>B#HXqCB3MY_ShrhxM#gVW+$(rM028C2B*=VgOfMFCZr-i?BKZ>^f_d#-HMI# zbw4i1vBAr<*JFz&MXLH*RC3Fq&lU)UCI~yU-OGMpMS8&a)QFN&OhQ8B3=);dhQO5z zSx5@A6^w{<6$Mkpavw|&u1Ni}?8f3!R>{~Y(V2}u8TKTYcbIhQ zfF4~QQ*=Dlf(w{|kNa=8Ii1e#sK;d?pXo&$F1wQQQn7u+qTR{}U9<<3QbBO8R|MRk z$>!G^lJd>2mWqT6vw!@)zy7OIo&}W{96fnzDqD^xh*aojP73&V>`wTh(AnPEC(fVz zP73}nzts1Q7_!r3Yuwm3$o{M&hB|V`j&FQ`Jzo(ip#hk| zXt;KMX3M`Q2|Xe!bpmMXOYrJuw8e zTr{1A-@SzvA9!r$_ISUWY3m)wh4o1OO5j4*U^fYPD}ciHeRY5sejP|M+`>L{ebtqS zS^e}W$T~0Ln^eDPiW3e@fO#C6<&oo1Vf!KZ^TT+{MH#?RLkr;vJMR$6jc~eWWE8y) z6eEVb;<=aAVwkAw(@F=tusB7IBXz>r8*Vg50#|gCh_}ckWv=52I-Q^X68FjLUoTeH zyw)?6YNf74FY~+apz?lurSd^S1o|pS&v{rfzuMhU>DYjK0XSm%8~`xaa7~ASRX%az zox_M6(n3Wa}pT=u@AhJwe??hLMi z%_!9>|4WVez77v5`e7L3@XJ-EH}~}oWRZjfc~qMtry=+N?}vJH?dnrg(_DROIuq$9 zzmoaEIDv%ahxb<)t{a)=+F$-ev5Ct%dm1e#Y_DR@YoINwUsg%2s+A(3JezrB#b{?&j2rRGvUWg+_bp>2Z)W zrPt*@(d$8{K?q7GB;5ywOQc!&t0Js&@(X?!OtXGBdx;U+OW%042(PpigLg@h|Fy;I z4)aHtQrY)IZer4lzcMgDCnuj)KMf3ebl<@t+DedI6RvFEzc9 zt($QjLZXj)7L`8!5GRxDTJtztAztRa{0JieJ01_dV$qI?mQ0(I)_H-4U$PS)w{bYs zp5);FLEG;xaUZA%zx@;ZY1>;8n^V^-j zx3sk$A?a`xiHg-qrK}g9r)9!~iqQH+;B`3LTk-7pn8DpbAan1M4U?+Szgzk{}bvNy<({U*^(9;A*MRO#ggYF8xDQP;kJDH%4UYdk6quA60uX5(K zR;t|K%X=RtrwN(v|Sj<`9^RuO+`2tMXM=g zqSxyq#c1TUc*5k$@>nJk% zvSraIEv0vX?I)|z6c#EWCnmUW*s^W7^G7QMhAf_rjHV$6lvcW<)z;RUEOr;auQROE z6>)q$eNV}hNo+dR2q}yLzpjZZI>cR;M98lM6UJhqk&==wI#6H+f}SKMmPhqc(%10Y zG&dV zV_G&dF^Lr7@L&JQ`Hd_&ry$6Urp0L($IfrA=klf5WBMbx5@58zl|n9qpQ#kqvF4!ahRtc{Q9V8+2Kd=82vmokkt+SKl#wVu*6Ce*94TmK1 zsMQbWqVZ9$+7(S#7m-NXB?MduScU@11zti-kZIR?L)JVcnl&@G^uD;5HSTlfGa@%F zls|m7+IXgQG2P?7TKJnf<4<(@1&8&3XC*XKW z|8b<`4RfinERD@{)g?he4I1Q9<;neCw%nuGa(EC^G#-kb#nRX^c?0+@wAv5xA@%A9 z(1kgtjce~y*i2WilL&d-g{(`qayie3z4e=bQC<{8hUP=-B)4}$upbZxT57PTIgGc` zDpEDrr+_uiU@}I)W%(~XLm}fYr6#~>qppmS6hdo9Gg$#d$Q^s&MN6^sR!qZfpOX zDzE3>at~8pG??sK-xff=Zsu2uMWxF=*GDw87vH3L_cr>lG&MD+NwwU@M~o!J0{sOb z3x~Rwe&K)OU`y~H^vlsA-}sA^28_OCD}aDfLpvM|;N%Y;RUYUa5TE|A1FPJpiRIB?ZUQcAJa87=HKG&+UVdcTQ`e1q;13nw(siuRR8aOH^XGb3(SZ?jX&p8+uuqx6I7V5f7i= zRA~yTnum!W@&N5kv+hi`Jv@5I5YdXhwm<360|XD%U&I(zR#vEF0;G}(I#H?q*`b)Y zEW{P$;ipa()2NeU+|lpVr)MMW0RQw$qgGXww$ABI#JKoYiE}>%!O9w)D25D}?)Na2 zs&VSPSBLZWCdnjirs`sc%kpB>>ce2O<##=h)`<9SHfRb%NO<#>6rzl{T@TnTr(7Sr zV$p32;log5wLw4otNgvPTgxuAw?Mr@=eGFV6U_$W&-|@EZ9OJ&H08l6^n zLupOI_E#eKa8jDfv@m91Doed*it#@Vi>*3;Co5mveoiqm@_p%D{+8YDmWY&8_6|YX zz2~xe*aDwc2lJ_XC`)?w#}6@aaIS-`8Sq!(2Hv2bDJ?pT9};1%C2sJ$U==wODHr)pu44Fd%EhmD_1DOtc z5dS3eJe7?Ja1z}*)yxcEy7j8)vRncmNNiu|fswo?#b;q1u_)bMg1f5Bz~VzW+nD}J z2$$sij(A+`qa+`Gy+^Ogf9+fZtJ&CHZttr1U~WFw(vs2b8_9?a?_y z(3i>XU665&tYN=V@=`yJP%`5bmjAuw<0CqC&sXCbLm)QNBUcei`e8ub{w~`XB8a@`*|U_#1=?Ti23F}Ac&UvS1cxr(RaGURH8sv{%($? zQWE(9&0HnMh>G4{x4|PUt4D7Z&>AEEqO#wmWVlSv>UpyXE8jdv&!qbpU4iQnLP43< zI^8tE*xMEpFo(>{MjnZ0DO(d;3hdt=CU^bv(g0d4$W-a-m=Q+rH+&=l3i&->!v+Z^ zAp(0I(@&QdeIr>C7ZaoK3kJNFXH`F{%7T#`|*b?59|u)HFSA zB7{VzG`&IZ(4;@WEP&miD)=~=Cf9FHbPv^ByCOF+_sx;Zz;VzaGK=jxHCN8l10YHL zXJO@t%S&n^uV7Y5UkM0Qvj1Z-UHX2fQs*?RHN(Q$c7Mskq%yTAi$RpNci_~md9P6R zPd_5F&)emSXuTYin*@5<4+u!8k_NtZ2ikP3iliGx%1biJtk2KnggN5yrfMoXwg-EM zS|cLy2R0JIckxn!%_x{p*aGXtf^y8~>fQOZOsT}&;bMKd4I6P`pXR_(#F9f8<$~eI6gQ!i6%*Am(#;vNRI+mD)#ePyM1-JtMgDDE?m>l;VX8=2q(qE>d z1gVPknq}sWv7o9YWd2osfg8rd%ma)H-ZoGe%4{3Jt75sFk}h=N5~}z4Vh4nZX6;&AChp{ zIiTbs^&-nNhRl6{C(HI^?Vk~cVnQuH@8}n0+W!&W%g0n22OnSRK^(||*)I!yck0y*bxYnRJUqb)q@DpN2kq%4 zgk{=)gFEe|>a;TBDW~yC3{c)Wc8~VxV90_~Zfu*EPYoS8d0!Ug1@VOF!JlKC2edw@ z+n!>T1Ldv)Rbh8Jn~wwEN#<`YZ5#{V)|i_RbFlSG_28q_iunjRN|-7zhZXx${C>MR zW$)=9m^xFM#;UjRw>0p3+NcYFyV-z^ve30in_v>0`~m40U56R>ONo_1b0U;JI@ z>Y=Xj)i*eS=H;lm;lP)oO}=X1#ENXL@mj;jFf;%S9DXjl-5P}-R0|AWTWSi53MN0; zCe@;lCL)TEy%r_WrOr^)hG7{g_VfJEe&^1QnHKJuUAmi`GGE1arQ{QJ-E)vUixP6hhopYFIyA`DO^-Nk|m@$%vef-!Ak+)*29)n6(mqz9? zG&qz6wmRi)D^dNcE?<3u=Yd}Uuz?F91|(gdfX$OD<`+@kn`yamjG2Y1&N%kB?@;~t zYiqa97Ks<=lo~ylSbQrRj zfN?^sUSqU4bm3aAzmP{(*X2h@ZRI&Ki;R3x2l4ZgvRSL!EP6oX45$O-4BUTzbaKnX z3h@t=Ups90+zMrlitFaMzkz);U^#}{a&J+zBkfobf-L|%nT3PaoqOoQfa^5Q{`!E7 zjF|y`7TRrO0~%gwVz62Ha~$MEAL>l<_Zt1?8OT*7(;4TC4k>LZ2R6k+4i4FMw$fK! z0YArMXZyH*CC!a&6eNBS-3ZvUI&hmWrK$>skQNmtx+ z?o?lHjXI~iGByj4HVN*O`8lTYJ6lJO-)kQ)zAr06MV7gc3<(e~2S2JVgKzCkT$IXy<*=)Wd#J0xtEmx4sI z21&E4q75t2ACrRURQRErqvbUiT8LB$ANOe77QJx;Ifww}e*L$5Z6!+%oVn`k9`K(PmSzw|{`303g*lYbIyZ%< zaQLRbui;@t@SQUf#!VDB-L0u;g&{0NZwL(JXmZm7D|zpKG2j4`H=Uiz1O zB#lmSH956c51C$7WG{MZSO#an1{oz$QC&%ivyCS(fzBBgX%VF-t6nyj;$sb`H{n_y zDwB??f2OkmbA7|}ZE|+Z2xQ+ynjYE$h%Xm?PkcqTxg}C) zWc9c#E%-mFm^3z1rmCSvEGvXij20ZtzF<`JN_h=VOPq+-lixGbuVHTW?oyp`u5rNLD{R+f9LX8tjmMF)_FI94{v?J+SgS-CrLKiFAF`J(veR)8TpR%375 zGk5fRCP-UKd;fs9Xp_Eq{-FZC;#R{6o$i|?w>;F44;9^E-l0RNNamR5lkFR!NAh^n zU2}Yz?rOoCmT4=%q+4|4yzF^&=bnL(G%!?iuODomGTIOmublV$AZ7yUW%k%KofoM0 z9LpBSGYQvac|jKu^Lns7$ylG>;#_vrjgD?YY5dZ}${H2+boV2IA6sxqFh$Za>fe`+ zwKoj_p6l|!m>J2CHC*JtB*X1A_=)W0tze*sH6!1k{-Sx!IR*Xv{ow0z)yIgfb|F@R z*;&kn)9-MiuKq#GLxl?Z4qC{smn-vnhc<17QMBFD4za^&ce#-Fc~#Itabx^AGmqCW zDYe74Tf%_9BuoaLStF-nseY=-`nx}3*w929O%5&K64cl1FSHcwFK@3K<`%8s(rn!C zpyt3L0P1d_9#fIvj(5p>bo;N|%Vx-TZnQULjm=9A+O!6mgMuV{NUZbsvk`S=cSS^g zn)Fa!{&+z65T1=g!qv6*XnHg>$1fT2?vHKZezbYJw7~E){iW*&D)@a@Z0_9JF)45I zb1QT?>Bg^mvLi_zB$t0i854qs?|^-SkizeS_(KY~qvvAxvdQ})H7~H;5Ucr_2nOkf zdwl$)q>e+1?EN*od*Nzj@QGgW5+458Ei7VlFcK}|ZO0SlbeGdT z-qkR9#RH@W=_N?%LF&3r-fl7sPs7SG^3&Zz3(i#;8Z-^N30qOa`$GAF6z4|<+H6QI z-jfVOYWQQXJ8I3y|2O|n7fEfSBv7C8t@{sP2M(_&N*4Tq`EPet^2&Th(K&3{3{kP@ z!`-Fs_ZbdZxqs)uTN0w_By4Q_)qZ@Vi@^W}gf$jbxVGsYot}1%CykXKOD4FoHS$#k7+&R^x{3BreqO@7gRpFYy$}eK!C6?~TfOQd z0^>cK-?gG~KMeooek1@X%w2A!#Nj2Jj|C*2V65523C57Sh2R~2eOg_20V%@mLLHIs z69uuB@D#u!t5fPW#7-g&M%5cGUt5we7tnl-^a4~bZ#E>*JR@RP9g~V1tjbDeicC7-UOl~00%QmtJdqZdJ>1( zj1-cVjJCvo%2j*B_a`&zEtsl4jzhv>z$Fm={64-F76w}|)4ewn5tSR>4+q6gxJR*7 z*!wz1s$Zv@;vnt#we*&37l^(wI3^9c8x2Ox2S)E?o^z~>?CicZHJBjE;OovVijAF9 zTU!p*XuYR2ND=MdIuvlwIpm#ddT%R*@yPX!FI`vEqL7cT*zp%nsFXR<9d4g1$?*W(b zA5|RE=Bq^><|a2lA`PM5bW6u+&6)c&Y{W~*v;2mF5K_msN&wa+06;YYNPa~Abh?lN z2%N;}G{5H|Iv9za4|(1{Jb3T2rKT=_u-@ulEoe$(5955pd;0|2#z$PYumJbO`70OmWkMvpu%IsjJ zu8_`p9WW#3G8_OtDd1^hHirU`zy5lS*0Klc3ca=Ud^J(HI!hav(*gH;K+rYES$PGB zzJilK3~d4L1B4FA9h7%YRBVqgSbWNlMyake9)i}=ntT*N1o_x)H`1#A4j?)1acd$+ zKb7}t0j#g8QB0rzc=P<|pKk$YK#}v1$x>}D?YqembvSbl%Y<6zBLUJfI=aEA_8Ey% z?!3~#Xtib;FyXAPaQ?f-^=YHLbg_=EmVn<&L;Kvp(XeA5WZHLftALRBjR|fh<^8cq zyi?K<{WB?GsH*7R@@^omJqann5XR=|+Y1~gQL0 zryu1h*Ut966w@s;ktOH_JNrM;vRQ-e5q`i4p5>kj9gIEl&0#LmMO{gxmL5@h_qI_-1)`R{#1vq2<( z@(p0I-J%fq0SC5h%?mM;WzQEN**B+Rr2LbyVymclZ0X#K=9gip)xu1EFvC z6aaS8BS?e?j1PU;9E~cEl2j?>pH}vtB!>ZI5DRZJe{Z498fi^e z>aXXMhW^#4sw<&=xQ)T-YSusQ_+olLR9P77#eB>6d7Q1Q7)BCbF_yTc)$T1%cw z#E4Fdn4oSZFVP8M=S(WMyW+q4HCK=Yraa4T)o2D0U-*QD=WEcZ!u`a=~M7`@cTW*Y+)`&y){-S z{d}Uf{<-F}t!9%4!Q&&;2_eX7fK*3e&qz#m?#kg|ugwV;O>5Z)1R zJD~%MwXOn^hhso}0xV?z^mIaq-PIi1uRk4Ue#ne~Jx6g>tMZADtUUyX5(ex`KDSia z-37B?0gnY1$VPOfTC(g5&Z=zL5HY|g=doJ${AOB-1>B)=`eSKvkC@P*6gwK&Mr%a% z0h0lqtXobLC}$)x=uz@Q3BrMjdcgB^qaPKViznQhwM#`sDUBWxYZt&u(RSn7)M1AY z_RBO~)TGG47dHP9aUF;z3J6h7xh`vVi^?I~-1-oeOc2ZiFsXdxpTJqbAJ}$MD>nt% z3H5`8cc1)0P^7WP=+l^A?}meS_>tCDRZ!Oyvx$l1DMwvpu@yCvV~-{klrSM zE;ZX*2~s=S0GJ@F1t_mDE`uXzL8zF@bZUI|+5a=8E!Qj9lkF+fgWXXqSymfGb|2y_ku znG$dC0TFH$`1hHNH${OgFC9QC4j(@F^4|RvgFX^?>I?J>(vY+%?rnxC{1n1*MugT&&D!B|{Ao%zf z+YGR^imku3-IBbjtd*s}jk3YnjI8z@o#usF<$Qz#EfiNbvw5wZ3<5Gwir@Ybdhf?#;bNXD#3r>w*b3Jxy9&KP&EH`3Y-=?3~VByx9dnwd@Vjky^qq%2XigT&uFO6pUNL{E}u zuTfdz@&K`z?&uH4R|p7E5$^8Zv7Z{VSvXjf>yOqp!@Y#Ni2)-Xn=O}NaOe{$GbwtS z8;a5;C^Df!`aQ=B?tbW!wm?)nh4z><`9;CIIY8S->iFLLrmo-zx`;KAW9LM}HG330 zJ3FXnrh;#|CEi%3Wag9*>@`2WdNYNL&mbhDX>4Pqm4=7g5j}4)8;}^^2YX*%KU+X~3dNsz z$@WV{!3rymTO(P~92(*WxuZ4e>U|C}M|O)vx0@Ium+;# zCBe}Tnuzau$zXDTdBXaF|G@U&hNM&5w=o!}ZjYe|6&dm}OdVyNl)edn|1!o|e&wFI z3bK7WREIT^UaaKvuKCA@7N9bW%TNt4nHSY1=U-0r8LOr4{v-o6!7?3oQO7CJ#s7R5 z_q_q$w%Ohw!l9tpM)C6~^{+tC75r+-pffj`Rp4p>Np@X`nVnS(vL$6tpIia!ue!|j zW==M)KMAB{_5Q#WJ;EojByiO}Nh_rccLqYX*`s`q*|JX+c1F&zvbD|4o*V8#uA`%h zI$SYoA`~Mj&T{>Cz)9EcR?}g3sRMy+aO0|-JZ-Z(jY{V{vh-Pb*b4hUl9{AHiE z=t=GKko!%|PO=iSsq#j<$n(a6TO@yY{rIh{CyX&36bA+2KYpeJ4^?mi#C|Flf!V&I zx2j-rN=sHY=rAV<^Gi!LayssnG?2&;gC|lM9j4+QB{JCQcNl=Q~JfY0uYmvlfjoxlQ*J8ddK*0C{DO^ z?SEw9I=)L07QJa>$bd~8`w2S7-tEX2*n!vq=RU~(@!@&@z~!(brX11yrua6mqrYAA zk{cMG)l{^Leuvc$M1us#LpXkb295FrqzQyDV$=};<%LJa`}?+N+Pdpxd@JgMy9!X* zq07!YgZ$pO!7V z8nV|cN63N<>D?j~-x}(&076~8kfuX*k!p7{2qgiv!u3no-X$fbltd(8)hcdd^Qz`x zDFQeuw$nSl4<(#K10kl5S&TBDUDygfX&K(dnb&T({+PV_?|JzUCec;F{v0i)hz<1H zuEx;+XAukZ+l7j?rtXlFUyDbCPG8nZ`WrB}dVi=l%lyS1;p zNKVaj8=(Q@bseDhT{Wh7Pea-{R7l2Fwt=k7%fUDnJf|ap;)P3~A%z@$z+LjXWa}9O zK5v8|E4P6`q)O!*o|;&gNx!Dc#Hp}xJWJS5fyytxFWA)Yzcq>j3CnuO<@=)Yz@+c8 zcj)PG#KAkh{PWL0kp17$FyO}~o}*E|fioWR)Pe}NHo)75B-eldzR@g4pdC$?Gz;gcbtf3+F+yQZS^e=D>pTp6+;E?e4SJnXc7erX5R56JH4MB&k0A(`qO z+^3I|c(vs~f3x6zjZK)Yjm`fXYj>gmea{u*Cj-J0PtI?oA?EYOm&Mz&Kl#S;9j~*> zg9@eZ;!T!+6*e6Jmn#$vhxUel_TG0c|9uEq2s-}%EyAYL6y%coFm2i9uF4@p$b&2< z7Vw*uqo;3t%N95%g%a|=s42A6wNBxd>|rzClu;|Ufww3w_S)NW1_sd(3$Agha3!lo zz(xf`jleZg#C~Eq0uE<12z?(!T66%d{~L7lACq`B*^sE-3b!|*VXdV$m^v}t$Z=5V zzOq&Gn>0fT)k^Bc8ntjaffw?}<^OxN#_p#9GzOGKogf?cIZe?emR=FVGa$5khNiOI zd>s+^eh(bT4Ideq&V?iDLcNRSEs zzNMrI{l^b15&rg4&-UD|i^GWk5s82m8JwV#?FP7^~c$L|d zLW|Kk4e%`~?nw6&fAfZrq{wFMr|q9mSLqEdDiJ|lVu(uz=*v2BYmX3+kOISNP|wfL zW9LJ|vVVnw!#ieU*&y6P9f)&(fOOp)uY)oWW&*ivAz64*Q|;~TAj|YE27|^cFq$kU z9El^t13-%nKrmf(WY7j;Nz#gg-qgOlIG|?$hyHhe*Z!y3MxI&n&?V}SJup7A8uImo|_5v0@Jl(QR6SzX~5LEYc60eGIwD)84@#fGM zTJ`$j5vN}hrU82TlyEc7SoGSX`&5Op7@DF;ThlLMCOF+rPASs)?Yl5~TqUA8N$j=; zf0T`a4tIk=9YJ}Itp|9} zu&&B{lfRN8m0>@Y{KQtL(%dDIcnfWt0~~*)yi1IK5kmoz#38xJ=eEQ9>NYFKOhf!q z36$^nz3zj+F>BTxlVwshVP!V)s}i%Eouca(%mBV!gVQILyzJRq197;(38G{sTXNuu zVuq|Ksa0UQ9!RFt8PqlxZ}6V&mnzI`rGal;+&fePJ|7%=%+(8XXMG)PiNEpz6wl%Y z#lMUYY#=RHHcg7fjQm>^k1=~^Fu}kGcQlm#D_qcWo)LT0+0?B$EYjM+k}h7K;@0O83X zYMB^8-|FgjgaWR+N8I%C=_F3YiET?Y$Z*mrlsaadb!I+= zFF{wn%B04|^KrqVeh?88qovZD{2Qrgf&bptx8P>*opCE&ZJGBnvYDIjmARE6Q^XYK zMftgzN1X`WoB^58N8F9M3Qw#S<#JNED$m-Z1(q-L2N(A%H$(H!7C?v}utAT=C>y|T zD0TELr$_##Fqn*9y9F5PXp?+-;pl%))=?L5xfj0pMMZBjcT__^-@R2&BXe%$2I*`c zt1N+`iP>B@gf`rGDyR)>eyExXOkNLs%z`q*3yh*`S& zY^v|jh{$rUOTS#qJ%qt;KHnc|ljpYqOxh6ZJg*3y9x9iM8OZ;zIFH&5UDBvm{Z+k+ zF?eG*l8k_2raHc=g^taPBGbq0*C8&yvYsyw8ga*<3@n;tx*k%;2pIxiyS1Vks>=Gl zNS1LyH3dl^Z>Y60&m0u=RTxGiB>l+yv?mKU*?Na&UXqK^*MfY-f;=4qz3{x>V6Ya0 z++sa#V7sXBXo7%D#DT+My*xSFyVT_Bcac)NK3E@eZQl7}W<~^6ML_a(u5?BlwN4&v zG^N~k@Pm3lCa94*JDf`&nWn``_nNNjmri4&{#-Gfh99-z4+9b}ZSux^?UG-KT+U3{ z;t>JJJ7c7E*AJk^2u1IKKp;y@k|dK8zmD3{Uv4WwdU&xe=C z7;W1$BBPo*m@H-KQ(Y`bdXg<~9qd-87{LubAB5Dp$dFSUd!`YI+0f=scdN7RWW!Jp z_9!m!Z}^jH>g}m~Dc?CVOAj(E+GD6jUQ_%)u`?J7MYT*$E=gedg2|aH?bn*exi-1S z0X&(oZXNcH7l$PuG?Y(vC(Y^b>5^oB{&*(@-2XVtr{|Gh6}ol`2ORNys^i%XMu zC^pozZZx}u7C9GXS%h{n!eoiC^hAMDn?ut|KtGAP;K(Z)&pIMECCERStZ( zPVrTTJ1goz-aTs#zEi-u7!@ns(Klw-R!wa5(OUEQpAqr|xsQB8)jXhXf_J{EO=L5a z`Muoe(xtI4;DC&TPvwzNqg*plgsuNXJ z(e5`XxQ;_>j;BTljm3ZwqySPQ*E_9af^k0fTyf^c+35mp$%?qC^|81j z01#^h)hO>$xw?9a0OLv_19#96e2lrR7Bcwq{Cw)c!GKy)FKFUED)}S2$cf7nVenfD z!6(8rV3V*~HkUp@k2}*?q~3NENjj@GKShTSWy$A#qGYcApqM8TWC6zH;EfdPg7%*} zb}$wHK1**|YWK-wD6;As03NUe8nG{(H2y~s>r3UqIQ_g)fFnAa(boqYA{F%^@gU0p z(QQCAn-pS^+?GSRl2kO$akbXbL*KCYHh&o069dGncmj90y70k!iaUF+&Yh2Y%zKN~QrtrDL~Ds+EW3)csE#PyFY**kW6N`v%UvcSMKmCG-+-f}O?*KZ{=( z)-t9|<$AN)@x)!e?O>{;zZ7~BghdtJy4SlG{FaiDU#HoMr9X~792lxRW~xO09MMMR z;aRUpV9?3ZAMugMU}x)&z|HMy%^%kIR-l-ZxkmK!;o#5Em#09qbfUkdqGX-n1V~i! zX2p3X9j^g6(9KH0gR$b`RaO!lXIecJMxBfnl0jmak zNyPV)b)4zy+7QB(EhatRYqt;+OM>E>XB9Tw=CmXladw8%9BbkEWBI_R=7cO}bB@O_ z;8TAzUw zqdPpck$o9eT%)nPMIm%g&X@YqS*WWRwZgDOig8n0gf$KUMI#YE7}==hW8LW?T~S+TmB@ zR(}NM5^Q^$J1o<>)lXjt_-qk@0Rc18a4faTYc!(zmGmlMHQ#w0C%6~-;wu@c_pX%&XsiRhbnM24I}_jUeE`B&YA-;V`~7xE!@?pMPwY3^uK zJs^@@Y!O$P(eD0plk44-MKeL;)z_7v)<_n2Oafe$GNX5V`Cu1i`f~K>7YO|P;%dAF z%Z4?~P4>yf*Y`eJwoV*J%}+vRLAkkCi7 zXS27yt#&(-oBZQ+X8NUa52H0-%Q`h3H75|FQY;F@gs(}2V6`51t9dYok_~wCqb*qt zH$5Ia)I@_`Z(MpyThf{ev(6V74QwPL&zgUv*s>mJ@!K9Rc~GrsGtS7fu*}&te{?-K z4BsBjxlyM-;PBK5w@6~sO`_KG7~20*=fF1Sa2WRQET0TgL|Z+F7%JL8I)FCPHXq-g z@9{FI-Hk>oxoIA)ud_x?;gJvS{kkt{gS2Do)mJr(;Ep&ss9yHJ$uV-6OB z&W<_XE!sG-NT7;rB%_`O-7ufWz5y_4-6=}*GM|~5pI0v~IxjJhn43sC8!gfQfX{WA zfhk7+<(FFK5mIp&vwq?PF*C{p&c-EMWlCzoABoH%2Qg-nHcsJy@JKIA=FUO%gnaoh zAF?kx>g-cf`~o!0JZd)cPu1DtmHu~lXIV~%hy1hLc|HF!YLAw7{xbAw;4=NL^(t*G zMX|o8b$0g7DZ3G`h~21MyYf7QYLRcTkG|M+co6$N|D5ZDp$V#4#p6d4k)X_3A*d72 z6M_#aGQ1wwGhd0pF}CmZgC+%Z(m&xj3{+@vE*;jV2nJJGH3E;Yo1CuPM<4I&Z=4*y zFlcKaJ9ri;bs_1mRdIn$kJ{9&(xf24oj zW>&u1T9DozGIQs4b$yWIChz^TGl!CPBNafG#BFmeO=3afUq1d*vapRT$!LkSvWxL& zJ#xvZtdU@&(|Y_#J#W0B4_q_!S3^sGwDip+*#Ke*pt}BTwuo6Ge{MYcE#&LV>M*6$ zxtm8Ax*6I58S9pi_b)}^16ef+=2$tMuV;Ww(oW2YYXy1mYbqxh6p!nr{Do{W1UDOs z+_BQl0;#^(yYIwu-`r|kKtZW3>boA)jYrd`9UIK%2-VJ`C}giu4_t84vrjA{{PrQGthWA9PijG%fAwEy<>1f14!$|ir0}O0P?YqyZzf_p~p3}+F zG#%7wz+JpW{WuQq$U1J)Hl`ee-UYwf?$3ew>u{z4Cm$++$4^$Y*P42Ea|W;kui~OS9p2 z!-6lBKhzJ(Kn-MOYm?+7Ct*oTW5(~Y;R2bfz=$#PXalZWB%>FvmUy_hnRFjNa3ja; z0CpAIuZM8s=tk0?IVBj;kly`Bj=mib%6%lqx4v#~2frdBX)^lJi*e8rsOD%ojox@g zI1yqqU!dq{R0g#D+vaxVylxUYp&8srpje%diBH#>`wCiTugfHIMWKm;Q!1lDt?$Mo z3m^|#?EGN#q{wUryUJ?nwLayrAVkFe_~34#$+hg_!!Vz~%>^PCnc!of!FTe{g@7uo z8yL>A6_8&98eju;5l$qf;i+z|j;~fs4|3a#B+FmxZ4+|2#MjM@)zdBEX3R3uyDXB7 zlF%6F-+@PpP;@7PVuQ%4?g2x=L4xm`&cHA|cisaVzPD^rVf7Dk?CC7WPbezM(Z%nr zj~MQ_PL^?+Ga60&mt>OCRna1@FwGT{qR;pMV=^>lKSet01{>3 zt1ZjK=paU$`>zN7t9-bX9Q^ZkGdSURX@9GlVEQ*?+r3Cmd*X_wM{{LWffp*y&iGH% zstod&uA4Tp!8CUI&v%~#klC#kMJqkYiBBq%8%?;_fn!#Zz-ov8_+SArSx?I|nauSu zl7AQ^C%)*L$79>A6#-T^T=N>(wR?3em{!h0jlWO}J~Qq=I-Rxdfp;YY&jMQ87N0?U zO8+Q|9Li4t4j&P5{V8#jD1_73x`X{+q7m*XRDY(4CUK7sy`Gh{BudIU7^$EDZj#eS zEdqsU|NdPlR`z;1MITh19HnI~B14w=EeaC^$U^~AZXaOnLu`1Oi_M<7#W0#siACGN zqN*wqjv;AWy|>Wfk4f~Dq+FVLY$gergc2E1sPfc;1y3dc5#r!p&&ma`t zpl$R2e(C$z4?k;HZr|Xr#Isr|e6em#hMb6Dv@x{I|JnHZR}p<8_l5iy$mPW~2p;Z? z?BkQBt`8UGgVdNjD#&T2&B8@_60wYrXy)Sn$zh5&Bh0`n?h$R?SdFCP#-N5NNpeJh znV@;SiK5PC2eDMUu~2xdrt~7IikP}9Yl}fWSoX`)%PS7+NIq%G^WC{&P96;f&m(pa z?0{JKiE`aI^!c@Mo?lxC*_~%Fg-P2O$9%l$&9F`rqygUc=Y5zXOe(7x2J|5P0;(3%BrcF*&JuLt1sv4#5_cEH|PspQRg2 z94s_*s2xDQVp2ppd_bZT`1zS81}|TPgVW}kQ>A1%bdSTj<8GWPS2hMQZJu2mRI#CZ z$apLW0JX~kU1TD=d6wL2Iw)e1wrSijY8j2zkTqLvktActg=)j&0~Ja%YjHr6 zSaKi{OU#cuUFLskD0Mxn<2R?wm)_8zx8I2aEKVAR8`y!pDFkI( z)?aF{M+=BZ3+<|;(r>TgvT1N50I?&Gg}|tPNs2Gv2rr+}Kq{B2M*S(9I+^2F4iv}9 zaXHejDa-Kkj$6Iuwj59>VDD#(3j@B8LbIJ~%vMK zk0X_Jw-KGD&Z=1?+v{zFCig8LfC~L>{)ERDzH+-Pa5q}@v4ptfgYY}Qz4opTE_as% zQkWkMG%-IIAsYh>IZLd=$!H828TTXs-bf5>2MLTFJP9#i0wRQW*8G`F_Z19AqTdh_9=+igaw)k1 z1ak$}0xg0sHAn4Wnt9*kT(R!a8G`pxKj1~;_?T1jd8LR4aEG&bqX@eT|YR*neV zR}LTH2R3ELi(kKClfpPBV4joD(#LU{C^gjAqU?y!Oy}=f5d8r0DXn~ek+&o%xq`>QOj*ajZR`dAF?h0gEo2?*Bo5LnD?n{M=BGu5pubSN+ zD}`$hd^)v$0fsQs-;#Qh_>e$MQr@h1blk2=e+gM@c)+%VH;Ptx< zQOKtL#b!nCi=h}5j0EUWXUJPT(2SW1_P=ID7X_+7*cic}ff(ZkufA-Z073*;dY135 zuC6$N!F`W1dspOgu?j~N$A*y`XB8mz0tN;D-$=~(NCiN`^)3SFxM>d`?yii4`(+}8 zeNExf$)!`NeJ)EznD{2WSWw&`*hr!w#e?%EFS8U~`b@cpuj>m^vCtr3L%P;q&9FBF znk=6v{;DwCt{GLK;S^Xo&|_Qz)0XEHt4Mft4R z;PGyJi?*}iWW35KBJz)X#AC<#1!_q$v0nQ%H>-ph^i4X~z|_>#etmWINJax%29oRm zoIA3+d!-|JFFM^^!WEyr{rFuX)8KT>7W#RlWoOu1o<9#s^tH@2B**G49!F;xo`DdU zgFtDz-0_o01R)CO*1Eo%Rz6^igl{&|si<_XJ3ZhR{I0EK`O>pAe19{LP-b(x4_7Ie z{_zlB{SCfd6LRvtkqb)bWVh!6Pr8(gG@&AYb<)69eW&Z$Nw5AT#$0Z-f~oZm!f+l z4b8$lA4h{A(d_kLD51EY$aJxE&?TMAsxA*L7M--gQ};I8(^ zntY5n)F784h-kXFMG3Sct)}zKIVb@m#pf)LCU|s&em@k2I9o^Rz%0(;)l`M@Uf6hA ziqTn+Jr;e+A`iaXW@+Hhk3||p#GjbqoCM`u%plB0|1#EjBpR?|n z7g8BE$S%YE^piH@7Y+XE=Z%KXOds=*)@*|Stjw?TEWIPx!BJ_3zxE#|xl+FyuuWn~ z#j2)~TJg8&e9rs=aeqe&4(H;W{jHGXYo^YqHSwoMDnz+B6*?n{7!>2LcH{-JE0jH{iF{}$SmC;*SCFz~Pn~>gA zCcggobDC>9mNGb%J%~eWaux+!EaJw{I}^MJ{3o;{US6BK@JgfO`^sgi*D)ko8v2wG z6N%jRhooSvS~OzV`_*r5lF@op3PnoNeTzZ6@am^D29uTh-Y_3`mF;@}4HSG)i!oj& z7$@ms@o`5}N@AFJKWmTt+2KJ1M!s^1oh#s?Vt$ym5GpG@aL~9r`DC1R^(QJBWxP`Q z3#~>A`^@hZ=PO%OnJ0F?rTy@1LhGPmeHD7Wa74s-@!DuqRA) z@~S}o{Zc>)45=Iomd4`tR{mYQ=?cLRoW7*{YrfK}YhC13^2_C7mrbQ~F&9S#47A1$ z7`{}u)rG@;#Q(4BSmm^4(r)tjIjf`2Y$TW*+ZVPcxmfr|;x&b8crmBj+UH1U@pVeB zwWvPEcEG9Je)zMbrI4jbv+iXW!1)RN#r^fk+f7M-85g#JI~S~0(+gS>>592OQI z=!|4f5F)=^lhkc^>`kma0ZI=;*k1%ig*A@a(g?S!^0Adb`vLLTSwyo6RM zTh11(HSE>-pJCQfjeH+-v?s5hi4;*dH+}pH zHySf?U!CRQ*z++I#35kK$v*GVh?uC#&sM6C4Lbz#N5~*OF$iT~D~Vua(fvxX$QF-N4)Hz3y<`{>1DV7!DCl(WT@(d{}Be`!jeWeLT`4>~gdFZY)>IhyFl= z)O-`U`>JHQN$ctfEIN@eTsYHD`JG@r(h??|TUOuF{dT!D;LMdR8JKh23uM`%|2n<- zF38BdXyDhBd3dt&`%Ey!@4k}C2uovyQ7v5(9P30F&9TP=Tkhy}gU2BlAcd9iJhg#_ zl`jBTFJ_r!d2~BM$UT5mOa^9dYR!5fURKhWA3{qHzWkY+n_GTF+%=Y%V|U!-%vwiXa4seQs#4gOX>lstmBl%mvXyfZ`s%RZhh?4@q zbAEd&D{i)2?em1<97MHh~ddqQZ^lrJ5_>Hr?sQCDrBS~G+`KFrcEB5wIuZ-tF z1L1N0-Ajzc*g+5k-$jnKFXNAAnRWEYb+7WJuK zJ%uCsM=RkLvT%E>_05vTIcrIDs%Mz_gZLD28re>cIK8|BL49!8hb=O_dlx7#{^ymx z0f7B;0(^cqbnE37;leflH{(l$fYvr00?aS_@ut=5t=>Y=KwxA{>L;v$QVfoUDGMG# zLc;9@^Ve%#VSaD?+}0=erm^{*5SN-f@p7FYIt~zu@aGtc?+v{sr`G1_2F?2fh9azT z>t$EnhfPb9#d@qMx-`h>6K~o5c0rWa(NTD0*b)@7aFntcA+tycS?)bk{PSQmyLgK- z9&(9VlG1+jD145$=A3+`eim;MnF}->5^XXFLS{EwDmaKjphaB~=;3 z?oID9mb(6*@f&NQjx>X&^Wjp+RGORqMYK8?SF^Xo*Vb~lq2Lx`-IXLyO!WdB z;1)c5@FBsmBlGzUBBe~&d(54kwJ5R9;w4_t%Izo4p`mj|3od|RoSvnjKVV&K+hkM8 zXRNN0VO6fgVzaI>JPh{P(yKM#dcKV4-c&{k`-p%bA*EAop`-)z3Vk1xOBk4gsV2xu z!3T|xqTT1F0OnC$sNXAw3L+0i&$JqP4|OT7n?a&lyJfQ6Of;4!6&&T~zIO&8b9D`< zVzX3bw9YrX+`Na6Nd8vw^T%q0QkTuYITYX(KRe<^;^=|yVliHiR$tI@v{5$TOzPOb0X9+^fG52HzOdbZ_56nBs|W^N?D9+m9rw8)Xn3}#%B#Jm!0C{+ z>?lm!2xh&O`03(@mVukFW}9x63?|5z9kPL!jjLZhv)n*6J9O;w0f%* zbJXfF4e%dRl{32E_N-1W-VO&Wus5e0B9lM}6`?Kx}m2?$G z{h3gB2$B1bSKpXy$gva#RINpZ%-)SQDQam^LZ!pgaqo5|`oI!wteMX?nYuAiXNv{< z0t48Wg#^Lqlz&6?J^|o|g+Ja2bgl0Ui>7)&R151s&&tCcS|X!eq>)chO6`$Z31x@q z6F`4ov;9DM#AW)69RwhKvbc#H#uYfYgwF&jq>my{$;nadQ!6+(KrwPj`LO?MZE6)3%T4qix z=&J`G@Zy_yg~4E^51DB#ENn5UT9%-XW`hj0xE+H}wF70_YrW(o(u|A%I-XT9DZtf@ zvB;&Hch;L&;ipzFjw~gcrMV*F9ed$px*|5*2Z)x{PaF4XT;6Z)&@368<3PuN6p(iP1S&U+fRV8C9N0i3c>*MN9v*?ax$-?7zC$)?BNDHsx&~VJDVYML z{CaphbCr^y%*c^a1Ca;y(p=jl*rR*l$_xNnUt!6gPPQ+T_qd4NI0mMEdYNhyns3Yh)rSm#S6;${6Je`#Kq zhy@_6(LZ6DO=ch{Ydl1-1FkG`5c(53HK?%pq%i3K6&_U46Cn!AcPs?yv7Cy~Ga}&@ zPUX}C{9d=Yo?Apa&NwRM!p@xsjl*-{z(N&o+V%Vz0Uq@U>0c4Y1V6RfFXlr5k6iUn z%WJJHTamLg{#A#ox`x21b6f28MugATlH!2%1yq|PucTtj+u@XCj(RZEQ zj(bly9nQaJ$n>qGK9{$Md;ls&)MK6yf@Zoo+Z@xA5MqWW4-up%rZDfkxIxk)>{wdb zZ_Z}R0eT=)K-ig|oQYCTU5!Uy96BYg=Wz%f&hka>)ovOKT7tg5&t=&jKEYwJ9vU*%xJ5n8?48JO0?96mHEOLYH~3`G?nr)Iwld06oBs zFQ6snR$A#(z6n=`4CAL3n3w==DV-X_pE&eB2>UD>ySJJ!j7E3iuRjLj!DHY%*t@S% zNuYO3pGETW<8X6WpGY;|Y@SXSb0%7&2PHFGB~D%8aJyv>mr^@=x;dGe7Y7>d@R)I; zcAUtkRlnxB+7}(@zF>usHv3w#Sf}hKsuO;}R$+9b)Yw0^r8HH0UO)IyKc%3+YN__c z&nw!J({c)Ws3F>-T|f$}wywpa<|i76kPg>|5hwMywxL!c&M+dDWaQ=Cf|NKxLTCVi zCmCe))94}eGS85RGDE=cy2Y1peH4P0kQ@rAy+-}r`y1mE`mu|p5*ayhKq!@8NJ1K` zE{pZ83ku=RWmD7rgcbs#Y0A;%E&d2rfChQRLieV0xfud?SNMhJZmh&g)^|X^<#RVo z)cB|`77VLHzGntFy|@Oa62~vrihFnPhoYd`Wt^w z^goJ=I`TT_({pvYSU#6dmy%_Diz}po9zD*-hrsY`?V>wuld8m1#Si~$ z5}$7sUHIomWJ>glB8-}Zc_4lI4OBmLxPTrkRr1e3GG1nf$@2bHljjwxI8g0MvTS|@ zlYNcxUE^zH2!~v81+;I7AJM;YIh|sFNf8(w3^6bR2=k{fFZpZoBz|<4sM%&@7Qw#O zjRDc@LI`m5f^_y}08$@JVE9eF4f1;&X#M1RGK0Hl|F@bu1Y;(OruEe9$kns%$3e5) zg-VpuNW$t?1q9?hH2$DNYDtLhLehJPKLFr4r`2+Eeq|+&i`q6!-%J$rI8-nm73L$K zjT(%l(zaSEr{k~%0nS%4|3CkP`1O1le3+jk{(ihQynA|_bb`k~9*AxG7)V{;*hmKO z7+j3O=us$X)e^t&FAc9XYqqZX zioTH9b0pyPxNjuLNL8gtKH%YbU$AjUQeBXn7lF~w;dtIjQ+X6Qp%UE^DRe4SfrXO6 zLDJD24&WX|S!jN+agL5O8eq=Oie6QEyLy88GWW!|v(w~sw9pjb5dA%K1{^PM81X!) z_er#Z%$sta^aTVH_6CNQKa-xbMliVlyZgjZg}*6^dL6nMg%>fB`bQ{(D5yjyO2_AHN|s7#SYh)d~fENiIU=+SV9IBJ2(0M>*c-St)@}LT!TKE)pC7c zn`h&yd(`YW`Cm#FcF8EaJ~u{*c-C@w3(Efp1f~jBOWE&yj0E4&B+4_~8m2J&-_D*T z*qnEI8|`fjr3u6M95h~ucW|>s(wdSsx&14x%#?H|Hx~&wkZ_8rQoI$f2qJgyV14y9 z7{DS|Qr~)C8}ub}~K%x+H;S2 zjQkcjm`tJ0#tdsYm+G(4yP(u&>IYw4v`71uxiVQ#rP}kQr*A13?4q5l$LM{PopTZe z96Z)NiGUXkb-Z1x`cxo|RG3^gx_87qx2_g>>%CTX5b92}{+C+Q6nI-108(fN%JnZJ z7Y&uBIu=;DwVahS*4{}$+4OIPF>bW`IvjT!%?CXi$M zs$dOl54tS)3Kz*bIt@0daD2%h$c`!}#7RUisaQHC>9Fey*a<4AJ|>I`-va%z`Zk|U zu92R^ViI6Xz}Lxg@%L#)Uu^J7e_RIegBfF+;3bPy=Zis|CUj|)(sAekru`f;&xWdQ zT#@K%QkG&cEtN+i8u(kItu<%*o1A!--`GIn#m+!Qs8>9k_)+OWB)P4S#oVm`Sh8v` zq;{$3`}#e!{V$+iypR<6`1$V5#@RNfEk+*auy`wpzJta!n5<-fnm6*nTqFeZPa`_D zfM%e8V3e)UxkV;4#DqmDT#wORzu#GB`oGwF%djffx7~Lth$4+agMsWj5FZF$QBiao^{4 zp1;%fv*;<)BS}xoYC*ZIXx^STmS73tkun9{Se{f&CusPrY+#HrrdZF3qNZxy zw~)kpcWpoLf00IAjSP`7jyxY4Q`X*6Jv~ye(gVDYU$o{Yjow~O2+I9G*&gP?>D6Cz zeJ;lxE`=@YbvE6s!aBFl@o*P`kwXsRl&3qGk@t`5ykb1pYR{GIT6k}!Gg~IH!wNy7 z5_MYp{QVU_sy>>^Qpjn%v%hk-5I8@>eT<9z3i1eM$E8$_xX~Rk%c@g7c|S#pK!J<3 z$fAF7s)2AMTW=WTmFmE0kS>IFNZ(A(m@n8geHwacL4O+ck?Zr~JeO(z?yH3*$B{so zBJ8R5SshUzo_g5Ba9Ff5h*`c~Udcw^iIhBk`t7ts)dHeyKG)sDJbJfWE4m@CS?`|O z9?2RCk#gk+F+wd?1dQ{-`^uG&LUBc`KPV5WUZ&O{AtrZ!$~j}l=4plXjDaFs0&rna z&GjtPDx_R-Uz_8UC0Nere%la0cI9+QB+ZiScaAVI<9nZ<*jHTo`H~bv$v2JsUK#fRX+3pO$6r!qW0sbqbxb%4+am1oPU;GTbAjsq}z#KsAVck8kTT z7DP+ZuEpwg24Cn>`V|#-{C=+mQAne3SoJk5<&tJfVn}o8>baBpn?cO?#HXdHYoTI%{-K z!+(iuGTh1`4EGTEE?((F!Z~~yGEsXj{MA9+j7vm*ErtRLmDE&r!;@gY{bstT{JX@N zMoo<<2uK~T7CVMWzncsv6Ts{)Ezn_QFm)Hs9sshl?Cq`7|A*RW`7|2%8U-p9|Fq`} zZ<0jZHDE(-(r`96#_?V$wKMTb`f!D8*d*6rS7*90N`Z!ANbmLW-e{Ikt5lI4P_urx zpPpNvs+WL4h$=vH3=VJRBdQvUb|EzgA&IbEnd>gc0Iw$RV#fdClh{Cg=*%`3%hcqP z3%tMcq|nM1j&FiPeCl2$@+Lx)XT{zt-Qpf%|KW&G$$rJj@E{5ky7IdCH0h+CFf58c z&KRp1Xs_w;zC=VfcjARRbp?B}u2;Xo+u&JA2a&<8wyMt={6Rc6lE)T?ptgQT$H(Nm z@6yyC-MquA$n-GjeH2xHzO81Ga-^16bO;{fVd$=0;B`Fc%&mPdQBDXNk66Ra+GWJO zo4?7I>p&7)qvK5t0R=5x_x+7)aC!O&m#6txyXS&;S#>I8$9AXZx!dfSwG%E46`Sc7 zjV2hH=Q`D17omZ`yDW@{o|V)zeAV{GdqI4t(GSk<3nn-}B&=)JK%UmMd35=N7lQkjn!u zjUgUu(s2i;`DVL;^4%M7piB3p@ihkX=DU$F9Jb}gg4(*p&A|W7X2zvFy!}tXI(^VB zV?NiDmDr0ZZ5nT2R#`t>ygDAl##>ro{$)&NZ22Q4^TRi@&BYi#fd;(?ZQtEC`F@DG zNF-i-F?+anR;sV8@v|?oai7$Xw)`4T!T@~CT=5)|{pFRF( zsByMa=ZW#)!}n0>@=NEtA8jJzf|paL0B?kamt^^6u-VO)F+^xciM{F3{h1@HXXHgj*wv2Aw54KLwZItfuLm=6#t5Z@FZiF&fN4fW9Rnqyxe zaU2+DtuE&wucJf5`wuNdt`njCPd@tgO^TT&}&se`yL}!RO5Kp7DjnO zv23^OQ(@cDfq=5zmBjnAqK#oU>A~leKK3ormc(FSK*3GaJ&db^LB7Aj(BQ}B>p<*B z#(J$uWTp49AKzOW0Edsrtm3S)jLLetnGI3&uI z&f15#65w!y9KPVx>_%jCH2T<&lwZcvmbfEyIqO+j)1F8m)g7at_RsFMWgkh{;?cI) z-YdV$NwS2u>lbe36S6BWS+CSqoycDB`GQA$^>bB(Tf;3u{iDXWLX>JJY{$HYjIwy^ z*g|9{qsR)t%MLjoXh~=;C_dTA{~d(QP))o4)RfuJEX|shB;lp1RhlP5Z6U5y5yKQc zbkn|G+K|SWv$g7u$gc1V&`npEmH!#Yvm`Nll96`!f%xV}oq?zo zZpfFAGjVD&l3~ze?MP0XcieW&UQ$0=dc-Ahy**COM414rB*y{aVgnZpfJc6VDO1?wv5`xD4*1!Ay(MsiO^BZRQB-S2&0v<8PmxlZ; zxwqAOj5Wi+GbG-gcojr{2yZR4!=?o`c- z2IG>DPO7mGX}UxXcDWiKf}CLy+mnPidMOV5iLTx#5iT$=LFO=Wi z>9_KDf_X=YoEswY?@-USskk(_wUKcnqGc1VgPX$t5JD0W>)%jT3My(Im?4%7(~iD7 zB0R5ep3^&(xBp!ed4)(!N6Xc1>W?K}+`WIAUj)pozQwGf7IpG_hwY)Z*<86=FgH|@ zy#CE4j5|ru7u3Y+`Jwws55hIXrZ%WPt?G+QG2Qf4JpZZo;#Q9Z2Q6Obp3MVQl=$`9DM$7l zb(+oa?3xvK0<$#VpRLzaK|^q{az=WcV6OQFrX(*4=wAq}j0W$h5dx>j)dUf;L&C!YcT?^|-yi@Ou|sn2AxiG39?qfoIfA zm;jBb5r)LcilM|;QhxP)u>J7pkzoy&}m))Q9zkk@tc~a&nzx^TT5Bt&D*6G9B zU;&_1xM2wLP86-$jR(p}O2Ms}R9&;g;~RWUca?x12Z6HsUUPm7T*@XDDXA(ch7-!i z93l> z1Q+QoXZC9C{R)LOxnalC3I1I@`BVQ8K2i@^Czqy>xiQD4=iF@p$nKfW_Uw}K_Ckru zR^X9&nN|$BcSchX1)0;M((mmYyqtD@l1snyNmwcRyM3QDT~aTz$(xwLgZpiPCn?h{KWPWm{U zL?qzn_p40#)fIJ{Yg#6tuAtpuVE9n*79`1PwoCR;SPEN0Oo~JcgK;ynGX0Kc4vxG= z6|*QF?b!cmuO!h7Eh01fNjh}>lWiD_slFctr#N@ebUv*FeN+7W?_a6e4?w3h7f0+5 z1&pDX(+mCCW??f#SG~X7uQ=3-Ycc*Q_7b=2)ccErgs5Dfn#S7is@|Ge4kKLjc!J(( zc6R%*i^EM5hDagrcC&8pLSB<^PRG}&&NB~6hsfNN0A$^>`CC%Qw;#dw1i~uh0oA)N zGum5{`nrlkN=3At2|ArbANt%jY9(Denf8a=GnVt=OSc(e#!!rik7V6CK9g9pTKkL= z;nc@IrZ=*sG8p9}I4azflk3A*?cMIvcyEm{oemKWj6M4wry1Q9a2M5&t6q8aeJIY= zhX+T3Z5)q*A{`MwrTAfPa`ib4xd)a`TwMJUBN><8!ldD?a``BJ*B@AcuUy&Lzel6U z_vE~nq(5H8&QHb6d7sh4=Xf&t2|W|SdjsS!MV!u^#cYz`hc2({f6HjxGF}|2t7q|^ zWq0uq#LCM?F@f~V#Bl&Q^)Y?digB0qnjfNFIPMEKkL4vqjJbI3TWvW0c3)!xK`f*8 zFey8D%M=Vo)H?O`+&bffr=@;n1N*f?S;Ve zbYzkbotr)l&x*r0@J+w2YP0>VI7ZV>=<^_2&->@Eb6Kcbac!x3>nRkemG&_69x{PY zOpduD^6A$rG1?}T&F1dz7K;+oognuB^xeb;ez2Syj5TxA=g`hRtuWZo=4Rzv`Ez}3 zAMdhhDC*v?7#_r)h~LNQ-7}WPer!RV7E<|bga6F^UJ-jbe@R+T1$r3lWVs_F6|9n# z^8DX{?JD2BBz$ln313b;i8Kq)6csjuO^=M*`hvemrktK<+kp-3X@Yfg^NLB3y~O{wc{9J=4|S>RQ# zFkAt}9EwKc{@n}qUq3bRzAxMzCz|4F`W@Wvj&<==OpKT*sC_nIQgwqWyrR^0RZ*AKC%g0YGr6%+}A#y*b5`nosWf~iF^G^Rk z>kosMor{_&Tk~7Gzo64O{X%Z?&Mz$8LLVSlh)*{LzY4n@DYFO)FpF-vD#p1KkP?&Z z!cpD~*o>fk$}a{zhU5tF!6g%gdJA(iT1oAe>sf!CuIXT04wF~=s5CZeye3~@-uW!m zIb6SyD<(6csr97R zUX?RPJ=tnR`LpcZb`KdJcmuwDd?(hK^mlP|<#0&pefO|C$#!83q)Zp0Ms$AsgvD^d zb9!8j@7p2H&38A`HzxMT4LQJ1cb?#q1X0k8``jJX4=QU1Q3GTzCyG89qHX(>^1w zG|YGmUD(uRxp0EDS``U@e(G5w6VWSGR%Iu7^Rk*%f;iG+NbDt%u#t(bU-aEa#)OKY zw*{fE)4Q~7h@wBZ3fx2G9IiW(TXXzM?Ee@J*{F|w(wb}XDpXoN*%=WA%bFLYBjO$U z?VURFO&3qs*47QmnZn4Gx+>m)$p`9LB7DAuQt>)Vae1NClE;;8sW|nespxKYyNEe% zC}EHZ<{8QQ@ZE3g3oR1*Z-kYnxgNOHPeAPc;k2z`JQLEI0)z8;VCbq@?)|ZBxnMN5 zLdC;zqdTyrBND_#%>CoHvE2JHlmahtucWqjR>h!lA^)U^;p_c;DkV$!8|>p_OLf>@ z7?Q8Q$&*jA@89=T7J8@1rHhL`23r2Yy1ZD^8oOZllpJ2ys<=4tb>Hs+&}y^jR1ZRp zLM&gE6wqXO9`{5SugDSfoo^Ajh})0j7MZR%ggQ8W5hHN<5#?;+SP&=qIIC*X4WYtg zuBn)<23R9LUvDlXC;e3MX_@>Zt>S4bT9g{0UaoyZ_!!BS?culNg3Qe>gtwyj!1#_! zDyodpBO+8>-JyELb1OEiJ*-ur*m+d~^(zQ}%9}tumeI0pbhuPq$}ds?15VUV+#>Lj zXMi;!v>KU@43SDt}Sf(*2}%_cuki>2iKrzz-tsk&dlZjQix_90!f%N){nvI z#j%l-PX0RFmRU-$-(rAx$yFgfPqFMe&}HfSEcPe{xs0n?o2n_!E1g}#F3(yDiQe#| z5ODi8O0e1~P!PS*J@B6yz3oV{k8|c=rfx{Zf9PIWT}Gqualf>zvM%#^AJE+oo-7Hi z_<0S-c4U9WxP1+`ta9XNRS1^dLF<}^lp?Q>a1R6Gd|J5Cb>Ub0z1Aj`j$CqH|wPX0Zv{#Evs^kGF*+jwzM28;8bo!gIx{yr02 z=I+c5iNYAO}l{dni=2D?(fD~$v$An*rn~* z0sDi){G@9&Zy7CNg1)Hud+dkIszjnno}{Iv?^bGrpfxstXVO}d?vHf`9+jQ_WqRN1 zRVVI75{hVCT@rJmPG$SuVYse8oG8&t+!!*tO30Ux!>PAm0oVt;&v!n_cnbCE8C=2c zxB6XQ>aV#DN*lP*$P%c^EN7`>Ybss`pTDbjRUgD}e@E{6+S`(tt|xQfaG*cp1K37u z*_;Yg7U#d!9)h2Pt!Ibkdb{V*vXI>$O7E@36jwA%!(u95U+@jfk=PB$3Ld5PKfybQ z({J!gKfZB@aQzd|?bcTWh}FAOdnh`0b5A=sGY9whk&ya^p(1(sY-`ws-rL7-H%Zuh zG#Xm`^n&SSE*Bnm=iCH7+^8Ghd{>g>vkmSTA4Dd9(maju;Rd->6PwZCDLo?CAO0-p zS?e%DaCYcTRCU7Wg@`TpbB$MR?ux&glIL7;q1mw!>{jbpOi+jp3kcMpAe-(lm+%p-k2XJcni~_qcG$`?B6Hd~R730$XzRye-N0 z`Y(~gtx=mUCcMW@xdGI%8dEzXhoE-8`9)n+Wg{7Xh}RljY`remi#69-f_M8fyMflq z^0NQOLh{57v|(qSg6r>6R}x#29FV@At^ty4rBbz=9MXMhmBV3rg8v#?{mHIN1|;hS z29#QQLg;W|d19&ooR;|Tb^^e%LZ%x&!Sk=D)hXt`JgsUjIK{K<1*iX|zlZMVcK!UY zwGEnO)o~kzAB~_q8ZGER1le+l5%SNSGqy#8JEzB<(ZJf?XDDj zo%qq-<&vFMcjI4+oCT2TtGi6XLGhJwkPrA0J+o+0*Dl&e(M87uXi+ywh(`w9H6=#k z+zq={czs2+la4_wji&k6$Hy>y)_0bLfe+>nW6v8fM=)>~d+DfuM9uh~garPm`l8e0 z#FmZl%%ok4r@!QSm{beg0R!*PzhX@&Y_9~hwV2Bq2e{=Fd`84zI+M)$daXbVEov*% z$3oUwDJSg87jYh4v{{o@@{cX@FW;%>o0hA)rQi>}f^Z8m)2290SS$i?qyFo{*vc_e4# zzL+;d%-+!U)8X4<6G}-`J4!8sGviryNU=31Mp=hLBED#ZhlB*Q%I&)|>pyAzj1zS} z7p=y6sjtGY>}B;ZXx?oe5&xxmFK&WjVs=C7ylEGDmQAbAK-6J9PhHord5-XT$6Q`F zFD|vb8A-$kWgiNeG?>-qh)42hN86_Bp z7ri&J%sTwke?8|}Mr{MGRFmw32yYnAe35JDdrwxYgJA|D5brP=GaD?y-bJh~CtvDq zM)hHtOYCd>fbZ~-+vPV(y*ir>f~Drh7nu5x0eY#)>Q zR&!sI3Ch9ie~2GOA*0=zXX8u`eXrcYO3GjR#~vzAZh28O2Jw+Khc{liZJ9Xcb2a05 zeBN}2{p4g2oG3^_xwfaIemj&=`8}g$=|Wx7d{B@}Nj?QT;Jw>DU<6~j6O0)EtNyPf zPj>7(S@EYJvhfz{J#L@%DE0I)mb8b|%XO9pi?n=p)$iG5!Za+Q#(VAKuy9qC==tZlld(~z*V5GF1N z_T3#1VgX^R70oH(=^(k10bvLTUYd&EfItP3udo`k>#GoOUMB_;8F6&+a@P_*MRX&% z(m~>@5C7~IzUpZd*h>2P^J7+Sq{%Cu~(jeRK_ zyx9P)mdy9Id!-IqeP`MeD_fjUG|>u@H48`>^WJNE;aX32))XBY=a)R!AI@nxBtsps(-H5#qgmc*YFnQzo4${ zTVn*Zg4c-O%f|ytnk&bj=1*MQ9gM22bz`o~4&6My@JfN6kTmTCcu(;JBCsM+?=@>M*Ix|ImkySu z)ILM)sZB~_^Gw}jg2df^?;)N%Nbfvzc`(AClF4mdGHT=_hUh~CrvuT~9?Jl@D#Sle zZ{Blmuo4(^e`js8o_A%m(mfFTAPY*5yrXf+N&(o1b%prWz4ySeA-((Z2i8y2>sV_H zC%gKC8C%an>n73Q3T{k)>6n3UJc|lC+**w=P!E7Tr)j19^(d5fQ(bYvaa~LJciYtp ztcZ7+22Pa3DDI^Ud5KMN9To2<>Kl*)@`IcV5KcMcaLBS}Tuz6L$uidDn19>4#Y+*_ zRVOyABVLh^L-G`RGm&`6 zCG5I*2V)qX5dYENkHRC5cUxQgR%eGwi#Emj1b`dw)xfu_;yLav1e>9HAH8jF&#oW% zYcfsFZW0yzbCzuN`r=2we&TuSM8xI##Q06;Km&&tZ&(mnO8aZnu@U*f+({3$s8liv3zOoh0T(l1-H zN*Mnsq<*1SO_c3q!6T#IVPzPWLPp$^i|xPT=yhTSBEWm9OvG0E93~AubQd;Eu^y_J z3#$LNe{;O6Mbq*knkAC*m5Ih5KYSs|4qD6GcvqY90E|>UG<2D)>#!#%vJ#bypTi0C zPG6(??;Eti+^yM=|t=gJdCRgryi^t+h6&D9m zC$*o^=%6pI^Tc3wz5NTf5UbPjN10$&gMp~eK5n=rdm*K~x0K@~qK!nooR6;{*rtLt z_Kgsc;9JH!gyx~EQ8Kh8W3bmXSWCke(y|0|pis;$^{$`w{Ws6#Q3PsZqyD_`XJ%H` z_Gn9Y_cO}j0=D4yCypjDSDUt7J58Ka(Fg`j?mX{*m_SJdzw=jw_1GaIY2Ar0g>cxq zW1kWk&!VLFI&|wQi&5k$<)cS`^y)o)xGOI&u?o{>R3quf?dG~N+)D1@cHeNL_M0lq zcSYJT?sOS@&vzQi-cDcSiZ?c<;xfLWu1O?m!pDN0t7H@7!?v!d~6Z&&Tc)1%2NXs!>rt_(_h^JS2lUbp64I|Z@zu@HQYIS0+ zVXx%C;6A2xoY=0j)%4Lm{v1~FsK#>?x~rI7=f}aHzn}B*AF1A=C83Yt{ScyD{{30= z*$=DYI*QKXuVC55zc^gtoP+LX$e*a}p53yM^(pxNx=w-n8Sb>?B7}ZWM=>9WAFK8C zX=pMtNxrOvF^Tu*CzxPZcxLTg%7_K*hBhuT)c1&CF#T{_ zP+=L5jM+)U$-GM=i^g8OWeo&$qgBxPF~Zac4)!&#ZSqdKujVrh+jz1H`MZ(JwCQLy2NFnav;9ocBZZ77GJyGF6HFI zvp2o$kKt3Xg}&M$<^r&)Ofy|65791bNWFAaVi3?Awl*_PJKKONIA!a|(t6{0<;(%-5S$0>4+MUxXOcX4sLLRLsFT}X4ABzaEehY%r6f$o;4_Cz znC!nQXxn#Yh_ehXayj=vWCsaBWJTwpW;D?DuGv%u3Lt~>bn6}UL<%Jr_2drb8u|uxVQFqZPjjS)*_N}*ab`O8BZD@FMWNe>tg zdJJ&Ry-=t0D~1e-C`KiwJ1tx+W6!KrM_GNBT0Q z;*L+uMeOH|greQJ>G8lb|FUAexuY#q3vfDIGNXrUn&)k~?0&eMxL#bvebnr;wKYqe znGRh)M!&Q(BTc;3d#5)^iOKF%4m+;h|5)6zt7atSynVo{-|*4v$s*rGd4sb7(fYVa zZ+iGIR0N&lL%fhIjbe3AGjESk$%T8{6hie8pP50IRZ;7%8K6y99U(8RgqFsIpc1g0 z&bL}iE(jw#S=Gk984NkQJ0t|lj4GsbcCS#4L*Q@yX0Ti7^fp33PZIVh(mqxn-VFURFGeJD$f|$+RSjl z3RZZi9gRR`OOAi7lZzCgqaMYI>yT(w-o)FXId5(!B$a~`d~Cd)HoPlG1v~=lIJa>+{q-FNQxfH z3x9{Smw4(XweX-Tx~}HyV#;Dms(7DmapeKC{&BJ)nUu|;-~KWD|b@* z1_W&ulyZ&2LY&mKhYG7UcqzUa)s8iW31WcX$G}DVvbs3{qaj z*Xt1|jONS_#A%-=oQYAf2mLSdSG{}mLn8Z=UYz6`TWN%+TL%WvUZ2~%f8HP#&4YmQ zb8Qxt!G~}KqH?{PH{8RKB{~!8-6o$ByA&y?csn&~)c%7MXiLE*+y;MT6OGYxh8ua~ z0-d{)N8_|+JSNwkzu+izlHwe9A_Nw_4UKd`ji-oc=+`Ydq2PppEiP`TiE z$r94z9-E$W5yF;QX+;J#J%d6nmYZL_F16TGIn>uIE1Nv+J@@&t_sE#li38Q&gAT!K z%^iMsUKO?aUEx{ju+*tUs>r5gUlZ}=xT57GS@u_jryOs-;)%Z$wW5C_&6D`Ur$PjJ z!L5CagR;Pq>{AkN+S9Bw6dsB~(I5eb&SC@LKEG!n=e8;B^JCUY`seoXe)6x-bTCP| z6px0Mil(;fbm;kV*{Z_|v!hb(mRQ}8yMu%vJSnEH)ksA~?Ji2T_O*=xf+mJ02`3YN zB*{NlTmzuV&IjTPwUlFJ*Tk-L9|(Qvy)|YouOWUo?RcpIas0@I>K{C+$2*)jM36C; z&1-z@F_6z3dCblJgeUm-q5l8D&lf)M{}_CPh>ri97=iChRURe-=dU&m&C+j-ZeDTz z2=9qx?W9(&Jn(3rKL&Y=nroFzOyWg+eNLx+wV@)w72m>nJvEWMTcqmZR*MW0D}c>? z^@j8Cd#BUsec_#}@R6`rtwmds^v?eK6yQBQcQ2Yj$ig!7#h)Q=m$fYgSKw&gB(d4m={8s+p>liz%CE9)*wX;6syj;gL~`b@7omVwZEhb3$_A z!q*pl-=Tgr1jiX*XLGmgmo}~Kf-~18%b4LW+=>R+_xaBv_gU^cin$G5n2kg#A-5h0 zIZTl$YuR^>Em@fAd)wP0eVGXkwn%pG z#g~zX@Iqtlx2(tgeQu5Z*sS55CDCwRn`Gs03*{Y8NJI&C2xYLaNU}$v3Tbt_BsDyIlty?eX;;;Pj@Gtx`Vt8nTuDRW z(}&}e8u>)MhzO{~Y7=Z9D|Tz8k_35667|qzW%^?CIQoN8|=l z_m1^1Ck`(No&B3M*579UZdd%{3CfcSozfTf+CFE`-uMO_-U%EG4~kx0&ktZ2W^yQc z^`+~qCztNP5y3!R_1+7&`)P3&#-z)8clPUX|MNd=AlW`?y}OI=2hYl|*fXCdNK&&j7r^QQY+)#t*S4I3}Ou)j-#$ zbp8zI?K{_lY5(HYa-Q1V(oIFm$|aA)@VJHhT!FGht>=7D_Dc@Pd8mqruj`j*$lyp(`CRuDTn$8OBet%zgm=ysA5;mV$E=W-w624flz%uv5N9j# z?$Sh13oYB<3vSpjEgRiC0w1Ya^o#GvG|cM~e6ZsDG!^yv;j6*SGD`4O$?>ANj2m-6-`-&l<93R}*T9*rTslK_ym#YQ`!ezm zT|!kW4A?fW=kzqVhp~Qu`#-sEsJJqeiukt&u8tf$vY&%F3JjoBU1R7qjiy_}ZWcsu zX=;>MasM6}*|uq6+?Fa6JUcTh?;`KcO$7k(M`q_ZUTno?>J*a!I`0`SWwK)I<^0ru z-Ud#C0rwonGeXp{8id2(bgk~=yA3lF(xJA$ytZVx`AS#b?54GVsk&rp5i5sXpNm%M zOFVE_j00<;BSnT@`Xg!Q9yOp3fx}8#b}|C47qJ^p16U9YL?w{f9Eg6!Rr>h^Qcr>7 z?L8swdE)v&p9HVXM1}IG_mqNp-(*8*ab)HH1alsy*=QRWwSq4%_jMgPrLZGbyOy?| zfr00eI#&V^i{--^pX7RP;m!4uS&#mx8-iINgbcu#atV+fa$sg}8=X4&!y1;Q5eOcY z@lJXY2xU_PGOrqKs2H!2yy2vT^^iiI0fs2ujDA(XV-x@zVrC}YoV{?NC1BGnSa(ia zn+*JhIhO;gyszs3*d?RSvKlgaYe)MyKU`AhX*Uy9H5%)b(WNar)K;U`;0ADDLQ1B!%@IJsKmahC_ zV6!)%Fa;}Q<#E{HuDw1TowVn-7(E9lEWdUD(7XT-ad|9EHv_K7T`*_TJhVDqTPQ4R z+R-sM{bC^CbaWM}H{JoD=AxrB==lxWvxoJw{8l3p=;}m-M~bwnhBsAc*e`t$c8F~o zb%vIua;no+mU_I^oN9(gFI%&Oc03?3Q*Q`3q^t%J;~{}(k(kjHy^uu9H6D}5H$-rq zU!V~goYcthtt8J~vz!V0sBo_1k*8eB>;}}-I|;kbUk0saan^NrMNB$FxqN$*;qKj0 z0~lZA`ci9qzWRGiW56*zSA2_%ANuQLmKQ!lk(q0&PVQq23?$M8Ito%>g>>VP@`SJ? z6|qv7?{om?nfJZE0suLJZ%Eu7kjd}G{S4SQgQn)~@2_TV1MVlMIuRQcV?sQca{bd0tq6quKv2N(fCW4mn&H+1d9iS?A(utVv-7p`4e zn=IP*$kivnOdLpBQ}x>!PYy8b%pdnPz`#MG%{$B|qlqIOavdgB(}1Oca8=^qBJqom zD%FEmrY}IBnA+G|pN!}o3bvqcy2OR~;@&m(%)ssxg>>#*q__hzE)ze~^U1fcKY~n2 z;Yk9hP#Hjs@II2u%LImLy42Xt(s4)=cY&@0f|m-sDZp(s`O1~0c8BGHhb;a=R_)l& zGKWn9%>?X6qIwOQ=WSIUeAI4XO=u$g7T?3|1O+$GKVzo+%yuW(!=_Z%)IzGCAAIn5p?6=f^DQViU z^yz%SuJ!#zeM3mBLH%^eyc1%V%aow^1Qr+>h8*;G?3B1q+pOcSwx-x?N5}nPtEN`$%S|j?LAddNBGS}a{UtJc<6;1qQaD7zaK9qD*-v-lt^MPR zfrR|Z7E_!Gxpjm*Tw8(PEO%)R2JN@zI{hT*(c6_vi`BJqmlQC(Pm?1bj14uM*i@W$ z>_y&-xDf}vHHY6- zrQMLJ+d+@^MxkB{zCnk)H-FQMWRp&dZ;p$nxvu?{rN4&{AHC<3`si?ayJkWW?Rtb7 zC8%is;tmoeCzgMbyu+cIKLV_C9W64~ZF&2Xrcve6@2Y~LP=3iA?Xy#v_QTmr-n<_& zU=fxD5+|9bg14S5T}hKuN|O8*K$Z;pI_vH{_0PF^k92HS^lf{&-rl3Z5E=hMkEGE+ zYdBuWY52ctc1K{N(hR0GUx$u&r#|>2aba^Q_Em(Kfk$0@uwGbE{P)*`*o9x8^fb_j zzMh0JMtwgFY~Daw(|aR|C*1a;ubs$!a>(-1{VWw@I@*hp#TYQ;^1iv`prLl8yci5u zN-YI*TR^SylWE2<7;1Rn!k;XUfcKw_CqXHji_eo z=e(^DM%2v^f7FYmY38gz2CwW#sq)#~6{+`vl@@Pk|8TkHDHqT$B=^vE7HLCUdW8qc z!s@t6*5W$ibX|sX%;`A=hKE*JWmDZN0_5+4-`txgmf^9zhfBtLE=!c8Ax_ZxgN-oY zpfNjKC^#%A5q|SJdJRfR+J4k()W!ZCc zifi1n?xtvK(P{}2SC+c3R9(s5;Nz5!M0kQ#qRa<&XH=fzCS%S={C7QmxUvAKkViI4&Tmr%w*2OR=#eV z%Ld&m^-8H#K<&f1usPE=xPrR=15i^7FC&{;3)nuTLkYOa&L@tTT1s_py+6~OCo8GO zvqdI@-%^bzVny5)tW(S$?{`1YLl*RqFQFT!bc3DpJJfh<5_m!z6if+^>%kxxDe_Op8Y6s$F^IZ$xr&U5Q2|v_^5s zVo+5WDxvX#TGg#YJDBr3ko@;WM_a?<0W4rGAt{34JC-0FKVpv+zHj_0bi$U)Abja* z(VZ3}B@Tiv%oJdjkrM{%ndY$$2DFl@WxhB!B?Js7y^1Mm0I%Ty_^7WcjIy{O8SZFK zE@9qIGKJqd(^qb;G0ut)F`r9pGVqD)-8UWm&IVA*)SO}IfJ}9L+f@HQkPaC{xT%Zw&E{g`~ncwGK!<#Eqf4OJS&l<=OGkfA4e?(=egKc?F; z4kl{XkDLB#47(dcl=5mIr z6zbSsWqwLk7+p{YU7Ty{5S1Tv8=btC>o|Ps*Pa0YM)l2bawbN%$!^Nk4F#!s|VZdm>cYCh#7;o{1_EY!#6=RYLh=}&}mFGMAIzrwB^_4tw^ zRKV?74Ug{a;-lI^h@kHYZ^Ib~R)L&XjFAv469nfR%qfuXOw|M_2y{~i1A4=#r_Xx- z`?rn3toeiH&O6jUk?p|lsO!L>j2uM7JE3YJKfVo60eXEN$=~gduO~=6#1s~dWA%I26i?{zQYx+@&U27xn>x}<^sZ+W`=4aRPhYk>=5>~4a_=N?; zHLwq1;C4Zw_QT=fucodDksWqHo{;d;AL$Z&e??d={&4@ zu|t9{hM@-lT4V;~yf@4w^r~AwypWF`n%rS{UH}NDWjV*dVP6ttlLpLPo)^;#JCs1k zfz%x{=gSw?uA&CN=zhH3zii;WgY)tfb5_75n%5c|X@r0fJ>8R!D8#%1wd+X2n2Vy6 z>1|qSw$=046cBZdd}!d~Dwqwr#`Bs+iF$jWp_0HViW4g!CVQmAk7uyBdXt2G9U2gk?KE)0 z7xV54=I@)zL51%+2nWC_C(hKfI=gO*37=j0DX|&lwb9VxX)6Bn)7|jQVCV^iR>j); z-b_ti$Dyg&NU$7Mz^vJu)!kQ$t;V>m*d_PY;Br&EUV_UVDqepx)m*}C5ma8D_aQ*F z0fL%vl^w*n@u3Gz3fKaEmIpAak5T6~SDDg00&+iHbI{kLlC}WBr#mbF0PG$Eet0Oc zT;OSH?GD3xhPQ%`x(J{_kp~(FFb5gL?P0I)VoiMHczRN<&Y|cb23Oy$e0S`F$RcjX zE{4~@w0o@-!fHst`_bqc8KmmHk+yEA2SiVJvg@i__&}F$dAQQqGA>~Z8|HF=f8jM` zs7-b}1jMW};h+07)|nIxQc2Q>R$<&GQ0pKA0%4(=zfu_+f92v`ZqWd0ktj3^ZDe(8 z+p=Sakqd>8TE5zK0cDOp%#A=y=ybG~sD3C~JFnyl7^jEq3Lg40E&HgMh>-CJ_uD6` z6`;gyO~&jj`p=}^C#W?+S+I!2J!qE_9c|?bS8ekKN?q3GVj_-x?|Kbv(Nn$ z-H(e(i)S`Bzms*iwcdRz7H=7Dng*_SJl7^_4Vs}fu#WH5mNZpDIDk{Omb&^fMMEcC zSH)Z|ts8)340`T;WM{~(aGn(E-lbXsL^=!G30q_ClB)u~Sv-*RcWTCUg&kJY5NU|y z{xC`|FdrlZL1Q~#8H}`kfx6{RS=AT1$2)yV*g>)Y#`uT6xRzz?a(j#98fx##Ba?I+ln)^#!m2e)dG-6pX68{ZT@AM-8`a zn(OBA&2x88KfY1?6S}sw#ihx1?(6tRD(;j~G57WJe0;jUKA=v12Fz?~sK^9bEvDKB z1N9LbnR{a>0@b!xa?n%GuYX7d%X@cFW^?}tx#4m`3Z`dYR{yfQy!=oVU>Z3NjB9a1l_1Ln#jy$YSS3@W&n|D`lVi z1_~i*cvDAy`9*wsPpOW87ICikDEAY?doq4Z`KdFqpD)k67*1NA{N{_wlj$HsgmSp< z-2!N2f^Jk#RxmDAW*r4l+-DNf0FxXg7nPJLoCzA;EN3xBxG={Uvc5jOLh2>1{_v8hUXO1^Yi zteUKgvsA2G?<`I7@;8+2P@$OfQ4LhsH(&pPpR&|nR?bn(sa++kJL zE|u1saz;8>pdHV&bM|fRX_p}}X^a)cKCoMTkKO%e{G`@LX!&URn-m5vVdIjC zKBS$ujhd0@1HL+4?QFe2=^N?gtL^Zb@0>0XWHKSb%kP!}a**vJ_2CT!}bYQ&@f z&9S#%ryKKGcFXema|$0Ys?VAO!bGDo9gs()LT36w0ky?QR4hm+a^k*sqrwYiBMBU{ zl<@hIumhP83Bya23Rw*4{S4d%C5V?&-Z4w+&Zc?1`sbT5lRJR89CmUes-ukNAR7zl|}=@0EA!+PL&SY*ARVi*0vxcuYEpWKPop z?iEJs|7K{oQOwVAx+MH=0Z$`Ayy+e=q5|2mOidO^Z3^OjwKdbD8jZsIQLC$Ig z4f3ApHfYRYUb_EsY1-7_AEU8q`n1Ottpv-7RAr|!duv2Na6cHSya|ZpI>?}_K!){| zY5#c``Bd4A5`Vwz`Vl)3%iVU|VsD^{>B$cctnHPLAgbBzL-W!4eD^ISa$!su6#U%n z%haO+kHgW*;n@(J?)3j6@4dsCY`-p1?1~7AfYMczDj*=eiAWJddT%04>AeRO1(e>a zG-)9;0qG=p5$U~#-g|%uB!r%G$M-ig-<)f{xz7J5{}|yRdCFb(UVH7ei1%QrYa4#$ zmO!v1aLGcoNsBo>^_(yzYSO<>m&7>Ey+a|d0H4~+ZVr=EwXXuj%-KX2ro}17#$KFl zoJxDIgnBYD0jasWz@6fa(e~yE2~leabXm^%`T!r}2L^^L&fhQ2x=a7P)l?$H+L7NS zZen06-Ie%)44A2zHc|p4f4D9GTtzoGc8t4s$r_!&;A5R z(wKn|VVoZr8P0EQ-8eLTL#s{HvI6>EX4ZF9KFI(zy3ZhA8>@GXvZDVAI+4&shmY_s zn#aa@!t0qt8qDlz2wy;ll8Cg|XRy~j0Pc1mkmUwGlcoAdo8 zZlvTZXnvrZWRaZ*4Gj?hjWs*mi10dD;@IEPE0D!lG!WqgoW+;f75fGN{xSnT5)EvwR#OgR=xHa z6EJlo0h^lb&6nrZW8qp)OdZ1M2CooT8xpnppM^yGN#K6RBza7s09fwo>wWxR%&dw1 zXMgpR0Q6Ju(Vo}lHy?Z3q#+HZpZe6CJoFC77r>ZXX#c92{KUR`Ul~->!qML(Amo4s z`5Z;geeUzny~Ia@0kPWJNDo1v))ERjmEHhr2|XdlR0Pk*N;=0&%!N`L=QG#j;wSmGEg?v;{HxSYD<3`XFfc{_~r`*_T>CQH$6J`LtM*5x|n~ zJ$kmp@!_(aC$1gqbl{P_5n?C>tFXc6qm>fzCYw)W3cibH^48Mg&Kt#6hk6>hb4esI z(H~-laA|a(1)>q8kKa86`YA$}BR-pbNgcMN1P)>>GACZ3JQdvr^x|%emJ_#>TQPv+ z@a2nC+=M(}EXPPY@-R9)67FD)O_h%|W--b{_8;Hf-?uuoGPv-vRx)7TnvvGuGC6zG zu!GE+a@c05F`K4{$29*U=#dNt#yKIt7wrtuH!`AfJs>TVwwbNg@@nKIZQvZv2gE!X zD_EO%J9SD!25#psZZ&wG+mC>!r(cc$mO#rGv9t232%G(>&IvZ>{pKr~arNB3RrheBHY4PhtH~w8npAtv@ zIfJORu?|hBS}9d$61UkXn?h9PFF>Vydh?-N`XhFtJImrvtVXmqAv?zk(=itC3a!aT+q>TZOy&hpv_RHN z0-W{zz~%*oNYK#f>(5`8YkFg!Sq%77Y1sT>&wYvPdh7^XV1&qZX0vWVMbw0)g6-lXb3jidmrP)jDbw~sj;>J2a1MF3L z)iZ&t-KFl!NdlM=SW|LfAK z3weBwr(qZf)b58xi$VH1Fl)gpknLMdc=Z*qWb`twTTbRxC z3ej`$a@F&$A?s48P8o0`vJVLjh$pY|xOvLn5Gigx@4@!7Xyb#@rRHr%VL}7=2H?9v zgs1(vK>Yp*h~E=yYQT2*hsCm3dQYiWOkno(Q{vtJZc0v!^00<6Sus=sp3dH|=0gyH ze3-xB^D9yIuSxHhK;UQ&9tclvGJXec7~qA`0alGOf3IR~^3YTU(5U`>{dx+PB=&A1 zl)Xz+mQ~~Ti1Yi~64tvCHs`@|8l>b~y*-s$z=3j?pXb+BFH)yJ0r;co zNU)hXD{Wupde8E@z@nI~&`AWa$MkECAu@w+oz4(*-(R@^+&i#cdIpRyYcOKXAlw5E zF5y7}Y?npSPx}_Cj_JD%8!16K{>AstI}u&i|7V~2XEl4!EYGFZVDJc#Xw)+^Mcgj~ zd%$d+5mLpJ2ed!|FACTf5%pMssaf-Z)U_w>TLM%-p-{@8 z|7kiB08crpcyg<*w-b}J%(|SWz~Bv-Kg4kPCOV2){e2>{!~y~ptG>QI(IpG=4Jb_C za3Z4^JTN2ek(HhKDbD(7rlGBOmkrLISclAS{A_*)-?tz%R^Ix)Z z%q3}pbkXfwrzG!(atDt=OC@gRut^pGl~gTf(2udyHaUbJQ%biId)O6E##f|PS&p>#-rFxBN5{?^$#v=s9fRPMD7;vUfdS8m z#YoA^@5C*n&u(a6-zW!WJ1;7ILzBu1Ck>I_$GW&>!v3Z#z!X7KdbM*(y0M!y8i=P2 zS`(s-!n=r`aX`*2*%AV}aIRYSnFfZ0TtS2Y)7ZH`1948C0ei;Ps>Ed;%SGgeD_)RS z{mJ~c^(U%cg0X?(AHZRH0<3(1Q8D=)9?@)Hq#2-+2%*bBLGm6@_Xoy3WtT-*U!_F= zSzd@{rjJ*n0_c4h$kx_;#UxGzI%V4uw;BN1>O4f!qdWPocyXrG_Dp#dxeC2_BeKQ* z(yAV-c&`F6gtXF=Ug}6-Tx1nr*ubT=qHf4=Tzh(HX=%N({o4THG4qA@As4@Z>Ty9I zu|yD8&?pGd{|7Z-E0Pw2>-t(ek zlSIPYgQIC@DBZ0f{nUiTtq7paX@0aB1Oh3c!2%Uv*|k4cSVS-MD!u3h#l|5ZrYn1-vwJ$)BZNQ zzkOkA9;~%%@$x)FDD^k1&wHK$l;QQD zfC7A|=;!zlemFYNb@MERQZBrCICriFn2*_3JN$en)n|&__m+g_PN(qa%I@aA{ByFb z3`CA@Z8hN9{MxQ5#&HHYOM)EbCeRWDo0qKZi@%%W01=H)PsUpJlzO(W4eF!ioN~2z)0h@i()?pZCl;-c$Hl_YMwOhWJMZL_q!UBjX z0Dnu=f6_WK(vy72Tq&`xzAnYFP4~VHsN>!U2c-i}y>m>O8p7|F4ho?lDF7f==-NQe z{K9gP7+LQVI7AJMGC%7hJwyF@*(Z2wJ=2QsCj!7~>l@PTl52jX09v~F41%KLmk_pK zf^c60`WtZug+X_W-&s2h33hMjq0yw)Tk2h`=1h6Ql3R@C=z* zBe;#K>4@%oUvn;}+_-a_zblpOP9xfH)~=51QM^>b_~`S%ylf8u{@$H@{WlOZ{UIU* zWWhnyw6H+UCgFM-8?)%P72JE4zy~p?bk8G?=EzKa#u3px4CIi4#PQ#HY)I0@T&4bz z!g~=+Dj~17W_LtJ4WLDMsM9}a}q@wY0 zO`T%Snt_^Xjujc?ItKy*Y#?%#(VCSV&-Ryhu^fOha-i~`X~p}nOU zG$a&XsL%+Xtu8w|Vo6u*q!gq=VDCw(9(NMxnShxVr81%aIBQ}hLCQYxV5Wy|SZFe% zdt1nJz$IYvUh|}Z`$o_WLyx(o!b+d1t$JJwFhT48@!Gx8JsqfjgS1_}M69+;w z(YF$z2+4SVTmH=;w(hQ&&x2nISEEk6UA`NJ=osAY&`R>)E(gLyz|&phf+&AuvEKao zR#cJB<14Gj6y{Ff@8(aEpSV}|8nDTpZvjM( zpZh~iENwU|n>QF95U$5dy-s|}#~R5L4r-k_%4JPo0EPuLVSc2p>)N zC30!rZEzKRUDK`l?}!N@Fz_J006l8TjbUiu2z|9*QU=^xfCUV39zhv;Z1|#`z>(U2dCMo1N=t#<*rXp@@H$d`p zE_DA;T5NijpNKYMxxDtaUFXthIcN6~)`(X1*b;DM&sw71e*%3- z3r$19$v=-6J`n*IX|w)!3ozFsLvG~w?q5>}DoSwdz4h5A*9RX)MpmMXX(fmke(jU| zszdFDBkqH|{__T6UoMt@UZRBR;lOu2bFCtVHR08tm!s-c!~+{?7aj!UA1EfUi~S0^ z-j+e^jF15?>&|;kTnzAoyKTJEAJNJbH|Qc9(l3BJD?tH1jOs1;);GZQu&n+|-0RXY ze{9Cg+etb3)Q28l=*H>P9~j>E^ST@Z9`ExB&~@pBo(~dc%BbBwq3kp9S#vp@?n&Y! z_ZjYJC-xrP3IdoU`&d&>c2C9PavFt*%g#0O4Bx;?;VCEV_rAwG#Irwr`GZ|yg4nCU zLuwIvy#ab_e2e&VWAKDc<<2hPq8h*A_K!-~G9>UH{SA zzdvc!7`;{b*L3+yJ0%ei`{nh&ejxq#?{|t>tp1XJZp8!?pFo1x?}B-;vwy14i2F0x z_5$mA-I1DhXOXSHZNB)yV#l6m_VmLCq%Ufn=5Ic#p#J_MG%JdDh5i?bt#BZ(AC>iF z5W(LK;gM)!L3tivzn)|HZWM^slxA{nh;lV1FMZ#|-F8dx~jg~j~#`g(5H1c!sDl!n{twFV_vD3uqhLPC$ zGj3RXhL4<~Y^|2*V7R7Mpr!QUYTqkjA)8bmtVEzMX;IhYH|P%()Qi9GO-5`t^d4*r zg8Wq6d zH|;cFfBS-1DD1}KLE4Nj#pizU&*Xy_wVuhf@QhveprUHX3VgsBy2eKH_`{{^`{&Px zKH$vy;C2;B(lr%RW^xpa`PeL-h@CRzTdi6Br6qUb}I>SgpqNaM2VgB=(US=+@Hj5qjzb}r1 zP<&=C`&I=*N(s5x$Z@taZ&1*%cBJ5$%f#BBh&C4*>cs;e;w=MxuSTEMD2=KeZyMUrbHKW00{}n>q||yB8zHs zUzv)D@93N($>MS1Aan~!H3N^N*O%UdBZ#lf2O{VHzWHE$QQ_|!k{@AnSO0zIaf{)c z2#XH|Q+84eYU)Ga z+xH!j`+*21COQq4V{if=f`jI+zI)cYF>UNnzYj~Dxk19l@#G&Z;!ZUoYO)(=mMU7{JRD0Fo-H$~H73kdD+!u5T^6g2?eTmJ5?{`Oi znL9_f#2+p@!7QvS!$KMwo;psv;6XS+joi+>wdv%AcVe9R-yYPh+=Ml>Ntc$Hifl5Z zVAs4L5McDYyB>&n=z(&0sjQqd)#$f_+eswfGU#&GHRUlj7fXe_$#47v>v7e`pRZs_ z#Sbl*E>AKCBr-PaE2^8tJPi$x-U}g5v@NUG`j#F=Ftspx{@qAyLk9vke5K&MH}ZbP z9+niRzGWdX8&hVxCTiU^i3wnD@W9*-2#~JZ{JyswJfQm}$6W5X4jWG9jf?QKJTxu( zEyG~(Y}SN-J7aQlTh+`grcn%SJYFxULrEEj6LofXj)Atu@B)4oztzJNxvuqcql z^)gN>Yro16lJ!41yaPF{O3H5!pcg{#rDSB>FEJ2GI^M2dhT?zt@b(@axE|UrBV&`l zJuWRR9fZjVKqNxa#n+No(TE0zIMj@fDyFQUAmm%X_Px5jYWm+jBe+_OEPAm37Kk{+ zo-QKtsRIZ(v*sIm`PLRnGBVj04~~^ywUN6Fx$CY&s)UQR-~o|-OlbVRy>MhjVXn9n z`|A}yY~4wv2V9CZgK2xPRE3MyeOXboNY~S~fyET{XYVo^KOdRy`@5~pFtcV7Tqrp? zxwe=mafoJKjU;(zZ&(wbKcHUWIeSjO(&3YZg+*v)W*K4&PaS~nSL8!{DdNqaJ^eYh zvSzuu)E?Yph>J~*|Bf8fDNr#qw_JA&7%Ns*?&~-0CKy!7n`E99W7j6?2BYwsVFJs? zLCSWcTjMpRRy`R-QBC3RRl_vB`!%LV9VZ@yB|eUe9n?5=eXCK?csL``(cT$*jH&oc zFDEDGGAkQraU5y9a!SLapD)YfOGQ3Ehr1gRJFO)h*t>Zt$esj^Y>N|X4Amqr&hF(U^Btou`r%F4{ z)EjT7JyD-Y6;+w2u`fZjw6L|?$W%uToYKR}hp zb5fh1{4izqULR+};r9CIA3Gw%RYP7N%k&DlLgG!62=5)FPb8F`mN2Vap3@$mUML9C z@baoUyST*v{%zsCRf2a<-rKP@?TE-JyLsnMlFcNcy2POUlQ|~(%O5r4`x+V=oRj9B zorIYbmb|qvK4gcf;)bMh?hz1LH>mgfK@7Tq*&Z zw8e`&eOX$+`9PGBoGCQP^$JDw^e}9C8qaa^)Jo9KByUXjPlj2% zG`}nhHEZFiqSEVu)fJ0J4_P~EeOnb5+no;AD$Cq%=I$$KYs2>@!hF*1m_mjm+W68Z z(47L*tUW%S8RVa6{PuFlHUbi{n=>sdh+*WqG7m#OSKkcbkHD#;`KY+7#hclg!H2Mz9?0Tt%B~t_ z&alKYR%L(VQd9tLTJ`V#)(<9aa&V@&B89Z3=?iVW+`ydTO}j(SEgy()h=;u!H0yKl zMP)^`XuqT7nhIOINk?zT2wq@mWqF->_$3}sqhVn2y!&Zh{c z%eNG9@rr{@@fmsSTmlMou(BuyKBFrNtxu|ls2PHSg4>taSl2@H{UJV`+wkM1D5MVB zy!NC|yI#!V(3D!6(`K!|<&IvU1hbrjGuCIkony*>6wUw_={(bw-x zzP_70j0}Z~+2<)g(e+wE!nG1o=*ZuZq4@*jw~fWYG(RZ`$027QG^_Mn zqk{ZrBm*l9%E?@Hcla2uC#zgi=$g&B98VG;?d>qgvaqr7;Iwap%DWWn%S?bec0e-t^tNm7rl!T=3j>|%w#F{g-_i}>EY zhkf@}M0R25Q4IySc=n45&n~z$u8dtCVb{H+0IQJ2SsrdhA-h8(_kziu)(9XhxNMTU z>ONiB;LYNNALVaO!F9`cxr?A4(r4xy(UYBh2h-I-wy58iMo&S)|9(;UDW-jJ?8S=eitZZ@9D#1}Hr<;Cml z@pblbi4;Ogu1C82YlABP#El#<9Y3`^>aKBPrWv{Lu4_~ej!^9@UBIkbI~1fc)3?sLHh6i}D*MZPbT~59 z2LiJzxaRlNJrTO4)nw8YR;PY2*uq2+k3iaa=CDsW+E26%Yy=Eb=v=I|sCxY7Y1hpK z(eX04Ls2G2$G*_h8k^pe+14I5vh?bf?fwyV`H=zEB2>1Ohvoy8W-#gvYcTPz87C5u zR$Zc^Gko>vQFNYnsxw&_)e#QFuU+N#{D{nJb`}z_4@gXDOJ{y8Xc;FKh zkw4Zpf$ALlY1zOeZ^bZsaIx#LySyoO12xdFeT=k zjrRS65VZ0LTW7S5yFa0N?@S``VUhW4fwizCm4<$OcXdaD;G&?m&{Cz8uFY6#s&OCm zL4_!}0#=wf4LCm$BU+82A$O>At`N83+FaW~#T3$!cUryPYl zSLp$jLgc~Gm5%mezdV)r5w~p{c`j-x=!~2~ubY)brrGMYXn|%p{HI8m=$LvK(1l>k~ zN#({hfvp6>x=n19G-2a&ndE&w{fvyG&~MUiKT4JKckng>Pt{bysof9pFu@f*omEBc z5~%*{rg~fZ-N@Wj`yv$d=y-F+c0Itg(dM>bH#h$SBjI6(+59T(#*8+;OCA1MYnI;K zm@#T_kbhcDzlXHO6=ibh*87;T9VcE?KpRYqDIMTu@#Mu?rkQbq0ZlbW-rCW`O&iTA zA}6@6gN}Hzf*G;J+DAzXsNjP&?#FJxE$`ZBb6BgLmNNycT&g>&2y^>_?ZJn2PxfvH zBqGAeD2B>ZE}7dvGsLzx4vNeT^>6853~_fi^k}H>FuQu?E-Wr`r=;k|#=7YBdF3BD z7G@alpMS!lC}{OUYt?q4l6S*jkJIjH?^hqjRdPDD_0!AX36c@#^7rRgS-$+(pWj4b zlT@4kxL!fewP#uF()IUITHV!J{=BoS+mn?~BRG^w`ntG4O_~XpCoS>#MF{&w*lxJ6 z6&lx?^v3pnlT7{BpQLVDeR-?-NUPwh`#v>yBfdqLJ#RHHlJ9dwh^h3_4x92E_A33-*4BGK@@`-#mll>K^WMCTp36i7B)eLt5)J z%PRTBe%h9NQqm5}>&@nqP=PyYUvTuT_gv5YBq1oVU0BGAcE3X|uD~iOHnY9I!SLG0 z;@gz?{7cEoR2!308e9C7|7~hwk}JYNy+}opWrUQHv`e+3VCu(1*Z&!@)8%}uX8O+v znKtKVuf>EyN~$%}^+o6rD_msx<@CkzmKOcKVWE|L+vTQm&#~#V^)}|=Ayg4Wvnjm( zYDO&UuwhrG>{b*k zSpyzqg*^N2-?+HmUtQJh=UkCd_?poWH!-sFlU&Zo$Y=?--nY?tpL(WH?h>tIuh&^} zGZ`~v&V+Fk+NO1*$`mqB`+nf@#P&XCzgn5;rTe1-{j^$Y!mC@fE@$4KaA$>kdB0?+ z`6Cf7xuV*Q-(hA}aDMH2k8LQk@(tPsvterf`MawX{M*Q5p}Nx*TO66&2Hyyu?^Yg1 z{d}d2HcR2$9!!~c5H%tNgWVI1OYuQM-*hmIygM$@Em&6)6&4mYZpyWCPCHqW6`EA3 z#gIdcM(VUzwcx8W8KNbFWimJ9{46JieA?6Nkv>e+i))nw$&?N1Kqa(qaa~x&MvNR=omG-U0@g-@G8a0Tm_?4vNN4| z#bOjSxwLY9qO*TST@Wt*EWLUKdBX*N(59*VZ< zv98;$E_c~KOzQGZ3Xe_0(DAxToi%nw3GSvEE|uV=;wR=Zt}rDXUPlN9_*)EWrAV$W z(oM4wKS^7b_&J-EyvVpDD+Gr%9>c;bDXyN8{6xpcEh0u*_l(PZr2aNw(|}zh6C;p{TK^v5_nsi@=LEoG(s1;wMx7P;WD&aR>(UwKGBbCMe=*29*LIa$ z?4Nqf{7cFab`6u{-Op3*lQ$}ycK8ox#a+9P?WTTe>#`M+=CHVL^y^gH&t^A2B0kqD zX4w&NqLOZe^xghAO^ZR-A@>CJBATA1siq4S!_+J-BO@(VhT1KzlMy*sT&0hDlH@UY zd&nL#el^4A_mun%3SVLXzP1LfQ9lj#H!atYHx|{HJ)3Dfq@=yYI+U5l4i;^n^rORS zU)J{CY2Q4{y?i0aSDrejAI^u)q&ljquv zYc$M`m>d&Z(59G|`%~+!=VoFlA7c^jFTe=(*J3#L?1t@hMV+4zp4(XNsmt#t#t{Ox z`$l!PCpjRWep|Wqk3T%>$a}9^FU@_r!DBExCi{IH`^(p&c4sG;`WyW3{?Y#~#!j6S zzm{j5rn4}=%>z>Kafw9I46?(+!;1H44_X%w={MA8ll@Z%PXmKDEtbO=cs;!QfcI}-;7HN5v*QhY>@5FAGul1X7< z_IdG5Reo^X8S#staN-D~p>tn;BqlQ9ITzh?^wUkdaOn4ms3eQ*`3o0d*f9h`s@`$n zl57ZryP=a>))r~e#cp7fw#?MtKRY2~2cWr>@6BB`~a?WW7fs{Fr=g{=Ig#4=u=I z4FL8KM=x*e+`Xl>xS6{|Wt?syY4Q4{;!mxog6|ph^HzzWi<-dS*%5)jna~a+qp;fp zD)`G20e_;qPnWJ=yGA|?|5D6tc$QRp0%l27hNKE`SN8ig#wj_QOs~+T;u(ot&xFPkKV$BZ$6}o;XOo_rRMMuii+_>k4-+6RCb1bS0Z_)Lq~!nv2gr~q85r0l z>s!~xhaxw1pYF^bTlThPb~UOZqO-C{yjQ+nDKZkJn$9h6#3R>tR{*LTf5%Em-MRd8 zNU&n2$#f*lPb@a|+EzqT!G@XJ?Se*&-u6*KvB`qb3=--+#7fCHq6a`TS1l(jexpjv zi5Q-%3Ew0R?1tUJFsugc{CxIqE0<=J_pxv$B4D_lPuRHVj%`nxT$gg6ly`RotS zbS(FG?+#(PhS~*S)SX~C-)Pf=uOM$r&9cNj7U(6OBJL7c0*9}iJM-TibiFOt*=f;- zxWJE|_9nuwhjTr;zX!_qt>^T|MR&KS4k?EUKRpd46Gq|DLz*)X^Li0+(e#*8FUOez z7j`L`jDQuso!#B|xqqH{znhv~*jRY$uy-7q;4-JyXyn(VR@kL=7kd_x*$GB+8anZ; z`uW1b?d28QvPAiaUzU5Ldft0*ibpI0%G-@NAJ>K|f_*$&K_h!k83TtkZu>qFpuHBQ znplVFj<*_6Z}aOVZnN6~Ua>~5Esqu!w;&Uy#GT)%9^}Z-+W^PQ(>$&|@+lzS0>HL5 ztE8}TwZrUv#)+z!dWJ8B%dgZ0(%rZFUmsyNtx9cYetsQRC^|D{1FM@w|38S8dN7rF z&iws_;5nh8p-at&rk#v6Mr;7B_Bp-649Slg7!!Bom@SR7162=Z$Wh)aKZ_~8%u4fP zAxZ|StWWt|yzsT32q|zzbFaX7Qe&%f7 z90QCVOZ)Mmwh>E9E1mR?eKFd$qc2@{&8}U$7Mm^Gn`r1*D}TkL)fGS+$&|NfIA{Hi z5KJ(kYFDi*)A40~HMOmEx<^Ioctf0nn>)ev@{Q;n{1hvX z4z)5^Ut$Sx0RTP>@1-;;oBy|HFEO!y~`Bw4Q*3U$EfK# zLlfUU$?)d-m)*om@MVOJQRYza`D?IYWTDtxw71zw%Rb+1p+2 z8F#hZy)LlS!jahjDoEbu%h=~~PsKMMkmSESw#Kcc+L~3RxWLMZJ}#QGM{UDn)6>mw zFBO^vu%%jV&$`oN`^#^S*LtTNc=>)sW|p1lJMPX37QJ zKolkQ4LSFDlDLGppKH4HK}=RSnujY6eoO%X{Ih3Q?rj;=#52?IBrG)-?vB`2x?jzG z`ksM9cv{p%=LBwW8C#M}gMdtqiZ!YqVK;bouD#^PFZQW&h-heY-rjB)I;nFl*k4%L zcraudVAwhhNvo}|Po~yT$;s)y@42tFT!md9*WRw*x(tE)qI!u1kNTrEy`~r+6IE4J z$)_N$?5|b-jF1#(Lv0IXFfr{;@uknm~y|P%;&nd z)4>tq-ahnQAM0Er?!|5*McDA2j%y#AEiSf$6#jZ>D#QQ&qf@?8Sa6k=a15TzaF{{u|`;K3`K=DG&I=F&=hgmhiA#XN98AwKd!c$ zG`n3*wrNd&v+$pH8(5S(izSPQ2<_}?tscd!hDzRbQc}CXo918iEs3}0C#lx(6JhEj z=%SmLJ7%IIpSBM6OMT?^sJ2v+$y1}w@T@XFg5=b+B@y_(T=jm$Nb2l_tLsT)#o(F!i`<2`rqKS(xU@3GD(bTkOUi~6J z3|oyLjR4_a5!D|(&8805A=4pkE^|z+z8?f)F_gIFbN3saj}EoAH@8CX#PY)C;~;H6 z1Was0)cBjdNsd^g&D|7($oS-M#k_1&0R@eR+abHXSD4r5-C>+-2yw>&>NlM6sfP;| zDT2PG5_>0HKJ9u@4t%@)wG9tp>K1wU${#-zO<^{?zFJV+Zvo!5H<(j>-bt(sKWA7) zrK|mRTtd3N>v8$r=9@)6+hC}FnD{bx^;vorr;XNfsHBX{N6I@%PQyOhI|D_&x@qp5 z>^~ASVzRkR!_S0xN_l(|xouOHWs@T11fkobd2!)L>+m{<)lztZmv+Ivfej0FYPzu3 z2csu$?dmm`u3So;A8KCQjs-}LW}jC6k&NW-u-4)B5QSS%bn5n5o^|H%e4n%}VJM;^ z)5oK}k+8z0Jr=cVW~l>@DTa$I-Z*q@w(>r%x`T~Yu!Wsh!s%$7rfT7mWKN4sn!g^= z(DtpP%?l-n0oF#vqVQLe4`NxelcONU7n&Yw%{u?J_Ta^I`*`Zc1&f}++t51ESKLoU z6!*8K-^`fk=&;(3+KX)Ot=^$pvthPXPUNmR9Iu^*elCnexzt1Zm)idnR}A;_k32OD z@>Ae}X>_N;m^9Ij$V0K|HxfKegMX&7U_nQ?bq-L#21yiW?@P-UpF`GBF;2hDIXR(6 zW&^xF(418DEk)RoVvh(Oy-O{G`DEEe7?ef7e$j)q#^(P$b1~wKnUPz~EU@OG$G2-9 zb)ABmN0I`Y%1HKSQl?`2aT>0&6AYvNVV?n{Fff;1!Swx%zXzdm38QVhl+%ZZO(;UO z$7z}jM|@YMN+i~)m!$~BBbpOMCJLj&PedGfdG&v~rPxhXs0fEjjex6rA^iSf1AZHo zJwYTau&`7P;u&2R-;MutZ;+7K(I=J=-1>5u@0>n}l9p82XBNq=sXIl4Sf1UPT{Fj_ zU;(&dXB&2t%6xSq6j+DJ{rn;3S1OiJ%N28J%h$}gE{pwds!I#kdvAkS6v7kUawIY9Hm$vjf(e2>!C!)~i>_Z-6D!i67`Af) zvJKH7pseza+!FT)%`7W>`qf5jeUX@T4C{c9Rc*DfGahB?uVZ!MSiK##xMq>EgZ}92 zSd)S;p<3ko!Khc`_J)FYM~GHICi4tbGF z=Ox9H>Q$YczmhSH>4Jho8n_ptuJj`ftL0ZP3Bt2!Y@Sp2FQs-zD?@nI^{Q_?cTAsX z3E55LU-+^4jR8c}A|P;|QkvFPF(b?n^1IS75Oz(IWDc+wQ!>##fcUrz_zJt*&FV0# zQa3{VpAn-rlu7D?Z3hP*dO^ECaPQ0we^r|`!`T_hrI|@b{>+@>Vy!*{j|ZF=wVHO+EhVT_>$RQ3NhJ9;48eTXE4||lQk<|CvPjAMbz>lRjvyFn^OH`0vX>US+*g>HKjMJu9A+zfaGs3?2X@QlEI3U6-Roy92d{wYN za`x?+3~^E4`UtX$gWddPe3aeJGW-2_K*-aqhU5MLcqUZnfu>(OHdZTRnYs( zSdx_<;( zWTf#QWt~Tkrob6NPuH9;4&VPTS<383+1-_fy`_(B8HrId5Mu!C+W6pY((mL;+&??> z*GTI2+mj?lqqMvd+<^PyVcHfImZ5a&CEh%1P+?UIsd_h~Wy<=fAQ`Qsq_p%YN-B<-=JWwa2!*m%j$+;3DE$(M zguCsBqvJqPJ^_^6tptpg|H<}BIXwznd6J?#o6LE$_P!o0htPY^&4}l!ThtH!9dy^wYc!A(C;Ib3O|SSyNZX>K+KaojUvwoJ|tew&w<0)+Z6Tc-u&&tEv{ zIfmXhX$C|O&lw;1H0026(JJM5=T3#7M*-E+9mUFgm8DX{vZRdZ28OCFq%Eoo)Kt38 zE`;Q2cdhJ>eZngNAT9DxjLVg&xNs2yW(f|5mp;+r{U7y&6?f>ohb_u+aFW~LC=KyV z&>TjdI_wScnwqX4%-uImSXx3Bca<^{}ls`jq04N1KBrP0l=Nhsa?E@1G>hr0DmGJb@5N0HCrb@ z#c=f;yp5c+DJlG?buU*=U0hw4*9&>OFkCud;f%^)RM`rtx(&Y)fya#`89&7M{V8XV zpkiZ25`v$GMaJ?X&SZFEdvAUh%^6eB3SmrwRaTb zs;k?@r&C6lWWLii6eX#ahfjb9G+ajve=u!>!;LBI_dL1#1CAVsbCz(${O|xMH`q*rX(0M})Ad|Dk=5 zl&1}{ZCWz;COwG3GVY6<-}v!6*8)O&i^-}qZNHO#7hIi1w+c#WFJHytFq3P!ydb~x z*b(snFs_QR=Bobw!Bo_DI^rm%c}+|*DhU6`baZzMIhxj5q?~E^k67O60ITZb2K;7R z%?v?NVrqIhm5WDoTME!ypL`9!8V8tohr5%^%aO;-iI8*_3k^PVC+! zQ-d;pCFcyiLg@OK2_TWZy{xcNpH@^AN+GdR@)OA9AnQbL_zNPdkRHhc!wxvYH<;cqOcrB(17XJ^5rwA?@5 z`T=zZtUSGZDIApiO*|3TZj#**3s2eaUQF=MC@B|T8vjaxz9~!0D0-6QaZ^bqZiquU zJ;-}*PM$>3OuzfOCSuR8@a;o4?tD6WFj?dqPS$`mIJ6z1BVy1XD5?XLvQe3soqCO- zLEN2VjyO)b1A~Bbx=}+26IqMy z!jS-G0tKENE|2iJwGvP1VRS?&m0XasWMp_5h#}wE#SWu; zH%3lt+eGsrV_Nd>_R#z?G-CyI( zai~*}I$WSP9svO9hog^kSD~`QH)H)*b+j}-hXl2whcX2N&H%HA$aM9$#y$0|yK8!a zJaI5f$S88}qB#ol*3qdxt9WlLlNESNyYPD;6M9v>Id2#e8%v@5!fTDs`iADD9pFIf z+TAU-wx#2TWl*BR+~|@yA;$wP7&(6 zdwZ5cqgy`Ep_o;b8wC?^Yb1GJq~|uha3~chze$Rx(~XV@=I7AfW8@XlX!=a|Ix#7e zSA!L(BHVImX2cWuHKjrt)QSoj!f4cYA4)+7DlAqs)< zl;$4z*k2yl2OgbPvejq#P|}d$Kho5%VCH`Om?D;Sl)4@}ORqFOdGa^g!}Y~%&K@n2 zqVvLV|WX0 z3@?oIVf{-M#xLD~;n;&CxghgC zgoq}ug3@D5f_tMY2EE8FVEV1t7YogP6&Xp+|HF~l_+xG$g|ETWu+ifx|3w-`2KUW= zomAofhqt$mYP;*!MX6BU3h$lci&z#RQ=L&fM+_{M|e(e&wu_}~0 z?=|dukEzB7=H~m;kY%xlYtTWT4s2t*k=0G6EkfCHnl{tCu2U64#)^{~&+&9bvB<;` zDiXpN3*x!&j$B=enV&t?yYxwismZ(y*zi@sM2lGZTnndpkbl?;^RY=0rPn0(D<>)= z?7;$`ORWh+99=#mYSo~#q*@7S0n2AQTf`=dabP|J$sI=B-D9OyIR$O~r+Gb$sTvQee*Ywk%!@{5{LMq$G&4)tO6W!KM*1 zGO~?y=}{10LYyAv&0AX5T>|L1n(94L?}d`saMtR$9i955Kx9NM7oir*1-Ouc)8o9? znm4q998IdD6B1k?qzW)tz>62U6foNSvekJdO{q|{PG$fO zxRy$=OLk0+k44p~)n@m%ACka=5pJczoezX1c<_J)lAL*Ie|_Y&4%GEq&*cK;$=WbC zCVm2_HP?ShYu(G6muk=W_yI&Cze;S-DH&AB$0T#7vK4BOJ{U9dN_fU&fuL!;PmPj$ z{_&cNe;0lW+w=H)>ZfMcbwFj`_hEKwewlr+u2irT~f6RBCq>n2L*uL(f~kfj$zFJ9qKrn%pHM8^i^i!GoT?LRi$}?&Fp(priu?xf zTO}h+>cD>)fdXL_bxvbUc^=x!upecb1X%8|IcLsxYBSw_M0NBs3Fy|7BV%PS|xcgp5SHf$LZegPw7;cOBRvRxn#GngLguDrDMMTEE}$iAAiBO995h zt2{WLZNy7uMJx|^HAq9B%K9g77}}abww$i0wl!{}e4{jEIJEzgEi@5Z33z;RTHw1!qyb zUHPxjGs3Ck)RQ>@&Egk5d^P)V8@+m{kxSx3EG27$hwHFd`nLP;n>81S><4P9{QD_r zqZ~xcNRJcFlcxPE+*f3~Op$9qv$X&6jMDx6+00(YaUCPVA`{?Ib$Oa1b>i{TNOn& z@spK1p*Qn16{V=dBP=2Uu$wG@@E4$n#7RXM#9DY_*3Z(VH?`|^t>%g=i#^rbzYEy{ z2Wdbm10CdKB9Yl8lyHf)X8MP@fX>sSQ*M&!(#X$9{?PuSz~!obDzI zPPH}1(u>;c&O%07~#Z{s`K`W)0?co&jpI?a&GLdb;ZZq!ZUZz+lj{b*Kdl)P(>dm)%?WbX1m?}H4(>azwTjH!k`jqEKD_x^s|bGTpT82~()q+1{9 zsdP42pu30maW3CjHFQl_y+AmuG8$WWVJ91Wv8kOp54x2b{OhlFKtZsWyZjY<_Jc&P zY5$4rZ9HFnB`@uQerwD`(LY&$q)qC-KHd2F3Eiy)zbC8@e#0B~Zixbdwoj;oVgS+p zBt?!?YJK5yhgLGG3#hqV$CE6o@HP)FoQ=wpD6gxyRS514i0WN$zN;b^TRSIQ78Vl| z*LiSqd6x!cBzETwtSz5xV9YT_^iw=aK#l;R{74V!s+pl*ZY~g&bCAy`iij%|e>71e zLnQ_{rY~3y?bJ>-K8@5tV&h?{zIB>rVbDIRtp-bT3o}zA_87C=ooW+z=z^f+X~@)`?4a==9G7-KTMt^K8k(99b5y-VQNYRN#l}}QyGq*r@Mm7&}5U+B6Y73 z4M7Hmytv_^(4>++R27seq=CK-@-OK>(kJm@LM%^nE?`&Gj+Bzbb)YKlzdt6*qy^`fu8mQ{ zO$Mu6_S(0LG;}q3)UV_Hy5r>H7)MJ&!GvsL-)Jw~j03 z(75`nVfkYmb(ZvtnB!WH&+!FyLL!K!psT=eLG<;Rg#AyV5>lDri^Lr}ppV?50%#en z#|>_n8Is{VRsxU%4Pgfhft9azxm=K;?h%x0ZNMm?DlAeZa&Tcuu7CqXr?d7a+d9U} zxxa)toSN)bVfKbs__4g$^MV~Z9HEB-#z~7PY@6nZVKI6%f}Q|Rl2F5^dVQ!m!8q|o zKu4j(1Dx&fwUo{OOC_(O5VC!_;`N*!EK61l0W~I&8bgrcFjrq9Q3u8yaSa%rcjxI| zB&i8Z4H*XN7QjMr5=FpXB4et1_$K~FmgKmB#XfQm3>zK4MNVIG zESH1u<1ffvV00n*b+^KksFSmNLR_gh!R4M}^on4#=k@0j0R_1JNCCq>nfP9gq--eL z0OGXkaP;dPuyG+LwH^vbc@JQpPpW=O16~Epyg#|wjAq#z-7C$nsaOGt>1!sHj^%mE zo|)qYbX_CYaLL`F^@d*Siz|KqBzHTML;f;fS{?bv^7;Y`9%}$e;}$cV`Db@Yv5o8B zOnTmJE(9iDKDe#fLZ7ew*&%%Uak=Qp|8-LW^9?-yi+=fEOorOq6rT$lVUGb9m>$x$ zo7`*Lw5e#QU-W$k29U&AW3h2@U3r7VYZzYRWT%%$VEpj%!f?&!DtmgCdH!yWhn*}& z_*nHuYF`;l5{9mCoSzF7}NngIc2t|rf~jTeg%P4XH;^Z%^Zv>YIE!c&2N`ec&8 zdt}v6sQYatZ>pr}g2es2G6-gYLadXE-ecD(j&JZFUf-bSqB3!sDosCcotTgZ{-$a3 z$JG9DaojqqybBh7n#SJVUctE>zbzG4vM6#e1c_V)-@i@9ITkN2ZamcBK>(i`skJql z+SpKxyI^f?Wi+>L0^jzXY^%uu$-64KErG+!i>i&$u|Ai~@}2p(v@}kAzQ_=6aoIjm zWQlMRI#GQQgW-B=vz+2l9E+UALImt4SZ#~BcuYFCXE*zol16=wpdeO~yEKpAwiz;BBRH&>h(9>sNRYVnIN2|$ zjQVP?OAC@W;oUA{M%Dog!DwDS+cSul*Kz5Up(Y=OW7ezL zlMuFlB*nv9o(&A_b7{XUx1B!6MZ$QHtQ^sJd443pepxhUCSC=bX|vLaI3IuOwnih{ zR;y$FjLWF#&~&SobMM4lqBD+Dl#y9b_3YdWvt^kIe<>(}i~aq2D`iRjz?nsYuuNk>CFLzQ3~4h;fduv%E`CnwIF1v|Kq_9S*K-16S?pSi-LjhdTXWOP5AWTv>tm*~^}(we zUa)OyQ9Y7->{twr_DJN1o6OYq?sc@hH97trjMrW2-6Eh@Z{i;l7dKG|J=?!EQDfEA zTkA(~?_h7I32t&I+8+#joTOVWJ?4E$xzenDw|P(q}dlj zinsj+XH3Bp#sqCb8F4CTNk5?RPn(6Awo&o&p-1v7Lu*%_8yOw+ZT7o7$@&p=U4r)qQiVnUNAZ9lhEEZoRUD zlFTZd`H_*;xuMqqnVEjm_UCfz_tU$<(zX0w-nt zTo~i|H+=0%P={dZNJ@j8hBRXh>AZ^*^UTKLAO7YkjjA6%p7koEL`Ym1Lob)(o0hQ~ zvWZ8UErU#|jZgwg^JJ8k_t~x+H6$ zWYd#qLfpmU-nD#CdY6r(yR*%;cH${d~zGdX(T+E}91a;O%o7F>B)T0t2O^bkzm}B9MyQlo3=>gvV=H>Fu;kRvH|+m79X)ZU+a8Bs zBfPRez7jPl1tUXNhuD!gy|A#mBge!YWvI0zBu8_T|A9E>Zf>)FZ(A1uT|Ug%?bCmOvX+#9m8Mpn!{PM?^_ z(Yjj>HOSgzB2qx=r^>p}r%%_ra`(-tP-J=4<2)RNJn1?L3JCY}D8;_W^8W4Y67KWm zwOQqtsJQfssMp#MNb9az%V4<6nabg`jI3M!Z%)l`K*@$crFv=YIC=#iRkD zaN#Y~?Vn= z+Op7dqey2hp*aTZe1&9M>zOc?tvOmZ$USsBO@k4WN)oHTj0}uP@U4KLbhXwiO)cWk zCcW(%w=$w|$d{bBX#RtG@4Z3UxrFp_`iT0hv4Vs(0o!0P%hR>J6m>LJ-lDbjQG$4HXk?h>vXR4MGkM)&h~dX{Tkj02el}N?%>8t$lgIg;k64z{ zRskZ>a)L*{xEAf{j!pQ++`x@ckfSEha>)M3u4R)M?pG^rt?QE@g1JIH3OW2{4BLHe zF;!JlT~niw*`Y=*alusc`-hY(Ql~^(tWjhIEzGXy4=brZ%L6AEXBOl@N?1|940}fp z4-KLjnQ}629(F!?&W`GS*PGZYfXsKX8nb!o#{)N(2Xj>N+M-%p3{Otb@G82S^`6RH zd}r;-B7e8(-U9d1g%thL(AU{NX?N)tR$2}Ih54*2ve45s;c3gmP4jG*f*k&eKtclx zi~MQF?!_|m9Vv35(p;qk&nEY&kK8_SW6(=A0&iNMXAMK?6lw;Z4c@UmXIlgbdu?MZDqv-~#gb6K9dbpl%Rtp&1BJWDxO*W#XpQx`wtaP2}} z`rBLAWi?Z%naQBfmMW-DladTi7D8lrEJyrTYHxE38Q``*Fhztp`)`6aMr6P*qexm< zAZZxM0>YpCe#9aMizc1pk*i+hP+#92|kvIgrd$W^-f`ldWC6s|I?4k9nDxtxx zn>S;sw)8u4_kF(f6jBLq6ms#*HygCJw|7t5o*s(vSdF%qnQn0Hf-Sb3;4r3cn}RbX zWgC0wvQgDhy{l>3#vaj`n|jW$F=jU$QEolK=5^|KFK4~`cj>E>&2k;pV;B!l+0t_S zTzqW7Xh=y3($Hf|28U1ORa9aVcvJb1Av51}X1z}}z2HE+bFedKgoD`i4PuzB+hknv z7ToppPOJ5gxSRGGy{Xm1T9z+P?PB_pr@lsjm?#^q_i-49;XxpkcoL}X;@@q9G|H## zhv`lTucl>Bx@ft>wfqQ-M6iHUTJv|rgc4PJH+t45$C3QAx4i$*ig*_9Kcy@8|0F=ptLUR_ty z^@YyMWZ`Fm}6XYip(BC8Mg>1?NPSO++ZITQpIrIQCdnUS2->Wn}t_0JvPI z*O3_e)2B8j3t1iv+}u#Jru~{{07*K{5o+et)5RM)hU1{;-hAL(m!@h%&NU7l9Wt-e zZtf=Lrt?O9+s1WAi^;;svzj+WN{M`$v#d4Dml|TPZz)t%uESZqUQr)EiGj+ehI}{k}9vj(+M!7!WRSqvP zmJXdi>{ zhCy@4%jb*yT|pGiQ(m^bg=B`eOcUj1t$&v%RE3=8^o88|mdJ1AIkFE4jF8}OXaN+Uh8%(Po&#&31=Lqm_ZtmXP-Wy9u3`tyAEro%0Ruj!GFqk2ZI zgUwP^HdEO?v$Zbq2iwD~0A*jCd#uqJuz8k_*17etHid(8F+-v=GY{5{)CuqX{VVrq zcyh85j!`QtE92S6R9Xxx0b!bnY~hx7{*(5BS(eBRqY4^f(e?s zu|o%#bH&ZDm`|VXCp=pwtT`z2FzFB$>=Is`WeHq}TKd)eiLJqw^zVGj;$}bCL?JC- zUi{`{4Kz3;q}K%`Jy^r&^T%-Ez5M)jtT{#~80}yZky?Vx%GRsG?PwGtAbp+huPav? zS6ui*mB{%gd7Rhf5D^h~pB)>_sH!)AMn=Swi)YcO#8=s{1z52nezf=Z8_c;DdI{-S ze6-qM^=#H_> zhp*#ylkk`0+pNT*D0p;He?+Wk5Rb$o{OBk2bxiAbM*XvM^*X{YMJ+e^J$n-oRh@lZ zPYb?W^*dwiWh+&Ov_{=B9I-)hJ{nLh<0;2D=?2P?h~-p+3WsfLq$J6wh<>-r7(PXXlu%;T5o@Cs8Q8 z+YE%+$ZBka*?!ib=IgCl#(|TnKr?MQ zJ~P|kz&sszZ+$Ym!P`bgrgbP^vvmX$8<`5t<4!JO^ANBNMe%0nX(9^{Ig2+52}kA! z_m(f$W)%bGe4s^?(W0U{7mI}si}qd#U%$S&p>c!1f}3@{l#gu=7X*K|hBN*`mP|aT zEbOw5_`taWJairO;MI3FdJjN)`gy^*wt?299OI? zO>%Kb!QZPzNC^~gke*;#m8`$|ERblv?@YxN1>B~wWv-8?ZBR{p=)RkMv{YP3b*?Xd zwk9gZ*Vk;hHr^{}#N;+DET8h;G2jm6C={d)Gq66DxiCISop#-bB_SnM&woowP0dPX z#HY)o0@HdKMgb}bzTD93blsfh_Q&^kJl%?Wi8|!7AW8R25WF~woOT{I74g*!D~z~f zmH0wtZDJH)p3H)Dh~^IcDKQ{cyTSww*-HA7=*4GKhH9J)PWJmN_r{PaK>3agIoEZeSf>P)pzKAZQPALCpl3c*|Pw2e?E0I6W_QxiG2_qy54l*(nb zUMYL8h`YP27=nYc3PehAPJ|dGyz(}ekU{^#;n}{OqN_^rGi=9}E|g-}EAnzm-#iTBzgEFgv6!tKvLp&rt~_TzikOH4 zt|WyoJqQs>pPlPqyplMNcSri5bZ3+LzHL7Jgc4)=he#5Ne*$tq{OC7nCdhQ?-@=SB|!EI|a*A z_b7rMA5W6{ORU{V&!H2jiem)&gTVuZWZAzDD?*t}P3`|s)=_1gsS_78asBq78=5#` z1bzQ5X9`zwnWJ6d{rDLB|0sKl=@R>fEw6KW;jFv^LOywaE=9vt%#&v-3&^kNN&+{h zKOY^X8t2r*cju`#m344)DEfy~2G>=2_kOAvyN4WC{zJ)pn{(3@F9<}Id%EK2a5liA z4t#&G<*d(Li2jO+CTh@F962G5!va`k9g!VH6 zDk|+kz)Li*{vS>}ARkaX=;!vI6l(Iv{&s)&n=#7hYl+WOqi zYwgrQbh*A4p!)G3WUH1Q3?m*$jIa*0v0~G}eJ@b;XM3;ojf!{7=14RPHGRgwQb~bU+JW=Xi}6Y=!4_>$*Va?}Piuf!3&Q$WnA%r+BG+m4s+^+F zDol%v?=us~Qqu^Fkl+~g83jeobai>X{2CPuZ*8fOU`_Q~Rk7P_V4k|r1FA0{s)UMy zLRh-p>>^WIu$c)0Fr8c9+z6+_3If#-}^w!Gx$ zJ64pL@K?kAAWV5c(7+5z1H@czxK}xwIl;qefNlnkt!}>$H!>|>$b|xSE5?Icq{16ZIOQ^rkg)B zE-bHm_e?d2%`mcjKMLLchiMMOG0lGo0?Ff;KNnDzhuNW{C1K$h6^8|32i1r-+=3mKza6`i+%yYXx!_>au*#m+T z7!7%c4@*oov2bksda6^jK#0?OQ3prYf^{R@?N;l?*(Nb3tS_RbIHN~;!VCngN*{St%xGBv}taW>F55m(&^^8%>#m&_RzVAC)~Ip=qo zCO_?B7Jg8{x1OCp{wYykc^rrwlE`KLiDsntjy66lI(bpz=87R{`mC}rm#69e^*;T! z_-jxwTaCU1S0wR!f-Gp&1)}bqlhtlhkK``VdLob?tHY!o0!BEjm(`{4esHFYz^v%`)^Nv*NRoqaL1y7~e6L>g{>N znx-Cl{HcnR*?i1W95?iMmRG^88R0QC%@(<@F|U?57|$YwVPr#$cQmVeeuO7nQI~vg zwM!KIpxts)KIA9{3$~(n^KoZz3R?Rde|J=hAP?cr(mIxjFv`b z-va44go^5G2X4~jSpm!jM7!pU6A+U8dGl6$J%OpbF1qq4mcq(o|H}L3WJkP>C2U{! z2rY`^*Hl_CwF?=ZopRHx>p|b5Xqsjm^)B@9^x2BTbm5pMf^Mk;yp5N*-Xj9QWb8}J z_-H-jJ9#IF$mYvg;dQu;;dDJCL#=_kn#sEI(#ks2sz3Tx=|>8;k&yI9%*sRew3-+c zOBgD%Hcu3}Z@8t}8-~4bp2I^LU->X; zR<^(P_aa6VGpqa#YKNlUbSg|3h=&-sH)X{bMJ1PM64@Rr*kwMapZk3%(2EqimN;_q z3J93+^4QUrNK#N6>sFa2gP3<_7OlSBAtPH?f9XWq4D-)BpE+KuD>}GhmONuLKO~@0 zS#j@bBum)2dSabX+?{z!8XtzzFAv_>I~puJEK9U4(4!%gA$rJ?4qrp7{b`SrU)^XkBP09&MF+q(P+E#+1V~bU}3og zOY(H)Y(4F2wf&Ab$!tc%MGZ(zbsSpgVvpkJMe;_wDV~k`!v?9SKolglSIJf!0ga-k z{ez6OepB-K-cS^@R*`tc+oBhMR)i$kl_$Hsdb>+p@XmPa-n3Ful98o#6Qv00K)zE( zT-#RQnhRsAOjK-HyXEI8*G`2p4uT7VG^B&GE13is9!n-c)k~ItZe+U@ApM$II^<&y zj)TejMl@pLZOg;pI%&tUZn3_5Uplx8W2>v5F6CEgN~PT0BoDsj$UyH9NE9{H65JQx2Bi zh`VVvBluOrIZ<*LRyKC_r$`5T2=Dt!9!i}8MgEbX#utPkw{D2(-%xzf-DuX7wK?J! zmov5eTEnc1hMK3%=0x3Y@z<}cQ+mDrrImZr8dOxtmd7a2VZ2;W)7n>LES`m%zRxV6 zJoLC%LnFqjJUQMcOKTrJxUXXEyNK)=t?0qt{+0+V$Et`_U zG$Q_dLwtBjsOEhz^aTMtVfxwG9K3-v z0;?4-IHO%&o$oC#%3#sX)9~=wwTvPPJGTdOzg-uWHrpm}&4eZDk>e%$KsJl(-!MB~PtM(5< zN)zULR56T>4y$+4Rn&URy!_zkZ29(dGg70ML$>m&h3^`Z?>5PEpN@KFAi>iAZyOe5 z5R6NjHrQO$wu1L~Bfv8Gl5AZbRuEY7NnnHS;&oi(i;MZ9pQx6I@((Z>;v(x2WA1<- z^>^e|S$5(fG%VD!NDb~Af;(R_v5YmU~<5QODh7Zv7jR62iHr0T=sk!YB zwoDeXM^Ae_7p4S_3RwVbQ-Rx{N|BFQCLf*X-4u{*Z|yZumEESbJz%utv`x97lKOQ= z?Sr-2VmR#S{iQ5NBIS^pu z+at8I(`Lly3%=X$%XA&bD}jlIhJW0=KNBK%#4sc^_>%R1+MEi%dEKzb=HcmLXfzco z4AaI)$yO9<(eWk_rhLiu0Ut0Z)u$7DvxgCD47Rh)Nx;AGy%FGeHH`>tWRaoAoTOes z3j%V+ZFF#(q?vk#YIz;_jnVDd5BydAc$O)*g8ec@Hd?==CR?K3d(>?w$JCz^gH?S- zfon759d}=`G5-N*6wjUsKmnH*78c)3$i0HP*n`TV+&;&|PC_2&9x-T)HJoWdALp#z zu=iwBBRblqpXbD`rFM5IFfwx%*FWwY@)YHDNB*=k$|LIqXzIECM9iElIQ7H%`rZq`V>go z44mnd*Y2VzptA*5qqB_a*zUt@+u(%J8KjA!0NBp+%s+QhO%~kAN=YddL{_^_k9|&D zM!hrK2H=%eiQ0neF@Z0SA)ssN-)ar zk7)&a*o3*|{sOE2x z5i=blJ~d;{{o5xVz1r(jnGMGAozSPU(>*ZQmHz%xLzex(1!$HvYXSs0Tn0G6$) zx}IK8O_*P1;%;8-M76#VBAxDl@v53me%e%~Y3YlKO{+u2E)_A|!S`S_C`@DqF8$Z- zO^4~^I`Te&Y%Ya2|C8gB_noGzN748a7j!lTFF{Nn>4}}O8~SFWd6RH29w~95ez>Xn zx1gYo#T+8Y(9@W9*WPXT$f(H_d3JW%6cv?-Q{)W^J=SeA^!Om8uZ26xXEF#R7Pd9M za3^;V;7v09X%`ojVF=%AU6&Q^cPamr_0;LCqUB6a&mJI8}8s zngoe9y{zp&5K!StSs)v1He(VbZj!??qSCgL^*aNKJeJCLyrW%-km1{IQi|@HS6s%cBzm7_XrKk1Agcv&8+O3aNB`lHM?+O>I;`Ie` zTb!O)NZ#Ll9cIS6k(T#lwX|2DIOT$b3N?5q7-AY(P)FQOCFz%YbsyLp*wagBZ$Hl| z%Q`zEda$%a#BS7&t3rXeUcfl60TYg*=g!o%^|=>`b;0FwuK4tDJe_V@ZH zcg$(|#&b~wke>Q3Zy%>Lot7x9_N&N%ARY_E-XC+1#-KexqMBE&Ce z)u(7wdcQD*Jjj6D>%lLE)9#Th%ssd~?{&iX3Ow>vzuZ0dB1}97}2(FvXC8{0>080qwnfw{03mb9to1Zhxp@;#?0hVWS0MU%Qn7aJ4+{vQFL@T zTDI3wZ%UyyU7obr8ysuu`c?ksXUmLM6p?vH&X&g#;;wmjqhC-hc@;$lI1PiV?xF@vms_xKdn*&qnzkhmz*pFR6cz{<)R z`yG#VeIzHp(FnscSt()c4Xsosw$hB1$C0;q;@JAiiP*#oWe-^k0QT+{aw_?P6)?0sV-zzSN41Lteosn5x?LPJg+2)B~ zm(9~sP`t&+n}1eP_n*-LvvEn_gzORvCh8#Xf;fp;TQm9Y{Fu+z^g+ED{mwN-YzQ(J z{*y7MbA_5UaKiiqBDj5oAm2k^R z^phHAT~%@~2Z{aN`->N0Kdzeeb-Q)$mp8_0ZMX)Pmq#a!HAO#j9BW-zUDV4TE8E=Z z-J93(>1f*`U=VDQeEj^X$@oO_a`6Tz&86xF-x9xc$o{z<6kica#md4Gh38|pKKm_U zw#p_X`0a0025N8Z_CCgi-2VmWAwF<^Mi_kDUra{9IVp$~LpBXHO{u<&XwkpB9; zKaoXet4~S6j4b58nPhKrrnVLVlhno4sWQPzAn2KmR8Nnz1KIE1@>d4W<^dufvGhD` zBiHqA&yd}AP&MDdLlxp;-sBI{B&6bzC^S+fRD(J`6*f#&r zDtl}9vB$@&P)L-zX}yj@{Ae;sMquNxJ-cJ`qzOImx({tqt?z^$4dxKSQj9Q zAtmW^+P0D0b1+H$m$YJ{LvD(rYwgb>*}2vjcX}>m0z2n8&&R-uSuP1)rFKwp^Rl3( z$UoJ!8%F3I-%KSIq8_2;gK&#gz6i-i7gaye83IAHOdffBg}@82t+vjTfFTOa=3&i< zu68GIiURny2rw_h8P!i@c@~i4*Ja$v1Fh#gZH-B$vkH{2S-I^<_Wfm9=`A+Z^<$2+ zWf1Zz{Q;RdGeA<`l7>q%LmRVFk6|8bQ{d2rU*(hCUaAIW*4ATXkxKPDJYF;H-5P`Y zV3{PQtB0&TA$Qw95xEcB-P&Ogzp(HHUd6mQ*tdEJDsr659u?;rZlxc?Y3{_WGZ6&==i;(N$TZ@dmK8(_M2mbNfu~g z+Gh1+%$tsWl}elXU_lAC*)IdSB?Er5LYmK`< z&sMJ;2E)iMKqDxGb?t0g!9+@XtbJFgS;=TDTSH$l8exJ9!s}xVOHq?)R zjyu)!D7UdV;Ag}B*~4A?T%S9Zf*69RKM}wmdv+E75doBDP~6|@S(+hfa!EJlBaq#_ zX<^D64Ua&_FLjhSfW)bTML{A+knLZI-rqrJ>JES)LUU%KJKx(DXtf9H^+CwQv8sm$ zf5g zTg;8rHy*v(Q$NBSVfdu!yAKCfF!uSOKwr8l1PZ8| zExu#?-Ve@(2FG)0C79bg+dnCZ*Dun!dfwnH!JuWDiM^3u`U0b%F;Xh%N3rM1fVz~^ zdQ6VgvzuesI}US!^Aklb7gV7)qT8XYV}{d5c{Y%p$NZaCGb;@%wO4?RLecjPjrr(T zsi?frx~kARaMA6Z14H*RHjGiBEnUNNgQ* zPWeE=a@y=XFnP(S8&l@ha_c1b*K>=treQr|G9YPT4*b=jkXzMjvREa??9Q@L@@B`C zWI>N3A!sVAutK*)vneg7)s8gv*V>-m07uIZpsqe=(l=$LUx4CG+#Y*kz2=SRV@{F7Mc5_>+BvP0%OTrs_uJe&KhX8G_HU$=un^C$|1`{mF#m$wu5J=8|6E6$*s zn@cP$1g%t+Md(;Qlac%7@k3>*%vz@xc$LZ)7C|PovrzF*Hg&dz$+2asNAf&ELLwn{ zx(4#D*324enRN#2_sQZ%4!8itw|bXKug3DHWW)EysD-PYO5>Kin)+sYG!2_`CMQGa zrOlk&$5lO1W;dgOx3ym53zO-ERsG7@XtG}mTMqfOdpualpS5C}xLsmz5to}2);OGqRs#YQ|OU2}@bapHGM`^@E6 z*FndAjP!CCG1&$kl)D}?qdsLQQkYv~17C>*%OR=QktZP6Wn;Xj@Gpdlj^Ti;K&w8i zn#IebB2rRf$I^!;tx7XC#63Y?K)IZk#0}m5#>)7EvW?lReL}lji4f9ms&K-wYteJX z>qY>|8RH}Z`Op{FDBK1^(w}e}@hk`=NNMO#!{o&(^gx_j^>Po972Jwy*lSSroqT~W zZe#EQ&HC$zS|KuKM2VG+HL~=PgqY2F@>mMHGQ|WDsfXzTA#4p5P;}aBHhu$GJ-sJZ z^(l1LyR?)`uRIY#!omt6OzAw1x~esYQ5UszN0B3HHN?;cot(zqu(Jjx?KxR33NhGN zYpkW5*Cpqw;;O6i0f#*$l6pW|1X7aLcJ~9%aUqf8{3}Jf`O~C3d zXE@ca&)cld2`MAsFl$ni{B*~*eo1Myuc@Z_k9jEGF#l9|Z5#kX4#rX>(}k(zBquVi z_dy_mV#*vOsXP&i(Gy(6Q;CF$E}OS0CSnS$cHvoB9f#dzxjrWDS{ha}kSW26niDb& zy!KImT455;HdX>>V&2x7RzL&x z>J49gEE-a0cb&AJS>55FD@zn{1h(FR?`jU8EVC2mL4F^av1|tTF@+T`$~0VK3FJ30 zrrJ@<@k`Y~nXL>i?*K)OS^K~i!=a_H`|KtQ@%U;t^P8x?7W z7)ltryBh}HHU97YywCgW``O2S_pv|hJ)ab27T2unTI(0*xz6(iZn(-k+2rtM9PB0? z56^c!ysuX*piFFhFEL(!5Lq;33hfked1E0#Gwiv}^+M?AD+zvnK~`^UlULKvpFib& zT9HU>fdfptpBB`hZX^N>n29wtMolfEs(5wR_<{GOy4R&S6Dzs_%JTKQ@`CNm8vwYWyPp_p z*Ri1GY2Iz8DE_F$5Kp8>UdXf}EW4uNRMw@8#A$hMCQ)IWR3ZYj0}JjE$$lF_MFW85 zkYIdHqN${^@^yKPyPV$HF7FgOlV{K(j3QQ2-@j_Ie^3`)j^fMnOlv0?-3a^rb8a;$ zAG9pgb#?P`KLHsyve)1-G~c%aWqI{^q|>8}0bXm`Tnz2Q&5Uh5!fNlq&Q~$Obih~#k`BY3p#MVfXi9@U@a9M=w_o9NLiak()|w@Q2{v zgnaCeD7sFe1!F3%6JM5Te>i%3sMxKHwAYV&)S^q^c2!LL;NBkY7r`uux`v}m7K8wt zqsXBvU>`I}SsoeBzc-TizAh{~rtmiRw$)HVC)Za@-(YZ=-9V<|=b_?0)|XMm>~P{W zS2QdUSJ_Ih)U7bhG@gI4G#j7eMMgw=7Ff+#s=&OHut?QKjD_*dY?$i46JmimFqN)d z!5qQ%4+Asi14s3xN-_yPiSesfFRS6a+szNrXvkjgIJ^PfX#zguz*4;E z`em)BqQlU%7so$*51GPlSXgwUeX$WXrRdWoF>vD3YpH26-`470x^)fs24mS)Yh2GHbEU2|OWC${jaQ*p7{=G90QxF; zcYf34a!KFo^(fJjg@Vi_J;k$}HF0PYtj%^1UE+2Q~~EV@6?g-iUhkr zvM!a-xPs*GmRDbH$6jZwgMOjGq_34t2FT<{fynqQ_p8jv^_tAbl1Aq5;m_?z3LuDS z|D(6Nhd5l`bh_tsRrmma?IR2j%z-gNbnvu3N+Id^xCYJ1EOgj0ttL9m|0D(3dw`A) zn2B%zE2h3&e-NejYyy|(f({^Gpf3&xG4ghsPQjsucDP{&nBmoj3hE*;zmTg}@?bAc z6~d^J_*a$z_$?gT7BoNMTVl^) z{l8KSGJ3k5!JaE+u?<_zJJHE?#cvb!$wVqrK76>fLihu^qX;wSuHY`I<}U4%o6Nu7 zDbG#T($^t43#=hzu;A+hcbUQG|R0U9m8=`pZ{# zUa4?2jzI7SmLSmjw}DRfvJGsa;v#?zHT=>_E;$GSGkS)^8QbVqP^4us)A5X~G=iAZ z={6s>*i;fQ z0Ran)KLEEReR9{ZtvVp-Ax!wjCwkbv{TzlAXFnu8o)nabxl zPztMS>lu8H`j8DcUL3cw0%X$KgkW*}g8NY*s0S1*)W~7TzJk2)ZJk)7t4EG_89Ew= zoBmEXQDla25||v7+xwpRGnS+SmX-g{j|JJ#9{7zei1dw|E& z=aBojWf%8+1}qv3H3~p6(cBh$09I?rc0RG>JF9B4mO-1w;`rEG8%J*)PH?M9=9h^l z6bvR$)gO@Oqa#(c+aK;S?VnLR#O`94P|+U+?_4b$53uInfS@{Ci3Z{kfO$k$H1g&z zw`%AZsZG~rAOA58v}J$2Y`m2nzM3!$s8Xv59|N;1rH<$(tcMa9g^mKwbxiF5y3c$g ztYHFlM6fL-&?_4w^%cP33#uT|I&UhCNL?HMg4VImwWN)S=*{SMVmF6TP7XclO~6G1 z&^2D0J8+%ToyqRsAvt=FrRfafltk9=*Ih~zIjz}PxbuVv1U>COrmgZql0Dc@Kl9fQbX3%iQ6F546%Y&JWfSt-BtE;>aA!{R}e(Z2~${!;fG-403s z>a9lHA{v|)9N7$g*`%qb{#-ZcWL%TbjAEKyUE9hoJJ4`KlYnRa zIVe-we*XL}a{_<`41TnHnHAlUG*akWoe`-!Wj+9?0S5{B{H40**VwpneX!Yfzf%r9 z;tlV!t0(C4N=(Uv@S~#D=;{Z(W!}Rb{x)W6f0zp$T~(&f^)gfz^9wXMRf6E5W)IN! zZ(PH~PTA3t|3oEunM;}fE-H;oK6qKI5t(94NC1M{SrRX@)-&j{fA$khMt{6>X%_-w z_l&;<#J@bG^77fY!LGUv3iYHnL+nhFAfR598etb;a4RdB&5iD=h~*C)gS$ zk{Q*ku-Z^~_76=-+vEaG5F9oKhH93Pnriq1wSX21Al~SQHC@=zi1|^)^-N^|`g@(H zaMhB8viRCrNg4aaE2};m$^Q0&Zwi2SrPP>q6JyxHm(PSk3WNONn=inKYWKi?D=t99 z_z@k5vftB*aZMv!8hq$$zMLvYS6WmVVW(e3nGbCR>;gX6#QUVGf}|3B#R*pE(v2vr z5T~I9fbBT##r~jg>LuyZB(!#)xzGF?3>=_=rW|(6q6t%eQGhVT*O+CY(p|Do4F&Wn zh4PP29Fcw%Ad{E(`H(V>?RbD|Db)f~Q19ReCDq7t3nU$vS*a1k(Ju8jUt<$Ik}G5O zF}VpC_-iuyiiyC?{=1sW!bx!=Kf#yGul&+t<3ER>uXiCh*@gAxcshr43SZ*B{H9Ph zNVIUC1nkQ{AEo>X*rP)4?SN_>W0o6z2wMYo^UIHK+vs4Q3pm9xDa*(pyw{IEr0Ben z){4f8{iPQi+Th+KN+o?Z9-Us>T9C}Bjpd|nTkUv+9jV5%*oz$^7+h%a=AXo8AQD{j z59rtT;REp&8*Qn3EU2%_k+GB@d3P@!4*mJBs%dr^Fw)c+`d`UAMw#YrRzu&U#kdWi z;ZApoT3sDN&r?3T-MH7_BE@aNo`7NT4H9i39&7YW$N+G9;~y)!?c>U`P)+Av(9!J- z%ynlLx1f2wscn2PPf%Ht`K}S+Gl2k9wcZHmSPCi=PbqNM?v4W3KdDh45VBZ!ogDsa zr6d}HzL^stGov@Y27dbiD8gw*Sq+B8e`!53zsytoG&0DBkn zAg3jZJO!_}o{(o#DV9Nxh!+9f8MMPw=Xwzd-#&mLeq;Mzxt`57LyL9 z+cZzk>#{wBn#t}1Z1Y+bP<(gZq&&6r%LIg%VlRp0toH_8audc}i_gV`+RVxR6@dr{ z;)0~@j9!lHUxG0)tZ>o9XeU4~A{ezQGy38uUkAOgJ*Pu6HvUghD13%J|DPa4NMM#n zsn&1xpJI@DMSA|62YSqPH2waYiKh4h(Ual?847mJTNy>L*C{WUSUmGg^R^Q{=*O?y zYFaLmP5k2gpzoOo)5PWrjq8OOo_{Y&R|X6sK>HTWELMFY4z|a?0;E9AOYLPipiMSO zxl`qU88zKVLfTk3A#)Q<98bc^FoQ2?1V2}#oRIj?p)bLT5Pb=GnzMwHl{H4#0209!nl zX`pQPg4jZKZIE6LNM_3K9RJnVJ4Ea2*&q|1s`eUL=RYTSC}yv-HYKX0Cw;co65v%Y z5SpEROO^=8$7zG}w{>^g>VLU+yu;Q7a++7usX*<-^KdfJ4#7JSu>fkA0w9%A@Q74I z)Vp|bnYd@xE(#$=FEPQ3NzHf@2>fl#2uRoow}jasGw4}HJ9^5i9M8GDnCHOJuW#nu z9Wrr}*&BgX@ksVH>aCQ!1f<}+kfi3P(!ZLT>lM1bGKKwCM@b+?sTtl6SIbA7u82;) z36vo|4qN_)RdLbpCrJElZ-w_8(Q#JLuzTEH!cvuR0AN)hKQ_t7Y`m=06Fj z8W{&SA38YCgTr=e_nu^o*WLM_2iX)H@$ZE{0umh<8RH;~0gcfAyQ}ZD?!nC6H4%@G zM!*@!Q6?OY7aE`3FW}wRv3%apE@D*@p(wW~OwrnDZO0Mu_n^=Ek^wy89!6C-mdSLQ z&~+`cNbk6n1WA=jazO0EGCn?jex8_8vQ!2tE(`|XVSrIo2_2W1_S?GSEArw;i%bOk z!^srWxPZX9m&2PkVcUv^hvV<<>45nkgyHX*DHX882HagLnx0z9wJsH0-kP2^LgZ-N z%g523_m`0V3$h}oimKLnx?T8Z|G13rP4qJ1GP*HQNVm%6~fk!6h}NnK5i@y1m% zFw*`o34Fj11=1CS+lA|nm2N0{U)09gj=ZVIAIzYsDYpg2iu&AIm{jxSvaBw!aM?uq zt0^cb=0IcGIh}qx)E#Ecfnz@olPfyDacmT>h0W8fx!IGB8%O)?NN3ZFz9~e5td8f} zIKz?cI!_-kl@N7*4aaK>XF(wKO0XW~D)^%)>@Bs5s+fm&UG0s3bKVV1X8$SGSXL;aE`&u*<2e zgE%P^JV$Ne>3(;r>h;{vf)VtgiOjvchj^oo9dd_lnJEUT#hga9vw0jl1qF0z&jp#}AY2*UI2(nwmgA zCD_Z+-I?n{HrHHUTg&=_!{XVio@vxQ%y4k20Z!mjNgrwPm}=sZ+&$hf(eBrjkx(z6 z^uACg7~xkpI-9XE6v_5_$a=NpUiWtKlUE9*p^3{8@T7|L@bZu|eieo5D0+5j-PO+? zc?R*JiBNr_zl3U8y*z+foUkqT1LK_pwEcWeP7W|(crH@kwtAG|dz3~(3LG3I@cK1~ zM0emRrL8nXv#C=p5!kE92KD~Jn{n+?51lr`vT1+Jr~+3*S}`$5ITi5(KDx=`+|)weKn4xpRj%;ybI`iaf>1ZkbvWBU6yZQvOQr z#(hOHO7evb~)z#N`wh?2;t;3R& z3)@E8+6>$mccm;WdO9+&kU)JKsHPPEM)qg~rU`x1`|BS4Z38T6IZ6BQt(6V^oDQ|8 z@@>Apy&Fm6@gxJllf`uNBLh63J-uijIh333g*#eM%6QCE@t z-L0hH-&tDo8Y7}OhPyPibJ>9npH-ktdOD@kwPB5di>8U;)5FDBV%C&+y=oDXg0-WC zekHC9$vZMNQ~dSa5itq&`gdG~3*N7oF0JUMuY~KD#4N*;JMDX}HbfAL^ulXSuA5fwF!FK-~S5TI1P1 zq9?`K1)m;)BCmW?`Nt@2L6j$|@#(E4;4y^s#dUXgKUe|o$D^THUNj;iM`pkTP|R#B z-M9XIJ}@1h-&!)WD?P`}H@3CnPsd6q`1Ny;Ie?jUq;(4$5StMBl0r>SY6^`%an1Os z>O4gOW1R6)&@}l+#OG^r{&N3a9v|zj(w>}ewXco*H&7aWn|Lei0~(zy%x_+yLV0z0 zoN@7R21Oz@ODSpiy*%bC>KQbEogePoV@iI;9-X3CU`giH%e2Lkt#eXR|H@6zE6^`& z{am-HUJ(jSN@ub~<+7GLj~Si0^gVAvC4R?~--u_00l!Xy{p;iHTO|ei5euhyHyG}R zc+XCI?}%`n@D2g>iwboXXHz|MGhMyp<>@--6t0tLZ`DcfS&Z9Pr86H+usohHW38{C zy!Pgu>P}KugjX=qOIubvqrEzT-0afu8bj;S<>h6s1x|>K9|h+xY{#Lv)%SQ|q1Zaa z#n=rtl>Mg`?@asS#}9RvzpTOCs%|PpJoPN6&2_RlX}+ae1x^THcp%YB{!vdHO(7uFt2DGa%tl*EVz;ewwhB{uXUU*8duz(HqRV?Fh`VVIYTGDR@;nMYgBJdn#3oJ zjli??8a*6yPIS_8F8IU}wHCNj2P4WPDpUCEdp12odu1(25T41R8l;b)M-kdL)*OTl zc~o*vluW|u$}35GcvI~MGO~Lu(poJ~XET0xbm(XoPg#megc34@>q0?S5d$29$B+^z zHagqNYu@jfoioq(G^{JM%5hR}ITk_y$37$Z2q7V%p4p6+82@F~c)A{)B+LR7)mdne z>@z|{EW3t8cLLkquL~xv#?N1DoXs}Ll-mty z3%FgvCL4YADMj`ZVDiE+sloT=;q*wKS0sp>ID2njHkx*o_#T~-5R2qtM6MZ^fam#_ zFX=CM5Vk}3TUV%i<;Dlvb#AC!l63QIB3;4Yv#SrXN<^a{?%~m9>PvK`uryTqwJ;t0 zRn3kNxPA9Jfg8X3632+srq38^NYkLn8Xtdk1A{tHzq(DaomKQX#fDkJM9%bd%amaY znX%~gv2xI=9eBV?s~EUGfoA64DFS`0zyJ6_0A>s0^DaXN28Psvywpog^pBtaM;iC{ z*Z&WH6mUO`R7^TV>l;)wM5l<3hpZ}=($_!sfS66hdB=06fgR#|@IkX+{I`vL$95C@ z5%ww&{MI9-+q&G&dx_YNvEwL6;}{%VV1V-w;^R|ujE_<@D7QdrUCusLXO9Kib!=ST zIjFKzZ5iD{TKbcVdI?XS+$il1(|2F&$2?eS_^GitCkYV*3ZEZ)m@e&aiAPFp+wCh} zPd|J2&Z&K)5t~ZnRsHcQON=buLko))IrpI=fd^(CB_fC#3mr#y_ZaLoVG~ng{VFa` z1Z;j|g?LKYoArJ6AYrb;#$)A~?&~zUX#VeCuAJX|w$=wH5fQY}BG%>?&g12#yrn}M zSRPvM24|dgdb%ozxB4jr{j{+b{hSQJ9ry?!BWm^3)Kps4zRhLFbOQleyHtL}M#BxK z-Cd5Z1O^M3_ugjRK{S(z7n4T$>%>ye#T}|jRoMf*oQy}@HmBX+pP#-dZ25T;)p8mj06 z9WRRFE`ju1)mM>KcUF!cEdB}BAb5gLty^Il-7jiI18z%s%d+(OMRwnm@{&^KrKedh zEPsB>7ZO6u$|zFr2DH3ympgP}t?|7n^R&(HcI{npFVu>*uXpE&lk>->q6rEo=WG3= zo}rS5_0{^58oygn_&Oi{XE$5n2b%0Db_ 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 I_wJMb0*1d_4gdfE diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index 80c48ab4..4365655b 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -28,7 +28,7 @@ Call `plot_lifecycle()` to create a chart adjusted to the operators, callbacks, ga_instance.plot_lifecycle() ``` -The chart shows the generation loop and exit paths. It includes population replacement and fitness evaluation before `on_generation`, and marks disabled crossover or mutation as bypassed. Configured callbacks still appear at their execution points. +The chart shows the generation loop and exit paths. It includes population replacement and fitness evaluation before `on_generation`, and omits disabled crossover or mutation. Configured callbacks still appear at their execution points, including `on_crossover` and `on_mutation` when their corresponding operators are disabled. The detailed `Stop Early?` block lists the configured stopping criteria and the possible `"stop"` return from `on_generation`, when that callback is supplied. Use `show_parameters=False` for a compact chart, or save the figure by passing `save_dir`. The filename extension selects the output format. diff --git a/docs/source/visualize.md b/docs/source/visualize.md index bfe0a4aa..372a5be9 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -36,7 +36,9 @@ ga_instance.plot_lifecycle() Operator cards show handler names, relevant probabilities, and parent or offspring shapes. The population update shows the effective retention policy: `keep_elitism` takes precedence over `keep_parents`. The configuration panel shows population size, generations per `run()`, gene types and precision, gene space or initialization range, constraints, and saving settings. Fitness batching and parallel processing appear when configured. Long gene configurations are abbreviated to keep the chart readable. -Callbacks appear only when supplied. If `crossover_type=None` or `mutation_type=None`, the corresponding card is marked as bypassed. Configured `on_crossover` and `on_mutation` callbacks still appear because they run even when the operator is disabled. Adaptive mutation includes its additional offspring fitness evaluation, and NSGA-III includes reference-point preparation. NSGA-III may grow the population during this preparation; shapes in a chart drawn before `run()` describe the current configuration. +Callbacks appear only when supplied. If `crossover_type=None` or `mutation_type=None`, the corresponding operator card is omitted and the remaining stages are connected directly. Configured `on_crossover` and `on_mutation` callbacks still appear because they run even when the operator is disabled. Adaptive mutation includes its additional offspring fitness evaluation, and NSGA-III includes reference-point preparation. NSGA-III may grow the population during this preparation; shapes in a chart drawn before `run()` describe the current configuration. + +In the detailed view, the `Stop Early?` block lists the configured `stop_criteria` and, when an `on_generation` callback is supplied, the possible condition `on_generation() returns "stop"`. Any one of these conditions ends the run. The chart does not analyze the callback's code or assume that it will return `"stop"`. The block is omitted when neither early stopping mechanism is configured. Built-in block and configuration titles capitalize the first letter of each word; method and handler names retain their original spelling. Parameters: `title` (default `"PyGAD - Lifecycle"`), `font_size` (default `11`, finite and positive), `show_parameters` (default `True`), `save_dir` (default `None`), `show` (default `True`). diff --git a/pygad/visualize/lifecycle.py b/pygad/visualize/lifecycle.py index 9fd1f059..75768b5b 100644 --- a/pygad/visualize/lifecycle.py +++ b/pygad/visualize/lifecycle.py @@ -79,32 +79,29 @@ def add_callback(name): fitness_parameters.append(f"Batch size: {ga_instance.fitness_batch_size}") fitness_parameters.append("Reuse available fitness where applicable") - add_stage("population", "Population ready", "population", + add_stage("population", "Population Ready", "population", parameters=[f"Shape: {population_shape}", "Prepared before run()"]) add_callback("on_start") - add_stage("initial_fitness", "Evaluate initial fitness", handler=ga_instance.fitness_func, + add_stage("initial_fitness", "Evaluate Initial Fitness", handler=ga_instance.fitness_func, parameters=fitness_parameters) if ga_instance.parent_selection_type in ("nsga3", "tournament_nsga3"): - add_stage("reference_points", "Prepare NSGA-III reference points", + add_stage("reference_points", "Prepare NSGA-III Reference Points", parameters=[f"Divisions: {ga_instance.nsga3_num_divisions}", "Grow population and evaluate added solutions if needed"]) - add_stage("generation_check", "Generations remaining?", "decision", + add_stage("generation_check", "Generations Remaining?", "decision", parameters=[f"{ga_instance.num_generations} generations per run()"]) add_callback("on_fitness") selection_parameters = [f"Parents: ({ga_instance.num_parents_mating}, {ga_instance.num_genes})"] if ga_instance.parent_selection_type in ("tournament", "tournament_nsga2", "tournament_nsga3"): selection_parameters.append(f"Tournament size: {ga_instance.K_tournament}") - add_stage("selection", "Select parents", handler=ga_instance.select_parents, + add_stage("selection", "Select Parents", handler=ga_instance.select_parents, parameters=selection_parameters) add_callback("on_parents") crossover_parameters = [f"Offspring: {offspring_shape}"] - if ga_instance.crossover_type is None: - add_stage("crossover", "Crossover bypassed", "bypass", - parameters=["Copy existing solutions", f"Offspring: {offspring_shape}"]) - else: + if ga_instance.crossover_type is not None: if not callable(ga_instance.crossover_type): if ga_instance.crossover_probability is not None: crossover_parameters.append(f"Probability: {ga_instance.crossover_probability}") @@ -116,10 +113,7 @@ def add_callback(name): add_callback("on_crossover") mutation_parameters = [f"Offspring: {offspring_shape}"] - if ga_instance.mutation_type is None: - add_stage("mutation", "Mutation bypassed", "bypass", - parameters=["Keep offspring unchanged", f"Offspring: {offspring_shape}"]) - else: + if ga_instance.mutation_type is not None: if ga_instance.mutation_type in ("random", "adaptive", "polynomial"): if ga_instance.mutation_type == "polynomial": probability = ga_instance.mutation_probability @@ -157,10 +151,10 @@ def add_callback(name): retention_text = f"Keep {ga_instance.keep_parents} parent(s)" else: retention_text = "Keep no parents or elite solutions" - add_stage("update_population", "Update population", + add_stage("update_population", "Update Population", parameters=[retention_text, f"Add {ga_instance.num_offspring} offspring", f"Population: {population_shape}"]) - add_stage("generation_fitness", "Evaluate updated population", handler=ga_instance.fitness_func, + add_stage("generation_fitness", "Evaluate Updated Population", handler=ga_instance.fitness_func, parameters=fitness_parameters) add_callback("on_generation") @@ -171,14 +165,14 @@ def add_callback(name): for criterion in ga_instance.stop_criteria: stopping_parameters.append("_".join(str(value) for value in criterion)) if stopping_parameters: - add_stage("early_stop", "Stop early?", "decision", - parameters=stopping_parameters) + add_stage("early_stop", "Stop Early?", "decision", + parameters=["Any condition below:"] + stopping_parameters) last_generation_stage = stages[-1]["id"] - add_stage("finalize", "Finalize results", + add_stage("finalize", "Finalize Results", parameters=["Refresh final parents and elitism", "Record best solution fitness"]) add_callback("on_stop") - add_stage("complete", "Run complete", "end") + add_stage("complete", "Run Complete", "end") connections = [] for source, target in zip(stages, stages[1:]): @@ -192,7 +186,7 @@ def add_callback(name): connections.append({"source": "generation_check", "target": "finalize", "label": "No", "route": "finish"}) connections.append({"source": last_generation_stage, "target": "generation_check", - "label": "No" if stopping_parameters else "Next generation", + "label": "No" if stopping_parameters else "Next Generation", "route": "repeat"}) if stopping_parameters: connections.append({"source": "early_stop", "target": "finalize", @@ -213,14 +207,8 @@ def add_callback(name): gene_type_text = (gene_type_names[0] if ga_instance.gene_type_single else "Per gene: " + _lifecycle_parameter_text(gene_type_names)) configuration.extend([("Population", population_shape), - ("Generations per run", str(ga_instance.num_generations)), - ("Gene type", gene_type_text)]) - if ga_instance.stop_criteria is not None: - criteria_text = ["_".join(str(value) for value in criterion) - for criterion in ga_instance.stop_criteria] - configuration.append(("Stop when any criterion is met", ", ".join(criteria_text))) - if ga_instance.on_generation is not None: - configuration.append(("Callback stop", 'on_generation() returns "stop"')) + ("Generations Per Run", str(ga_instance.num_generations)), + ("Gene Type", gene_type_text)]) if ga_instance.last_generation_fitness is not None: first_fitness = ga_instance.last_generation_fitness[0] objectives = len(first_fitness) if isinstance(first_fitness, (list, tuple, numpy.ndarray)) else 1 @@ -228,20 +216,20 @@ def add_callback(name): else: configuration.append(("Objectives", "Known after fitness evaluation")) if ga_instance.gene_space is not None: - configuration.append(("Gene space", _lifecycle_parameter_text(ga_instance.gene_space))) + configuration.append(("Gene Space", _lifecycle_parameter_text(ga_instance.gene_space))) else: - configuration.append(("Initial population range", _lifecycle_parameter_text( + configuration.append(("Initial Population Range", _lifecycle_parameter_text( (ga_instance.init_range_low, ga_instance.init_range_high)))) if ga_instance.gene_constraint is not None: constraint_count = sum(constraint is not None for constraint in ga_instance.gene_constraint) - configuration.append(("Gene constraints", f"{constraint_count} constrained gene(s)")) - configuration.append(("Allow duplicate genes", str(ga_instance.allow_duplicate_genes))) + configuration.append(("Gene Constraints", f"{constraint_count} constrained gene(s)")) + configuration.append(("Allow Duplicate Genes", str(ga_instance.allow_duplicate_genes))) if ga_instance.parallel_processing is not None: - configuration.append(("Parallel fitness evaluation", _lifecycle_parameter_text(ga_instance.parallel_processing))) + configuration.append(("Parallel Fitness Evaluation", _lifecycle_parameter_text(ga_instance.parallel_processing))) if ga_instance.random_seed is not None: - configuration.append(("Random seed", str(ga_instance.random_seed))) - configuration.extend([("Save solutions", str(ga_instance.save_solutions)), - ("Save best solutions", str(ga_instance.save_best_solutions))]) + configuration.append(("Random Seed", str(ga_instance.random_seed))) + configuration.extend([("Save Solutions", str(ga_instance.save_solutions)), + ("Save Best Solutions", str(ga_instance.save_best_solutions))]) return {"stages": stages, "connections": connections, "configuration": configuration} @@ -293,7 +281,6 @@ def wrap_text(text, width_inches, text_font_size, weight="normal"): "population": ("#e8eef9", "#4773ba"), "callback": ("#e6f5ef", "#26866c"), "decision": ("#fff4da", "#b38325"), - "bypass": ("#f1f3f5", "#8492a3"), "end": ("#253e65", "#253e65")} text_color = "#25354b" arrow_color = "#718198" @@ -307,17 +294,16 @@ def wrap_text(text, width_inches, text_font_size, weight="normal"): current_top = 0.82 + 0.27 * len(title_lines) for stage in lifecycle["stages"]: - title_text = wrap_text(stage["title"], stage_width - 0.4, font_size, "bold") + # Put decision questions and their conditions inside the middle + # half of the diamond, where the sloping sides leave room for text. + text_width = stage_width / 2 - 0.2 if stage["kind"] == "decision" else stage_width - 0.4 + title_text = wrap_text(stage["title"], text_width, font_size, "bold") detail_lines = [] - # Decision settings live in the configuration panel. Keeping - # just the question in each diamond avoids crowded corners. - if stage["kind"] != "decision": - for detail in stage["details"]: - detail_lines.extend(wrap_text(detail, stage_width - 0.4, font_size * 0.88)) + for detail in stage["details"]: + detail_lines.extend(wrap_text(detail, text_width, font_size * 0.88)) stage_height = 0.30 + 0.20 * len(title_text) + 0.17 * len(detail_lines) if stage["kind"] == "decision": - # A broad diamond keeps its text inside the sloping edges. - stage_height = max(0.95, stage_height + 0.45) + stage_height = max(0.95, 2 * stage_height) stage_positions[stage["id"]] = {"top": current_top, "bottom": current_top + stage_height, "center": current_top + stage_height / 2, "height": stage_height} @@ -340,7 +326,7 @@ def wrap_text(text, width_inches, text_font_size, weight="normal"): axes.axis("off") axes.text(0.7, 0.28, "\n".join(title_lines), fontsize=font_size * 1.4, weight="bold", color=text_color, va="top", parse_math=False) - axes.text(0.7, current_top + 0.12, "Configured lifecycle | Operators / callbacks / decisions", + axes.text(0.7, current_top + 0.12, "Configured Lifecycle | Operators / Callbacks / Decisions", fontsize=font_size * 0.82, color=arrow_color, va="top", parse_math=False) for stage, title_text, detail_lines in wrapped_stages: @@ -356,8 +342,7 @@ def wrap_text(text, width_inches, text_font_size, weight="normal"): card = FancyBboxPatch((stage_center - stage_width / 2, position["top"]), stage_width, position["height"], boxstyle="round,pad=0,rounding_size=0.10", - facecolor=fill_color, edgecolor=border_color, linewidth=1.2, - linestyle="--" if stage["kind"] == "bypass" else "-") + facecolor=fill_color, edgecolor=border_color, linewidth=1.2) axes.add_patch(card) text_top = position["center"] - (0.20 * len(title_text) + 0.17 * len(detail_lines)) / 2 stage_text_color = "#ffffff" if stage["kind"] == "end" else text_color diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index e2b6266d..1f493301 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -47,8 +47,10 @@ def plot_lifecycle(self, font_size : numeric Positive font size. The figure scales with the font size. show_parameters : bool - If True, include stage parameters and a configuration - panel. If False, show a compact chart with handler names. + If True, include stage parameters, decision conditions, + and a configuration panel. If False, show a compact chart + with handler names. Disabled operators and unset callbacks + are omitted in either view. save_dir : str or None If set, save the figure to this path. The extension determines the format, for example SVG, PNG, or PDF. diff --git a/tests/test_plot_lifecycle.py b/tests/test_plot_lifecycle.py index 14da0a9f..75390cc9 100644 --- a/tests/test_plot_lifecycle.py +++ b/tests/test_plot_lifecycle.py @@ -72,7 +72,7 @@ def on_generation(ga_instance): matplt.close(fig) -def test_lifecycle_callback_order_matches_execution_with_bypassed_operators(): +def test_lifecycle_callback_order_matches_execution_with_disabled_operators(): events = [] def fitness_func_recorded(ga_instance, solution, solution_idx): @@ -110,8 +110,8 @@ def on_stop(ga_instance, population_fitness): on_generation=on_generation, on_stop=on_stop) lifecycle = _describe_lifecycle(ga_instance) stages = {stage["id"]: stage for stage in lifecycle["stages"]} - assert stages["crossover"]["kind"] == "bypass" - assert stages["mutation"]["kind"] == "bypass" + assert "crossover" not in stages + assert "mutation" not in stages assert stages["generation_fitness"]["details"][0] == "fitness_func_recorded()" ga_instance.run() @@ -209,7 +209,7 @@ def fitness_func_multi(ga_instance, solution, solution_idx): labels = figure_text(fig) assert "Population fitness: (8, 2)" in labels assert "Known after fitness evaluation" not in labels - assert ("Prepare NSGA-III reference points" in labels) == (parent_selection_type == "nsga3") + assert ("Prepare NSGA-III Reference Points" in labels) == (parent_selection_type == "nsga3") assert ga_instance.num_fitness_evaluations == original_evaluation_count numpy.testing.assert_array_equal(ga_instance.last_generation_fitness, original_fitness) finally: @@ -263,6 +263,93 @@ def test_lifecycle_polynomial_mutation_and_sbx_parameters(): matplt.close(fig) +@pytest.mark.parametrize("crossover_type,mutation_type", [ + (None, None), (None, "random"), ("single_point", None), ("single_point", "random"), +]) +def test_lifecycle_only_shows_enabled_operators(crossover_type, mutation_type): + ga_instance = create_ga_instance(crossover_type=crossover_type, mutation_type=mutation_type) + lifecycle = _describe_lifecycle(ga_instance) + stage_identifiers = {stage["id"] for stage in lifecycle["stages"]} + assert ("crossover" in stage_identifiers) == (crossover_type is not None) + assert ("mutation" in stage_identifiers) == (mutation_type is not None) + # Skipping operators must reconnect the remaining stages without + # leaving an arrow to a block that is no longer drawn. + for connection in lifecycle["connections"]: + assert connection["source"] in stage_identifiers + assert connection["target"] in stage_identifiers + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig).splitlines() + assert ("Crossover" in labels) == (crossover_type is not None) + assert ("Mutation" in labels) == (mutation_type is not None) + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("with_callback,stop_criteria", [ + (False, None), (True, None), (False, ["reach_20", "saturate_3"]), + (True, ["reach_20", "time_10", "evaluations_100"]), +]) +def test_lifecycle_stop_conditions_appear_inside_the_decision(with_callback, stop_criteria): + def on_generation(ga_instance): + # Drawing cannot predict this callback's result; it should + # describe the possible stop even when the function returns None. + return None + + ga_instance = create_ga_instance(on_generation=on_generation if with_callback else None, + stop_criteria=stop_criteria) + fig = ga_instance.plot_lifecycle(show=False) + try: + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + axes = fig.axes[0] + decisions = [card for card in axes.patches if isinstance(card, matplotlib.patches.Polygon)] + assert len(decisions) == (2 if with_callback or stop_criteria else 1) + if not with_callback and not stop_criteria: + assert "Stop Early?" not in figure_text(fig) + return + stop_decision = decisions[-1] + decision_path = stop_decision.get_path().transformed(stop_decision.get_transform()) + decision_texts = [text for text in axes.texts + if stop_decision.get_window_extent(renderer).contains( + *text.get_window_extent(renderer).get_points()[0])] + labels = "\n".join(text.get_text().replace("\n", " ") for text in decision_texts) + assert "Stop Early?" in labels + assert ('on_generation() returns "stop"' in labels) == with_callback + for criterion in ga_instance.stop_criteria or []: + assert "_".join(str(value) for value in criterion) in labels + for text in decision_texts: + text_bounds = text.get_window_extent(renderer) + corners = [(text_bounds.x0, text_bounds.y0), (text_bounds.x1, text_bounds.y0), + (text_bounds.x0, text_bounds.y1), (text_bounds.x1, text_bounds.y1)] + assert decision_path.contains_points(corners).all(), text.get_text() + finally: + matplt.close(fig) + + +def test_lifecycle_titles_use_capital_initials_and_preserve_method_names(): + def on_generation(ga_instance): + return None + + ga_instance = create_ga_instance(on_generation=on_generation) + lifecycle = _describe_lifecycle(ga_instance) + for stage in lifecycle["stages"]: + if stage["kind"] == "callback": + assert stage["title"] == stage["id"] + "()" + else: + assert all(word[0].isupper() for word in stage["title"].split()) + for label, value in lifecycle["configuration"]: + assert all(word[0].isupper() for word in label.split()) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "fitness_func()" in labels + assert "on_generation()" in labels + assert "On_Generation()" not in labels + finally: + matplt.close(fig) + + @pytest.mark.parametrize("show_parameters,font_size", [(True, 11), (False, 16)]) def test_lifecycle_long_names_fit_in_the_chart(show_parameters, font_size): def fitness_func_with_long_name(ga_instance, solution, solution_idx): From cffd6ffff2051a2d85eae767afa5d7357b2a0428 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 19:12:16 -0400 Subject: [PATCH 03/22] Unify duplicate-gene repair across the GA lifecycle Repair finite spaces through replacement chains using each destination gene's type, range, and constraints. Reuse the same repair for initialization, operators, callbacks, and population growth while retaining the existing helper signatures. Add regression coverage, restore chain tests, and document sampling limits and the new example. --- docs/source/gene_values.md | 91 +-- docs/source/pygad.md | 22 +- docs/source/releases.md | 6 +- docs/source/user_defined_operators.md | 2 + examples/example_duplicate_gene_repair.py | 38 + pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 332 ++++---- pygad/helper/unique.py | 927 +++++++++------------- pygad/pygad.py | 2 +- pygad/utils/__init__.py | 2 +- pygad/utils/crossover.py | 74 +- pygad/utils/engine.py | 56 +- pygad/utils/mutation.py | 96 +-- pygad/utils/validation.py | 38 +- pygad/visualize/__init__.py | 2 +- tests/test_allow_duplicate_genes.py | 97 +-- tests/test_duplicate_gene_repair.py | 339 ++++++++ 17 files changed, 1031 insertions(+), 1095 deletions(-) create mode 100644 examples/example_duplicate_gene_repair.py create mode 100644 tests/test_duplicate_gene_repair.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 0cbe2566..43a0ff55 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -228,13 +228,11 @@ Then the value of the first gene in the passed solution is `1`. By filtering the Sometimes it is normal for PyGAD to fail to find a gene value that satisfies the constraint. For example, if the possible gene values are only `[20,30,40]` and the gene constraint restricts the values to be greater than 50, then it is impossible to meet the constraint. -For some other cases, the constraint can be met but with some changes. For example, increasing the range from which a value is sampled. If the `gene_space` is used and assigned `range(10)`, then the gene constraint can be met by using `range(50)` so that we can find values greater than 50. +For some other cases, the constraint can be met but with some changes. For example, increasing the range from which a value is sampled. If the `gene_space` is used and assigned `range(10)`, then the gene constraint can be met by using `range(100)` so that we can find values greater than 50. -Even if the gene space is already assigned `range(1000)`, it might still not find values that meet the constraints. This is because PyGAD samples a number of values equal to the `sample_size` parameter which defaults to *100*. +Finite gene spaces, such as `range(1000)`, provide their full candidate list for constraint checks. When candidates come from random sampling, a larger `sample_size` can increase the chance of finding a value that meets a narrow constraint. -Out of the range of *1000* numbers, all the 100 values might not be satisfying the constraint. This issue could be solved by simply assigning a larger value for the `sample_size` parameter. - -> PyGAD does not yet handle the **dependencies** among the genes in the `gene_constraint` parameter. +> Initialization and ordinary mutation apply gene constraints sequentially. They do not determine the dependency order among the genes automatically. > > This is an example where gene 0 depends on gene 1. To efficiently enforce the constraints, the constraint for gene 1 must be enforced first (if not `None`) then the constraint for gene 0. > @@ -248,6 +246,8 @@ Out of the range of *1000* numbers, all the 100 values might not be satisfying t > > PyGAD applies constraints sequentially, starting from the first gene to the last. To ensure correct behavior when genes depend on each other, structure your GA problem so that if gene X depends on gene Y, then gene Y appears earlier in the chromosome (solution) than gene X. As a result, its gene constraint will be earlier in the list. +Duplicate repair also checks all constraints against complete candidate solutions before accepting changes. Its additional search for dependent constraints is bounded by `sample_size`; this does not reorder the general initialization or mutation constraint checks. + ### Full Example For a full example, please check the [`examples/example_gene_constraint.py` script](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_gene_constraint.py). @@ -270,11 +270,17 @@ If the objective is to find a unique value or enforce the gene constraint, then Sometimes 100 values is not enough and PyGAD sometimes fails to find a good value. In this case, it is highly recommended to increase the `sample_size` parameter. This is to create a larger sample to increase the chance of finding a value that meets our objectives. +For duplicate repair, finite spaces are considered in full. These include lists, tuples, NumPy arrays, `range` objects, stepped dictionaries, and integer random ranges. Increasing `sample_size` is useful for continuous candidates and constraints that depend on other genes; it is not needed to explore a larger finite space. + +When replacement chains do not satisfy a dependent constraint, PyGAD also tries alternative complete assignments. This additional search considers up to `sample_size * num_genes` tentative gene assignments. A larger value allows more alternatives to be checked. The limit prevents arbitrary constraint functions from requiring an unbounded combinatorial search. + ## Prevent Duplicates in Gene Values In [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0), a new bool parameter called `allow_duplicate_genes` is supported to control whether duplicates are supported in the chromosome or not. In other words, whether 2 or more genes might have the same exact value. -If `allow_duplicate_genes=True` (which is the default case), genes may have the same value. If `allow_duplicate_genes=False`, then no 2 genes will have the same value given that there are enough unique values for the genes. +If `allow_duplicate_genes=True` (which is the default case), genes may have the same value. If `allow_duplicate_genes=False`, PyGAD tries to give each gene a different numeric value within its solution. Duplicates are checked after applying the configured gene types and rounding. Mixed numeric types are compared by their exact stored values; for example, integer 1 and float 1.0 duplicate each other. + +The same repair is used for generated and manually supplied initial populations, crossover, mutation, and NSGA-III population growth. It also checks outputs from custom crossover and mutation functions and the `on_crossover` and `on_mutation` callbacks. Duplicate solutions in different population rows are allowed; this parameter controls repeated values within a single row. The next code gives an example to use the `allow_duplicate_genes` parameter. A callback generation function is implemented to print the population after each generation. @@ -398,79 +404,44 @@ Generation 5 [1 2 4 3]] ``` -You should give enough values for the genes so that PyGAD can find an alternative when a gene value duplicates another gene. - -If PyGAD fails to find a unique gene value while there is still room to find one, then set the `sample_size` parameter to a larger value. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. +(solve-duplicates-using-a-third-gene)= -### Limitation +### Repair through Other Genes -There might be 2 duplicate genes where changing either of the 2 duplicating genes will not solve the problem. For example, if `gene_space=[[3, 0, 1], [4, 1, 2], [0, 2], [3, 2, 0]]` and the solution is `[3 2 0 0]`, then the values of the last 2 genes duplicate. There are no possible changes in the last 2 genes to solve the problem. +There must be enough distinct values that can be assigned to the individual genes. Counting all values in the combined space is not sufficient. For example, `gene_space=[[0], [0], [1, 2]]` cannot give the first two genes different values. -This problem can be solved by randomly changing one of the non-duplicating genes to make room for a unique value in one of the 2 duplicating genes. For example, by changing the second gene from 2 to 4, then any of the last 2 genes can take the value 2 and solve the duplicates. The resultant gene is then `[3 4 2 0]`. But this option is not yet supported in PyGAD. +Repair first tries unused values. If a needed value is already used by another gene, PyGAD looks for an alternative for that gene. This can involve either duplicate occurrence and a chain of several replacements. The chain is applied together so no temporary duplicate is treated as a finished solution. -### Solve Duplicates using a Third Gene - -When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happens that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene with another. In [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0), the duplicates are solved by looking for a third gene that helps solve them. The following examples explain how it works. - -Example 1: - -Let's assume that this gene space is used and there is a solution with 2 duplicate genes with the same value 4. +For example: ```python -Gene space: [[2, 3], - [3, 4], - [4, 5], - [5, 6]] -Solution: [3, 4, 4, 5] +gene_space = [[0, 1], [1, 2], [2, 3], [0]] +initial_population = [[0, 1, 2, 0], [0, 1, 2, 0]] ``` -By checking the gene space, the second gene can have the values `[3, 4]` and the third gene can have the values `[4, 5]`. To solve the duplicates, we change the value of one of these 2 genes. +The last gene can only keep 0. Repair moves the third gene from 2 to 3, the second gene from 1 to 2, and the first gene from 0 to 1. The repaired solution is `[1, 2, 3, 0]`. Each replacement belongs to its destination gene's space. -If the value of the second gene changes from 4 to 3, then it will duplicate the first gene. If we change the value of the third gene from 4 to 5, then it will duplicate the fourth gene. In short, simply selecting a different value for either the second or third gene will introduce new duplicate genes. +This behavior also handles third-gene repairs, such as changing `[3, 4, 4, 5]` into `[2, 3, 4, 5]` for `gene_space=[[2, 3], [3, 4], [4, 5], [5, 6]]`. -When there are 2 duplicate genes but there is no way to solve their duplicates, then the solution is to change a third gene that makes a room to solve the duplicates between the 2 genes. +A runnable example is available in [`examples/example_duplicate_gene_repair.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_duplicate_gene_repair.py). -In our example, duplicates between the second and third genes can be solved by, for example,: +### Ranges, Types, and Constraints -* Changing the first gene from 3 to 2 then changing the second gene from 4 to 3. -* Or changing the fourth gene from 5 to 6 then changing the third gene from 4 to 5. +Each replacement uses its own gene's range, type, precision, and constraint. Initialization uses `init_range_low` and `init_range_high` with replacement. Random and adaptive mutation use `random_mutation_min_val` and `random_mutation_max_val`, respecting `mutation_by_replacement`. SBX crossover and polynomial mutation use their per-gene initialization bounds for repair. -Generally, this is how to solve such duplicates: +An explicit gene-space value replaces the gene regardless of `mutation_by_replacement`. A nested space entry of `None` follows the random-mutation mode. A `None` inside a list, tuple, or array supplies fresh replacement candidates from the relevant initialization or mutation range. It is not frozen into a single cached initial value. -1. For any duplicate gene **GENE1**, select another value. -2. Check which other gene **GENEX** has duplicate with this new value. -3. Find if **GENEX** can have another value that will not cause any more duplicates. If so, go to step 7. -4. If all the other values of **GENEX** will cause duplicates, then try another gene **GENEY**. -5. Repeat steps 3 and 4 until exploring all the genes. -6. If there is no way to solve the duplicates, then we have to keep the duplicate value. -7. If a value for a gene **GENEM** is found that will not cause more duplicates, then use this value for the gene **GENEM**. -8. Replace the value of the gene **GENE1** by the old value of the gene **GENEM**. This solves the duplicates. +Constraints on other genes may depend on a replaced position. PyGAD checks all constraints against a complete repaired solution before accepting it. If the repair would violate another gene's constraint, that assignment is rejected and alternatives are considered. -This is an example to solve the duplicate for the solution `[3, 4, 4, 5]`: +Manually supplied values and values inherited from parents are kept when possible. Supplying a population does not automatically replace every value outside `gene_space` or the initialization range. New repair values follow the configured destination space or range. -1. Let's use the second gene with value 4. Because the space of this gene is `[3, 4]`, then the only other value we can select is 3. -2. The first gene also has the value 3. -3. The first gene has another value 2 that will not cause more duplicates in the solution. Then go to step 7. -4. Skip. -5. Skip. -6. Skip. -7. The value of the first gene 3 will be replaced by the new value 2. The new solution is [2, 4, 4, 5]. -8. Replace the value of the second gene 4 by the old value of the first gene which is 3. The new solution is [2, 3, 4, 5]. The duplicate is solved. +### When Duplicates Remain -Example 2: - -```python -Gene space: [[0, 1], - [1, 2], - [2, 3], - [3, 4]] -Solution: [1, 2, 2, 3] -``` +Finite spaces without dependent constraints are searched completely, including replacement chains. If no unique assignment exists, repair keeps as many distinct values as possible and reports the remaining duplicates with warnings unless `suppress_warnings=True`. -The quick summary is: +Continuous ranges are sampled, so finding every possible value cannot be guaranteed. Rounding or a narrow numeric type can also reduce the number of distinct available values. Increase `sample_size` when continuous sampling or the additional search for dependent constraints needs more candidates or alternatives. If no constraint-valid improvement is found, existing values are retained instead of accepting a repair that violates a constraint. -* Change the value of the first gene from 1 to 0. The solution becomes [0, 2, 2, 3]. -* Change the value of the second gene from 2 to 1. The solution becomes [0, 1, 2, 3]. The duplicate is solved. +Changes made directly to the population in other callbacks remain the responsibility of those callbacks. Custom operators still need to generate meaningful values for the problem; duplicate repair is not a general validator of every custom operator output. ## More about the `gene_type` Parameter diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 6499f17e..1553b4be 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -165,7 +165,7 @@ The upper value of the random range from which the gene values in the initial po :::{dropdown} `allow_duplicate_genes=True`: Allow repeated values within a solution. :animate: fade-in-slide-down -Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. +Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, PyGAD tries to give each gene a different numeric value after conversion and rounding. Repair can follow chains of replacements using each destination gene's space, range, type, precision, and constraint. If no usable alternative is found, duplicates remain with a warning unless warnings are suppressed. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See {ref}`Mutation Methods `. ::: @@ -177,6 +177,8 @@ The size of the sample of candidate values PyGAD draws when it needs to pick a g It is useful when `allow_duplicate_genes=False` or `gene_constraint` is used. If PyGAD cannot find a unique value or a value that meets a constraint, increase this parameter. +Duplicate repair considers finite spaces in full. For constraints depending on other genes, an additional search checks up to `sample_size * num_genes` tentative assignments when replacement chains do not satisfy all constraints. + Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. ::: @@ -580,7 +582,7 @@ Constructor settings and user callables are stored as instance attributes, with - `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). +- `gene_space_unpacked`: A snapshot of the converted finite values and continuous samples in `gene_space`. Generation reads the original space so `None` entries remain random. 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 @@ -763,15 +765,21 @@ The {ref}`complete fitness dispatch reference ` documents pa - `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. +- `solve_duplicate_genes(solution, build_initial_pop=False, ...)`: Shared repair for a solution using each gene's space, range, type, precision, and constraint. Returns the repaired copy, remaining duplicate indices, and their count. Can follow chains of replacements. +- `solve_duplicate_genes_in_population(population, build_initial_pop=False)`: Convert and round population rows before applying the shared repair. +- `get_duplicate_gene_indices(solution)`: Return the indices after the first occurrence of each repeated value. +- `solution_satisfies_gene_constraints(solution)`: Check every constraint against a complete candidate solution. +- `get_gene_space_values(...)`: Return converted finite candidates or fresh continuous candidates for one gene. +- `is_gene_value_in_space(...)`: Check a swap candidate against the original finite space or continuous bounds. +- `solve_duplicate_genes_randomly(...)`: Compatibility helper for the shared repair using explicit random ranges. +- `solve_duplicate_genes_by_space(...)`: Compatibility helper for the shared repair using `gene_space`. +- `solve_duplicates_deeply(...)`: Compatibility helper for replacement-chain repair. Returns `None` when no duplicates are resolved. - `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. +- `select_unique_value(...)`: Pick an unused candidate when possible, otherwise keep the current value. +- `find_two_duplicates(solution, gene_space_unpacked)`: Find a duplicated gene with alternative values in its space. - `unpack_gene_space(...)`: Materialize the unpacked `gene_space` (used to build `gene_space_unpacked`). #### Saving, Loading, and Reporting diff --git a/docs/source/releases.md b/docs/source/releases.md index aead10cb..6ab92a0e 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -738,6 +738,10 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). 12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). 13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. -14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. +14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. + +15. Duplicate-gene repair now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. +16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.1` and `1.5.3`. +17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index 61b6c5e7..e990ae2a 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -10,6 +10,8 @@ This way, the user can only use the built-in functions for each of these operato Starting from [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0), the user can create a custom crossover, mutation, and parent selection operators and assign these functions to the above parameters. Thus, a new operator can be plugged easily into the [PyGAD Lifecycle](https://pygad.readthedocs.io/en/latest/lifecycle.html#life-cycle-of-pygad). +When `allow_duplicate_genes=False`, PyGAD applies its shared duplicate repair to custom crossover and mutation outputs after the corresponding callback has finished. Values are converted and rounded before repair. This also handles duplicate values returned or changed in place by `on_crossover` and `on_mutation`. If the configured spaces, ranges, or constraints leave no usable alternative, duplicates can remain with a warning. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). + This is a sample code that does not use any custom function. ```python diff --git a/examples/example_duplicate_gene_repair.py b/examples/example_duplicate_gene_repair.py new file mode 100644 index 00000000..8fb0826c --- /dev/null +++ b/examples/example_duplicate_gene_repair.py @@ -0,0 +1,38 @@ +"""Prevent duplicate genes when repair needs a chain of replacements.""" + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return sum((index + 1) * value for index, value in enumerate(solution)) + + +def on_generation(ga_instance): + for solution in ga_instance.population: + assert len(set(solution)) == len(solution) + assert all(value in space for value, space in zip(solution, ga_instance.gene_space)) + + +gene_space = [[0, 1], [1, 2], [2, 3], [0]] +initial_population = [[0, 1, 2, 0], [0, 1, 2, 0]] + +ga_instance = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_space=gene_space, + gene_type=int, + allow_duplicate_genes=False, + mutation_num_genes=1, + on_generation=on_generation, + random_seed=1) + +# The last gene can only keep 0. Repair moves the first three genes to +# 1, 2, and 3, making room for 0 without leaving any gene's own space. +print("Initial Population") +print(ga_instance.initial_population) + +ga_instance.run() + +print("Final Population") +print(ga_instance.population) diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index f905dd01..286cc309 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.0" \ No newline at end of file +__version__ = "1.4.1" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index c9125289..8b7585d4 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -387,7 +387,7 @@ def change_gene_dtype_and_round(self, if round_precision is None: pass else: - gene_value = numpy.round(gene_value, round_precision) + gene_value = numpy.round(numpy.asarray(gene_value, dtype=float), round_precision) gene_value_new = numpy.asarray(gene_value, dtype=dtype) gene_value_new = gene_value_new[0] @@ -472,7 +472,8 @@ def validate_gene_constraint_callable_output(self, def filter_gene_values_by_constraint(self, values, solution, - gene_idx): + gene_idx, + warn=True): """ Pass a list of candidate values through the user-supplied gene constraint callable and return the subset that satisfies @@ -487,6 +488,9 @@ def filter_gene_values_by_constraint(self, callable so it can look at the other genes if needed. gene_idx : int Index of the gene inside ``solution``. + warn : bool + Warn if no candidate satisfies the constraint. Repair uses + False while exploring alternatives and warns about its final result. Returns ------- @@ -522,7 +526,7 @@ def filter_gene_values_by_constraint(self, pass else: # No value found for the current gene that satisfies the constraint. - if not self.suppress_warnings: warnings.warn(f"Failed to find a value that satisfies its gene constraint for the gene at index {gene_idx} with value {solution[gene_idx]} at generation {self.generations_completed}.") + if warn and not self.suppress_warnings: warnings.warn(f"Failed to find a value that satisfies its gene constraint for the gene at index {gene_idx} with value {solution[gene_idx]} at generation {getattr(self, 'generations_completed', 0)}.") return None return filtered_values @@ -613,184 +617,167 @@ def get_initial_population_range(self, gene_index): range_max = self.init_range_high[gene_index] return range_min, range_max - def generate_gene_value_from_space(self, - gene_idx, - mutation_by_replacement, - solution=None, - gene_value=None, - sample_size=1): + def get_gene_space_values(self, gene_idx, gene_value=None, + mutation_by_replacement=True, sample_size=100, + range_min=None, range_max=None): """ - 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. + Generate converted candidates for one gene from its original space. + Lists, tuples, arrays, ranges, fixed values, and stepped dictionaries + keep all their finite values. Continuous entries are sampled, and + None entries draw fresh values from the appropriate per-gene range. 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. + Index of the gene whose space and type are used. 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. + Current value, or None when building the initial population. + mutation_by_replacement : bool + Replace the gene or add an offset for a None space entry. + Explicit space values always replace the gene. + sample_size : int or None + Number of samples for continuous entries. None uses + ``self.sample_size``. Finite entries are returned in full. + range_min, range_max : numeric or None + Optional bounds for None entries when unpacking a space. Returns ------- - value : numeric or numpy.ndarray - A single value when ``sample_size=1``; otherwise an - array of up to ``sample_size`` values. + values : numpy.ndarray + Distinct candidates after conversion and rounding. """ - + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + dtype = self.get_gene_dtype(gene_idx) + if sample_size is None: + sample_size = self.sample_size if gene_value is None: - # Use the initial population range. - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) + if range_min is None: + range_min, range_max = self.get_initial_population_range(gene_idx) + mutation_by_replacement = True else: - # Use the mutation range. - range_min, range_max = self.get_random_mutation_range(gene_idx) - - if self.gene_space_nested: - # Returning the current gene space from the 'gene_space' attribute. - # It is used to determine the way of selecting the next gene value: - # 1) List/NumPy Array: Whether it has only one value, multiple values, or one of its values is None. - # 2) Fixed Numeric Value - # 3) None - # 4) Dict: Whether the dict has the key `step` or not. - if type(self.gene_space[gene_idx]) in [numpy.ndarray, list]: - # Get the gene space from the `gene_space_unpacked` property because it undergoes data type change and rounded. - curr_gene_space = self.gene_space_unpacked[gene_idx].copy() - elif type(self.gene_space[gene_idx]) in pygad.GA.supported_int_float_types: - # Get the gene space from the `gene_space_unpacked` property because it undergoes data type change and rounded. - curr_gene_space = self.gene_space_unpacked[gene_idx] + if range_min is None: + range_min, range_max = self.get_random_mutation_range(gene_idx) + + if space is None: + values = self.generate_gene_value_randomly( + range_min, range_max, gene_value, gene_idx, + mutation_by_replacement, + sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + elif type(space) is dict: + if 'step' in space: + values = numpy.arange(space['low'], space['high'], space['step']) + elif dtype[0] in pygad.GA.supported_int_types: + # A continuous dictionary with fractional bounds can cast + # to an integer near either end, not just values on a grid + # starting at low. Include every representable integer. + lower, upper = sorted([space['low'], space['high']]) + first_value = int(numpy.trunc(lower)) + last_value = int(numpy.trunc(numpy.nextafter(float(upper), -numpy.inf))) + values = numpy.arange(first_value, last_value + 1) else: - curr_gene_space = self.gene_space[gene_idx] - - if type(curr_gene_space) in pygad.GA.supported_int_float_types: - # If the gene space is simply a single numeric value (e.g. 5), use it as the new gene value. - value_from_space = curr_gene_space - elif curr_gene_space is None: - # If the gene space is None, apply mutation by adding a random value between the range defined by the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. - rand_val = numpy.random.uniform(low=range_min, - high=range_max, - size=sample_size) - if mutation_by_replacement: - value_from_space = rand_val - else: - value_from_space = gene_value + rand_val - elif type(curr_gene_space) is dict: - # Selecting a value randomly from the current gene's space in the 'gene_space' attribute. - # The gene's space of type dict specifies the lower and upper limits of a gene. - if 'step' in curr_gene_space.keys(): - # When the `size` parameter is used, the numpy.random.choice() and numpy.random.uniform() functions return a NumPy array as the output even if the array has a single value (i.e. size=1). - # We have to return the output at index 0 to force a numeric value to be returned not an object of type numpy.ndarray. - # If numpy.ndarray is returned, then it will cause an issue later while using the set() function. - # Randomly select a value from a discrete range. - value_from_space = numpy.random.choice(numpy.arange(start=curr_gene_space['low'], - stop=curr_gene_space['high'], - step=curr_gene_space['step']), - size=sample_size) - else: - value_from_space = numpy.random.uniform(low=curr_gene_space['low'], - high=curr_gene_space['high'], - size=sample_size) - else: - # Selecting a value randomly from the current gene's space in the 'gene_space' attribute. - # If the gene space has only 1 value, then select it. The old and new values of the gene are identical. - if len(curr_gene_space) == 1: - value_from_space = curr_gene_space - else: - # Change the data type and round the generated values. - curr_gene_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=curr_gene_space) - - if gene_value is None: - # Just generate the value(s) without being added to the gene value specially when initializing the population. - value_from_space = curr_gene_space - else: - # If the gene space has more than 1 value, then select a new one that is different from the current value. - # To avoid selecting the current gene value again, remove it from the current gene space and do the selection. - value_from_space = list(set(curr_gene_space) - set([gene_value])) + values = numpy.random.uniform(space['low'], space['high'], size=sample_size) + elif type(space) in pygad.GA.supported_int_float_types: + values = [space] else: - # Selecting a value randomly from the global gene space in the 'gene_space' attribute. - # The gene's space of type dict specifies the lower and upper limits of a gene. - if type(self.gene_space) is dict: - # When the gene_space is assigned a dict object, then it specifies the lower and upper limits of all genes in the space. - if 'step' in self.gene_space.keys(): - value_from_space = numpy.random.choice(numpy.arange(start=self.gene_space['low'], - stop=self.gene_space['high'], - step=self.gene_space['step']), - size=sample_size) - else: - value_from_space = numpy.random.uniform(low=self.gene_space['low'], - high=self.gene_space['high'], - size=sample_size) - else: - curr_gene_space = list(self.gene_space).copy() - for idx in range(len(curr_gene_space)): - if curr_gene_space[idx] is None: - curr_gene_space[idx] = numpy.random.uniform(low=range_min, - high=range_max) - curr_gene_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=curr_gene_space) - - if gene_value is None: - # Just generate the value(s) without being added to the gene value specially when initializing the population. - value_from_space = curr_gene_space - else: - # If the space type is not of type dict, then a value is randomly selected from the gene_space attribute. - # To avoid selecting the current gene value again, remove it from the current gene space and do the selection. - value_from_space = list(set(curr_gene_space) - set([gene_value])) - - if len(value_from_space) == 0: + values = [value for value in space if value is not None] + if any(value is None for value in space): + random_values = self.generate_gene_value_randomly( + range_min, range_max, gene_value, gene_idx, + True, + sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + values.extend(numpy.atleast_1d(random_values)) + + values = self.change_gene_dtype_and_round(gene_idx, numpy.atleast_1d(values)) + if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: + # Rounding may reach the excluded upper bound. Such a value + # cannot be selected from this continuous space. + values = values[(values >= space['low']) & (values < space['high'])] + if len(values) == 0: + # A single sample can round to the upper bound. Use a + # representable in-range value instead of failing randomly. + lower = space['low'] + if dtype[1] is not None: + precision_step = 10.0 ** -dtype[1] + lower = numpy.ceil(lower / precision_step) * precision_step + value = self.change_gene_dtype_and_round(gene_idx, lower) + if space['low'] <= value < space['high']: + values = numpy.atleast_1d(value) + return numpy.unique(values) + + def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): + """ + Check a prospective swap against the destination's original space. + Continuous and None entries use their bounds rather than membership + in an inspection sample. Finite entries use converted values. + """ + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + dtype = self.get_gene_dtype(gene_idx) + if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: + return space['low'] <= gene_value < space['high'] + has_none = space is None + if type(space) in [list, tuple, numpy.ndarray, range]: + explicit_values = [value for value in space if value is not None] + explicit_values = self.change_gene_dtype_and_round(gene_idx, explicit_values) + if gene_value in explicit_values: + return True + has_none = any(value is None for value in space) + if has_none: + range_min, range_max = self.get_random_mutation_range(gene_idx) + replacement = self.mutation_by_replacement if space is None else True + if dtype[0] in pygad.GA.supported_int_types: + values = self.generate_gene_value_randomly( + range_min, range_max, current_gene_value, gene_idx, + replacement, sample_size=None) + return gene_value in values + if not replacement: + range_min += current_gene_value + range_max += current_gene_value + lower, upper = sorted([range_min, range_max]) + lower = self.change_gene_dtype_and_round(gene_idx, lower) + upper = self.change_gene_dtype_and_round(gene_idx, upper) + return lower <= gene_value <= upper + return gene_value in self.get_gene_space_values(gene_idx, current_gene_value) + + def generate_gene_value_from_space(self, gene_idx, mutation_by_replacement, + solution=None, gene_value=None, + sample_size=1): + """ + Generate values from the gene's space using its type and precision. + A single candidate is returned when sample_size=1; otherwise an + array is returned. Finite spaces are considered in full, while + continuous and None entries use sample_size random candidates. + With allow_duplicate_genes=False, single-value selection prefers + an unused value. If no alternative exists, keep the current value. + """ + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + if space is None and sample_size == 1: + # Traditional mutation of a None entry draws a continuous + # offset before conversion. Casting the offset to an integer + # first would bias additive mutation toward negative changes. if gene_value is None: - raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_idx}.") + range_min, range_max = self.get_initial_population_range(gene_idx) + mutation_by_replacement = True else: - # After removing the current gene value from the space, there are no more values. - # Then keep the current gene value. - value_from_space = gene_value - if sample_size > 1: - value_from_space = numpy.array([gene_value]) - elif sample_size == 1: - if self.allow_duplicate_genes == True: - # Select a value randomly from the current gene space. - value_from_space = random.choice(value_from_space) - else: - # We must check if the selected value will respect the allow_duplicate_genes parameter. - # Instead of selecting a value randomly, we have to select a value that will be unique if allow_duplicate_genes=False. - # Only select a value from the current gene space that is, hopefully, unique. - value_from_space = self.select_unique_value(gene_values=value_from_space, - solution=solution, - gene_index=gene_idx) - - # The gene space might be [None, 1, 7]. - # It might happen that the value None is selected. - # In this case, generate a random value out of the mutation range. - if value_from_space is None: - value_from_space = numpy.random.uniform(low=range_min, - high=range_max, - size=sample_size) + range_min, range_max = self.get_random_mutation_range(gene_idx) + random_value = numpy.random.uniform(range_min, range_max) + values = numpy.atleast_1d(self.mutation_change_gene_dtype_and_round( + random_value, gene_idx, gene_value, mutation_by_replacement)) else: - value_from_space = numpy.array(value_from_space) - - # Change the data type and round the generated values. - # It has to be called here for all the missed cases. - value_from_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=value_from_space) - if sample_size == 1 and type(value_from_space) not in pygad.GA.supported_int_float_types: - value_from_space = value_from_space[0] - - return value_from_space + values = self.get_gene_space_values(gene_idx, gene_value, + mutation_by_replacement, sample_size) + if gene_value is not None: + alternatives = values[values != gene_value] + if len(alternatives): + values = alternatives + else: + values = numpy.atleast_1d(gene_value) + if len(values) == 0: + raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_idx}.") + if sample_size == 1: + if self.allow_duplicate_genes or solution is None: + return random.choice(values) + return self.select_unique_value(values, solution, gene_idx) + return values def generate_gene_value_randomly(self, range_min, @@ -838,10 +825,15 @@ def generate_gene_value_randomly(self, gene_type = self.get_gene_dtype(gene_index=gene_idx) if gene_type[0] in pygad.GA.supported_int_types: - random_value = numpy.asarray(numpy.arange(range_min, - range_max, - step=step), - dtype=gene_type[0]) + if range_min == range_max: + random_value = numpy.asarray([range_min], dtype=gene_type[0]) + else: + if step > 0: + range_min, range_max = min(range_min, range_max), max(range_min, range_max) + random_value = numpy.asarray(numpy.arange(range_min, + range_max, + step=step), + dtype=gene_type[0]) if sample_size is None: # Keep all the values. pass @@ -859,7 +851,7 @@ def generate_gene_value_randomly(self, # Generating a random value. random_value = numpy.asarray(numpy.random.uniform(low=range_min, high=range_max, - size=sample_size), + size=1 if sample_size is None else sample_size), dtype=object) # Change the random mutation value data type. @@ -991,7 +983,7 @@ def get_valid_gene_constraint_values(self, sample_size=sample_size, step=step) # It returns None if no value found that satisfies the constraint. - values_filtered = self.filter_gene_values_by_constraint(values=values, + values_filtered = self.filter_gene_values_by_constraint(values=numpy.atleast_1d(values), solution=solution, gene_idx=gene_idx) return values_filtered diff --git a/pygad/helper/unique.py b/pygad/helper/unique.py index 435cc72f..3c4c09d3 100644 --- a/pygad/helper/unique.py +++ b/pygad/helper/unique.py @@ -2,608 +2,393 @@ The pygad.helper.unique module has helper methods to solve duplicate genes and make sure every gene is unique. """ +from collections import deque import numpy import warnings import random import pygad + class Unique: - def solve_duplicate_genes_randomly(self, - solution, - min_val, - max_val, - mutation_by_replacement, - gene_type, - sample_size=100): + def get_duplicate_gene_indices(self, solution): + """Return the indices after the first occurrence of each gene value.""" + seen_values = set() + duplicate_indices = set() + for gene_index, gene_value in enumerate(solution): + value_key = self._gene_value_key(gene_value) + if value_key in seen_values: + duplicate_indices.add(gene_index) + else: + seen_values.add(value_key) + return duplicate_indices + + def _gene_value_key(self, gene_value): + """Compare exact numeric values without NumPy scalar promotion.""" + if isinstance(gene_value, numpy.generic): + gene_value = gene_value.item() + # Preserve the treatment of repeated NaNs as duplicates. + if gene_value != gene_value: + return ('nan',) + return gene_value + + def solve_duplicate_genes_in_population(self, population, build_initial_pop=False): """ - Resolves duplicates in a solution by randomly selecting new values for the duplicate genes. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - min_val (int): The minimum value of the range to sample a number randomly. - max_val (int): The maximum value of the range to sample a number randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., int, float). - sample_size (int): The maximum number of random values to generate to find a unique value. - - Returns: - tuple: - list: The updated solution after attempting to resolve duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. + Convert and round a population before repairing each solution. + Used for initialization, population growth, and user-supplied + operator or callback outputs. Returns a repaired copy. """ - - new_solution = solution.copy() - - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - num_unsolved_duplicates = 0 - if len(not_unique_indices) > 0: - for duplicate_index in not_unique_indices: - dtype = self.get_gene_dtype(gene_index=duplicate_index) - - if type(min_val) in self.supported_int_float_types: - min_val_gene = min_val - max_val_gene = max_val - else: - min_val_gene = min_val[duplicate_index] - max_val_gene = max_val[duplicate_index] - - if dtype[0] in pygad.GA.supported_int_types: - temp_val = self.unique_int_gene_from_range(solution=new_solution, - gene_index=duplicate_index, - min_val=min_val_gene, - max_val=max_val_gene, - mutation_by_replacement=mutation_by_replacement, - gene_type=gene_type) - else: - temp_val = self.unique_float_gene_from_range(solution=new_solution, - gene_index=duplicate_index, - min_val=min_val_gene, - max_val=max_val_gene, - mutation_by_replacement=mutation_by_replacement, - gene_type=gene_type, - sample_size=sample_size) - - if temp_val in new_solution: - num_unsolved_duplicates = num_unsolved_duplicates + 1 - if not self.suppress_warnings: warnings.warn(f"Failed to find a unique value for gene with index {duplicate_index} whose value is {solution[duplicate_index]} at generation {self.generations_completed}. Consider adding more values in the gene space or use a wider range for initial population or random mutation.") + population = self.change_population_dtype_and_round(population) + for solution_index, solution in enumerate(population): + population[solution_index], _, _ = self.solve_duplicate_genes( + solution, build_initial_pop=build_initial_pop) + return population + + def solve_duplicate_genes(self, solution, build_initial_pop=False, + mutation_by_replacement=None, sample_size=None, + min_val=None, max_val=None, warn=True): + """ + Resolve duplicates using the space, type, precision, and range of + each gene. Existing unique values are kept whenever possible. + A chain of replacements can move an earlier gene to make room + for a later gene, including one whose space has a single value. + + Parameters + ---------- + solution : numpy.ndarray or list + The solution to repair. The input is not modified. + build_initial_pop : bool + Use initialization ranges and replacement when True. + Otherwise use the random-mutation ranges and mode. + mutation_by_replacement : bool or None + Override the mutation mode. None uses the GA setting. + sample_size : int or None + Number of candidates for continuous ranges. None uses + ``self.sample_size``. Finite spaces are searched in full. + min_val, max_val : numeric, iterable, or None + Optional range overrides for the compatibility helpers. + warn : bool + Issue warnings for duplicates left after all repair attempts. + + Returns + ------- + solution : numpy.ndarray + A copy of the solution after repair. + duplicate_indices : set + Indices that still duplicate earlier genes. + num_unsolved_duplicates : int + The number of remaining duplicate indices. + """ + new_solution = self.change_population_dtype_and_round([solution])[0] + duplicate_indices = self.get_duplicate_gene_indices(new_solution) + if not duplicate_indices: + return new_solution, duplicate_indices, 0 + + if sample_size is None: + sample_size = self.sample_size + if mutation_by_replacement is None: + mutation_by_replacement = self.mutation_by_replacement + if build_initial_pop: + mutation_by_replacement = True + + candidate_values = [] + for gene_index, gene_value in enumerate(new_solution): + dtype = self.get_gene_dtype(gene_index) + if self.gene_space is None: + if min_val is None: + if build_initial_pop: + range_min, range_max = self.get_initial_population_range(gene_index) + else: + range_min, range_max = self.get_random_mutation_range(gene_index) + elif type(min_val) in self.supported_int_float_types: + range_min, range_max = min_val, max_val else: - # Unique gene value found. - new_solution[duplicate_index] = temp_val - - # Update the list of duplicate indices after each iteration. - _, unique_gene_indices = numpy.unique(new_solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - # self.logger.info("not_unique_indices INSIDE", not_unique_indices) - - return new_solution, not_unique_indices, num_unsolved_duplicates - - def solve_duplicate_genes_by_space(self, - solution, - gene_type, - mutation_by_replacement, - sample_size=100, - build_initial_pop=False): - + range_min, range_max = min_val[gene_index], max_val[gene_index] + values = self.generate_gene_value_randomly( + range_min=range_min, range_max=range_max, + gene_value=gene_value, gene_idx=gene_index, + mutation_by_replacement=mutation_by_replacement, + sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + else: + values = self.get_gene_space_values( + gene_idx=gene_index, + gene_value=None if build_initial_pop else gene_value, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) + + # Compare converted values: rounding and casting can turn + # different candidates into the same numeric value. + values = list(dict.fromkeys(dtype[0](value) for value in numpy.atleast_1d(values))) + values = [value for value in values if value != gene_value] + random.shuffle(values) + # Keep manually supplied values and values inherited from parents. + # Only a replacement must come from the current domain. + candidate_values.append([gene_value] + values) + + # First try values satisfying each constraint in the current + # solution. This completely searches independent finite constraints. + # Keep the full domains for constraints depending on changed genes. + constrained_values = [] + for gene_index, values in enumerate(candidate_values): + if self.gene_constraint and self.gene_constraint[gene_index]: + selected_values = self.filter_gene_values_by_constraint( + numpy.array(values), new_solution, gene_index, warn=False) + dtype = self.get_gene_dtype(gene_index) + constrained_values.append([] if selected_values is None else [dtype[0](value) for value in selected_values]) + else: + constrained_values.append(values) + repaired_solution = self._assign_unique_gene_values(new_solution, constrained_values) + if self.solution_satisfies_gene_constraints(repaired_solution): + new_solution = repaired_solution + if self.get_duplicate_gene_indices(new_solution) and self.gene_constraint: + unconstrained_solution = self._assign_unique_gene_values(new_solution, candidate_values) + if self.solution_satisfies_gene_constraints(unconstrained_solution): + new_solution = unconstrained_solution + elif not self.get_duplicate_gene_indices(unconstrained_solution): + # A dependent constraint must see the complete assignment, + # including other positions changed by the replacement chain. + constrained_solution = self._assign_unique_gene_values_by_constraint( + new_solution, candidate_values, sample_size) + if constrained_solution is not None: + new_solution = constrained_solution + + duplicate_indices = self.get_duplicate_gene_indices(new_solution) + if warn and not self.suppress_warnings: + for gene_index in sorted(duplicate_indices): + stage = "while creating the initial population" if build_initial_pop else f"at generation {getattr(self, 'generations_completed', 0)}" + warnings.warn(f"Failed to find a unique value for gene with index {gene_index} whose value is {new_solution[gene_index]} {stage}. Consider adding more values in the gene space, using a wider range, or increasing sample_size for continuous ranges and gene constraints.") + return new_solution, duplicate_indices, len(duplicate_indices) + + def _assign_unique_gene_values(self, solution, candidate_values): """ - Resolves duplicates in a solution by selecting new values for the duplicate genes from the gene space. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_type (type): The data type of the gene (e.g., int, float). - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int, optional): The maximum number of attempts to resolve duplicates by selecting values from the gene space. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - tuple: - list: The updated solution after attempting to resolve duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. + Find a maximum assignment of different candidate values to genes. + Search replacement chains iteratively so long chromosomes do not + depend on Python's recursion limit. Without constraints, a finite + candidate space is searched completely. """ - new_solution = solution.copy() - - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - # First try to solve the duplicates. - # For a solution like [3 2 0 0], the indices of the 2 duplicating genes are 2 and 3. - # The next call to the find_unique_value() method tries to change the value of the gene with index 3 to solve the duplicate. - if len(not_unique_indices) > 0: - new_solution, not_unique_indices, num_unsolved_duplicates = self.unique_genes_by_space(solution=new_solution, - gene_type=gene_type, - not_unique_indices=not_unique_indices, - sample_size=sample_size, - mutation_by_replacement=mutation_by_replacement, - build_initial_pop=build_initial_pop) - else: - return new_solution, not_unique_indices, len(not_unique_indices) - - # DEEP-DUPLICATE-REMOVAL-NEEDED - # Search by this phrase to find where deep duplicates removal should be applied. - # If there exist duplicate genes, then changing either of the 2 duplicating genes (with indices 2 and 3) will not solve the problem. - # This problem can be solved by randomly changing one of the non-duplicating genes that may make room for a unique value in one of the 2 duplicating genes. - # For example, if gene_space=[[3, 0, 1], [4, 1, 2], [0, 2], [3, 2, 0]] and the solution is [3 2 0 0], then the values of the last 2 genes duplicate. - # There are no possible changes in the last 2 genes to solve the problem. But it could be solved by changing the second gene from 2 to 4. - # As a result, any of the last 2 genes can take the value 2 and solve the duplicates. - - return new_solution, not_unique_indices, num_unsolved_duplicates - - def unique_int_gene_from_range(self, - solution, - gene_index, - min_val, - max_val, - mutation_by_replacement, - gene_type, - step=1): - + value_owners = {} + duplicate_indices = [] + for gene_index, gene_value in enumerate(solution): + value_key = self._gene_value_key(gene_value) + if value_key in value_owners: + duplicate_indices.append(gene_index) + else: + value_owners[value_key] = gene_index + + for duplicate_index in duplicate_indices: + genes_to_search = deque([duplicate_index]) + previous_genes = {duplicate_index: None} + replacement_found = False + while genes_to_search and not replacement_found: + gene_index = genes_to_search.popleft() + for value in candidate_values[gene_index]: + if self._gene_value_key(value) not in value_owners: + # Walk back from the unused value to the duplicate. + # Each gene releases its predecessor's needed value. + while True: + new_solution[gene_index] = value + value_owners[self._gene_value_key(value)] = gene_index + previous_gene = previous_genes[gene_index] + if previous_gene is None: + break + gene_index, value = previous_gene + replacement_found = True + break + owner_index = value_owners[self._gene_value_key(value)] + if owner_index not in previous_genes: + previous_genes[owner_index] = (gene_index, value) + genes_to_search.append(owner_index) + return new_solution + + def _assign_unique_gene_values_by_constraint(self, solution, candidate_values, + sample_size): """ - Finds a unique integer value for a specific gene in a solution. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. - min_val (int): The minimum value of the range to sample an integer randomly. - max_val (int): The maximum value of the range to sample an integer randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., int, int8, uint16, etc). - step (int, optional): The step size for generating candidate values. Defaults to 1. - - Returns: - int: The new integer value of the gene. If no unique value can be found, the original gene value is returned. + Try alternative complete assignments when a replacement chain + violates a dependent constraint. Limit tentative assignments to + ``sample_size * num_genes`` to keep arbitrary user constraints + from causing an unbounded combinatorial search. """ + gene_order = sorted(range(len(solution)), key=lambda index: len(candidate_values[index])) + candidate_solution = solution.copy() + candidate_positions = [0] * len(solution) + selected_values = set() + search_depth = 0 + num_attempts = 0 + max_attempts = sample_size * len(solution) + while search_depth >= 0 and (num_attempts < max_attempts or search_depth == len(solution)): + if search_depth == len(solution): + if self.solution_satisfies_gene_constraints(candidate_solution): + return candidate_solution + search_depth -= 1 + selected_values.remove(self._gene_value_key(candidate_solution[gene_order[search_depth]])) + continue + gene_index = gene_order[search_depth] + values = candidate_values[gene_index] + if candidate_positions[search_depth] == len(values): + candidate_positions[search_depth] = 0 + search_depth -= 1 + if search_depth >= 0: + selected_values.remove(self._gene_value_key(candidate_solution[gene_order[search_depth]])) + continue + value = values[candidate_positions[search_depth]] + candidate_positions[search_depth] += 1 + num_attempts += 1 + value_key = self._gene_value_key(value) + if value_key in selected_values: + continue + candidate_solution[gene_index] = value + selected_values.add(value_key) + search_depth += 1 + return None - if self.gene_constraint and self.gene_constraint[gene_index]: - # A unique value is created out of the values that satisfy the constraint. - # sample_size=None to return all the values. - random_values = self.get_valid_gene_constraint_values(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=None, - step=step) - # If there is no value satisfying the constraint, then return the current gene value. - if random_values is None: - return solution[gene_index] - else: - pass - else: - # There is no constraint for the current gene. Return the same range. - # sample_size=None to return all the values. - random_values = self.generate_gene_value(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=None, - step=step) - - selected_value = self.select_unique_value(gene_values=random_values, - solution=solution, - gene_index=gene_index) - - # The gene_type is of the form [type, precision] - selected_value = gene_type[0](selected_value) - - return selected_value - - def unique_float_gene_from_range(self, - solution, - gene_index, - min_val, - max_val, - mutation_by_replacement, - gene_type, - sample_size=100): - + def solution_satisfies_gene_constraints(self, solution): + """Check all gene constraints against a complete candidate solution.""" + if not self.gene_constraint: + return True + for gene_index, constraint in enumerate(self.gene_constraint): + if constraint is None: + continue + selected_values = self.filter_gene_values_by_constraint( + numpy.array([solution[gene_index]]), solution, gene_index, warn=False) + if selected_values is None: + return False + return True + + def solve_duplicate_genes_randomly(self, solution, min_val, max_val, + mutation_by_replacement, gene_type, + sample_size=100): """ - Finds a unique floating-point value for a specific gene in a solution. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. - min_val (int): The minimum value of the range to sample a floating-point number randomly. - max_val (int): The maximum value of the range to sample a floating-point number randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., float, float16, float32, etc). - sample_size (int): The maximum number of random values to generate to find a unique value. - - Returns: - float: The new floating-point value of the gene. If no unique value can be found, the original gene value is returned. + Compatibility helper for repair using explicit random ranges. + Returns the repaired solution, remaining duplicate indices, and + their count. Gene types are obtained from the GA configuration. """ + return self.solve_duplicate_genes(solution, min_val=min_val, max_val=max_val, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) + def solve_duplicate_genes_by_space(self, solution, gene_type, + mutation_by_replacement, sample_size=100, + build_initial_pop=False): + """Compatibility helper for repair using the configured gene space.""" + return self.solve_duplicate_genes(solution, build_initial_pop=build_initial_pop, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) + + def unique_int_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, gene_type, step=1): + """Return an unused integer candidate, or keep the gene if none exists.""" + return self._unique_gene_from_range(solution, gene_index, min_val, max_val, + mutation_by_replacement, None, step) + + def unique_float_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, gene_type, + sample_size=100): + """Return an unused float candidate, or keep the gene if none exists.""" + return self._unique_gene_from_range(solution, gene_index, min_val, max_val, + mutation_by_replacement, sample_size, 1) + + def _unique_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, sample_size, step): + """Generate, filter, and select a candidate for the compatibility helpers.""" + values = self.generate_gene_value_randomly( + range_min=min_val, range_max=max_val, gene_value=solution[gene_index], + gene_idx=gene_index, mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size, step=step) + return self._select_unique_value_by_constraint(values, solution, gene_index) + + def select_unique_value(self, gene_values, solution, gene_index): + """Select an unused value, accepting both scalar and array candidates.""" + gene_values = list(numpy.atleast_1d(gene_values)) + used_values = {self._gene_value_key(value) for value in solution} + values_to_select_from = list({self._gene_value_key(value): value for value in gene_values + if self._gene_value_key(value) not in used_values}.values()) + if values_to_select_from: + return random.choice(values_to_select_from) + if solution[gene_index] is None: + if not gene_values: + raise ValueError(f"There are no values to select for the gene at index {gene_index}.") + return random.choice(gene_values) + return solution[gene_index] + + def _select_unique_value_by_constraint(self, values, solution, gene_index): + """Filter candidates before selecting an unused value for one gene.""" + values = numpy.atleast_1d(values) if self.gene_constraint and self.gene_constraint[gene_index]: - # A unique value is created out of the values that satisfy the constraint. - values = self.get_valid_gene_constraint_values(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=sample_size) - # If there is no value satisfying the constraint, then return the current gene value. + values = self.filter_gene_values_by_constraint(values, solution, gene_index) if values is None: return solution[gene_index] - else: - pass - else: - # There is no constraint for the current gene. Return the same range. - values = self.generate_gene_value(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size) - - selected_value = self.select_unique_value(gene_values=values, - solution=solution, - gene_index=gene_index) - return selected_value - - def select_unique_value(self, - gene_values, - solution, - gene_index): - - """ - Select a unique value (if possible) from a list of gene values. - - Args: - gene_values (NumPy Array): An array of values from which a unique value should be selected. - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. + return self.select_unique_value(values, solution, gene_index) - Returns: - selected_gene: The new (hopefully unique) value of the gene. If no unique value can be found, the original gene value is returned. - """ - - values_to_select_from = list(set(list(gene_values)) - set(solution)) - - if len(values_to_select_from) == 0: - if solution[gene_index] is None: - # The initial population is created as an empty array (numpy.empty()). - # If we are assigning values to the initial population, then the gene value is already None. - # If the gene value is None, then we do not have an option other than selecting a value even if it causes duplicates. - # If there is no value that is unique to the solution, then select any of the current values randomly from the current set of gene values. - selected_value = random.choice(gene_values) - else: - # If the gene is not None, then just keep its current value as long as there are no values that make it unique. - selected_value = solution[gene_index] - else: - selected_value = random.choice(values_to_select_from) - return selected_value - - def unique_genes_by_space(self, - solution, - gene_type, - not_unique_indices, - mutation_by_replacement, - sample_size=100, + def unique_genes_by_space(self, solution, gene_type, not_unique_indices, + mutation_by_replacement, sample_size=100, build_initial_pop=False): + """Compatibility helper that repairs duplicates and replacement chains.""" + return self.solve_duplicate_genes_by_space(solution, gene_type, + mutation_by_replacement, + sample_size, build_initial_pop) - """ - Iterates through all duplicate genes to find unique values from their gene spaces and resolve duplicates. - For each duplicate gene, a call is made to the `unique_gene_by_space()` function. - - Args: - solution (list): A solution containing genes with duplicate values. - gene_type (type): The data type of the all the genes (e.g., int, float). - not_unique_indices (list): The indices of genes with duplicate values. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int): The maximum number of attempts to resolve duplicates for each gene. Only works for floating-point numbers. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - tuple: - list: The updated solution after attempting to resolve all duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. - """ - - num_unsolved_duplicates = 0 - for duplicate_index in not_unique_indices: - temp_val = self.unique_gene_by_space(solution=solution, - gene_idx=duplicate_index, - gene_type=gene_type, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size, - build_initial_pop=build_initial_pop) - - if temp_val in solution: - num_unsolved_duplicates = num_unsolved_duplicates + 1 - if not self.suppress_warnings: warnings.warn(f"Failed to find a unique value for gene with index {duplicate_index} whose value is {solution[duplicate_index]} at generation {self.generations_completed+1}. Consider adding more values in the gene space or use a wider range for initial population or random mutation.") - else: - solution[duplicate_index] = temp_val - - # Update the list of duplicate indices after each iteration. - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - return solution, not_unique_indices, num_unsolved_duplicates - - def unique_gene_by_space(self, - solution, - gene_idx, - gene_type, - mutation_by_replacement, - sample_size=100, + def unique_gene_by_space(self, solution, gene_idx, gene_type, + mutation_by_replacement, sample_size=100, build_initial_pop=False): - - """ - Returns a unique value for a specific gene based on its value space to resolve duplicates. - - Args: - solution (list): A solution containing genes with duplicate values. - gene_idx (int): The index of the gene that has a duplicate value. - gene_type (type): The data type of the gene (e.g., int, float). - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int): The maximum number of attempts to resolve duplicates for each gene. Only works for floating-point numbers. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - Any: A unique value for the gene, if one exists; otherwise, the original gene value. - """ - - # When gene_value is None, this forces the gene value generators to select a value for use by the initial population. - # Otherwise, it considers selecting a value for mutation. - if build_initial_pop: - gene_value = None - else: - gene_value = solution[gene_idx] - - if self.gene_constraint and self.gene_constraint[gene_idx]: - # A unique value is created out of the values that satisfy the constraint. - values = self.get_valid_gene_constraint_values(range_min=None, - range_max=None, - gene_value=gene_value, - gene_idx=gene_idx, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=sample_size) - # If there is no value satisfying the constraint, then return the current gene value. - if values is None: - return solution[gene_idx] - else: - pass - else: - # There is no constraint for the current gene. Return the same range. - values = self.generate_gene_value(range_min=None, - range_max=None, - gene_value=gene_value, - gene_idx=gene_idx, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size) - - selected_value = self.select_unique_value(gene_values=values, - solution=solution, - gene_index=gene_idx) - - return selected_value - - def find_two_duplicates(self, - solution, - gene_space_unpacked): - """ - Identifies the first occurrence of a duplicate gene in the solution. - - Args: - solution: The solution containing genes with duplicate values. - gene_space_unpacked: A list of values from the gene space to choose the values that resolve duplicates. - - Returns: - int: The index of the first gene with a duplicate value. - Any: The value of the duplicate gene. - """ - - for gene in set(solution): - gene_indices = numpy.where(numpy.array(solution) == gene)[0] - if len(gene_indices) == 1: + """Return an unused candidate from a gene's space, if available.""" + values = self.get_gene_space_values( + gene_idx, None if build_initial_pop else solution[gene_idx], + mutation_by_replacement, sample_size) + return self._select_unique_value_by_constraint(values, solution, gene_idx) + + def find_two_duplicates(self, solution, gene_space_unpacked): + """Return a duplicate gene with alternatives, or ``(None, None)``.""" + duplicate_values = {self._gene_value_key(solution[index]) + for index in self.get_duplicate_gene_indices(solution)} + for gene_index, gene_value in enumerate(solution): + if self._gene_value_key(gene_value) not in duplicate_values: continue - for gene_idx in gene_indices: - number_alternate_values = len(set(gene_space_unpacked[gene_idx])) - if number_alternate_values > 1: - return gene_idx, gene - # This means there is no way to solve the duplicates between the genes. - # Because the space of the duplicate genes only has a single value and there are no alternatives. - return None, gene + space = gene_space_unpacked[gene_index] if self.gene_space_nested or not self.gene_type_single else gene_space_unpacked + if len({self._gene_value_key(value) for value in numpy.atleast_1d(space)}) > 1: + return gene_index, gene_value + return None, None - def unpack_gene_space(self, - range_min, - range_max, - sample_size_from_inf_range=100): + def unpack_gene_space(self, range_min, range_max, sample_size_from_inf_range=100): """ - Unpacks the gene space for selecting a value to resolve duplicates by converting ranges into lists of values. - - Args: - range_min (float or int): The minimum value of the range. - range_max (float or int): The maximum value of the range. - sample_size_from_inf_range (int): The number of values to generate for an infinite range of float values using `numpy.linspace()`. - - Returns: - list: A list representing the unpacked gene space. + Return converted finite spaces and samples of continuous spaces. + This attribute is a snapshot for inspection. Value generation + reads the original space so ``None`` entries remain random and + use the current initialization or mutation range. """ - - # Copy the gene_space to keep it isolated from the changes. if self.gene_space is None: return None - - if self.gene_space_nested == False: - if type(self.gene_space) is range: - gene_space_unpacked = list(self.gene_space) - elif type(self.gene_space) in [numpy.ndarray, list]: - gene_space_unpacked = self.gene_space.copy() - elif type(self.gene_space) is dict: - if 'step' in self.gene_space.keys(): - gene_space_unpacked = numpy.arange(start=self.gene_space['low'], - stop=self.gene_space['high'], - step=self.gene_space['step']) - else: - gene_space_unpacked = numpy.linspace(start=self.gene_space['low'], - stop=self.gene_space['high'], - num=sample_size_from_inf_range, - endpoint=False) - - if self.gene_type_single == True: - # Change the data type. - for idx in range(len(gene_space_unpacked)): - if gene_space_unpacked[idx] is None: - gene_space_unpacked[idx] = numpy.random.uniform(low=range_min, - high=range_max) - gene_space_unpacked = numpy.array(gene_space_unpacked, - dtype=self.gene_type[0]) - if not self.gene_type[1] is None: - # Round the values for float (non-int) data types. - gene_space_unpacked = numpy.round(gene_space_unpacked, - self.gene_type[1]) + if self.gene_space_nested: + num_spaces = len(self.gene_space) + elif not self.gene_type_single: + num_spaces = len(self.gene_type) + else: + num_spaces = 1 + unpacked_spaces = [] + for gene_index in range(num_spaces): + if type(range_min) in self.supported_int_float_types: + low, high = range_min, range_max else: - temp_gene_space_unpacked = gene_space_unpacked.copy() - gene_space_unpacked = [] - # Get the number of genes from the length of gene_type. - # The num_genes attribute is not set yet when this method (unpack_gene_space) is called for the first time. - for gene_idx in range(len(self.gene_type)): - # Change the data type. - gene_space_item_unpacked = numpy.array(temp_gene_space_unpacked, - self.gene_type[gene_idx][0]) - if not self.gene_type[gene_idx][1] is None: - # Round the values for float (non-int) data types. - gene_space_item_unpacked = numpy.round(temp_gene_space_unpacked, - self.gene_type[gene_idx][1]) - gene_space_unpacked.append(gene_space_item_unpacked) - - elif self.gene_space_nested == True: - gene_space_unpacked = self.gene_space.copy() - for space_idx, space in enumerate(gene_space_unpacked): - if type(space) in pygad.GA.supported_int_float_types: - gene_space_unpacked[space_idx] = [space] - elif space is None: - # Randomly generate the value using the mutation range. - gene_space_unpacked[space_idx] = numpy.arange(start=range_min, - stop=range_max) - elif type(space) is range: - # Convert the range to a list. - gene_space_unpacked[space_idx] = list(space) - elif type(space) is dict: - # Create a list of values using the dict range. - # Use numpy.linspace() - dtype = self.get_gene_dtype(gene_index=space_idx) - - if dtype[0] in pygad.GA.supported_int_types: - if 'step' in space.keys(): - step = space['step'] - else: - step = 1 - - gene_space_unpacked[space_idx] = numpy.arange(start=space['low'], - stop=space['high'], - step=step) - else: - if 'step' in space.keys(): - gene_space_unpacked[space_idx] = numpy.arange(start=space['low'], - stop=space['high'], - step=space['step']) - else: - gene_space_unpacked[space_idx] = numpy.linspace(start=space['low'], - stop=space['high'], - num=sample_size_from_inf_range, - endpoint=False) - elif type(space) in [numpy.ndarray, list, tuple]: - # list/tuple/numpy.ndarray - # Convert all to list - gene_space_unpacked[space_idx] = list(space) - - # Check if there is an item with the value None. If so, replace it with a random value using the mutation range. - none_indices = numpy.where(numpy.array(gene_space_unpacked[space_idx]) == None)[0] - if len(none_indices) > 0: - for idx in none_indices: - random_value = numpy.random.uniform(low=range_min, - high=range_max, - size=1)[0] - gene_space_unpacked[space_idx][idx] = random_value - - dtype = self.get_gene_dtype(gene_index=space_idx) - - # Change the data type. - gene_space_unpacked[space_idx] = numpy.array(gene_space_unpacked[space_idx], - dtype=dtype[0]) - if not dtype[1] is None: - # Round the values for float (non-int) data types. - gene_space_unpacked[space_idx] = numpy.round(gene_space_unpacked[space_idx], - dtype[1]) - - return gene_space_unpacked - - def solve_duplicates_deeply(self, - solution): - """ - Sometimes it is impossible to solve the duplicate genes by simply selecting another value for either genes. - This function solves the duplicates between 2 genes by searching for a third gene that can assist in the solution. - - Args: - solution (list): The current solution containing genes, potentially with duplicates. - - Returns: - list or None: The updated solution with duplicates resolved, or `None` if the duplicates cannot be resolved. - """ - - # gene_space_unpacked = self.unpack_gene_space() - # Create a copy of the gene_space_unpacked attribute because it will be changed later. - gene_space_unpacked = self.gene_space_unpacked.copy() - - duplicate_index, duplicate_value = self.find_two_duplicates(solution, - gene_space_unpacked) - - if duplicate_index is None: - # Impossible to solve the duplicates for the genes with value duplicate_value. - return None - - - # Without copy(), the gene will be removed from the gene_space. - # Convert the space to list because tuples do not have copy() - gene_other_values = list(gene_space_unpacked[duplicate_index]).copy() - - # This removes all the occurrences of this value. - gene_other_values = [v for v in gene_other_values if v != duplicate_value] - - # Two conditions to solve the duplicates of the value D: - # 1. From gene_other_values, select a value V such that it is available in the gene space of another gene X. - # 2. Find an alternate value for the gene X that will not cause any duplicates. - # 2.1 If the gene X does not have alternatives, then go back to step 1 to find another gene. - # 2.2 Set the gene X to the value D. - # 2.3 Set the target gene to the value V. - # Set the space of the duplicate gene to empty list []. Do not remove it to not alter the indices of the gene spaces. - gene_space_unpacked[duplicate_index] = [] - - for other_value in gene_other_values: - for space_idx, space in enumerate(gene_space_unpacked): - if other_value in space: - if other_value in solution and list(solution).index(other_value) != space_idx: - continue - else: - # Find an alternate value for the third gene. - # Copy the space so that the original space is not changed after removing the value. - space_other_values = space.copy() - # This removes all the occurrences of this value. It is not enough to use the remove() function because it only removes the first occurrence. - space_other_values = [v for v in space_other_values if v != other_value] - - for val in space_other_values: - if val in solution: - # If the value exists in another gene of the solution, then we cannot use this value as it will cause another duplicate. - # End the current iteration and go check another value. - continue - else: - solution[space_idx] = val - solution[duplicate_index] = other_value - return solution - - # Reaching here means we cannot solve the duplicate genes. + low, high = range_min[gene_index], range_max[gene_index] + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + dtype = self.get_gene_dtype(gene_index) + if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: + # A deterministic inspection snapshot must not consume the + # random draws used to build and evolve the population. + values = numpy.linspace(space['low'], space['high'], + num=sample_size_from_inf_range, endpoint=False) + unpacked_spaces.append(self.change_gene_dtype_and_round(gene_index, values)) + else: + unpacked_spaces.append(self.get_gene_space_values( + gene_index, sample_size=sample_size_from_inf_range, + range_min=low, range_max=high)) + if not self.gene_space_nested and self.gene_type_single: + return unpacked_spaces[0] + return unpacked_spaces + + def solve_duplicates_deeply(self, solution): + """Repair replacement chains, returning None if no progress is possible.""" + repaired_solution, duplicate_indices, _ = self.solve_duplicate_genes(solution, warn=False) + if len(duplicate_indices) < len(self.get_duplicate_gene_indices(solution)): + return repaired_solution return None diff --git a/pygad/pygad.py b/pygad/pygad.py index 7db5f560..988059a9 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -129,7 +129,7 @@ def __init__(self, suppress_warnings: Added in PyGAD 2.10.0 and its type is bool. If True, then no warning messages will be displayed. It defaults to False. - allow_duplicate_genes: Added in PyGAD 2.13.0. If True, then a solution/chromosome may have duplicate gene values. If False, then each gene will have a unique value in its solution. + allow_duplicate_genes: Added in PyGAD 2.13.0. If True, then a solution/chromosome may have duplicate gene values. If False, PyGAD tries to give every gene a unique numeric value after conversion and rounding, using replacement chains when needed. Duplicates may remain if the spaces, ranges, constraints, or sampling limit leave no usable alternative. stop_criteria: Added in PyGAD 2.15.0. It is assigned to some criteria to stop the evolution if at least one criterion holds. diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index fba9e31f..39c63105 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.2" +__version__ = "1.5.3" diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index 5178ef64..9b5d0b08 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -72,19 +72,7 @@ def single_point_crossover(self, parents, offspring_size): offspring[k, crossover_points[k]:] = parents[parent2_idx, crossover_points[k]:] 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) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring @@ -162,19 +150,7 @@ def two_points_crossover(self, parents, offspring_size): offspring[k, crossover_points_1[k]:crossover_points_2[k]] = parents[parent2_idx, crossover_points_1[k]:crossover_points_2[k]] 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) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring def uniform_crossover(self, parents, offspring_size): @@ -239,19 +215,7 @@ def uniform_crossover(self, parents, offspring_size): parents[parent2_idx, :]) 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) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring @@ -314,7 +278,7 @@ def sbx_crossover(self, parents, offspring_size): if y2 - y1 < near_zero: # The two parents have the same value on this gene. - offspring[k, gene_idx] = p1 + offspring[k, gene_idx] = self.change_gene_dtype_and_round(gene_idx, p1) continue range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) @@ -338,22 +302,10 @@ def sbx_crossover(self, parents, offspring_size): else: child = 0.5 * ((y1 + y2) + beta_q * (y2 - y1)) child = numpy.clip(child, lower, upper) - offspring[k, gene_idx] = child + offspring[k, gene_idx] = self.change_gene_dtype_and_round(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) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k], build_initial_pop=True) return offspring @@ -421,17 +373,5 @@ def scattered_crossover(self, parents, offspring_size): parents[parent2_idx, :]) 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) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index b173f9a8..74cc4cff 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -164,22 +164,10 @@ def initialize_population(self, # Error by the user's defined gene constraint callable. raise Exception(f"It is expected to receive a list/numpy.ndarray from the gene_constraint callable that is either empty or has a single value equal, but received a list/numpy.ndarray of length {len(filtered_values)}.") - # 4) Solve duplicate genes. + # 4) Solve duplicate genes using the same rules as manual populations. if allow_duplicate_genes == False: - for solution_idx in range(self.population.shape[0]): - if self.gene_space is None: - self.population[solution_idx], _, _ = self.solve_duplicate_genes_randomly(solution=self.population[solution_idx], - min_val=self.init_range_low, - max_val=self.init_range_high, - gene_type=gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size) - else: - self.population[solution_idx], _, _ = self.solve_duplicate_genes_by_space(solution=self.population[solution_idx].copy(), - gene_type=self.gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size, - build_initial_pop=True) + self.population = self.solve_duplicate_genes_in_population( + self.population, build_initial_pop=True) # Change the data type and round all genes within the initial population. self.population = self.change_population_dtype_and_round(self.population) @@ -737,6 +725,13 @@ def run_crossover(self): else: raise ValueError(f"The output of on_crossover() is expected to be tuple/list/numpy.ndarray but {type(on_crossover_output)} found.") + # User operators and callbacks can return duplicates, including + # duplicates introduced by conversion to the configured gene types. + if not self.allow_duplicate_genes and (callable(self.crossover_type) or self.on_crossover is not None): + self.last_generation_offspring_crossover = self.solve_duplicate_genes_in_population( + self.last_generation_offspring_crossover, + build_initial_pop=self.crossover_type == 'sbx') + def run_mutation(self): """ Run the mutation step of one generation. Mutates the @@ -798,6 +793,11 @@ def run_mutation(self): else: raise ValueError(f"The output of on_mutation() is expected to be tuple/list/numpy.ndarray but {type(on_mutation_output)} found.") + if not self.allow_duplicate_genes and (callable(self.mutation_type) or self.on_mutation is not None): + self.last_generation_offspring_mutation = self.solve_duplicate_genes_in_population( + self.last_generation_offspring_mutation, + build_initial_pop=self.mutation_type == 'polynomial') + def run_update_population(self): """ Build the next generation in ``self.population`` from the @@ -1071,27 +1071,5 @@ def _nsga3_apply_gene_constraints(self, population): 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 + """Repair newly generated rows using initialization rules.""" + return self.solve_duplicate_genes_in_population(population, build_initial_pop=True) diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 0fd44e1c..9a91b334 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -94,11 +94,7 @@ def mutation_by_space(self, offspring): offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def mutation_probs_by_space(self, offspring): @@ -144,11 +140,7 @@ def mutation_probs_by_space(self, offspring): offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def mutation_process_gene_value(self, @@ -256,12 +248,6 @@ def swap_gene_by_space(self, The solution after the swap, unchanged if no gene qualifies. """ - def gene_space_values(idx): - if self.gene_space_nested or not self.gene_type_single: - return self.gene_space_unpacked[idx] - else: - return self.gene_space_unpacked - def has_constraint(idx): return bool(self.gene_constraint and self.gene_constraint[idx]) @@ -288,10 +274,11 @@ def has_constraint(idx): # Preserve the original set of numeric values. In particular, # casting or rounding must not turn a value into a duplicate. - if new_gene_value != other_value or new_other_value != gene_value: + if (self._gene_value_key(new_gene_value) != self._gene_value_key(other_value) + or self._gene_value_key(new_other_value) != self._gene_value_key(gene_value)): continue - if (new_gene_value not in gene_space_values(gene_idx) - or new_other_value not in gene_space_values(other_idx)): + if (not self.is_gene_value_in_space(gene_idx, new_gene_value, gene_value) + or not self.is_gene_value_in_space(other_idx, new_other_value, other_value)): continue # A constraint on a different gene may depend on either @@ -300,18 +287,7 @@ def has_constraint(idx): candidate_solution = solution.copy() candidate_solution[gene_idx] = new_gene_value candidate_solution[other_idx] = new_other_value - constraints_satisfied = True - for idx, constraint in enumerate(self.gene_constraint): - if constraint is None: - continue - values = numpy.array([candidate_solution[idx]]) - selected_values = constraint(candidate_solution.copy(), values.copy()) - if not self.validate_gene_constraint_callable_output(selected_values, values): - raise Exception("The output from the gene_constraint callable/function must be a list or NumPy array that is a subset of the passed values (second argument).") - if len(selected_values) == 0: - constraints_satisfied = False - break - if not constraints_satisfied: + if not self.solution_satisfies_gene_constraints(candidate_solution): continue candidates.append((other_idx, new_gene_value, new_other_value)) @@ -359,12 +335,7 @@ def mutation_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring @@ -407,12 +378,7 @@ def mutation_probs_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def polynomial_mutation(self, offspring): @@ -470,16 +436,10 @@ def polynomial_mutation(self, offspring): new_value = gene_value + delta_q * (upper - lower) new_value = numpy.clip(new_value, lower, upper) - offspring[sol_idx, gene_idx] = new_value + offspring[sol_idx, gene_idx] = self.change_gene_dtype_and_round(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) + if self.allow_duplicate_genes == False: + offspring[sol_idx], _, _ = self.solve_duplicate_genes(solution=offspring[sol_idx], build_initial_pop=True) return offspring def swap_mutation(self, offspring): @@ -508,6 +468,8 @@ def swap_mutation(self, offspring): temp = offspring[idx, mutation_gene1] offspring[idx, mutation_gene1] = offspring[idx, mutation_gene2] offspring[idx, mutation_gene2] = temp + if self.allow_duplicate_genes == False: + offspring[:] = self.solve_duplicate_genes_in_population(offspring) return offspring def inversion_mutation(self, offspring): @@ -532,6 +494,8 @@ def inversion_mutation(self, offspring): genes_to_scramble = numpy.flip(offspring[idx, mutation_gene1:mutation_gene2]) offspring[idx, mutation_gene1:mutation_gene2] = genes_to_scramble + if self.allow_duplicate_genes == False: + offspring[:] = self.solve_duplicate_genes_in_population(offspring) return offspring def scramble_mutation(self, offspring): @@ -560,6 +524,8 @@ def scramble_mutation(self, offspring): genes_to_scramble = offspring[offspring_idx, segment_start:segment_end].copy() numpy.random.shuffle(genes_to_scramble) offspring[offspring_idx, segment_start:segment_end] = genes_to_scramble + if self.allow_duplicate_genes == False: + offspring[:] = self.solve_duplicate_genes_in_population(offspring) return offspring def adaptive_mutation_population_fitness(self, offspring): @@ -705,11 +671,7 @@ def adaptive_mutation_by_space(self, offspring): offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_randomly(self, offspring): @@ -777,12 +739,7 @@ def adaptive_mutation_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_probs_by_space(self, offspring): @@ -861,11 +818,7 @@ def adaptive_mutation_probs_by_space(self, offspring): offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_probs_randomly(self, offspring): @@ -935,10 +888,5 @@ def adaptive_mutation_probs_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 62c46534..56b5823b 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -160,7 +160,7 @@ def _validate_gene_space(self, if len(gene_space) == 0: self.valid_parameters = False raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") - elif type(gene_space) in [list, numpy.ndarray]: + elif type(gene_space) in [list, tuple, numpy.ndarray]: if len(gene_space) == 0: self.valid_parameters = False raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") @@ -508,22 +508,9 @@ def _build_initial_population(self, # Change the data type and round all genes within the initial population. self.initial_population = self.change_population_dtype_and_round(initial_population) - # Check if duplicates are allowed. If not, then solve any existing duplicates in the passed initial population. if self.allow_duplicate_genes == False: - for initial_solution_idx, initial_solution in enumerate(self.initial_population): - if self.gene_space is None: - self.initial_population[initial_solution_idx], _, _ = self.solve_duplicate_genes_randomly(solution=initial_solution, - min_val=self.init_range_low, - max_val=self.init_range_high, - mutation_by_replacement=True, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - self.initial_population[initial_solution_idx], _, _ = self.solve_duplicate_genes_by_space(solution=initial_solution, - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=True, - build_initial_pop=True) + self.initial_population = self.solve_duplicate_genes_in_population( + self.initial_population, build_initial_pop=True) # A NumPy array holding the initial population. self.population = self.initial_population.copy() @@ -2068,6 +2055,17 @@ def validate_parameters(self, num_genes, initial_population) + # Repair can call constraints while building either generated or + # manually supplied populations. Validate and store them first. + if initial_population is not None and numpy.asarray(initial_population).ndim == 2: + self.num_genes = numpy.asarray(initial_population).shape[1] + else: + self.num_genes = num_genes + self._validate_gene_constraint(gene_constraint) + if self.gene_space_nested and len(gene_space) != self.num_genes: + self.valid_parameters = False + raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") + # Call the unpack_gene_space() method in the pygad.helper.unique.Unique class. self.gene_space_unpacked = self.unpack_gene_space(range_min=self.init_range_low, range_max=self.init_range_high) @@ -2079,17 +2077,9 @@ def validate_parameters(self, allow_duplicate_genes, gene_constraint) - # In case the 'gene_space' parameter is nested, then make sure the number of its elements equals to the number of genes. - if self.gene_space_nested: - if len(gene_space) != self.num_genes: - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") - self._validate_mutation_range(random_mutation_min_val, random_mutation_max_val) - self._validate_gene_constraint(gene_constraint) - # Validating the number of parents to be selected for mating (num_parents_mating) if num_parents_mating <= 0: self.valid_parameters = False diff --git a/pygad/visualize/__init__.py b/pygad/visualize/__init__.py index aa4c7690..617edf09 100644 --- a/pygad/visualize/__init__.py +++ b/pygad/visualize/__init__.py @@ -1,3 +1,3 @@ from pygad.visualize import plot -__version__ = "1.2.0" \ No newline at end of file +__version__ = "1.2.1" diff --git a/tests/test_allow_duplicate_genes.py b/tests/test_allow_duplicate_genes.py index 63037f3f..87c62066 100644 --- a/tests/test_allow_duplicate_genes.py +++ b/tests/test_allow_duplicate_genes.py @@ -247,44 +247,14 @@ def test_number_duplicates_nested_gene_space_initial_population(): assert num_duplicates == 0 -# def test_number_duplicates_nested_gene_space_nested_gene_type(): - """ - This example causes duplicate genes that can only be solved by changing the values of a chain of genes. - Let's explain it using this solution: [0, 2, 3, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 6 at indices 5 and 6. - According to the gene space, none of these genes can has a different value that solves the duplicates. - -If the value of the gene at index 5 is changed from 6 to 5, then it causes another duplicate with the gene at index 4. - -If the value of the gene at index 6 is changed from 6 to 7, then it causes another duplicate with the gene at index 7. - The solution is to change a chain of genes that make a room to solve the duplicates between the 2 genes. - 1) Change the second gene from 2 to 1. - 2) Change the third gene from 3 to 2. - 3) Change the fourth gene from 4 to 3. - 4) Change the fifth gene from 5 to 4. - 5) Change the sixth gene from 6 to 5. This solves the duplicates. - But this is NOT SUPPORTED yet. - We support changing only a single gene that makes a room to solve the duplicates. - - Let's explain it using this solution: [1, 2, 2, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 2 at indices 1 and 2. - This is how the duplicates are solved: - 1) Change the first gene from 1 to 0. - 2) Change the second gene from 2 to 1. This solves the duplicates. - The result is [0, 1, 2, 4, 5, 6, 6, 7, 8, 9] - """ - # num_duplicates = number_duplicate_genes(gene_space=[[0, 1], - # [1, 2], - # [2, 3], - # [3, 4], - # [4, 5], - # [5, 6], - # [6, 7], - # [7, 8], - # [8, 9], - # [9, 10]], - # gene_type=[int, int, int, int, int, int, int, int, int, int], - # num_genes=10) - - # assert num_duplicates == 0 +def test_number_duplicates_nested_gene_space_nested_gene_type(): + """Repair duplicates that require a chain of gene replacements.""" + num_duplicates = number_duplicate_genes(gene_space=[[index, index + 1] for index in range(10)], + gene_type=[int] * 10, + num_genes=10) + + assert num_duplicates == 0 + def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population(): num_duplicates = number_duplicate_genes(gene_space=[[0, 1], @@ -480,44 +450,15 @@ def test_number_duplicates_nested_gene_space_initial_population_multi_objective( assert num_duplicates == 0 -# def test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective(): - """ - This example causes duplicate genes that can only be solved by changing the values of a chain of genes. - Let's explain it using this solution: [0, 2, 3, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 6 at indices 5 and 6. - According to the gene space, none of these genes can has a different value that solves the duplicates. - -If the value of the gene at index 5 is changed from 6 to 5, then it causes another duplicate with the gene at index 4. - -If the value of the gene at index 6 is changed from 6 to 7, then it causes another duplicate with the gene at index 7. - The solution is to change a chain of genes that make a room to solve the duplicates between the 2 genes. - 1) Change the second gene from 2 to 1. - 2) Change the third gene from 3 to 2. - 3) Change the fourth gene from 4 to 3. - 4) Change the fifth gene from 5 to 4. - 5) Change the sixth gene from 6 to 5. This solves the duplicates. - But this is NOT SUPPORTED yet. - We support changing only a single gene that makes a room to solve the duplicates. - - Let's explain it using this solution: [1, 2, 2, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 2 at indices 1 and 2. - This is how the duplicates are solved: - 1) Change the first gene from 1 to 0. - 2) Change the second gene from 2 to 1. This solves the duplicates. - The result is [0, 1, 2, 4, 5, 6, 6, 7, 8, 9] - """ - # num_duplicates = number_duplicate_genes(gene_space=[[0, 1], - # [1, 2], - # [2, 3], - # [3, 4], - # [4, 5], - # [5, 6], - # [6, 7], - # [7, 8], - # [8, 9], - # [9, 10]], - # gene_type=[int, int, int, int, int, int, int, int, int, int], - # num_genes=10) - - # assert num_duplicates == 0 +def test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective(): + """Repair duplicates that require a chain of gene replacements.""" + num_duplicates = number_duplicate_genes(gene_space=[[index, index + 1] for index in range(10)], + gene_type=[int] * 10, + num_genes=10, + multi_objective=True) + + assert num_duplicates == 0 + def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population_multi_objective(): num_duplicates = number_duplicate_genes(gene_space=[[0, 1], @@ -580,7 +521,7 @@ def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population print() # This example causes duplicates that can only be solved by changing a chain of genes. - # test_number_duplicates_nested_gene_space_nested_gene_type() + test_number_duplicates_nested_gene_space_nested_gene_type() # print() test_number_duplicates_nested_gene_space_nested_gene_type_initial_population() print() @@ -626,7 +567,7 @@ def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population print() # This example causes duplicates that can only be solved by changing a chain of genes. - # test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective() + test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective() # print() test_number_duplicates_nested_gene_space_nested_gene_type_initial_population_multi_objective() print() diff --git a/tests/test_duplicate_gene_repair.py b/tests/test_duplicate_gene_repair.py new file mode 100644 index 00000000..f71d4605 --- /dev/null +++ b/tests/test_duplicate_gene_repair.py @@ -0,0 +1,339 @@ +"""Regression tests for duplicate repair across the GA lifecycle.""" + +import copy +import itertools +import random + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return float(numpy.sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=3, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + gene_type=int, init_range_low=0, init_range_high=6, + random_mutation_min_val=0, random_mutation_max_val=6, + mutation_by_replacement=True, allow_duplicate_genes=False, + random_seed=1, suppress_warnings=True) + parameters.update(copy.deepcopy(options)) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("gene_space", [None, [0, 1, 2], (0, 1, 2), range(3), + numpy.arange(3), {'low': 0, 'high': 3}, + {'low': 0, 'high': 3, 'step': 1}]) +def test_manual_population_duplicates_are_repaired_without_changing_input(gene_space): + population = [[0, 0, 1], [0, 0, 1]] + before = copy.deepcopy(population) + ga_instance = make_ga(initial_population=population, gene_space=gene_space) + assert population == before + for solution in ga_instance.population: + assert len(set(solution)) == 3 + + +@pytest.mark.parametrize("manual", [False, True]) +def test_fixed_later_gene_can_move_an_earlier_duplicate(manual): + options = dict(gene_space=[[0, 1], [0], [2]]) + if manual: + options['initial_population'] = [[0, 0, 2], [0, 0, 2]] + ga_instance = make_ga(**options) + numpy.testing.assert_array_equal(ga_instance.population, + numpy.tile([1, 0, 2], (ga_instance.sol_per_pop, 1))) + + +def test_long_replacement_chain_does_not_use_python_recursion(): + num_genes = 1100 + spaces = [[index, index + 1] for index in range(num_genes - 1)] + [[0]] + solution = numpy.array(list(range(num_genes - 1)) + [0]) + ga_instance = make_ga(num_genes=num_genes, gene_space=spaces, sample_size=1, + initial_population=[solution.tolist(), solution.tolist()]) + numpy.testing.assert_array_equal(ga_instance.population[0], + list(range(1, num_genes)) + [0]) + + +def test_finite_repair_matches_exhaustive_search_for_small_spaces(): + # Compare with all assignments, including spaces that cannot make + # every gene unique. The repair should maximize distinct values. + rng = random.Random(42) + for _ in range(100): + spaces = [rng.sample(range(4), rng.randint(1, 3)) for _ in range(4)] + solution = [rng.choice(space) for space in spaces] + expected = max(len(set(values)) for values in itertools.product(*spaces)) + ga_instance = make_ga(num_genes=4, gene_space=spaces, + initial_population=[solution, solution], sample_size=1) + for repaired_solution in ga_instance.population: + assert len(set(repaired_solution)) == expected + assert all(value in space for value, space in zip(repaired_solution, spaces)) + + +@pytest.mark.parametrize("gene_type", [[numpy.int16, numpy.float32, int], + [int, [float, 0], numpy.int32]]) +def test_mixed_gene_types_are_preserved_during_range_repair(gene_type): + ga_instance = make_ga(gene_type=gene_type, + initial_population=[[0, 0, 1], [0, 0, 1]]) + for solution in ga_instance.population: + assert len(set(solution)) == 3 + for index, value in enumerate(solution): + assert isinstance(value, ga_instance.get_gene_dtype(index)[0]) + + +@pytest.mark.parametrize("method", ['mutation_randomly', 'mutation_probs_randomly', + 'adaptive_mutation_randomly', + 'adaptive_mutation_probs_randomly']) +def test_mutation_repairs_each_gene_using_its_own_range(method, monkeypatch): + adaptive = method.startswith('adaptive') + probability = [1.0, 1.0] if adaptive else 1.0 + ga_instance = make_ga(initial_population=[[0, 2], [0, 2]], + mutation_type='adaptive' if adaptive else 'random', + mutation_probability=probability if 'probs' in method else None, + mutation_num_genes=[1, 1] if adaptive else 1, + random_mutation_min_val=[1, 10], + random_mutation_max_val=[3, 12]) + monkeypatch.setattr(random, 'sample', lambda values, count: [0]) + monkeypatch.setattr(ga_instance, 'mutation_process_gene_value', + lambda solution, gene_idx, **kwargs: 2 if gene_idx == 0 else solution[gene_idx]) + if adaptive: + monkeypatch.setattr(ga_instance, 'adaptive_mutation_population_fitness', + lambda offspring: (1.0, numpy.ones(len(offspring)))) + result = getattr(ga_instance, method)(numpy.array([[0, 2]])) + assert len(set(result[0])) == 2 + assert 10 <= result[0, 1] < 12 + + +@pytest.mark.parametrize("gene_space, gene_type", [(None, float), + ([0, 1, 2], int), + ({'low': 0, 'high': 3, 'step': 1}, int)]) +def test_sample_size_one_accepts_scalar_candidates(gene_space, gene_type): + ga_instance = make_ga(gene_space=gene_space, gene_type=gene_type, + sample_size=1, initial_population=[[0, 0, 1], [0, 0, 1]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("space", [None, [None], [None, 100], (None, 100), + numpy.array([None, 100], dtype=object)]) +def test_none_entries_generate_fresh_values_from_per_gene_ranges(space): + ga_instance = make_ga(gene_space=[space, [100], [200]], gene_type=float, + init_range_low=[0, 100, 200], init_range_high=[1, 101, 201], + random_mutation_min_val=[10, 100, 200], + random_mutation_max_val=[11, 101, 201]) + candidates = ga_instance.get_gene_space_values(0, gene_value=0.5, sample_size=10) + random_candidates = candidates[candidates != 100] + assert len(random_candidates) > 1 + assert numpy.all((random_candidates >= 10) & (random_candidates < 11)) + + +def test_flat_none_space_with_mixed_types_and_per_gene_ranges(): + ga_instance = make_ga(gene_space=[None, 100], gene_type=[int, float, numpy.int16], + init_range_low=[0, 10, 20], init_range_high=[3, 13, 23]) + for solution in ga_instance.population: + assert len(set(solution)) == 3 + assert set(ga_instance.get_gene_space_values(2)) == {20, 21, 22, 100} + + +def test_repair_does_not_invalidate_a_constraint_on_another_gene(): + constraint = lambda solution, values: [value for value in values if value >= solution[1]] + ga_instance = make_ga(gene_space=[[0], [0, 1], [2]], + gene_constraint=[constraint, None, None]) + before = numpy.array([0, 0, 2]) + repaired, duplicates, count = ga_instance.solve_duplicate_genes(before) + numpy.testing.assert_array_equal(repaired, before) + assert ga_instance.solution_satisfies_gene_constraints(repaired) + assert duplicates == {1} + assert count == 1 + + +def test_constraint_can_require_changing_another_nonduplicated_gene(): + constraint = lambda solution, values: [value for value in values if value <= solution[2]] + ga_instance = make_ga(gene_space=[[0], [0, 1], [0, 2]], + gene_constraint=[None, constraint, None]) + repaired, duplicates, count = ga_instance.solve_duplicate_genes(numpy.array([0, 0, 0])) + numpy.testing.assert_array_equal(repaired, [0, 1, 2]) + assert duplicates == set() + assert count == 0 + + +def test_impossible_initial_space_warns_and_returns_remaining_duplicates(): + with pytest.warns(UserWarning, match='Failed to find a unique value'): + ga_instance = make_ga(gene_space=[0], suppress_warnings=False) + repaired, duplicates, count = ga_instance.solve_duplicate_genes( + [0, 0, 0], build_initial_pop=True, warn=False) + assert repaired.tolist() == [0, 0, 0] + assert duplicates == {1, 2} + assert count == 2 + + +@pytest.mark.parametrize("crossover_type", ['single_point', 'two_points', 'uniform', + 'scattered']) +def test_crossover_can_repair_a_chain_without_mutation(crossover_type): + ga_instance = make_ga(num_genes=4, gene_space=[[0, 1], [1, 2], [2, 3], [0]], + crossover_type=crossover_type, mutation_type=None) + offspring = ga_instance.crossover(numpy.array([[0, 1, 2, 0], [0, 1, 2, 0]]), (2, 4)) + numpy.testing.assert_array_equal(offspring, [[1, 2, 3, 0], [1, 2, 3, 0]]) + + +@pytest.mark.parametrize("stage", ['crossover', 'mutation', 'on_crossover', 'on_mutation']) +def test_user_operator_and_callback_outputs_are_repaired(stage): + def crossover(parents, offspring_size, ga_instance): + return numpy.zeros(offspring_size) + + def mutation(offspring, ga_instance): + return numpy.zeros_like(offspring) + + def callback(ga_instance, offspring): + offspring[:] = 0 + + options = dict(gene_space=[0, 1, 2], crossover_type=None, mutation_type=None) + if stage == 'crossover': + options['crossover_type'] = crossover + elif stage == 'mutation': + options['mutation_type'] = mutation + else: + options[stage] = callback + ga_instance = make_ga(**options) + ga_instance.run() + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +def test_rounding_is_applied_before_duplicate_repair(): + ga_instance = make_ga(gene_type=[float, 0], gene_space=[0, 1, 2]) + result = ga_instance.solve_duplicate_genes_in_population(numpy.array([[0.1, 0.2, 1.1]])) + assert len(set(result[0])) == 3 + assert set(result[0]) == {0, 1, 2} + + +@pytest.mark.parametrize("method", ['swap_mutation', 'inversion_mutation', + 'scramble_mutation']) +def test_permutation_mutation_repairs_duplicates_created_by_destination_casts(method, + monkeypatch): + ga_instance = make_ga(num_genes=4, gene_type=[float, int, int, int], mutation_type=method.split('_')[0], + initial_population=[[0.5, 1, 0, 2], [0.5, 1, 0, 2]]) + if method == 'swap_mutation': + monkeypatch.setattr(numpy.random, 'choice', lambda *args, **kwargs: numpy.array([0, 1])) + else: + monkeypatch.setattr(numpy.random, 'randint', lambda *args, **kwargs: numpy.array([0])) + if method == 'scramble_mutation': + monkeypatch.setattr(numpy.random, 'shuffle', lambda values: values.__setitem__(slice(None), values[::-1].copy())) + result = getattr(ga_instance, method)(ga_instance.population.copy()) + for solution in result: + assert len(set(solution)) == 4 + for gene_index, value in enumerate(solution): + assert isinstance(value, ga_instance.get_gene_dtype(gene_index)[0]) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_round_values_and_repair_using_their_own_bounds(operator): + ga_instance = make_ga(gene_type=[float, 0], crossover_type='sbx', + mutation_type='polynomial', mutation_probability=1.0, + init_range_low=0, init_range_high=3, + random_mutation_min_val=100, random_mutation_max_val=200, + initial_population=[[0, 1, 2], [2, 0, 1]]) + if operator == 'sbx': + result = ga_instance.sbx_crossover(ga_instance.population, (20, 3)) + else: + result = ga_instance.polynomial_mutation(numpy.tile([0, 1, 2], (20, 1)).astype(float)) + for solution in result: + assert len(set(solution)) == 3 + assert numpy.all((solution >= 0) & (solution <= 3)) + numpy.testing.assert_array_equal(solution, numpy.round(solution)) + + +def test_constraint_sample_size_one_does_not_crash(): + constraints = [lambda solution, values: values] * 3 + ga_instance = make_ga(gene_constraint=constraints, sample_size=1, + initial_population=[[0, 0, 1], [0, 0, 1]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("gene_type", [int, float, numpy.int16, [float, 1]]) +def test_equal_random_bounds_keep_values_and_report_impossible_duplicates(gene_type): + ga_instance = make_ga(gene_type=gene_type, init_range_low=0, init_range_high=0, + random_mutation_min_val=0, random_mutation_max_val=0) + repaired, duplicates, count = ga_instance.solve_duplicate_genes([0, 0, 0]) + assert repaired.tolist() == [0, 0, 0] + assert duplicates == {1, 2} + assert count == 2 + + +def test_reversed_integer_bounds_remain_usable_for_initialization_and_repair(): + ga_instance = make_ga(init_range_low=3, init_range_high=0, + random_mutation_min_val=3, random_mutation_max_val=0) + assert all(set(solution) == {0, 1, 2} for solution in ga_instance.population) + repaired, duplicates, count = ga_instance.solve_duplicate_genes([0, 0, 0]) + assert set(repaired) == {0, 1, 2} + assert duplicates == set() + assert count == 0 + + +@pytest.mark.parametrize("gene_type", [numpy.float16, numpy.float32, [numpy.float16, 1]]) +def test_none_candidates_support_small_float_types_and_precision(gene_type): + ga_instance = make_ga(gene_type=gene_type, gene_space=[[None], [None], [None]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("space", [None, [None], {'low': 0, 'high': 2}]) +def test_swap_fallback_uses_current_none_ranges_and_continuous_bounds(space): + ga_instance = make_ga(num_genes=2, gene_space=[space, [0, 1]], + init_range_low=10, init_range_high=12, + random_mutation_min_val=0, random_mutation_max_val=2, + gene_type=float, initial_population=[[0, 1], [0, 1]]) + solution = numpy.array([0.0, 1.0]) + numpy.testing.assert_array_equal(ga_instance.swap_gene_by_space(solution, 0), [1, 0]) + + +def test_integer_none_mutation_adds_the_offset_before_casting(monkeypatch): + ga_instance = make_ga(gene_space=[None, [100], [200]], + initial_population=[[-2, 100, 200], [-2, 100, 200]], + mutation_by_replacement=False, + random_mutation_min_val=-1, random_mutation_max_val=1) + monkeypatch.setattr(numpy.random, 'uniform', lambda *args, **kwargs: 0.75) + value = ga_instance.generate_gene_value_from_space( + 0, False, ga_instance.population[0], gene_value=-2, sample_size=1) + assert value == -1 + + +def test_integer_dictionary_with_fractional_bounds_includes_all_converted_values(): + ga_instance = make_ga(num_genes=5, gene_space={'low': 0.8, 'high': 4.2}, + initial_population=[[0, 0, 1, 2, 3], [0, 0, 1, 2, 3]], + sample_size=1) + assert set(ga_instance.get_gene_space_values(0)) == {0, 1, 2, 3, 4} + assert all(set(solution) == {0, 1, 2, 3, 4} for solution in ga_instance.population) + + +def test_mixed_float_precision_compares_exact_numeric_values(): + ga_instance = make_ga(num_genes=2, gene_type=[numpy.float32, float], + gene_space=[[0.1, 1], [0.1, 1]], + initial_population=[[0.1, 0.1], [0.1, 0.1]]) + # float32(0.1) and Python's float(0.1) have different stored values. + # NumPy scalar comparison can promote the Python float to float32. + assert ga_instance.get_duplicate_gene_indices(ga_instance.population[0]) == set() + assert ga_instance.get_duplicate_gene_indices([numpy.float32(1), 1.0]) == {1} + assert ga_instance.find_two_duplicates(ga_instance.population[0], + ga_instance.gene_space_unpacked) == (None, None) + value = ga_instance.select_unique_value([numpy.float32(0.1)], [1.0, 0.1], 0) + assert isinstance(value, numpy.float32) + assert float(value) != 0.1 + + +def test_repeated_nans_can_be_repaired_from_a_finite_space(): + ga_instance = make_ga(gene_type=float, gene_space=[0, 1, 2], + initial_population=[[numpy.nan, numpy.nan, 0], + [numpy.nan, numpy.nan, 0]]) + for solution in ga_instance.population: + assert ga_instance.get_duplicate_gene_indices(solution) == set() + assert numpy.count_nonzero(numpy.isnan(solution)) == 1 + + +def test_repair_and_full_runs_are_reproducible(): + populations = [] + for _ in range(2): + ga_instance = make_ga(gene_space=[[0, 1], [1, 2], [0, 2]], + initial_population=[[0, 1, 0], [1, 1, 2]]) + ga_instance.run() + populations.append(ga_instance.population) + numpy.testing.assert_array_equal(*populations) From f3931e1c160ba9e62fa12e3c5d82721f31d84b13 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 20:21:18 -0400 Subject: [PATCH 04/22] Simplify and batch initial population creation --- docs/source/gene_values.md | 57 +++- docs/source/pygad.md | 11 +- docs/source/releases.md | 4 +- examples/example_initial_population.py | 57 ++++ pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 206 +++++++++++--- pygad/helper/unique.py | 24 +- pygad/pygad.py | 4 +- pygad/utils/__init__.py | 2 +- pygad/utils/engine.py | 328 +++++++--------------- pygad/utils/validation.py | 364 ++++++++----------------- tests/test_initial_population.py | 287 +++++++++++++++++++ 12 files changed, 821 insertions(+), 525 deletions(-) create mode 100644 examples/example_initial_population.py create mode 100644 tests/test_initial_population.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 43a0ff55..8140d3e7 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -2,6 +2,61 @@ This page covers the parameters that control the values a gene can take: the `gene_space` and `gene_type` parameters, gene constraints, the `sample_size` parameter, and preventing duplicate genes. +## Creating the Initial Population + +PyGAD can generate the initial population or start from a population passed to `initial_population`. + +### Generate a Population + +When `initial_population=None`, set positive integers for `sol_per_pop` and `num_genes`. PyGAD samples the gene values using these rules: + +| Gene settings | Source of initial values | +| --- | --- | +| `gene_space=None` | The range defined by `init_range_low` and `init_range_high`. | +| A flat list, tuple, range, or NumPy array | The same finite choices for every gene, converted to that gene's type and precision. | +| A nested gene space | Each gene's own choices, fixed numeric value, dictionary, or `None` entry. | +| A dictionary without `step` | The continuous interval between `low` and `high`, restricted to the configured gene type and precision. | +| A dictionary with `step` | The finite values generated by `numpy.arange(low, high, step)`, converted to the configured type and precision. | +| `None`, including inside a list of choices | A fresh random value from that gene's initialization range. Explicit choices in the same list remain available. | + +Both initialization bounds can be numbers shared by all genes or 1D lists, tuples, or NumPy arrays with one bound per gene. For example: + +```python +init_range_low = [0, -2, 20] +init_range_high = [5, 2, 30] +gene_type = [int, [float, 2], float] +``` + +For random ranges and dictionaries without `step`, generated values stay within the interval after conversion and rounding. The smaller bound is included and the larger bound is excluded. Reversed bounds use the same interval. Equal bounds produce a fixed value if that value can be represented by the gene type and precision. A `ValueError` is raised if no valid value can be represented. For example, `gene_type=int` cannot generate a value in `[0.1, 0.9)`. + +Finite choices are converted as supplied. Integer conversion truncates fractional values towards zero, and floating-point values use the requested precision. Conversion can make different choices equal. + +PyGAD samples arrays of values rather than calling the sampler for every gene value. Integer intervals are sampled directly, and finite choices are prepared once per column. Continuous populations sharing one floating-point type are drawn as one array in solution-then-gene order. NSGA-III population growth uses the same generation and preparation methods. + +### Supply a Population + +Pass a non-empty rectangular 2D list, tuple, or NumPy array containing numeric values. PyGAD infers `sol_per_pop` and `num_genes` from its shape. These inferred dimensions take precedence over explicit values for the 2 parameters, including when validating per-gene ranges, spaces, types, and constraints. + +```python +initial_population = ((1.236, 10), + (2.341, 20)) +gene_type = ([float, 2], int) +``` + +This population has 2 solutions and 2 genes per solution. Its values are converted to `[[1.24, 10], [2.34, 20]]`, with the configured type preserved for each gene. The supplied population and `gene_type` specification are not modified. `population` and `initial_population` are separate arrays, so changing the working population does not change the initial snapshot. + +Supplied values can lie outside `gene_space` and the initialization ranges. They are retained after type conversion and rounding unless a constraint or duplicate repair requires a replacement. Replacement values follow the gene's own initialization settings. + +### Constraints and Duplicates + +Both generated and supplied populations are converted before checking `gene_constraint`. Constraints are applied in gene-index order to complete solutions. Finite choices are searched in full; continuous intervals and large integer intervals contribute up to `sample_size` candidates. If no candidate satisfies a constraint, the existing value remains and PyGAD warns unless `suppress_warnings=True`. + +Constraints depending on other genes should follow the dependency order: a gene should depend on earlier genes, as explained in the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section. Initialization does not solve arbitrary systems of dependent constraints. + +When `allow_duplicate_genes=False`, duplicate repair follows constraint handling and uses the same converted domains. The search behavior and limits are described in [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). + +See [`examples/example_initial_population.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_initial_population.py) for generated and supplied populations. + ## Limit the Gene Value Range using the `gene_space` Parameter In [PyGAD 2.11.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-11-0), the `gene_space` parameter added a new feature that lets you customize the range of accepted values for each gene. Let us first review the `gene_space` parameter and build on it. @@ -99,7 +154,7 @@ gene_space = {"low": 4, "high": 30} gene_space = {"low": 4, "high": 30, "step": 2.5} ``` -> Setting a `dict` like `{"low": 0, "high": 10}` in the `gene_space` means that random values from the continuous range [0, 10) are sampled. Note that `0` is included but `10` is not included while sampling. Thus, the maximum value that could be returned is less than `10` like `9.9999`. But if the user decided to round the genes using, for example, `[float, 2]`, then this value will become 10. So, the user should be careful to the inputs. +> Setting a `dict` like `{"low": 0, "high": 10}` in the `gene_space` samples values from [0, 10). During initialization, values remain within these bounds after conversion and rounding, including with `[float, 2]`. If the interval contains no value representable by the configured gene type and precision, PyGAD raises a `ValueError`. If a `None` is assigned to only a single gene, then its value will be randomly generated initially using the `init_range_low` and `init_range_high` parameters in the `pygad.GA` class's constructor. During mutation, the value is sampled from the range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This is an example where the second gene is given a `None` value. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 1553b4be..f2dd1d28 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -45,6 +45,8 @@ Number of genes in the solution/chromosome. This parameter is not needed if the A population you provide yourself to start the run instead of a random one. It defaults to `None`, in which case PyGAD builds the initial population from the `sol_per_pop` and `num_genes` parameters. +Pass a non-empty rectangular 2D list, tuple, or NumPy array of numeric values. PyGAD infers both dimensions from its shape, overriding any explicit `sol_per_pop` and `num_genes` values. It copies and converts the values using `gene_type`, applies `gene_constraint`, and repairs duplicates when `allow_duplicate_genes=False`. Supplied values may lie outside the gene space or initialization ranges; replacements follow the initialization settings. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). + If `initial_population` is `None` and either `sol_per_pop` or `num_genes` is also `None`, an exception is raised. Introduced in [PyGAD 2.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-0-0) and higher. @@ -153,13 +155,13 @@ Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pyga :::{dropdown} `init_range_low=-4`: Lower bound for the initial gene values. :animate: fade-in-slide-down -The lower value of the random range from which the gene values in the initial population are selected. `init_range_low` defaults to `-4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. This parameter has no action if the `initial_population` parameter exists. +The lower value of the random range from which the gene values in the initial population are selected. `init_range_low` defaults to `-4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). ::: :::{dropdown} `init_range_high=4`: Upper bound for the initial gene values. :animate: fade-in-slide-down -The upper value of the random range from which the gene values in the initial population are selected. `init_range_high` defaults to `+4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. This parameter has no action if the `initial_population` parameter exists. +The upper value of the random range from which the gene values in the initial population are selected. `init_range_high` defaults to `+4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). ::: :::{dropdown} `allow_duplicate_genes=True`: Allow repeated values within a solution. @@ -587,6 +589,11 @@ Constructor settings and user callables are stored as instance attributes, with ##### Methods - `initialize_population(allow_duplicate_genes, gene_type, gene_constraint)`: Build the initial population, apply gene types and constraints, resolve duplicates when not allowed. +- `generate_initial_population(num_solutions)`: Sample new population values in bulk and prepare the resulting solutions. Also used by NSGA-III population growth. +- `prepare_initial_population(population)`: Convert generated or supplied rows, apply constraints, and repair duplicates. +- `apply_initial_population_gene_constraints(population)`: Replace rejected values using initialization candidates, warning when no candidate satisfies a constraint. +- `sample_initial_population_gene_values(gene_index, num_values)`: Sample one column from its space or initialization range. +- `get_initial_population_gene_candidates(gene_index, sample_size, all_integer_values=True)`: Return converted replacement candidates for initialization constraints and duplicate repair. - `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. diff --git a/docs/source/releases.md b/docs/source/releases.md index 6ab92a0e..555e7ab6 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -741,7 +741,9 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. 15. Duplicate-gene repair now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. -16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.1` and `1.5.3`. +16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. 17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. +18. Initial population creation and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/examples/example_initial_population.py b/examples/example_initial_population.py new file mode 100644 index 00000000..3bbc7af4 --- /dev/null +++ b/examples/example_initial_population.py @@ -0,0 +1,57 @@ +"""Generate an initial population or start from supplied numeric values.""" + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return sum(solution) + + +# Each column uses its own range and type. The float column is rounded +# to 2 decimal places while remaining inside [-2, 2). +random_population_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=4, + num_genes=3, + init_range_low=[0, -2, 20], + init_range_high=[5, 2, 30], + gene_type=[int, [float, 2], float], + mutation_num_genes=1, + random_seed=7) + +print("Population from Per-Gene Ranges") +print(random_population_ga.initial_population) + +# The third gene has no explicit space, so its values come from [20, 30). +# The last gene has the fixed value 5. +gene_space_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=4, + num_genes=4, + gene_space=[[0, 1, 2], {'low': 1, 'high': 2}, None, 5], + init_range_low=[0, 0, 20, 0], + init_range_high=[3, 3, 30, 10], + gene_type=[int, [float, 2], [float, 1], int], + mutation_num_genes=1, + random_seed=7) + +print("Population from a Nested Gene Space") +print(gene_space_ga.initial_population) + +# Neither sol_per_pop nor num_genes is needed. PyGAD infers both from +# the supplied population, converts the values, and keeps its own copy. +initial_population = ((1.236, 10), (2.341, 20)) +supplied_population_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_type=([float, 2], int), + mutation_num_genes=1, + random_seed=7) + +print("Population from Supplied Values") +print(supplied_population_ga.initial_population) +print("Inferred Population Shape", supplied_population_ga.pop_size) +print("Original Supplied Values", initial_population) diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index 286cc309..cee87778 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.1" +__version__ = "1.4.2" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 8b7585d4..503039d5 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -3,6 +3,7 @@ """ import numpy +import math import warnings import random import pygad @@ -306,40 +307,27 @@ def change_population_dtype_and_round(self, The same data cast (and rounded) to the right type. """ - population_new = numpy.array(population.copy(), dtype=object) - - # Forcing the iterable to have the data type assigned to the gene_type parameter. - if self.gene_type_single == True: - # Round the numbers first then change the data type. - # This solves issues with some data types such as numpy.float32. - if self.gene_type[1] is None: - pass - else: - # This block is reached only for non-integer data types (i.e. float). - population_new = numpy.round(numpy.array(population_new, float), - self.gene_type[1]) - - population_new = numpy.array(population_new, - dtype=self.gene_type[0]) - else: - population = numpy.array(population.copy()) - population_new = numpy.zeros(shape=population.shape, - dtype=object) - for gene_idx in range(population.shape[1]): - # Round the numbers first then change the data type. - # This solves issues with some data types such as numpy.float32. - if self.gene_type[gene_idx][1] is None: - # Do not round. - population_new[:, gene_idx] = population[:, gene_idx] - else: - # This block is reached only for non-integer data types (i.e. float). - population_new[:, gene_idx] = numpy.round(numpy.array(population[:, gene_idx], float), - self.gene_type[gene_idx][1]) - # Once rounding is done, change the data type. - # population_new[:, gene_idx] = numpy.asarray(population_new[:, gene_idx], dtype=self.gene_type[gene_idx][0]) - # Use a for loop to maintain the data type of each individual gene. - for sol_idx in range(population.shape[0]): - population_new[sol_idx, gene_idx] = self.gene_type[gene_idx][0](population_new[sol_idx, gene_idx]) + if self.gene_type_single: + dtype, precision = self.gene_type + if precision is None: + return numpy.array(population, dtype=dtype, copy=True) + # Round before casting so narrow NumPy types do not lose + # precision before the requested decimal rounding is applied. + population_new = numpy.round(numpy.asarray(population, dtype=float), precision) + return numpy.asarray(population_new, dtype=dtype) + + # Keep each input value exact until its own column is converted. + # A shared floating dtype could lose large integer values in + # solutions containing both integer and floating-point genes. + population = numpy.asarray(population, dtype=object) + population_new = numpy.empty(population.shape, dtype=object) + for gene_index in range(population.shape[1]): + values = self.change_gene_dtype_and_round(gene_index, population[:, gene_index]) + dtype = self.gene_type[gene_index][0] + for solution_index, value in enumerate(values): + # Assign scalars individually to preserve their configured + # types in the object array, including NumPy numeric types. + population_new[solution_index, gene_index] = dtype(value) return population_new def change_gene_dtype_and_round(self, @@ -617,6 +605,156 @@ def get_initial_population_range(self, gene_index): range_max = self.init_range_high[gene_index] return range_min, range_max + def sample_initial_population_gene_values(self, gene_index, num_values): + """ + Sample a column using its own space, range, type, and precision. + Finite spaces are converted once before selection. A None entry + draws fresh values from the initialization range. Integer ranges + are sampled directly without allocating every possible value. + """ + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + lower, upper = self.get_initial_population_range(gene_index) + return self._initial_population_range_values(gene_index, lower, upper, num_values) + if type(space) is dict and 'step' not in space: + return self._initial_population_range_values( + gene_index, space['low'], space['high'], num_values) + if type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + explicit_values = [value for value in space if value is not None] + explicit_values = numpy.unique(self.change_gene_dtype_and_round(gene_index, explicit_values)) + # None is one choice in the space, rather than a fixed value + # sampled once and reused throughout the column. + selected_indices = numpy.random.randint(0, len(explicit_values) + 1, size=num_values) + values = numpy.empty(num_values, dtype=object) + random_positions = selected_indices == len(explicit_values) + values[~random_positions] = explicit_values[selected_indices[~random_positions]] + if numpy.any(random_positions): + lower, upper = self.get_initial_population_range(gene_index) + values[random_positions] = self._initial_population_range_values( + gene_index, lower, upper, int(numpy.sum(random_positions))) + return values + candidates = self.get_gene_space_values(gene_index) + if len(candidates) == 0: + raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_index}.") + return numpy.random.choice(candidates, size=num_values, replace=True) + + def get_initial_population_gene_candidates(self, gene_index, sample_size, + all_integer_values=True): + """ + Return replacement candidates for initialization constraints and + duplicates. Finite domains are considered in full; continuous + domains contribute sample_size values. When all_integer_values + is False, large integer intervals are also sampled. Candidates + already have the configured gene type and precision. + """ + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + lower, upper = self.get_initial_population_range(gene_index) + return self._initial_population_range_values( + gene_index, lower, upper, sample_size, + all_integer_values=all_integer_values or abs(math.ceil(upper) - math.ceil(lower)) <= sample_size) + if type(space) is dict and 'step' not in space: + return self._initial_population_range_values( + gene_index, space['low'], space['high'], sample_size, + all_integer_values=all_integer_values or abs(math.ceil(space['high']) - math.ceil(space['low'])) <= sample_size) + if type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + explicit_values = [value for value in space if value is not None] + explicit_values = self.change_gene_dtype_and_round(gene_index, explicit_values) + lower, upper = self.get_initial_population_range(gene_index) + random_values = self._initial_population_range_values( + gene_index, lower, upper, sample_size, + all_integer_values=all_integer_values or abs(math.ceil(upper) - math.ceil(lower)) <= sample_size) + return numpy.unique(numpy.concatenate([explicit_values, random_values])) + return self.get_gene_space_values(gene_index) + + def _initial_population_range_values(self, gene_index, lower, upper, num_values, + all_integer_values=False): + """ + Sample converted values inside an initialization interval. + The smaller bound is included and the larger bound is excluded. + Equal bounds describe a fixed value. Raise a descriptive error + if the gene type and precision cannot represent any valid value. + """ + lower, upper = sorted([lower, upper]) + dtype = self.get_gene_dtype(gene_index)[0] + if dtype in self.supported_int_types: + first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) + if first_value > last_value: + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type for the gene at index {gene_index}.") + if all_integer_values: + return numpy.arange(first_value, last_value + 1, dtype=dtype) + # RandomState interprets the Python int type as C long on + # Windows. Use NumPy's resolved dtype to match the population. + return numpy.random.randint(first_value, last_value + 1, + size=num_values, dtype=numpy.dtype(dtype).type) + + values = numpy.random.uniform(lower, upper, size=num_values) + return self._convert_initial_population_range_values(gene_index, lower, upper, values) + + def _initial_population_integer_bounds(self, gene_index, lower, upper): + """Return the first and last representable integers in an interval.""" + lower, upper = sorted([lower, upper]) + type_limits = numpy.iinfo(self.get_gene_dtype(gene_index)[0]) + first_value = max(math.ceil(lower), int(type_limits.min)) + last_value = min(math.ceil(upper) - 1, int(type_limits.max)) + if lower == upper and lower == first_value: + last_value = first_value + return first_value, last_value + + def _initial_population_range_snapshot(self, gene_index, lower, upper, sample_size): + """Create an inspection sample without allocating a range or drawing random values.""" + dtype = self.get_gene_dtype(gene_index)[0] + if dtype in self.supported_int_types: + first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) + count = min(sample_size, last_value - first_value + 1) + if count <= 0: + return numpy.empty(0, dtype=dtype) + # Use integer arithmetic so large bounds are not coerced to floats. + values = [first_value + (last_value - first_value) * index // max(1, count - 1) + for index in range(count)] + else: + values = numpy.linspace(lower, upper, num=sample_size, endpoint=False) + return self.change_gene_dtype_and_round(gene_index, values) + + def _convert_initial_population_range_values(self, gene_index, lower, upper, values): + """Convert floating range samples without crossing their bounds.""" + # Compare stored values as Python numbers. NumPy scalar promotion + # can otherwise cast a Python bound to the narrower gene dtype. + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + dtype, precision = self.get_gene_dtype(gene_index) + # Rounding or a narrow NumPy dtype can reach the excluded upper + # bound. Keep sampled values within the representable interval. + first_value = self.change_gene_dtype_and_round(gene_index, lower) + last_value = self.change_gene_dtype_and_round(gene_index, upper) + if lower != upper: + if precision is None: + if float(first_value) < lower: + first_value = numpy.nextafter(first_value, dtype(numpy.inf), dtype=dtype) + if float(last_value) >= upper: + last_value = numpy.nextafter(last_value, dtype(-numpy.inf), dtype=dtype) + else: + precision_step = 10.0 ** -precision + if float(first_value) < lower: + first_unrounded_value = dtype(lower) + if float(first_unrounded_value) < lower: + first_unrounded_value = numpy.nextafter(first_unrounded_value, dtype(numpy.inf), dtype=dtype) + first_value = self.change_gene_dtype_and_round( + gene_index, numpy.ceil(float(first_unrounded_value) / precision_step) * precision_step) + if float(last_value) >= upper: + last_unrounded_value = dtype(upper) + if float(last_unrounded_value) >= upper: + last_unrounded_value = numpy.nextafter(last_unrounded_value, dtype(-numpy.inf), dtype=dtype) + last_value = self.change_gene_dtype_and_round( + gene_index, numpy.floor(float(last_unrounded_value) / precision_step) * precision_step) + if (not numpy.isfinite(first_value) or not numpy.isfinite(last_value) + or float(first_value) < lower or first_value > last_value + or (lower != upper and float(last_value) >= upper) + or (lower == upper and float(first_value) != lower)): + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type and its precision for the gene at index {gene_index}.") + values = self.change_gene_dtype_and_round(gene_index, values) + return numpy.clip(values, first_value, last_value) + def get_gene_space_values(self, gene_idx, gene_value=None, mutation_by_replacement=True, sample_size=100, range_min=None, range_max=None): diff --git a/pygad/helper/unique.py b/pygad/helper/unique.py index 3c4c09d3..2d25cd39 100644 --- a/pygad/helper/unique.py +++ b/pygad/helper/unique.py @@ -94,7 +94,9 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, candidate_values = [] for gene_index, gene_value in enumerate(new_solution): dtype = self.get_gene_dtype(gene_index) - if self.gene_space is None: + if build_initial_pop and min_val is None: + values = self.get_initial_population_gene_candidates(gene_index, sample_size) + elif self.gene_space is None: if min_val is None: if build_initial_pop: range_min, range_max = self.get_initial_population_range(gene_index) @@ -371,13 +373,19 @@ def unpack_gene_space(self, range_min, range_max, sample_size_from_inf_range=100 else: low, high = range_min[gene_index], range_max[gene_index] space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space - dtype = self.get_gene_dtype(gene_index) - if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: - # A deterministic inspection snapshot must not consume the - # random draws used to build and evolve the population. - values = numpy.linspace(space['low'], space['high'], - num=sample_size_from_inf_range, endpoint=False) - unpacked_spaces.append(self.change_gene_dtype_and_round(gene_index, values)) + # Continuous spaces and None entries are inspection samples. + # They must not allocate a large integer range or consume the + # random draws used to generate the population. + if space is None or (type(space) is dict and 'step' not in space): + if type(space) is dict: + low, high = space['low'], space['high'] + unpacked_spaces.append(self._initial_population_range_snapshot( + gene_index, low, high, sample_size_from_inf_range)) + elif type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + values = [value for value in space if value is not None] + values.extend(self._initial_population_range_snapshot( + gene_index, low, high, sample_size_from_inf_range)) + unpacked_spaces.append(numpy.unique(self.change_gene_dtype_and_round(gene_index, values))) else: unpacked_spaces.append(self.get_gene_space_values( gene_index, sample_size=sample_size_from_inf_range, diff --git a/pygad/pygad.py b/pygad/pygad.py index 988059a9..14bf579d 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -79,13 +79,13 @@ def __init__(self, fitness_func: Accepts a function/method and returns the fitness value of the solution. In PyGAD 2.20.0, a third parameter is passed referring to the 'pygad.GA' instance. fitness_batch_size: Added in PyGAD 2.19.0. Supports calculating the fitness in batches. If the value is 1 or None, then the fitness function is called for each individual solution. If given another value X where X is neither 1 nor None (e.g. X=3), then the fitness function is called once for each X (3) solutions. - initial_population: A user-defined initial population. It is useful when the user wants to start the generations with a custom initial population. It defaults to None which means no initial population is specified by the user. In this case, PyGAD creates an initial population using the 'sol_per_pop' and 'num_genes' parameters. An exception is raised if the 'initial_population' is None while any of the 2 parameters ('sol_per_pop' or 'num_genes') is also None. + initial_population: A user-defined initial population. It is useful when the user wants to start the generations with a custom initial population. It defaults to None which means no initial population is specified by the user. In this case, PyGAD creates an initial population using the 'sol_per_pop' and 'num_genes' parameters. An exception is raised if the 'initial_population' is None while any of the 2 parameters ('sol_per_pop' or 'num_genes') is also None. A supplied population must be a non-empty rectangular 2D numeric list, tuple, or NumPy array. Its shape determines sol_per_pop and num_genes before per-gene validation. The input is copied, converted, checked against gene constraints, and repaired for duplicates when required. sol_per_pop: Number of solutions in the population. num_genes: Number of genes in the solution. init_range_low: The lower value of the random range from which the gene values in the initial population are selected. It defaults to -4. Available in PyGAD 1.0.20 and higher. init_range_high: The upper value of the random range from which the gene values in the initial population are selected. It defaults to 4. Available in PyGAD 1.0.20. - It is OK for the 2 parameters ('init_range_low' and 'init_range_high') to be equal, or for one to be higher or lower than the other (i.e. 'init_range_low' does not need to be lower than 'init_range_high'). + It is OK for the 2 parameters ('init_range_low' and 'init_range_high') to be equal, or for one to be higher or lower than the other (i.e. 'init_range_low' does not need to be lower than 'init_range_high'). Generated range values remain inside the interval after conversion and rounding; an error is raised if the type and precision cannot represent any valid value. 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. diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 39c63105..0bfd781e 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.3" +__version__ = "1.5.4" diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index 74cc4cff..7669fc4d 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -34,146 +34,108 @@ def round_genes(self, solutions): self.gene_type[gene_idx][1]) return solutions - def initialize_population(self, - allow_duplicate_genes, - gene_type, - gene_constraint): + def initialize_population(self, allow_duplicate_genes, gene_type, gene_constraint): """ - 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: - - - ``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. + Generate and store the initial population using the validated + initialization settings. Sample the values, apply constraints, + and repair duplicate genes when they are not allowed. The working + population and its initial snapshot are independent arrays. 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. + Whether repeated gene values are allowed within a solution. + gene_type : type or list + Retained for compatibility. Sampling uses the validated + gene types and optional precisions stored in self.gene_type. 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. + Validated per-gene constraint callables. """ - - # Population size = (number of chromosomes, number of genes per chromosome) - # The population will have sol_per_pop chromosome where each chromosome has num_genes genes. - self.pop_size = (self.sol_per_pop, self.num_genes) - - # There are 4 steps to build the initial population: - # 1) Generate the population. - # 2) Change the data type and round the values. - # 3) Check for the constraints. - # 4) Solve duplicates if not allowed. - - # Create an empty population. - self.population = numpy.empty(shape=self.pop_size, dtype=object) - - # 1) Create the initial population either randomly or using the gene space. - if self.gene_space is None: - # Create the initial population randomly. - - # Set gene_value=None to consider generating values for the initial population instead of generating values for mutation. - # Loop through the genes, randomly generate the values of a single gene at a time, and insert the values of each gene to the population. - for sol_idx in range(self.sol_per_pop): - for gene_idx in range(self.num_genes): - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - self.population[sol_idx, gene_idx] = 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) - - else: - # Generate the initial population using the gene_space. - for sol_idx in range(self.sol_per_pop): - for gene_idx in range(self.num_genes): - self.population[sol_idx, gene_idx] = self.generate_gene_value_from_space(gene_idx=gene_idx, - mutation_by_replacement=True, - gene_value=None, - solution=self.population[sol_idx], - sample_size=1) - - # 2) Change the data type and round all genes within the initial population. - # This step is necessary before applying the gene constraints since the right gene value must be used for accuracy. - self.population = self.change_population_dtype_and_round(self.population) - - # Note that gene_constraint is not validated yet. - # We have to set it as a property of the pygad.GA instance to retrieve without passing it as an additional parameter. + # Store the policies passed by the constructor or by a caller + # explicitly rebuilding the initial population. + self.allow_duplicate_genes = allow_duplicate_genes self.gene_constraint = gene_constraint + self.pop_size = (self.sol_per_pop, self.num_genes) + self.population = self.generate_initial_population(self.sol_per_pop) + self.initial_population = self.population.copy() - # 3) Enforce the gene constraints as much as possible. - if self.gene_constraint is None: - pass + def generate_initial_population(self, num_solutions): + """ + Return new solutions using the initialization settings. Sampling + in bulk avoids rebuilding finite candidate sets for every solution + and calling the sampler for every value. NSGA-III population growth + uses this same method. + """ + continuous_space = (self.gene_space is None or + (type(self.gene_space) is dict and 'step' not in self.gene_space)) + if self.gene_type_single and self.gene_type[0] in self.supported_float_types and continuous_space: + if self.gene_space is None: + lower = numpy.minimum(self.init_range_low, self.init_range_high) + upper = numpy.maximum(self.init_range_low, self.init_range_high) + else: + lower = min(self.gene_space['low'], self.gene_space['high']) + upper = max(self.gene_space['low'], self.gene_space['high']) + # A single draw retains the traditional solution-then-gene + # order for continuous populations while avoiding scalar calls. + population = numpy.random.uniform(lower, upper, size=(num_solutions, self.num_genes)) + for gene_index in range(self.num_genes): + if self.gene_space is None: + gene_lower, gene_upper = self.get_initial_population_range(gene_index) + gene_lower, gene_upper = sorted([gene_lower, gene_upper]) + else: + gene_lower, gene_upper = lower, upper + population[:, gene_index] = self._convert_initial_population_range_values( + gene_index, gene_lower, gene_upper, population[:, gene_index]) else: - for sol_idx, solution in enumerate(self.population): - for gene_idx in range(self.num_genes): - # Check that a constraint is available for the gene and that the current value does not satisfy that constraint - if self.gene_constraint[gene_idx]: - # Remember that the second argument to the gene constraint callable is a list/numpy.ndarray of the values to check if they meet the gene constraint. - 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 result: - pass - else: - 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]: - # Error by the user's defined gene constraint callable. - raise Exception(f"It is expected to receive a list/numpy.ndarray from the gene_constraint callable with a single value equal to {values[0]}, but the value {filtered_values[0]} found.") - - # Check if the gene value does not satisfy the gene constraint. - # Note that we already passed a list of a single value. - # It is expected to receive a list of either a single value or an empty list. - if len(filtered_values) < 1: - # Search for a value that satisfies the gene constraint. - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - # While initializing the population, we follow a mutation by replacement approach. So, the original gene value is not needed. - 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 gene at index {gene_idx} with value {solution[gene_idx]} while creating the initial population.") - else: - self.population[sol_idx, gene_idx] = random.choice(values_filtered) - elif len(filtered_values) == 1: - # The value already satisfied the gene constraint. - pass - else: - # Error by the user's defined gene constraint callable. - raise Exception(f"It is expected to receive a list/numpy.ndarray from the gene_constraint callable that is either empty or has a single value equal, but received a list/numpy.ndarray of length {len(filtered_values)}.") - - # 4) Solve duplicate genes using the same rules as manual populations. - if allow_duplicate_genes == False: - self.population = self.solve_duplicate_genes_in_population( - self.population, build_initial_pop=True) + population = numpy.empty((num_solutions, self.num_genes), dtype=object) + for gene_index in range(self.num_genes): + population[:, gene_index] = self.sample_initial_population_gene_values( + gene_index, num_solutions) + return self.prepare_initial_population(population) - # Change the data type and round all genes within the initial population. - self.population = self.change_population_dtype_and_round(self.population) + def prepare_initial_population(self, population): + """ + Convert a generated or supplied population, then apply constraints + and duplicate repair. Existing supplied values need not belong to + the gene space or initialization range. Any replacement uses the + initialization settings for its own gene. + """ + population = self.change_population_dtype_and_round(population) + population = self.apply_initial_population_gene_constraints(population) + if not self.allow_duplicate_genes: + population = self.solve_duplicate_genes_in_population( + population, build_initial_pop=True) + return population - # Keeping the initial population in the initial_population attribute. - self.initial_population = self.population.copy() + def apply_initial_population_gene_constraints(self, population): + """ + Replace values rejected by their constraints using converted + initialization candidates. Constraints see the complete solution + and are applied in gene-index order. Leave the existing value and + warn when no candidate satisfies a constraint. + """ + if self.gene_constraint is None: + return population + for solution in population: + for gene_index, constraint in enumerate(self.gene_constraint): + if constraint is None: + continue + accepted_values = self.filter_gene_values_by_constraint( + [solution[gene_index]], solution, gene_index, warn=False) + if accepted_values is not None: + if len(accepted_values) != 1: + raise ValueError("A gene constraint checking a single value must return an empty list or NumPy array, or one containing only that value.") + continue + candidates = self.get_initial_population_gene_candidates( + gene_index, self.sample_size, all_integer_values=False) + accepted_values = self.filter_gene_values_by_constraint( + candidates, solution, gene_index, warn=False) + if accepted_values is None: + if not self.suppress_warnings: + warnings.warn(f"No value satisfied the constraint for the gene at index {gene_index} with value {solution[gene_index]} while creating the initial population.") + else: + solution[gene_index] = random.choice(accepted_values) + return population def cal_pop_fitness(self): """Compute population fitness with the same cache rules in all modes.""" @@ -961,114 +923,16 @@ def _nsga3_grow_population(self, required_size, num_objectives): 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 + """Generate NSGA-III growth rows using the initialization settings.""" + return self.generate_initial_population(count) 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) + """Compatibility helper for sampling one initialization value.""" + return self.sample_initial_population_gene_values(gene_idx, 1)[0] 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 + """Compatibility helper for applying initialization constraints.""" + return self.apply_initial_population_gene_constraints(population) def _nsga3_resolve_duplicate_genes(self, population): """Repair newly generated rows using initialization rules.""" diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 56b5823b..1114506c 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -178,42 +178,14 @@ def _validate_gene_space(self, elif type(el) == type(None): pass elif type(el) is dict: - if len(el.items()) == 2: - if ('low' in el.keys()) and ('high' in el.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it can have the keys 'low', 'high', and 'step' (optional) but the following keys found: {el.keys()}") - elif len(el.items()) == 3: - if ('low' in el.keys()) and ('high' in el.keys()) and ('step' in el.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it can have the keys 'low', 'high', and 'step' (optional) but the following keys found: {el.keys()}") - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it must have only 2 items but ({len(el.items())}) items found.") + self._validate_gene_space_dictionary(el) self.gene_space_nested = True elif not (type(el) in self.supported_int_float_types): self.valid_parameters = False raise TypeError(f"Unexpected type {type(el)} for the element indexed {index} of 'gene_space'. The accepted types are list/tuple/range/numpy.ndarray of numbers, a single number (int/float), or None.") elif type(gene_space) is dict: - if len(gene_space.items()) == 2: - if ('low' in gene_space.keys()) and ('high' in gene_space.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it can have only the keys 'low', 'high', and 'step' (optional) but the following keys found: {gene_space.keys()}") - elif len(gene_space.items()) == 3: - if ('low' in gene_space.keys()) and ('high' in gene_space.keys()) and ('step' in gene_space.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it can have only the keys 'low', 'high', and 'step' (optional) but the following keys found: {gene_space.keys()}") - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it must have only 2 items but ({len(gene_space.items())}) items found.") + self._validate_gene_space_dictionary(gene_space) else: self.valid_parameters = False @@ -221,11 +193,29 @@ def _validate_gene_space(self, self.gene_space = gene_space + def _validate_gene_space_dictionary(self, space): + """Validate range bounds and an optional step in a gene-space dict.""" + if set(space) not in [{'low', 'high'}, {'low', 'high', 'step'}]: + self.valid_parameters = False + raise ValueError("A gene_space dictionary must have 'low' and 'high' keys and may also have 'step'.") + for name, value in space.items(): + if type(value) not in self.supported_int_float_types: + self.valid_parameters = False + raise TypeError(f"The '{name}' value in a gene_space dictionary must be numeric but {type(value)} found.") + if type(value) in self.supported_float_types and not numpy.isfinite(value): + self.valid_parameters = False + raise ValueError(f"The '{name}' value in a gene_space dictionary must be finite but {value} found.") + if 'step' in space: + if (space['step'] == 0 + or (space['step'] > 0 and space['high'] <= space['low']) + or (space['step'] < 0 and space['high'] >= space['low'])): + self.valid_parameters = False + raise ValueError("The step in a gene_space dictionary must be non-zero and lead from low towards high so the space is not empty.") + def _validate_init_range(self, init_range_low, init_range_high, - num_genes, - initial_population): + num_genes): """ Validate the ``init_range_low`` and ``init_range_high`` parameters used to build the initial population when the user @@ -241,13 +231,9 @@ def _validate_init_range(self, 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. + num_genes : int + Resolved number of genes per solution, inferred from the + supplied population when one is available. Raises ------ @@ -257,64 +243,36 @@ def _validate_init_range(self, 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: - if init_range_low == init_range_high: - if not self.suppress_warnings: - warnings.warn("The values of the 2 parameters 'init_range_low' and 'init_range_high' are equal and this might return the same value for some genes in the initial population.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'init_range_low' {type(init_range_low)} and 'init_range_high' {type(init_range_high)}.") - elif type(init_range_low) in [list, tuple, numpy.ndarray]: - # Get the number of genes before validating the num_genes parameter. - if num_genes is None: - if initial_population is None: - self.valid_parameters = False - raise TypeError("When the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.") - elif not len(init_range_low) == len(initial_population[0]): + low_is_scalar = type(init_range_low) in self.supported_int_float_types + high_is_scalar = type(init_range_high) in self.supported_int_float_types + if low_is_scalar and high_is_scalar: + bounds = [('init_range_low', [init_range_low]), ('init_range_high', [init_range_high])] + if init_range_low == init_range_high and not self.suppress_warnings: + warnings.warn("The values of the 2 parameters 'init_range_low' and 'init_range_high' are equal and this might return the same value for some genes in the initial population.") + elif (type(init_range_low) in [list, tuple, numpy.ndarray] + and type(init_range_high) in [list, tuple, numpy.ndarray]): + bounds = [('init_range_low', init_range_low), ('init_range_high', init_range_high)] + for parameter_name, values in bounds: + if numpy.asarray(values, dtype=object).ndim != 1 or len(values) != num_genes: self.valid_parameters = False - raise ValueError(f"The length of the 'init_range_low' parameter is {len(init_range_low)} which is different from the number of genes {len(initial_population[0])}.") - elif not len(init_range_low) == num_genes: - self.valid_parameters = False - raise ValueError(f"The length of the 'init_range_low' parameter is {len(init_range_low)} which is different from the number of genes {num_genes}.") - - if type(init_range_high) in [list, tuple, numpy.ndarray]: - if len(init_range_low) == len(init_range_high): - pass - else: - self.valid_parameters = False - raise ValueError(f"Size mismatch between the 2 parameters 'init_range_low' {len(init_range_low)} and 'init_range_high' {len(init_range_high)}.") - - # Validate the values in init_range_low - for val in init_range_low: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'init_range_low' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - - # Validate the values in init_range_high - for val in init_range_high: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'init_range_high' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'init_range_low' {type(init_range_low)} and 'init_range_high' {type(init_range_high)}. Both of them can be either numeric or iterable (list/tuple/numpy.ndarray).") + raise ValueError(f"{parameter_name} must be a 1D list, tuple, or NumPy array with length equal to the number of genes ({num_genes}).") else: self.valid_parameters = False - raise TypeError(f"The expected type of the 'init_range_low' parameter is numeric or list/tuple/numpy.ndarray but {type(init_range_low)} found.") - + raise TypeError("init_range_low and init_range_high must both be numeric or both be lists, tuples, or NumPy arrays.") + for parameter_name, values in bounds: + for value in values: + if type(value) not in self.supported_int_float_types: + self.valid_parameters = False + raise TypeError(f"The values of {parameter_name} must be numeric but {value} of type {type(value)} found.") + if type(value) in self.supported_float_types and not numpy.isfinite(value): + self.valid_parameters = False + raise ValueError(f"The values of {parameter_name} must be finite but {value} found.") self.init_range_low = init_range_low self.init_range_high = init_range_high - + def _validate_gene_type(self, gene_type, - num_genes, - initial_population): + num_genes): """ Validate the ``gene_type`` parameter and store it on the GA instance. A gene type may be: @@ -329,12 +287,9 @@ def _validate_gene_type(self, ---------- 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. + num_genes : int + Resolved number of genes per solution, inferred from the + supplied population when one is available. Raises ------ @@ -345,6 +300,13 @@ def _validate_gene_type(self, If the per-gene specification has a length different from ``num_genes``, or the precision is not an integer. """ + if type(gene_type) in [list, tuple, numpy.ndarray]: + gene_type = [list(value) if type(value) in [list, tuple, numpy.ndarray] else value + for value in gene_type] + elif gene_type not in self.supported_int_float_types: + self.valid_parameters = False + raise TypeError(f"gene_type must be a supported numeric type or a list, tuple, or NumPy array, but {type(gene_type)} found.") + # Validate gene_type if gene_type in self.supported_int_float_types: self.gene_type = [gene_type, None] @@ -362,17 +324,9 @@ def _validate_gene_type(self, self.gene_type_single = False raise ValueError(f"Integers cannot have precision. Please use the integer data type directly instead of {gene_type}.") elif type(gene_type) in [list, tuple, numpy.ndarray]: - # Get the number of genes before validating the num_genes parameter. - if num_genes is None: - if initial_population is None: - self.valid_parameters = False - raise TypeError("When the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.") - elif not len(gene_type) == len(initial_population[0]): - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_type' is nested, then it can be either [float, int] or with length equal to the number of genes parameter. Instead, value {gene_type} with len(gene_type) ({len(gene_type)}) != number of genes ({len(initial_population[0])}) found.") - elif not len(gene_type) == num_genes: + if len(gene_type) != num_genes: self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_type' is nested, then it can be either [float, int] or with length equal to the value passed to the 'num_genes' parameter. Instead, value {gene_type} with len(gene_type) ({len(gene_type)}) != len(num_genes) ({num_genes}) found.") + raise ValueError(f"When gene_type specifies a type for each gene, its length ({len(gene_type)}) must equal the number of genes ({num_genes}).") for gene_type_idx, gene_type_val in enumerate(gene_type): if gene_type_val in self.supported_int_float_types: # If the gene type is float and no precision is passed or an integer, set its precision to None. @@ -381,7 +335,7 @@ def _validate_gene_type(self, # A float type is expected in a list/tuple/numpy.ndarray of length 2. if len(gene_type_val) == 2: if gene_type_val[0] in self.supported_float_types: - if type(gene_type_val[1]) in self.supported_int_types: + if gene_type_val[1] is None or type(gene_type_val[1]) in self.supported_int_types: pass else: self.valid_parameters = False @@ -409,122 +363,63 @@ def _validate_gene_type(self, raise ValueError(f"The value passed to the 'gene_type' parameter must be either a single integer, floating-point, list, tuple, or numpy.ndarray but ({gene_type}) of type {type(gene_type)} found.") - def _build_initial_population(self, - initial_population, - sol_per_pop, - num_genes, - gene_space, - allow_duplicate_genes, - gene_constraint): + def _validate_initial_population_shape(self, initial_population, sol_per_pop, num_genes): """ - 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. + Validate the population dimensions before any per-gene settings. + A supplied population determines both dimensions, regardless of + the values passed to ``sol_per_pop`` and ``num_genes``. Return an + independent object array so mixed numeric values remain exact. """ - # Build the initial population if initial_population is None: - if (sol_per_pop is None) or (num_genes is None): + if sol_per_pop is None or num_genes is None: self.valid_parameters = False - raise TypeError("Error creating the initial population:\n\nWhen the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.\nThere are 2 options to prepare the initial population:\n1) Assigning the initial population to the 'initial_population' parameter. In this case, the values of the 2 parameters sol_per_pop and num_genes will be deduced.\n2) Assign integer values to the 'sol_per_pop' and 'num_genes' parameters so that PyGAD can create the initial population automatically.") - elif (type(sol_per_pop) is int) and (type(num_genes) is int): - # Validating the number of solutions in the population (sol_per_pop) - if sol_per_pop <= 0: + raise TypeError("When initial_population is None, both sol_per_pop and num_genes must be specified.") + for parameter_name, parameter_value in [('sol_per_pop', sol_per_pop), ('num_genes', num_genes)]: + if type(parameter_value) is not int: self.valid_parameters = False - raise ValueError(f"The number of solutions in the population (sol_per_pop) must be > 0 but ({sol_per_pop}) found. \nThe following parameters must be > 0: \n1) Population size (i.e. number of solutions per population) (sol_per_pop).\n2) Number of selected parents in the mating pool (num_parents_mating).\n") - # Validating the number of gene. - if (num_genes <= 0): + raise TypeError(f"The expected type of the {parameter_name} parameter is int but {type(parameter_value)} found.") + if parameter_value <= 0: self.valid_parameters = False - raise ValueError(f"The number of genes cannot be <= 0 but ({num_genes}) found.\n") - # When initial_population=None and the 2 parameters sol_per_pop and num_genes have valid integer values, then the initial population is created. - # Inside the initialize_population() method, the initial_population attribute is assigned to keep the initial population accessible. - self.num_genes = num_genes # Number of genes in the solution. - - # In case the 'gene_space' parameter is nested, then make sure the number of its elements equals to the number of genes. - if self.gene_space_nested: - if len(gene_space) != self.num_genes: - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") - - # Number of solutions in the population. - self.sol_per_pop = sol_per_pop - self.initialize_population(allow_duplicate_genes=allow_duplicate_genes, - gene_type=self.gene_type, - gene_constraint=gene_constraint) - else: + raise ValueError(f"The value of {parameter_name} must be > 0 but {parameter_value} found.") + population = None + else: + if type(initial_population) not in [list, tuple, numpy.ndarray]: self.valid_parameters = False - raise TypeError(f"The expected type of both the sol_per_pop and num_genes parameters is int but {type(sol_per_pop)} and {type(num_genes)} found.") - elif not type(initial_population) in [list, tuple, numpy.ndarray]: - self.valid_parameters = False - raise TypeError(f"The value assigned to the 'initial_population' parameter is expected to be of type list, tuple, or ndarray but {type(initial_population)} found.") - elif numpy.array(initial_population).ndim != 2: - self.valid_parameters = False - raise ValueError(f"A 2D list is expected to the initial_population parameter but a ({numpy.array(initial_population).ndim}-D) list found.") + raise TypeError(f"The value assigned to the 'initial_population' parameter is expected to be of type list, tuple, or ndarray but {type(initial_population)} found.") + try: + population = numpy.array(initial_population, dtype=object, copy=True) + except ValueError as error: + self.valid_parameters = False + raise ValueError("initial_population must be a rectangular 2D list, tuple, or NumPy array.") from error + if population.ndim != 2 or 0 in population.shape: + self.valid_parameters = False + raise ValueError("initial_population must be a non-empty rectangular 2D list, tuple, or NumPy array.") + for value in population.flat: + if type(value) not in self.supported_int_float_types: + self.valid_parameters = False + raise TypeError(f"The values in the initial population can be integers or floats but the value ({value}) of type {type(value)} found.") + sol_per_pop, num_genes = population.shape + + self.sol_per_pop = sol_per_pop + self.num_genes = num_genes + self.pop_size = (sol_per_pop, num_genes) + return population + + def _build_initial_population(self, initial_population): + """ + Store a generated or supplied population after applying gene types, + constraints, and duplicate repair. Dimensions and numeric values + are validated before this method is called. Supplied values are + preserved even outside the generation range or gene space; only + replacements use the configured generation settings. + """ + if initial_population is None: + self.initialize_population(self.allow_duplicate_genes, self.gene_type, self.gene_constraint) else: - # Validate the type of each value in the 'initial_population' parameter. - for row_idx in range(len(initial_population)): - for col_idx in range(len(initial_population[0])): - if type(initial_population[row_idx][col_idx]) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"The values in the initial population can be integers or floats but the value ({initial_population[row_idx][col_idx]}) of type {type(initial_population[row_idx][col_idx])} found.") - - # Change the data type and round all genes within the initial population. - self.initial_population = self.change_population_dtype_and_round(initial_population) - - if self.allow_duplicate_genes == False: - self.initial_population = self.solve_duplicate_genes_in_population( - self.initial_population, build_initial_pop=True) - - # A NumPy array holding the initial population. - self.population = self.initial_population.copy() - # Number of genes in the solution. - self.num_genes = self.initial_population.shape[1] - # Number of solutions in the population. - self.sol_per_pop = self.initial_population.shape[0] - # The population size. - self.pop_size = (self.sol_per_pop, self.num_genes) - - # Change the data type and round all genes within the initial population. - self.initial_population = self.change_population_dtype_and_round(self.initial_population) - self.population = self.initial_population.copy() - + self.population = self.prepare_initial_population(initial_population) + # Keep separate arrays so evolution cannot modify this snapshot. + self.initial_population = self.population.copy() + def _validate_mutation_range(self, random_mutation_min_val, random_mutation_max_val): @@ -2044,38 +1939,21 @@ def validate_parameters(self, sample_size, allow_duplicate_genes) + # Establish dimensions first, especially when the supplied population + # overrides sol_per_pop and num_genes. + initial_population = self._validate_initial_population_shape( + initial_population, sol_per_pop, num_genes) self._validate_gene_space(gene_space) - - self._validate_init_range(init_range_low, - init_range_high, - num_genes, - initial_population) - - self._validate_gene_type(gene_type, - num_genes, - initial_population) - - # Repair can call constraints while building either generated or - # manually supplied populations. Validate and store them first. - if initial_population is not None and numpy.asarray(initial_population).ndim == 2: - self.num_genes = numpy.asarray(initial_population).shape[1] - else: - self.num_genes = num_genes + self._validate_init_range(init_range_low, init_range_high, self.num_genes) + self._validate_gene_type(gene_type, self.num_genes) self._validate_gene_constraint(gene_constraint) if self.gene_space_nested and len(gene_space) != self.num_genes: self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") - - # Call the unpack_gene_space() method in the pygad.helper.unique.Unique class. - self.gene_space_unpacked = self.unpack_gene_space(range_min=self.init_range_low, - range_max=self.init_range_high) - - self._build_initial_population(initial_population, - sol_per_pop, - num_genes, - gene_space, - allow_duplicate_genes, - gene_constraint) + raise ValueError(f"When gene_space is nested, its length ({len(gene_space)}) must equal the number of genes ({self.num_genes}).") + + self.gene_space_unpacked = self.unpack_gene_space( + range_min=self.init_range_low, range_max=self.init_range_high) + self._build_initial_population(initial_population) self._validate_mutation_range(random_mutation_min_val, random_mutation_max_val) diff --git a/tests/test_initial_population.py b/tests/test_initial_population.py new file mode 100644 index 00000000..384c03a5 --- /dev/null +++ b/tests/test_initial_population.py @@ -0,0 +1,287 @@ +"""Tests for generating and accepting initial populations.""" + +import copy + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(numpy.sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=1, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=50, num_genes=3, + mutation_type=None, crossover_type=None, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("container", [list, tuple, numpy.array]) +def test_supplied_population_determines_dimensions_before_per_gene_validation(container): + population = container([[1, 2, 3], [4, 5, 6]]) + original_population = copy.deepcopy(population) + ga_instance = make_ga(initial_population=population, sol_per_pop=-1, + num_genes=99, gene_type=(int, float, numpy.int8), + gene_space=[[1, 4], [2, 5], [3, 6]], + init_range_low=[0, 0, 0], init_range_high=[10, 10, 10], + gene_constraint=[None, None, None]) + assert ga_instance.sol_per_pop == 2 + assert ga_instance.num_genes == 3 + assert ga_instance.pop_size == (2, 3) + numpy.testing.assert_array_equal(population, original_population) + numpy.testing.assert_array_equal(ga_instance.population, original_population) + assert not numpy.shares_memory(ga_instance.population, ga_instance.initial_population) + + +@pytest.mark.parametrize("population", [[], [[]], [[1], [2, 3]], [1, 2], + numpy.empty((0, 3)), numpy.empty((3, 0)), + numpy.zeros((2, 2, 2))]) +def test_invalid_population_shape_has_a_descriptive_error(population): + with pytest.raises(ValueError, match="non-empty rectangular 2D"): + make_ga(initial_population=population, gene_type=(int, float, int)) + + +@pytest.mark.parametrize("population", ["population", 5, [[None]], [[True]], + [["1"]], [[1 + 2j]]]) +def test_invalid_population_type_is_rejected(population): + with pytest.raises(TypeError, match="initial population|initial_population"): + make_ga(initial_population=population) + + +@pytest.mark.parametrize("gene_type", [[int, [numpy.float32, 2], numpy.int8], + (int, (numpy.float32, 2), numpy.int8), + numpy.array([int, [numpy.float32, 2], numpy.int8], dtype=object)]) +def test_gene_type_specifications_are_not_modified(gene_type): + original_specification = copy.deepcopy(gene_type) + ga_instance = make_ga(initial_population=((2**53 + 1, 1.236, 7), (2**53 + 3, 2.341, 8)), + gene_type=gene_type) + assert ga_instance.population[0, 0] == 2**53 + 1 + assert ga_instance.population[1, 0] == 2**53 + 3 + assert type(ga_instance.population[0, 1]) is numpy.float32 + assert type(ga_instance.population[0, 2]) is numpy.int8 + assert ga_instance.population[0, 1] == numpy.float32(1.24) + for original, actual in zip(original_specification, gene_type): + if isinstance(original, (list, tuple, numpy.ndarray)): + assert list(original) == list(actual) + else: + assert original is actual + + +@pytest.mark.parametrize("gene_type", [(float, None, ), ((float, None), int, float)]) +def test_float_types_accept_an_explicit_none_precision(gene_type): + ga_instance = make_ga(gene_type=gene_type) + assert ga_instance.population.shape == (50, 3) + + +@pytest.mark.parametrize("gene_type", [int, numpy.int8, numpy.uint8, float, + [float, 1], [numpy.float16, 1], + [numpy.float16, 5], numpy.float32]) +@pytest.mark.parametrize("bounds", [(0.2, 3.8), (-3.8, -0.2), (3.8, 0.2)]) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_range_values_remain_inside_bounds_after_conversion(gene_type, bounds, use_gene_space): + lower, upper = bounds + if gene_type is numpy.uint8 and max(bounds) < 0: + with pytest.raises(ValueError, match="representable"): + make_ga(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + return + options = dict(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + if use_gene_space: + options['gene_space'] = {'low': lower, 'high': upper} + ga_instance = make_ga(**options) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) >= min(bounds)) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) < max(bounds)) + + +@pytest.mark.parametrize("gene_space", [None, [None, None, None], + [[None], [None], [None]]]) +def test_none_entries_follow_their_own_ranges(gene_space): + ga_instance = make_ga(gene_space=gene_space, gene_type=(int, (float, 1), numpy.float32), + init_range_low=[2.2, -1.3, 20.0], + init_range_high=[6.1, -0.2, 21.0]) + for gene_index, (lower, upper) in enumerate(zip([2.2, -1.3, 20.0], [6.1, -0.2, 21.0])): + column = ga_instance.population[:, gene_index] + assert numpy.all(column >= lower) and numpy.all(column < upper) + assert len(numpy.unique(column)) > 1 + + +@pytest.mark.parametrize("gene_space", [[10, 20], (10, 20), range(10, 21, 10), + numpy.array([10, 20]), + {'low': 10, 'high': 21, 'step': 10}, + {'low': 20, 'high': 9, 'step': -10}]) +def test_finite_spaces_override_initialization_ranges(gene_space): + ga_instance = make_ga(gene_space=gene_space, gene_type=int, + init_range_low=-5, init_range_high=-1) + assert set(ga_instance.population.flat) == {10, 20} + + +def test_nested_spaces_support_fixed_values_dictionaries_and_none_choices(): + space = [5, {'low': 10, 'high': 13, 'step': 1}, [None, 100]] + original_space = copy.deepcopy(space) + ga_instance = make_ga(gene_space=space, gene_type=int, + init_range_low=[0, 0, 20], init_range_high=[1, 1, 30]) + assert numpy.all(ga_instance.population[:, 0] == 5) + assert set(ga_instance.population[:, 1]).issubset({10, 11, 12}) + values = set(ga_instance.population[:, 2]) + assert 100 in values and len(values) > 2 + assert values.issubset(set(range(20, 30)) | {100}) + assert space == original_space + + +@pytest.mark.parametrize("gene_space", [None, {'low': 0, 'high': 10**9}, + [[None], [None], [None]]]) +def test_large_integer_ranges_do_not_allocate_the_whole_domain(monkeypatch, gene_space): + def fail_if_enumerated(*args, **kwargs): + raise AssertionError("The initialization range must be sampled directly.") + monkeypatch.setattr(numpy, 'arange', fail_if_enumerated) + ga_instance = make_ga(gene_type=int, gene_space=gene_space, + init_range_low=0, init_range_high=10**9) + assert ga_instance.population.shape == (50, 3) + + +def test_large_integer_bounds_preserve_exact_values(): + ga_instance = make_ga(gene_type=int, init_range_low=2**53 + 1, + init_range_high=2**53 + 4) + assert set(ga_instance.population.flat) == {2**53 + 1, 2**53 + 2, 2**53 + 3} + + +def test_large_integer_constraint_search_does_not_allocate_the_whole_domain(monkeypatch): + def fail_if_enumerated(*args, **kwargs): + raise AssertionError("Large initialization intervals must be sampled for constraints.") + monkeypatch.setattr(numpy, 'arange', fail_if_enumerated) + ga_instance = make_ga(gene_type=int, init_range_low=0, init_range_high=10**9, + gene_constraint=[lambda solution, values: [], None, None]) + assert ga_instance.population.shape == (50, 3) + + +@pytest.mark.parametrize("gene_type", [numpy.int64, numpy.uint64]) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_integer_type_limits_do_not_overflow_during_sampling(gene_type, use_gene_space): + upper = int(numpy.iinfo(gene_type).max) + 1 + space = {'low': upper - 3, 'high': upper} if use_gene_space else None + ga_instance = make_ga(gene_type=gene_type, init_range_low=upper - 3, + init_range_high=upper, gene_space=space) + assert set(int(value) for value in ga_instance.population.flat) == {upper - 3, upper - 2, upper - 1} + if use_gene_space: + assert set(int(value) for value in ga_instance.gene_space_unpacked) == {upper - 3, upper - 2, upper - 1} + + +@pytest.mark.parametrize("gene_type,lower,upper", [(int, 0.1, 0.9), + (numpy.int8, 128, 130), + ([float, 1], 0.01, 0.09), + (int, 1.5, 1.5)]) +def test_ranges_without_representable_values_fail_clearly(gene_type, lower, upper): + with pytest.raises(ValueError, match="representable.*gene"): + make_ga(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + + +@pytest.mark.parametrize("gene_type", [int, float, [float, 1]]) +def test_equal_bounds_generate_a_fixed_representable_value(gene_type): + ga_instance = make_ga(gene_type=gene_type, init_range_low=2, init_range_high=2) + assert numpy.all(ga_instance.population == 2) + + +@pytest.mark.parametrize("dtype", [numpy.float16, numpy.float32, numpy.float64]) +def test_rounding_uses_the_stored_numpy_value_at_decimal_bounds(dtype): + ga_instance = make_ga(gene_type=[dtype, 1], init_range_low=0.1, init_range_high=0.2) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) >= 0.1) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) < 0.2) + + +def test_supplied_values_outside_the_generation_domain_are_preserved(): + ga_instance = make_ga(initial_population=((100, -100, 30), (20, 30, 40)), + gene_space=[0, 1, 2], gene_type=int, + init_range_low=0, init_range_high=3) + numpy.testing.assert_array_equal(ga_instance.population, [[100, -100, 30], [20, 30, 40]]) + + +def test_supplied_population_constraints_use_initialization_candidates(): + population = [[1.236, 1, 1], [1.236, 1, 1]] + ga_instance = make_ga(initial_population=population, gene_type=([float, 2], int, int), + gene_space=[[1.236], [2, 3], [4, 5]], sample_size=1, + gene_constraint=[lambda solution, values: [value for value in values if value == 1.24], + lambda solution, values: [value for value in values if value > 2], + lambda solution, values: [value for value in values if value > solution[1]]]) + assert numpy.all(ga_instance.population[:, 0] == 1.24) + assert numpy.all(ga_instance.population[:, 1] == 3) + assert numpy.all(ga_instance.population[:, 2] > 3) + assert population == [[1.236, 1, 1], [1.236, 1, 1]] + + +def test_failed_constraints_warn_without_modifying_the_supplied_input(): + with pytest.warns(UserWarning, match="No value satisfied"): + ga_instance = make_ga(initial_population=[[1, 2, 3], [1, 2, 3]], + gene_space=[1, 2, 3], suppress_warnings=False, + gene_constraint=[lambda solution, values: [], None, None]) + assert numpy.all(ga_instance.population[:, 0] == 1) + + +def test_constraints_receive_an_independent_complete_solution(): + def constraint(solution, values): + assert len(solution) == 3 and all(value is not None for value in solution) + solution[1] = -100 + return values + ga_instance = make_ga(initial_population=[[1, 2, 3], [1, 2, 3]], + gene_constraint=[constraint, None, None]) + assert numpy.all(ga_instance.population[:, 1] == 2) + + +def test_growth_and_initialization_use_the_same_rules(): + ga_instance = make_ga(num_genes=4, gene_type=(int, int, int, int), + gene_space=[[0, 1], [1, 2], [2, 3], [0]], + allow_duplicate_genes=False, + gene_constraint=[None, None, lambda solution, values: [value for value in values if value >= 2], None]) + extra = ga_instance._nsga3_generate_extra_random_solutions(12) + assert extra.shape == (12, 4) + for solution in numpy.vstack([ga_instance.population, extra]): + assert len(set(solution)) == 4 + assert solution[3] == 0 and solution[2] >= 2 + + +def test_reinitialization_uses_its_constraint_and_duplicate_settings(): + ga_instance = make_ga(gene_space=[1, 2, 3], gene_type=int) + constraints = [lambda solution, values: [value for value in values if value == 3], None, None] + ga_instance.initialize_population(False, ga_instance.gene_type, constraints) + assert numpy.all(ga_instance.population[:, 0] == 3) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + numpy.testing.assert_array_equal(ga_instance.population, ga_instance.initial_population) + assert not numpy.shares_memory(ga_instance.population, ga_instance.initial_population) + + +def test_seed_reproduces_generated_population_with_none_entries(): + options = dict(gene_space=[[1, None], {'low': -2, 'high': 3}, range(5)], + gene_type=(int, [float, 2], int)) + first = make_ga(**options) + second = make_ga(**options) + numpy.testing.assert_array_equal(first.population, second.population) + + +@pytest.mark.parametrize("gene_space", [None, {'low': 0.0, 'high': 1.0}]) +def test_continuous_population_keeps_solution_then_gene_draw_order(gene_space): + numpy.random.seed(7) + expected = numpy.random.uniform(0, 1, size=(50, 3)) + ga_instance = make_ga(gene_space=gene_space, init_range_low=0, init_range_high=1) + numpy.testing.assert_array_equal(ga_instance.population, expected) + + +@pytest.mark.parametrize("result", [lambda values: [values[0], values[0]], + lambda values: [1000], lambda values: None]) +def test_invalid_constraint_outputs_are_rejected(result): + with pytest.raises(Exception, match="constraint"): + make_ga(gene_constraint=[lambda solution, values: result(values), None, None]) + + +@pytest.mark.parametrize("options,error", [({'init_range_low': [0, 1], 'init_range_high': [2, 3]}, ValueError), + ({'init_range_low': [[0], [1], [2]], 'init_range_high': [2, 3, 4]}, ValueError), + ({'init_range_low': numpy.nan}, ValueError), + ({'gene_space': {'low': '0', 'high': 3}}, TypeError), + ({'gene_space': {'low': 0, 'high': numpy.inf}}, ValueError), + ({'gene_space': {'low': 0, 'high': 3, 'step': 0}}, ValueError), + ({'gene_space': [{'low': 0, 'high': 3, 'step': -1}]}, ValueError)]) +def test_invalid_generation_settings_are_rejected_early(options, error): + with pytest.raises(error): + make_ga(**options) From 0f218232138434276a5c13d52e88fe9f9eedf6f8 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 20:52:54 -0400 Subject: [PATCH 05/22] Unify gene type conversion and rounding across the GA lifecycle --- docs/source/gene_values.md | 19 +- docs/source/pygad.md | 11 +- docs/source/releases.md | 2 + docs/source/user_defined_operators.md | 2 + examples/example_gene_type_conversion.py | 44 +++ pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 227 ++++++++------- pygad/helper/unique.py | 6 +- pygad/utils/__init__.py | 2 +- pygad/utils/engine.py | 78 ++++-- pygad/utils/mutation.py | 24 +- pygad/utils/validation.py | 139 ++++------ tests/test_gene_type_conversion.py | 333 +++++++++++++++++++++++ 13 files changed, 653 insertions(+), 236 deletions(-) create mode 100644 examples/example_gene_type_conversion.py create mode 100644 tests/test_gene_type_conversion.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 8140d3e7..953abcb6 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -185,7 +185,7 @@ For the second gene, its space is set to `None`. So, traditional mutation happen 1. Generating a random value from the range defined by the `random_mutation_min_val` and `random_mutation_max_val` parameters. 2. Adding this random value to the current gene's value. -If its current value is 5 and the random value is `-0.5`, then the new value is 4.5. If the gene type is integer, then the value will be rounded. +If its current value is 5 and the random value is `-0.5`, then the new value is 4.5. If the gene type is integer, then conversion truncates the fractional part, giving 4. On the other hand, if a gene has a **continuous space** defined in the `gene_space` parameter, then mutation occurs by adding a random value to the current gene value. @@ -507,6 +507,21 @@ The `gene_type` parameter allows the user to control the data type for all genes Let us look at some examples. +### Conversion and Rounding Rules + +PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. + +- Integer conversion truncates the fractional part towards zero. For example, `1.9` becomes `1` and `-1.9` becomes `-1`. Additive mutation adds the random value before converting the result. +- Floating-point precision rounds before casting to the requested type. This avoids rounding in a narrow type such as `numpy.float16` before the final conversion. +- Precision must be an integer or `None`. `None` keeps the value unrounded, `0` rounds to whole numbers, and a negative precision rounds to positions before the decimal point. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. +- Rounding uses NumPy's nearest-even rule at halfway points: `0.5` rounds to `0.0` and `1.5` rounds to `2.0` with precision `0`. + +Floating-point types use binary representations, so a stored value can differ slightly from its decimal representation. Requested precision does not increase the accuracy or range of the selected type. For unusually large values or precisions where NumPy's intermediate decimal scaling overflows, PyGAD uses scalar rounding to preserve finite values before the final cast. + +When types are specified per gene, population arrays use `dtype=object` so each column can retain its requested Python or NumPy scalar type. Saved best solutions retain these types too. This also preserves large integers when other genes are floating-point values. A NumPy array constructed without `dtype=object` can already lose integer precision through conversion to a shared floating-point type; use a list or an object array for mixed input values that must remain exact. + +The new `examples/example_gene_type_conversion.py` demonstrates mixed types, rounding, and a custom mutation function. + ### Data Type for All Genes without Precision The data type for all genes can be specified by assigning the numeric data type directly to the `gene_type` parameter. This is an example to make all genes of `int` data types. @@ -515,7 +530,7 @@ The data type for all genes can be specified by assigning the numeric data type gene_type=int ``` -Given that the supported numeric data types of PyGAD include Python's `int` and `float` in addition to all numeric types of `NumPy`, then any of these types can be assigned to the `gene_type` parameter. +The supported numeric types include Python's `int` and `float`, NumPy's signed and unsigned integer types with widths of 8, 16, 32, and 64 bits, and `numpy.float16`, `numpy.float32`, and `numpy.float64`. Any of these types can be assigned to `gene_type`. If no precision is specified for a `float` data type, then the complete floating-point number is kept. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index f2dd1d28..3a761163 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -113,10 +113,14 @@ Sets the data type (and optional precision) of the genes. It defaults to `float` You can set it to: -- **One type for all genes:** a numeric type such as `int`, `float`, or any `numpy.int/uint/float(8-64)` type. Example: `gene_type=int`. +- **One type for all genes:** `int`, `float`, NumPy signed or unsigned integer types with widths of 8, 16, 32, or 64 bits, or `numpy.float16`, `numpy.float32`, or `numpy.float64`. Example: `gene_type=int`. - **A type per gene:** a `list`, `tuple`, or `numpy.ndarray` with one type per gene. Example: `gene_type=[int, float, numpy.int8]`. - **A float precision:** pair a `float` type with the number of decimal places. Example: `gene_type=[float, 2]`. +Integer conversion truncates towards zero. Floating-point values are rounded before casting, using nearest-even rounding at halfway points. Precision can be `None` to leave values unrounded, or an integer including `0` and negative values. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. + +The same rules apply to initialization, mutation, custom operators, and the outputs of `on_parents`, `on_crossover`, and `on_mutation`, independently of `allow_duplicate_genes`. Per-gene type specifications and saved best solutions preserve mixed scalar types using object arrays. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). + Version history: - [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0): a single numeric type can be used. @@ -596,8 +600,9 @@ Constructor settings and user callables are stored as instance attributes, with - `get_initial_population_gene_candidates(gene_index, sample_size, all_integer_values=True)`: Return converted replacement candidates for initialization constraints and duplicate repair. - `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. +- `change_gene_dtype_and_round(gene_index, gene_value)`: Apply one gene's type and precision to a scalar or an array of candidates, preserving the input shape. +- `round_genes(solutions)`: Convert and round genes in a 2D array according to `self.gene_type`. Update the input array when its dtype matches the converted output. +- `prepare_operator_output(population, build_initial_pop=False)`: Apply gene types and precision, then repair duplicates if `allow_duplicate_genes=False`. SBX and polynomial mutation use initialization bounds for repair. - `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. diff --git a/docs/source/releases.md b/docs/source/releases.md index 555e7ab6..27bddb8d 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -746,4 +746,6 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 18. Initial population creation and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. +19. Gene-type validation and conversion share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index e990ae2a..ad64f14f 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -12,6 +12,8 @@ Starting from [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.htm When `allow_duplicate_genes=False`, PyGAD applies its shared duplicate repair to custom crossover and mutation outputs after the corresponding callback has finished. Values are converted and rounded before repair. This also handles duplicate values returned or changed in place by `on_crossover` and `on_mutation`. If the configured spaces, ranges, or constraints leave no usable alternative, duplicates can remain with a warning. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). +PyGAD applies `gene_type` and its precision to custom parent selection, crossover, and mutation outputs before the corresponding callback receives them. Values returned or changed in place by `on_parents`, `on_crossover`, and `on_mutation` are converted again before use, even when duplicates are allowed. When constructing an output array containing both large integers and floating-point values, use `numpy.array(values, dtype=object)` to preserve the original values until each gene's type is applied. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). + This is a sample code that does not use any custom function. ```python diff --git a/examples/example_gene_type_conversion.py b/examples/example_gene_type_conversion.py new file mode 100644 index 00000000..a176db4c --- /dev/null +++ b/examples/example_gene_type_conversion.py @@ -0,0 +1,44 @@ +import numpy +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + # Keep the integer exact while comparing it with the target. + integer_error = abs(solution[0] - (2**53 + 1)) + return 1.0 / (1.0 + integer_error + abs(solution[1] - 1.25)) + + +def mutation_func(offspring, ga_instance): + # Object arrays preserve mixed integer and floating-point values. + offspring = offspring.copy() + offspring[:, 1] = [float(value) + 0.126 for value in offspring[:, 1]] + # PyGAD applies each gene's type and precision to the returned values. + return offspring + + +initial_population = [[2**53 + 1, 1.236, 3.9], + [2**53 + 2, 1.754, 4.1], + [2**53 + 3, 2.345, 5.8], + [2**53 + 4, 2.876, 6.2]] + +ga_instance = pygad.GA(num_generations=3, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_type=[int, [numpy.float32, 2], numpy.int8], + mutation_type=mutation_func, + mutation_num_genes=1, + save_best_solutions=True, + random_seed=7) + +print("Initial Population") +print(ga_instance.initial_population) + +ga_instance.run() + +print("Final Population") +print(ga_instance.population) +print("Gene Types") +print([type(value) for value in ga_instance.population[0]]) +print("Saved Best Solutions") +print(ga_instance.best_solutions) diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index cee87778..c384bfb1 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.2" +__version__ = "1.4.3" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 503039d5..08c364e7 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -308,79 +308,86 @@ def change_population_dtype_and_round(self, """ if self.gene_type_single: - dtype, precision = self.gene_type - if precision is None: - return numpy.array(population, dtype=dtype, copy=True) - # Round before casting so narrow NumPy types do not lose - # precision before the requested decimal rounding is applied. - population_new = numpy.round(numpy.asarray(population, dtype=float), precision) - return numpy.asarray(population_new, dtype=dtype) - - # Keep each input value exact until its own column is converted. - # A shared floating dtype could lose large integer values in - # solutions containing both integer and floating-point genes. + return self._convert_gene_values(population, self.gene_type) + + # An object array keeps each value exact until its own type is + # applied. Group matching types and precisions to convert whole + # blocks instead of converting every scalar separately. population = numpy.asarray(population, dtype=object) population_new = numpy.empty(population.shape, dtype=object) - for gene_index in range(population.shape[1]): - values = self.change_gene_dtype_and_round(gene_index, population[:, gene_index]) - dtype = self.gene_type[gene_index][0] - for solution_index, value in enumerate(values): - # Assign scalars individually to preserve their configured - # types in the object array, including NumPy numeric types. - population_new[solution_index, gene_index] = dtype(value) + gene_columns_by_type = {} + for gene_index, gene_type in enumerate(self.gene_type): + gene_columns_by_type.setdefault(tuple(gene_type), []).append(gene_index) + for gene_type, gene_indices in gene_columns_by_type.items(): + values = self._convert_gene_values(population[:, gene_indices], gene_type) + dtype = gene_type[0] + if dtype in [int, float, object]: + population_new[:, gene_indices] = values.astype(object) + else: + # astype(object) alone turns NumPy scalars into Python + # numbers. Preserve explicitly requested NumPy types. + population_new[:, gene_indices] = numpy.frompyfunc(dtype, 1, 1)(values) return population_new - def change_gene_dtype_and_round(self, - gene_index, - gene_value): + def change_gene_dtype_and_round(self, gene_index, gene_value): """ - 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. + Convert a scalar or an array of candidates using one gene's type + and precision. Return a scalar for a scalar input, or an array + with the input shape. The input is not modified. Parameters ---------- gene_index : int - Index of the gene whose dtype / precision should be used. + Index of the gene whose type and precision are applied. gene_value : numeric or iterable - Either a single value or a vector of values for that - gene. + A single value or an array of candidate values for this gene. Returns ------- - gene_value_new : numeric - The first (or only) value after casting and rounding. + numeric or numpy.ndarray + The converted scalar or array of candidates. """ + return self._convert_gene_values(gene_value, self.get_gene_dtype(gene_index)) - if self.gene_type_single == True: - dtype = self.gene_type[0] - if self.gene_type[1] is None: - # No rounding for this gene. Use the old gene value. - round_precision = None - else: - round_precision = self.gene_type[1] - else: - dtype = self.gene_type[gene_index][0] - if self.gene_type[gene_index][1] is None: - # No rounding for this gene. Use the old gene value. - round_precision = None - else: - round_precision = self.gene_type[gene_index][1] - - # Sometimes the values represent the gene_space when it is not nested (e.g. gene_space=range(10)) - # Copy it to avoid changing the original gene_space. - gene_value = [gene_value].copy() - - # Round the number before changing its data type to avoid precision loss for some data types like numpy.float32. - if round_precision is None: - pass + def _convert_gene_values(self, values, gene_type): + """ + Apply the shared conversion rule: round floating-point values + before casting to the requested type. Integer casts truncate + towards zero. None precision leaves values unrounded. NumPy's + rounding rule selects the nearest even value at halfway points. + """ + dtype, precision = gene_type + if precision is None: + if numpy.isscalar(values): + return values if dtype is object else dtype(values) + converted_values = numpy.array(values, dtype=dtype, copy=True) else: - gene_value = numpy.round(numpy.asarray(gene_value, dtype=float), round_precision) - - gene_value_new = numpy.asarray(gene_value, dtype=dtype) - gene_value_new = gene_value_new[0] - - return gene_value_new + rounded_values = self._round_gene_values(numpy.asarray(values, dtype=float), precision) + converted_values = numpy.asarray(rounded_values, dtype=dtype) + if converted_values.ndim == 0: + value = converted_values[()] + return value if dtype is object else dtype(value) + return converted_values + + def _round_gene_values(self, values, precision): + """ + Use NumPy's array rounding, preserving finite inputs when its + decimal scaling overflows. Python's scalar round handles those + uncommon values without the intermediate scaling operation. + """ + try: + with numpy.errstate(over='ignore', invalid='ignore', divide='ignore'): + rounded_values = numpy.round(values, precision) + except OverflowError: + # NumPy limits decimals to a C integer; Python round accepts + # the full integer precision supplied by the user. + return numpy.asarray(numpy.frompyfunc(lambda value: round(float(value), precision), 1, 1)(values), dtype=float) + invalid_results = numpy.isfinite(values) & ~numpy.isfinite(rounded_values) + if numpy.any(invalid_results): + rounded_values = numpy.asarray(rounded_values).copy() + rounded_values[invalid_results] = [round(float(value), precision) + for value in numpy.atleast_1d(values[invalid_results])] + return rounded_values def mutation_change_gene_dtype_and_round(self, random_value, @@ -413,15 +420,19 @@ def mutation_change_gene_dtype_and_round(self, """ if mutation_by_replacement: - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - gene_value = random_value + mutated_value = random_value else: - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - gene_value = gene_value + random_value - - gene_value_new = self.change_gene_dtype_and_round(gene_index=gene_index, - gene_value=gene_value) - return gene_value_new + # NumPy can add narrow scalars in their original dtype, losing + # precision or overflowing before the final cast. Python + # numeric values keep integer addition exact and float + # addition in the working precision used by the converter. + gene_value = gene_value.item() if isinstance(gene_value, numpy.generic) else gene_value + if numpy.ndim(random_value) == 0: + random_value = random_value.item() if isinstance(random_value, (numpy.generic, numpy.ndarray)) else random_value + mutated_value = gene_value + random_value + else: + mutated_value = gene_value + numpy.asarray(random_value, dtype=object) + return self.change_gene_dtype_and_round(gene_index, mutated_value) def validate_gene_constraint_callable_output(self, selected_values, @@ -677,7 +688,7 @@ def _initial_population_range_values(self, gene_index, lower, upper, num_values, """ lower, upper = sorted([lower, upper]) dtype = self.get_gene_dtype(gene_index)[0] - if dtype in self.supported_int_types: + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) if first_value > last_value: raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type for the gene at index {gene_index}.") @@ -693,6 +704,10 @@ def _initial_population_range_values(self, gene_index, lower, upper, num_values, def _initial_population_integer_bounds(self, gene_index, lower, upper): """Return the first and last representable integers in an interval.""" + # Python math functions may coerce NumPy integers to floats. + # Use their exact Python values before computing integer bounds. + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper lower, upper = sorted([lower, upper]) type_limits = numpy.iinfo(self.get_gene_dtype(gene_index)[0]) first_value = max(math.ceil(lower), int(type_limits.min)) @@ -704,7 +719,7 @@ def _initial_population_integer_bounds(self, gene_index, lower, upper): def _initial_population_range_snapshot(self, gene_index, lower, upper, sample_size): """Create an inspection sample without allocating a range or drawing random values.""" dtype = self.get_gene_dtype(gene_index)[0] - if dtype in self.supported_int_types: + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) count = min(sample_size, last_value - first_value + 1) if count <= 0: @@ -723,30 +738,40 @@ def _convert_initial_population_range_values(self, gene_index, lower, upper, val lower = lower.item() if isinstance(lower, numpy.generic) else lower upper = upper.item() if isinstance(upper, numpy.generic) else upper dtype, precision = self.get_gene_dtype(gene_index) + if dtype is object: + dtype = float # Rounding or a narrow NumPy dtype can reach the excluded upper # bound. Keep sampled values within the representable interval. first_value = self.change_gene_dtype_and_round(gene_index, lower) last_value = self.change_gene_dtype_and_round(gene_index, upper) if lower != upper: - if precision is None: + # At 324 decimal places, a decimal step is smaller than the + # smallest positive float64 value used during rounding. + if precision is None or precision >= 324: if float(first_value) < lower: first_value = numpy.nextafter(first_value, dtype(numpy.inf), dtype=dtype) if float(last_value) >= upper: last_value = numpy.nextafter(last_value, dtype(-numpy.inf), dtype=dtype) else: - precision_step = 10.0 ** -precision + if float(first_value) < lower or float(last_value) >= upper: + try: + precision_step = 10.0 ** -precision + except OverflowError: + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type and its precision for the gene at index {gene_index}.") from None if float(first_value) < lower: first_unrounded_value = dtype(lower) if float(first_unrounded_value) < lower: first_unrounded_value = numpy.nextafter(first_unrounded_value, dtype(numpy.inf), dtype=dtype) - first_value = self.change_gene_dtype_and_round( - gene_index, numpy.ceil(float(first_unrounded_value) / precision_step) * precision_step) + decimal_units = float(first_unrounded_value) / precision_step + rounded_bound = numpy.ceil(decimal_units) * precision_step if numpy.isfinite(decimal_units) else first_unrounded_value + first_value = self.change_gene_dtype_and_round(gene_index, rounded_bound) if float(last_value) >= upper: last_unrounded_value = dtype(upper) if float(last_unrounded_value) >= upper: last_unrounded_value = numpy.nextafter(last_unrounded_value, dtype(-numpy.inf), dtype=dtype) - last_value = self.change_gene_dtype_and_round( - gene_index, numpy.floor(float(last_unrounded_value) / precision_step) * precision_step) + decimal_units = float(last_unrounded_value) / precision_step + rounded_bound = numpy.floor(decimal_units) * precision_step if numpy.isfinite(decimal_units) else last_unrounded_value + last_value = self.change_gene_dtype_and_round(gene_index, rounded_bound) if (not numpy.isfinite(first_value) or not numpy.isfinite(last_value) or float(first_value) < lower or first_value > last_value or (lower != upper and float(last_value) >= upper) @@ -800,18 +825,20 @@ def get_gene_space_values(self, gene_idx, gene_value=None, values = self.generate_gene_value_randomly( range_min, range_max, gene_value, gene_idx, mutation_by_replacement, - sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) elif type(space) is dict: if 'step' in space: values = numpy.arange(space['low'], space['high'], space['step']) - elif dtype[0] in pygad.GA.supported_int_types: + elif numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): # A continuous dictionary with fractional bounds can cast # to an integer near either end, not just values on a grid # starting at low. Include every representable integer. lower, upper = sorted([space['low'], space['high']]) - first_value = int(numpy.trunc(lower)) - last_value = int(numpy.trunc(numpy.nextafter(float(upper), -numpy.inf))) - values = numpy.arange(first_value, last_value + 1) + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + first_value = math.trunc(lower) + last_value = math.ceil(upper) - 1 if upper > 0 else math.trunc(upper) + values = numpy.arange(first_value, last_value + 1, dtype=dtype[0]) else: values = numpy.random.uniform(space['low'], space['high'], size=sample_size) elif type(space) in pygad.GA.supported_int_float_types: @@ -822,24 +849,22 @@ def get_gene_space_values(self, gene_idx, gene_value=None, random_values = self.generate_gene_value_randomly( range_min, range_max, gene_value, gene_idx, True, - sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) values.extend(numpy.atleast_1d(random_values)) - values = self.change_gene_dtype_and_round(gene_idx, numpy.atleast_1d(values)) - if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: + # Convert directly from the input values so mixed finite spaces + # do not promote large integers to a shared floating-point type. + values = self.change_gene_dtype_and_round(gene_idx, values) + if type(space) is dict and 'step' not in space and not numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): # Rounding may reach the excluded upper bound. Such a value # cannot be selected from this continuous space. - values = values[(values >= space['low']) & (values < space['high'])] + compared_values = values.astype(float) + values = values[(compared_values >= space['low']) & (compared_values < space['high'])] if len(values) == 0: # A single sample can round to the upper bound. Use a # representable in-range value instead of failing randomly. - lower = space['low'] - if dtype[1] is not None: - precision_step = 10.0 ** -dtype[1] - lower = numpy.ceil(lower / precision_step) * precision_step - value = self.change_gene_dtype_and_round(gene_idx, lower) - if space['low'] <= value < space['high']: - values = numpy.atleast_1d(value) + values = self._convert_initial_population_range_values( + gene_idx, space['low'], space['high'], [space['low']]) return numpy.unique(values) def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): @@ -850,7 +875,7 @@ def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): """ space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space dtype = self.get_gene_dtype(gene_idx) - if type(space) is dict and 'step' not in space and dtype[0] not in pygad.GA.supported_int_types: + if type(space) is dict and 'step' not in space and not numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): return space['low'] <= gene_value < space['high'] has_none = space is None if type(space) in [list, tuple, numpy.ndarray, range]: @@ -862,7 +887,7 @@ def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): if has_none: range_min, range_max = self.get_random_mutation_range(gene_idx) replacement = self.mutation_by_replacement if space is None else True - if dtype[0] in pygad.GA.supported_int_types: + if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): values = self.generate_gene_value_randomly( range_min, range_max, current_gene_value, gene_idx, replacement, sample_size=None) @@ -962,16 +987,13 @@ def generate_gene_value_randomly(self, """ gene_type = self.get_gene_dtype(gene_index=gene_idx) - if gene_type[0] in pygad.GA.supported_int_types: + if numpy.issubdtype(numpy.dtype(gene_type[0]), numpy.integer): if range_min == range_max: - random_value = numpy.asarray([range_min], dtype=gene_type[0]) + random_value = numpy.asarray([range_min]) else: if step > 0: range_min, range_max = min(range_min, range_max), max(range_min, range_max) - random_value = numpy.asarray(numpy.arange(range_min, - range_max, - step=step), - dtype=gene_type[0]) + random_value = numpy.arange(range_min, range_max, step=step) if sample_size is None: # Keep all the values. pass @@ -981,7 +1003,7 @@ def generate_gene_value_randomly(self, # Makes no sense to create a larger sample out of the population because it just creates redundant values. pass else: - # Set replace=True to avoid selecting the same value more than once. + # Sample without replacement to avoid repeated candidates. random_value = numpy.random.choice(random_value, size=sample_size, replace=False) @@ -992,12 +1014,9 @@ def generate_gene_value_randomly(self, size=1 if sample_size is None else sample_size), dtype=object) - # Change the random mutation value data type. - for idx, val in enumerate(random_value): - random_value[idx] = self.mutation_change_gene_dtype_and_round(random_value[idx], - gene_idx, - gene_value, - mutation_by_replacement=mutation_by_replacement) + # Apply the offset and convert the entire candidate array once. + random_value = self.mutation_change_gene_dtype_and_round( + random_value, gene_idx, gene_value, mutation_by_replacement) # Rounding different values could return the same value multiple times. # For example, 2.8 and 2.7 will be 3.0. diff --git a/pygad/helper/unique.py b/pygad/helper/unique.py index 2d25cd39..41cacec3 100644 --- a/pygad/helper/unique.py +++ b/pygad/helper/unique.py @@ -110,7 +110,7 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, range_min=range_min, range_max=range_max, gene_value=gene_value, gene_idx=gene_index, mutation_by_replacement=mutation_by_replacement, - sample_size=None if dtype[0] in pygad.GA.supported_int_types else sample_size) + sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) else: values = self.get_gene_space_values( gene_idx=gene_index, @@ -120,7 +120,7 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, # Compare converted values: rounding and casting can turn # different candidates into the same numeric value. - values = list(dict.fromkeys(dtype[0](value) for value in numpy.atleast_1d(values))) + values = list(dict.fromkeys((value if dtype[0] is object else dtype[0](value)) for value in numpy.atleast_1d(values))) values = [value for value in values if value != gene_value] random.shuffle(values) # Keep manually supplied values and values inherited from parents. @@ -136,7 +136,7 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, selected_values = self.filter_gene_values_by_constraint( numpy.array(values), new_solution, gene_index, warn=False) dtype = self.get_gene_dtype(gene_index) - constrained_values.append([] if selected_values is None else [dtype[0](value) for value in selected_values]) + constrained_values.append([] if selected_values is None else [(value if dtype[0] is object else dtype[0](value)) for value in selected_values]) else: constrained_values.append(values) repaired_solution = self._assign_unique_gene_values(new_solution, constrained_values) diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 0bfd781e..b406e8ce 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.4" +__version__ = "1.5.5" diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index 7669fc4d..abfdd3f1 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -7,8 +7,8 @@ class GAEngine(FitnessEvaluation): def round_genes(self, solutions): """ - Round the genes in ``solutions`` according to the precision - encoded in ``self.gene_type``. When ``gene_type_single`` is + Convert and round genes in ``solutions`` using ``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. @@ -20,19 +20,14 @@ def round_genes(self, solutions): Returns ------- solutions : numpy.ndarray - The same array with the rounding applied. + The converted and rounded array. The original array is + updated when its dtype can hold the configured gene types. """ - if self.gene_type_single: - if not self.gene_type[1] is None: - solutions = numpy.round(numpy.asarray(solutions, dtype=self.gene_type[0]), - self.gene_type[1]) - else: - for gene_idx in range(self.num_genes): - if not self.gene_type[gene_idx][1] is None: - solutions[:, gene_idx] = numpy.round(numpy.asarray(solutions[:, gene_idx], - dtype=self.gene_type[gene_idx][0]), - self.gene_type[gene_idx][1]) - return solutions + converted_solutions = self.change_population_dtype_and_round(solutions) + if isinstance(solutions, numpy.ndarray) and solutions.dtype == converted_solutions.dtype: + solutions[:] = converted_solutions + return solutions + return converted_solutions def initialize_population(self, allow_duplicate_genes, gene_type, gene_constraint): """ @@ -432,7 +427,7 @@ def run(self): self.on_stop(self, self.last_generation_fitness) # Converting the 'best_solutions' list into a NumPy array. - self.best_solutions = numpy.array(self.best_solutions) + self.best_solutions = numpy.array(self.best_solutions, dtype=self.population.dtype) # Update previous_generation_fitness because it is used to get the fitness of the parents. self.previous_generation_fitness = self.last_generation_fitness.copy() @@ -562,6 +557,9 @@ def run_select_parents(self, call_on_parents=True): elif len(self.last_generation_parents_indices) != self.num_parents_mating: raise ValueError(f"The iterable holding the selected parents indices is expected to have ({self.num_parents_mating}) values but ({len(self.last_generation_parents_indices)}) found.") + if callable(self.parent_selection_type): + self.last_generation_parents = self.change_population_dtype_and_round(self.last_generation_parents) + if call_on_parents: if not (self.on_parents is None): on_parents_output = self.on_parents(self, @@ -580,7 +578,7 @@ def run_select_parents(self, call_on_parents=True): raise ValueError("The returned outputs of on_parents() cannot be None but the first output is None.") else: if type(on_parents_selected_parents) in [tuple, list, numpy.ndarray]: - on_parents_selected_parents = numpy.array(on_parents_selected_parents) + on_parents_selected_parents = numpy.asarray(on_parents_selected_parents, dtype=object) if on_parents_selected_parents.shape == self.last_generation_parents.shape: self.last_generation_parents = on_parents_selected_parents else: @@ -605,6 +603,9 @@ def run_select_parents(self, call_on_parents=True): else: raise TypeError(f"The output of on_parents() is expected to be tuple/list/numpy.ndarray but {type(on_parents_output)} found.") + if call_on_parents and self.on_parents is not None: + self.last_generation_parents = self.change_population_dtype_and_round(self.last_generation_parents) + def run_crossover(self): """ Run the crossover step of one generation. Produces @@ -671,6 +672,10 @@ def run_crossover(self): elif self.last_generation_offspring_crossover.shape[1] != self.num_genes: raise ValueError(f"Size mismatch between the crossover output {self.last_generation_offspring_crossover.shape} and the expected crossover output {(self.num_offspring, self.num_genes)}. It is expected that the offspring has ({self.num_genes}) genes but ({self.last_generation_offspring_crossover.shape[1]}) produced.") + if callable(self.crossover_type) and self.on_crossover is not None: + self.last_generation_offspring_crossover = self.change_population_dtype_and_round( + self.last_generation_offspring_crossover) + # PyGAD 2.18.2 // The on_crossover() callback function is called even if crossover_type is None. if not (self.on_crossover is None): on_crossover_output = self.on_crossover(self, @@ -679,7 +684,7 @@ def run_crossover(self): pass else: if type(on_crossover_output) in [tuple, list, numpy.ndarray]: - on_crossover_output = numpy.array(on_crossover_output) + on_crossover_output = numpy.asarray(on_crossover_output, dtype=object) if on_crossover_output.shape == self.last_generation_offspring_crossover.shape: self.last_generation_offspring_crossover = on_crossover_output else: @@ -687,10 +692,8 @@ def run_crossover(self): else: raise ValueError(f"The output of on_crossover() is expected to be tuple/list/numpy.ndarray but {type(on_crossover_output)} found.") - # User operators and callbacks can return duplicates, including - # duplicates introduced by conversion to the configured gene types. - if not self.allow_duplicate_genes and (callable(self.crossover_type) or self.on_crossover is not None): - self.last_generation_offspring_crossover = self.solve_duplicate_genes_in_population( + if callable(self.crossover_type) or self.on_crossover is not None: + self.last_generation_offspring_crossover = self.prepare_operator_output( self.last_generation_offspring_crossover, build_initial_pop=self.crossover_type == 'sbx') @@ -738,6 +741,10 @@ def run_mutation(self): elif self.last_generation_offspring_mutation.shape[1] != self.num_genes: raise ValueError(f"Size mismatch between the mutation output {self.last_generation_offspring_mutation.shape} and the expected mutation output {(self.num_offspring, self.num_genes)}. It is expected that the offspring has ({self.num_genes}) genes but ({self.last_generation_offspring_mutation.shape[1]}) produced.") + if callable(self.mutation_type) and self.on_mutation is not None: + self.last_generation_offspring_mutation = self.change_population_dtype_and_round( + self.last_generation_offspring_mutation) + # PyGAD 2.18.2 // The on_mutation() callback function is called even if mutation_type is None. if not (self.on_mutation is None): on_mutation_output = self.on_mutation(self, @@ -747,7 +754,7 @@ def run_mutation(self): pass else: if type(on_mutation_output) in [tuple, list, numpy.ndarray]: - on_mutation_output = numpy.array(on_mutation_output) + on_mutation_output = numpy.asarray(on_mutation_output, dtype=object) if on_mutation_output.shape == self.last_generation_offspring_mutation.shape: self.last_generation_offspring_mutation = on_mutation_output else: @@ -755,11 +762,34 @@ def run_mutation(self): else: raise ValueError(f"The output of on_mutation() is expected to be tuple/list/numpy.ndarray but {type(on_mutation_output)} found.") - if not self.allow_duplicate_genes and (callable(self.mutation_type) or self.on_mutation is not None): - self.last_generation_offspring_mutation = self.solve_duplicate_genes_in_population( + if callable(self.mutation_type) or self.on_mutation is not None: + self.last_generation_offspring_mutation = self.prepare_operator_output( self.last_generation_offspring_mutation, build_initial_pop=self.mutation_type == 'polynomial') + def prepare_operator_output(self, population, build_initial_pop=False): + """ + Convert an operator's population using the configured gene types + and precision, then repair duplicates if they are disallowed. + + Parameters + ---------- + population : numpy.ndarray + Parents or offspring to prepare without modifying the input. + build_initial_pop : bool + Use initialization bounds for duplicate repair, as required + for SBX crossover and polynomial mutation. + + Returns + ------- + numpy.ndarray + The converted population, with duplicate repair applied when + allow_duplicate_genes is False. + """ + if self.allow_duplicate_genes: + return self.change_population_dtype_and_round(population) + return self.solve_duplicate_genes_in_population(population, build_initial_pop=build_initial_pop) + def run_update_population(self): """ Build the next generation in ``self.population`` from the diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 9a91b334..823c3c47 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -90,8 +90,7 @@ def mutation_by_space(self, offspring): swapped_genes=swapped_genes) continue - # Before assigning the selected value from the space to the gene, change its data type and round it. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) @@ -137,7 +136,7 @@ def mutation_probs_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) @@ -211,8 +210,10 @@ def mutation_process_gene_value(self, solution=solution, mutation_by_replacement=self.mutation_by_replacement, sample_size=1) - # Even though its name is singular, it might hold multiple values. - return value_selected + # Candidate arrays use NumPy scalar types. Match the declared + # Python or NumPy scalar type before storing the selected value. + dtype = self.get_gene_dtype(gene_idx)[0] + return value_selected if dtype is object else dtype(value_selected) def swap_gene_by_space(self, solution, @@ -468,8 +469,7 @@ def swap_mutation(self, offspring): temp = offspring[idx, mutation_gene1] offspring[idx, mutation_gene1] = offspring[idx, mutation_gene2] offspring[idx, mutation_gene2] = temp - if self.allow_duplicate_genes == False: - offspring[:] = self.solve_duplicate_genes_in_population(offspring) + offspring[:] = self.prepare_operator_output(offspring) return offspring def inversion_mutation(self, offspring): @@ -494,8 +494,7 @@ def inversion_mutation(self, offspring): genes_to_scramble = numpy.flip(offspring[idx, mutation_gene1:mutation_gene2]) offspring[idx, mutation_gene1:mutation_gene2] = genes_to_scramble - if self.allow_duplicate_genes == False: - offspring[:] = self.solve_duplicate_genes_in_population(offspring) + offspring[:] = self.prepare_operator_output(offspring) return offspring def scramble_mutation(self, offspring): @@ -524,8 +523,7 @@ def scramble_mutation(self, offspring): genes_to_scramble = offspring[offspring_idx, segment_start:segment_end].copy() numpy.random.shuffle(genes_to_scramble) offspring[offspring_idx, segment_start:segment_end] = genes_to_scramble - if self.allow_duplicate_genes == False: - offspring[:] = self.solve_duplicate_genes_in_population(offspring) + offspring[:] = self.prepare_operator_output(offspring) return offspring def adaptive_mutation_population_fitness(self, offspring): @@ -668,7 +666,7 @@ def adaptive_mutation_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) @@ -815,7 +813,7 @@ def adaptive_mutation_probs_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 1114506c..0fc00ff7 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -270,99 +270,68 @@ def _validate_init_range(self, self.init_range_low = init_range_low self.init_range_high = init_range_high - def _validate_gene_type(self, - gene_type, - num_genes): + def _validate_gene_type(self, gene_type, num_genes): """ - 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 - Resolved number of genes per solution, inferred from the - supplied population when one is available. - - 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. + Normalize one type, a [type, precision] pair, or a per-gene + specification into independent [type, precision] lists. A pair + such as [float, int] specifies two gene types, while [float, 2] + specifies one floating-point type with decimal precision. """ - if type(gene_type) in [list, tuple, numpy.ndarray]: - gene_type = [list(value) if type(value) in [list, tuple, numpy.ndarray] else value - for value in gene_type] - elif gene_type not in self.supported_int_float_types: - self.valid_parameters = False - raise TypeError(f"gene_type must be a supported numeric type or a list, tuple, or NumPy array, but {type(gene_type)} found.") - - # Validate gene_type - if gene_type in self.supported_int_float_types: + if any(gene_type is supported_type for supported_type in self.supported_int_float_types): self.gene_type = [gene_type, None] self.gene_type_single = True - # A single data type of float with precision. - elif len(gene_type) == 2 and gene_type[0] in self.supported_float_types and (type(gene_type[1]) in self.supported_int_types or gene_type[1] is None): - self.gene_type = gene_type - self.gene_type_single = True - # A single data type of integer with precision None ([int, None]). - elif len(gene_type) == 2 and gene_type[0] in self.supported_int_types and gene_type[1] is None: - self.gene_type = gene_type + return + if type(gene_type) not in [list, tuple, numpy.ndarray]: + self.valid_parameters = False + raise TypeError("gene_type must be a supported numeric type, list, tuple, or NumPy array.") + if isinstance(gene_type, numpy.ndarray) and gene_type.ndim == 0: + self.valid_parameters = False + raise ValueError("gene_type must contain a type or a sequence of types, not a 0D NumPy array.") + + specification = list(gene_type) + first_is_type = len(specification) > 0 and any( + specification[0] is supported_type for supported_type in self.supported_int_float_types) + second_is_type = len(specification) == 2 and any( + specification[1] is supported_type for supported_type in self.supported_int_float_types) + second_is_sequence = len(specification) == 2 and type(specification[1]) in [list, tuple, numpy.ndarray] + if len(specification) == 2 and first_is_type and not second_is_type and not second_is_sequence: + self.gene_type = self._normalize_gene_type_entry(specification, 'gene_type') self.gene_type_single = True - # Raise an exception for a single data type of int with integer precision. - elif len(gene_type) == 2 and gene_type[0] in self.supported_int_types and (type(gene_type[1]) in self.supported_int_types or gene_type[1] is None): - self.gene_type_single = False - raise ValueError(f"Integers cannot have precision. Please use the integer data type directly instead of {gene_type}.") - elif type(gene_type) in [list, tuple, numpy.ndarray]: - if len(gene_type) != num_genes: + else: + if len(specification) != num_genes: self.valid_parameters = False - raise ValueError(f"When gene_type specifies a type for each gene, its length ({len(gene_type)}) must equal the number of genes ({num_genes}).") - for gene_type_idx, gene_type_val in enumerate(gene_type): - if gene_type_val in self.supported_int_float_types: - # If the gene type is float and no precision is passed or an integer, set its precision to None. - gene_type[gene_type_idx] = [gene_type_val, None] - elif type(gene_type_val) in [list, tuple, numpy.ndarray]: - # A float type is expected in a list/tuple/numpy.ndarray of length 2. - if len(gene_type_val) == 2: - if gene_type_val[0] in self.supported_float_types: - if gene_type_val[1] is None or type(gene_type_val[1]) in self.supported_int_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"In the 'gene_type' parameter, the precision for float gene data types must be an integer but the element {gene_type_val} at index {gene_type_idx} has a precision of {gene_type_val[1]} with type {gene_type_val[0]}.") - elif gene_type_val[0] in self.supported_int_types: - if gene_type_val[1] is None: - pass - else: - self.valid_parameters = False - raise TypeError(f"In the 'gene_type' parameter, either do not set a precision for integer data types or set it to None. But the element {gene_type_val} at index {gene_type_idx} has a precision of {gene_type_val[1]} with type {gene_type_val[0]}.") - else: - self.valid_parameters = False - raise TypeError( - f"In the 'gene_type' parameter, a precision is expected only for float gene data types but the element {gene_type_val} found at index {gene_type_idx}.\nNote that the data type must be at index 0 of the item followed by precision at index 1.") - else: - self.valid_parameters = False - raise ValueError(f"In the 'gene_type' parameter, a precision is specified in a list/tuple/numpy.ndarray of length 2 but value ({gene_type_val}) of type {type(gene_type_val)} with length {len(gene_type_val)} found at index {gene_type_idx}.") - else: - self.valid_parameters = False - raise ValueError(f"When a list/tuple/numpy.ndarray is assigned to the 'gene_type' parameter, then its elements must be of integer, floating-point, list, tuple, or numpy.ndarray data types but the value ({gene_type_val}) of type {type(gene_type_val)} found at index {gene_type_idx}.") - self.gene_type = gene_type + raise ValueError(f"When gene_type specifies a type for each gene, its length ({len(specification)}) must equal the number of genes ({num_genes}).") + self.gene_type = [self._normalize_gene_type_entry(entry, f'gene_type at index {gene_index}') + for gene_index, entry in enumerate(specification)] self.gene_type_single = False - else: + + def _normalize_gene_type_entry(self, specification, parameter_name): + """Return an independent type/precision pair for one specification.""" + if any(specification is supported_type for supported_type in self.supported_int_float_types): + return [specification, None] + if type(specification) not in [list, tuple, numpy.ndarray]: self.valid_parameters = False - raise ValueError(f"The value passed to the 'gene_type' parameter must be either a single integer, floating-point, list, tuple, or numpy.ndarray but ({gene_type}) of type {type(gene_type)} found.") - - + raise TypeError(f"{parameter_name} must be a supported numeric type or a [type, precision] pair.") + if isinstance(specification, numpy.ndarray) and specification.ndim != 1: + self.valid_parameters = False + raise ValueError(f"A type/precision pair in {parameter_name} must be a 1D sequence of 2 elements.") + if len(specification) != 2: + self.valid_parameters = False + raise ValueError(f"A type/precision pair in {parameter_name} must have 2 elements.") + gene_dtype, precision = specification + if not any(gene_dtype is supported_type for supported_type in self.supported_int_float_types): + self.valid_parameters = False + raise TypeError(f"The data type in {parameter_name} must be a supported numeric type.") + if precision is not None: + if gene_dtype not in self.supported_float_types: + self.valid_parameters = False + raise ValueError(f"Integers cannot have precision in {parameter_name}. Use the integer type directly or pair it with None.") + if type(precision) not in self.supported_int_types or type(precision) is object: + self.valid_parameters = False + raise TypeError(f"The precision in {parameter_name} must be an integer or None.") + precision = int(precision) + return [gene_dtype, precision] + def _validate_initial_population_shape(self, initial_population, sol_per_pop, num_genes): """ Validate the population dimensions before any per-gene settings. diff --git a/tests/test_gene_type_conversion.py b/tests/test_gene_type_conversion.py new file mode 100644 index 00000000..6a00075b --- /dev/null +++ b/tests/test_gene_type_conversion.py @@ -0,0 +1,333 @@ +"""Conversion, rounding, and gene-type preservation across the GA lifecycle.""" + +import copy + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + mutation_type=None, crossover_type=None, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("dtype", [float, numpy.float16, numpy.float32, numpy.float64]) +@pytest.mark.parametrize("precision", [None, 0, 1, 2, 4, -1]) +def test_scalar_candidate_and_population_conversions_agree(dtype, precision): + ga_instance = make_ga(gene_type=[dtype, precision]) + values = numpy.array([1.25, -1.75, 7.12345]) + expected = values if precision is None else numpy.round(values, precision) + expected = numpy.asarray(expected, dtype=dtype) + candidates = ga_instance.change_gene_dtype_and_round(0, values) + population = ga_instance.change_population_dtype_and_round([values])[0] + numpy.testing.assert_array_equal(candidates, expected) + numpy.testing.assert_array_equal(population, expected) + for value, converted in zip(values, expected): + scalar = ga_instance.change_gene_dtype_and_round(0, value) + assert type(scalar) is dtype + assert scalar == converted + + +def test_rounding_uses_nearest_even_halfway_values(): + ga_instance = make_ga(gene_type=[float, 0]) + values = [0.5, 1.5, 2.5, -0.5, -1.5, -2.5] + numpy.testing.assert_array_equal(ga_instance.change_gene_dtype_and_round(0, values), + [0, 2, 2, 0, -2, -2]) + + +@pytest.mark.parametrize("dtype", [int, numpy.int8, numpy.int16, numpy.int32, numpy.int64, + numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64]) +def test_integer_conversion_truncates_fractional_values(dtype): + ga_instance = make_ga(gene_type=dtype) + values = [0.9, 1.9, 2.1] if numpy.issubdtype(numpy.dtype(dtype), numpy.unsignedinteger) else [-1.9, -0.9, 2.9] + expected = [int(value) for value in values] + numpy.testing.assert_array_equal(ga_instance.change_gene_dtype_and_round(0, values), expected) + assert type(ga_instance.change_gene_dtype_and_round(0, values[0])) is dtype + + +@pytest.mark.parametrize("values", [numpy.array(1.25), [1.25, 1.75], + ((1.25, 1.75), (2.25, 2.75)), numpy.empty((0, 2))]) +def test_candidate_conversion_preserves_input_shape_and_values(values): + ga_instance = make_ga(gene_type=[numpy.float32, 1]) + original_values = copy.deepcopy(values) + converted = ga_instance.change_gene_dtype_and_round(0, values) + assert numpy.shape(converted) == numpy.shape(values) + numpy.testing.assert_array_equal(values, original_values) + if isinstance(converted, numpy.ndarray) and isinstance(values, numpy.ndarray): + assert not numpy.shares_memory(values, converted) + + +@pytest.mark.parametrize("gene_type", [numpy.float32, [float, 2], + [int, [numpy.float32, 2], numpy.int8]]) +def test_population_conversion_returns_an_independent_array(gene_type): + ga_instance = make_ga(gene_type=gene_type) + population = numpy.array([[1, 2.236, 3], [4, 5.678, 6]], dtype=object) + original_population = population.copy() + converted = ga_instance.change_population_dtype_and_round(population) + converted[0, 0] = 10 + numpy.testing.assert_array_equal(population, original_population) + assert not numpy.shares_memory(population, converted) + + +def test_grouped_conversion_preserves_declared_scalar_types_and_large_integers(): + gene_type = [int, [numpy.float32, 2], int, [numpy.float32, 2], numpy.int8, float] + ga_instance = make_ga(num_genes=6, gene_type=gene_type) + values = ((2**53 + 1, 1.236, 2**53 + 3, 2.341, 7, 1.5),) + converted = ga_instance.change_population_dtype_and_round(values) + assert converted[0, 0] == 2**53 + 1 and type(converted[0, 0]) is int + assert converted[0, 2] == 2**53 + 3 and type(converted[0, 2]) is int + assert converted[0, 1] == numpy.float32(1.24) and type(converted[0, 1]) is numpy.float32 + assert converted[0, 3] == numpy.float32(2.34) and type(converted[0, 3]) is numpy.float32 + assert type(converted[0, 4]) is numpy.int8 and type(converted[0, 5]) is float + + +def test_round_genes_rounds_before_narrow_float_casting(): + ga_instance = make_ga(gene_type=[numpy.float16, 4]) + values = numpy.array([[7.12345, 1.25, 1.75]]) + expected = numpy.asarray(numpy.round(values, 4), dtype=numpy.float16) + rounded = ga_instance.round_genes(values) + numpy.testing.assert_array_equal(rounded, expected) + assert numpy.all(numpy.isfinite(rounded)) + + +def test_round_genes_preserves_mixed_array_identity_and_destination_types(): + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], float]) + values = numpy.array([[1.9, 2.236, 3.5]], dtype=object) + rounded = ga_instance.round_genes(values) + assert rounded is values + assert type(rounded[0, 0]) is int and rounded[0, 0] == 1 + assert type(rounded[0, 1]) is numpy.float32 and rounded[0, 1] == numpy.float32(2.24) + + +def test_additive_mutation_rounds_after_adding_in_working_precision(): + ga_instance = make_ga(gene_type=[numpy.float16, 2]) + converted = ga_instance.mutation_change_gene_dtype_and_round( + numpy.float16(0.00501), 0, numpy.float16(1), False) + assert converted == numpy.float16(1.01) + + +def test_additive_mutation_keeps_mixed_signed_and_unsigned_integer_addition_exact(): + ga_instance = make_ga(gene_type=numpy.uint64) + value = 2**63 + 1 + converted = ga_instance.mutation_change_gene_dtype_and_round( + numpy.int64(2), 0, numpy.uint64(value), False) + assert int(converted) == value + 2 + candidates = ga_instance.mutation_change_gene_dtype_and_round( + numpy.array([1, 2], dtype=numpy.int64), 0, numpy.uint64(value), False) + assert [int(candidate) for candidate in candidates] == [value + 1, value + 2] + + +def test_fractional_integer_offsets_are_added_before_truncation(): + ga_instance = make_ga(gene_type=int) + value = ga_instance.generate_gene_value_randomly(0.2, 0.3, -1, 0, False) + assert value == 0 + + +@pytest.mark.parametrize("gene_type", [[int, float], (int, float), + numpy.array([int, float]), [[float, numpy.int32(2)], int], + numpy.array([[float, 2], [int, None]], dtype=object)]) +def test_validation_normalizes_specifications_without_modifying_them(gene_type): + original = copy.deepcopy(gene_type) + ga_instance = make_ga(num_genes=2, gene_type=gene_type) + assert not ga_instance.gene_type_single + numpy.testing.assert_array_equal(numpy.asarray(gene_type, dtype=object), numpy.asarray(original, dtype=object)) + + +@pytest.mark.parametrize("specification,error", [([float, True], TypeError), + ([float, 1.5], TypeError), ([float, '2'], TypeError), + ([int, 1], ValueError), ([int, -1], ValueError), + (numpy.array(float), ValueError), + ([numpy.array(float), int, float], ValueError), + ([[float, 1, 2], int, float], ValueError), + (numpy.dtype('float32'), TypeError), + (bool, TypeError), (complex, TypeError)]) +def test_invalid_type_and_precision_specifications_fail_descriptively(specification, error): + with pytest.raises(error, match="gene_type|precision"): + make_ga(gene_type=specification) + + +def custom_crossover(parents, offspring_size, ga_instance): + return numpy.tile(numpy.array([7.9, 1.236, 2.341]), (offspring_size[0], 1)) + + +def custom_mutation(offspring, ga_instance): + return numpy.tile(numpy.array([8.9, 2.341, 3.456]), (offspring.shape[0], 1)) + + +@pytest.mark.parametrize("allow_duplicate_genes", [True, False]) +def test_custom_operators_are_converted_before_callbacks_and_population_use(allow_duplicate_genes): + events = [] + def check_types(ga_instance, offspring): + events.append(offspring.copy()) + assert type(offspring[0, 0]) is int + assert type(offspring[0, 1]) is numpy.float32 + assert type(offspring[0, 2]) is float + assert offspring[0, 1] in [numpy.float32(1.24), numpy.float32(2.34)] + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], [float, 2]], + crossover_type=custom_crossover, mutation_type=custom_mutation, + on_crossover=check_types, on_mutation=check_types, + keep_elitism=0, keep_parents=0, allow_duplicate_genes=allow_duplicate_genes) + ga_instance.run() + assert len(events) == 4 + numpy.testing.assert_array_equal(ga_instance.population[0], [8, numpy.float32(2.34), 3.46]) + + +@pytest.mark.parametrize("callback_name", ['on_crossover', 'on_mutation', 'on_parents']) +@pytest.mark.parametrize("return_values", [True, False]) +def test_callback_outputs_keep_mixed_types_and_large_integer_values(callback_name, return_values): + value = 2**53 + 1 + def callback(ga_instance, population): + replacement = [[value, 1.236, 4] for _ in population] + if return_values: + return (replacement, ga_instance.last_generation_parents_indices) if callback_name == 'on_parents' else replacement + population[:] = numpy.asarray(replacement, dtype=object) + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], numpy.int8], + keep_elitism=0, keep_parents=0, **{callback_name: callback}) + ga_instance.run() + population = ga_instance.last_generation_parents if callback_name == 'on_parents' else ga_instance.population + assert population[0, 0] == value and type(population[0, 0]) is int + assert population[0, 1] == numpy.float32(1.24) and type(population[0, 1]) is numpy.float32 + assert type(population[0, 2]) is numpy.int8 + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive', 'swap', 'inversion', 'scramble', 'polynomial']) +def test_builtin_mutations_preserve_mixed_destination_types(mutation_type): + parameters = dict(gene_type=[int, [numpy.float32, 2], numpy.int8], mutation_type=mutation_type, + crossover_type='uniform', keep_elitism=0, keep_parents=0) + parameters['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + ga_instance = make_ga(**parameters) + ga_instance.run() + for solution in ga_instance.population: + assert type(solution[0]) is int + assert type(solution[1]) is numpy.float32 + assert type(solution[2]) is numpy.int8 + + +def test_saved_best_solutions_preserve_mixed_types_across_repeated_runs(): + value = 2**53 + 1 + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], numpy.int8], + initial_population=[[value, 1.236, 4], [value, 1.236, 4]], + save_best_solutions=True) + ga_instance.run() + ga_instance.run() + assert ga_instance.best_solutions.dtype == object + for solution in ga_instance.best_solutions: + assert solution[0] == value and type(solution[0]) is int + assert type(solution[1]) is numpy.float32 and solution[1] == numpy.float32(1.24) + assert type(solution[2]) is numpy.int8 + + +@pytest.mark.parametrize("gene_type", [object, [object, 2], [int, object, float]]) +def test_object_storage_keeps_numeric_values_without_integer_range_handling(gene_type): + ga_instance = make_ga(gene_type=gene_type, mutation_type='random', mutation_probability=1.0) + ga_instance.run() + assert ga_instance.population.dtype == object + assert all(isinstance(value, (int, float, numpy.number)) for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("precision", [2, 309, 400, -309, 2**40, -(2**40)]) +def test_extreme_rounding_preserves_finite_values(precision): + values = [1.234, -1.234, 1e308, -1e308, 0.0] + ga_instance = make_ga(gene_type=[float, precision], initial_population=[values, values]) + expected = [round(value, precision) for value in values] + numpy.testing.assert_array_equal(ga_instance.population[0], expected) + for value, rounded_value in zip(values, expected): + assert ga_instance.change_gene_dtype_and_round(0, value) == rounded_value + + +@pytest.mark.parametrize("precision", [309, 400, -309, 2**40, -(2**40)]) +def test_generated_population_handles_extreme_precision(precision): + ga_instance = make_ga(gene_type=[float, precision]) + assert numpy.all(numpy.isfinite(ga_instance.population)) + assert numpy.all(ga_instance.population >= -4) + assert numpy.all(ga_instance.population < 4) + if precision < 0: + numpy.testing.assert_array_equal(ga_instance.population, 0) + + +@pytest.mark.parametrize("gene_type", [int, [int, numpy.float32, numpy.int64]]) +def test_finite_gene_spaces_convert_large_integers_before_float_inference(gene_type): + value = 2**53 + 1 + ga_instance = make_ga(gene_type=gene_type, gene_space=[value, 1.5]) + assert value in ga_instance.get_gene_space_values(0) + unpacked_space = ga_instance.gene_space_unpacked if ga_instance.gene_type_single else ga_instance.gene_space_unpacked[0] + assert value in unpacked_space + + +@pytest.mark.parametrize("lower,upper,expected", [(2**53 + 1, 2**53 + 3, [2**53 + 1, 2**53 + 2]), + (numpy.int64(2**53 + 1), numpy.int64(2**53 + 3), [2**53 + 1, 2**53 + 2]), + (numpy.float32(1.2), numpy.float32(2.0), [1]), + (-1.9, -1.0, [-1]), (-1.9, -0.2, [-1, 0]), + (1.2, 2.0, [1])]) +def test_integer_continuous_space_bounds_remain_exact(lower, upper, expected): + ga_instance = make_ga(gene_type=int, gene_space={'low': lower, 'high': upper}, + initial_population=[[expected[0]] * 3] * 2) + numpy.testing.assert_array_equal(ga_instance.get_gene_space_values(0), expected) + + +@pytest.mark.parametrize("dtype", [numpy.int64, numpy.uint64]) +def test_generated_integer_ranges_preserve_exact_numpy_bounds(dtype): + lower = 2**53 + 1 if dtype is numpy.int64 else 2**63 + 1 + ga_instance = make_ga(gene_type=dtype, init_range_low=dtype(lower), init_range_high=dtype(lower + 3)) + assert all(lower <= int(value) < lower + 3 for value in ga_instance.population.flat) + numpy.testing.assert_array_equal(ga_instance._initial_population_integer_bounds(0, dtype(lower), dtype(lower + 3)), + [lower, lower + 2]) + + +@pytest.mark.parametrize("dtype,precision", [(numpy.float32, 2), (float, 400), (float, 2**40)]) +def test_continuous_space_fallback_respects_stored_value_bounds(dtype, precision, monkeypatch): + ga_instance = make_ga(gene_type=[dtype, precision], gene_space={'low': 0.9, 'high': 1.0}) + monkeypatch.setattr(numpy.random, 'uniform', lambda *args, **kwargs: numpy.ones(kwargs['size'])) + values = ga_instance.get_gene_space_values(0, sample_size=1) + assert len(values) == 1 + assert 0.9 <= float(values[0]) < 1.0 + + +def test_custom_parent_selection_applies_types_before_its_callback(): + def selection_func(fitness, num_parents, ga_instance): + return (numpy.tile([7.9, 1.236, 4.9], (num_parents, 1)), numpy.arange(num_parents)) + def on_parents(ga_instance, parents): + assert type(parents[0, 0]) is int and parents[0, 0] == 7 + assert type(parents[0, 1]) is numpy.float32 and parents[0, 1] == numpy.float32(1.24) + assert type(parents[0, 2]) is numpy.int8 and parents[0, 2] == 4 + ga_instance = make_ga(parent_selection_type=selection_func, on_parents=on_parents, + gene_type=[int, [numpy.float32, 2], numpy.int8], + keep_elitism=0, keep_parents=0) + ga_instance.run() + + +def test_custom_operators_apply_types_without_callbacks(): + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], [float, 2]], + crossover_type=custom_crossover, mutation_type=custom_mutation, + keep_elitism=0, keep_parents=0) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.last_generation_offspring_crossover[0], + [7, numpy.float32(1.24), 2.34]) + numpy.testing.assert_array_equal(ga_instance.population[0], [8, numpy.float32(2.34), 3.46]) + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive']) +@pytest.mark.parametrize("use_probability", [True, False]) +def test_mutation_from_finite_spaces_preserves_declared_scalar_types(mutation_type, use_probability): + options = dict(gene_type=[int, [numpy.float32, 2], numpy.int8], gene_space=[1.236, 2.345, 3.456], + mutation_type=mutation_type, keep_elitism=0, keep_parents=0) + if use_probability: + options['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + else: + options['mutation_num_genes'] = [3, 3] if mutation_type == 'adaptive' else 3 + ga_instance = make_ga(**options) + ga_instance.run() + for solution in ga_instance.population: + assert type(solution[0]) is int + assert type(solution[1]) is numpy.float32 and solution[1] in [numpy.float32(1.24), numpy.float32(2.35), numpy.float32(3.46)] + assert type(solution[2]) is numpy.int8 From 039513feb821aaa8be487d52e88df2f3a8a634d8 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 21:50:39 -0400 Subject: [PATCH 06/22] Improve GA constructor validation and parameter handling --- docs/source/gene_values.md | 6 +- docs/source/pygad.md | 59 +- docs/source/releases.md | 5 + docs/source/user_defined_operators.md | 22 +- examples/example_constructor_parameters.py | 52 + pygad/helper/__init__.py | 2 +- pygad/helper/misc.py | 307 +++- pygad/helper/unique.py | 24 +- pygad/pygad.py | 11 +- pygad/utils/__init__.py | 2 +- pygad/utils/crossover.py | 70 +- pygad/utils/engine.py | 24 +- pygad/utils/mutation.py | 241 ++- pygad/utils/nsga3.py | 8 +- pygad/utils/parent_selection.py | 18 +- pygad/utils/validation.py | 1911 ++++---------------- tests/test_constructor_parameters.py | 459 +++++ tests/test_duplicate_gene_repair.py | 14 +- tests/test_gene_type_conversion.py | 5 +- tests/test_operator_regressions.py | 14 +- tests/test_parent_selection_regressions.py | 11 +- tests/test_plot_lifecycle.py | 2 +- tests/test_sbx_polynomial.py | 8 +- 23 files changed, 1416 insertions(+), 1859 deletions(-) create mode 100644 examples/example_constructor_parameters.py create mode 100644 tests/test_constructor_parameters.py diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 953abcb6..27af2d94 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -49,7 +49,7 @@ Supplied values can lie outside `gene_space` and the initialization ranges. They ### Constraints and Duplicates -Both generated and supplied populations are converted before checking `gene_constraint`. Constraints are applied in gene-index order to complete solutions. Finite choices are searched in full; continuous intervals and large integer intervals contribute up to `sample_size` candidates. If no candidate satisfies a constraint, the existing value remains and PyGAD warns unless `suppress_warnings=True`. +Both generated and supplied populations are converted before checking `gene_constraint`. Constraints are applied in gene-index order to complete solutions. Explicit lists and small finite domains are searched in full; continuous intervals and large ranges or stepped dictionaries contribute up to `sample_size` candidates for constraint checks. If no candidate satisfies a constraint, the existing value remains and PyGAD warns unless `suppress_warnings=True`. Constraints depending on other genes should follow the dependency order: a gene should depend on earlier genes, as explained in the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section. Initialization does not solve arbitrary systems of dependent constraints. @@ -285,7 +285,7 @@ Sometimes it is normal for PyGAD to fail to find a gene value that satisfies the For some other cases, the constraint can be met but with some changes. For example, increasing the range from which a value is sampled. If the `gene_space` is used and assigned `range(10)`, then the gene constraint can be met by using `range(100)` so that we can find values greater than 50. -Finite gene spaces, such as `range(1000)`, provide their full candidate list for constraint checks. When candidates come from random sampling, a larger `sample_size` can increase the chance of finding a value that meets a narrow constraint. +Explicit lists provide their full candidate list for constraint checks. Ranges, stepped dictionaries, and integer intervals are sampled by index when only a small candidate set is needed, avoiding allocation of the entire domain. When candidates come from random sampling, a larger `sample_size` can increase the chance of finding a value that meets a narrow constraint. > Initialization and ordinary mutation apply gene constraints sequentially. They do not determine the dependency order among the genes automatically. > @@ -301,6 +301,8 @@ Finite gene spaces, such as `range(1000)`, provide their full candidate list for > > PyGAD applies constraints sequentially, starting from the first gene to the last. To ensure correct behavior when genes depend on each other, structure your GA problem so that if gene X depends on gene Y, then gene Y appears earlier in the chromosome (solution) than gene X. As a result, its gene constraint will be earlier in the list. +Swap, inversion, and scramble mutation also check constraints against the complete proposed solution after destination conversion. SBX and polynomial mutation use shared space, type, precision, constraint, and duplicate checks. Proposals that cannot be made valid leave the original solution unchanged. + Duplicate repair also checks all constraints against complete candidate solutions before accepting changes. Its additional search for dependent constraints is bounded by `sample_size`; this does not reorder the general initialization or mutation constraint checks. ### Full Example diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 3a761163..cdc086c9 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -12,6 +12,8 @@ The `pygad` module has a class named `GA` for building the genetic algorithm. Th To create an instance of the `pygad.GA` class, the constructor accepts several parameters. These let you adjust the genetic algorithm for different types of applications. +The constructor validates settings before generating a population or calling gene constraints. Counts accept Python and NumPy integers, excluding Boolean values; ranges, probabilities, and distribution indices must be finite. Mutable settings such as gene spaces, per-gene ranges, and adaptive mutation rates are copied, so changing the original containers does not change the GA configuration. + The `pygad.GA` class constructor supports the parameters below, grouped by purpose. Click a parameter to expand its full description. #### Population and Generations @@ -19,13 +21,13 @@ The `pygad.GA` class constructor supports the parameters below, grouped by purpo :::{dropdown} `num_generations`: Number of generations to run. :animate: fade-in-slide-down -Number of generations. +Number of generations per `run()` call. Must be a non-negative integer; `0` evaluates the initial population without evolving it. ::: :::{dropdown} `num_parents_mating`: How many solutions are selected as parents. :animate: fade-in-slide-down -Number of solutions to be selected as parents. +Number of solutions to be selected as parents. Must be an integer between `1` and `sol_per_pop`, inclusive. ::: :::{dropdown} `sol_per_pop`: Number of solutions in the population. @@ -64,7 +66,9 @@ Four stop words are supported: - `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 solutions whose fitness was evaluated inside `run()` reaches the given count. Example: `"evaluations_1000"` stops after at least 1000 solution evaluations. A batch counts once per solution, and adaptive mutation's offspring evaluations are included. Cached fitness values do not count. The criterion is checked at generation boundaries, so the count can exceed the threshold. -You can also pass a list of criteria; the run stops as soon as any one of them is met. +You can also pass a list, tuple, or 1D NumPy array of criteria; the run stops as soon as any one of them is met. Duplicate criteria are removed while preserving their order. + +The counts for `saturate` and `evaluations` must be positive integers. Fractional counts are rejected. The threshold for `reach` must be finite, and the duration for `time` must be finite and non-negative; `time_0` stops at the first stopping check. Scientific notation is accepted, for example `evaluations_1e3` or `time_1e-2`. For multi-objective problems, `reach_10_20` requires both objective thresholds to be met; a single threshold applies to every objective. 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. ::: @@ -84,7 +88,7 @@ A fitness **function** must accept 3 parameters: If you pass a **method**, it takes a fourth parameter for the method's class instance. -A callable instance's `__call__(self, ga_instance, solution, solution_idx)` uses the same three fitness arguments after `self`. Process evaluation transports the callable and current GA state with cloudpickle; custom attributes and resources must be serializable. Thread evaluation shares the GA instance, so changes to shared state need synchronization. +A callable instance's `__call__(self, ga_instance, solution, solution_idx)` uses the same three fitness arguments after `self`. Functions, bound methods, callable instances, and `functools.partial` are supported when their signatures accept PyGAD's positional arguments. Additional optional parameters are allowed. Required keyword-only parameters and asynchronous callables are rejected during construction. Process evaluation transports the callable and current GA state with cloudpickle; custom attributes and resources must be serializable. Thread evaluation shares the GA instance, so changes to shared state need synchronization. Return a single number for a single-objective problem, or a `list`, `tuple`, or `numpy.ndarray` for a multi-objective problem (supported since [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0)). @@ -151,7 +155,7 @@ Version history: :::{dropdown} `gene_constraint=None`: Functions that restrict gene values. :animate: fade-in-slide-down -A list of callables (functions), one per gene, that restrict the values a gene can take. Before a value is chosen for a gene, its callable checks that the candidate value is valid. +A list of callables (functions), one per gene, that restrict the values a gene can take. Before a value is chosen for a gene, its callable checks that the candidate value is valid. A list or tuple must contain exactly one callable or `None` per gene. Each callable must accept the solution and candidate values as 2 positional arguments; bound methods, callable instances, and partial functions are supported. Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. ::: @@ -214,7 +218,7 @@ You can also pass your own parent selection function (since [PyGAD 2.16.0](https :::{dropdown} `K_tournament=3`: Contestants per tournament selection. :animate: fade-in-slide-down -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`. +For `tournament`, `tournament_nsga2`, and `tournament_nsga3`, this is the number of contestants per tournament. It must be a positive integer and defaults to `3`. Values larger than `sol_per_pop` are clipped to the population size with a warning unless warnings are suppressed. Other selection types do not use this parameter. ::: :::{dropdown} `nsga3_num_divisions=None`: Number of divisions per objective axis for NSGA-III. @@ -249,7 +253,7 @@ The number of parents to keep in the next population. It defaults to `-1`. - `0`: keep no parents. - A positive integer: keep that many parents. -The value cannot be less than `-1` or greater than `sol_per_pop`. +The value must be an integer from `-1` through `num_parents_mating`. Passing `None` uses the default of keeping all parents. This parameter has an effect only when `keep_elitism=0` (since [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0)). Since PyGAD 2.20.0, the parents' fitness from the last generation is not re-used if `keep_parents=0`. @@ -279,9 +283,9 @@ If `crossover_type=None`, the crossover step is skipped and no offspring are cre :::{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`. +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 finite positive number. Defaults to `30`. -Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds are taken from `init_range_low` and `init_range_high`. +Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds come from each gene's `gene_space`, falling back to its initialization range for a `None` space. Reversed initialization bounds are sorted for calculation. Supplied parent values outside these bounds are clipped before the SBX calculation. Results apply the destination type, precision, space, constraints, and duplicate policy; an invalid proposed child falls back to its parent. ::: :::{dropdown} `crossover_probability=None`: Chance a parent is used for crossover. @@ -289,7 +293,7 @@ Each crossed gene selects the lower or upper SBX child with equal probability. T The probability of selecting a parent for crossover. Its value must be between 0.0 and 1.0. -For each parent, a random value between 0.0 and 1.0 is generated. If that value is less than or equal to `crossover_probability`, the parent is selected. +For each parent, a random value between 0.0 and 1.0 is generated. If that value is less than `crossover_probability`, the parent is selected. Setting `0` copies parents without crossing them. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. ::: @@ -312,13 +316,17 @@ The built-in types are: 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). +Permutation mutations (`swap`, `inversion`, and `scramble`) check the complete proposed solution against destination gene spaces, types, constraints, and the duplicate policy. An incompatible permutation is retried; if no acceptable proposal is found, the solution is retained. With no explicit mutation probability, count, or percentage, swap selects one pair and inversion/scramble use their usual half-length segment. When a control is explicitly supplied, it selects the eligible positions: swap exchanges pairs, inversion reverses the selected values, and scramble shuffles them. Fewer than 2 eligible positions leave the solution unchanged; an odd number in swap leaves one eligible position unpaired. + 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`. +Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A higher value means a smaller change. Must be a finite positive number. Defaults to `20`. + +Polynomial mutation uses the gene-space bounds, falling back to the initialization range for a `None` space. It supports probabilities, explicit counts, and percentages. When none is explicitly supplied, its default per-gene probability is `1 / num_genes`. It clips supplied values before calculation, converts and rounds the results, and checks spaces, constraints, and duplicates before accepting the complete solution. ::: :::{dropdown} `mutation_probability=None`: Per-gene chance of mutation. @@ -326,15 +334,15 @@ Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A The probability of selecting a gene for mutation. Its value must be between 0.0 and 1.0. -For each gene, a random value between 0.0 and 1.0 is generated. If that value is less than or equal to `mutation_probability`, the gene is mutated. +For each gene, a random value between 0.0 and 1.0 is generated. If that value is less than `mutation_probability`, the gene is selected. Setting `0` leaves the offspring unchanged, including permutation and polynomial mutation; `1` selects every gene. Adaptive mutation takes 2 probabilities, for below-average and above-average solutions, respectively. -If this parameter is set, you do not need `mutation_percent_genes` or `mutation_num_genes`. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. +Only the active mutation control is validated: `mutation_probability` takes precedence over `mutation_num_genes`, which takes precedence over `mutation_percent_genes`. Values for inactive controls are ignored. Built-in operators apply these controls; custom mutation functions implement their own selection rules. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. ::: :::{dropdown} `mutation_by_replacement=False`: Replace the gene value instead of adding to it. :animate: fade-in-slide-down -A bool that controls how `random` mutation changes a gene. It works only when `mutation_type="random"`. +A bool that controls how `random` and `adaptive` mutation change a gene when drawing random values. Finite gene spaces select replacement values directly. - `True`: replace the gene with the randomly generated value. - `False` (default): add the random value to the gene. @@ -347,7 +355,7 @@ Supported in [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html# The percentage of genes to mutate. It defaults to the string `"default"`, which becomes `10` (10% of the genes). The value must be `> 0` and `<= 100`. -PyGAD uses this percentage to compute `mutation_num_genes`. +PyGAD computes `mutation_num_genes` by multiplying the percentage by `num_genes` and discarding the fractional part. If this gives `0`, it selects `1` gene and warns unless warnings are suppressed. Adaptive mutation requires 2 percentages, for below-average and above-average solutions, respectively. This parameter has no effect if `mutation_probability` or `mutation_num_genes` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). ::: @@ -355,7 +363,7 @@ This parameter has no effect if `mutation_probability` or `mutation_num_genes` i :::{dropdown} `mutation_num_genes=None`: Number of genes to mutate. :animate: fade-in-slide-down -The number of genes to mutate. It defaults to `None`, meaning no number is set. +The number of genes eligible for mutation. It defaults to `None`, meaning no number is set. When active, it must be an integer between `1` and `num_genes`, inclusive. Adaptive mutation requires 2 counts, for below-average and above-average solutions, respectively. Permutation operators may leave selected positions unchanged when a compatible rearrangement is unavailable. This parameter has no effect if `mutation_probability` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). ::: @@ -464,7 +472,7 @@ If `True`, then all solutions in each generation are appended into an attribute :::{dropdown} `logger=None`: Custom logger for the outputs. :animate: fade-in-slide-down -An instance of the `logging.Logger` class used to log the outputs. When set, messages are logged instead of printed with `print()`. If `None`, PyGAD creates a logger that uses a `StreamHandler` to write the messages to the console. +An instance of the `logging.Logger` class used to log the outputs. When set, messages are logged instead of printed with `print()`. If `None`, PyGAD uses its default logger and adds a console `StreamHandler` only when it has no handlers. Existing handlers are preserved. Invalid logger values raise a `TypeError` naming the parameter. Added in [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0). See [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. ::: @@ -484,6 +492,7 @@ Runs the fitness calculation in parallel. It defaults to `None` (no parallel pro You can set it to: +- **`None` or integer `0`:** disable parallel processing. - **A positive integer:** the number of threads. Example: `parallel_processing=5` uses 5 threads (the same as `["thread", 5]`). - **A list/tuple of 2 elements:** the first is `"process"` or `"thread"`; the second is a positive maximum worker count, `None` for the executor's default, or `0` to disable parallel processing. Example: `parallel_processing=["process", 10]` uses up to 10 processes. @@ -495,7 +504,9 @@ Added in [PyGAD 2.17.0](https://pygad.readthedocs.io/en/latest/releases.html#pyg :::{dropdown} `random_seed=None`: Seed for reproducible runs. :animate: fade-in-slide-down -The random seed used by the NumPy and `random` number generators. Setting it makes runs reproducible (for example, `random_seed=2`). It defaults to `None`, which means no seed is used. +The seed for this instance's NumPy and Python random generators. Accepts `None` or a Python/NumPy integer from `0` through `2**32 - 1`. Setting it makes built-in operations reproducible (for example, `random_seed=2`). With `None`, each instance starts with an automatically initialized state. + +Each GA owns `numpy_random_generator` (a `numpy.random.RandomState`) and `python_random_generator` (a `random.Random`). Creating or running another GA and drawing from the global generators do not alter its state. Repeated `run()` calls continue the same generator states, and checkpoints preserve them. To reproduce random draws in custom operators or callbacks, use these instance generators, for example `ga_instance.numpy_random_generator.random()`. Global random draws in user code need their own seed. Seeded results may differ between PyGAD versions. See `examples/example_constructor_parameters.py` for an example. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). ::: @@ -553,7 +564,7 @@ Since the `pygad.GA` class extends such classes, the attributes and methods insi ### Other Instance Attributes & Methods -Constructor settings and user callables are stored as instance attributes, with some values normalized during validation. Active operators are exposed as `select_parents`, `crossover`, and `mutation`. The following sections describe additional instance attributes and inherited methods; the supported numeric type lists above are class attributes. +Constructor settings and user callables are stored as instance attributes, with some values normalized during validation. Numeric counts become Python integers, mutable parameter containers are copied, and inactive mutation counts/percentages are normalized to `None`/`"default"`. Active operators are exposed as `select_parents`, `crossover`, and `mutation`. The following sections describe additional instance attributes and inherited methods; the supported numeric type lists above are class attributes. > 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. @@ -588,7 +599,7 @@ Constructor settings and user callables are stored as instance attributes, with - `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`: A snapshot of the converted finite values and continuous samples in `gene_space`. Generation reads the original space so `None` entries remain random. 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). +- `gene_space_unpacked`: An inspection snapshot of converted values in `gene_space`. Small finite spaces are kept in full; large ranges, stepped dictionaries, and continuous intervals contribute at most 100 values per gene by default. Generation and duplicate repair read the original space, so inspection samples do not restrict the available values and `None` entries remain random. 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 @@ -781,7 +792,7 @@ The {ref}`complete fitness dispatch reference ` documents pa - `solve_duplicate_genes_in_population(population, build_initial_pop=False)`: Convert and round population rows before applying the shared repair. - `get_duplicate_gene_indices(solution)`: Return the indices after the first occurrence of each repeated value. - `solution_satisfies_gene_constraints(solution)`: Check every constraint against a complete candidate solution. -- `get_gene_space_values(...)`: Return converted finite candidates or fresh continuous candidates for one gene. +- `get_gene_space_values(..., all_values=True)`: Return converted candidates for one gene. The default includes complete finite domains for duplicate repair; `all_values=False` samples ranges, stepped dictionaries, and integer intervals without allocating their complete domains. - `is_gene_value_in_space(...)`: Check a swap candidate against the original finite space or continuous bounds. - `solve_duplicate_genes_randomly(...)`: Compatibility helper for the shared repair using explicit random ranges. - `solve_duplicate_genes_by_space(...)`: Compatibility helper for the shared repair using `gene_space`. @@ -792,7 +803,11 @@ The {ref}`complete fitness dispatch reference ` documents pa - `unique_genes_by_space(...)`: Pick unique values for several genes from `gene_space`. - `select_unique_value(...)`: Pick an unused candidate when possible, otherwise keep the current value. - `find_two_duplicates(solution, gene_space_unpacked)`: Find a duplicated gene with alternative values in its space. -- `unpack_gene_space(...)`: Materialize the unpacked `gene_space` (used to build `gene_space_unpacked`). +- `unpack_gene_space(...)`: Build an inspection snapshot of `gene_space`, sampling large lazy domains without allocating them in full. +- `get_bounded_operator_gene_range(gene_index)`: Resolve SBX/polynomial bounds from a gene's space or initialization range. +- `convert_bounded_operator_gene_value(gene_index, value)`: Apply the destination type and precision and select an allowed value. +- `prepare_changed_operator_solution(...)`: Validate a complete converted proposal against spaces, constraints, and duplicates. +- `prepare_bounded_operator_solution(...)`: Repair bounded-operator constraints and accept a valid proposal or retain the original solution. #### Saving, Loading, and Reporting diff --git a/docs/source/releases.md b/docs/source/releases.md index 27bddb8d..982c30f4 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -748,4 +748,9 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 19. Gene-type validation and conversion share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. +20. Constructor validation shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. Stop criteria share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. +21. Only the active mutation control is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. +22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. +23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index ad64f14f..760166f5 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -10,6 +10,10 @@ This way, the user can only use the built-in functions for each of these operato Starting from [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0), the user can create a custom crossover, mutation, and parent selection operators and assign these functions to the above parameters. Thus, a new operator can be plugged easily into the [PyGAD Lifecycle](https://pygad.readthedocs.io/en/latest/lifecycle.html#life-cycle-of-pygad). +Custom operators may be functions, bound methods, callable instances, or `functools.partial` objects. The constructor checks whether the callable accepts the positional arguments shown below; additional optional parameters are allowed. Required keyword-only parameters and asynchronous callables are rejected. + +For reproducible random choices, use `ga_instance.numpy_random_generator` or `ga_instance.python_random_generator`. These generators belong to the GA and use its `random_seed`. They are also preserved when saving and loading the GA. Mutation selection and replacement settings remain the responsibility of a custom mutation function. + When `allow_duplicate_genes=False`, PyGAD applies its shared duplicate repair to custom crossover and mutation outputs after the corresponding callback has finished. Values are converted and rounded before repair. This also handles duplicate values returned or changed in place by `on_crossover` and `on_mutation`. If the configured spaces, ranges, or constraints leave no usable alternative, duplicates can remain with a warning. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). PyGAD applies `gene_type` and its precision to custom parent selection, crossover, and mutation outputs before the corresponding callback receives them. Values returned or changed in place by `on_parents`, `on_crossover`, and `on_mutation` are converted again before use, even when duplicates are allowed. When constructing an output array containing both large integers and floating-point values, use `numpy.array(values, dtype=object)` to preserve the original values until each gene's type is applied. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). @@ -69,7 +73,7 @@ def crossover_func(parents, offspring_size, ga_instance): parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[1])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -112,9 +116,9 @@ The next code builds the random mutation where a single gene from each chromosom def mutation_func(offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[1])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring ``` @@ -234,7 +238,7 @@ def crossover_func(parents, offspring_size, ga_instance): parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[1])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -247,9 +251,9 @@ def crossover_func(parents, offspring_size, ga_instance): def mutation_func(offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[0])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring @@ -303,7 +307,7 @@ class Test: parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[0])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -316,9 +320,9 @@ class Test: def mutation_func(self, offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[1])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring diff --git a/examples/example_constructor_parameters.py b/examples/example_constructor_parameters.py new file mode 100644 index 00000000..dc222a03 --- /dev/null +++ b/examples/example_constructor_parameters.py @@ -0,0 +1,52 @@ +from functools import partial + +import numpy +import pygad + + +def fitness_func(ga_instance, solution, solution_idx, target): + return 1.0 / (1.0 + abs(numpy.sum(solution) - target)) + + +def mutation_func(offspring, ga_instance): + # Use the GA's own generator so random_seed also covers this operator. + for solution in offspring: + gene_index = ga_instance.numpy_random_generator.randint(ga_instance.num_genes) + lower, upper = ga_instance.get_initial_population_range(gene_index) + solution[gene_index] = ga_instance.numpy_random_generator.uniform(lower, upper) + return offspring + + +def create_ga(random_seed): + return pygad.GA(num_generations=numpy.uint8(5), + num_parents_mating=numpy.int64(2), + fitness_func=partial(fitness_func, target=3.0), + sol_per_pop=6, + num_genes=3, + init_range_low=0.0, + init_range_high=2.0, + gene_type=[float, 2], + mutation_type=mutation_func, + mutation_num_genes=1, + random_seed=random_seed) + + +first_ga = create_ga(numpy.int64(7)) +second_ga = create_ga(7) + +first_ga.run() + +# Another GA and global random draws do not change second_ga's generator states. +other_ga = create_ga(99) +other_ga.run() +numpy.random.seed(100) +numpy.random.random(10) + +second_ga.run() +numpy.testing.assert_array_equal(first_ga.population, second_ga.population) +print("Independent instances with the same seed produce the same population.") +print(first_ga.population) + +# Count arithmetic uses Python integers even when NumPy counts are supplied. +first_ga.run() +print("Generations after two runs:", first_ga.generations_completed) diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index c384bfb1..2fdad67f 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.3" +__version__ = "1.4.4" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index 08c364e7..5adad37c 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -5,7 +5,6 @@ import numpy import math import warnings -import random import pygad class Helper: @@ -504,7 +503,7 @@ def filter_gene_values_by_constraint(self, returns a result that is not a subset of ``values``. """ - if self.gene_constraint and self.gene_constraint[gene_idx]: + if self.gene_constraint and self.gene_constraint[gene_idx] is not None: pass else: raise Exception(f"Either the gene at index {gene_idx} is not assigned a callable/function or the gene_constraint itself is not used.") @@ -584,7 +583,7 @@ def get_random_mutation_range(self, gene_index): else: range_min = self.random_mutation_min_val[gene_index] range_max = self.random_mutation_max_val[gene_index] - return range_min, range_max + return tuple(sorted([range_min, range_max])) def get_initial_population_range(self, gene_index): """ @@ -614,7 +613,195 @@ def get_initial_population_range(self, gene_index): else: range_min = self.init_range_low[gene_index] range_max = self.init_range_high[gene_index] - return range_min, range_max + return tuple(sorted([range_min, range_max])) + + def get_bounded_operator_gene_range(self, gene_index): + """Return SBX and polynomial bounds from the gene space or initialization range.""" + if self.gene_space is None: + return self.get_initial_population_range(gene_index) + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + return self.get_initial_population_range(gene_index) + if isinstance(space, dict) and 'step' not in space: + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + return self._continuous_gene_space_integer_bounds(space) + return space['low'], space['high'] + if isinstance(space, range) or isinstance(space, dict): + count = self._finite_gene_space_length(space) + values = [self._finite_gene_space_value(space, 0), self._finite_gene_space_value(space, count - 1)] + elif isinstance(space, (list, tuple, numpy.ndarray)): + values = [value for value in space if value is not None] + if any(value is None for value in space): + values.extend(self.get_initial_population_range(gene_index)) + else: + values = [space] + values = self.change_gene_dtype_and_round(gene_index, values) + return min(values), max(values) + + def convert_bounded_operator_gene_value(self, gene_index, value): + """Convert a generated value within its bounds and select an allowed space value.""" + lower, upper = self.get_bounded_operator_gene_range(gene_index) + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + dtype = self.get_gene_dtype(gene_index)[0] + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): + value = value.item() if isinstance(value, numpy.generic) else value + type_limits = numpy.iinfo(dtype) + first = max(math.ceil(lower), int(type_limits.min)) + last = min(math.floor(upper), int(type_limits.max)) + if first > last: + raise ValueError(f'The operator bounds have no value representable by gene_type for the gene at index {gene_index}.') + # Real-coded arithmetic may round an integer type's maximum + # up to the next power of two. Clip as Python integers before + # casting so a valid bounded proposal does not overflow. + value = min(max(math.trunc(value), first), last) + converted = self.change_gene_dtype_and_round(gene_index, value) + if not lower <= float(converted) <= upper: + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + converted = min(max(converted, math.ceil(lower)), math.floor(upper)) + else: + converted = self._convert_initial_population_range_values(gene_index, lower, upper, [value])[0] + if self.gene_space is not None: + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is not None and not self.is_bounded_operator_gene_value_in_space(gene_index, converted): + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + low = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + position = math.floor((float(converted) - low) / step) + indices = {max(0, min(count - 1, position + offset)) for offset in range(-2, 4)} + candidates = [self._finite_gene_space_value(space, index) for index in sorted(indices)] + candidates = self.change_gene_dtype_and_round(gene_index, candidates) + elif isinstance(space, dict): + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + # Integer conversion of a continuous space truncates + # its endpoints, including a fixed fractional value. + first, last = self._continuous_gene_space_integer_bounds(space) + converted = min(max(converted, first), last) + else: + converted = self._convert_initial_population_range_values( + gene_index, space['low'], space['high'], [value])[0] + candidates = [converted] + else: + candidates = self.get_initial_population_gene_candidates(gene_index, self.sample_size, all_integer_values=False) + converted = min(candidates, key=lambda candidate: abs(float(candidate) - float(converted))) + return converted if dtype is object else dtype(converted) + + def is_bounded_operator_gene_value_in_space(self, gene_index, value): + """Check a bounded operator against the original space, including None entries.""" + if self.gene_space is None: + return True + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + return True + if isinstance(space, (list, tuple, numpy.ndarray)) and any(item is None for item in space): + lower, upper = self.get_initial_population_range(gene_index) + if lower <= float(value) <= upper: + return True + return self.is_gene_value_in_space(gene_index, value, value) + + def prepare_changed_operator_solution(self, original_solution, proposed_solution, build_initial_pop=False): + """Return a valid converted change, or None when it violates a destination rule. + + Validate complete solutions after conversion and duplicate repair so + dependent constraints can see every changed gene. The caller keeps the + original solution when its candidate cannot be accepted. + """ + candidate = self.change_population_dtype_and_round([proposed_solution])[0] + if not self.allow_duplicate_genes: + candidate, _, remaining = self.solve_duplicate_genes(candidate, build_initial_pop=build_initial_pop, warn=False) + if remaining > len(self.get_duplicate_gene_indices(original_solution)): + return None + if self.gene_space is not None: + for index, value in enumerate(candidate): + valid = (self.is_bounded_operator_gene_value_in_space(index, value) if build_initial_pop else + self.is_gene_value_in_space(index, value, original_solution[index])) + if not valid: + return None + if not self.solution_satisfies_gene_constraints(candidate): + return None + return candidate + + def prepare_bounded_operator_solution(self, original_solution, proposed_solution): + """Apply constraints to a bounded proposal and retain a valid fallback if needed.""" + candidate = self.change_population_dtype_and_round([proposed_solution])[0] + if self.gene_constraint: + candidate = self.apply_initial_population_gene_constraints([candidate], warn=False)[0] + prepared = self.prepare_changed_operator_solution(original_solution, candidate, build_initial_pop=True) + return original_solution.copy() if prepared is None else prepared + + def _continuous_gene_space_integer_bounds(self, space): + """Find the integer endpoints produced by truncating a continuous space. + + Include integers reached from fractional endpoints, while excluding + a positive integral upper bound. Equal bounds describe one fixed value. + """ + lower, upper = space['low'], space['high'] + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + first = math.trunc(lower) + if lower == upper: + last = first + elif upper > 0: + last = math.ceil(upper) - 1 + else: + last = math.trunc(upper) + return first, last + + def _finite_gene_space_length(self, space): + """Count a range or stepped dictionary without allocating its values.""" + if isinstance(space, range): + lower, upper, step = space.start, space.stop, space.step + else: + lower, upper, step = space['low'], space['high'], space['step'] + # Integer arithmetic keeps large discrete bounds exact. + if all(isinstance(value, (int, numpy.integer)) for value in (lower, upper, step)): + lower, upper, step = int(lower), int(upper), int(step) + return max(0, (abs(upper - lower) + abs(step) - 1) // abs(step)) if (upper - lower) * step > 0 else 0 + return max(0, math.ceil((upper - lower) / step)) + + def _finite_gene_space_value(self, space, index): + """Return one indexed value from a lazy finite numeric space.""" + if isinstance(space, range): + return space.start + index * space.step + lower, step = space['low'], space['step'] + if index == 0: + return lower + if isinstance(lower, int) and isinstance(step, int) and isinstance(space['high'], int): + return lower + index * step + # Match numpy.arange's stored floating-point increment, which + # can differ slightly from the supplied step after adding low. + lower = float(lower) + stored_step = (lower + float(step)) - lower + return lower + index * stored_step + + def _sample_finite_gene_space(self, space, sample_size, without_replacement=False): + """Sample finite-space indices, enumerating only when explicitly requested. + + None requests the full domain for exhaustive duplicate repair. Regular + generation samples indices directly, including domains larger than a + NumPy integer bound or Python's maximum sequence length. + """ + count = self._finite_gene_space_length(space) + if count == 0: + return numpy.empty(0, dtype=object) + if sample_size is None or (without_replacement and sample_size >= count): + indices = range(count) + elif without_replacement: + # random.sample requires len(range(count)) to fit a platform + # integer. Small samples from larger domains use rejection. + if count <= numpy.iinfo(numpy.intp).max: + indices = self.python_random_generator.sample(range(count), sample_size) + else: + indices, selected = [], set() + while len(indices) < sample_size: + index = self.python_random_generator.randrange(count) + if index not in selected: + selected.add(index) + indices.append(index) + else: + indices = [self.python_random_generator.randrange(count) for _ in range(sample_size)] + return numpy.asarray([self._finite_gene_space_value(space, index) for index in indices], dtype=object) def sample_initial_population_gene_values(self, gene_index, num_values): """ @@ -635,7 +822,7 @@ def sample_initial_population_gene_values(self, gene_index, num_values): explicit_values = numpy.unique(self.change_gene_dtype_and_round(gene_index, explicit_values)) # None is one choice in the space, rather than a fixed value # sampled once and reused throughout the column. - selected_indices = numpy.random.randint(0, len(explicit_values) + 1, size=num_values) + selected_indices = self.numpy_random_generator.randint(0, len(explicit_values) + 1, size=num_values) values = numpy.empty(num_values, dtype=object) random_positions = selected_indices == len(explicit_values) values[~random_positions] = explicit_values[selected_indices[~random_positions]] @@ -644,10 +831,13 @@ def sample_initial_population_gene_values(self, gene_index, num_values): values[random_positions] = self._initial_population_range_values( gene_index, lower, upper, int(numpy.sum(random_positions))) return values + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + values = self._sample_finite_gene_space(space, num_values) + return self.change_gene_dtype_and_round(gene_index, values) candidates = self.get_gene_space_values(gene_index) if len(candidates) == 0: raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_index}.") - return numpy.random.choice(candidates, size=num_values, replace=True) + return self.numpy_random_generator.choice(candidates, size=num_values, replace=True) def get_initial_population_gene_candidates(self, gene_index, sample_size, all_integer_values=True): @@ -655,7 +845,7 @@ def get_initial_population_gene_candidates(self, gene_index, sample_size, Return replacement candidates for initialization constraints and duplicates. Finite domains are considered in full; continuous domains contribute sample_size values. When all_integer_values - is False, large integer intervals are also sampled. Candidates + is False, large ranges and stepped dictionaries are also sampled. Candidates already have the configured gene type and precision. """ space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space @@ -668,6 +858,9 @@ def get_initial_population_gene_candidates(self, gene_index, sample_size, return self._initial_population_range_values( gene_index, space['low'], space['high'], sample_size, all_integer_values=all_integer_values or abs(math.ceil(space['high']) - math.ceil(space['low'])) <= sample_size) + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + return self.get_gene_space_values(gene_index, sample_size=sample_size, + all_values=all_integer_values) if type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): explicit_values = [value for value in space if value is not None] explicit_values = self.change_gene_dtype_and_round(gene_index, explicit_values) @@ -696,10 +889,10 @@ def _initial_population_range_values(self, gene_index, lower, upper, num_values, return numpy.arange(first_value, last_value + 1, dtype=dtype) # RandomState interprets the Python int type as C long on # Windows. Use NumPy's resolved dtype to match the population. - return numpy.random.randint(first_value, last_value + 1, + return self.numpy_random_generator.randint(first_value, last_value + 1, size=num_values, dtype=numpy.dtype(dtype).type) - values = numpy.random.uniform(lower, upper, size=num_values) + values = self.numpy_random_generator.uniform(lower, upper, size=num_values) return self._convert_initial_population_range_values(gene_index, lower, upper, values) def _initial_population_integer_bounds(self, gene_index, lower, upper): @@ -782,7 +975,7 @@ def _convert_initial_population_range_values(self, gene_index, lower, upper, val def get_gene_space_values(self, gene_idx, gene_value=None, mutation_by_replacement=True, sample_size=100, - range_min=None, range_max=None): + range_min=None, range_max=None, all_values=True): """ Generate converted candidates for one gene from its original space. Lists, tuples, arrays, ranges, fixed values, and stepped dictionaries @@ -800,9 +993,13 @@ def get_gene_space_values(self, gene_idx, gene_value=None, Explicit space values always replace the gene. sample_size : int or None Number of samples for continuous entries. None uses - ``self.sample_size``. Finite entries are returned in full. + ``self.sample_size``. With all_values=False, this also limits + samples from ranges, stepped dictionaries, and integer intervals. range_min, range_max : numeric or None Optional bounds for None entries when unpacking a space. + all_values : bool + If True, return complete finite domains for duplicate repair. + If False, sample lazy finite domains without materializing them. Returns ------- @@ -825,31 +1022,33 @@ def get_gene_space_values(self, gene_idx, gene_value=None, values = self.generate_gene_value_randomly( range_min, range_max, gene_value, gene_idx, mutation_by_replacement, - sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) + sample_size=None if all_values and numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) elif type(space) is dict: if 'step' in space: - values = numpy.arange(space['low'], space['high'], space['step']) + values = self._sample_finite_gene_space(space, None if all_values else sample_size, + without_replacement=True) elif numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): # A continuous dictionary with fractional bounds can cast # to an integer near either end, not just values on a grid # starting at low. Include every representable integer. - lower, upper = sorted([space['low'], space['high']]) - lower = lower.item() if isinstance(lower, numpy.generic) else lower - upper = upper.item() if isinstance(upper, numpy.generic) else upper - first_value = math.trunc(lower) - last_value = math.ceil(upper) - 1 if upper > 0 else math.trunc(upper) - values = numpy.arange(first_value, last_value + 1, dtype=dtype[0]) + first_value, last_value = self._continuous_gene_space_integer_bounds(space) + values = self._sample_finite_gene_space( + {'low': first_value, 'high': last_value + 1, 'step': 1}, + None if all_values else sample_size, without_replacement=True) else: - values = numpy.random.uniform(space['low'], space['high'], size=sample_size) + values = self.numpy_random_generator.uniform(space['low'], space['high'], size=sample_size) elif type(space) in pygad.GA.supported_int_float_types: values = [space] + elif isinstance(space, range): + values = self._sample_finite_gene_space(space, None if all_values else sample_size, + without_replacement=True) else: values = [value for value in space if value is not None] if any(value is None for value in space): random_values = self.generate_gene_value_randomly( range_min, range_max, gene_value, gene_idx, True, - sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) + sample_size=None if all_values and numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) values.extend(numpy.atleast_1d(random_values)) # Convert directly from the input values so mixed finite spaces @@ -859,7 +1058,7 @@ def get_gene_space_values(self, gene_idx, gene_value=None, # Rounding may reach the excluded upper bound. Such a value # cannot be selected from this continuous space. compared_values = values.astype(float) - values = values[(compared_values >= space['low']) & (compared_values < space['high'])] + values = values[(compared_values >= space['low']) & ((compared_values < space['high']) if space['low'] != space['high'] else compared_values == space['high'])] if len(values) == 0: # A single sample can round to the upper bound. Use a # representable in-range value instead of failing randomly. @@ -875,26 +1074,34 @@ def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): """ space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space dtype = self.get_gene_dtype(gene_idx) - if type(space) is dict and 'step' not in space and not numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): - return space['low'] <= gene_value < space['high'] + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + lower = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + value = self._gene_value_key(gene_value) + position = math.floor((value - lower) / step) if not isinstance(value, int) or not isinstance(step, int) else (value - lower) // step + indices = {max(0, min(count - 1, position + offset)) for offset in range(-2, 4)} + return any(self._gene_value_key(self.change_gene_dtype_and_round(gene_idx, self._finite_gene_space_value(space, index))) == value + for index in indices) if count else False + if type(space) is dict: + if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): + first, last = self._continuous_gene_space_integer_bounds(space) + return first <= self._gene_value_key(gene_value) <= last + value = float(gene_value) + return space['low'] <= value < space['high'] if space['low'] != space['high'] else value == space['low'] has_none = space is None if type(space) in [list, tuple, numpy.ndarray, range]: explicit_values = [value for value in space if value is not None] explicit_values = self.change_gene_dtype_and_round(gene_idx, explicit_values) - if gene_value in explicit_values: + if self._gene_value_key(gene_value) in {self._gene_value_key(value) for value in explicit_values}: return True has_none = any(value is None for value in space) if has_none: range_min, range_max = self.get_random_mutation_range(gene_idx) replacement = self.mutation_by_replacement if space is None else True - if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): - values = self.generate_gene_value_randomly( - range_min, range_max, current_gene_value, gene_idx, - replacement, sample_size=None) - return gene_value in values if not replacement: - range_min += current_gene_value - range_max += current_gene_value + range_min += self._gene_value_key(current_gene_value) + range_max += self._gene_value_key(current_gene_value) lower, upper = sorted([range_min, range_max]) lower = self.change_gene_dtype_and_round(gene_idx, lower) upper = self.change_gene_dtype_and_round(gene_idx, upper) @@ -922,12 +1129,13 @@ def generate_gene_value_from_space(self, gene_idx, mutation_by_replacement, mutation_by_replacement = True else: range_min, range_max = self.get_random_mutation_range(gene_idx) - random_value = numpy.random.uniform(range_min, range_max) + random_value = self.numpy_random_generator.uniform(range_min, range_max) values = numpy.atleast_1d(self.mutation_change_gene_dtype_and_round( random_value, gene_idx, gene_value, mutation_by_replacement)) else: values = self.get_gene_space_values(gene_idx, gene_value, - mutation_by_replacement, sample_size) + mutation_by_replacement, sample_size, + all_values=not self.allow_duplicate_genes) if gene_value is not None: alternatives = values[values != gene_value] if len(alternatives): @@ -938,7 +1146,7 @@ def generate_gene_value_from_space(self, gene_idx, mutation_by_replacement, raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_idx}.") if sample_size == 1: if self.allow_duplicate_genes or solution is None: - return random.choice(values) + return self.python_random_generator.choice(values) return self.select_unique_value(values, solution, gene_idx) return values @@ -953,7 +1161,7 @@ def generate_gene_value_randomly(self, """ 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 + gene types the helper samples discrete indices directly; for float types it samples uniformly. Parameters @@ -986,30 +1194,21 @@ def generate_gene_value_randomly(self, array of unique values. """ + if step == 0: + raise ValueError('step must be non-zero when generating gene values.') + if step > 0: + range_min, range_max = sorted([range_min, range_max]) gene_type = self.get_gene_dtype(gene_index=gene_idx) if numpy.issubdtype(numpy.dtype(gene_type[0]), numpy.integer): if range_min == range_max: - random_value = numpy.asarray([range_min]) + random_value = numpy.asarray([range_min], dtype=object) else: - if step > 0: - range_min, range_max = min(range_min, range_max), max(range_min, range_max) - random_value = numpy.arange(range_min, range_max, step=step) - if sample_size is None: - # Keep all the values. - pass - else: - if sample_size >= len(random_value): - # Number of values is larger than or equal to the number of elements in random_value. - # Makes no sense to create a larger sample out of the population because it just creates redundant values. - pass - else: - # Sample without replacement to avoid repeated candidates. - random_value = numpy.random.choice(random_value, - size=sample_size, - replace=False) + random_value = self._sample_finite_gene_space( + {'low': range_min, 'high': range_max, 'step': step}, sample_size, + without_replacement=True) else: # Generating a random value. - random_value = numpy.asarray(numpy.random.uniform(low=range_min, + random_value = numpy.asarray(self.numpy_random_generator.uniform(low=range_min, high=range_max, size=1 if sample_size is None else sample_size), dtype=object) diff --git a/pygad/helper/unique.py b/pygad/helper/unique.py index 41cacec3..c44db1cc 100644 --- a/pygad/helper/unique.py +++ b/pygad/helper/unique.py @@ -5,7 +5,6 @@ from collections import deque import numpy import warnings -import random import pygad @@ -122,7 +121,7 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, # different candidates into the same numeric value. values = list(dict.fromkeys((value if dtype[0] is object else dtype[0](value)) for value in numpy.atleast_1d(values))) values = [value for value in values if value != gene_value] - random.shuffle(values) + self.python_random_generator.shuffle(values) # Keep manually supplied values and values inherited from parents. # Only a replacement must come from the current domain. candidate_values.append([gene_value] + values) @@ -132,7 +131,7 @@ def solve_duplicate_genes(self, solution, build_initial_pop=False, # Keep the full domains for constraints depending on changed genes. constrained_values = [] for gene_index, values in enumerate(candidate_values): - if self.gene_constraint and self.gene_constraint[gene_index]: + if self.gene_constraint and self.gene_constraint[gene_index] is not None: selected_values = self.filter_gene_values_by_constraint( numpy.array(values), new_solution, gene_index, warn=False) dtype = self.get_gene_dtype(gene_index) @@ -306,17 +305,17 @@ def select_unique_value(self, gene_values, solution, gene_index): values_to_select_from = list({self._gene_value_key(value): value for value in gene_values if self._gene_value_key(value) not in used_values}.values()) if values_to_select_from: - return random.choice(values_to_select_from) + return self.python_random_generator.choice(values_to_select_from) if solution[gene_index] is None: if not gene_values: raise ValueError(f"There are no values to select for the gene at index {gene_index}.") - return random.choice(gene_values) + return self.python_random_generator.choice(gene_values) return solution[gene_index] def _select_unique_value_by_constraint(self, values, solution, gene_index): """Filter candidates before selecting an unused value for one gene.""" values = numpy.atleast_1d(values) - if self.gene_constraint and self.gene_constraint[gene_index]: + if self.gene_constraint and self.gene_constraint[gene_index] is not None: values = self.filter_gene_values_by_constraint(values, solution, gene_index) if values is None: return solution[gene_index] @@ -373,10 +372,15 @@ def unpack_gene_space(self, range_min, range_max, sample_size_from_inf_range=100 else: low, high = range_min[gene_index], range_max[gene_index] space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space - # Continuous spaces and None entries are inspection samples. - # They must not allocate a large integer range or consume the - # random draws used to generate the population. - if space is None or (type(space) is dict and 'step' not in space): + # Large ranges and stepped spaces remain compact inspection + # snapshots. Repair reads the original space in full when needed. + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + sample_count = min(count, sample_size_from_inf_range) + indices = [index * (count - 1) // max(1, sample_count - 1) for index in range(sample_count)] + values = [self._finite_gene_space_value(space, index) for index in indices] + unpacked_spaces.append(numpy.unique(self.change_gene_dtype_and_round(gene_index, values))) + elif space is None or (type(space) is dict and 'step' not in space): if type(space) is dict: low, high = space['low'], space['high'] unpacked_spaces.append(self._initial_population_range_snapshot( diff --git a/pygad/pygad.py b/pygad/pygad.py index 14bf579d..497d8764 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -97,14 +97,14 @@ def __init__(self, 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. + crossover_probability: The probability of selecting a solution for the crossover operation. If the generated value 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. + mutation_probability: The probability of selecting a gene for the mutation operation. If the generated value is < mutation_probability, the gene is selected. Zero leaves offspring unchanged. 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. + mutation_by_replacement: An optional bool parameter. It applies when random or adaptive mutation draws random values. 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. mutation_percent_genes: Percentage of genes to mutate which defaults to the string 'default' which means 10%. This parameter has no action if any of the 2 parameters mutation_probability or mutation_num_genes exist. mutation_num_genes: Number of genes to mutate which defaults to None. If the parameter mutation_num_genes exists, then no need for the parameter mutation_percent_genes. This parameter has no action if the mutation_probability parameter exists. @@ -135,7 +135,7 @@ def __init__(self, parallel_processing: Added in PyGAD 2.17.0. Defaults to `None` which means no parallel processing is used. If a positive integer is assigned, it specifies the number of threads to be used. If a list or a tuple of exactly 2 elements is assigned, then: 1) The first element can be either "process" or "thread" to specify whether processes or threads are used, respectively. 2) The second element can be: 1) A positive integer to select the maximum number of processes or threads to be used. 2) 0 to indicate that parallel processing is not used. This is identical to setting 'parallel_processing=None'. 3) None to use the default value as calculated by the concurrent.futures module. - random_seed: Added in PyGAD 2.18.0. It defines the random seed to be used by the random function generators (we use random functions in the NumPy and random modules). This helps to reproduce the same results by setting the same random seed. + random_seed: Added in PyGAD 2.18.0. It accepts None or a Python/NumPy integer from 0 through 2**32-1. Each GA owns numpy_random_generator and python_random_generator; their states continue across runs and checkpoints. Custom operators can use these generators for reproducible random draws. logger: Added in PyGAD 2.20.0. It accepts a logger object of the 'logging.Logger' class to log the messages. If no logger is passed, then a default logger is created to log/print the messages to the console exactly like using the 'print()' function. """ @@ -185,7 +185,8 @@ def __init__(self, random_seed=random_seed, logger=logger) except Exception as e: - self.logger.exception(e) + if hasattr(self, 'logger'): + self.logger.exception(e) # sys.exit(-1) raise e diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index b406e8ce..553660df 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.5" +__version__ = "1.5.6" diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index 9b5d0b08..9c7d9795 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -3,7 +3,6 @@ """ import numpy -import random class Crossover: @@ -38,16 +37,16 @@ def single_point_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the K points at which crossover takes place between each two parents. The point does not have to be always at the center of the solutions. - # This saves time by calling the numpy.random.randint() function only once. - crossover_points = numpy.random.randint(low=0, + # This saves time by calling the self.numpy_random_generator.randint() function only once. + crossover_points = self.numpy_random_generator.randint(low=0, high=parents.shape[1], size=offspring_size[0]) for k in range(offspring_size[0]): # Check if the crossover_probability parameter is used. 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])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected as is. if len(indices) == 0: @@ -57,7 +56,7 @@ def single_point_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -103,13 +102,13 @@ def two_points_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the K pairs of points at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only twice. + # This saves time by calling the self.numpy_random_generator.randint() function only twice. # The 2 points of a pair are different values in [0, num_genes], and every such pair is equally likely. # If the chromosome has only a single gene, the points are 0 and 1: the gene is copied from the second parent. - points_a = numpy.random.randint(low=0, + points_a = self.numpy_random_generator.randint(low=0, high=parents.shape[1] + 1, size=offspring_size[0]) - points_b = numpy.random.randint(low=0, + points_b = self.numpy_random_generator.randint(low=0, high=parents.shape[1], size=offspring_size[0]) # Skip the value of the first point so that the 2 points differ. @@ -122,8 +121,8 @@ def two_points_crossover(self, parents, offspring_size): 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])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -133,7 +132,7 @@ def two_points_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -179,17 +178,17 @@ def uniform_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the genes sources at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only once. + # This saves time by calling the self.numpy_random_generator.randint() function only once. # There is a list of 0 and 1 for each offspring. # [0, 1, 0, 0, 1, 1]: If the value is 0, then take the gene from the first parent. If 1, take it from the second parent. - genes_sources = numpy.random.randint(low=0, + genes_sources = self.numpy_random_generator.randint(low=0, high=2, size=offspring_size) 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])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -199,7 +198,7 @@ def uniform_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -253,8 +252,8 @@ def sbx_crossover(self, parents, offspring_size): 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])) + probs = self.numpy_random_generator.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], :] @@ -263,7 +262,7 @@ def sbx_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -271,25 +270,23 @@ def sbx_crossover(self, parents, offspring_size): 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]) + range_min, range_max = self.get_bounded_operator_gene_range(gene_idx) + lower, upper = float(range_min), float(range_max) + p1 = float(numpy.clip(parents[parent1_idx, gene_idx], lower, upper)) + p2 = float(numpy.clip(parents[parent2_idx, gene_idx], lower, upper)) 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] = self.change_gene_dtype_and_round(gene_idx, p1) + offspring[k, gene_idx] = self.convert_bounded_operator_gene_value(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() + rand_u = self.numpy_random_generator.random() if rand_u <= 1.0 / alpha: beta_q = pow(rand_u * alpha, 1.0 / (eta + 1.0)) else: @@ -297,15 +294,14 @@ def sbx_crossover(self, parents, offspring_size): # SBX makes 2 children, symmetric around the parents' mean. # Pick one of them at random so that the child is not always below the mean. - if numpy.random.random() < 0.5: + if self.numpy_random_generator.random() < 0.5: child = 0.5 * ((y1 + y2) - beta_q * (y2 - y1)) else: child = 0.5 * ((y1 + y2) + beta_q * (y2 - y1)) child = numpy.clip(child, lower, upper) - offspring[k, gene_idx] = self.change_gene_dtype_and_round(gene_idx, child) + offspring[k, gene_idx] = self.convert_bounded_operator_gene_value(gene_idx, child) - if self.allow_duplicate_genes == False: - offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k], build_initial_pop=True) + offspring[k] = self.prepare_bounded_operator_solution(parents[parent1_idx], offspring[k]) return offspring @@ -337,17 +333,17 @@ def scattered_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the genes sources at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only once. + # This saves time by calling the self.numpy_random_generator.randint() function only once. # There is a list of 0 and 1 for each offspring. # [0, 1, 0, 0, 1, 1]: If the value is 0, then take the gene from the first parent. If 1, take it from the second parent. - genes_sources = numpy.random.randint(low=0, + genes_sources = self.numpy_random_generator.randint(low=0, high=2, size=offspring_size) 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])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -357,7 +353,7 @@ def scattered_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index abfdd3f1..0f800b8b 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -5,6 +5,22 @@ class GAEngine(FitnessEvaluation): + def __setstate__(self, state): + """Restore generator states, initializing them for older checkpoints.""" + self.__dict__.update(state) + for name in ['random_seed', 'num_generations', 'num_parents_mating', 'sol_per_pop', + 'num_genes', 'K_tournament', 'nsga3_num_divisions', 'sample_size', + 'fitness_batch_size', 'keep_parents', 'keep_elitism']: + value = getattr(self, name, None) + if isinstance(value, numpy.integer): + setattr(self, name, int(value)) + if not hasattr(self, 'numpy_random_generator'): + self.numpy_random_generator = numpy.random.RandomState(self.random_seed) + if not hasattr(self, 'python_random_generator'): + self.python_random_generator = random.Random(self.random_seed) + if not hasattr(self, 'mutation_control_explicitly_set'): + self.mutation_control_explicitly_set = False + def round_genes(self, solutions): """ Convert and round genes in ``solutions`` using ``self.gene_type``. @@ -72,7 +88,7 @@ def generate_initial_population(self, num_solutions): upper = max(self.gene_space['low'], self.gene_space['high']) # A single draw retains the traditional solution-then-gene # order for continuous populations while avoiding scalar calls. - population = numpy.random.uniform(lower, upper, size=(num_solutions, self.num_genes)) + population = self.numpy_random_generator.uniform(lower, upper, size=(num_solutions, self.num_genes)) for gene_index in range(self.num_genes): if self.gene_space is None: gene_lower, gene_upper = self.get_initial_population_range(gene_index) @@ -102,7 +118,7 @@ def prepare_initial_population(self, population): population, build_initial_pop=True) return population - def apply_initial_population_gene_constraints(self, population): + def apply_initial_population_gene_constraints(self, population, warn=True): """ Replace values rejected by their constraints using converted initialization candidates. Constraints see the complete solution @@ -126,10 +142,10 @@ def apply_initial_population_gene_constraints(self, population): accepted_values = self.filter_gene_values_by_constraint( candidates, solution, gene_index, warn=False) if accepted_values is None: - if not self.suppress_warnings: + if warn and not self.suppress_warnings: warnings.warn(f"No value satisfied the constraint for the gene at index {gene_index} with value {solution[gene_index]} while creating the initial population.") else: - solution[gene_index] = random.choice(accepted_values) + solution[gene_index] = self.python_random_generator.choice(accepted_values) return population def cal_pop_fitness(self): diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 823c3c47..8333e557 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -2,8 +2,9 @@ The pygad.utils.mutation module has all the built-in mutation operators. """ +from itertools import chain, combinations + import numpy -import random import pygad @@ -72,7 +73,7 @@ def mutation_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), self.mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), self.mutation_num_genes)) swapped_genes = set() for gene_idx in mutation_indices: @@ -116,14 +117,14 @@ def mutation_probs_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) swapped_genes = set() for gene_idx in range(offspring.shape[1]): if gene_idx in swapped_genes: continue - if probs[gene_idx] <= self.mutation_probability: + if probs[gene_idx] < self.mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, sample_size=self.sample_size) @@ -183,7 +184,7 @@ def mutation_process_gene_value(self, """ # Check if the gene has a constraint. - if self.gene_constraint and self.gene_constraint[gene_idx]: + if self.gene_constraint and self.gene_constraint[gene_idx] is not None: # Generate values that meet the gene constraint. Select more than 1 value. # This method: 1) generates or selects the values 2) filters the values according to the constraint. values = self.get_valid_gene_constraint_values(range_min=range_min, @@ -199,8 +200,8 @@ def mutation_process_gene_value(self, value_selected = solution[gene_idx] else: # Select a value randomly from the list of values satisfying the constraint. - # If size is used with numpy.random.choice(), it returns an array even if it has a single value. To return a numeric value, not an array, then return index 0. - value_selected = numpy.random.choice(values, size=1)[0] + # If size is used with self.numpy_random_generator.choice(), it returns an array even if it has a single value. To return a numeric value, not an array, then return index 0. + value_selected = self.numpy_random_generator.choice(values, size=1)[0] else: # The gene does not have a constraint. Just select a single value. value_selected = self.generate_gene_value(range_min=range_min, @@ -250,7 +251,7 @@ def swap_gene_by_space(self, """ def has_constraint(idx): - return bool(self.gene_constraint and self.gene_constraint[idx]) + return bool(self.gene_constraint and self.gene_constraint[idx] is not None) if swapped_genes is None: swapped_genes = set() @@ -294,7 +295,7 @@ def has_constraint(idx): candidates.append((other_idx, new_gene_value, new_other_value)) if len(candidates) > 0: - other_idx, new_gene_value, new_other_value = random.choice(candidates) + other_idx, new_gene_value, new_other_value = self.python_random_generator.choice(candidates) solution[gene_idx] = new_gene_value solution[other_idx] = new_other_value swapped_genes.update((gene_idx, other_idx)) @@ -320,7 +321,7 @@ def mutation_randomly(self, offspring): # Random mutation changes one or more genes in each offspring randomly. for offspring_idx in range(offspring.shape[0]): # Return the indices of the genes to mutate. - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), self.mutation_num_genes)) for gene_idx in mutation_indices: @@ -361,13 +362,13 @@ def mutation_probs_randomly(self, offspring): # Random mutation changes one or more genes in each offspring randomly. for offspring_idx in range(offspring.shape[0]): # The mutation probabilities for the current offspring. - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): range_min, range_max = self.get_random_mutation_range(gene_idx) - # A gene is mutated only if its mutation probability is less than or equal to the threshold. - if probs[gene_idx] <= self.mutation_probability: + # A gene is mutated only if its mutation probability is less than the threshold. + if probs[gene_idx] < self.mutation_probability: # Generate a random value for mutation that meets the gene constraint, if one exists. random_value = self.mutation_process_gene_value(range_min=range_min, @@ -404,126 +405,115 @@ def polynomial_mutation(self, offspring): 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: + for solution_index, solution in enumerate(offspring): + original = solution.copy() + if self.mutation_probability is not None or not self.mutation_control_explicitly_set: + probability = self.mutation_probability if self.mutation_probability is not None else 1.0 / self.num_genes + gene_indices = (index for index in range(self.num_genes) if self.numpy_random_generator.random() < probability) + else: + gene_indices = self.select_mutation_gene_indices() + selected_any_gene = False + for gene_index in gene_indices: + selected_any_gene = True + lower, upper = map(float, self.get_bounded_operator_gene_range(gene_index)) + if upper - lower < 1e-14: + solution[gene_index] = self.convert_bounded_operator_gene_value(gene_index, lower) 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 + value = float(numpy.clip(solution[gene_index], lower, upper)) + delta_lower = (value - lower) / (upper - lower) + delta_upper = (upper - value) / (upper - lower) + quantile = self.numpy_random_generator.random() + if quantile <= 0.5: + distance = 1.0 - delta_lower + spread = 2.0 * quantile + (1.0 - 2.0 * quantile) * pow(distance, eta_plus_one) + change = pow(spread, 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] = self.change_gene_dtype_and_round(gene_idx, new_value) - - if self.allow_duplicate_genes == False: - offspring[sol_idx], _, _ = self.solve_duplicate_genes(solution=offspring[sol_idx], build_initial_pop=True) + distance = 1.0 - delta_upper + spread = 2.0 * (1.0 - quantile) + 2.0 * (quantile - 0.5) * pow(distance, eta_plus_one) + change = 1.0 - pow(spread, 1.0 / eta_plus_one) + new_value = numpy.clip(value + change * (upper - lower), lower, upper) + solution[gene_index] = self.convert_bounded_operator_gene_value(gene_index, new_value) + if selected_any_gene: + offspring[solution_index] = self.prepare_bounded_operator_solution(original, solution) return offspring - def swap_mutation(self, offspring): - """ - Swap the values of two genes inside each offspring. The two - genes are 2 different genes picked at random. - Offspring with fewer than two genes are returned unchanged. + def select_mutation_gene_indices(self): + """Select eligible genes using the active probability or gene count.""" + if self.mutation_probability is not None: + return numpy.where(self.numpy_random_generator.random(self.num_genes) < self.mutation_probability)[0] + return numpy.asarray(self.python_random_generator.sample(range(self.num_genes), self.mutation_num_genes), dtype=int) - Parameters - ---------- - offspring : numpy.ndarray - The offspring solutions to mutate (modified in place). + def swap_mutation(self, offspring): + """Swap a compatible pair of eligible genes in each offspring. - Returns - ------- - offspring : numpy.ndarray - The mutated offspring. + Explicit mutation controls select eligible positions. With no explicit + control, all positions are eligible and a pair is selected uniformly. """ - - if offspring.shape[1] < 2: - return offspring - - for idx in range(offspring.shape[0]): - mutation_gene1, mutation_gene2 = numpy.random.choice(offspring.shape[1], size=2, replace=False) - - temp = offspring[idx, mutation_gene1] - offspring[idx, mutation_gene1] = offspring[idx, mutation_gene2] - offspring[idx, mutation_gene2] = temp - offspring[:] = self.prepare_operator_output(offspring) - return offspring + return self._apply_permutation_mutation(offspring, 'swap') def inversion_mutation(self, 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]): - mutation_gene1 = numpy.random.randint(low=0, high=numpy.ceil(offspring.shape[1]/2 + 1), size=1)[0] - mutation_gene2 = mutation_gene1 + int(offspring.shape[1]/2) - - genes_to_scramble = numpy.flip(offspring[idx, mutation_gene1:mutation_gene2]) - offspring[idx, mutation_gene1:mutation_gene2] = genes_to_scramble - offspring[:] = self.prepare_operator_output(offspring) - return offspring + """Reverse eligible genes, keeping changes that satisfy destination rules.""" + return self._apply_permutation_mutation(offspring, 'inversion') def scramble_mutation(self, offspring): - """ - Pick a slice of genes inside each offspring and shuffle the - values in that slice. The segment contains num_genes // 2 genes; - genes outside it are unchanged. A shuffle may keep the original - order, and segments with fewer than two genes cannot change. + """Shuffle eligible genes, keeping changes that satisfy destination rules.""" + return self._apply_permutation_mutation(offspring, 'scramble') - Parameters - ---------- - offspring : numpy.ndarray - The offspring solutions to mutate (modified in place). + def _apply_permutation_mutation(self, offspring, mutation_type): + """Share gene selection and complete-solution validation for permutations. - Returns - ------- - offspring : numpy.ndarray - The mutated offspring. + Inversion and scramble retain their historical half-length segment when + no control is explicitly set. Swap checks alternative pairs and + inversion checks shorter eligible intervals when the first proposal + fails. Scramble and swaps of multiple pairs try sample_size proposals. """ - - for offspring_idx in range(offspring.shape[0]): - segment_start = numpy.random.randint(low=0, high=numpy.ceil(offspring.shape[1]/2 + 1), size=1)[0] - segment_end = segment_start + int(offspring.shape[1]/2) - # Shuffle values, not indices, so each permutation of the - # selected segment is possible without a separate reversal. - genes_to_scramble = offspring[offspring_idx, segment_start:segment_end].copy() - numpy.random.shuffle(genes_to_scramble) - offspring[offspring_idx, segment_start:segment_end] = genes_to_scramble - offspring[:] = self.prepare_operator_output(offspring) + if self.num_genes < 2: + return offspring + for solution_index, solution in enumerate(offspring): + if self.mutation_control_explicitly_set: + eligible = numpy.sort(self.select_mutation_gene_indices()) + elif mutation_type == 'swap': + eligible = numpy.arange(self.num_genes) + else: + start = self.numpy_random_generator.randint(low=0, high=int(numpy.ceil(self.num_genes / 2 + 1)), size=1)[0] + eligible = numpy.arange(start, start + self.num_genes // 2) + if len(eligible) < 2: + continue + original = solution.copy() + grouped_swap = mutation_type == 'swap' and self.mutation_control_explicitly_set and len(eligible) > 2 + if mutation_type == 'swap' and not grouped_swap: + first, second = self.numpy_random_generator.choice(eligible, size=2, replace=False) + candidate_pairs = [(first, second)] + # Iterate remaining pairs lazily only if the initial swap + # fails, rather than allocating a quadratic pair array. + candidates = chain(candidate_pairs, combinations(eligible.tolist(), 2)) + elif mutation_type == 'inversion': + candidates = chain([(0, len(eligible) - 1)], combinations(range(len(eligible)), 2)) + else: + candidates = range(self.sample_size) + for candidate in candidates: + proposed = original.copy() + if grouped_swap: + positions = eligible.copy() + self.numpy_random_generator.shuffle(positions) + for first, second in zip(positions[::2], positions[1::2]): + proposed[first], proposed[second] = original[second], original[first] + elif mutation_type == 'swap': + first, second = candidate + proposed[first], proposed[second] = original[second], original[first] + elif mutation_type == 'inversion': + first, last = candidate + selected = eligible[first:last + 1] + proposed[selected] = original[selected][::-1] + else: + values = original[eligible].copy() + self.numpy_random_generator.shuffle(values) + proposed[eligible] = values + prepared = self.prepare_changed_operator_solution(original, proposed) + if prepared is not None: + offspring[solution_index] = prepared + break return offspring def adaptive_mutation_population_fitness(self, offspring): @@ -574,6 +564,9 @@ def adaptive_mutation(self, offspring): The mutated offspring. """ + if self.mutation_probability is not None and not any(self.mutation_probability): + return 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'. # When the 'mutation_probability' parameter exists (i.e. not None), then it is used in the mutation. Otherwise, the 'mutation_num_genes' parameter is used. @@ -647,7 +640,7 @@ def adaptive_mutation_by_space(self, offspring): else: adaptive_mutation_num_genes = self.mutation_num_genes[1] - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) swapped_genes = set() for gene_idx in mutation_indices: @@ -722,7 +715,7 @@ def adaptive_mutation_randomly(self, offspring): else: adaptive_mutation_num_genes = self.mutation_num_genes[1] - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) for gene_idx in mutation_indices: range_min, range_max = self.get_random_mutation_range(gene_idx) @@ -792,14 +785,14 @@ def adaptive_mutation_probs_by_space(self, offspring): else: adaptive_mutation_probability = self.mutation_probability[1] - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) swapped_genes = set() for gene_idx in range(offspring.shape[1]): if gene_idx in swapped_genes: continue - if probs[gene_idx] <= adaptive_mutation_probability: + if probs[gene_idx] < adaptive_mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, @@ -870,12 +863,12 @@ def adaptive_mutation_probs_randomly(self, offspring): else: adaptive_mutation_probability = self.mutation_probability[1] - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): range_min, range_max = self.get_random_mutation_range(gene_idx) - if probs[gene_idx] <= adaptive_mutation_probability: + if probs[gene_idx] < adaptive_mutation_probability: # Generate a random value for mutation that meets the gene constraint, if one exists. random_value = self.mutation_process_gene_value(range_min=range_min, range_max=range_max, diff --git a/pygad/utils/nsga3.py b/pygad/utils/nsga3.py index 546b93d5..bc050ff2 100644 --- a/pygad/utils/nsga3.py +++ b/pygad/utils/nsga3.py @@ -344,6 +344,7 @@ def nsga3_niching_select(self, candidates_at_target, critical_front_distances, niche_counts[target_reference_index], + random_generator=getattr(self, 'numpy_random_generator', numpy.random), ) picked.append(critical_front_indices[chosen_position]) niche_counts[target_reference_index] += 1 @@ -373,7 +374,7 @@ def _nsga3_pick_target_reference_point(niche_counts, def _nsga3_pick_candidate_at_reference(candidates_at_target, critical_front_distances, - niche_count_at_target): + niche_count_at_target, random_generator=None): """ Choose one critical-front candidate at the given reference point. If the niche count is 0 (empty niche), pick the closest candidate. @@ -382,9 +383,8 @@ def _nsga3_pick_candidate_at_reference(candidates_at_target, 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)) - ] + random_generator = numpy.random if random_generator is None else random_generator + return candidates_at_target[random_generator.randint(len(candidates_at_target))] def _nsga3_enumerate_compositions(num_objectives, num_divisions): diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 8b893a9c..3d7174dd 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -80,7 +80,7 @@ def rank_selection(self, fitness, num_parents): parents_indices = [] for parent_num in range(num_parents): - rand_prob = numpy.random.rand() + rand_prob = self.numpy_random_generator.rand() for idx in range(probs.shape[0]): if (rand_prob >= probs_start[idx] and rand_prob < probs_end[idx]): # The variable idx holds the rank of the solution, not its index in the population. @@ -113,7 +113,7 @@ def random_selection(self, fitness, num_parents): """ parents = self.initialize_parents_array((num_parents, self.population.shape[1])) - rand_indices = numpy.random.randint(low=0.0, high=fitness.shape[0], size=num_parents) + rand_indices = self.numpy_random_generator.randint(low=0.0, high=fitness.shape[0], size=num_parents) parents[:, :] = self.population[rand_indices, :].copy() return parents, rand_indices @@ -150,7 +150,7 @@ def tournament_selection(self, fitness, num_parents): for parent_num in range(num_parents): # Generate random indices for the candidate solutions. - rand_indices = numpy.random.randint(low=0, high=len(fitness), size=self.K_tournament) + rand_indices = self.numpy_random_generator.randint(low=0, high=len(fitness), size=self.K_tournament) # Find the rank of the candidate solutions. The lower the rank, the better the solution. rand_indices_rank = [rank_lookup[rand_idx] for rand_idx in rand_indices] @@ -223,7 +223,7 @@ def roulette_wheel_selection(self, fitness, num_parents): parents_indices = [] for parent_num in range(num_parents): - rand_prob = numpy.random.rand() + rand_prob = self.numpy_random_generator.rand() for idx in range(probs.shape[0]): if (rand_prob >= probs_start[idx] and rand_prob < probs_end[idx]): parents_indices.append(idx) @@ -332,7 +332,7 @@ def stochastic_universal_selection(self, fitness, num_parents): # Space pointers using the requested count, which can differ from # num_parents_mating when this operator is called directly. pointers_distance = 1.0 / num_parents - first_pointer = numpy.random.uniform(low=0.0, + first_pointer = self.numpy_random_generator.uniform(low=0.0, high=pointers_distance, size=1)[0] # Location of the first pointer. @@ -412,7 +412,7 @@ def tournament_selection_nsga2(self, 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, + rand_indices = self.numpy_random_generator.randint(low=0, high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) @@ -454,7 +454,7 @@ def tournament_selection_nsga2(self, if len(current_indices_unique) == 1: #### DONE # There is only one solution in the best pareto front. Just select it as a parent. - # The same solution index was randomly generated more than once using the numpy.random.randint() + # The same solution index was randomly generated more than once using the self.numpy_random_generator.randint() selected_parent_index = current_indices_unique[0] else: # There are different solutions at the same front. @@ -496,7 +496,7 @@ def tournament_selection_nsga2(self, selected_parent_index = current_indices_unique[solutions_crowding_distance.index(max_crowding_distance)] else: # If the crowding distance is equal across multiple solutions, select a solution randomly as a parent. - selected_parent_index = numpy.random.choice(current_indices_unique) + selected_parent_index = self.numpy_random_generator.choice(current_indices_unique) # Insert the selected parent index. parents_indices.append(selected_parent_index) @@ -768,7 +768,7 @@ def tournament_selection_nsga3(self, fitness, num_parents): niche_counts = numpy.bincount(associations, minlength=len(self.nsga3_reference_points)) - rand_indices = numpy.random.randint(low=0, + rand_indices = self.numpy_random_generator.randint(low=0, high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) parents_indices = [self._nsga3_pick_tournament_winner(rand_indices[slot], diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 0fc00ff7..97ca5ebe 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -6,125 +6,121 @@ class Validation: - 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. - logger = logging.getLogger(__name__) - # Set the logger log level to 'DEBUG' to log all kinds of messages. - logger.setLevel(logging.DEBUG) - - # Clear any attached handlers to the logger from the previous runs. - logger.handlers.clear() - - # Create the handlers. - stream_handler = logging.StreamHandler() - # Set the handler log level to 'DEBUG' to log all kinds of messages received from the logger. - stream_handler.setLevel(logging.DEBUG) - - # Create the formatter that just includes the log message. - formatter = logging.Formatter('%(message)s') - - # Add the formatter to the handler. - stream_handler.setFormatter(formatter) - - # Add the handler to the logger. - logger.addHandler(stream_handler) - else: - # Validate that the passed logger is of type 'logging.Logger'. - if isinstance(logger, logging.Logger): - pass - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'logger' parameter is 'logging.Logger' but {type(logger)} found.") - - # Create the 'self.logger' attribute to hold the logger. - self.logger = logger - - self.random_seed = random_seed - if random_seed is None: - pass - else: - numpy.random.seed(self.random_seed) - random.seed(self.random_seed) - - # If suppress_warnings is bool and its value is False, then print warning messages. - if type(suppress_warnings) is bool: - self.suppress_warnings = suppress_warnings - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'suppress_warnings' parameter is bool but {type(suppress_warnings)} found.") - - # Validating mutation_by_replacement - if not (type(mutation_by_replacement) is bool): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'mutation_by_replacement' parameter is bool but {type(mutation_by_replacement)} found.") + def _validate_header(self, logger, random_seed, suppress_warnings, + mutation_by_replacement, sample_size, allow_duplicate_genes): + """Set up logging and validate flags, sample size, and the random seed. + + Random generators are created after the remaining parameter checks, + so rejected configurations do not sample values or run constraints. + """ + self.valid_parameters = False + if logger is not None and not isinstance(logger, logging.Logger): + raise TypeError("logger must be a logging.Logger instance or None.") + self.logger = logger if logger is not None else logging.getLogger(__name__) + if logger is None and not self.logger.handlers: + self.logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('%(message)s')) + self.logger.addHandler(handler) + self.suppress_warnings = self._validate_boolean_parameter(suppress_warnings, 'suppress_warnings') + self.mutation_by_replacement = self._validate_boolean_parameter(mutation_by_replacement, 'mutation_by_replacement') + self.allow_duplicate_genes = self._validate_boolean_parameter(allow_duplicate_genes, 'allow_duplicate_genes') + self.sample_size = self._validate_integer_parameter(sample_size, 'sample_size', minimum=1) + self.random_seed = (None if random_seed is None else + self._validate_integer_parameter(random_seed, 'random_seed', minimum=0, maximum=2**32 - 1)) + + def _validate_boolean_parameter(self, value, parameter_name): + """Return a boolean setting or raise an error naming the parameter.""" + if type(value) is not bool: + raise TypeError(f"{parameter_name} must be a bool, but {type(value).__name__} found.") + return value + + def _validate_integer_parameter(self, value, parameter_name, minimum=None, maximum=None): + """Validate a count and return a Python integer before any arithmetic.""" + if isinstance(value, (bool, numpy.bool_)) or not isinstance(value, (int, numpy.integer)): + raise TypeError(f"{parameter_name} must be an integer, but {type(value).__name__} found.") + value = int(value) + if minimum is not None and value < minimum: + raise ValueError(f"{parameter_name} must be >= {minimum}, but {value} found.") + if maximum is not None and value > maximum: + raise ValueError(f"{parameter_name} must be <= {maximum}, but {value} found.") + return value + + def _validate_numeric_parameter(self, value, parameter_name, minimum=None, maximum=None): + """Validate a finite real number and normalize NumPy scalar values.""" + if isinstance(value, (bool, numpy.bool_)) or not isinstance(value, (int, float, numpy.integer, numpy.floating)): + raise TypeError(f"{parameter_name} must be numeric, but {type(value).__name__} found.") + if isinstance(value, numpy.integer): + value = int(value) + elif isinstance(value, numpy.floating): + value = float(value) + if isinstance(value, float) and not numpy.isfinite(value): + raise ValueError(f"{parameter_name} must be finite, but {value} found.") + if minimum is not None and value < minimum: + raise ValueError(f"{parameter_name} must be >= {minimum}, but {value} found.") + if maximum is not None and value > maximum: + raise ValueError(f"{parameter_name} must be <= {maximum}, but {value} found.") + return value + + def _validate_callable_parameter(self, function, parameter_name, num_arguments): + """Check the positional call PyGAD makes without invoking user code. + + Signature binding supports functions, bound methods, callable objects, + partial functions, and additional optional parameters. Counting names + alone would also accept required keyword-only parameters incorrectly. + """ + if (not callable(function) or inspect.isclass(function) or inspect.iscoroutinefunction(function) + or inspect.iscoroutinefunction(getattr(function, '__call__', None))): + raise TypeError(f"{parameter_name} must be a synchronous callable.") + try: + signature = inspect.signature(function) + signature.bind(*([None] * num_arguments)) + except (TypeError, ValueError) as error: + raise ValueError(f"{parameter_name} must accept {num_arguments} positional arguments: {error}") from error + return function + + def _resolve_operator(self, operator, parameter_name, built_in_operators, num_arguments, allow_none=False): + """Resolve a built-in name or validate a user-defined operator once.""" + if operator is None and allow_none: + return None, None + if isinstance(operator, str): + operator = operator.lower() + if operator not in built_in_operators: + raise TypeError(f"Unknown {parameter_name} '{operator}'. Supported names are {list(built_in_operators)}.") + return operator, getattr(self, built_in_operators[operator]) + return operator, self._validate_callable_parameter(operator, parameter_name, num_arguments) + + def _validate_range_parameters(self, lower, upper, lower_name, upper_name): + """Copy scalar or per-gene bounds, validating their shape and values.""" + sequences = (list, tuple, numpy.ndarray) + for value, name in [(lower, lower_name), (upper, upper_name)]: + if isinstance(value, numpy.ndarray) and value.ndim == 0: + raise ValueError(f'{name} must be a numeric scalar or a 1D sequence, not a 0D array.') + if isinstance(lower, sequences) and isinstance(upper, sequences): + result = [] + for values, name in [(lower, lower_name), (upper, upper_name)]: + if numpy.asarray(values, dtype=object).ndim != 1 or len(values) != self.num_genes: + raise ValueError(f"{name} must be a 1D sequence with length equal to num_genes ({self.num_genes}).") + result.append([self._validate_numeric_parameter(value, name) for value in values]) + return tuple(result) + if isinstance(lower, sequences) or isinstance(upper, sequences): + raise TypeError(f"{lower_name} and {upper_name} must both be numeric or both be per-gene sequences.") + lower = self._validate_numeric_parameter(lower, lower_name) + upper = self._validate_numeric_parameter(upper, upper_name) + if lower == upper and not self.suppress_warnings: + warnings.warn(f"The values of {lower_name} and {upper_name} are equal, so sampling uses a fixed value.") + return lower, upper + + def _copy_parameter_container(self, value): + """Copy parameter containers recursively, retaining numeric values and callables.""" + if isinstance(value, dict): + return {key: self._copy_parameter_container(item) for key, item in value.items()} + if isinstance(value, (list, tuple, numpy.ndarray)): + if isinstance(value, numpy.ndarray) and value.ndim == 0: + raise ValueError('Parameter arrays must be sequences, not 0D arrays.') + return [self._copy_parameter_container(item) for item in value] + return value - self.mutation_by_replacement = mutation_by_replacement - - # Validate the sample_size parameter. - if type(sample_size) in self.supported_int_types: - if sample_size > 0: - pass - else: - self.valid_parameters = False - raise ValueError(f"The value of the sample_size parameter must be > 0 but the value ({sample_size}) found.") - else: - self.valid_parameters = False - raise TypeError(f"The type of the sample_size parameter must be integer but the value ({sample_size}) of type {type(sample_size)} found.") - - self.sample_size = sample_size - - # Validate allow_duplicate_genes - if not (type(allow_duplicate_genes) is bool): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'allow_duplicate_genes' parameter is bool but {type(allow_duplicate_genes)} found.") - - self.allow_duplicate_genes = allow_duplicate_genes - def _validate_gene_space(self, gene_space): """ @@ -152,12 +148,13 @@ def _validate_gene_space(self, If a nested gene space has an unsupported element type, or if a dict gene space is missing required keys. """ + gene_space = self._copy_parameter_container(gene_space) # Validate gene_space self.gene_space_nested = False if type(gene_space) is type(None): pass elif type(gene_space) is range: - if len(gene_space) == 0: + if self._finite_gene_space_length(gene_space) == 0: self.valid_parameters = False raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") elif type(gene_space) in [list, tuple, numpy.ndarray]: @@ -166,6 +163,11 @@ def _validate_gene_space(self, raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") else: for index, el in enumerate(gene_space): + if isinstance(el, range): + if self._finite_gene_space_length(el) == 0: + raise ValueError(f'The gene_space range at index {index} cannot be empty.') + self.gene_space_nested = True + continue if type(el) in [numpy.ndarray, list, tuple, range]: if len(el) == 0: self.valid_parameters = False @@ -199,12 +201,9 @@ def _validate_gene_space_dictionary(self, space): self.valid_parameters = False raise ValueError("A gene_space dictionary must have 'low' and 'high' keys and may also have 'step'.") for name, value in space.items(): - if type(value) not in self.supported_int_float_types: - self.valid_parameters = False - raise TypeError(f"The '{name}' value in a gene_space dictionary must be numeric but {type(value)} found.") - if type(value) in self.supported_float_types and not numpy.isfinite(value): - self.valid_parameters = False - raise ValueError(f"The '{name}' value in a gene_space dictionary must be finite but {value} found.") + space[name] = self._validate_numeric_parameter(value, f"gene_space '{name}'") + if 'step' not in space: + space['low'], space['high'] = sorted([space['low'], space['high']]) if 'step' in space: if (space['step'] == 0 or (space['step'] > 0 and space['high'] <= space['low']) @@ -212,63 +211,10 @@ def _validate_gene_space_dictionary(self, space): self.valid_parameters = False raise ValueError("The step in a gene_space dictionary must be non-zero and lead from low towards high so the space is not empty.") - def _validate_init_range(self, - init_range_low, - init_range_high, - num_genes): - """ - 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 - Resolved number of genes per solution, inferred from the - supplied population when one is available. - - Raises - ------ - TypeError - If either parameter is not a supported type. - ValueError - If the per-gene iterables have a length different from - ``num_genes``. - """ - low_is_scalar = type(init_range_low) in self.supported_int_float_types - high_is_scalar = type(init_range_high) in self.supported_int_float_types - if low_is_scalar and high_is_scalar: - bounds = [('init_range_low', [init_range_low]), ('init_range_high', [init_range_high])] - if init_range_low == init_range_high and not self.suppress_warnings: - warnings.warn("The values of the 2 parameters 'init_range_low' and 'init_range_high' are equal and this might return the same value for some genes in the initial population.") - elif (type(init_range_low) in [list, tuple, numpy.ndarray] - and type(init_range_high) in [list, tuple, numpy.ndarray]): - bounds = [('init_range_low', init_range_low), ('init_range_high', init_range_high)] - for parameter_name, values in bounds: - if numpy.asarray(values, dtype=object).ndim != 1 or len(values) != num_genes: - self.valid_parameters = False - raise ValueError(f"{parameter_name} must be a 1D list, tuple, or NumPy array with length equal to the number of genes ({num_genes}).") - else: - self.valid_parameters = False - raise TypeError("init_range_low and init_range_high must both be numeric or both be lists, tuples, or NumPy arrays.") - for parameter_name, values in bounds: - for value in values: - if type(value) not in self.supported_int_float_types: - self.valid_parameters = False - raise TypeError(f"The values of {parameter_name} must be numeric but {value} of type {type(value)} found.") - if type(value) in self.supported_float_types and not numpy.isfinite(value): - self.valid_parameters = False - raise ValueError(f"The values of {parameter_name} must be finite but {value} found.") - self.init_range_low = init_range_low - self.init_range_high = init_range_high + def _validate_init_range(self, init_range_low, init_range_high, num_genes): + """Validate initialization bounds using the resolved population dimensions.""" + self.init_range_low, self.init_range_high = self._validate_range_parameters( + init_range_low, init_range_high, 'init_range_low', 'init_range_high') def _validate_gene_type(self, gene_type, num_genes): """ @@ -343,13 +289,8 @@ def _validate_initial_population_shape(self, initial_population, sol_per_pop, nu if sol_per_pop is None or num_genes is None: self.valid_parameters = False raise TypeError("When initial_population is None, both sol_per_pop and num_genes must be specified.") - for parameter_name, parameter_value in [('sol_per_pop', sol_per_pop), ('num_genes', num_genes)]: - if type(parameter_value) is not int: - self.valid_parameters = False - raise TypeError(f"The expected type of the {parameter_name} parameter is int but {type(parameter_value)} found.") - if parameter_value <= 0: - self.valid_parameters = False - raise ValueError(f"The value of {parameter_name} must be > 0 but {parameter_value} found.") + sol_per_pop = self._validate_integer_parameter(sol_per_pop, 'sol_per_pop', minimum=1) + num_genes = self._validate_integer_parameter(num_genes, 'num_genes', minimum=1) population = None else: if type(initial_population) not in [list, tuple, numpy.ndarray]: @@ -389,756 +330,134 @@ def _build_initial_population(self, initial_population): # Keep separate arrays so evolution cannot modify this snapshot. self.initial_population = self.population.copy() - 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: - if random_mutation_min_val == random_mutation_max_val: - if not self.suppress_warnings: - warnings.warn("The values of the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val' are equal and this might cause a fixed mutation to some genes.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'random_mutation_min_val' {type(random_mutation_min_val)} and 'random_mutation_max_val' {type(random_mutation_max_val)}.") - elif type(random_mutation_min_val) in [list, tuple, numpy.ndarray]: - if len(random_mutation_min_val) == self.num_genes: - pass - else: - self.valid_parameters = False - raise ValueError(f"The length of the 'random_mutation_min_val' parameter is {len(random_mutation_min_val)} which is different from the number of genes {self.num_genes}.") - if type(random_mutation_max_val) in [list, tuple, numpy.ndarray]: - if len(random_mutation_min_val) == len(random_mutation_max_val): - pass - else: - self.valid_parameters = False - raise ValueError(f"Size mismatch between the 2 parameters 'random_mutation_min_val' {len(random_mutation_min_val)} and 'random_mutation_max_val' {len(random_mutation_max_val)}.") - - # Validate the values in random_mutation_min_val - for val in random_mutation_min_val: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'random_mutation_min_val' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - - # Validate the values in random_mutation_max_val - for val in random_mutation_max_val: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'random_mutation_max_val' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'random_mutation_min_val' {type(random_mutation_min_val)} and 'random_mutation_max_val' {type(random_mutation_max_val)}.") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'random_mutation_min_val' parameter is numeric or list/tuple/numpy.ndarray but {type(random_mutation_min_val)} found.") - - self.random_mutation_min_val = random_mutation_min_val - self.random_mutation_max_val = random_mutation_max_val - - - 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]: - if len(gene_constraint) == self.num_genes: - for constraint_idx, item in enumerate(gene_constraint): - # Check whether the element is None or a callable. - if item is None: - pass - elif item and callable(item): - if item.__code__.co_argcount == 2: - # Every callable is valid if it receives 2 arguments. - # The 2 arguments: 1) solution 2) A list or numpy.ndarray of values to check if they meet the constraint. - pass - else: - self.valid_parameters = False - raise ValueError(f"Every callable inside the gene_constraint parameter must accept 2 arguments representing 1) The solution/chromosome where the gene exists 2) A list or NumPy array of values to check if they meet the constraint. But the callable at index {constraint_idx} named '{item.__code__.co_name}' accepts {item.__code__.co_argcount} argument(s).") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of an element in the 'gene_constraint' parameter is None or a callable (e.g. function). But {item} at index {constraint_idx} of type {type(item)} found.") - else: - self.valid_parameters = False - raise ValueError(f"The number of constraints ({len(gene_constraint)}) in the 'gene_constraint' parameter must be equal to the number of genes ({self.num_genes}).") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'gene_constraint' parameter is either a list or tuple. But the value {gene_constraint} of type {type(gene_constraint)} found.") - else: - # gene_constraint is None and not used. - pass + def _validate_mutation_range(self, random_mutation_min_val, random_mutation_max_val): + """Validate and copy the scalar or per-gene random mutation bounds.""" + self.random_mutation_min_val, self.random_mutation_max_val = self._validate_range_parameters( + random_mutation_min_val, random_mutation_max_val, + 'random_mutation_min_val', 'random_mutation_max_val') - self.gene_constraint = gene_constraint - - def _validate_crossover(self, - crossover_type, - 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: - self.crossover = None - elif inspect.ismethod(crossover_type): - # Check if the crossover_type is a method that accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover method assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned to a method, then this crossover method must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.\n3) The instance from the pygad.GA class.\n\nThe passed crossover method named '{crossover_type.__code__.co_name}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - elif inspect.isfunction(crossover_type): - # Check if the crossover_type is a function that accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover function assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned to a function, then this crossover function must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed crossover function named '{crossover_type.__code__.co_name}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - elif callable(crossover_type) and not inspect.isclass(crossover_type): - # The object must have the __call__() method. - if hasattr(crossover_type, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover class instance assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.\n3) The instance from the pygad.GA class.\n\nThe passed instance of the class named '{crossover_type.__class__.__name__}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'crossover_type' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - elif not (type(crossover_type) is str): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'crossover_type' parameter is either callable or str but {type(crossover_type)} found.") - else: # type crossover_type is str - crossover_type = crossover_type.lower() - if crossover_type == "single_point": - self.crossover = self.single_point_crossover - elif crossover_type == "two_points": - self.crossover = self.two_points_crossover - elif crossover_type == "uniform": - 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-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 - elif type(crossover_probability) in self.supported_int_float_types: - if 0 <= crossover_probability <= 1: - self.crossover_probability = crossover_probability - else: - self.valid_parameters = False - raise ValueError(f"The value assigned to the 'crossover_probability' parameter must be between 0 and 1 inclusive but ({crossover_probability}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'crossover_probability' parameter. Float is expected but ({crossover_probability}) of type {type(crossover_probability)} found.") - - def _validate_mutation(self, - mutation_type, - mutation_probability, - mutation_num_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 - if mutation_type is None: - self.mutation = None - elif inspect.ismethod(mutation_type): - # Check if the mutation_type is a method that accepts 2 parameters. - if (len(inspect.signature(mutation_type).parameters) == 2): - # The mutation method assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: - self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned to a method, then it must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class.\n\nThe passed mutation method named '{mutation_type.__code__.co_name}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - elif inspect.isfunction(mutation_type): - # Check if the mutation_type is a function that accepts 2 parameters. - if (len(inspect.signature(mutation_type).parameters) == 2): - # The mutation function assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: - self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned to a function, then this mutation function must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed mutation function named '{mutation_type.__code__.co_name}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - elif callable(mutation_type) and not inspect.isclass(mutation_type): - # The object must have the __call__() method. - if hasattr(mutation_type, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(mutation_type).parameters) == 2: - # The mutation class instance assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: - self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed instance of the class named '{mutation_type.__class__.__name__}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'mutation_type' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - elif not (type(mutation_type) is str): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'mutation_type' parameter is either callable or str but {type(mutation_type)} found.") - else: # type mutation_type is str - mutation_type = mutation_type.lower() - if mutation_type == "random": - self.mutation = self.random_mutation - elif mutation_type == "swap": - self.mutation = self.swap_mutation - elif mutation_type == "scramble": - self.mutation = self.scramble_mutation - elif mutation_type == "inversion": - 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-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: - self.mutation_probability = None - elif mutation_type != "adaptive": - # Mutation probability is fixed not adaptive. - if type(mutation_probability) in self.supported_int_float_types: - if 0 <= mutation_probability <= 1: - self.mutation_probability = mutation_probability - else: - self.valid_parameters = False - raise ValueError(f"The value assigned to the 'mutation_probability' parameter must be between 0 and 1 inclusive but ({mutation_probability}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_probability' parameter. A numeric value is expected but ({mutation_probability}) of type {type(mutation_probability)} found.") - else: - # Mutation probability is adaptive not fixed. - if type(mutation_probability) in [list, tuple, numpy.ndarray]: - if len(mutation_probability) == 2: - for el in mutation_probability: - if type(el) in self.supported_int_float_types: - if 0 <= el <= 1: - pass - else: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_probability' parameter must be between 0 and 1 inclusive but ({el}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_probability' parameter. A numeric value is expected but ({el}) of type {type(el)} found.") - if mutation_probability[0] < mutation_probability[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_probability' parameter is {mutation_probability[0]} which is smaller than the second element {mutation_probability[1]}. This means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions. Please make the first element higher than the second element.") - self.mutation_probability = mutation_probability - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_probability' parameter must have only 2 elements but ({len(mutation_probability)}) element(s) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_probability' parameter. When mutation_type='adaptive', then list/tuple/numpy.ndarray is expected but ({mutation_probability}) of type {type(mutation_probability)} found.") - else: - pass - - # Calculate the value of mutation_num_genes - if not (self.mutation_type is None): - if mutation_num_genes is None: - # The mutation_num_genes parameter does not exist. Checking whether adaptive mutation is used. - if mutation_type != "adaptive": - # The percent of genes to mutate is fixed not adaptive. - if mutation_percent_genes == 'default'.lower(): - mutation_percent_genes = 10 - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes = numpy.uint32( - (mutation_percent_genes*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes == 0: - if self.mutation_probability is None: - if not self.suppress_warnings: - warnings.warn( - f"The percentage of genes to mutate (mutation_percent_genes={mutation_percent_genes}) resulted in selecting ({mutation_num_genes}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes = 1 - - elif type(mutation_percent_genes) in self.supported_int_float_types: - if mutation_percent_genes <= 0 or mutation_percent_genes > 100: - self.valid_parameters = False - raise ValueError(f"The percentage of selected genes for mutation (mutation_percent_genes) must be > 0 and <= 100 but ({mutation_percent_genes}) found.\n") - else: - # If mutation_percent_genes equals the string "default", then it is replaced by the numeric value 10. - if mutation_percent_genes == 'default'.lower(): - mutation_percent_genes = 10 - - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes = numpy.uint32( - (mutation_percent_genes*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes == 0: - if self.mutation_probability is None: - if not self.suppress_warnings: - warnings.warn(f"The percentage of genes to mutate (mutation_percent_genes={mutation_percent_genes}) resulted in selecting ({mutation_num_genes}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes = 1 - else: - self.valid_parameters = False - raise TypeError(f"Unexpected value or type of the 'mutation_percent_genes' parameter. It only accepts the string 'default' or a numeric value but ({mutation_percent_genes}) of type {type(mutation_percent_genes)} found.") - else: - # The percent of genes to mutate is adaptive not fixed. - if type(mutation_percent_genes) in [list, tuple, numpy.ndarray]: - if len(mutation_percent_genes) == 2: - mutation_num_genes = numpy.zeros_like( - mutation_percent_genes, dtype=numpy.uint32) - for idx, el in enumerate(mutation_percent_genes): - if type(el) in self.supported_int_float_types: - if el <= 0 or el > 100: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_percent_genes' must be > 0 and <= 100 but ({mutation_percent_genes}) found.\n") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_percent_genes' parameter. An integer value is expected but ({el}) of type {type(el)} found.") - # At this point of the loop, the current value assigned to the parameter 'mutation_percent_genes' is validated. - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes[idx] = numpy.uint32( - (mutation_percent_genes[idx]*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes[idx] == 0: - if not self.suppress_warnings: - warnings.warn(f"The percentage of genes to mutate ({mutation_percent_genes[idx]}) resulted in selecting ({mutation_num_genes[idx]}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes[idx] = 1 - if mutation_percent_genes[0] < mutation_percent_genes[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_percent_genes' parameter is ({mutation_percent_genes[0]}) which is smaller than the second element ({mutation_percent_genes[1]}).\nThis means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions.\nPlease make the first element higher than the second element.") - # At this point outside the loop, all values of the parameter 'mutation_percent_genes' are validated. Everything is OK. - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_percent_genes' parameter must have only 2 elements but ({len(mutation_percent_genes)}) element(s) found.") - else: - if self.mutation_probability is None: - self.valid_parameters = False - raise TypeError(f"Unexpected type of the 'mutation_percent_genes' parameter. When mutation_type='adaptive', then the 'mutation_percent_genes' parameter should exist and assigned a list/tuple/numpy.ndarray with 2 values but ({mutation_percent_genes}) found.") - # The mutation_num_genes parameter exists. Checking whether adaptive mutation is used. - elif mutation_type != "adaptive": - # Number of genes to mutate is fixed not adaptive. - if type(mutation_num_genes) in self.supported_int_types: - if mutation_num_genes <= 0: - self.valid_parameters = False - raise ValueError(f"The number of selected genes for mutation (mutation_num_genes) cannot be <= 0 but ({mutation_num_genes}) found. If you do not want to use mutation, please set mutation_type=None\n") - elif mutation_num_genes > self.num_genes: - self.valid_parameters = False - raise ValueError(f"The number of selected genes for mutation (mutation_num_genes), which is ({mutation_num_genes}), cannot be greater than the number of genes ({self.num_genes}).\n") - else: - self.valid_parameters = False - raise TypeError(f"The 'mutation_num_genes' parameter is expected to be a positive integer but the value ({mutation_num_genes}) of type {type(mutation_num_genes)} found.\n") - else: - # Number of genes to mutate is adaptive not fixed. - if type(mutation_num_genes) in [list, tuple, numpy.ndarray]: - if len(mutation_num_genes) == 2: - for el in mutation_num_genes: - if type(el) in self.supported_int_types: - if el <= 0: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_num_genes' cannot be <= 0 but ({el}) found. If you do not want to use mutation, please set mutation_type=None\n") - elif el > self.num_genes: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_num_genes' cannot be greater than the number of genes ({self.num_genes}) but ({el}) found.\n") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_num_genes' parameter. An integer value is expected but ({el}) of type {type(el)} found.") - # At this point of the loop, the current value assigned to the parameter 'mutation_num_genes' is validated. - if mutation_num_genes[0] < mutation_num_genes[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_num_genes' parameter is {mutation_num_genes[0]} which is smaller than the second element {mutation_num_genes[1]}. This means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions. Please make the first element higher than the second element.") - # At this point outside the loop, all values of the parameter 'mutation_num_genes' are validated. Everything is OK. - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_num_genes' parameter must have only 2 elements but ({len(mutation_num_genes)}) element(s) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_num_genes' parameter. When mutation_type='adaptive', then list/tuple/numpy.ndarray is expected but ({mutation_num_genes}) of type {type(mutation_num_genes)} found.") - else: - pass - - # Validating mutation_by_replacement and mutation_type - if self.mutation_type != "random" and self.mutation_by_replacement: - if not self.suppress_warnings: - warnings.warn(f"The mutation_by_replacement parameter is set to True while the mutation_type parameter is not set to random but ({mutation_type}). Note that the mutation_by_replacement parameter has an effect only when mutation_type='random'.") - - # Check if crossover and mutation are both disabled. - if (self.mutation_type is None) and (self.crossover_type is None): - if not self.suppress_warnings: - 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 + def _validate_gene_constraint(self, gene_constraint): + """Copy one optional constraint per gene and validate its positional call.""" + if gene_constraint is None: + self.gene_constraint = None 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, - 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): - # Check if the parent_selection_type is a method that accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection method assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned to a method, then it must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class.\n\nThe passed parent selection method named '{parent_selection_type.__code__.co_name}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - elif inspect.isfunction(parent_selection_type): - # Check if the parent_selection_type is a function that accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection function assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned to a user-defined function, then this parent selection function must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed parent selection function named '{parent_selection_type.__code__.co_name}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - elif callable(parent_selection_type) and not inspect.isclass(parent_selection_type): - # The object must have the __call__() method. - if hasattr(parent_selection_type, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection class instance assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed instance of the class named '{parent_selection_type.__class__.__name__}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'parent_selection_type' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - elif not (type(parent_selection_type) is str): - self.valid_parameters = False - - raise TypeError(f"The expected type of the 'parent_selection_type' parameter is either callable or str but {type(parent_selection_type)} found.") + if not isinstance(gene_constraint, (list, tuple)): + raise TypeError("gene_constraint must be a list or tuple, or None.") + if len(gene_constraint) != self.num_genes: + raise ValueError(f"The number of constraints ({len(gene_constraint)}) must equal num_genes ({self.num_genes}).") + self.gene_constraint = [None if constraint is None else + self._validate_callable_parameter(constraint, f'gene_constraint at index {index}', 2) + for index, constraint in enumerate(gene_constraint)] + + def _validate_crossover(self, crossover_type, crossover_probability, sbx_crossover_eta=30): + """Resolve crossover and validate its probability and finite distribution index.""" + operators = {'single_point': 'single_point_crossover', 'two_points': 'two_points_crossover', + 'uniform': 'uniform_crossover', 'scattered': 'scattered_crossover', 'sbx': 'sbx_crossover'} + self.crossover_type, self.crossover = self._resolve_operator(crossover_type, 'crossover_type', operators, 3, allow_none=True) + self.sbx_crossover_eta = self._validate_numeric_parameter(sbx_crossover_eta, 'sbx_crossover_eta') + if self.sbx_crossover_eta <= 0: + raise ValueError('sbx_crossover_eta must be positive.') + self.crossover_probability = (None if crossover_probability is None else + self._validate_numeric_parameter(crossover_probability, 'crossover_probability', 0, 1)) + + def _validate_mutation(self, mutation_type, mutation_probability, mutation_num_genes, + mutation_percent_genes, polynomial_mutation_eta=20): + """Resolve the active mutation control before validating its values. + + Probability takes precedence over a gene count, which takes precedence + over a percentage. Permutation and polynomial operators keep their + historical defaults when none of these controls is explicitly set. + """ + operators = {'random': 'random_mutation', 'swap': 'swap_mutation', 'inversion': 'inversion_mutation', + 'scramble': 'scramble_mutation', 'adaptive': 'adaptive_mutation', 'polynomial': 'polynomial_mutation'} + self.mutation_type, self.mutation = self._resolve_operator(mutation_type, 'mutation_type', operators, 2, allow_none=True) + self.polynomial_mutation_eta = self._validate_numeric_parameter(polynomial_mutation_eta, 'polynomial_mutation_eta') + if self.polynomial_mutation_eta <= 0: + raise ValueError('polynomial_mutation_eta must be positive.') + self.mutation_probability = None + self.mutation_control_explicitly_set = (mutation_probability is not None or mutation_num_genes is not None or + not (isinstance(mutation_percent_genes, str) and mutation_percent_genes == 'default')) + if self.mutation_type is None: + if self.crossover_type is None and not self.suppress_warnings: + warnings.warn('Crossover and mutation are disabled, so the initial population cannot evolve.') + return None, 'default' + adaptive = self.mutation_type == 'adaptive' + if mutation_probability is not None: + self.mutation_probability = self._validate_mutation_control(mutation_probability, 'mutation_probability', adaptive, 0, 1) + mutation_num_genes, mutation_percent_genes = None, 'default' + elif mutation_num_genes is not None: + mutation_num_genes = self._validate_mutation_control(mutation_num_genes, 'mutation_num_genes', adaptive, 1, self.num_genes, integer=True) + mutation_percent_genes = 'default' else: - parent_selection_type = parent_selection_type.lower() - if parent_selection_type == "sss": - self.select_parents = self.steady_state_selection - elif parent_selection_type == "rws": - self.select_parents = self.roulette_wheel_selection - elif parent_selection_type == "sus": - self.select_parents = self.stochastic_universal_selection - elif parent_selection_type == "random": - self.select_parents = self.random_selection - elif parent_selection_type == "tournament": - self.select_parents = self.tournament_selection - elif parent_selection_type == "tournament_nsga2": # Supported in PyGAD >= 3.2 - 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-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": - if type(K_tournament) in self.supported_int_types: - if K_tournament > self.sol_per_pop: - K_tournament = self.sol_per_pop + if isinstance(mutation_percent_genes, str) and mutation_percent_genes == 'default': + if adaptive: + raise TypeError("Adaptive mutation requires a pair of probabilities, gene counts, or percentages.") + mutation_percent_genes = 10 + mutation_percent_genes = self._validate_mutation_control(mutation_percent_genes, 'mutation_percent_genes', adaptive, 0, 100) + percentages = mutation_percent_genes if adaptive else [mutation_percent_genes] + counts = [] + for percentage in percentages: + if percentage <= 0: + raise ValueError('mutation_percent_genes must be > 0 and <= 100.') + count = int(percentage * self.num_genes / 100) + if count == 0: + count = 1 if not self.suppress_warnings: - warnings.warn(f"K of the tournament selection ({K_tournament}) should not be greater than the number of solutions within the population ({self.sol_per_pop}).\nK will be clipped to be equal to the number of solutions in the population (sol_per_pop).\n") - elif K_tournament <= 0: - self.valid_parameters = False - raise ValueError(f"K of the tournament selection cannot be <=0 but ({K_tournament}) found.\n") - else: - self.valid_parameters = False - raise ValueError(f"The type of K of the tournament selection must be integer but the value ({K_tournament}) of type ({type(K_tournament)}) found.") + warnings.warn('mutation_percent_genes selects fewer than one gene. mutation_num_genes is set to 1.') + counts.append(count) + mutation_num_genes = counts if adaptive else counts[0] + if self.mutation_by_replacement and self.mutation_type not in ('random', 'adaptive') and not self.suppress_warnings: + warnings.warn('mutation_by_replacement applies only to random and adaptive mutation.') + if self.crossover_type is None and self.mutation_type is None and not self.suppress_warnings: + warnings.warn('Crossover and mutation are disabled, so the initial population cannot evolve.') + return mutation_num_genes, mutation_percent_genes - self.K_tournament = K_tournament + def _validate_mutation_control(self, value, parameter_name, adaptive, minimum, maximum, integer=False): + """Validate a scalar setting or the two rates used by adaptive mutation.""" + if adaptive: + if not isinstance(value, (list, tuple, numpy.ndarray)) or numpy.asarray(value, dtype=object).shape != (2,): + raise ValueError(f"{parameter_name} must be a 1D sequence of two values for adaptive mutation.") + values = list(value) + else: + values = [value] + validator = self._validate_integer_parameter if integer else self._validate_numeric_parameter + values = [validator(item, parameter_name, minimum, maximum) for item in values] + if adaptive and values[0] < values[1] and not self.suppress_warnings: + warnings.warn(f'The first {parameter_name} value is smaller than the second, so high-quality solutions mutate more frequently.') + return values if adaptive else values[0] + def _validate_nsga3_num_divisions(self, parent_selection_type, nsga3_num_divisions): + """Validate the division count when an NSGA-III operator uses it.""" + if parent_selection_type in ('nsga3', 'tournament_nsga3'): + if nsga3_num_divisions is None: + raise ValueError('NSGA-III requires nsga3_num_divisions to be a positive integer.') + nsga3_num_divisions = self._validate_integer_parameter(nsga3_num_divisions, 'nsga3_num_divisions', minimum=1) + self.nsga3_num_divisions = nsga3_num_divisions + + def _validate_parent_selection(self, parent_selection_type, K_tournament, + keep_parents, keep_elitism, nsga3_num_divisions=None): + """Resolve selection and validate tournament size and retained solutions.""" + operators = {'sss': 'steady_state_selection', 'rws': 'roulette_wheel_selection', + 'sus': 'stochastic_universal_selection', 'random': 'random_selection', + 'tournament': 'tournament_selection', 'tournament_nsga2': 'tournament_selection_nsga2', + 'nsga2': 'nsga2_selection', 'tournament_nsga3': 'tournament_selection_nsga3', + 'nsga3': 'nsga3_selection', 'rank': 'rank_selection'} + parent_selection_type, self.select_parents = self._resolve_operator(parent_selection_type, 'parent_selection_type', operators, 3) + if parent_selection_type in ('tournament', 'tournament_nsga2', 'tournament_nsga3'): + K_tournament = self._validate_integer_parameter(K_tournament, 'K_tournament', minimum=1) + if K_tournament > self.sol_per_pop: + if not self.suppress_warnings: + warnings.warn(f'K_tournament is clipped to sol_per_pop ({self.sol_per_pop}).') + K_tournament = self.sol_per_pop + self.K_tournament = int(K_tournament) if isinstance(K_tournament, numpy.integer) else 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.") - elif keep_parents > self.sol_per_pop or keep_parents > self.num_parents_mating or keep_parents < -1: - self.valid_parameters = False - raise ValueError(f"Incorrect value to the keep_parents parameter: {keep_parents}. \nThe assigned value to the keep_parent parameter must satisfy the following conditions: \n1) Less than or equal to sol_per_pop\n2) Less than or equal to num_parents_mating\n3) Greater than or equal to -1.") - - self.keep_parents = keep_parents - - if parent_selection_type == "sss" and self.keep_parents == 0: - if not self.suppress_warnings: - warnings.warn("The steady-state parent (sss) selection operator is used despite that no parents are kept in the next generation.") - - # Validating the number of elitism to keep in the next population: keep_elitism - if not (type(keep_elitism) in self.supported_int_types): - self.valid_parameters = False - raise TypeError(f"Incorrect type of the value assigned to the keep_elitism parameter. The value ({keep_elitism}) of type {type(keep_elitism)} found but an integer is expected.") - elif keep_elitism > self.sol_per_pop or keep_elitism < 0: - self.valid_parameters = False - raise ValueError(f"Incorrect value to the keep_elitism parameter: {keep_elitism}. \nThe assigned value to the keep_elitism parameter must satisfy the following conditions: \n1) Less than or equal to sol_per_pop\n2) Greater than or equal to 0.") - - self.keep_elitism = keep_elitism - - # 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.keep_parents = self._validate_integer_parameter(-1 if keep_parents is None else keep_parents, + 'keep_parents', -1, self.num_parents_mating) + self.keep_elitism = self._validate_integer_parameter(keep_elitism, 'keep_elitism', 0, self.sol_per_pop) + if self.keep_parents_explicitly_set and self.keep_elitism > 0 and not self.suppress_warnings: + warnings.warn(f'keep_elitism (={self.keep_elitism}) takes precedence over keep_parents (={self.keep_parents}). Set keep_elitism=0 to retain parents instead.') self._refresh_num_offspring() - return parent_selection_type def _refresh_num_offspring(self): @@ -1159,551 +478,91 @@ def _refresh_num_offspring(self): else: self.num_offspring = self.sol_per_pop - self.keep_elitism - 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. - if len(inspect.signature(fitness_func).parameters) == 3: - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"In PyGAD 2.20.0, if a method is used to calculate the fitness value, then it must accept 3 parameters\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed fitness method named '{fitness_func.__code__.co_name}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - elif inspect.isfunction(fitness_func): - # Check if the fitness function accepts 3 parameters. - if len(inspect.signature(fitness_func).parameters) == 3: - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"In PyGAD 2.20.0, the fitness function must accept 3 parameters:\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed fitness function named '{fitness_func.__code__.co_name}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - elif callable(fitness_func) and not inspect.isclass(fitness_func): - # The object must have the __call__() method. - if hasattr(fitness_func, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(fitness_func).parameters) == 3: - # The fitness class instance assigned to the fitness_func parameter is validated. - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"When 'fitness_func' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed instance of the class named '{fitness_func.__class__.__name__}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'fitness_func' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - else: - self.valid_parameters = False - - raise TypeError(f"The value assigned to the fitness_func parameter is expected to be a function or a method but {type(fitness_func)} found.") - - if fitness_batch_size is None: - pass - elif not (type(fitness_batch_size) in self.supported_int_types): - self.valid_parameters = False - raise TypeError(f"The value assigned to the fitness_batch_size parameter is expected to be integer but the value ({fitness_batch_size}) of type {type(fitness_batch_size)} found.") - elif fitness_batch_size <= 0 or fitness_batch_size > self.sol_per_pop: - self.valid_parameters = False - raise ValueError(f"The value assigned to the fitness_batch_size parameter must be:\n1) Greater than 0.\n2) Less than or equal to sol_per_pop ({self.sol_per_pop}).\nBut the value ({fitness_batch_size}) found.") - - self.fitness_batch_size = fitness_batch_size - - def _validate_callbacks(self, - on_start, - on_fitness, - on_parents, - on_crossover, - 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): - # Check if the on_start method accepts 1 parameter. - if len(inspect.signature(on_start).parameters) == 1: - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_start parameter must accept only 1 parameter representing the instance of the genetic algorithm. The passed method named '{on_start.__code__.co_name}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - # Check if the on_start is a function. - elif inspect.isfunction(on_start): - # Check if the on_start function accepts only a single parameter. - if len(inspect.signature(on_start).parameters) == 1: - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_start parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed function named '{on_start.__code__.co_name}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - elif callable(on_start) and not inspect.isclass(on_start): - # The object must have the __call__() method. - if hasattr(on_start, '__call__'): - # Check if the __call__() method accepts 1 parameter. - if len(inspect.signature(on_start).parameters) == 1: - # The on_start class instance assigned to the on_start parameter is validated. - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"When 'on_start' is assigned a class instance, then its __call__ method must accept only 1 parameter representing the instance of the genetic algorithm.\n\nThe passed instance of the class named '{on_start.__class__.__name__}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_start' is assigned a class instance, then its __call__ method must be implemented and accept 1 parameter.") - else: - self.valid_parameters = False - - raise TypeError(f"The value assigned to the on_start parameter is expected to be of type function but {type(on_start)} found.") - else: - self.on_start = None - - # Check if the on_fitness exists. - if not (on_fitness is None): - # Check if the on_fitness is a method. - if inspect.ismethod(on_fitness): - # Check if the on_fitness method accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_fitness parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed method named '{on_fitness.__code__.co_name}' accepts {len(inspect.signature(on_fitness).parameters)} parameter(s).") - # Check if the on_fitness is a function. - elif inspect.isfunction(on_fitness): - # Check if the on_fitness function accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_fitness parameter must accept 2 parameters representing the instance of the genetic algorithm and the fitness values of all solutions.\nThe passed function named '{on_fitness.__code__.co_name}' accepts {on_fitness.__code__.co_argcount} parameter(s).") - elif callable(on_fitness) and not inspect.isclass(on_fitness): - # The object must have the __call__() method. - if hasattr(on_fitness, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - # The on_fitness class instance assigned to the on_fitness parameter is validated. - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"When 'on_fitness' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\n\nThe passed instance of the class named '{on_fitness.__class__.__name__}' accepts {len(inspect.signature(on_fitness).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_fitness' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_fitness parameter is expected to be of type function but {type(on_fitness)} found.") - else: - self.on_fitness = None - - # Check if the on_parents exists. - if not (on_parents is None): - # Check if the on_parents is a method. - if inspect.ismethod(on_parents): - # Check if the on_parents method accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_parents parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed method named '{on_parents.__code__.co_name}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - # Check if the on_parents is a function. - elif inspect.isfunction(on_parents): - # Check if the on_parents function accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_parents parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed function named '{on_parents.__code__.co_name}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - elif callable(on_parents) and not inspect.isclass(on_parents): - # The object must have the __call__() method. - if hasattr(on_parents, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - # The on_parents class instance assigned to the on_parents parameter is validated. - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"When 'on_parents' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\n\nThe passed instance of the class named '{on_parents.__class__.__name__}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_parents' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_parents parameter is expected to be of type function but {type(on_parents)} found.") - else: - self.on_parents = None - - # Check if the on_crossover exists. - if not (on_crossover is None): - # Check if the on_crossover is a method. - if inspect.ismethod(on_crossover): - # Check if the on_crossover method accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_crossover parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring generated using crossover.\nThe passed method named '{on_crossover.__code__.co_name}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - # Check if the on_crossover is a function. - elif inspect.isfunction(on_crossover): - # Check if the on_crossover function accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_crossover parameter must accept 2 parameters representing the instance of the genetic algorithm and the offspring generated using crossover.\nThe passed function named '{on_crossover.__code__.co_name}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - elif callable(on_crossover) and not inspect.isclass(on_crossover): - # The object must have the __call__() method. - if hasattr(on_crossover, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - # The on_crossover class instance assigned to the on_crossover parameter is validated. - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"When 'on_crossover' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring generated using crossover.\n\nThe passed instance of the class named '{on_crossover.__class__.__name__}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_crossover' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_crossover parameter is expected to be of type function but {type(on_crossover)} found.") - else: - self.on_crossover = None - - # Check if the on_mutation exists. - if not (on_mutation is None): - # Check if the on_mutation is a method. - if inspect.ismethod(on_mutation): - # Check if the on_mutation method accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_mutation parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring after applying the mutation operation.\nThe passed method named '{on_mutation.__code__.co_name}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - # Check if the on_mutation is a function. - elif inspect.isfunction(on_mutation): - # Check if the on_mutation function accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_mutation parameter must accept 2 parameters representing the instance of the genetic algorithm and the offspring after applying the mutation operation.\nThe passed function named '{on_mutation.__code__.co_name}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - elif callable(on_mutation) and not inspect.isclass(on_mutation): - # The object must have the __call__() method. - if hasattr(on_mutation, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - # The on_mutation class instance assigned to the on_mutation parameter is validated. - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"When 'on_mutation' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring after applying the mutation operation.\n\nThe passed instance of the class named '{on_mutation.__class__.__name__}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_mutation' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_mutation parameter is expected to be of type function but {type(on_mutation)} found.") - else: - self.on_mutation = None - - # Check if the on_generation exists. - if not (on_generation is None): - # Check if the on_generation is a method. - if inspect.ismethod(on_generation): - # Check if the on_generation method accepts 1 parameter. - if len(inspect.signature(on_generation).parameters) == 1: - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_generation parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed method named '{on_generation.__code__.co_name}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - # Check if the on_generation is a function. - elif inspect.isfunction(on_generation): - # Check if the on_generation function accepts only a single parameter. - if len(inspect.signature(on_generation).parameters) == 1: - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_generation parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed function named '{on_generation.__code__.co_name}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - elif callable(on_generation) and not inspect.isclass(on_generation): - # The object must have the __call__() method. - if hasattr(on_generation, '__call__'): - # Check if the __call__() method accepts 1 parameter. - if len(inspect.signature(on_generation).parameters) == 1: - # The on_generation class instance assigned to the on_generation parameter is validated. - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"When 'on_generation' is assigned a class instance, then its __call__ method must accept only 1 parameter representing the instance of the genetic algorithm.\n\nThe passed instance of the class named '{on_generation.__class__.__name__}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_generation' is assigned a class instance, then its __call__ method must be implemented and accept 1 parameter.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_generation parameter is expected to be of type function but {type(on_generation)} found.") - else: - self.on_generation = None - - # Check if the on_stop exists. - if not (on_stop is None): - # Check if the on_stop is a method. - if inspect.ismethod(on_stop): - # Check if the on_stop method accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_stop parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) A list of the fitness values of the solutions in the last population.\n\nThe passed method named '{on_stop.__code__.co_name}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - # Check if the on_stop is a function. - elif inspect.isfunction(on_stop): - # Check if the on_stop function accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_stop parameter must accept 2 parameters representing the instance of the genetic algorithm and a list of the fitness values of the solutions in the last population.\nThe passed function named '{on_stop.__code__.co_name}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - elif callable(on_stop) and not inspect.isclass(on_stop): - # The object must have the __call__() method. - if hasattr(on_stop, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - # The on_stop class instance assigned to the on_stop parameter is validated. - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"When 'on_stop' is assigned a class instance, then its __call__ method must accept 2 parameters: \n1) The instance of the genetic algorithm.\n2) A list of the fitness values of the solutions in the last population.\n\nThe passed instance of the class named '{on_stop.__class__.__name__}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_stop' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the 'on_stop' parameter is expected to be of type function but {type(on_stop)} found.") - else: - self.on_stop = None - - 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", "time", "evaluations"] + def _validate_fitness_func(self, fitness_func, fitness_batch_size): + """Validate the fitness call and optional batch size before sampling.""" + self.fitness_func = self._validate_callable_parameter(fitness_func, 'fitness_func', 3) + self.fitness_batch_size = (None if fitness_batch_size is None else + self._validate_integer_parameter(fitness_batch_size, 'fitness_batch_size', 1, self.sol_per_pop)) + + def _validate_callbacks(self, on_start, on_fitness, on_parents, on_crossover, + on_mutation, on_generation, on_stop): + """Validate each lifecycle callback using the arguments PyGAD supplies.""" + callbacks = [('on_start', on_start, 1), ('on_fitness', on_fitness, 2), + ('on_parents', on_parents, 2), ('on_crossover', on_crossover, 2), + ('on_mutation', on_mutation, 2), ('on_generation', on_generation, 1), + ('on_stop', on_stop, 2)] + for name, function, num_arguments in callbacks: + setattr(self, name, None if function is None else self._validate_callable_parameter(function, name, num_arguments)) + + def _validate_stop_criteria(self, stop_criteria): + """Parse stopping criteria once, preserving order while removing duplicates.""" + self.supported_stop_words = ['reach', 'saturate', 'time', 'evaluations'] if stop_criteria is None: - # None: Stop after passing through all generations. self.stop_criteria = None - elif type(stop_criteria) is str: - # reach_{target_fitness}: Stop if the target fitness value is reached. - # saturate_{num_generations}: Stop if the fitness value does not change (saturates) for the given number of generations. - criterion = stop_criteria.split("_") - stop_word = criterion[0] - # criterion[1] might be a single or multiple numbers. - number = criterion[1:] - if stop_word in self.supported_stop_words: - pass - else: - self.valid_parameters = False - raise ValueError(f"In the 'stop_criteria' parameter, the supported stop words are '{self.supported_stop_words}' but '{stop_word}' found.") - - if len(criterion) == 2: - # There is only a single number. - number = number[0] - if number.replace(".", "").replace("-", "").isnumeric(): - number = float(number) - else: - self.valid_parameters = False - raise ValueError(f"The value following the stop word in the 'stop_criteria' parameter must be a number but the value ({number}) of type {type(number)} found.") - - self.stop_criteria.append([stop_word, number]) - elif len(criterion) > 2: - number = self.validate_multi_stop_criteria(stop_word, number) - self.stop_criteria.append([stop_word] + number) - else: - self.valid_parameters = False - raise ValueError(f"The format of a single criterion in the 'stop_criteria' parameter is 'word_number' but '{stop_criteria}' found.") - - elif type(stop_criteria) in [list, tuple, numpy.ndarray]: - # Remove duplicate criteria by converting the list to a set then back to a list. - stop_criteria = list(set(stop_criteria)) - for idx, val in enumerate(stop_criteria): - if type(val) is str: - criterion = val.split("_") - stop_word = criterion[0] - number = criterion[1:] - if len(criterion) == 2: - # There is only a single number. - number = number[0] - if stop_word in self.supported_stop_words: - pass - else: - self.valid_parameters = False - raise ValueError(f"In the 'stop_criteria' parameter, the supported stop words are {self.supported_stop_words} but '{stop_word}' found.") - - if number.replace(".", "").replace("-", "").isnumeric(): - number = float(number) - else: - self.valid_parameters = False - raise ValueError(f"The value following the stop word in the 'stop_criteria' parameter must be a number but the value ({number}) of type {type(number)} found.") - - self.stop_criteria.append([stop_word, number]) - elif len(criterion) > 2: - number = self.validate_multi_stop_criteria(stop_word, number) - self.stop_criteria.append([stop_word] + number) - else: - self.valid_parameters = False - raise ValueError(f"The format of a single criterion in the 'stop_criteria' parameter is 'word_number' but {criterion} found.") - else: - self.valid_parameters = False - raise TypeError(f"When the 'stop_criteria' parameter is assigned a tuple/list/numpy.ndarray, then its elements must be strings but the value ({val}) of type {type(val)} found at index {idx}.") + return + if isinstance(stop_criteria, str): + criteria = [stop_criteria] + elif isinstance(stop_criteria, (list, tuple, numpy.ndarray)): + if numpy.asarray(stop_criteria, dtype=object).ndim != 1: + raise ValueError('stop_criteria must be a 1D sequence of strings.') + criteria = list(stop_criteria) else: - self.valid_parameters = False - raise TypeError(f"The expected value of the 'stop_criteria' is a single string or a list/tuple/numpy.ndarray of strings but the value ({stop_criteria}) of type {type(stop_criteria)} found.") - - 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. + raise TypeError('stop_criteria must be a string, a sequence of strings, or None.') + self.stop_criteria = [] + seen = set() + for criterion in criteria: + if not isinstance(criterion, str): + raise TypeError('Each stop_criteria entry must be a string.') + if criterion not in seen: + self.stop_criteria.append(self._parse_stop_criterion(criterion)) + seen.add(criterion) + + def _parse_stop_criterion(self, criterion): + """Parse a finite threshold or an exact positive integer count.""" + from decimal import Decimal, InvalidOperation + parts = criterion.split('_') + word, numbers = parts[0], parts[1:] + if word not in self.supported_stop_words: + raise ValueError(f'Unknown stop criterion {word!r}. Supported words are {self.supported_stop_words}.') + if not numbers or (word != 'reach' and len(numbers) != 1): + raise ValueError('A stop criterion has the form word_number; only reach accepts multiple thresholds.') + result = [word] + for number in numbers: + try: + value = Decimal(number) + except InvalidOperation: + raise ValueError(f'The threshold in stop_criteria must be numeric, but {number!r} found.') from None + if not value.is_finite(): + raise ValueError('Stop criterion thresholds must be finite.') + if word in ('saturate', 'evaluations'): + if value <= 0 or value != value.to_integral_value(): + raise ValueError(f'{word} requires a positive integer count.') + result.append(int(value)) + else: + if word == 'time' and value < 0: + raise ValueError('time requires a non-negative number of seconds.') + threshold = float(value) + if not numpy.isfinite(threshold): + raise ValueError('Stop criterion thresholds must fit a finite float.') + result.append(threshold) + return result + + def _validate_parallel_processing(self, parallel_processing): + """Normalize the executor mode and an optional integer worker count.""" if parallel_processing is None: self.parallel_processing = None - elif type(parallel_processing) in self.supported_int_types: - if parallel_processing > 0: - self.parallel_processing = ["thread", parallel_processing] - else: - self.valid_parameters = False - raise ValueError(f"When the 'parallel_processing' parameter is assigned an integer, then the integer must be positive but the value ({parallel_processing}) found.") - elif type(parallel_processing) in [list, tuple]: - if len(parallel_processing) == 2: - if type(parallel_processing[0]) is str: - if parallel_processing[0] in ["process", "thread"]: - if (type(parallel_processing[1]) in self.supported_int_types and parallel_processing[1] > 0) or (parallel_processing[1] == 0) or (parallel_processing[1] is None): - if parallel_processing[1] == 0: - # If the number of processes/threads is 0, this means no parallel processing is used. It is equivalent to setting parallel_processing=None. - self.parallel_processing = None - else: - # Whether the second value is None or a positive integer. - self.parallel_processing = parallel_processing - else: - self.valid_parameters = False - raise TypeError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the second element must be an integer but the value ({parallel_processing[1]}) of type {type(parallel_processing[1])} found.") - else: - self.valid_parameters = False - raise ValueError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the value of the first element must be either 'process' or 'thread' but the value ({parallel_processing[0]}) found.") - else: - self.valid_parameters = False - raise TypeError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the first element must be of type 'str' but the value ({parallel_processing[0]}) of type {type(parallel_processing[0])} found.") - else: - self.valid_parameters = False - raise ValueError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then it must have 2 elements but ({len(parallel_processing)}) found.") + return + if isinstance(parallel_processing, (list, tuple)): + if len(parallel_processing) != 2: + raise ValueError('parallel_processing must contain a mode and a worker count.') + mode, workers = parallel_processing + if not isinstance(mode, str) or mode not in ('thread', 'process'): + raise ValueError("The parallel_processing mode must be 'thread' or 'process'.") + workers = None if workers is None else self._validate_integer_parameter(workers, 'parallel_processing worker count', minimum=0) else: - self.valid_parameters = False - raise ValueError(f"Unexpected value ({parallel_processing}) of type ({type(parallel_processing)}) assigned to the 'parallel_processing' parameter. The accepted values for this parameter are:\n1) None: (Default) It means no parallel processing is used.\n2) A positive integer referring to the number of threads to be used (i.e. threads, not processes, are used.\n3) list/tuple: If a list or a tuple of exactly 2 elements is assigned, then:\n\t*1) The first element can be either 'process' or 'thread' to specify whether processes or threads are used, respectively.\n\t*2) The second element can be:\n\t\t**1) A positive integer to select the maximum number of processes or threads to be used.\n\t\t**2) 0 to indicate that parallel processing is not used. This is identical to setting 'parallel_processing=None'.\n\t\t**3) None to use the default value as calculated by the concurrent.futures module.") + mode = 'thread' + workers = self._validate_integer_parameter(parallel_processing, 'parallel_processing', minimum=0) + self.parallel_processing = None if workers == 0 else [mode, workers] def _validate_footer(self, num_generations, @@ -1715,9 +574,8 @@ def _validate_footer(self, """ 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. + and ``save_solutions``. Store the already validated mutation + controls and initialize the lifecycle state before population creation. Parameters ---------- @@ -1748,33 +606,14 @@ def _validate_footer(self, If ``num_generations`` is negative. """ - # Validate num_generations - if type(num_generations) in self.supported_int_types: - if num_generations >= 0: - self.num_generations = num_generations - else: - raise ValueError(f"The value assigned to the 'num_generations' parameter must be a non-negative integer >= 0. But the value {num_generations} found.") - else: - self.valid_parameters = False - raise ValueError(f"Unexpected value ({num_generations}) of type ({type(num_generations)}) assigned to the 'num_generations' parameter. It must be assigned a non-negative integer.") - - # Validate save_best_solutions - if type(save_best_solutions) is bool: - if save_best_solutions == True: - if not self.suppress_warnings: - warnings.warn("Use the 'save_best_solutions' parameter with caution as it may cause memory overflow when either the number of generations or number of genes is large.") - else: - self.valid_parameters = False - raise TypeError(f"The value passed to the 'save_best_solutions' parameter must be of type bool but {type(save_best_solutions)} found.") - - # Validate save_solutions - if type(save_solutions) is bool: - if save_solutions == True: - if not self.suppress_warnings: - warnings.warn("Use the 'save_solutions' parameter with caution as it may cause memory overflow when either the number of generations, number of genes, or number of solutions in population is large.") - else: - self.valid_parameters = False - raise TypeError(f"The value passed to the 'save_solutions' parameter must be of type bool but {type(save_solutions)} found.") + self.num_generations = self._validate_integer_parameter(num_generations, 'num_generations', minimum=0) + self.save_best_solutions = self._validate_boolean_parameter(save_best_solutions, 'save_best_solutions') + self.save_solutions = self._validate_boolean_parameter(save_solutions, 'save_solutions') + if not self.suppress_warnings: + if save_best_solutions: + warnings.warn('Use save_best_solutions with caution as saving large histories can cause memory overflow.') + if save_solutions: + warnings.warn('Use save_solutions with caution as saving large histories can cause memory overflow.') # Set the `run_completed` property to False. It is set to `True` only after the `run()` method is complete. self.run_completed = False @@ -1790,10 +629,6 @@ def _validate_footer(self, # "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 - # Parameters of the genetic algorithm. self.parent_selection_type = parent_selection_type @@ -1920,24 +755,9 @@ def validate_parameters(self, self.valid_parameters = False raise ValueError(f"When gene_space is nested, its length ({len(gene_space)}) must equal the number of genes ({self.num_genes}).") - self.gene_space_unpacked = self.unpack_gene_space( - range_min=self.init_range_low, range_max=self.init_range_high) - self._build_initial_population(initial_population) - - self._validate_mutation_range(random_mutation_min_val, - random_mutation_max_val) - - # Validating the number of parents to be selected for mating (num_parents_mating) - if num_parents_mating <= 0: - self.valid_parameters = False - raise ValueError(f"The number of parents mating (num_parents_mating) parameter must be > 0 but ({num_parents_mating}) found. \nThe following parameters must be > 0: \n1) Population size (i.e. number of solutions per population) (sol_per_pop).\n2) Number of selected parents in the mating pool (num_parents_mating).\n") - - # Validating the number of parents to be selected for mating: num_parents_mating - if num_parents_mating > self.sol_per_pop: - self.valid_parameters = False - raise ValueError(f"The number of parents to select for mating ({num_parents_mating}) cannot be greater than the number of solutions in the population ({self.sol_per_pop}) (i.e., num_parents_mating must always be <= sol_per_pop).\n") - - self.num_parents_mating = num_parents_mating + self._validate_mutation_range(random_mutation_min_val, random_mutation_max_val) + self.num_parents_mating = self._validate_integer_parameter( + num_parents_mating, 'num_parents_mating', 1, self.sol_per_pop) self._validate_crossover(crossover_type, crossover_probability, @@ -1977,42 +797,15 @@ def validate_parameters(self, save_best_solutions, 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: - self.valid_parameters = False - raise ValueError(f"Passing multiple numbers following the keyword in the 'stop_criteria' parameter is expected only with the 'reach' keyword but the keyword ({stop_word}) found.") + self.numpy_random_generator = numpy.random.RandomState(self.random_seed) + self.python_random_generator = random.Random(self.random_seed) + self.gene_space_unpacked = self.unpack_gene_space( + range_min=self.init_range_low, range_max=self.init_range_high) + self._build_initial_population(initial_population) + self.valid_parameters = True - for idx, num in enumerate(number): - if num.replace(".", "").replace("-", "").isnumeric(): - number[idx] = float(num) - else: - self.valid_parameters = False - raise ValueError(f"The value(s) following the stop word in the 'stop_criteria' parameter must be numeric but the value ({num}) of type {type(num)} found.") - return number + def validate_multi_stop_criteria(self, stop_word, number): + """Compatibility helper for parsing multiple reach thresholds.""" + if stop_word != 'reach': + raise ValueError('Only reach accepts multiple stop thresholds.') + return self._parse_stop_criterion('_'.join([stop_word] + list(number)))[1:] diff --git a/tests/test_constructor_parameters.py b/tests/test_constructor_parameters.py new file mode 100644 index 00000000..e0903914 --- /dev/null +++ b/tests/test_constructor_parameters.py @@ -0,0 +1,459 @@ +"""Constructor validation, parameter ownership, and operator compatibility.""" + +import functools +import logging +import random +import warnings +from unittest.mock import Mock + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, fitness_func=fitness_func, + sol_per_pop=4, num_genes=8, random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("name", ['num_parents_mating', 'num_generations', 'sol_per_pop', 'num_genes', + 'sample_size', 'keep_elitism', 'fitness_batch_size', 'random_seed']) +@pytest.mark.parametrize("value", [True, numpy.bool_(False), 1.5, numpy.nan, '2']) +def test_integer_parameters_reject_non_integer_values(name, value): + with pytest.raises(TypeError, match=name): + make_ga(**{name: value}) + + +@pytest.mark.parametrize("dtype", [numpy.int8, numpy.uint8, numpy.int64, numpy.uint64]) +def test_numpy_counts_are_python_integers_before_arithmetic(dtype): + ga_instance = make_ga(num_generations=dtype(200) if dtype is not numpy.int8 else dtype(100), + sol_per_pop=dtype(4), num_genes=dtype(3), num_parents_mating=dtype(2), + mutation_type=None, crossover_type=None, fitness_batch_size=dtype(2), + fitness_func=lambda ga, solutions, indices: [1.0] * len(solutions)) + count = int(ga_instance.num_generations) + ga_instance.run() + ga_instance.run() + assert ga_instance.generations_completed == 2 * count + for name in ['num_generations', 'sol_per_pop', 'num_genes', 'num_parents_mating', 'fitness_batch_size']: + assert type(getattr(ga_instance, name)) is int + + +@pytest.mark.parametrize("adaptive", [False, True]) +def test_numpy_percentages_do_not_overflow(adaptive): + ga_instance = make_ga(num_genes=3, mutation_type='adaptive' if adaptive else 'random', + mutation_percent_genes=[numpy.uint8(100), numpy.uint8(100)] if adaptive else numpy.uint8(100)) + assert ga_instance.mutation_num_genes == ([3, 3] if adaptive else 3) + + +@pytest.mark.parametrize("mutation_type,probability", [('random', 0.5), ('adaptive', [0.8, 0.2])]) +def test_probability_takes_precedence_over_inactive_counts_and_percentages(mutation_type, probability): + ga_instance = make_ga(mutation_type=mutation_type, mutation_probability=probability, + mutation_num_genes='ignored', mutation_percent_genes=numpy.array([0, numpy.nan])) + ga_instance.run() + assert ga_instance.mutation_num_genes is None + + +def test_gene_count_takes_precedence_over_inactive_percentage(): + ga_instance = make_ga(mutation_num_genes=2, mutation_percent_genes=numpy.array([0, 0])) + assert ga_instance.mutation_num_genes == 2 + + +@pytest.mark.parametrize("operator", ['random', 'adaptive', 'swap', 'inversion', 'scramble', 'polynomial']) +def test_zero_probability_keeps_offspring_unchanged(operator): + ga_instance = make_ga(mutation_type=operator, mutation_probability=[0.0, 0.0] if operator == 'adaptive' else 0.0) + original = ga_instance.population.copy() + result = ga_instance.mutation(original.copy()) + numpy.testing.assert_array_equal(result, original) + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_mutation_preserves_fixed_destination_spaces(operator): + population = numpy.tile(numpy.arange(8), (4, 1)) + ga_instance = make_ga(initial_population=population, mutation_type=operator, mutation_probability=1.0, + gene_space=[[value] for value in range(8)], allow_duplicate_genes=False) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, population) + + +def fixed_constraint(value): + def constraint(solution, values): + return [candidate for candidate in values if candidate == value] + return constraint + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_mutation_preserves_fixed_constraints(operator): + population = numpy.tile(numpy.arange(8), (4, 1)) + ga_instance = make_ga(initial_population=population, mutation_type=operator, mutation_probability=1.0, + gene_constraint=[fixed_constraint(value) for value in range(8)], allow_duplicate_genes=False) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, population) + + +def test_swap_searches_other_pairs_when_the_initial_pair_is_incompatible(): + ga_instance = make_ga(num_genes=3, initial_population=[[0, 1, 2]] * 4, + gene_space=[[0], [1, 2], [1, 2]], mutation_type='swap', allow_duplicate_genes=False) + ga_instance.numpy_random_generator = Mock(wraps=ga_instance.numpy_random_generator) + ga_instance.numpy_random_generator.choice = lambda *args, **kwargs: numpy.array([0, 1]) + result = ga_instance.swap_mutation(ga_instance.population.copy()) + numpy.testing.assert_array_equal(result, [[0, 2, 1]] * 4) + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_explicit_gene_counts_limit_mutation_to_eligible_positions(operator): + ga_instance = make_ga(mutation_type=operator, mutation_num_genes=2, initial_population=[list(range(8))] * 4, + init_range_low=0, init_range_high=8) + ga_instance.python_random_generator.sample = lambda values, count: [1, 2] + result = ga_instance.mutation(ga_instance.population.copy()) + numpy.testing.assert_array_equal(result[:, [0, 3, 4, 5, 6, 7]], ga_instance.population[:, [0, 3, 4, 5, 6, 7]]) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_use_gene_spaces_outside_default_initialization_bounds(operator): + ga_instance = make_ga(num_genes=3, initial_population=[[10, 20, 30], [11, 21, 31]] * 2, + gene_space=[[10, 11], [20, 21], [30, 31]], + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1.0) + ga_instance.run() + for solution in ga_instance.population: + assert all(value in ga_instance.gene_space[index] for index, value in enumerate(solution)) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_handle_reversed_bounds_and_outside_supplied_values(operator): + ga_instance = make_ga(initial_population=[list(range(10, 18)), list(range(20, 28))] * 2, + init_range_low=4, init_range_high=-4, sbx_crossover_eta=1.5, + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1.0, keep_elitism=0, keep_parents=0) + ga_instance.run() + assert numpy.all(numpy.isfinite(ga_instance.population)) + assert numpy.all((ga_instance.population >= -4) & (ga_instance.population <= 4)) + + +@pytest.mark.parametrize("selection", ['tournament', 'tournament_nsga2', 'tournament_nsga3']) +@pytest.mark.parametrize("value,error", [(0, ValueError), (-1, ValueError), (1.5, TypeError), (True, TypeError)]) +def test_all_tournament_operators_validate_their_size(selection, value, error): + with pytest.raises(error, match='K_tournament'): + make_ga(parent_selection_type=selection, K_tournament=value, nsga3_num_divisions=1) + + +@pytest.mark.parametrize("criterion", ['saturate_0', 'saturate_-1', 'saturate_1.5', + 'evaluations_0', 'evaluations_-1', 'evaluations_1.5', + 'time_-1', 'time_nan', 'reach_inf', 'reach_1..2']) +def test_stopping_criteria_reject_invalid_thresholds(criterion): + with pytest.raises(ValueError): + make_ga(stop_criteria=criterion) + + +def test_stopping_criteria_support_scientific_notation_exact_counts_and_order(): + ga_instance = make_ga(stop_criteria=numpy.array(['reach_1e3', 'saturate_3', 'reach_1e3', 'evaluations_9007199254740993'])) + assert ga_instance.stop_criteria == [['reach', 1000.0], ['saturate', 3], ['evaluations', 9007199254740993]] + + +@pytest.mark.parametrize("name", ['random_mutation_min_val', 'random_mutation_max_val', 'sbx_crossover_eta', 'polynomial_mutation_eta']) +@pytest.mark.parametrize("value", [numpy.nan, numpy.inf, -numpy.inf]) +def test_ranges_and_distribution_indices_must_be_finite(name, value): + with pytest.raises(ValueError, match=name): + make_ga(**{name: value}) + + +@pytest.mark.parametrize("name", ['init_range_low', 'random_mutation_min_val', 'gene_space']) +def test_zero_dimensional_parameter_arrays_fail_descriptively(name): + with pytest.raises(ValueError, match='1D|0D'): + make_ga(**{name: numpy.array(1)}) + + +class Constraint: + def method(self, solution, values): + return values + + def __call__(self, solution, values): + return values + + +def constraint_with_option(solution, values, threshold=0): + return values + + +@pytest.mark.parametrize("constraint", [Constraint(), Constraint().method, + functools.partial(constraint_with_option, threshold=1)]) +def test_constraints_support_bound_methods_callable_objects_and_partials(constraint): + ga_instance = make_ga(gene_constraint=[constraint] * 8) + ga_instance.run() + + +@pytest.mark.parametrize("value", [False, 0, [], ()]) +def test_constraint_parameter_does_not_bypass_validation_when_falsey(value): + with pytest.raises((TypeError, ValueError), match='constraint'): + make_ga(gene_constraint=value) + + +def test_callable_validation_checks_positional_arguments_without_executing_functions(): + def keyword_fitness(ga, solution, *, index): + pytest.fail('Validation must not call fitness functions.') + def keyword_callback(*, ga): + pytest.fail('Validation must not call callbacks.') + with pytest.raises(ValueError, match='fitness_func'): + make_ga(fitness_func=keyword_fitness) + with pytest.raises(ValueError, match='on_start'): + make_ga(on_start=keyword_callback) + + +def test_functions_can_have_additional_optional_parameters(): + def fitness(ga, solution, index, offset=1): + return float(sum(solution)) + offset + def on_generation(ga, unused=None): + pass + make_ga(fitness_func=fitness, on_generation=on_generation).run() + + +def test_invalid_logger_does_not_mask_the_validation_error(): + with pytest.raises(TypeError, match='logger'): + make_ga(logger='invalid') + + +def test_adaptive_replacement_is_supported_without_an_incorrect_warning(): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter('always') + ga_instance = make_ga(mutation_type='adaptive', mutation_probability=[1.0, 1.0], + mutation_by_replacement=True, random_mutation_min_val=7, + random_mutation_max_val=7, suppress_warnings=False, keep_elitism=0, keep_parents=0) + ga_instance.run() + assert not any('replacement' in str(warning.message) for warning in captured) + numpy.testing.assert_array_equal(ga_instance.population, 7) + + +@pytest.mark.parametrize("setting", [0, numpy.int64(0), ['thread', 0], ('process', numpy.int64(0))]) +def test_zero_workers_consistently_disable_parallel_processing(setting): + assert make_ga(parallel_processing=setting).parallel_processing is None + + +@pytest.mark.parametrize("setting", [False, 0.0, ['thread', 0.0], ['process', False]]) +def test_worker_counts_require_integers(setting): + with pytest.raises(TypeError, match='parallel_processing'): + make_ga(parallel_processing=setting) + + +def test_mutable_parameter_containers_are_owned_by_the_ga(): + space = [[0, 1], {'low': 2, 'high': 4}] + [None] * 6 + minimum, maximum = [-1] * 8, [1] * 8 + constraints = [None] * 8 + rates = [0.8, 0.2] + ga_instance = make_ga(gene_space=space, gene_constraint=constraints, mutation_type='adaptive', + mutation_probability=rates, random_mutation_min_val=minimum, random_mutation_max_val=maximum) + space[0][:] = [100] + space[1]['low'] = 100 + minimum[:] = [100] * 8 + maximum[:] = [100] * 8 + constraints[0] = False + rates[:] = [0, 0] + assert ga_instance.gene_space[:2] == [[0, 1], {'low': 2, 'high': 4}] + assert ga_instance.random_mutation_min_val == [-1] * 8 + assert ga_instance.random_mutation_max_val == [1] * 8 + assert ga_instance.gene_constraint == [None] * 8 + assert ga_instance.mutation_probability == [0.8, 0.2] + + +def test_rejected_constructors_do_not_sample_or_execute_constraints(): + numpy_state, python_state = numpy.random.get_state(), random.getstate() + def constraint(solution, values): + pytest.fail('Invalid parameters must be rejected before constraints execute.') + with pytest.raises(TypeError, match='fitness_func'): + make_ga(fitness_func=None, gene_constraint=[constraint] * 8) + numpy.testing.assert_equal(numpy.random.get_state(), numpy_state) + assert random.getstate() == python_state + + +def test_seeded_instances_and_global_generators_do_not_interfere(): + first = make_ga(random_seed=numpy.int64(7)) + second = make_ga(random_seed=7) + first.run() + unrelated = make_ga(random_seed=99) + unrelated.run() + numpy.random.seed(123) + random.seed(123) + second.run() + numpy.testing.assert_array_equal(first.population, second.population) + + +def test_generator_states_survive_checkpoint_continuation(tmp_path): + ga_instance = make_ga() + ga_instance.run() + filename = str(tmp_path / 'generator-state') + ga_instance.save(filename) + loaded = pygad.load(filename) + ga_instance.run() + loaded.run() + numpy.testing.assert_array_equal(ga_instance.population, loaded.population) + + +@pytest.mark.parametrize("space", [range(10**12), {'low': 0, 'high': 10**12, 'step': 1}]) +def test_large_finite_spaces_are_sampled_and_inspected_without_materialization(space, monkeypatch): + original_arange = numpy.arange + def checked_arange(*args, **kwargs): + if args and max(args) > 100: + pytest.fail('A large domain must not be allocated for ordinary sampling.') + return original_arange(*args, **kwargs) + monkeypatch.setattr(numpy, 'arange', checked_arange) + ga_instance = make_ga(num_genes=3, gene_type=int, gene_space=space, mutation_probability=1.0) + assert len(ga_instance.gene_space_unpacked) <= 100 + ga_instance.run() + assert all(0 <= int(value) < 10**12 for value in ga_instance.population.flat) + candidates = ga_instance.get_initial_population_gene_candidates(0, 5, all_integer_values=False) + assert len(candidates) <= 5 + assert ga_instance.is_gene_value_in_space(0, 500_000_000_000, 1) + + +def test_large_integer_mutation_ranges_sample_small_candidate_arrays(): + ga_instance = make_ga(gene_type=int, random_mutation_min_val=-10**12, random_mutation_max_val=10**12, + mutation_probability=1.0) + values = ga_instance.generate_gene_value_randomly(-10**12, 10**12, 1, 0, False, sample_size=5) + assert len(values) == 5 + assert all(-10**12 + 1 <= int(value) < 10**12 + 1 for value in values) + + +def test_default_logger_does_not_remove_existing_handlers(): + logger = logging.getLogger('pygad.utils.validation') + handler = logging.NullHandler() + logger.addHandler(handler) + try: + make_ga() + assert handler in logger.handlers + finally: + logger.removeHandler(handler) + + +@pytest.mark.parametrize("space", [range(10**12), {'low': 0, 'high': 10**12}, + {'low': 0, 'high': 10**12, 'step': 2}]) +def test_large_nested_spaces_and_constraints_sample_bounded_candidates(space, monkeypatch): + original_arange = numpy.arange + def checked_arange(*args, **kwargs): + if args and max(args) > 100: + pytest.fail('Constraint sampling must not materialize a large domain.') + return original_arange(*args, **kwargs) + monkeypatch.setattr(numpy, 'arange', checked_arange) + def constraint(solution, values): + return [value for value in values if value >= 0] + ga_instance = make_ga(num_genes=3, gene_type=int, gene_space=[space] * 3, + gene_constraint=[constraint] * 3, mutation_probability=1.0) + ga_instance.run() + assert all(0 <= int(value) < 10**12 for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("lower,upper,step,expected", [(5, 1, 1, [1, 2, 3, 4]), + (5, 1, -1, [2, 3, 4, 5])]) +def test_integer_range_helper_supports_reversed_bounds_and_descending_steps(lower, upper, step, expected): + ga_instance = make_ga(gene_type=int) + values = ga_instance.generate_gene_value_randomly(lower, upper, 0, 0, True, + sample_size=None, step=step) + numpy.testing.assert_array_equal(values, expected) + + +def test_swap_explicit_count_can_change_multiple_pairs(): + ga_instance = make_ga(mutation_type='swap', mutation_num_genes=4) + original = numpy.arange(8, dtype=float)[None, :] + mutated = ga_instance.mutation(original.copy()) + assert numpy.count_nonzero(original != mutated) == 4 + numpy.testing.assert_array_equal(numpy.sort(mutated), original) + + +@pytest.mark.parametrize("dtype", [numpy.float16, numpy.float32, numpy.float64]) +@pytest.mark.parametrize("precision", [None, 1]) +def test_bounded_conversion_keeps_the_closest_value_below_excluded_upper_bound(dtype, precision): + ga_instance = make_ga(gene_type=[dtype, precision], gene_space={'low': 0.0, 'high': 1.0}) + converted = ga_instance.convert_bounded_operator_gene_value(0, 1.0) + assert isinstance(converted, dtype) + assert float(converted) < 1.0 + expected = numpy.nextafter(dtype(1.0), dtype(-numpy.inf), dtype=dtype) if precision is None else dtype(0.9) + assert converted == expected + + +def test_fixed_integer_dictionary_membership_matches_converted_candidates(): + ga_instance = make_ga(gene_type=int, gene_space={'low': 2, 'high': 2}) + assert ga_instance.is_gene_value_in_space(0, 2, 2) + assert ga_instance.convert_bounded_operator_gene_value(0, 2.0) == 2 + + +def test_falsey_callable_constraints_are_still_applied(): + class Constraint: + def __bool__(self): + return False + def __call__(self, solution, values): + return [value for value in values if value == 2] + ga_instance = make_ga(gene_type=int, gene_space=[1, 2, 3], gene_constraint=[Constraint()] * 8, + mutation_probability=1.0) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, 2) + + +def test_async_callable_objects_are_rejected_before_population_creation(): + class Fitness: + async def __call__(self, ga_instance, solution, solution_index): + return 1 + with pytest.raises(TypeError, match='fitness_func'): + make_ga(fitness_func=Fitness()) + + +@pytest.mark.parametrize("space", [{'low': 0.3, 'high': 1.0, 'step': 0.1}, + {'low': 1.0, 'high': 0.3, 'step': -0.1}, + {'low': 1.0, 'high': 2.0, 'step': 0.03}]) +@pytest.mark.parametrize("gene_type", [float, int, [float, 2]]) +def test_lazy_float_steps_match_numpy_arange_before_conversion(space, gene_type): + ga_instance = make_ga(gene_type=gene_type, gene_space=space) + expected = ga_instance.change_gene_dtype_and_round(0, numpy.arange(space['low'], space['high'], space['step'])) + numpy.testing.assert_array_equal(ga_instance.get_gene_space_values(0), numpy.unique(expected)) + assert all(ga_instance.is_gene_value_in_space(0, value, value) for value in expected) + + +def test_legacy_checkpoints_initialize_generators_and_normalize_counts(tmp_path): + ga_instance = make_ga(num_generations=200, mutation_probability=0) + ga_instance.num_generations = numpy.uint8(200) + del ga_instance.numpy_random_generator + del ga_instance.python_random_generator + del ga_instance.mutation_control_explicitly_set + filename = str(tmp_path / 'legacy-generator-state') + ga_instance.save(filename) + loaded = pygad.load(filename) + assert type(loaded.num_generations) is int + loaded.run() + loaded.run() + assert loaded.generations_completed == 400 + + +@pytest.mark.parametrize("crossover_type", ['single_point', 'two_points', 'uniform', 'scattered', 'sbx']) +def test_zero_crossover_probability_preserves_parents_even_when_random_draw_is_zero(crossover_type, monkeypatch): + ga_instance = make_ga(crossover_type=crossover_type, crossover_probability=0) + ga_instance.numpy_random_generator = Mock(wraps=ga_instance.numpy_random_generator) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'random', + lambda size=None: 0.0 if size is None else numpy.zeros(size)) + parents = numpy.array([[0.0] * 8, [1.0] * 8]) + children = ga_instance.crossover(parents, (4, 8)) + numpy.testing.assert_array_equal(children, numpy.tile(parents, (2, 1))) + + +@pytest.mark.parametrize("dtype", [numpy.int64, numpy.uint64]) +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_bounded_operators_do_not_overflow_at_large_integer_type_limits(dtype, operator, use_gene_space): + maximum = int(numpy.iinfo(dtype).max) + def fitness(ga_instance, solution, solution_index): + return float(int(solution[0]) - maximum) + ga_instance = make_ga(num_genes=3, gene_type=dtype, init_range_low=maximum - 10, + init_range_high=maximum + 1, keep_elitism=0, keep_parents=0, + fitness_func=fitness, + gene_space=range(maximum - 10, maximum + 1) if use_gene_space else None, + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1) + ga_instance.run() + assert ga_instance.population.dtype == numpy.dtype(dtype) + assert all(maximum - 10 <= int(value) <= maximum for value in ga_instance.population.flat) diff --git a/tests/test_duplicate_gene_repair.py b/tests/test_duplicate_gene_repair.py index f71d4605..c7b9155c 100644 --- a/tests/test_duplicate_gene_repair.py +++ b/tests/test_duplicate_gene_repair.py @@ -1,5 +1,7 @@ """Regression tests for duplicate repair across the GA lifecycle.""" +from unittest.mock import Mock + import copy import itertools import random @@ -95,7 +97,7 @@ def test_mutation_repairs_each_gene_using_its_own_range(method, monkeypatch): mutation_num_genes=[1, 1] if adaptive else 1, random_mutation_min_val=[1, 10], random_mutation_max_val=[3, 12]) - monkeypatch.setattr(random, 'sample', lambda values, count: [0]) + monkeypatch.setattr(ga_instance.python_random_generator, 'sample', lambda values, count: [0]) monkeypatch.setattr(ga_instance, 'mutation_process_gene_value', lambda solution, gene_idx, **kwargs: 2 if gene_idx == 0 else solution[gene_idx]) if adaptive: @@ -213,12 +215,13 @@ def test_permutation_mutation_repairs_duplicates_created_by_destination_casts(me monkeypatch): ga_instance = make_ga(num_genes=4, gene_type=[float, int, int, int], mutation_type=method.split('_')[0], initial_population=[[0.5, 1, 0, 2], [0.5, 1, 0, 2]]) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) if method == 'swap_mutation': - monkeypatch.setattr(numpy.random, 'choice', lambda *args, **kwargs: numpy.array([0, 1])) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'choice', lambda *args, **kwargs: numpy.array([0, 1])) else: - monkeypatch.setattr(numpy.random, 'randint', lambda *args, **kwargs: numpy.array([0])) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'randint', lambda *args, **kwargs: numpy.array([0])) if method == 'scramble_mutation': - monkeypatch.setattr(numpy.random, 'shuffle', lambda values: values.__setitem__(slice(None), values[::-1].copy())) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'shuffle', lambda values: values.__setitem__(slice(None), values[::-1].copy())) result = getattr(ga_instance, method)(ga_instance.population.copy()) for solution in result: assert len(set(solution)) == 4 @@ -291,7 +294,8 @@ def test_integer_none_mutation_adds_the_offset_before_casting(monkeypatch): initial_population=[[-2, 100, 200], [-2, 100, 200]], mutation_by_replacement=False, random_mutation_min_val=-1, random_mutation_max_val=1) - monkeypatch.setattr(numpy.random, 'uniform', lambda *args, **kwargs: 0.75) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'uniform', lambda *args, **kwargs: 0.75) value = ga_instance.generate_gene_value_from_space( 0, False, ga_instance.population[0], gene_value=-2, sample_size=1) assert value == -1 diff --git a/tests/test_gene_type_conversion.py b/tests/test_gene_type_conversion.py index 6a00075b..6ea69a78 100644 --- a/tests/test_gene_type_conversion.py +++ b/tests/test_gene_type_conversion.py @@ -1,5 +1,7 @@ """Conversion, rounding, and gene-type preservation across the GA lifecycle.""" +from unittest.mock import Mock + import copy import numpy @@ -287,7 +289,8 @@ def test_generated_integer_ranges_preserve_exact_numpy_bounds(dtype): @pytest.mark.parametrize("dtype,precision", [(numpy.float32, 2), (float, 400), (float, 2**40)]) def test_continuous_space_fallback_respects_stored_value_bounds(dtype, precision, monkeypatch): ga_instance = make_ga(gene_type=[dtype, precision], gene_space={'low': 0.9, 'high': 1.0}) - monkeypatch.setattr(numpy.random, 'uniform', lambda *args, **kwargs: numpy.ones(kwargs['size'])) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'uniform', lambda *args, **kwargs: numpy.ones(kwargs['size'])) values = ga_instance.get_gene_space_values(0, sample_size=1) assert len(values) == 1 assert 0.9 <= float(values[0]) < 1.0 diff --git a/tests/test_operator_regressions.py b/tests/test_operator_regressions.py index efc77180..29aab839 100644 --- a/tests/test_operator_regressions.py +++ b/tests/test_operator_regressions.py @@ -1,5 +1,7 @@ """Edge cases and invariants for the crossover and mutation operators.""" +from unittest.mock import Mock + import itertools import numpy @@ -131,7 +133,8 @@ def test_sbx_can_select_both_symmetric_children_at_boundaries(monkeypatch, paren parents = numpy.array(parents).reshape(2, 1) # The same spread draw with opposite child choices must straddle the mean. draws = iter([quantile, 0.0, quantile, 0.99]) - monkeypatch.setattr(numpy.random, "random", lambda: next(draws)) + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "random", lambda: next(draws)) children = ga.sbx_crossover(parents, (2, 1))[:, 0] assert children[0] < parents.mean() < children[1] @@ -146,7 +149,8 @@ def test_sbx_equal_parents_do_not_draw_random_values(monkeypatch): def unexpected_draw(*args, **kwargs): pytest.fail("Equal parents should be copied without a random draw") - monkeypatch.setattr(numpy.random, "random", unexpected_draw) + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "random", unexpected_draw) numpy.testing.assert_array_equal(ga.sbx_crossover(parents, (3, 2)), numpy.tile(parents[0], (3, 1))) @@ -189,7 +193,8 @@ def test_scramble_mutation_preserves_values_and_unselected_genes( segment_start = (0 if segment_position == "first" else int(numpy.ceil(num_genes / 2 + 1)) - 1) segment_end = segment_start + num_genes // 2 - monkeypatch.setattr(numpy.random, "randint", + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "randint", lambda **options: numpy.array([segment_start])) original = numpy.tile(numpy.arange(num_genes, dtype=gene_type), (64, 1)) offspring = original.copy() @@ -206,7 +211,8 @@ def test_scramble_mutation_preserves_values_and_unselected_genes( def test_scramble_mutation_can_reach_every_permutation_in_selected_segment(monkeypatch): ga = _make_ga(6, gene_type=int, mutation_type="scramble") - monkeypatch.setattr(numpy.random, "randint", + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "randint", lambda **options: numpy.array([0])) offspring = numpy.tile(numpy.arange(6), (256, 1)) diff --git a/tests/test_parent_selection_regressions.py b/tests/test_parent_selection_regressions.py index 09b389f8..3bffd65b 100644 --- a/tests/test_parent_selection_regressions.py +++ b/tests/test_parent_selection_regressions.py @@ -1,5 +1,7 @@ """Sampling probabilities, requested parent counts, and population row mappings.""" +from unittest.mock import Mock + import copy import numpy @@ -30,7 +32,8 @@ def test_rank_selection_favors_better_fitness_and_maps_population_rows( suppress_warnings=True) # Sample every interval uniformly without statistical sampling noise. random_pointers = iter((numpy.arange(1000) + 0.5) / 1000) - monkeypatch.setattr(numpy.random, "rand", lambda: next(random_pointers)) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "rand", lambda: next(random_pointers)) parents, parents_indices = ga_instance.rank_selection( fitness=numpy.array(fitness), num_parents=1000) @@ -51,7 +54,8 @@ def test_rank_selection_favors_best_tied_group_or_crowding_boundaries(monkeypatc mutation_type=None, suppress_warnings=True) random_pointers = iter((numpy.arange(1000) + 0.5) / 1000) - monkeypatch.setattr(numpy.random, "rand", lambda: next(random_pointers)) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "rand", lambda: next(random_pointers)) parents, parents_indices = ga_instance.rank_selection( fitness=numpy.array(fitness), num_parents=1000) @@ -115,7 +119,8 @@ def test_sus_respects_requested_count_and_balances_equal_fitness( random_seed=17, suppress_warnings=True) fitness = (numpy.ones((4, 2)) if multi_objective else numpy.ones(4)) - monkeypatch.setattr(numpy.random, "uniform", + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "uniform", lambda **options: numpy.array([options["high"] * offset_fraction])) parents, parents_indices = ga_instance.stochastic_universal_selection( diff --git a/tests/test_plot_lifecycle.py b/tests/test_plot_lifecycle.py index 75390cc9..a2882a2a 100644 --- a/tests/test_plot_lifecycle.py +++ b/tests/test_plot_lifecycle.py @@ -241,7 +241,7 @@ def positive_gene_values(solution, values): assert "decimal places" in labels assert "1 constrained gene(s)" in labels assert "['thread', 2]" in labels - for criterion in ["reach_20.0", "saturate_3.0", "time_10.0", "evaluations_100.0"]: + for criterion in ["reach_20.0", "saturate_3", "time_10.0", "evaluations_100"]: assert criterion in labels finally: matplt.close(fig) diff --git a/tests/test_sbx_polynomial.py b/tests/test_sbx_polynomial.py index dd20d811..ef694afb 100644 --- a/tests/test_sbx_polynomial.py +++ b/tests/test_sbx_polynomial.py @@ -132,7 +132,7 @@ def test_unknown_mutation_error_message_lists_polynomial(): def test_sbx_with_fixed_seed_matches_pinned_output(): - # Pinned regression: with numpy.random seeded to 0 and the two + # Pinned regression: with the GA's NumPy generator 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. @@ -145,7 +145,7 @@ def test_sbx_with_fixed_seed_matches_pinned_output(): [0.2, 0.5, 0.7, 0.9], [0.4, 0.3, 0.5, 0.1], ]) - numpy.random.seed(0) + ga.numpy_random_generator.seed(0) offspring = ga.sbx_crossover(parents, (2, 4)) expected = numpy.array([ [0.4003319281416015, 0.5007449415190373, 0.6994669447528481, 0.8982769171720244], @@ -170,7 +170,7 @@ def test_sbx_children_are_symmetric_around_the_parents_mean(): def test_polynomial_mutation_with_fixed_seed_matches_pinned_output(): - # Pinned regression: with numpy.random seeded to 0 and the input + # Pinned regression: with the GA's NumPy generator 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. @@ -179,7 +179,7 @@ def test_polynomial_mutation_with_fixed_seed_matches_pinned_output(): 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) + ga.numpy_random_generator.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]]) From fa7213f6c8dd2e922e33ad5657034ecf5c7aceff Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 23:06:53 -0400 Subject: [PATCH 07/22] Fix fitness validation, saturation, and repeated-run histories --- docs/source/fitness_calculation.md | 23 ++ docs/source/pygad.md | 12 +- docs/source/releases.md | 6 + docs/source/visualize.md | 2 + examples/example_repeated_runs.py | 53 ++++ pygad/utils/__init__.py | 2 +- pygad/utils/engine.py | 210 +++++++++---- pygad/utils/parallel.py | 66 +++- pygad/utils/validation.py | 5 + pygad/visualize/__init__.py | 2 +- pygad/visualize/plot.py | 134 ++++---- tests/test_fitness_history_regressions.py | 365 ++++++++++++++++++++++ tests/test_nsga3_dtlz2.py | 27 +- 13 files changed, 765 insertions(+), 142 deletions(-) create mode 100644 examples/example_repeated_runs.py create mode 100644 tests/test_fitness_history_regressions.py diff --git a/docs/source/fitness_calculation.md b/docs/source/fitness_calculation.md index 82634f03..cecaee2a 100644 --- a/docs/source/fitness_calculation.md +++ b/docs/source/fitness_calculation.md @@ -2,6 +2,25 @@ This page covers how PyGAD calculates the fitness efficiently: parallel processing, non-deterministic problems, reusing fitness values, and batch fitness calculation. +## Fitness Output Validation + +For a single-objective problem, `fitness_func` returns one numeric value per solution. For a multi-objective problem, it returns a non-empty, one-dimensional list, tuple, or NumPy array of numeric objective values. Every solution must return the same number of objectives throughout a run, including cached solutions and offspring evaluated for adaptive mutation. Empty vectors, nested vectors, non-numeric values, and inconsistent objective counts raise a descriptive error before parent selection. + +`NaN` is rejected. Single-objective fitness may be positive or negative infinity, for example to represent a perfect or rejected solution; proportional selection and numerical plots can still require finite scores. Multi-objective fitness must contain finite values because crowding distances and reference-point normalization use differences between objective values. + +Batch evaluation returns one such fitness value per supplied solution, including a smaller final batch. Sequential, threaded, and process evaluation use the same validation. `on_fitness` outputs are validated too, whether the callback returns replacement values or edits the supplied array in place. + +(saved-fitness-across-repeated-runs)= +## Saved Fitness across Repeated Runs + +Calling `run()` again continues from `generations_completed` and extends the existing histories. Each run saves its starting population and final population. For two runs of 2 generations, `best_solutions_generations` contains `[0, 1, 2, 2, 3, 4]`. Both snapshots of generation 2 remain available. The corresponding `best_solutions_fitness` entries have the same positions, and `best_solutions` uses those positions when `save_best_solutions=True`. + +When `save_solutions=True`, `solutions_generations` contains one generation number per saved population. `solutions` and `solutions_fitness` retain their existing flat layout, with one entry per solution. Population boundaries are recorded internally, including populations enlarged by NSGA-III. `best_solution_generation` reports the actual generation of the best saved fitness rather than its position in the history. For multi-objective histories, it uses the same NSGA-II ordering as `best_solution()`. + +`on_fitness(ga_instance, population_fitness)` runs before parent selection for each generation. After it returns, PyGAD recomputes the best solution so the saved solution and fitness agree. The final population is saved without an additional `on_fitness` call. Previously saved arrays are independent of later callback edits. Callbacks receive the population fitness after cache reuse, so changes to already cached scores can accumulate if the callback repeatedly adds to them. + +Checkpoints preserve the generation numbers and population boundaries. Older checkpoints containing a single-run history recover the generation numbers automatically. Older repeated-run checkpoints did not record run boundaries, so unavailable generation numbers are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. New snapshots have their actual generation numbers. Plots use snapshot positions only for those unknown legacy entries. A complete example is available at [`examples/example_repeated_runs.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_repeated_runs.py). + (parallel-processing-guide)= ## Parallel Processing in PyGAD @@ -134,6 +153,10 @@ This way, PyGAD will not save any explored solution, so the fitness function has ## Reuse the Fitness instead of Calling the Fitness Function +Saved solutions are indexed by their complete gene values to avoid scanning the entire history for every population member. Built-in evolution indexes new snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Duplicate solutions that have not been evaluated or saved are still evaluated independently. + +Indexes are rebuilt for direct evaluations outside `run()`, at the start of each run, and after user operators or callbacks that may edit the public histories. The indexes are omitted from checkpoints and process-worker snapshots and rebuilt when needed. This preserves history edits and cache behavior without adding configuration parameters. + It may happen that a previously explored solution in generation X is explored again in another generation Y (where Y > X). For some problems, calling the fitness function takes much time. For deterministic problems, it is better not to call the fitness function for an already explored solution. Instead, reuse the fitness of the old solution. PyGAD supports some options to help you save the time of calling the fitness function for a previously explored solution. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index cdc086c9..dcac65b7 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -70,6 +70,8 @@ You can also pass a list, tuple, or 1D NumPy array of criteria; the run stops as The counts for `saturate` and `evaluations` must be positive integers. Fractional counts are rejected. The threshold for `reach` must be finite, and the duration for `time` must be finite and non-negative; `time_0` stops at the first stopping check. Scientific notation is accepted, for example `evaluations_1e3` or `time_1e-2`. For multi-objective problems, `reach_10_20` requires both objective thresholds to be met; a single threshold applies to every objective. +`saturate_N` counts consecutive completed generations whose best fitness equals the preceding population's best fitness, including the initial population as the baseline. `saturate_1` stops after one unchanged generation. Any change resets the count, so matching endpoints with changes in between do not count as saturation. Multi-objective problems compare the whole best-fitness vector. Each `run()` starts a new saturation count while `generations_completed` continues increasing across runs. + 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. ::: @@ -400,9 +402,9 @@ Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pyga :::{dropdown} `on_fitness=None`: Called after the fitness is calculated. :animate: fade-in-slide-down -A function (or method) called after the fitness of all solutions is calculated. +A function (or method) called before parent selection after the fitness of all solutions is calculated. It receives `on_fitness(ga_instance, population_fitness)` and may return replacement fitness with the same shape, or modify the supplied array in place and return `None`. Both forms are validated before selection, and saved best solutions are updated to agree with the resulting fitness. -- As a **function**, it takes 2 parameters: a list of all the solutions' fitness values, and the instance of the genetic algorithm. +- As a **function**, it takes 2 parameters: the instance of the genetic algorithm, and a NumPy array of all the solutions' fitness values. - As a **method**, it takes a third parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). @@ -466,7 +468,7 @@ Supported in [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html# :::{dropdown} `save_solutions=False`: Save every solution of each generation. :animate: fade-in-slide-down -If `True`, then all solutions in each generation are appended into an attribute called `solutions` which is NumPy array. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). +If `True`, then all solutions in each generation are appended into the `solutions` list, with their fitness in `solutions_fitness`. Each run includes its starting and final populations. `solutions_generations` records one generation number per saved population. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). ::: :::{dropdown} `logger=None`: Custom logger for the outputs. @@ -628,11 +630,13 @@ Constructor settings and user callables are stored as instance attributes, with - `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`: Fitness history of the best solution per generation, including the final population. Recorded even when `save_best_solutions=False`; with saving enabled, entries correspond to `best_solutions`. +- `best_solutions_generations`: The actual generation number for each entry in `best_solutions_fitness`. Repeated runs retain both the final and starting snapshots at their boundary. - `best_solutions`: A NumPy array of the best solution per generation. Only populated when `save_best_solutions=True`. - `solutions`: All visited solutions when `save_solutions=True`. - `solutions_fitness`: Fitness for every entry in `solutions`. +- `solutions_generations`: One generation number per saved population when `save_solutions=True`. The solutions and their fitness retain one entry per solution. - `num_fitness_evaluations`: Number of solutions evaluated during the current `run()`, including adaptive offspring and every solution in returned fitness batches. Cache hits do not count. Each run resets the counter after `on_start`; direct fitness evaluations outside a run increment the existing count. `evaluations_` checks the count at generation boundaries, so the run can exceed the requested budget by a generation's work. -- `best_solution_generation`: Generation at which the best fitness was reached. `-1` until `run()` completes. +- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs). ##### Methods diff --git a/docs/source/releases.md b/docs/source/releases.md index 982c30f4..8c0f2fad 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -753,4 +753,10 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. 23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. +24. Fitness histories record actual generation numbers across repeated `run()` calls while preserving all starting and final snapshots. `best_solution_generation` uses those numbers and the same ordering as `best_solution()`. Population history records each snapshot's size. Fitness plots, best-solution gene plots, population diagnostics, Pareto evolution, and PDF reports use this metadata. Checkpoints preserve it; older single-run checkpoints recover their generation numbers, while unavailable numbers in older repeated-run histories are marked as unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +25. `saturate_N` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. +26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots. Callback order and call counts are preserved. +27. Fitness validation is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. +28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches. Built-in evolution indexes newly saved snapshots incrementally, preserving cache precedence and the first matching entry. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 372a5be9..3d90e0da 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -23,6 +23,8 @@ Every method returns the `matplotlib.figure.Figure` it created and optionally wr Except for `plot_lifecycle()`, 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. +After repeated `run()` calls, fitness plots, best-solution gene plots, and population diagnostics use the actual generation numbers. Histories retain both snapshots at a run boundary, so two points can have the same generation number. Population diagnostics also retain each snapshot's population size. `plot_new_solution_rate()` uses the latest saved population once per generation and excludes the final population, as in a single run. `plot_pareto_front_evolution(every_k=N)` selects actual generation numbers divisible by `N`, uses the latest snapshot at repeated boundaries, and always includes the final population. These plots also work in generated PDF reports. + (plot-lifecycle)= ## `plot_lifecycle()` diff --git a/examples/example_repeated_runs.py b/examples/example_repeated_runs.py new file mode 100644 index 00000000..fa92983e --- /dev/null +++ b/examples/example_repeated_runs.py @@ -0,0 +1,53 @@ +"""Continue a GA from a checkpoint and inspect its saved generation numbers.""" + +import os +import tempfile + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return int(solution[0]) + + +def mutation_func(offspring, ga_instance): + return offspring + 10 + + +def main(): + ga_instance = pygad.GA(num_generations=2, + num_parents_mating=1, + fitness_func=fitness_func, + initial_population=[[0], [1]], + gene_type=int, + crossover_type=None, + mutation_type=mutation_func, + keep_parents=0, + keep_elitism=0, + save_best_solutions=True, + save_solutions=True, + suppress_warnings=True, + random_seed=7) + ga_instance.run() + + with tempfile.TemporaryDirectory() as directory: + filename = os.path.join(directory, 'repeated_runs') + ga_instance.save(filename) + resumed_instance = pygad.load(filename) + resumed_instance.run() + + print('Completed generations:', resumed_instance.generations_completed) + print('Best solution generation:', resumed_instance.best_solution_generation) + print('Saved best generation numbers:', resumed_instance.best_solutions_generations) + for generation, solution, fitness in zip(resumed_instance.best_solutions_generations, + resumed_instance.best_solutions, + resumed_instance.best_solutions_fitness): + print('Generation:', generation, 'Best solution:', solution, 'Fitness:', fitness) + # The final population of the first run and the starting population of + # the resumed run are both kept, with the same generation number (2). + assert resumed_instance.best_solutions_generations == [0, 1, 2, 2, 3, 4] + assert resumed_instance.best_solution_generation == 4 + + +if __name__ == '__main__': + main() diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 553660df..67dd2205 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.6" +__version__ = "1.5.7" diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index 0f800b8b..2900cd3e 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -7,6 +7,7 @@ class GAEngine(FitnessEvaluation): def __setstate__(self, state): """Restore generator states, initializing them for older checkpoints.""" + legacy_history = 'best_solutions_generations' not in state self.__dict__.update(state) for name in ['random_seed', 'num_generations', 'num_parents_mating', 'sol_per_pop', 'num_genes', 'K_tournament', 'nsga3_num_divisions', 'sample_size', @@ -20,6 +21,77 @@ def __setstate__(self, state): self.python_random_generator = random.Random(self.random_seed) if not hasattr(self, 'mutation_control_explicitly_set'): self.mutation_control_explicitly_set = False + self._restore_history_generations() + if legacy_history and self.run_completed and len(self.best_solutions_fitness) > 0: + self._update_best_solution_generation() + + def _restore_history_generations(self): + """Supply history metadata for checkpoints saved before it existed. + + A single-run history has an unambiguous generation sequence. Older + repeated-run checkpoints did not save their run boundaries; None + marks generation numbers that cannot be recovered reliably. + """ + count = len(self.best_solutions_fitness) + if (not hasattr(self, 'best_solutions_generations') + or len(self.best_solutions_generations) != count): + self.best_solutions_generations = (list(range(count)) + if count == self.generations_completed + 1 else [None] * count) + if (not hasattr(self, '_saved_population_sizes') + or sum(self._saved_population_sizes) != len(self.solutions)): + count = len(self.solutions) // self.sol_per_pop + self._saved_population_sizes = [self.sol_per_pop] * count + count = len(self._saved_population_sizes) + if (not hasattr(self, 'solutions_generations') + or len(self.solutions_generations) != count): + self.solutions_generations = (list(range(count)) + if count == self.generations_completed + 1 else [None] * count) + + def _history_generation_numbers(self, generations): + """Use snapshot positions only for unknown legacy generation numbers.""" + return [index if generation is None else generation + for index, generation in enumerate(generations)] + + def _update_best_solution_generation(self): + """Find the best history entry using the same ordering as best_solution().""" + fitness = numpy.asarray(self.best_solutions_fitness) + if fitness.ndim == 1: + index = int(numpy.argmax(fitness)) + else: + index = self.sort_solutions_nsga2(fitness=fitness, + find_best_solution=True)[0] + generation = self.best_solutions_generations[index] + self.best_solution_generation = -1 if generation is None else generation + + def _saved_fitness_index(self, name, solutions, fitness): + """Index saved solutions incrementally, retaining the first match. + + Rebuild outside run() so edits to public history arrays are honored. + During a run, only newly appended snapshots need indexing. Fitness + is read from the source list on each hit, so changes to saved scores + are also respected without duplicating those scores in the index. + """ + indexes = getattr(self, '_saved_fitness_indexes', {}) + self._saved_fitness_indexes = indexes + count = min(len(solutions), len(fitness)) + indexed_solutions, indexed_count, lookup = indexes.get(name, (None, 0, {})) + if (not getattr(self, '_fitness_run_active', False) + or indexed_solutions is not solutions or indexed_count > count): + indexed_count, lookup = 0, {} + for index in range(indexed_count, count): + lookup.setdefault(tuple(solutions[index]), index) + indexes[name] = (solutions, count, lookup) + return lookup + + def _save_population_snapshot(self): + """Append independent population and fitness snapshots with their generation.""" + if self.save_solutions: + # Building each row preserves NumPy scalar types. tolist() + # would convert narrow NumPy types into Python int or float. + self.solutions.extend([list(solution) for solution in self.population]) + self.solutions_fitness.extend(self.last_generation_fitness.copy()) + self.solutions_generations.append(self.generations_completed) + self._saved_population_sizes.append(len(self.population)) def round_genes(self, solutions): """ @@ -158,31 +230,41 @@ def cal_pop_fitness(self): if type(self.best_solutions) is numpy.ndarray: self.best_solutions = self.best_solutions.tolist() - saved_solutions = (self.solutions.tolist() - if type(self.solutions) is numpy.ndarray - else self.solutions) + saved_solutions = self.solutions + saved_index = (self._saved_fitness_index('solutions', saved_solutions, + self.solutions_fitness) if self.save_solutions else {}) + best_index = (self._saved_fitness_index('best_solutions', self.best_solutions, + self.best_solutions_fitness) if self.save_best_solutions else {}) parents = (self.last_generation_parents.tolist() if self.last_generation_parents is not None else []) elites = (self.last_generation_elitism.tolist() if self.last_generation_elitism is not None else []) + parent_index = {} + elite_index = {} + for index, solution in enumerate(parents): + parent_index.setdefault(tuple(solution), index) + for index, solution in enumerate(elites): + elite_index.setdefault(tuple(solution), index) pop_fitness = [None] * len(self.population) missing_indices = [] + if not getattr(self, '_fitness_run_active', False): + self._fitness_value_shape = None for index, solution in enumerate(self.population): - values = solution.tolist() - if self.save_solutions and values in saved_solutions: - fitness = self.solutions_fitness[saved_solutions.index(values)] - elif self.save_best_solutions and values in self.best_solutions: - fitness = self.best_solutions_fitness[self.best_solutions.index(values)] - elif self.keep_elitism > 0 and values in elites: - previous_index = self.last_generation_elitism_indices[elites.index(values)] + values = tuple(solution) + if values in saved_index: + fitness = self.solutions_fitness[saved_index[values]] + elif values in best_index: + fitness = self.best_solutions_fitness[best_index[values]] + elif self.keep_elitism > 0 and values in elite_index: + previous_index = self.last_generation_elitism_indices[elite_index[values]] fitness = self.previous_generation_fitness[previous_index] - elif self.keep_parents != 0 and values in parents: - previous_index = self.last_generation_parents_indices[parents.index(values)] + elif self.keep_parents != 0 and values in parent_index: + previous_index = self.last_generation_parents_indices[parent_index[values]] fitness = self.previous_generation_fitness[previous_index] else: missing_indices.append(index) continue - pop_fitness[index] = fitness + pop_fitness[index] = self._validate_fitness_value(fitness, 'cached fitness') fitness_values = self._evaluate_fitness(self.population, missing_indices) for index, fitness in zip(missing_indices, fitness_values): @@ -219,12 +301,15 @@ def run(self): current number of objectives. """ self._fitness_run_active = True + self._saved_fitness_indexes = {} + self._fitness_value_shape = None try: if self.valid_parameters == False: raise Exception("Error calling the run() method: \nThe run() method cannot be executed with invalid parameters. Please check the parameters passed while creating an instance of the GA class.\n") # Starting from PyGAD 2.18.0, the 4 properties (best_solutions, best_solutions_fitness, solutions, and solutions_fitness) are no longer reset with each call to the run() method. Instead, they are extended. - # For example, if there are 50 generations and the user set save_best_solutions=True, then the length of the 2 properties best_solutions and best_solutions_fitness will be 50 after the first call to the run() method, then 100 after the second call, 150 after the third, and so on. + # Each run retains its starting population and every completed generation. + self._restore_history_generations() # self.best_solutions: Holds the best solution in each generation. if type(self.best_solutions) is numpy.ndarray: @@ -288,9 +373,12 @@ def run(self): if self.save_best_solutions: self.best_solutions.append(list(best_solution)) + unchanged_generations = 0 + for generation in range(generation_first_idx, generation_last_idx): self.run_loop_head(best_solution_fitness) + previous_best_fitness = self.best_solutions_fitness[-1] # Call the 'run_select_parents()' method to select the parents. # It edits these 2 instance attributes: @@ -314,6 +402,15 @@ def run(self): # 1) population: A NumPy array of the population of solutions/chromosomes. self.run_update_population() + # User operators and callbacks may edit the public histories. + # Rebuild their indexes after those calls; ordinary built-in + # evolution keeps the incremental indexes between generations. + if (any(callable(operator) for operator in + (self.parent_selection_type, self.crossover_type, self.mutation_type)) + or any(callback is not None for callback in + (self.on_parents, self.on_crossover, self.on_mutation))): + self._saved_fitness_indexes = {} + # The generations_completed attribute holds the number of the last completed generation. self.generations_completed = generation + 1 @@ -323,6 +420,10 @@ def run(self): best_solution, best_solution_fitness, best_match_idx = self.best_solution( pop_fitness=self.last_generation_fitness) + if numpy.array_equal(previous_best_fitness, best_solution_fitness): + unchanged_generations += 1 + else: + unchanged_generations = 0 # Appending the best solution in the current generation to the best_solutions list. if self.save_best_solutions: @@ -332,6 +433,7 @@ def run(self): # If the on_generation attribute is not None, then call the callback function after the generation. if not (self.on_generation is None): r = self.on_generation(self) + self._saved_fitness_indexes = {} if type(r) is str and r.lower() == "stop": break @@ -376,22 +478,9 @@ def run(self): stop_run = False break elif criterion[0] == "saturate": - criterion[1] = int(criterion[1]) - if self.generations_completed >= criterion[1]: - # Single-objective problem. - if type(self.last_generation_fitness[0]) in self.supported_int_float_types: - if (self.best_solutions_fitness[self.generations_completed - criterion[1]] - self.best_solutions_fitness[self.generations_completed - 1]) == 0: - stop_run = True - break - # Multi-objective problem. - elif type(self.last_generation_fitness[0]) in [list, tuple, numpy.ndarray]: - stop_run = True - for obj_idx in range(len(self.last_generation_fitness[0])): - if (self.best_solutions_fitness[self.generations_completed - criterion[1]][obj_idx] - self.best_solutions_fitness[self.generations_completed - 1][obj_idx]) == 0: - pass - else: - stop_run = False - break + if unchanged_generations >= criterion[1]: + stop_run = True + break elif criterion[0] == "time": # Stop when the time spent inside run() # passes the user limit. @@ -410,13 +499,7 @@ def run(self): break # Save the fitness of the last generation. - if self.save_solutions: - # self.solutions.extend(self.population.copy()) - population_as_list = self.population.copy() - population_as_list = [list(item) for item in population_as_list] - self.solutions.extend(population_as_list) - - self.solutions_fitness.extend(self.last_generation_fitness) + self._save_population_snapshot() # Call the run_select_parents() method to update these 2 attributes according to the 'last_generation_fitness' attribute: # 1) last_generation_parents 2) last_generation_parents_indices @@ -429,12 +512,15 @@ def run(self): num_parents=self.keep_elitism) # Save the fitness value of the best solution. - _, best_solution_fitness, _ = self.best_solution( + best_solution, best_solution_fitness, _ = self.best_solution( pop_fitness=self.last_generation_fitness) - self.best_solutions_fitness.append(best_solution_fitness) + if self.save_best_solutions: + self.best_solutions[-1] = best_solution.tolist() + self.best_solutions_fitness.append(numpy.copy(best_solution_fitness) + if isinstance(best_solution_fitness, numpy.ndarray) else best_solution_fitness) + self.best_solutions_generations.append(self.generations_completed) - self.best_solution_generation = numpy.where(numpy.array( - self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + self._update_best_solution_generation() # After the run() method completes, the run_completed flag is changed from False to True. # Set to True only after the run() method completes gracefully. self.run_completed = True @@ -463,7 +549,7 @@ 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 + validation of returned or edited values), recompute and append the 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 @@ -474,7 +560,8 @@ def run_loop_head(self, best_solution_fitness): Parameters ---------- best_solution_fitness : numeric or numpy.ndarray - Fitness of the best solution in the previous generation. + Retained for compatibility. The best fitness is recomputed + after on_fitness so it agrees with the saved solution. Raises ------ @@ -483,8 +570,10 @@ def run_loop_head(self, best_solution_fitness): match the population fitness, or an unsupported type. """ if not (self.on_fitness is None): + expected_shape = self.last_generation_fitness.shape on_fitness_output = self.on_fitness(self, self.last_generation_fitness) + self._saved_fitness_indexes = {} if on_fitness_output is None: pass @@ -497,18 +586,25 @@ def run_loop_head(self, best_solution_fitness): raise ValueError(f"Size mismatch between the output of on_fitness() {on_fitness_output.shape} and the expected fitness output {self.last_generation_fitness.shape}.") else: raise ValueError(f"The output of on_fitness() is expected to be tuple/list/range/numpy.ndarray but {type(on_fitness_output)} found.") + self.last_generation_fitness = self._validate_population_fitness( + self.last_generation_fitness, 'on_fitness output') + if self.last_generation_fitness.shape != expected_shape: + raise ValueError(f"Size mismatch between the output of on_fitness() {self.last_generation_fitness.shape} and the expected fitness output {expected_shape}.") # Appending the fitness value of the best solution in the current generation to the best_solutions_fitness attribute. - self.best_solutions_fitness.append(best_solution_fitness) + if self.on_fitness is not None: + best_solution, best_solution_fitness, _ = self.best_solution( + pop_fitness=self.last_generation_fitness) + self.best_solutions_fitness.append(numpy.copy(best_solution_fitness) + if isinstance(best_solution_fitness, numpy.ndarray) else best_solution_fitness) + self.best_solutions_generations.append(self.generations_completed) + if self.save_best_solutions and self.on_fitness is not None: + # This snapshot was appended before on_fitness ran. The callback + # may change which solution is best, so update the matching entry. + self.best_solutions[-1] = best_solution.tolist() # Appending the solutions in the current generation to the solutions list. - if self.save_solutions: - # self.solutions.extend(self.population.copy()) - population_as_list = self.population.copy() - population_as_list = [list(item) for item in population_as_list] - self.solutions.extend(population_as_list) - - self.solutions_fitness.extend(self.last_generation_fitness) + self._save_population_snapshot() def run_select_parents(self, call_on_parents=True): """ @@ -886,24 +982,24 @@ def best_solution(self, pop_fitness=None): pop_fitness = self.cal_pop_fitness() # Verify the type of the 'pop_fitness' parameter. elif type(pop_fitness) in [tuple, list, numpy.ndarray]: + if isinstance(pop_fitness, numpy.ndarray) and pop_fitness.ndim == 0: + raise ValueError("pop_fitness must contain one fitness value per population solution.") # Verify that the length of the passed population fitness matches the length of the 'self.population' attribute. if len(pop_fitness) == len(self.population): # This successfully verifies the 'pop_fitness' parameter. - pass + pop_fitness = self._validate_population_fitness( + pop_fitness, 'pop_fitness', check_shape=False) else: raise ValueError(f"The length of the list/tuple/numpy.ndarray passed to the 'pop_fitness' parameter ({len(pop_fitness)}) must match the length of the 'self.population' attribute ({len(self.population)}).") else: raise ValueError(f"The type of the 'pop_fitness' parameter is expected to be list, tuple, or numpy.ndarray but ({type(pop_fitness)}) found.") - # Return the index of the best solution that has the best fitness value. - # For multi-objective optimization: find the index of the solution with the maximum fitness in the first objective, - # break ties using the second objective, then third, etc. + # Use the same ordering as parent selection for multi-objective fitness. pop_fitness_arr = numpy.array(pop_fitness) # Get the indices that would sort by all objectives in descending order if pop_fitness_arr.ndim == 1: # Single-objective optimization. - best_match_idx = numpy.where( - pop_fitness == numpy.max(pop_fitness))[0][0] + best_match_idx = int(numpy.argmax(pop_fitness_arr)) elif pop_fitness_arr.ndim == 2: # Multi-objective optimization. # Use NSGA-2 to sort the solutions using the fitness. diff --git a/pygad/utils/parallel.py b/pygad/utils/parallel.py index b0cdb986..60ad1383 100644 --- a/pygad/utils/parallel.py +++ b/pygad/utils/parallel.py @@ -55,14 +55,14 @@ def __getstate__(self): ------- state : dict A shallow copy of instance attributes excluding the executor, - its configuration, and the active-run flag. Cloudpickle uses + its configuration, the active-run flag, and saved fitness indexes. Cloudpickle uses it for checkpoints and GA snapshots sent to process workers. """ # Executors contain locks and worker handles. Neither checkpoints # nor GA snapshots sent to workers should contain these resources. state = self.__dict__.copy() for name in ("_fitness_executor", "_fitness_executor_config", - "_fitness_run_active"): + "_fitness_run_active", "_saved_fitness_indexes"): state.pop(name, None) return state @@ -188,7 +188,8 @@ def _evaluate_fitness(self, population, indices, adaptive=False): If a batch call returns neither list, tuple, nor numpy.ndarray. ValueError If a batch's result length differs from its solution count, - or an individual fitness value has an unsupported type. + or a fitness value has an unsupported type, shape, objective + count, or non-finite objective value. Notes ----- @@ -224,6 +225,8 @@ def _evaluate_fitness(self, population, indices, adaptive=False): raise TypeError("Expected to receive a list, tuple, or " "numpy.ndarray from the fitness function " f"but the value ({result}) of type {type(result)}.") + if isinstance(result, numpy.ndarray) and result.ndim == 0: + raise ValueError("A batched fitness_func must return one fitness value per solution, not a scalar array.") if len(result) != len(group): raise ValueError("There is a mismatch between the number " "of solutions passed to the fitness function " @@ -232,15 +235,56 @@ def _evaluate_fitness(self, population, indices, adaptive=False): values = result else: values = [result] - for value in values: - if (type(value) not in self.supported_int_float_types - and type(value) not in (list, tuple, numpy.ndarray)): - raise ValueError("The fitness function should return a " - "number or an iterable (list, tuple, or " - f"numpy.ndarray) but the value {value} " - f"of type {type(value)} found.") - fitness_values.append(value) + for index, value in zip(group, values): + fitness_values.append(self._validate_fitness_value( + value, f"fitness_func for solution {index}")) finally: # Close an out-of-run executor even if result validation fails. results.close() return fitness_values + + def _validate_fitness_value(self, value, source, check_shape=True): + """Validate one scalar or non-empty objective vector before selection. + + Scalars may include infinity to represent a perfect or rejected + solution. Objective vectors must be finite because Pareto distance + and normalization calculations require finite objective ranges. + Every solution must return the same number of objectives. + """ + if isinstance(value, numpy.ndarray) and value.ndim == 0: + value = value.item() + if type(value) in self.supported_int_float_types and type(value) is not object: + shape = () + values = [value] + elif isinstance(value, (list, tuple, numpy.ndarray)): + try: + array = numpy.asarray(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{source} must return a non-empty one-dimensional numeric objective vector.") from error + if array.ndim != 1 or array.size == 0: + raise ValueError(f"{source} must return a number or a non-empty one-dimensional objective vector.") + shape = array.shape + values = value + else: + raise ValueError(f"{source} must return a number or a non-empty one-dimensional objective vector, but received {type(value)}.") + for objective in values: + if type(objective) not in self.supported_int_float_types or type(objective) is object: + raise ValueError(f"{source} contains a non-numeric fitness value: {objective!r}.") + if isinstance(objective, (float, numpy.floating)): + if numpy.isnan(objective) or (shape and not numpy.isfinite(objective)): + raise ValueError(f"{source} contains an invalid fitness value. NaN is not supported, and objective vectors must contain finite numbers.") + if check_shape: + expected_shape = getattr(self, '_fitness_value_shape', None) + if expected_shape is not None and shape != expected_shape: + raise ValueError(f"{source} has fitness shape {shape}, but all solutions must use the same fitness shape {expected_shape}.") + self._fitness_value_shape = shape + return value + + def _validate_population_fitness(self, fitness, source, check_shape=True): + """Return validated population fitness without changing its shape.""" + values = [self._validate_fitness_value(value, source, check_shape=check_shape) + for value in fitness] + shapes = [numpy.shape(value) for value in values] + if shapes and any(shape != shapes[0] for shape in shapes): + raise ValueError(f"{source} must use the same fitness shape for all solutions.") + return numpy.asarray(values) diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 97ca5ebe..6abef7b4 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -639,6 +639,11 @@ def _validate_footer(self, # Even though this parameter is declared in the class header, it is assigned to the object here to access it after saving the object. # A list holding the fitness value of the best solution for each generation. self.best_solutions_fitness = [] + # Histories retain the final and starting snapshots of repeated runs. + # Their positions are therefore different from generation numbers. + self.best_solutions_generations = [] + self.solutions_generations = [] + self._saved_population_sizes = [] # 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. self.best_solution_generation = -1 diff --git a/pygad/visualize/__init__.py b/pygad/visualize/__init__.py index 617edf09..be5a79e0 100644 --- a/pygad/visualize/__init__.py +++ b/pygad/visualize/__init__.py @@ -1,3 +1,3 @@ from pygad.visualize import plot -__version__ = "1.2.1" +__version__ = "1.2.2" diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index 1f493301..ae6eb394 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -160,6 +160,7 @@ def plot_fitness(self, matplt = get_matplotlib() + generations = self._history_generation_numbers(self.best_solutions_generations) fig = matplt.figure() if type(self.best_solutions_fitness[0]) in [list, tuple, numpy.ndarray] and len(self.best_solutions_fitness[0]) > 1: # Multi-objective optimization problem. @@ -187,18 +188,18 @@ def plot_fitness(self, # Return the fitness values for the current objective function across all generations. fitness = numpy.array(self.best_solutions_fitness)[:, objective_idx] if plot_type == "plot": - matplt.plot(fitness, + matplt.plot(generations, fitness, linewidth=current_linewidth, color=current_color, label=current_label) elif plot_type == "scatter": - matplt.scatter(range(len(fitness)), + matplt.scatter(generations, fitness, linewidth=current_linewidth, color=current_color, label=current_label) elif plot_type == "bar": - matplt.bar(range(len(fitness)), + matplt.bar(generations, fitness, linewidth=current_linewidth, color=current_color, @@ -206,16 +207,16 @@ def plot_fitness(self, else: # Single-objective optimization problem. if plot_type == "plot": - matplt.plot(self.best_solutions_fitness, + matplt.plot(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) elif plot_type == "scatter": - matplt.scatter(range(len(self.best_solutions_fitness)), + matplt.scatter(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) elif plot_type == "bar": - matplt.bar(range(len(self.best_solutions_fitness)), + matplt.bar(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) @@ -296,30 +297,29 @@ def plot_new_solution_rate(self, unique_solutions = set() num_unique_solutions_per_generation = [] - for generation_idx in range(self.generations_completed): - + populations = self._per_generation_solutions() + generations = self._population_history_generations(len(populations)) + # Repeated runs retain two snapshots at their boundary. Use the + # latest snapshot once per generation, excluding the final population + # as in a single run's new-solution-rate plot. + population_by_generation = dict(zip(generations, populations)) + generations = sorted(generation for generation in population_by_generation + if generation < self.generations_completed) + for generation in generations: len_before = len(unique_solutions) - - start = generation_idx * self.sol_per_pop - end = start + self.sol_per_pop - - for sol in self.solutions[start:end]: - unique_solutions.add(tuple(sol)) - - len_after = len(unique_solutions) - - generation_num_unique_solutions = len_after - len_before - num_unique_solutions_per_generation.append(generation_num_unique_solutions) + unique_solutions.update(tuple(solution) + for solution in population_by_generation[generation]) + num_unique_solutions_per_generation.append(len(unique_solutions) - len_before) matplt = get_matplotlib() fig = matplt.figure() if plot_type == "plot": - matplt.plot(num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.plot(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) elif plot_type == "scatter": - matplt.scatter(range(self.generations_completed), num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.scatter(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) elif plot_type == "bar": - matplt.bar(range(self.generations_completed), num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.bar(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) matplt.title(title, fontsize=font_size) matplt.xlabel(xlabel, fontsize=font_size) matplt.ylabel(ylabel, fontsize=font_size) @@ -423,6 +423,8 @@ def plot_genes(self, self.logger.error("The solutions parameter must be a string but {solutions_type} found.".format(solutions_type=type(solutions))) raise RuntimeError("The solutions parameter must be a string but {solutions_type} found.".format(solutions_type=type(solutions))) + generations = (self._history_generation_numbers(self.best_solutions_generations) + if solutions == 'best' else range(solutions_to_plot.shape[0])) if graph_type == "plot": # num_rows will be always be >= 1 # num_cols can only be 0 if num_genes=1 @@ -434,11 +436,11 @@ def plot_genes(self, # There is only a single gene fig, ax = matplt.subplots(num_rows, figsize=figsize) if plot_type == "plot": - ax.plot(solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.plot(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - ax.scatter(range(self.generations_completed + 1), solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.scatter(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - ax.bar(range(self.generations_completed + 1), solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.bar(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) ax.set_xlabel(0, fontsize=font_size) else: fig, axs = matplt.subplots(num_rows, num_cols) @@ -446,18 +448,19 @@ def plot_genes(self, if num_cols == 1 and num_rows == 1: fig.set_figwidth(5 * num_cols) fig.set_figheight(4) - axs.plot(solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + getattr(axs, plot_type)(generations, solutions_to_plot[:, 0], + linewidth=linewidth, color=fill_color) axs.set_xlabel("Gene " + str(0), fontsize=font_size) elif num_cols == 1 or num_rows == 1: fig.set_figwidth(5 * num_cols) fig.set_figheight(4) for gene_idx in range(len(axs)): if plot_type == "plot": - axs[gene_idx].plot(solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].plot(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - axs[gene_idx].scatter(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].scatter(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - axs[gene_idx].bar(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].bar(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) axs[gene_idx].set_xlabel("Gene " + str(gene_idx), fontsize=font_size) else: gene_idx = 0 @@ -469,11 +472,11 @@ def plot_genes(self, # axs[row_idx, col_idx].remove() break if plot_type == "plot": - axs[row_idx, col_idx].plot(solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].plot(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - axs[row_idx, col_idx].scatter(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].scatter(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - axs[row_idx, col_idx].bar(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].bar(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) axs[row_idx, col_idx].set_xlabel("Gene " + str(gene_idx), fontsize=font_size) gene_idx += 1 @@ -740,33 +743,34 @@ def _require_save_solutions(self, method_name): 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 _population_history_slices(self, count): + """Return saved population boundaries, including varying population sizes.""" + sizes = self._saved_population_sizes + if sum(sizes) != count: + # Public histories and older checkpoints may lack size metadata. + sizes = [self.sol_per_pop] * (count // self.sol_per_pop) + start = 0 + for size in sizes: + yield slice(start, start + size) + start += size + + def _population_history_generations(self, count): + """Return generation labels for saved population snapshots.""" + if len(self.solutions_generations) != count: + return list(range(count)) + return self._history_generation_numbers(self.solutions_generations) + 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 + """Return fitness arrays for saved population snapshots in order.""" + fitness = numpy.asarray(self.solutions_fitness) + return [fitness[boundary] for boundary in + self._population_history_slices(len(fitness))] 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 + """Return saved populations in order, retaining their original gene types.""" + solutions = numpy.asarray(self.solutions, dtype=self.population.dtype) + return [solutions[boundary] for boundary in + self._population_history_slices(len(solutions))] # ── Pareto-front views for M >= 3 ──────────────────────────────────────── @@ -1037,7 +1041,7 @@ def plot_fitness_band(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(per_gen)) + generations = self._population_history_generations(len(per_gen)) ax.fill_between(generations, min_vals, max_vals, color=color, alpha=band_alpha, label='min-max') ax.plot(generations, mean_vals, @@ -1111,7 +1115,7 @@ def plot_non_dominated_hypervolume(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(hv_values)) + generations = self._population_history_generations(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) @@ -1165,6 +1169,7 @@ def plot_population_diversity(self, per_gen = self._per_generation_solutions() diversity = [] for population in per_gen: + population = numpy.asarray(population, dtype=float) diff = population[:, None, :] - population[None, :, :] distances = numpy.sqrt((diff * diff).sum(axis=2)) # Mean over the upper triangle (each pair counted once). @@ -1177,7 +1182,7 @@ def plot_population_diversity(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(diversity)) + generations = self._population_history_generations(len(diversity)) ax.plot(generations, diversity, color=color, linewidth=linewidth) ax.set_title(title, fontsize=font_size) ax.set_xlabel(xlabel, fontsize=font_size) @@ -1245,8 +1250,11 @@ def plot_pareto_front_evolution(self, 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: + generations = self._population_history_generations(len(per_gen)) + latest_snapshot = dict((generation, index) for index, generation in enumerate(generations)) + indices = [index for generation, index in latest_snapshot.items() + if generation % every_k == 0] + if not indices or indices[-1] != len(per_gen) - 1: indices.append(len(per_gen) - 1) matplt = get_matplotlib() @@ -1268,11 +1276,11 @@ def plot_pareto_front_evolution(self, if num_objectives == 2: ax.scatter(front[:, 0], front[:, 1], color=color, marker=marker, alpha=alpha, - label=f"gen {gen_idx}") + label=f"gen {generations[gen_idx]}") else: ax.scatter(front[:, 0], front[:, 1], front[:, 2], color=color, marker=marker, alpha=alpha, - label=f"gen {gen_idx}") + label=f"gen {generations[gen_idx]}") ax.set_title(title, fontsize=font_size) ax.set_xlabel(xlabel, fontsize=font_size) diff --git a/tests/test_fitness_history_regressions.py b/tests/test_fitness_history_regressions.py new file mode 100644 index 00000000..70a2a87c --- /dev/null +++ b/tests/test_fitness_history_regressions.py @@ -0,0 +1,365 @@ +"""Fitness validation, saved history, and repeated-run regression tests.""" + +import cloudpickle +import matplotlib +import numpy +import pytest + +import pygad + +matplotlib.use('Agg') + + +def fitness_func(ga_instance, solution, solution_index): + return int(solution[0]) + + +def increasing_mutation(offspring, ga_instance): + return offspring + 10 + + +def make_ga(**kwargs): + parameters = dict(num_generations=2, num_parents_mating=1, + initial_population=[[0], [1]], gene_type=int, + fitness_func=fitness_func, crossover_type=None, + mutation_type=increasing_mutation, keep_parents=0, + keep_elitism=0, suppress_warnings=True, random_seed=7) + parameters.update(kwargs) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize('save_best', [False, True]) +@pytest.mark.parametrize('save_all', [False, True]) +def test_repeated_runs_preserve_snapshots_and_actual_generation_numbers(save_best, save_all): + ga = make_ga(save_best_solutions=save_best, save_solutions=save_all) + ga.run() + first_fitness = numpy.asarray(ga.best_solutions_fitness).copy() + ga.run() + assert ga.generations_completed == 4 + assert ga.best_solution_generation == 4 + assert ga.best_solutions_generations == [0, 1, 2, 2, 3, 4] + numpy.testing.assert_array_equal(ga.best_solutions_fitness[:3], first_fitness) + assert len(ga.best_solutions_fitness) == 6 + assert len(ga.best_solutions) == (6 if save_best else 0) + if save_all: + assert ga.solutions_generations == [0, 1, 2, 2, 3, 4] + assert len(ga.solutions) == len(ga.solutions_fitness) == 12 + for solution, fitness in zip(ga.solutions, ga.solutions_fitness): + assert solution[0] == fitness + if save_best: + numpy.testing.assert_array_equal(ga.best_solutions[:, 0], ga.best_solutions_fitness) + + +def test_zero_generation_runs_keep_boundary_snapshots(): + ga = make_ga(num_generations=0, save_best_solutions=True, save_solutions=True) + ga.run() + ga.run() + assert ga.generations_completed == ga.best_solution_generation == 0 + assert ga.best_solutions_generations == ga.solutions_generations == [0, 0] + assert len(ga.best_solutions) == 2 + assert len(ga.solutions) == 4 + + +def test_early_stop_and_resume_track_absolute_generations(): + def stop(ga): + return 'stop' + ga = make_ga(on_generation=stop, save_solutions=True) + ga.run() + ga.run() + assert ga.generations_completed == ga.best_solution_generation == 2 + assert ga.best_solutions_generations == ga.solutions_generations == [0, 1, 1, 2] + + +@pytest.mark.parametrize('in_place', [False, True]) +def test_on_fitness_updates_saved_best_and_population_fitness(in_place): + calls = [] + def on_fitness(ga, fitness): + calls.append(ga.generations_completed) + if in_place: + fitness[:] = [100, 0] + else: + return [100, 0] + ga = make_ga(on_fitness=on_fitness, save_best_solutions=True, save_solutions=True) + ga.run() + assert calls == [0, 1] + for snapshot in range(2): + assert ga.best_solutions_fitness[snapshot] == 100 + assert ga.best_solutions[snapshot, 0] == ga.solutions[snapshot * 2][0] + assert ga.solutions_fitness[snapshot * 2:snapshot * 2 + 2] == [100, 0] + + +@pytest.mark.parametrize('sequence,window,expected', [ + ([0, 1, 2, 3, 4, 5], 1, 5), + ([1, 1, 1, 1, 1, 1], 1, 1), + ([1, 1, 1, 1, 1, 1], 3, 3), + ([0, 1, 1, 1, 1, 1], 2, 3), + ([0, 1, 0, 1, 0, 1], 2, 5), + ([0, 0, 1, 1, 1, 1], 2, 4), +]) +@pytest.mark.parametrize('multi_objective', [False, True]) +def test_saturation_counts_consecutive_unchanged_generations(sequence, window, expected, multi_objective): + def scheduled_fitness(ga, solution, index): + value = sequence[ga.generations_completed] + return [value, -value] if multi_objective else value + ga = make_ga(fitness_func=scheduled_fitness, num_generations=5, + stop_criteria=f'saturate_{window}') + ga.run() + assert ga.generations_completed == expected + + +def test_saturation_counter_resets_for_each_run(): + ga = make_ga(fitness_func=lambda ga, solution, index: 1, + num_generations=10, stop_criteria='saturate_3') + ga.run() + assert ga.generations_completed == 3 + ga.run() + assert ga.generations_completed == 6 + assert ga.best_solution_generation == 0 + + +@pytest.mark.parametrize('mode', [None, ['thread', 2], ['process', 2]]) +@pytest.mark.parametrize('batch_size', [None, 2]) +@pytest.mark.parametrize('invalid', [[], [[1], [2]], 'bad', [1, 'bad'], [1, None], + numpy.nan, [1, numpy.nan], [1, numpy.inf], [[1], [2, 3]], object()]) +def test_invalid_fitness_is_rejected_before_selection(mode, batch_size, invalid): + def invalid_fitness(ga, solution, index): + return [invalid] * len(solution) if batch_size else invalid + ga = make_ga(fitness_func=invalid_fitness, parallel_processing=mode, + fitness_batch_size=batch_size) + with pytest.raises(ValueError, match='fitness_func'): + ga.cal_pop_fitness() + assert getattr(ga, '_fitness_executor', None) is None + + +@pytest.mark.parametrize('batch_size', [None, 2]) +def test_inconsistent_objective_counts_are_rejected(batch_size): + def inconsistent_fitness(ga, solution, index): + if batch_size: + return [1, [1, 2]] + return 1 if index == 0 else [1, 2] + ga = make_ga(fitness_func=inconsistent_fitness, fitness_batch_size=batch_size) + with pytest.raises(ValueError, match='same fitness shape'): + ga.cal_pop_fitness() + + +@pytest.mark.parametrize('in_place', [False, True]) +def test_invalid_callback_fitness_is_rejected(in_place): + def invalid_callback(ga, fitness): + if in_place: + fitness[:] = numpy.nan + else: + return ['bad', 'bad'] + ga = make_ga(gene_type=float, fitness_func=lambda ga, solution, index: float(solution[0]), + on_fitness=invalid_callback) + with pytest.raises(ValueError, match='on_fitness'): + ga.run() + + +def test_scalar_infinity_is_supported_by_best_solution_and_saturation(): + ga = make_ga(fitness_func=lambda ga, solution, index: numpy.inf, + stop_criteria='saturate_2', num_generations=5) + ga.run() + assert ga.generations_completed == 2 + assert ga.best_solution()[1] == numpy.inf + + +def test_checkpoint_resume_matches_uninterrupted_repeated_runs(): + ga = make_ga(save_best_solutions=True, save_solutions=True, mutation_type='random') + ga.run() + resumed = cloudpickle.loads(cloudpickle.dumps(ga)) + assert not hasattr(resumed, '_saved_fitness_indexes') + ga.run() + resumed.run() + numpy.testing.assert_array_equal(resumed.population, ga.population) + numpy.testing.assert_array_equal(resumed.best_solutions, ga.best_solutions) + assert resumed.best_solutions_generations == ga.best_solutions_generations + assert resumed.solutions_generations == ga.solutions_generations + assert resumed.best_solution_generation == ga.best_solution_generation + + +@pytest.mark.parametrize('runs', [1, 2]) +@pytest.mark.parametrize('array_history', [False, True]) +def test_older_checkpoints_restore_available_generation_information(runs, array_history): + ga = make_ga(save_solutions=True) + for _ in range(runs): + ga.run() + state = ga.__getstate__() + if array_history: + state['best_solutions_fitness'] = numpy.asarray(state['best_solutions_fitness']) + for name in ['best_solutions_generations', 'solutions_generations', '_saved_population_sizes']: + state.pop(name) + restored = pygad.GA.__new__(pygad.GA) + restored.__setstate__(state) + if runs == 1: + assert restored.best_solutions_generations == [0, 1, 2] + assert restored.best_solution_generation == 2 + else: + assert restored.best_solutions_generations == [None] * 6 + assert restored.best_solution_generation == -1 + restored.run() + assert restored.best_solution_generation == restored.generations_completed + + +def test_clearing_public_histories_keeps_new_generation_numbers_correct(): + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.run() + ga.best_solutions = [] + ga.best_solutions_fitness = [] + ga.solutions = [] + ga.solutions_fitness = [] + ga.run() + assert ga.best_solutions_generations == ga.solutions_generations == [2, 3, 4] + assert ga.best_solution_generation == 4 + + +def test_cache_precedence_first_match_and_public_history_edits(): + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.solutions = [[0], [0], [1]] + ga.solutions_fitness = [12, 99, 13] + ga.best_solutions = [[0], [1]] + ga.best_solutions_fitness = [100, 101] + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [12, 13]) + ga.solutions_fitness[0] = 42 + ga.solutions[2][0] = 2 + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [42, 101]) + assert ga.num_fitness_evaluations == 0 + + +def test_incremental_cache_only_indexes_new_saved_solutions(): + class CountingHistory(list): + reads = 0 + def __getitem__(self, index): + self.reads += 1 + return super().__getitem__(index) + ga = make_ga(save_solutions=True) + ga.solutions = CountingHistory([[index] for index in range(1000)]) + ga.solutions_fitness = list(range(1000)) + ga._fitness_run_active = True + ga.cal_pop_fitness() + assert ga.solutions.reads == 1000 + ga.cal_pop_fitness() + assert ga.solutions.reads == 1000 + ga.solutions.append([1000]) + ga.solutions_fitness.append(1000) + ga.cal_pop_fitness() + assert ga.solutions.reads == 1001 + + +@pytest.mark.parametrize('plot_type', ['plot', 'scatter', 'bar']) +def test_repeated_run_fitness_and_gene_plots_use_generation_numbers(plot_type, monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_best_solutions=True) + ga.run() + ga.run() + expected = [0, 1, 2, 2, 3, 4] + for figure in [ga.plot_fitness(plot_type=plot_type), + ga.plot_genes(solutions='best', plot_type=plot_type)]: + axis = figure.axes[0] + if plot_type == 'plot': + actual = axis.lines[0].get_xdata() + elif plot_type == 'scatter': + actual = axis.collections[0].get_offsets()[:, 0] + else: + actual = [bar.get_x() + bar.get_width() / 2 for bar in axis.patches] + numpy.testing.assert_array_equal(actual, expected) + pyplot.close(figure) + + +def test_repeated_run_population_plots_and_new_solution_rate(monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_solutions=True) + ga.run() + ga.run() + for figure in [ga.plot_fitness_band(), ga.plot_population_diversity()]: + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 2, 3, 4]) + pyplot.close(figure) + figure = ga.plot_new_solution_rate() + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 3]) + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_ydata(), [2, 2, 1, 1]) + pyplot.close(figure) + + +def test_population_snapshot_sizes_survive_nsga3_growth_between_runs(): + ga = make_ga(save_solutions=True, fitness_func=lambda ga, solution, index: [int(solution[0])] * 3) + ga.run() + ga.parent_selection_type = 'nsga3' + ga.nsga3_num_divisions = 2 + # Change the configured operator consistently, as constructor validation does. + ga.select_parents = ga.nsga3_selection + ga.run() + assert ga._saved_population_sizes == [2, 2, 2, 6, 6, 6] + assert [len(population) for population in ga._per_generation_solutions()] == [2, 2, 2, 6, 6, 6] + assert [len(fitness) for fitness in ga._per_generation_fitness()] == [2, 2, 2, 6, 6, 6] + + +def test_repeated_multi_objective_history_does_not_overwrite_current_pareto_fronts(monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_solutions=True, save_best_solutions=True, + fitness_func=lambda ga, solution, index: [int(solution[0]), -int(solution[0])]) + ga.run() + ga.run() + assert 0 <= ga.best_solution_generation <= ga.generations_completed + assert len(ga.pareto_fronts[0]) == len(ga.population) + assert all(int(row[0]) < len(ga.population) for row in ga.pareto_fronts[0]) + figure = ga.plot_non_dominated_hypervolume(reference_point=[-100, -100]) + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 2, 3, 4]) + pyplot.close(figure) + figure = ga.plot_pareto_front_evolution(every_k=2) + assert figure.axes[0].get_legend_handles_labels()[1] == ['gen 0', 'gen 2', 'gen 4'] + pyplot.close(figure) + + +def test_report_after_repeated_runs_includes_history_plots(tmp_path): + pytest.importorskip('reportlab') + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.run() + ga.run() + filename = ga.generate_report(str(tmp_path / 'repeated_runs'), + include_plots=['plot_fitness', 'plot_genes', + 'plot_new_solution_rate', 'plot_fitness_band']) + assert (tmp_path / 'repeated_runs.pdf').stat().st_size > 1000 + assert filename.endswith('.pdf') + assert ga.best_solution_generation == 4 + + +def test_cache_keeps_large_integer_solution_keys_exact(): + large_integer = 2 ** 60 + ga = make_ga(initial_population=[[large_integer], [large_integer + 1]], + save_solutions=True) + ga.solutions = [[large_integer], [large_integer + 1]] + ga.solutions_fitness = [12, 13] + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [12, 13]) + assert ga.num_fitness_evaluations == 0 + + +def test_cache_observes_history_edits_made_by_callbacks(): + def edit_history(ga): + if ga.generations_completed == 1: + ga.solutions[0][0] = int(ga.population[0, 0]) + ga.solutions_fitness[0] = 999 + ga = make_ga(save_solutions=True, on_generation=edit_history, + crossover_type=None, mutation_type=None) + ga.run() + assert 999 in ga.last_generation_fitness + + +@pytest.mark.parametrize('fitness', [[[], []], [[[1]], [[2]]], [1, numpy.nan], + [[1], [1, 2]], ['bad', 'bad'], numpy.array(1)]) +def test_best_solution_validates_explicit_fitness(fitness): + ga = make_ga() + with pytest.raises(ValueError, match='pop_fitness'): + ga.best_solution(pop_fitness=fitness) + + +def test_adaptive_fitness_uses_the_population_objective_count(): + def fitness(ga, solution, index): + return [1, 2] if index is None else 1 + ga = make_ga(fitness_func=fitness, mutation_type='adaptive', + mutation_num_genes=[1, 1]) + ga.last_generation_fitness = ga.cal_pop_fitness() + ga.run_select_parents(call_on_parents=False) + with pytest.raises(ValueError, match='same fitness shape'): + ga.adaptive_mutation_population_fitness(numpy.ones((2, 1))) diff --git a/tests/test_nsga3_dtlz2.py b/tests/test_nsga3_dtlz2.py index e55dc67a..dca397a9 100644 --- a/tests/test_nsga3_dtlz2.py +++ b/tests/test_nsga3_dtlz2.py @@ -69,17 +69,16 @@ def _dtlz2_max_fitness(ga, solution, sol_idx): 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. + Polynomial mutation operator used in the Deb & Jain paper. Use the + GA's generator so random_seed controls this custom operator too. """ 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: + if ga_instance.numpy_random_generator.random() >= per_gene_probability: continue - u = numpy.random.random() + u = ga_instance.numpy_random_generator.random() if u < 0.5: delta = pow(2.0 * u, 1.0 / eta_plus_one) - 1.0 else: @@ -106,6 +105,24 @@ def _make_dtlz2_ga(): suppress_warnings=True) +def test_custom_mutation_uses_instance_seed_despite_global_random_draws(): + first = _make_dtlz2_ga() + second = _make_dtlz2_ga() + first.num_generations = second.num_generations = 5 + global_state = numpy.random.get_state() + try: + numpy.random.seed(1) + first.run() + numpy.random.seed(123) + numpy.random.random(1000) + second.run() + numpy.testing.assert_array_equal(first.population, second.population) + numpy.testing.assert_array_equal(first.last_generation_fitness, + second.last_generation_fitness) + finally: + numpy.random.set_state(global_state) + + def _evaluate_final_fitness(ga): return numpy.array([_dtlz2_max_fitness(ga, sol, idx) for idx, sol in enumerate(ga.population)], From a44c29e62cc3cb657941f307c464b8b66a1236c7 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Thu, 8 Oct 2026 23:47:31 -0400 Subject: [PATCH 08/22] Clarify release notes for fitness and repeated-run changes --- docs/source/pygad.md | 2 +- docs/source/releases.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/pygad.md b/docs/source/pygad.md index dcac65b7..1ff3d383 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -636,7 +636,7 @@ Constructor settings and user callables are stored as instance attributes, with - `solutions_fitness`: Fitness for every entry in `solutions`. - `solutions_generations`: One generation number per saved population when `save_solutions=True`. The solutions and their fitness retain one entry per solution. - `num_fitness_evaluations`: Number of solutions evaluated during the current `run()`, including adaptive offspring and every solution in returned fitness batches. Cache hits do not count. Each run resets the counter after `on_start`; direct fitness evaluations outside a run increment the existing count. `evaluations_` checks the count at generation boundaries, so the run can exceed the requested budget by a generation's work. -- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs). +- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See {ref}`Saved Fitness across Repeated Runs `. ##### Methods diff --git a/docs/source/releases.md b/docs/source/releases.md index 8c0f2fad..545c7dfd 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -753,10 +753,12 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. 23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. -24. Fitness histories record actual generation numbers across repeated `run()` calls while preserving all starting and final snapshots. `best_solution_generation` uses those numbers and the same ordering as `best_solution()`. Population history records each snapshot's size. Fitness plots, best-solution gene plots, population diagnostics, Pareto evolution, and PDF reports use this metadata. Checkpoints preserve it; older single-run checkpoints recover their generation numbers, while unavailable numbers in older repeated-run histories are marked as unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +24. The new `best_solutions_generations` and `solutions_generations` attributes record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. 25. `saturate_N` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. -26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots. Callback order and call counts are preserved. +26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. 27. Fitness validation is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. -28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches. Built-in evolution indexes newly saved snapshots incrementally, preserving cache precedence and the first matching entry. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. +28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. +29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. +30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. From 83a44a50e336baebc8fb2b268d2d4ff5d8572945 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 00:07:48 -0400 Subject: [PATCH 09/22] Show recent release notes first with expandable history --- docs/source/_static/custom.css | 77 ++ docs/source/_static/release-history.js | 134 +++ docs/source/conf.py | 2 +- docs/source/releases.md | 1109 ++++++++++++------------ 4 files changed, 768 insertions(+), 554 deletions(-) create mode 100644 docs/source/_static/release-history.js diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 7f356d08..37d9fc3f 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -41,3 +41,80 @@ details.sd-dropdown > summary.sd-summary-title code, details.sd-dropdown > .sd-summary-title code { font-weight: 600; } + +/* Release-history controls follow the theme in light and dark mode. */ +/* Keep the release-page header compact so the newest notes are in view. */ +#release-history > p > img[alt="PYGAD-LOGO"] { + max-width: 12rem; + height: auto; +} + +.release-history-navigation { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0; +} + +.release-history-navigation select { + max-width: 100%; + padding: 0.5rem; + border: 1px solid var(--color-brand-primary); + border-radius: 0.35rem; + background: var(--color-background-primary); + color: var(--color-foreground-primary); + font: inherit; +} + +.release-history-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 2rem 0; +} + +.release-history-controls p { + flex-basis: 100%; + margin: 0; + color: var(--color-foreground-secondary); +} + +.release-history-controls button { + padding: 0.65rem 1rem; + border: 1px solid var(--color-brand-primary); + border-radius: 0.35rem; + background: var(--color-background-primary); + color: var(--color-brand-content); + font: inherit; + cursor: pointer; +} + +.release-history-controls button:hover { + background: var(--color-background-secondary); +} + +.release-history-controls button:focus-visible, +.release-history-navigation select:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 3px; +} + +/* Some theme display rules otherwise override the native hidden attribute. */ +#release-history > section[hidden], +.toc-tree li[hidden], +.release-history-controls button[hidden] { + display: none; +} + +/* Printed and exported notes always include the complete release history. */ +@media print { + #release-history > section[hidden] { + display: block; + } + + .release-history-controls, + .release-history-navigation { + display: none; + } +} diff --git a/docs/source/_static/release-history.js b/docs/source/_static/release-history.js new file mode 100644 index 00000000..8d2559f3 --- /dev/null +++ b/docs/source/_static/release-history.js @@ -0,0 +1,134 @@ +// Show recent release notes first, with older notes available on the same page. +// All notes remain in the HTML for documentation search, printing, and readers +// without JavaScript. Existing release links reveal their target automatically. +window.addEventListener("DOMContentLoaded", function () { + var releaseHistory = document.getElementById("release-history"); + if (!releaseHistory) { + return; + } + + var releaseSections = Array.from(releaseHistory.children).filter(function (element) { + return element.tagName === "SECTION"; + }); + var releasesPerPage = 10; + if (releaseSections.length <= releasesPerPage) { + return; + } + + var visibleReleaseCount = releasesPerPage; + var contentsLinks = Array.from(document.querySelectorAll(".toc-tree a")); + var navigation = document.createElement("div"); + navigation.className = "release-history-navigation"; + var jumpLabel = document.createElement("label"); + jumpLabel.htmlFor = "release-history-version"; + jumpLabel.textContent = "Jump to a release"; + navigation.appendChild(jumpLabel); + var releaseSelector = document.createElement("select"); + releaseSelector.id = "release-history-version"; + var placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select a release"; + releaseSelector.appendChild(placeholder); + releaseSections.forEach(function (section) { + var option = document.createElement("option"); + option.value = section.id; + option.textContent = section.querySelector("h2").firstChild.textContent.trim(); + releaseSelector.appendChild(option); + }); + navigation.appendChild(releaseSelector); + releaseHistory.insertBefore(navigation, releaseSections[0]); + + var controls = document.createElement("div"); + controls.className = "release-history-controls"; + controls.setAttribute("role", "group"); + controls.setAttribute("aria-label", "Older release notes"); + + var status = document.createElement("p"); + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + controls.appendChild(status); + + var showMoreButton = document.createElement("button"); + showMoreButton.type = "button"; + showMoreButton.textContent = "Show 10 more releases"; + controls.appendChild(showMoreButton); + + var showAllButton = document.createElement("button"); + showAllButton.type = "button"; + showAllButton.textContent = "Show all releases"; + controls.appendChild(showAllButton); + releaseHistory.appendChild(controls); + + function updateVisibleReleases() { + releaseSections.forEach(function (section, index) { + section.hidden = index >= visibleReleaseCount; + }); + contentsLinks.forEach(function (link) { + var section = document.getElementById(link.hash.slice(1)); + var item = link.closest("li"); + if (section && releaseSections.indexOf(section) !== -1 && item) { + item.hidden = section.hidden; + } + }); + var remainingReleaseCount = releaseSections.length - visibleReleaseCount; + status.textContent = "Showing " + visibleReleaseCount + " of " + + releaseSections.length + " release entries."; + var nextReleaseCount = Math.min(releasesPerPage, remainingReleaseCount); + showMoreButton.textContent = "Show " + nextReleaseCount + " more " + + (nextReleaseCount === 1 ? "release" : "releases"); + showMoreButton.hidden = remainingReleaseCount === 0; + showAllButton.hidden = remainingReleaseCount === 0; + } + + function showOlderReleases(count) { + var firstNewSection = releaseSections[visibleReleaseCount]; + visibleReleaseCount = Math.min(count, releaseSections.length); + updateVisibleReleases(); + // Move keyboard focus to the newly revealed notes, rather than leaving + // it on a button that moved below them or disappeared after "Show all". + var heading = firstNewSection.querySelector("h2"); + heading.setAttribute("tabindex", "-1"); + heading.focus({ preventScroll: true }); + heading.scrollIntoView({ block: "start" }); + } + + function revealLinkedRelease() { + var targetId; + try { + targetId = decodeURIComponent(window.location.hash.slice(1)); + } catch (error) { + return; + } + var target = document.getElementById(targetId); + var releaseIndex = releaseSections.findIndex(function (section) { + return target && section.contains(target); + }); + if (releaseIndex === -1) { + return; + } + releaseSelector.value = releaseSections[releaseIndex].id; + if (releaseIndex >= visibleReleaseCount) { + visibleReleaseCount = Math.min(releaseSections.length, + Math.ceil((releaseIndex + 1) / releasesPerPage) * releasesPerPage); + updateVisibleReleases(); + } + target.scrollIntoView({ block: "start" }); + } + + showMoreButton.addEventListener("click", function () { + showOlderReleases(visibleReleaseCount + releasesPerPage); + }); + showAllButton.addEventListener("click", function () { + showOlderReleases(releaseSections.length); + }); + releaseSelector.addEventListener("change", function () { + if (releaseSelector.value) { + var releaseHash = "#" + releaseSelector.value; + window.location.hash = releaseHash; + revealLinkedRelease(); + } + }); + window.addEventListener("hashchange", revealLinkedRelease); + updateVisibleReleases(); + revealLinkedRelease(); +}); diff --git a/docs/source/conf.py b/docs/source/conf.py index ebf4c2da..f34a04cb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -59,7 +59,7 @@ html_title = 'PyGAD' html_static_path = ['_static'] html_css_files = ['custom.css'] -html_js_files = ['scroll-sidebar.js'] +html_js_files = ['scroll-sidebar.js', 'release-history.js'] html_theme_options = { 'light_css_variables': { diff --git a/docs/source/releases.md b/docs/source/releases.md index 545c7dfd..1b72b39a 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -2,261 +2,494 @@ ![PYGAD-LOGO](images/101267295-c74c0180-375f-11eb-9ad0-f8e37bd796ce.png) -## PyGAD 1.0.17 +Release notes are listed from newest to oldest. Unreleased contains changes planned for a future release. -Release Date: 15 April 2020 +## Unreleased -1. The **pygad.GA** class accepts a new argument named `fitness_func` which accepts a function to be used for calculating the fitness values for the solutions. This allows the project to be customized to any problem by building the right fitness function. +These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. -## PyGAD 1.0.20 +1. Two-point crossover selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). +2. Swap mutation can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). +3. SBX crossover selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). +4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). +5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. +6. Parallel fitness evaluation now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. +7. Serial, thread, and process modes use the same fitness-cache rules and result validation. Adaptive mutation evaluates the actual offspring, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). +8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). +9. `pygad.kerasga.predict()` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). +10. Stochastic universal selection uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). +11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). +12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). +13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. -Release Date: 4 May 2020 +15. Duplicate-gene repair now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. +16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. +17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. -1. The **pygad.GA** attributes are moved from the class scope to the instance scope. -2. Raising an exception for incorrect values of the passed parameters. -3. Two new parameters are added to the **pygad.GA** class constructor (`init_range_low` and `init_range_high`) allowing the user to customize the range from which the genes values in the initial population are selected. -4. The code object `__code__` of the passed fitness function is checked to ensure it has the right number of parameters. +18. Initial population creation and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. -## PyGAD 2.0.0 +19. Gene-type validation and conversion share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. -Release Date: 13 May 2020 +20. Constructor validation shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. Stop criteria share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. +21. Only the active mutation control is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. +22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. +23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. -1. The fitness function accepts a new argument named `sol_idx` representing the index of the solution within the population. -2. A new parameter to the **pygad.GA** class constructor named `initial_population` is supported to allow the user to use a custom initial population to be used by the genetic algorithm. If not None, then the passed population will be used. If `None`, then the genetic algorithm will create the initial population using the `sol_per_pop` and `num_genes` parameters. -3. The parameters `sol_per_pop` and `num_genes` are optional and set to `None` by default. -4. A new parameter named `callback_generation` is introduced in the **pygad.GA** class constructor. It accepts a function with a single parameter representing the **pygad.GA** class instance. This function is called after each generation. This helps the user to do post-processing or debugging operations after each generation. +24. The new `best_solutions_generations` and `solutions_generations` attributes record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +25. `saturate_N` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. +26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. +27. Fitness validation is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. +28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. +29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. +30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. -## PyGAD 2.1.0 +31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. -Release Date: 14 May 2020 +The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. -1. The `best_solution()` method in the **pygad.GA** class returns a new output representing the index of the best solution within the population. Now, it returns a total of 3 outputs and their order is: best solution, best solution fitness, and best solution index. Here is an example: -```python -solution, solution_fitness, solution_idx = ga_instance.best_solution() -print("Parameters of the best solution :", solution) -print("Fitness value of the best solution :", solution_fitness, "\n") -print("Index of the best solution :", solution_idx, "\n") -``` +## PyGAD 3.7.0 -2. A new attribute named `best_solution_generation` is added to the instances of the **pygad.GA** class. it holds the generation number at which the best solution is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. -Example: -```python -print("Best solution reached after {best_solution_generation} generations.".format(best_solution_generation=ga_instance.best_solution_generation)) +Release Date June 5, 2026 + +Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). + +```{raw} html + ``` -3. The `best_solution_fitness` attribute is renamed to `best_solutions_fitness` (plural solution). -4. Mutation is applied independently for the genes. +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: + 1. `nn` + 2. `cnn` + 3. `gacnn` + 4. `kerasga` + 5. `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. +35. Version changed in the following modules: + 1. A new submodule `pygad.benchmarks` is added with the version `1.0.0`. + 2. The version of the `pygad.utils` submodule is upgraded from `1.4.0` to `1.5.0`. + 3. The version of the `pygad.helper` submodule is upgraded from `1.3.0` to `1.4.0`. + 4. The version of the `pygad.visualize` submodule is upgraded from `1.1.1` to `1.2.0`. + 5. The version of the `pygad.nn` submodule is upgraded from `1.2.2` to `1.2.3`. + 6. The version of the `pygad.cnn` submodule is upgraded from `1.1.1` to `1.1.2`. + 7. The version of the `pygad.kerasga` submodule is upgraded from `1.3.1` to `1.3.2`. + 8. The version of the `pygad.torchga` submodule is upgraded from `1.4.1` to `1.4.2`. + 9. The version of the `pygad.gann` submodule is upgraded from `1.0.0` to `1.0.1`. + 10. The version of the `pygad.gacnn` submodule is upgraded from `1.0.0` to `1.0.1`. +36. The PDF report built by `generate_report()` now shows the PyGAD logo on its title page. The logo image ships with the package, so no network access is needed. If the image file is missing, the report is built without it. +37. Two private helper functions are added to the `pygad/utils/report.py` script for the logo. `_pdf_report_read_logo_bytes()` reads the bundled logo file and returns its bytes, or `None` if the file is missing. `_pdf_report_build_logo_image()` builds the image that is placed on the title page, or returns `None` so the report still builds without the logo. +38. The private helper functions in the `pygad/utils/report.py` script are renamed to start with the `_pdf_report_` prefix so their purpose is clear from the name. For example, `_build_title_section()` becomes `_pdf_report_build_title_section()` and `_render_plot_to_png()` becomes `_pdf_report_render_plot_to_png()`. -## PyGAD 2.2.1 +## PyGAD 3.6.0 -Release Date: 17 May 2020 +Release Date April 8, 2026 -1. Adding 2 extra modules (pygad.nn and pygad.gann) for building and training neural networks with the genetic algorithm. +1. Support passing a class to the fitness, crossover, and mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/342 +2. A new class called `Validation` is created in the new `pygad/utils/validation.py` script. It has a method called `validate_parameters()` to validate all the parameters passed while instantiating the `pygad.GA` class. +3. Refactoring the `pygad.py` script by moving a lot of functions and methods to other classes in other scripts. + 4. The `summary()` method was moved to `Helper` class in the `pygad/helper/misc.py` script. + 5. The validation code in the `__init__()` method of the `pygad.GA` class is moved to the new `validate_parameters()` method in the new `Validation` class in the new `pygad/utils/validation.py` script. Moreover, the `validate_multi_stop_criteria()` method is also moved to the same class. + 6. The GA main workflow is moved into the new `GAEngine` class in the new `pygad/utils/engine.py` script. Specifically, these methods are moved from the `pygad.GA` class to the new `GAEngine` class: + 1. `run()` + 1. `run_loop_head()` + 2. `run_select_parents()` + 3. `run_crossover()` + 4. `run_mutation()` + 5. `run_update_population()` + 2. `initialize_population()` + 3. `cal_pop_fitness()` + 4. `best_solution()` + 5. `round_genes()` +7. The `pygad.GA` class now extends the two new classes `utils.validation.Validation` and `utils.engine.GAEngine`. +8. The version of the `pygad.utils` submodule is upgraded from `1.3.0` to `1.4.0`. +9. The version of the `pygad.helper` submodule is upgraded from `1.2.0` to `1.3.0`. +10. The version of the `pygad.visualize` submodule is upgraded from `1.1.0` to `1.1.1`. +11. The version of the `pygad.nn` submodule is upgraded from `1.2.1` to `1.2.2`. +12. The version of the `pygad.cnn` submodule is upgraded from `1.1.0` to `1.1.1`. +13. The version of the `pygad.torchga` submodule is upgraded from `1.4.0` to `1.4.1`. +14. The version of the `pygad.kerasga` submodule is upgraded from `1.3.0` to `1.3.1`. +15. Update the elitism after the evolution ends to fix issue where the best solution returned by the `best_solution()` method is not correct. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/337 +16. Fix a bug in calling the `numpy.reshape()` function. The parameter `newshape` is removed since it is no longer supported started from NumPy `2.4.0`. https://numpy.org/doc/stable/release/2.4.0-notes.html#removed-newshape-parameter-from-numpy-reshape +17. A minor change in the documentation is made to replace the `newshape` parameter when calling `numpy.reshape()`. +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`. +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 2.2.2 +## PyGAD 3.5.0 -Release Date: 18 May 2020 -1. The initial value of the `generations_completed` attribute of instances from the pygad.GA class is `0` rather than `None`. +Release Date 08 July 2025 -2. An optional bool parameter named `mutation_by_replacement` is added to the constructor of the pygad.GA class. 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. This parameter should be used when the gene falls within a fixed range and its value must not go out of this range. Here are some examples: +1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/323 +2. Fix a bug when the `stop_criteria` parameter is passed as an iterable (e.g. list) for multi-objective problems (e.g. `['reach_50_60', 'reach_20, 40']`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 +3. Call the `get_matplotlib()` function from the `plot_genes()` method inside the `pygad.visualize.plot.Plot` class to import the matplotlib library. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/315 +4. Create a new helper method called `select_unique_value()` inside the `pygad/helper/unique.py` script to select a unique gene from an array of values. +5. Create a new helper method called `get_random_mutation_range()` inside the `pygad/utils/mutation.py` script that returns the random mutation range (min and max) for a single gene by its index. +6. Create a new helper method called `change_random_mutation_value_dtype` inside the `pygad/utils/mutation.py` script that changes the data type of the value used to apply random mutation. +7. Create a new helper method called `round_random_mutation_value()` inside the `pygad/utils/mutation.py` script that rounds the value used to apply random mutation. +8. Create the `pygad/helper/misc.py` script with a class called `Helper` that has the following helper methods: + 1. `change_population_dtype_and_round()`: For each gene in the population, round the gene value and change the data type. + 2. `change_gene_dtype_and_round()`: Round the change the data type of a single gene. + 3. `mutation_change_gene_dtype_and_round()`: Decides whether mutation is done by replacement or not. Then it rounds and change the data type of the new gene value. + 4. `validate_gene_constraint_callable_output()`: Validates the output of the user-defined callable/function that checks whether the gene constraint defined in the `gene_constraint` parameter is satisfied or not. + 5. `get_gene_dtype()`: Returns the gene data type from the `gene_type` instance attribute. + 6. `get_random_mutation_range()`: Returns the random mutation range using the `random_mutation_min_val` and `random_mutation_min_val` instance attributes. + 7. `get_initial_population_range()`: Returns the initial population values range using the `init_range_low` and `init_range_high` instance attributes. + 8. `generate_gene_value_from_space()`: Generates/selects a value for a gene using the `gene_space` instance attribute. + 9. `generate_gene_value_randomly()`: Generates a random value for the gene. Only used if `gene_space` is `None`. + 10. `generate_gene_value()`: Generates a value for the gene. It checks whether `gene_space` is `None` and calls either `generate_gene_value_randomly()` or `generate_gene_value_from_space()`. + 11. `filter_gene_values_by_constraint()`: Receives a list of values for a gene. Then it filters such values using the gene constraint. + 12. `get_valid_gene_constraint_values()`: Selects one valid gene value that satisfy the gene constraint. It simply calls `generate_gene_value()` to generate some gene values then it filters such values using `filter_gene_values_by_constraint()`. +9. Create a new helper method called `mutation_process_random_value()` inside the `pygad/utils/mutation.py` script that generates constrained random values for mutation. It calls either `generate_gene_value()` or `get_valid_gene_constraint_values()` based on whether the `gene_constraint` parameter is used or not. +10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/119 +11. A new parameter called `sample_size` is added. To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either `allow_duplicate_genes` or `gene_constraint` is used. An instance attribute of the same name is created in the instances of the `pygad.GA` class. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. +12. Use the `sample_size` parameter instead of `num_trials` in the methods `solve_duplicate_genes_randomly()` and `unique_float_gene_from_range()` inside the `pygad/helper/unique.py` script. It is the maximum number of values to generate as the search space when looking for a unique float value out of a range. +13. Fixed a bug in population initialization when `allow_duplicate_genes=False`. Previously, gene values were checked for duplicates before rounding, which could allow near-duplicates like 7.61 and 7.62 to pass. After rounding (e.g., both becoming 7.6), this resulted in unintended duplicates. The fix ensures gene values are now rounded before duplicate checks, preventing such cases. +14. More tests are created. +15. More examples are created. +16. Edited the `sort_solutions_nsga2()` method in the `pygad/utils/nsga2.py` script to accept an optional parameter called `find_best_solution` when calling this method just to find the best solution. +17. Fixed a bug while applying the non-dominated sorting in the `get_non_dominated_set()` method inside the `pygad/utils/nsga2.py` script. It was swapping the non-dominated and dominated sets. In other words, it used the non-dominated set as if it is the dominated set and vice versa. All the calls to this method were edited accordingly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/320. +18. Fix a bug retrieving in the `best_solution()` method when retrieving the best solution for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/331 - Assume there is a gene with the value 0.5. +## PyGAD 3.4.0 - If `mutation_type="random"` and `mutation_by_replacement=False`, then the generated random value (e.g. 0.1) will be added to the gene value. The new gene value is **0.5+0.1=0.6**. +Release Date 07 January 2025 - If `mutation_type="random"` and `mutation_by_replacement=True`, then the generated random value (e.g. 0.1) will replace the gene value. The new gene value is **0.1**. +1. The `delay_after_gen` parameter is removed from the `pygad.GA` class constructor. As a result, it is no longer an attribute of the `pygad.GA` class instances. To add a delay after each generation, apply it inside the `on_generation` callback. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/283 +2. In the `single_point_crossover()` method of the `pygad.utils.crossover.Crossover` class, all the random crossover points are returned before the `for` loop. This is by calling the `numpy.random.randint()` function only once before the loop to generate all the K points (where K is the offspring size). This is compared to calling the `numpy.random.randint()` function inside the `for` loop K times, once for each individual offspring. +3. Bug fix in the `examples/example_custom_operators.py` script. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/285 +4. While making prediction using the `pygad.torchga.predict()` function, no gradients are calculated. +5. The `gene_type` parameter of the `pygad.helper.unique.Unique.unique_int_gene_from_range()` method accepts the type of the current gene only instead of the full gene_type list. +6. Created a new method called `unique_float_gene_from_range()` inside the `pygad.helper.unique.Unique` class to find a unique floating-point number from a range. +7. Fix a bug in the `pygad.helper.unique.Unique.unique_gene_by_space()` method to return the numeric value only instead of a NumPy array. +8. Refactoring the `pygad/helper/unique.py` script to remove duplicate codes and reformatting the docstrings. +9. The `plot_pareto_front_curve()` method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 +11. Fix a bug converting a nested NumPy array to a nested list. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/300 +12. The `Matplotlib` library is only imported when a method inside the `pygad/visualize/plot.py` script is used. This is more efficient than using `import matplotlib.pyplot` at the module level as this causes it to be imported when `pygad` is imported even when it is not needed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/292 +13. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter (e.g. `stop_criteria=["saturate_10", "reach_-0.5"]`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/296 +14. Make sure `self.best_solutions` is a list of lists inside the `cal_pop_fitness` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/293 +15. Fix a bug where the `cal_pop_fitness()` method was using the `previous_generation_fitness` attribute to return the parents fitness. This instance attribute was not using the fitness of the latest population, instead the fitness of the population before the last one. The issue is solved by updating the `previous_generation_fitness` attribute to the latest population fitness before the GA completes. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/291 -3. `None` value could be assigned to the `mutation_type` and `crossover_type` parameters of the pygad.GA class constructor. When `None`, this means the step is bypassed and has no action. +## PyGAD 3.3.1 -## PyGAD 2.3.0 +Release Date 17 February 2024 -Release date: 1 June 2020 +1. After the last generation and before the `run()` method completes, update the 2 instance attributes: 1) `last_generation_parents` 2) `last_generation_parents_indices`. This is to keep the list of parents up-to-date with the latest population fitness `last_generation_fitness`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/275 +2. 5 methods with names starting with `run_`. Their purpose is to keep the main loop inside the `run()` method clean. Check the [Other Methods](https://pygad.readthedocs.io/en/latest/pygad.html#other-methods) section for more information. + 1. `run_loop_head()`: The code before the loop starts. + 2. `run_select_parents()`: The parent selection-related code. + 3. `run_crossover()`: The crossover-related code. + 4. `run_mutation()`: The mutation-related code. + 5. `run_update_population()`: Update the `population` instance attribute after completing the processes of crossover and mutation. -1. A new module named `pygad.cnn` is supported for building convolutional neural networks. -2. A new module named `pygad.gacnn` is supported for training convolutional neural networks using the genetic algorithm. -3. The `pygad.plot_result()` method has 3 optional parameters named `title`, `xlabel`, and `ylabel` to customize the plot title, x-axis label, and y-axis label, respectively. -4. The `pygad.nn` module supports the softmax activation function. -5. The name of the `pygad.nn.predict_outputs()` function is changed to `pygad.nn.predict()`. -6. The name of the `pygad.nn.train_network()` function is changed to `pygad.nn.train()`. +## PyGAD 3.3.0 -## PyGAD 2.4.0 +Release Date 29 January 2024 -Release date: 5 July 2020 +1. Solve bugs when multi-objective optimization is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 +2. When the `stop_ciiteria` parameter is used with the `reach` keyword, then multiple numeric values can be passed when solving a multi-objective problem. For example, if a problem has 3 objective functions, then `stop_criteria="reach_10_20_30"` means the GA stops if the fitness of the 3 objectives are at least 10, 20, and 30, respectively. The number values must match the number of objective functions. If a single value found (e.g. `stop_criteria=reach_5`) when solving a multi-objective problem, then it is used across all the objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 +3. The `delay_after_gen` parameter is now deprecated and will be removed in a future release. If it is necessary to have a time delay after each generation, then assign a callback function/method to the `on_generation` parameter to pause the evolution. +4. Parallel processing now supports calculating the fitness during adaptive mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201 +5. The population size can be changed during runtime by changing all the parameters that would affect the size of any thing used by the GA. For more information, check the [Change Population Size during Runtime](https://pygad.readthedocs.io/en/latest/generations.html#change-population-size-during-runtime) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/234 +6. When a dictionary exists in the `gene_space` parameter without a step, then mutation occurs by adding a random value to the gene value. The random vaue is generated based on the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. For more information, check the [How Mutation Works with the gene_space Parameter?](https://pygad.readthedocs.io/en/latest/gene_values.html#how-mutation-works-with-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/229 +7. Add `object` as a supported data type for int (GA.supported_int_types) and float (GA.supported_float_types). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/174 +8. Use the `raise` clause instead of the `sys.exit(-1)` to terminate the execution. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/213 +9. Fix a bug when multi-objective optimization is used with batch fitness calculation (e.g. `fitness_batch_size` set to a non-zero number). +10. Fix a bug in the `pygad.py` script when finding the index of the best solution. It does not work properly with multi-objective optimization where `self.best_solutions_fitness` have multiple columns. -1. A new parameter named `delay_after_gen` is added which accepts a non-negative number specifying the time in seconds to wait after a generation completes and before going to the next generation. It defaults to `0.0` which means no delay after the generation. + ```python + self.best_solution_generation = numpy.where(numpy.array( + self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + ``` -2. The passed function to the `callback_generation` parameter of the pygad.GA class constructor can terminate the execution of the genetic algorithm if it returns the string `stop`. This causes the `run()` method to stop. +## PyGAD 3.2.0 -One important use case for that feature is to stop the genetic algorithm when a condition is met before passing though all the generations. The user may assigned a value of 100 to the `num_generations` parameter of the pygad.GA class constructor. Assuming that at generation 50, for example, a condition is met and the user wants to stop the execution before waiting the remaining 50 generations. To do that, just make the function passed to the `callback_generation` parameter to return the string `stop`. +Release Date 7 September 2023 -Here is an example of a function to be passed to the `callback_generation` parameter which stops the execution if the fitness value 70 is reached. The value 70 might be the best possible fitness value. After being reached, then there is no need to pass through more generations because no further improvement is possible. +1. A new module `pygad.utils.nsga2` is created that has the `NSGA2` class that includes the functionalities of NSGA-II. The class has these methods: 1) `get_non_dominated_set()` 2) `non_dominated_sorting()` 3) `crowding_distance()` 4) `sort_solutions_nsga2()`. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. +2. Support of multi-objective optimization using Non-Dominated Sorting Genetic Algorithm II (NSGA-II) using the `NSGA2` class in the `pygad.utils.nsga2` module. Just return a `list`, `tuple`, or `numpy.ndarray` from the fitness function and the library will consider the problem as multi-objective optimization. All the objectives are expected to be maximization. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. +3. The parent selection methods and adaptive mutation are edited to support multi-objective optimization. +4. Two new NSGA-II parent selection methods are supported in the `pygad.utils.parent_selection` module: 1) Tournament selection for NSGA-II 2) NSGA-II selection. +5. The `plot_fitness()` method in the `pygad.plot` module has a new optional parameter named `label` to accept the label of the plots. This is only used for multi-objective problems. Otherwise, it is ignored. It defaults to `None` and accepts a `list`, `tuple`, or `numpy.ndarray`. The labels are used in a legend inside the plot. +6. The default color in the methods of the `pygad.plot` module is changed to the greenish `#64f20c` color. +7. A new instance attribute named `pareto_fronts` added to the `pygad.GA` instances that holds the pareto fronts when solving a multi-objective problem. +8. The `gene_type` accepts a `list`, `tuple`, or `numpy.ndarray` for integer data types given that the precision is set to `None` (e.g. `gene_type=[float, [int, None]]`). +9. In the `cal_pop_fitness()` method, the fitness value is re-used if `save_best_solutions=True` and the solution is found in the `best_solutions` attribute. These parameters also can help re-using the fitness of a solution instead of calling the fitness function: `keep_elitism`, `keep_parents`, and `save_solutions`. +10. The value `99999999999` is replaced by `float('inf')` in the 2 methods `wheel_cumulative_probs()` and `stochastic_universal_selection()` inside the `pygad.utils.parent_selection.ParentSelection` class. +11. The `plot_result()` method in the `pygad.visualize.plot.Plot` class is removed. Instead, please use the `plot_fitness()` if you did not upgrade yet. + +## PyGAD 3.1.0 + +Release Date 20 June 2023 + +1. Fix a bug when the initial population has duplciate genes if a nested gene space is used. +2. The `gene_space` parameter can no longer be assigned a tuple. +3. Fix a bug when the `gene_space` parameter has a member of type `tuple`. +4. A new instance attribute called `gene_space_unpacked` which has the unpacked `gene_space`. It is used to solve duplicates. For infinite ranges in the `gene_space`, they are unpacked to a limited number of values (e.g. 100). +5. Bug fixes when creating the initial population using `gene_space` attribute. +6. When a `dict` is used with the `gene_space` attribute, the new gene value was calculated by summing 2 values: 1) the value sampled from the `dict` 2) a random value returned from the random mutation range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This might cause the gene value to exceed the range limit defined in the `gene_space`. To respect the `gene_space` range, this release only returns the value from the `dict` without summing it to a random value. +7. Formatting the strings using f-string instead of the `format()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 +8. In the `__init__()` of the `pygad.GA` class, the logged error messages are handled using a `try-except` block instead of repeating the `logger.error()` command. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 +9. A new class named `CustomLogger` is created in the `pygad.cnn` module to create a default logger using the `logging` module assigned to the `logger` attribute. This class is extended in all other classes in the module. The constructors of these classes have a new parameter named `logger` which defaults to `None`. If no logger is passed, then the default logger in the `CustomLogger` class is used. +10. Except for the `pygad.nn` module, the `print()` function in all other modules are replaced by the `logging` module to log messages. +11. The callback functions/methods `on_fitness()`, `on_parents()`, `on_crossover()`, and `on_mutation()` can return values. These returned values override the corresponding properties. The output of `on_fitness()` overrides the population fitness. The `on_parents()` function/method must return 2 values representing the parents and their indices. The output of `on_crossover()` overrides the crossover offspring. The output of `on_mutation()` overrides the mutation offspring. +12. Fix a bug when adaptive mutation is used while `fitness_batch_size`>1. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195 +13. When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happen that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene by another gene. This release tries to solve such duplicates by looking for a third gene that will help in solving the duplicates. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) for more information. +14. Use probabilities to select parents using the rank parent selection method. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/205 +15. The 2 parameters `random_mutation_min_val` and `random_mutation_max_val` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the mutation range for each individual gene. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 +16. The 2 parameters `init_range_low` and `init_range_high` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the initial range for each individual gene when creating the initial population. +17. The `data` parameter in the `predict()` function of the `pygad.kerasga` module can be assigned a data generator. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/115 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 +18. The `predict()` function of the `pygad.kerasga` module accepts 3 optional parameters: 1) `batch_size=None`, `verbose=0`, and `steps=None`. Check documentation of the [Keras Model.predict()](https://keras.io/api/models/model_training_apis) method for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 +19. The documentation is updated to explain how mutation works when `gene_space` is used with `int` or `float` data types. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter). https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 + +## PyGAD 3.0.1 + +Release Date 20 April 2023 - ```python - def func_generation(ga_instance): - if ga_instance.best_solution()[1] >= 70: - return "stop" - ``` +1. Fix an issue with passing user-defined function/method for parent selection. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/179 -## PyGAD 2.5.0 +## PyGAD 3.0.0 -Release date: 19 July 2020 +Release Date 8 April 2023 -1. 2 new optional parameters added to the constructor of the `pygad.GA` class which are `crossover_probability` and `mutation_probability`. - While applying the crossover operation, each parent has a random value generated between 0.0 and 1.0. If this random value is less than or equal to the value assigned to the `crossover_probability` parameter, then the parent is selected for the crossover operation. - For the mutation operation, a random value between 0.0 and 1.0 is generated for each gene in the solution. If this value is less than or equal to the value assigned to the `mutation_probability`, then this gene is selected for mutation. -2. A new optional parameter named `linewidth` is added to the `plot_result()` method to specify the width of the curve in the plot. It defaults to 3.0. -3. Previously, the indices of the genes selected for mutation was randomly generated once for all solutions within the generation. Currently, the genes' indices are randomly generated for each solution in the population. If the population has 4 solutions, the indices are randomly generated 4 times inside the single generation, 1 time for each solution. -4. Previously, the position of the point(s) for the single-point and two-points crossover was(were) randomly selected once for all solutions within the generation. Currently, the position(s) is(are) randomly selected for each solution in the population. If the population has 4 solutions, the position(s) is(are) randomly generated 4 times inside the single generation, 1 time for each solution. -5. A new optional parameter named `gene_space` as added to the `pygad.GA` class constructor. It is used to specify the possible values for each gene in case the user wants to restrict the gene values. It is useful if the gene space is restricted to a certain range or to discrete values. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for requesting this useful feature. +1. The structure of the library is changed and some methods defined in the `pygad.py` module are moved to the `pygad.utils`, `pygad.helper`, and `pygad.visualize` submodules. + 2. The `pygad.utils.parent_selection` module has a class named `ParentSelection` where all the parent selection operators exist. The `pygad.GA` class extends this class. + 3. The `pygad.utils.crossover` module has a class named `Crossover` where all the crossover operators exist. The `pygad.GA` class extends this class. + 4. The `pygad.utils.mutation` module has a class named `Mutation` where all the mutation operators exist. The `pygad.GA` class extends this class. + 5. The `pygad.helper.unique` module has a class named `Unique` some helper methods exist to solve duplicate genes and make sure every gene is unique. The `pygad.GA` class extends this class. + 6. The `pygad.visualize.plot` module has a class named `Plot` where all the methods that create plots exist. The `pygad.GA` class extends this class. + 7. Support of using the `logging` module to log the outputs to both the console and text file instead of using the `print()` function. This is by assigning the `logging.Logger` to the new `logger` parameter. Check the [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. + 8. A new instance attribute called `logger` to save the logger. + 9. The function/method passed to the `fitness_func` parameter accepts a new parameter that refers to the instance of the `pygad.GA` class. Check this for an example: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/163 + 10. Update the documentation to include an example of using functions and methods to calculate the fitness and build callbacks. Check this for more details: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92#issuecomment-1443635003 + 11. Validate the value passed to the `initial_population` parameter. + 12. Validate the type and length of the `pop_fitness` parameter of the `best_solution()` method. + 13. Some edits in the documentation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/106 + 14. Fix an issue when building the initial population as (some) genes have their value taken from the mutation range (defined by the parameters `random_mutation_min_val` and `random_mutation_max_val`) instead of using the parameters `init_range_low` and `init_range_high`. + 15. The `summary()` method returns the summary as a single-line string. Just log/print the returned string it to see it properly. + 16. The `callback_generation` parameter is removed. Use the `on_generation` parameter instead. + 17. There was an issue when using the `parallel_processing` parameter with Keras and PyTorch. As Keras/PyTorch are not thread-safe, the `predict()` method gives incorrect and weird results when more than 1 thread is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/145 https://github.com/ahmedfgad/TorchGA/issues/5 https://github.com/ahmedfgad/KerasGA/issues/6. Thanks to this [StackOverflow answer](https://stackoverflow.com/a/75606666/5426539). + 18. Replace `numpy.float` by `float` in the 2 parent selection operators roulette wheel and stochastic universal. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/168 -## PyGAD 2.6.0 +## PyGAD 2.19.2 -Release Date: 6 August 2020 +Release Date 23 February 2023 -1. A bug fix in assigning the value to the `initial_population` parameter. -2. A new parameter named `gene_type` is added to control the gene type. It can be either `int` or `float`. It has an effect only when the parameter `gene_space` is `None`. -3. 7 new parameters that accept callback functions: `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. +1. Fix an issue when parallel processing was used where the elitism solutions' fitness values are not re-used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160#issuecomment-1441718184 -## PyGAD 2.7.0 +## PyGAD 2.19.1 -Release Date: 11 September 2020 -1. The `learning_rate` parameter in the `pygad.nn.train()` function defaults to **0.01**. -2. Added support of building neural networks for regression using the new parameter named `problem_type`. It is added as a parameter to both `pygad.nn.train()` and `pygad.nn.predict()` functions. The value of this parameter can be either **classification** or **regression** to define the problem type. It defaults to **classification**. -3. The activation function for a layer can be set to the string `"None"` to refer that there is no activation function at this layer. As a result, the supported values for the activation function are `"sigmoid"`, `"relu"`, `"softmax"`, and `"None"`. +Release Date: 22 February 2023 -To build a regression network using the `pygad.nn` module, just do the following: -1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. -2. Set the activation function for the output layer to the string `"None"`. This sets no limits on the range of the outputs as it will be from `-infinity` to `+infinity`. If you are sure that all outputs will be nonnegative values, then use the ReLU function. +1. Add the [cloudpickle](https://github.com/cloudpipe/cloudpickle) library as a dependency. -Check the documentation of the `pygad.nn` module for an example that builds a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NumPyANN): https://github.com/ahmedfgad/NumPyANN +## PyGAD 2.19.0 -To build and train a regression network using the `pygad.gann` module, do the following: +Release Date: 22 February 2023 +1. A new `summary()` method is supported to return a Keras-like summary of the PyGAD lifecycle. +2. A new optional parameter called `fitness_batch_size` is supported to calculate the fitness in batches. If it is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136. +3. The `cloudpickle` library (https://github.com/cloudpipe/cloudpickle) is used instead of the `pickle` library to pickle the `pygad.GA` objects. This solves the issue of having to redefine the functions (e.g. fitness function). The `cloudpickle` library is added as a dependency in the `requirements.txt` file. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/159 +4. Support of assigning methods to these parameters: `fitness_func`, `crossover_type`, `mutation_type`, `parent_selection_type`, `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +5. Validating the output of the parent selection, crossover, and mutation functions. +6. The built-in parent selection operators return the parent's indices as a NumPy array. +7. The outputs of the parent selection, crossover, and mutation operators must be NumPy arrays. +8. Fix an issue when `allow_duplicate_genes=True`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 +9. Fix an issue creating scatter plots of the solutions' fitness. +10. Sampling from a `set()` is no longer supported in Python 3.11. Instead, sampling happens from a `list()`. Thanks `Marco Brenna` for pointing to this issue. +11. The lifecycle is updated to reflect that the new population's fitness is calculated at the end of the lifecycle not at the beginning. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154#issuecomment-1438739483 +12. There was an issue when `save_solutions=True` that causes the fitness function to be called for solutions already explored and have their fitness pre-calculated. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160 +13. A new instance attribute named `last_generation_elitism_indices` added to hold the indices of the selected elitism. This attribute helps to re-use the fitness of the elitism instead of calling the fitness function. +14. Fewer calls to the `best_solution()` method which in turns saves some calls to the fitness function. +15. Some updates in the documentation to give more details about the `cal_pop_fitness()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/79#issuecomment-1439605442 -1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. -2. Set the `output_activation` parameter in the constructor of the `pygad.gann.GANN` class to `"None"`. +## PyGAD 2.18.3 -Check the documentation of the `pygad.gann` module for an example that builds and trains a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NeuralGenetic): https://github.com/ahmedfgad/NeuralGenetic +Release Date: 14 February 2023 -To build a classification network, either ignore the `problem_type` parameter or set it to `"classification"` (default value). In this case, the activation function of the last layer can be set to any type (e.g. softmax). +1. Bug fixes. -## PyGAD 2.7.1 +## PyGAD 2.18.2 +Release Date: 14 February 2023 -Release Date: 11 September 2020 +1. Remove `numpy.int` and `numpy.float` from the list of supported data types. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/151 https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/152 +2. Call the `on_crossover()` callback function even if `crossover_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +3. Call the `on_mutation()` callback function even if `mutation_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -1. A bug fix when the `problem_type` argument is set to `regression`. +## PyGAD 2.18.1 -## PyGAD 2.7.2 +Release Date: 19 September 2022 -Release Date: 14 September 2020 +1. A big fix when `keep_elitism` is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/132 -1. Bug fix to support building and training regression neural networks with multiple outputs. +## PyGAD 2.18.0 +Release Date: 9 September 2022 -## PyGAD 2.8.0 +1. Raise an exception if the sum of fitness values is zero while either roulette wheel or stochastic universal parent selection is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/129 +2. Initialize the value of the `run_completed` property to `False`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/122 +3. The values of these properties are no longer reset with each call to the `run()` method `self.best_solutions, self.best_solutions_fitness, self.solutions, self.solutions_fitness`: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123. Now, the user can have the flexibility of calling the `run()` method more than once while extending the data collected after each generation. Another advantage happens when the instance is loaded and the `run()` method is called, as the old fitness value are shown on the graph alongside with the new fitness values. Read more in this section: [Continue without Losing Progress](https://pygad.readthedocs.io/en/latest/generations.html#continue-without-losing-progress) +4. Thanks [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan) (Dept. of Information and Communications Engineering, University of Murcia, Murcia, Spain) for editing this [comment](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/5315bbec02777df96ce1ec665c94dece81c440f4/pygad.py#L73) in the code. https://github.com/ahmedfgad/GeneticAlgorithmPython/commit/5315bbec02777df96ce1ec665c94dece81c440f4 +5. A bug fixed when `crossover_type=None`. +6. Support of elitism selection through a new parameter named `keep_elitism`. It defaults to 1 which means for each generation keep only the best solution in the next generation. If assigned 0, then it has no effect. Read more in this section: [Elitism Selection](https://pygad.readthedocs.io/en/latest/generations.html#elitism-selection). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/74 +7. A new instance attribute named `last_generation_elitism` added to hold the elitism in the last generation. +8. A new parameter called `random_seed` added to accept a seed for the random function generators. Credit to this issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/70 and [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan). Read more in this section: [Random Seed](https://pygad.readthedocs.io/en/latest/generations.html#random-seed). +9. Editing the `pygad.TorchGA` module to make sure the tensor data is moved from GPU to CPU. Thanks to Rasmus Johansson for opening this pull request: https://github.com/ahmedfgad/TorchGA/pull/2 -Release Date: 20 September 2020 +## PyGAD 2.17.0 -1. Support of a new module named `kerasga` so that the Keras models can be trained by the genetic algorithm using PyGAD. +Release Date: 8 July 2022 -## PyGAD 2.8.1 +1. An issue is solved when the `gene_space` parameter is given a fixed value. e.g. gene_space=[range(5), 4]. The second gene's value is static (4) which causes an exception. +2. Fixed the issue where the `allow_duplicate_genes` parameter did not work when mutation is disabled (i.e. `mutation_type=None`). This is by checking for duplicates after crossover directly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 +3. Solve an issue in the `tournament_selection()` method as the indices of the selected parents were incorrect. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/89 +4. Reuse the fitness values of the previously explored solutions rather than recalculating them. This feature only works if `save_solutions=True`. +4. Parallel processing is supported. This is by the introduction of a new parameter named `parallel_processing` in the constructor of the `pygad.GA` class. Thanks to [@windowshopr](https://github.com/windowshopr) for opening the issue [#78](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/78) at GitHub. Check the [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) section for more information and examples. -Release Date: 3 October 2020 +## PyGAD 2.16.3 -1. Bug fix in applying the crossover operation when the `crossover_probability` parameter is used. Thanks to [Eng. Hamada Kassem, Research and Teaching Assistant, Construction Engineering and Management, Faculty of Engineering, Alexandria University, Egypt](https://www.linkedin.com/in/hamadakassem). +Release Date: 2 February 2022 -## PyGAD 2.9.0 +1. Validate the fitness value returned from the fitness function. An exception is raised if something is wrong. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/67 -Release Date: 06 December 2020 +## PyGAD 2.16.2 -1. The fitness values of the initial population are considered in the `best_solutions_fitness` attribute. -2. An optional parameter named `save_best_solutions` is added. It defaults to `False`. When it is `True`, then the best solution after each generation is saved into an attribute named `best_solutions`. If `False`, then no solutions are saved and the `best_solutions` attribute will be empty. -3. Scattered crossover is supported. To use it, assign the `crossover_type` parameter the value `"scattered"`. -4. NumPy arrays are now supported by the `gene_space` parameter. -5. The following parameters (`gene_type`, `crossover_probability`, `mutation_probability`, `delay_after_gen`) can be assigned to a numeric value of any of these data types: `int`, `float`, `numpy.int`, `numpy.int8`, `numpy.int16`, `numpy.int32`, `numpy.int64`, `numpy.float`, `numpy.float16`, `numpy.float32`, or `numpy.float64`. +Release Date: 2 February 2022 -## PyGAD 2.10.0 +1. A new instance attribute called `previous_generation_fitness` added in the `pygad.GA` class. It holds the fitness values of one generation before the fitness values saved in the `last_generation_fitness`. +3. Issue in the `cal_pop_fitness()` method in getting the correct indices of the previous parents. This is solved by using the previous generation's fitness saved in the new attribute `previous_generation_fitness` to return the parents' fitness values. Thanks to Tobias Tischhauser (M.Sc. - [Mitarbeiter Institut EMS, Departement Technik, OST – Ostschweizer Fachhochschule, Switzerland](https://www.ost.ch/de/forschung-und-dienstleistungen/technik/systemtechnik/ems/team)) for detecting this bug. -Release Date: 03 January 2021 +## PyGAD 2.16.1 -1. Support of a new module `pygad.torchga` to train PyTorch models using PyGAD. Check [its documentation](https://pygad.readthedocs.io/en/latest/torchga.html). -2. Support of adaptive mutation where the mutation rate is determined by the fitness value of each solution. Read the [Adaptive Mutation](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#adaptive-mutation) section for more details. Also, read this paper: [Libelli, S. Marsili, and P. Alba. "Adaptive mutation in genetic algorithms." Soft computing 4.2 (2000): 76-80.](https://www.researchgate.net/publication/225642916_Adaptive_mutation_in_genetic_algorithms) -3. Before the `run()` method completes or exits, the fitness value of the best solution in the current population is appended to the `best_solution_fitness` list attribute. Note that the fitness value of the best solution in the initial population is already saved at the beginning of the list. So, the fitness value of the best solution is saved before the genetic algorithm starts and after it ends. -4. When the parameter `parent_selection_type` is set to `sss` (steady-state selection), then a warning message is printed if the value of the `keep_parents` parameter is set to 0. -5. More validations to the user input parameters. -6. The default value of the `mutation_percent_genes` is set to the string `"default"` rather than the integer 10. This change helps to know whether the user explicitly passed a value to the `mutation_percent_genes` parameter or it is left to its default one. The `"default"` value is later translated into the integer 10. -7. The `mutation_percent_genes` parameter is no longer accepting the value 0. It must be `>0` and `<=100`. -8. The built-in `warnings` module is used to show warning messages rather than just using the `print()` function. -9. A new `bool` parameter called `suppress_warnings` is added to the constructor of the `pygad.GA` class. It allows the user to control whether the warning messages are printed or not. It defaults to `False` which means the messages are printed. -10. A helper method called `adaptive_mutation_population_fitness()` is created to calculate the average fitness value used in adaptive mutation to filter the solutions. -11. The `best_solution()` method accepts a new optional parameter called `pop_fitness`. It accepts a list of the fitness values of the solutions in the population. If `None`, then the `cal_pop_fitness()` method is called to calculate the fitness values of the population. +Release Date: 28 September 2021 -## PyGAD 2.10.1 +1. The user can use the `tqdm` library to show a progress bar. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50. -Release Date: 10 January 2021 +```python +import pygad +import numpy +import tqdm -1. In the `gene_space` parameter, any `None` value (regardless of its index or axis), is replaced by a randomly generated number based on the 3 parameters `init_range_low`, `init_range_high`, and `gene_type`. So, the `None` value in `[..., None, ...]` or `[..., [..., None, ...], ...]` are replaced with random values. This gives more freedom in building the space of values for the genes. -2. All the numbers passed to the `gene_space` parameter are casted to the type specified in the `gene_type` parameter. -3. The `numpy.uint` data type is supported for the parameters that accept integer values. -4. In the `pygad.kerasga` module, the `model_weights_as_vector()` function uses the `trainable` attribute of the model's layers to only return the trainable weights in the network. So, only the trainable layers with their `trainable` attribute set to `True` (`trainable=True`), which is the default value, have their weights evolved. All non-trainable layers with the `trainable` attribute set to `False` (`trainable=False`) will not be evolved. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for pointing about that at [GitHub](https://github.com/ahmedfgad/KerasGA/issues/1). +equation_inputs = [4,-2,3.5] +desired_output = 44 -## PyGAD 2.10.2 +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 -Release Date: 15 January 2021 +num_generations = 10000 +with tqdm.tqdm(total=num_generations) as pbar: + ga_instance = pygad.GA(num_generations=num_generations, + sol_per_pop=5, + num_parents_mating=2, + num_genes=len(equation_inputs), + fitness_func=fitness_func, + on_generation=lambda _: pbar.update(1)) -1. A bug fix when `save_best_solutions=True`. Refer to this issue for more information: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/25 + ga_instance.run() -## PyGAD 2.11.0 +ga_instance.plot_result() +``` +But this work does not work if the `ga_instance` will be pickled (i.e. the `save()` method will be called. -Release Date: 16 February 2021 +```python +ga_instance.save("test") +``` -1. In the `gene_space` argument, the user can use a dictionary to specify the lower and upper limits of the gene. This dictionary must have only 2 items with keys `low` and `high` to specify the low and high limits of the gene, respectively. This way, PyGAD takes care of not exceeding the value limits of the gene. For a problem with only 2 genes, then using `gene_space=[{'low': 1, 'high': 5}, {'low': 0.2, 'high': 0.81}]` means the accepted values in the first gene start from 1 (inclusive) to 5 (exclusive) while the second one has values between 0.2 (inclusive) and 0.85 (exclusive). For more information, please check the [Limit the Gene Value Range](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter) section of the documentation. -2. The `plot_result()` method returns the figure so that the user can save it. -3. Bug fixes in copying elements from the gene space. -4. For a gene with a set of discrete values (more than 1 value) in the `gene_space` parameter like `[0, 1]`, it was possible that the gene value may not change after mutation. That is if the current value is 0, then the randomly selected value could also be 0. Now, it is verified that the new value is changed. So, if the current value is 0, then the new value after mutation will not be 0 but 1. +To solve this issue, define a function and pass it to the `on_generation` parameter. In the next code, the `on_generation_progress()` function is defined which updates the progress bar. -## PyGAD 2.12.0 +```python +import pygad +import numpy +import tqdm -Release Date: 20 February 2021 +equation_inputs = [4,-2,3.5] +desired_output = 44 -1. 4 new instance attributes are added to hold temporary results after each generation: `last_generation_fitness` holds the fitness values of the solutions in the last generation, `last_generation_parents` holds the parents selected from the last generation, `last_generation_offspring_crossover` holds the offspring generated after applying the crossover in the last generation, and `last_generation_offspring_mutation` holds the offspring generated after applying the mutation in the last generation. You can access these attributes inside the `on_generation()` method for example. -2. A bug fixed when the `initial_population` parameter is used. The bug occurred due to a mismatch between the data type of the array assigned to `initial_population` and the gene type in the `gene_type` attribute. Assuming that the array assigned to the `initial_population` parameter is `((1, 1), (3, 3), (5, 5), (7, 7))` which has type `int`. When `gene_type` is set to `float`, then the genes will not be float but casted to `int` because the defined array has `int` type. The bug is fixed by forcing the array assigned to `initial_population` to have the data type in the `gene_type` attribute. Check the [issue at GitHub](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27 +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 -Thanks to Andrei Rozanski [PhD Bioinformatics Specialist, Department of Tissue Dynamics and Regeneration, Max Planck Institute for Biophysical Chemistry, Germany] for opening my eye to the first change. +def on_generation_progress(ga): + pbar.update(1) -Thanks to [Marios Giouvanakis](https://www.researchgate.net/profile/Marios-Giouvanakis), a PhD candidate in Electrical & Computer Engineer, [Aristotle University of Thessaloniki (Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης), Greece](https://www.auth.gr/en), for emailing me about the second issue. +num_generations = 100 +with tqdm.tqdm(total=num_generations) as pbar: + ga_instance = pygad.GA(num_generations=num_generations, + sol_per_pop=5, + num_parents_mating=2, + num_genes=len(equation_inputs), + fitness_func=fitness_func, + on_generation=on_generation_progress) -## PyGAD 2.13.0 + ga_instance.run() -Release Date: 12 March 2021 +ga_instance.plot_result() -1. A new `bool` parameter called `allow_duplicate_genes` is supported. If `True`, which is the default, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. Check the [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) section for more details. -2. The `last_generation_fitness` is updated at the end of each generation not at the beginning. This keeps the fitness values of the most up-to-date population assigned to the `last_generation_fitness` parameter. +ga_instance.save("test") +``` -## PyGAD 2.14.0 +2. Solved the issue of unequal length between the `solutions` and `solutions_fitness` when the `save_solutions` parameter is set to `True`. Now, the fitness of the last population is appended to the `solutions_fitness` array. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/64 -PyGAD 2.14.0 has an issue that is solved in PyGAD 2.14.1. Please consider using 2.14.1 not 2.14.0. +3. There was an issue of getting the length of these 4 variables (`solutions`, `solutions_fitness`, `best_solutions`, and `best_solutions_fitness`) doubled after each call of the `run()` method. This is solved by resetting these variables at the beginning of the `run()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/62 +4. Bug fixes when adaptive mutation is used (`mutation_type="adaptive"`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/65 -Release Date: 19 May 2021 +## PyGAD 2.16.0 -1. [Issue #40](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40) is solved. Now, the `None` value works with the `crossover_type` and `mutation_type` parameters: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40 -2. The `gene_type` parameter supports accepting a `list/tuple/numpy.ndarray` of numeric data types for the genes. This helps to control the data type of each individual gene. Previously, the `gene_type` can be assigned only to a single data type that is applied for all genes. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for asking about this feature in [this discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43): https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43 -3. A new `bool` attribute named `gene_type_single` is added to the `pygad.GA` class. It is `True` when there is a single data type assigned to the `gene_type` parameter. When the `gene_type` parameter is assigned a `list/tuple/numpy.ndarray`, then `gene_type_single` is set to `False`. -4. The `mutation_by_replacement` flag now has no effect if `gene_space` exists except for the genes with `None` values. For example, for `gene_space=[None, [5, 6]]` the `mutation_by_replacement` flag affects only the first gene which has `None` for its value space. -5. When an element has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[None, [5, 6]]`), then its value will be randomly generated for each solution rather than being generate once for all solutions. Previously, the gene with `None` value in `gene_space` is the same across all solutions -6. Some changes in the documentation according to [issue #32](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32 +Release Date: 19 June 2021 -## PyGAD 2.14.2 +1. A user-defined function can be passed to the `mutation_type`, `crossover_type`, and `parent_selection_type` parameters in the `pygad.GA` class to create a custom mutation, crossover, and parent selection operators. Check the [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) section for more details. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50 -Release Date: 27 May 2021 +## PyGAD 2.15.2 -1. Some bug fixes when the `gene_type` parameter is nested. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for opening [a discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342) to report this bug: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342 +Release Date: 18 June 2021 -[Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) helped a lot in suggesting new features and suggesting enhancements in 2.14.0 to 2.14.2 releases. +1. Fix a bug when using the `kerasga` or `torchga` modules. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/51 -## PyGAD 2.14.3 +## PyGAD 2.15.1 -Release Date: 6 June 2021 +Release Date: 18 June 2021 -1. Some bug fixes when setting the `save_best_solutions` parameter to `True`. Previously, the best solution for generation `i` was added into the `best_solutions` attribute at generation `i+1`. Now, the `best_solutions` attribute is updated by each best solution at its exact generation. +1. Fix a bug when `keep_parents` is set to a positive integer. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/49 ## PyGAD 2.15.0 @@ -277,488 +510,258 @@ Release Date: 17 June 2021 13. A new method named `plot_genes()` creates, shows, and returns a figure to show how each gene changes per each generation. It accepts similar parameters like the `plot_fitness()` method in addition to the `graph_type`, `fill_color`, and `solutions` parameters. The `graph_type` parameter can be either `"plot"` (default), `"boxplot"`, or `"histogram"`. `fill_color` accepts the fill color which works when `graph_type` is either `"boxplot"` or `"histogram"`. `solutions` can be either `"all"` or `"best"` to decide whether all solutions or only best solutions are used. 14. The `gene_type` parameter now supports controlling the precision of `float` data types. For a gene, rather than assigning just the data type like `float`, assign a `list`/`tuple`/`numpy.ndarray` with 2 elements where the first one is the type and the second one is the precision. For example, `[float, 2]` forces a gene with a value like `0.1234` to be `0.12`. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. -## PyGAD 2.15.1 - -Release Date: 18 June 2021 - -1. Fix a bug when `keep_parents` is set to a positive integer. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/49 - -## PyGAD 2.15.2 - -Release Date: 18 June 2021 - -1. Fix a bug when using the `kerasga` or `torchga` modules. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/51 - -## PyGAD 2.16.0 - -Release Date: 19 June 2021 - -1. A user-defined function can be passed to the `mutation_type`, `crossover_type`, and `parent_selection_type` parameters in the `pygad.GA` class to create a custom mutation, crossover, and parent selection operators. Check the [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) section for more details. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50 +## PyGAD 2.14.3 -## PyGAD 2.16.1 +Release Date: 6 June 2021 -Release Date: 28 September 2021 +1. Some bug fixes when setting the `save_best_solutions` parameter to `True`. Previously, the best solution for generation `i` was added into the `best_solutions` attribute at generation `i+1`. Now, the `best_solutions` attribute is updated by each best solution at its exact generation. -1. The user can use the `tqdm` library to show a progress bar. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50. +## PyGAD 2.14.2 -```python -import pygad -import numpy -import tqdm +Release Date: 27 May 2021 -equation_inputs = [4,-2,3.5] -desired_output = 44 +1. Some bug fixes when the `gene_type` parameter is nested. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for opening [a discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342) to report this bug: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342 -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 +[Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) helped a lot in suggesting new features and suggesting enhancements in 2.14.0 to 2.14.2 releases. -num_generations = 10000 -with tqdm.tqdm(total=num_generations) as pbar: - ga_instance = pygad.GA(num_generations=num_generations, - sol_per_pop=5, - num_parents_mating=2, - num_genes=len(equation_inputs), - fitness_func=fitness_func, - on_generation=lambda _: pbar.update(1)) - - ga_instance.run() +## PyGAD 2.14.0 -ga_instance.plot_result() -``` -But this work does not work if the `ga_instance` will be pickled (i.e. the `save()` method will be called. +PyGAD 2.14.0 has an issue that is solved in PyGAD 2.14.1. Please consider using 2.14.1 not 2.14.0. -```python -ga_instance.save("test") -``` +Release Date: 19 May 2021 -To solve this issue, define a function and pass it to the `on_generation` parameter. In the next code, the `on_generation_progress()` function is defined which updates the progress bar. +1. [Issue #40](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40) is solved. Now, the `None` value works with the `crossover_type` and `mutation_type` parameters: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40 +2. The `gene_type` parameter supports accepting a `list/tuple/numpy.ndarray` of numeric data types for the genes. This helps to control the data type of each individual gene. Previously, the `gene_type` can be assigned only to a single data type that is applied for all genes. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for asking about this feature in [this discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43): https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43 +3. A new `bool` attribute named `gene_type_single` is added to the `pygad.GA` class. It is `True` when there is a single data type assigned to the `gene_type` parameter. When the `gene_type` parameter is assigned a `list/tuple/numpy.ndarray`, then `gene_type_single` is set to `False`. +4. The `mutation_by_replacement` flag now has no effect if `gene_space` exists except for the genes with `None` values. For example, for `gene_space=[None, [5, 6]]` the `mutation_by_replacement` flag affects only the first gene which has `None` for its value space. +5. When an element has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[None, [5, 6]]`), then its value will be randomly generated for each solution rather than being generate once for all solutions. Previously, the gene with `None` value in `gene_space` is the same across all solutions +6. Some changes in the documentation according to [issue #32](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32 -```python -import pygad -import numpy -import tqdm +## PyGAD 2.13.0 -equation_inputs = [4,-2,3.5] -desired_output = 44 +Release Date: 12 March 2021 -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 +1. A new `bool` parameter called `allow_duplicate_genes` is supported. If `True`, which is the default, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. Check the [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) section for more details. +2. The `last_generation_fitness` is updated at the end of each generation not at the beginning. This keeps the fitness values of the most up-to-date population assigned to the `last_generation_fitness` parameter. -def on_generation_progress(ga): - pbar.update(1) +## PyGAD 2.12.0 -num_generations = 100 -with tqdm.tqdm(total=num_generations) as pbar: - ga_instance = pygad.GA(num_generations=num_generations, - sol_per_pop=5, - num_parents_mating=2, - num_genes=len(equation_inputs), - fitness_func=fitness_func, - on_generation=on_generation_progress) +Release Date: 20 February 2021 - ga_instance.run() +1. 4 new instance attributes are added to hold temporary results after each generation: `last_generation_fitness` holds the fitness values of the solutions in the last generation, `last_generation_parents` holds the parents selected from the last generation, `last_generation_offspring_crossover` holds the offspring generated after applying the crossover in the last generation, and `last_generation_offspring_mutation` holds the offspring generated after applying the mutation in the last generation. You can access these attributes inside the `on_generation()` method for example. +2. A bug fixed when the `initial_population` parameter is used. The bug occurred due to a mismatch between the data type of the array assigned to `initial_population` and the gene type in the `gene_type` attribute. Assuming that the array assigned to the `initial_population` parameter is `((1, 1), (3, 3), (5, 5), (7, 7))` which has type `int`. When `gene_type` is set to `float`, then the genes will not be float but casted to `int` because the defined array has `int` type. The bug is fixed by forcing the array assigned to `initial_population` to have the data type in the `gene_type` attribute. Check the [issue at GitHub](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27 -ga_instance.plot_result() +Thanks to Andrei Rozanski [PhD Bioinformatics Specialist, Department of Tissue Dynamics and Regeneration, Max Planck Institute for Biophysical Chemistry, Germany] for opening my eye to the first change. -ga_instance.save("test") -``` +Thanks to [Marios Giouvanakis](https://www.researchgate.net/profile/Marios-Giouvanakis), a PhD candidate in Electrical & Computer Engineer, [Aristotle University of Thessaloniki (Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης), Greece](https://www.auth.gr/en), for emailing me about the second issue. -2. Solved the issue of unequal length between the `solutions` and `solutions_fitness` when the `save_solutions` parameter is set to `True`. Now, the fitness of the last population is appended to the `solutions_fitness` array. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/64 +## PyGAD 2.11.0 -3. There was an issue of getting the length of these 4 variables (`solutions`, `solutions_fitness`, `best_solutions`, and `best_solutions_fitness`) doubled after each call of the `run()` method. This is solved by resetting these variables at the beginning of the `run()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/62 -4. Bug fixes when adaptive mutation is used (`mutation_type="adaptive"`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/65 +Release Date: 16 February 2021 -## PyGAD 2.16.2 +1. In the `gene_space` argument, the user can use a dictionary to specify the lower and upper limits of the gene. This dictionary must have only 2 items with keys `low` and `high` to specify the low and high limits of the gene, respectively. This way, PyGAD takes care of not exceeding the value limits of the gene. For a problem with only 2 genes, then using `gene_space=[{'low': 1, 'high': 5}, {'low': 0.2, 'high': 0.81}]` means the accepted values in the first gene start from 1 (inclusive) to 5 (exclusive) while the second one has values between 0.2 (inclusive) and 0.85 (exclusive). For more information, please check the [Limit the Gene Value Range](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter) section of the documentation. +2. The `plot_result()` method returns the figure so that the user can save it. +3. Bug fixes in copying elements from the gene space. +4. For a gene with a set of discrete values (more than 1 value) in the `gene_space` parameter like `[0, 1]`, it was possible that the gene value may not change after mutation. That is if the current value is 0, then the randomly selected value could also be 0. Now, it is verified that the new value is changed. So, if the current value is 0, then the new value after mutation will not be 0 but 1. -Release Date: 2 February 2022 +## PyGAD 2.10.2 -1. A new instance attribute called `previous_generation_fitness` added in the `pygad.GA` class. It holds the fitness values of one generation before the fitness values saved in the `last_generation_fitness`. -3. Issue in the `cal_pop_fitness()` method in getting the correct indices of the previous parents. This is solved by using the previous generation's fitness saved in the new attribute `previous_generation_fitness` to return the parents' fitness values. Thanks to Tobias Tischhauser (M.Sc. - [Mitarbeiter Institut EMS, Departement Technik, OST – Ostschweizer Fachhochschule, Switzerland](https://www.ost.ch/de/forschung-und-dienstleistungen/technik/systemtechnik/ems/team)) for detecting this bug. +Release Date: 15 January 2021 -## PyGAD 2.16.3 +1. A bug fix when `save_best_solutions=True`. Refer to this issue for more information: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/25 -Release Date: 2 February 2022 +## PyGAD 2.10.1 -1. Validate the fitness value returned from the fitness function. An exception is raised if something is wrong. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/67 +Release Date: 10 January 2021 -## PyGAD 2.17.0 +1. In the `gene_space` parameter, any `None` value (regardless of its index or axis), is replaced by a randomly generated number based on the 3 parameters `init_range_low`, `init_range_high`, and `gene_type`. So, the `None` value in `[..., None, ...]` or `[..., [..., None, ...], ...]` are replaced with random values. This gives more freedom in building the space of values for the genes. +2. All the numbers passed to the `gene_space` parameter are casted to the type specified in the `gene_type` parameter. +3. The `numpy.uint` data type is supported for the parameters that accept integer values. +4. In the `pygad.kerasga` module, the `model_weights_as_vector()` function uses the `trainable` attribute of the model's layers to only return the trainable weights in the network. So, only the trainable layers with their `trainable` attribute set to `True` (`trainable=True`), which is the default value, have their weights evolved. All non-trainable layers with the `trainable` attribute set to `False` (`trainable=False`) will not be evolved. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for pointing about that at [GitHub](https://github.com/ahmedfgad/KerasGA/issues/1). -Release Date: 8 July 2022 +## PyGAD 2.10.0 -1. An issue is solved when the `gene_space` parameter is given a fixed value. e.g. gene_space=[range(5), 4]. The second gene's value is static (4) which causes an exception. -2. Fixed the issue where the `allow_duplicate_genes` parameter did not work when mutation is disabled (i.e. `mutation_type=None`). This is by checking for duplicates after crossover directly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 -3. Solve an issue in the `tournament_selection()` method as the indices of the selected parents were incorrect. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/89 -4. Reuse the fitness values of the previously explored solutions rather than recalculating them. This feature only works if `save_solutions=True`. -4. Parallel processing is supported. This is by the introduction of a new parameter named `parallel_processing` in the constructor of the `pygad.GA` class. Thanks to [@windowshopr](https://github.com/windowshopr) for opening the issue [#78](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/78) at GitHub. Check the [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) section for more information and examples. +Release Date: 03 January 2021 -## PyGAD 2.18.0 -Release Date: 9 September 2022 +1. Support of a new module `pygad.torchga` to train PyTorch models using PyGAD. Check [its documentation](https://pygad.readthedocs.io/en/latest/torchga.html). +2. Support of adaptive mutation where the mutation rate is determined by the fitness value of each solution. Read the [Adaptive Mutation](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#adaptive-mutation) section for more details. Also, read this paper: [Libelli, S. Marsili, and P. Alba. "Adaptive mutation in genetic algorithms." Soft computing 4.2 (2000): 76-80.](https://www.researchgate.net/publication/225642916_Adaptive_mutation_in_genetic_algorithms) +3. Before the `run()` method completes or exits, the fitness value of the best solution in the current population is appended to the `best_solution_fitness` list attribute. Note that the fitness value of the best solution in the initial population is already saved at the beginning of the list. So, the fitness value of the best solution is saved before the genetic algorithm starts and after it ends. +4. When the parameter `parent_selection_type` is set to `sss` (steady-state selection), then a warning message is printed if the value of the `keep_parents` parameter is set to 0. +5. More validations to the user input parameters. +6. The default value of the `mutation_percent_genes` is set to the string `"default"` rather than the integer 10. This change helps to know whether the user explicitly passed a value to the `mutation_percent_genes` parameter or it is left to its default one. The `"default"` value is later translated into the integer 10. +7. The `mutation_percent_genes` parameter is no longer accepting the value 0. It must be `>0` and `<=100`. +8. The built-in `warnings` module is used to show warning messages rather than just using the `print()` function. +9. A new `bool` parameter called `suppress_warnings` is added to the constructor of the `pygad.GA` class. It allows the user to control whether the warning messages are printed or not. It defaults to `False` which means the messages are printed. +10. A helper method called `adaptive_mutation_population_fitness()` is created to calculate the average fitness value used in adaptive mutation to filter the solutions. +11. The `best_solution()` method accepts a new optional parameter called `pop_fitness`. It accepts a list of the fitness values of the solutions in the population. If `None`, then the `cal_pop_fitness()` method is called to calculate the fitness values of the population. -1. Raise an exception if the sum of fitness values is zero while either roulette wheel or stochastic universal parent selection is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/129 -2. Initialize the value of the `run_completed` property to `False`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/122 -3. The values of these properties are no longer reset with each call to the `run()` method `self.best_solutions, self.best_solutions_fitness, self.solutions, self.solutions_fitness`: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123. Now, the user can have the flexibility of calling the `run()` method more than once while extending the data collected after each generation. Another advantage happens when the instance is loaded and the `run()` method is called, as the old fitness value are shown on the graph alongside with the new fitness values. Read more in this section: [Continue without Losing Progress](https://pygad.readthedocs.io/en/latest/generations.html#continue-without-losing-progress) -4. Thanks [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan) (Dept. of Information and Communications Engineering, University of Murcia, Murcia, Spain) for editing this [comment](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/5315bbec02777df96ce1ec665c94dece81c440f4/pygad.py#L73) in the code. https://github.com/ahmedfgad/GeneticAlgorithmPython/commit/5315bbec02777df96ce1ec665c94dece81c440f4 -5. A bug fixed when `crossover_type=None`. -6. Support of elitism selection through a new parameter named `keep_elitism`. It defaults to 1 which means for each generation keep only the best solution in the next generation. If assigned 0, then it has no effect. Read more in this section: [Elitism Selection](https://pygad.readthedocs.io/en/latest/generations.html#elitism-selection). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/74 -7. A new instance attribute named `last_generation_elitism` added to hold the elitism in the last generation. -8. A new parameter called `random_seed` added to accept a seed for the random function generators. Credit to this issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/70 and [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan). Read more in this section: [Random Seed](https://pygad.readthedocs.io/en/latest/generations.html#random-seed). -9. Editing the `pygad.TorchGA` module to make sure the tensor data is moved from GPU to CPU. Thanks to Rasmus Johansson for opening this pull request: https://github.com/ahmedfgad/TorchGA/pull/2 +## PyGAD 2.9.0 -## PyGAD 2.18.1 +Release Date: 06 December 2020 -Release Date: 19 September 2022 +1. The fitness values of the initial population are considered in the `best_solutions_fitness` attribute. +2. An optional parameter named `save_best_solutions` is added. It defaults to `False`. When it is `True`, then the best solution after each generation is saved into an attribute named `best_solutions`. If `False`, then no solutions are saved and the `best_solutions` attribute will be empty. +3. Scattered crossover is supported. To use it, assign the `crossover_type` parameter the value `"scattered"`. +4. NumPy arrays are now supported by the `gene_space` parameter. +5. The following parameters (`gene_type`, `crossover_probability`, `mutation_probability`, `delay_after_gen`) can be assigned to a numeric value of any of these data types: `int`, `float`, `numpy.int`, `numpy.int8`, `numpy.int16`, `numpy.int32`, `numpy.int64`, `numpy.float`, `numpy.float16`, `numpy.float32`, or `numpy.float64`. -1. A big fix when `keep_elitism` is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/132 +## PyGAD 2.8.1 -## PyGAD 2.18.2 -Release Date: 14 February 2023 +Release Date: 3 October 2020 -1. Remove `numpy.int` and `numpy.float` from the list of supported data types. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/151 https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/152 -2. Call the `on_crossover()` callback function even if `crossover_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -3. Call the `on_mutation()` callback function even if `mutation_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +1. Bug fix in applying the crossover operation when the `crossover_probability` parameter is used. Thanks to [Eng. Hamada Kassem, Research and Teaching Assistant, Construction Engineering and Management, Faculty of Engineering, Alexandria University, Egypt](https://www.linkedin.com/in/hamadakassem). -## PyGAD 2.18.3 +## PyGAD 2.8.0 -Release Date: 14 February 2023 +Release Date: 20 September 2020 -1. Bug fixes. +1. Support of a new module named `kerasga` so that the Keras models can be trained by the genetic algorithm using PyGAD. -## PyGAD 2.19.0 +## PyGAD 2.7.2 -Release Date: 22 February 2023 -1. A new `summary()` method is supported to return a Keras-like summary of the PyGAD lifecycle. -2. A new optional parameter called `fitness_batch_size` is supported to calculate the fitness in batches. If it is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136. -3. The `cloudpickle` library (https://github.com/cloudpipe/cloudpickle) is used instead of the `pickle` library to pickle the `pygad.GA` objects. This solves the issue of having to redefine the functions (e.g. fitness function). The `cloudpickle` library is added as a dependency in the `requirements.txt` file. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/159 -4. Support of assigning methods to these parameters: `fitness_func`, `crossover_type`, `mutation_type`, `parent_selection_type`, `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -5. Validating the output of the parent selection, crossover, and mutation functions. -6. The built-in parent selection operators return the parent's indices as a NumPy array. -7. The outputs of the parent selection, crossover, and mutation operators must be NumPy arrays. -8. Fix an issue when `allow_duplicate_genes=True`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 -9. Fix an issue creating scatter plots of the solutions' fitness. -10. Sampling from a `set()` is no longer supported in Python 3.11. Instead, sampling happens from a `list()`. Thanks `Marco Brenna` for pointing to this issue. -11. The lifecycle is updated to reflect that the new population's fitness is calculated at the end of the lifecycle not at the beginning. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154#issuecomment-1438739483 -12. There was an issue when `save_solutions=True` that causes the fitness function to be called for solutions already explored and have their fitness pre-calculated. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160 -13. A new instance attribute named `last_generation_elitism_indices` added to hold the indices of the selected elitism. This attribute helps to re-use the fitness of the elitism instead of calling the fitness function. -14. Fewer calls to the `best_solution()` method which in turns saves some calls to the fitness function. -15. Some updates in the documentation to give more details about the `cal_pop_fitness()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/79#issuecomment-1439605442 +Release Date: 14 September 2020 -## PyGAD 2.19.1 +1. Bug fix to support building and training regression neural networks with multiple outputs. -Release Date: 22 February 2023 +## PyGAD 2.7.1 -1. Add the [cloudpickle](https://github.com/cloudpipe/cloudpickle) library as a dependency. +Release Date: 11 September 2020 -## PyGAD 2.19.2 +1. A bug fix when the `problem_type` argument is set to `regression`. -Release Date 23 February 2023 +## PyGAD 2.7.0 -1. Fix an issue when parallel processing was used where the elitism solutions' fitness values are not re-used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160#issuecomment-1441718184 +Release Date: 11 September 2020 +1. The `learning_rate` parameter in the `pygad.nn.train()` function defaults to **0.01**. +2. Added support of building neural networks for regression using the new parameter named `problem_type`. It is added as a parameter to both `pygad.nn.train()` and `pygad.nn.predict()` functions. The value of this parameter can be either **classification** or **regression** to define the problem type. It defaults to **classification**. +3. The activation function for a layer can be set to the string `"None"` to refer that there is no activation function at this layer. As a result, the supported values for the activation function are `"sigmoid"`, `"relu"`, `"softmax"`, and `"None"`. -## PyGAD 3.0.0 +To build a regression network using the `pygad.nn` module, just do the following: +1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. +2. Set the activation function for the output layer to the string `"None"`. This sets no limits on the range of the outputs as it will be from `-infinity` to `+infinity`. If you are sure that all outputs will be nonnegative values, then use the ReLU function. -Release Date 8 April 2023 +Check the documentation of the `pygad.nn` module for an example that builds a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NumPyANN): https://github.com/ahmedfgad/NumPyANN -1. The structure of the library is changed and some methods defined in the `pygad.py` module are moved to the `pygad.utils`, `pygad.helper`, and `pygad.visualize` submodules. - 2. The `pygad.utils.parent_selection` module has a class named `ParentSelection` where all the parent selection operators exist. The `pygad.GA` class extends this class. - 3. The `pygad.utils.crossover` module has a class named `Crossover` where all the crossover operators exist. The `pygad.GA` class extends this class. - 4. The `pygad.utils.mutation` module has a class named `Mutation` where all the mutation operators exist. The `pygad.GA` class extends this class. - 5. The `pygad.helper.unique` module has a class named `Unique` some helper methods exist to solve duplicate genes and make sure every gene is unique. The `pygad.GA` class extends this class. - 6. The `pygad.visualize.plot` module has a class named `Plot` where all the methods that create plots exist. The `pygad.GA` class extends this class. - 7. Support of using the `logging` module to log the outputs to both the console and text file instead of using the `print()` function. This is by assigning the `logging.Logger` to the new `logger` parameter. Check the [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. - 8. A new instance attribute called `logger` to save the logger. - 9. The function/method passed to the `fitness_func` parameter accepts a new parameter that refers to the instance of the `pygad.GA` class. Check this for an example: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/163 - 10. Update the documentation to include an example of using functions and methods to calculate the fitness and build callbacks. Check this for more details: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92#issuecomment-1443635003 - 11. Validate the value passed to the `initial_population` parameter. - 12. Validate the type and length of the `pop_fitness` parameter of the `best_solution()` method. - 13. Some edits in the documentation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/106 - 14. Fix an issue when building the initial population as (some) genes have their value taken from the mutation range (defined by the parameters `random_mutation_min_val` and `random_mutation_max_val`) instead of using the parameters `init_range_low` and `init_range_high`. - 15. The `summary()` method returns the summary as a single-line string. Just log/print the returned string it to see it properly. - 16. The `callback_generation` parameter is removed. Use the `on_generation` parameter instead. - 17. There was an issue when using the `parallel_processing` parameter with Keras and PyTorch. As Keras/PyTorch are not thread-safe, the `predict()` method gives incorrect and weird results when more than 1 thread is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/145 https://github.com/ahmedfgad/TorchGA/issues/5 https://github.com/ahmedfgad/KerasGA/issues/6. Thanks to this [StackOverflow answer](https://stackoverflow.com/a/75606666/5426539). - 18. Replace `numpy.float` by `float` in the 2 parent selection operators roulette wheel and stochastic universal. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/168 +To build and train a regression network using the `pygad.gann` module, do the following: -## PyGAD 3.0.1 +1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. +2. Set the `output_activation` parameter in the constructor of the `pygad.gann.GANN` class to `"None"`. -Release Date 20 April 2023 +Check the documentation of the `pygad.gann` module for an example that builds and trains a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NeuralGenetic): https://github.com/ahmedfgad/NeuralGenetic -1. Fix an issue with passing user-defined function/method for parent selection. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/179 +To build a classification network, either ignore the `problem_type` parameter or set it to `"classification"` (default value). In this case, the activation function of the last layer can be set to any type (e.g. softmax). -## PyGAD 3.1.0 +## PyGAD 2.6.0 -Release Date 20 June 2023 +Release Date: 6 August 2020 -1. Fix a bug when the initial population has duplciate genes if a nested gene space is used. -2. The `gene_space` parameter can no longer be assigned a tuple. -3. Fix a bug when the `gene_space` parameter has a member of type `tuple`. -4. A new instance attribute called `gene_space_unpacked` which has the unpacked `gene_space`. It is used to solve duplicates. For infinite ranges in the `gene_space`, they are unpacked to a limited number of values (e.g. 100). -5. Bug fixes when creating the initial population using `gene_space` attribute. -6. When a `dict` is used with the `gene_space` attribute, the new gene value was calculated by summing 2 values: 1) the value sampled from the `dict` 2) a random value returned from the random mutation range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This might cause the gene value to exceed the range limit defined in the `gene_space`. To respect the `gene_space` range, this release only returns the value from the `dict` without summing it to a random value. -7. Formatting the strings using f-string instead of the `format()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 -8. In the `__init__()` of the `pygad.GA` class, the logged error messages are handled using a `try-except` block instead of repeating the `logger.error()` command. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 -9. A new class named `CustomLogger` is created in the `pygad.cnn` module to create a default logger using the `logging` module assigned to the `logger` attribute. This class is extended in all other classes in the module. The constructors of these classes have a new parameter named `logger` which defaults to `None`. If no logger is passed, then the default logger in the `CustomLogger` class is used. -10. Except for the `pygad.nn` module, the `print()` function in all other modules are replaced by the `logging` module to log messages. -11. The callback functions/methods `on_fitness()`, `on_parents()`, `on_crossover()`, and `on_mutation()` can return values. These returned values override the corresponding properties. The output of `on_fitness()` overrides the population fitness. The `on_parents()` function/method must return 2 values representing the parents and their indices. The output of `on_crossover()` overrides the crossover offspring. The output of `on_mutation()` overrides the mutation offspring. -12. Fix a bug when adaptive mutation is used while `fitness_batch_size`>1. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195 -13. When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happen that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene by another gene. This release tries to solve such duplicates by looking for a third gene that will help in solving the duplicates. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) for more information. -14. Use probabilities to select parents using the rank parent selection method. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/205 -15. The 2 parameters `random_mutation_min_val` and `random_mutation_max_val` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the mutation range for each individual gene. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 -16. The 2 parameters `init_range_low` and `init_range_high` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the initial range for each individual gene when creating the initial population. -17. The `data` parameter in the `predict()` function of the `pygad.kerasga` module can be assigned a data generator. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/115 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 -18. The `predict()` function of the `pygad.kerasga` module accepts 3 optional parameters: 1) `batch_size=None`, `verbose=0`, and `steps=None`. Check documentation of the [Keras Model.predict()](https://keras.io/api/models/model_training_apis) method for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 -19. The documentation is updated to explain how mutation works when `gene_space` is used with `int` or `float` data types. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter). https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 +1. A bug fix in assigning the value to the `initial_population` parameter. +2. A new parameter named `gene_type` is added to control the gene type. It can be either `int` or `float`. It has an effect only when the parameter `gene_space` is `None`. +3. 7 new parameters that accept callback functions: `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. -## PyGAD 3.2.0 +## PyGAD 2.5.0 -Release Date 7 September 2023 +Release date: 19 July 2020 -1. A new module `pygad.utils.nsga2` is created that has the `NSGA2` class that includes the functionalities of NSGA-II. The class has these methods: 1) `get_non_dominated_set()` 2) `non_dominated_sorting()` 3) `crowding_distance()` 4) `sort_solutions_nsga2()`. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. -2. Support of multi-objective optimization using Non-Dominated Sorting Genetic Algorithm II (NSGA-II) using the `NSGA2` class in the `pygad.utils.nsga2` module. Just return a `list`, `tuple`, or `numpy.ndarray` from the fitness function and the library will consider the problem as multi-objective optimization. All the objectives are expected to be maximization. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. -3. The parent selection methods and adaptive mutation are edited to support multi-objective optimization. -4. Two new NSGA-II parent selection methods are supported in the `pygad.utils.parent_selection` module: 1) Tournament selection for NSGA-II 2) NSGA-II selection. -5. The `plot_fitness()` method in the `pygad.plot` module has a new optional parameter named `label` to accept the label of the plots. This is only used for multi-objective problems. Otherwise, it is ignored. It defaults to `None` and accepts a `list`, `tuple`, or `numpy.ndarray`. The labels are used in a legend inside the plot. -6. The default color in the methods of the `pygad.plot` module is changed to the greenish `#64f20c` color. -7. A new instance attribute named `pareto_fronts` added to the `pygad.GA` instances that holds the pareto fronts when solving a multi-objective problem. -8. The `gene_type` accepts a `list`, `tuple`, or `numpy.ndarray` for integer data types given that the precision is set to `None` (e.g. `gene_type=[float, [int, None]]`). -9. In the `cal_pop_fitness()` method, the fitness value is re-used if `save_best_solutions=True` and the solution is found in the `best_solutions` attribute. These parameters also can help re-using the fitness of a solution instead of calling the fitness function: `keep_elitism`, `keep_parents`, and `save_solutions`. -10. The value `99999999999` is replaced by `float('inf')` in the 2 methods `wheel_cumulative_probs()` and `stochastic_universal_selection()` inside the `pygad.utils.parent_selection.ParentSelection` class. -11. The `plot_result()` method in the `pygad.visualize.plot.Plot` class is removed. Instead, please use the `plot_fitness()` if you did not upgrade yet. +1. 2 new optional parameters added to the constructor of the `pygad.GA` class which are `crossover_probability` and `mutation_probability`. + While applying the crossover operation, each parent has a random value generated between 0.0 and 1.0. If this random value is less than or equal to the value assigned to the `crossover_probability` parameter, then the parent is selected for the crossover operation. + For the mutation operation, a random value between 0.0 and 1.0 is generated for each gene in the solution. If this value is less than or equal to the value assigned to the `mutation_probability`, then this gene is selected for mutation. +2. A new optional parameter named `linewidth` is added to the `plot_result()` method to specify the width of the curve in the plot. It defaults to 3.0. +3. Previously, the indices of the genes selected for mutation was randomly generated once for all solutions within the generation. Currently, the genes' indices are randomly generated for each solution in the population. If the population has 4 solutions, the indices are randomly generated 4 times inside the single generation, 1 time for each solution. +4. Previously, the position of the point(s) for the single-point and two-points crossover was(were) randomly selected once for all solutions within the generation. Currently, the position(s) is(are) randomly selected for each solution in the population. If the population has 4 solutions, the position(s) is(are) randomly generated 4 times inside the single generation, 1 time for each solution. +5. A new optional parameter named `gene_space` as added to the `pygad.GA` class constructor. It is used to specify the possible values for each gene in case the user wants to restrict the gene values. It is useful if the gene space is restricted to a certain range or to discrete values. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for requesting this useful feature. -## PyGAD 3.3.0 +## PyGAD 2.4.0 -Release Date 29 January 2024 +Release date: 5 July 2020 -1. Solve bugs when multi-objective optimization is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 -2. When the `stop_ciiteria` parameter is used with the `reach` keyword, then multiple numeric values can be passed when solving a multi-objective problem. For example, if a problem has 3 objective functions, then `stop_criteria="reach_10_20_30"` means the GA stops if the fitness of the 3 objectives are at least 10, 20, and 30, respectively. The number values must match the number of objective functions. If a single value found (e.g. `stop_criteria=reach_5`) when solving a multi-objective problem, then it is used across all the objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 -3. The `delay_after_gen` parameter is now deprecated and will be removed in a future release. If it is necessary to have a time delay after each generation, then assign a callback function/method to the `on_generation` parameter to pause the evolution. -4. Parallel processing now supports calculating the fitness during adaptive mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201 -5. The population size can be changed during runtime by changing all the parameters that would affect the size of any thing used by the GA. For more information, check the [Change Population Size during Runtime](https://pygad.readthedocs.io/en/latest/generations.html#change-population-size-during-runtime) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/234 -6. When a dictionary exists in the `gene_space` parameter without a step, then mutation occurs by adding a random value to the gene value. The random vaue is generated based on the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. For more information, check the [How Mutation Works with the gene_space Parameter?](https://pygad.readthedocs.io/en/latest/gene_values.html#how-mutation-works-with-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/229 -7. Add `object` as a supported data type for int (GA.supported_int_types) and float (GA.supported_float_types). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/174 -8. Use the `raise` clause instead of the `sys.exit(-1)` to terminate the execution. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/213 -9. Fix a bug when multi-objective optimization is used with batch fitness calculation (e.g. `fitness_batch_size` set to a non-zero number). -10. Fix a bug in the `pygad.py` script when finding the index of the best solution. It does not work properly with multi-objective optimization where `self.best_solutions_fitness` have multiple columns. +1. A new parameter named `delay_after_gen` is added which accepts a non-negative number specifying the time in seconds to wait after a generation completes and before going to the next generation. It defaults to `0.0` which means no delay after the generation. + +2. The passed function to the `callback_generation` parameter of the pygad.GA class constructor can terminate the execution of the genetic algorithm if it returns the string `stop`. This causes the `run()` method to stop. + +One important use case for that feature is to stop the genetic algorithm when a condition is met before passing though all the generations. The user may assigned a value of 100 to the `num_generations` parameter of the pygad.GA class constructor. Assuming that at generation 50, for example, a condition is met and the user wants to stop the execution before waiting the remaining 50 generations. To do that, just make the function passed to the `callback_generation` parameter to return the string `stop`. + +Here is an example of a function to be passed to the `callback_generation` parameter which stops the execution if the fitness value 70 is reached. The value 70 might be the best possible fitness value. After being reached, then there is no need to pass through more generations because no further improvement is possible. ```python - self.best_solution_generation = numpy.where(numpy.array( - self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + def func_generation(ga_instance): + if ga_instance.best_solution()[1] >= 70: + return "stop" ``` -## PyGAD 3.3.1 +## PyGAD 2.3.0 -Release Date 17 February 2024 +Release date: 1 June 2020 -1. After the last generation and before the `run()` method completes, update the 2 instance attributes: 1) `last_generation_parents` 2) `last_generation_parents_indices`. This is to keep the list of parents up-to-date with the latest population fitness `last_generation_fitness`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/275 -2. 5 methods with names starting with `run_`. Their purpose is to keep the main loop inside the `run()` method clean. Check the [Other Methods](https://pygad.readthedocs.io/en/latest/pygad.html#other-methods) section for more information. - 1. `run_loop_head()`: The code before the loop starts. - 2. `run_select_parents()`: The parent selection-related code. - 3. `run_crossover()`: The crossover-related code. - 4. `run_mutation()`: The mutation-related code. - 5. `run_update_population()`: Update the `population` instance attribute after completing the processes of crossover and mutation. +1. A new module named `pygad.cnn` is supported for building convolutional neural networks. +2. A new module named `pygad.gacnn` is supported for training convolutional neural networks using the genetic algorithm. +3. The `pygad.plot_result()` method has 3 optional parameters named `title`, `xlabel`, and `ylabel` to customize the plot title, x-axis label, and y-axis label, respectively. +4. The `pygad.nn` module supports the softmax activation function. +5. The name of the `pygad.nn.predict_outputs()` function is changed to `pygad.nn.predict()`. +6. The name of the `pygad.nn.train_network()` function is changed to `pygad.nn.train()`. +## PyGAD 2.2.2 -## PyGAD 3.4.0 +Release Date: 18 May 2020 +1. The initial value of the `generations_completed` attribute of instances from the pygad.GA class is `0` rather than `None`. -Release Date 07 January 2025 +2. An optional bool parameter named `mutation_by_replacement` is added to the constructor of the pygad.GA class. 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. This parameter should be used when the gene falls within a fixed range and its value must not go out of this range. Here are some examples: -1. The `delay_after_gen` parameter is removed from the `pygad.GA` class constructor. As a result, it is no longer an attribute of the `pygad.GA` class instances. To add a delay after each generation, apply it inside the `on_generation` callback. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/283 -2. In the `single_point_crossover()` method of the `pygad.utils.crossover.Crossover` class, all the random crossover points are returned before the `for` loop. This is by calling the `numpy.random.randint()` function only once before the loop to generate all the K points (where K is the offspring size). This is compared to calling the `numpy.random.randint()` function inside the `for` loop K times, once for each individual offspring. -3. Bug fix in the `examples/example_custom_operators.py` script. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/285 -4. While making prediction using the `pygad.torchga.predict()` function, no gradients are calculated. -5. The `gene_type` parameter of the `pygad.helper.unique.Unique.unique_int_gene_from_range()` method accepts the type of the current gene only instead of the full gene_type list. -6. Created a new method called `unique_float_gene_from_range()` inside the `pygad.helper.unique.Unique` class to find a unique floating-point number from a range. -7. Fix a bug in the `pygad.helper.unique.Unique.unique_gene_by_space()` method to return the numeric value only instead of a NumPy array. -8. Refactoring the `pygad/helper/unique.py` script to remove duplicate codes and reformatting the docstrings. -9. The `plot_pareto_front_curve()` method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 -11. Fix a bug converting a nested NumPy array to a nested list. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/300 -12. The `Matplotlib` library is only imported when a method inside the `pygad/visualize/plot.py` script is used. This is more efficient than using `import matplotlib.pyplot` at the module level as this causes it to be imported when `pygad` is imported even when it is not needed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/292 -13. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter (e.g. `stop_criteria=["saturate_10", "reach_-0.5"]`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/296 -14. Make sure `self.best_solutions` is a list of lists inside the `cal_pop_fitness` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/293 -15. Fix a bug where the `cal_pop_fitness()` method was using the `previous_generation_fitness` attribute to return the parents fitness. This instance attribute was not using the fitness of the latest population, instead the fitness of the population before the last one. The issue is solved by updating the `previous_generation_fitness` attribute to the latest population fitness before the GA completes. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/291 + Assume there is a gene with the value 0.5. -## PyGAD 3.5.0 + If `mutation_type="random"` and `mutation_by_replacement=False`, then the generated random value (e.g. 0.1) will be added to the gene value. The new gene value is **0.5+0.1=0.6**. -Release Date 08 July 2025 + If `mutation_type="random"` and `mutation_by_replacement=True`, then the generated random value (e.g. 0.1) will replace the gene value. The new gene value is **0.1**. -1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/323 -2. Fix a bug when the `stop_criteria` parameter is passed as an iterable (e.g. list) for multi-objective problems (e.g. `['reach_50_60', 'reach_20, 40']`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 -3. Call the `get_matplotlib()` function from the `plot_genes()` method inside the `pygad.visualize.plot.Plot` class to import the matplotlib library. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/315 -4. Create a new helper method called `select_unique_value()` inside the `pygad/helper/unique.py` script to select a unique gene from an array of values. -5. Create a new helper method called `get_random_mutation_range()` inside the `pygad/utils/mutation.py` script that returns the random mutation range (min and max) for a single gene by its index. -6. Create a new helper method called `change_random_mutation_value_dtype` inside the `pygad/utils/mutation.py` script that changes the data type of the value used to apply random mutation. -7. Create a new helper method called `round_random_mutation_value()` inside the `pygad/utils/mutation.py` script that rounds the value used to apply random mutation. -8. Create the `pygad/helper/misc.py` script with a class called `Helper` that has the following helper methods: - 1. `change_population_dtype_and_round()`: For each gene in the population, round the gene value and change the data type. - 2. `change_gene_dtype_and_round()`: Round the change the data type of a single gene. - 3. `mutation_change_gene_dtype_and_round()`: Decides whether mutation is done by replacement or not. Then it rounds and change the data type of the new gene value. - 4. `validate_gene_constraint_callable_output()`: Validates the output of the user-defined callable/function that checks whether the gene constraint defined in the `gene_constraint` parameter is satisfied or not. - 5. `get_gene_dtype()`: Returns the gene data type from the `gene_type` instance attribute. - 6. `get_random_mutation_range()`: Returns the random mutation range using the `random_mutation_min_val` and `random_mutation_min_val` instance attributes. - 7. `get_initial_population_range()`: Returns the initial population values range using the `init_range_low` and `init_range_high` instance attributes. - 8. `generate_gene_value_from_space()`: Generates/selects a value for a gene using the `gene_space` instance attribute. - 9. `generate_gene_value_randomly()`: Generates a random value for the gene. Only used if `gene_space` is `None`. - 10. `generate_gene_value()`: Generates a value for the gene. It checks whether `gene_space` is `None` and calls either `generate_gene_value_randomly()` or `generate_gene_value_from_space()`. - 11. `filter_gene_values_by_constraint()`: Receives a list of values for a gene. Then it filters such values using the gene constraint. - 12. `get_valid_gene_constraint_values()`: Selects one valid gene value that satisfy the gene constraint. It simply calls `generate_gene_value()` to generate some gene values then it filters such values using `filter_gene_values_by_constraint()`. -9. Create a new helper method called `mutation_process_random_value()` inside the `pygad/utils/mutation.py` script that generates constrained random values for mutation. It calls either `generate_gene_value()` or `get_valid_gene_constraint_values()` based on whether the `gene_constraint` parameter is used or not. -10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/119 -11. A new parameter called `sample_size` is added. To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either `allow_duplicate_genes` or `gene_constraint` is used. An instance attribute of the same name is created in the instances of the `pygad.GA` class. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. -12. Use the `sample_size` parameter instead of `num_trials` in the methods `solve_duplicate_genes_randomly()` and `unique_float_gene_from_range()` inside the `pygad/helper/unique.py` script. It is the maximum number of values to generate as the search space when looking for a unique float value out of a range. -13. Fixed a bug in population initialization when `allow_duplicate_genes=False`. Previously, gene values were checked for duplicates before rounding, which could allow near-duplicates like 7.61 and 7.62 to pass. After rounding (e.g., both becoming 7.6), this resulted in unintended duplicates. The fix ensures gene values are now rounded before duplicate checks, preventing such cases. -14. More tests are created. -15. More examples are created. -16. Edited the `sort_solutions_nsga2()` method in the `pygad/utils/nsga2.py` script to accept an optional parameter called `find_best_solution` when calling this method just to find the best solution. -17. Fixed a bug while applying the non-dominated sorting in the `get_non_dominated_set()` method inside the `pygad/utils/nsga2.py` script. It was swapping the non-dominated and dominated sets. In other words, it used the non-dominated set as if it is the dominated set and vice versa. All the calls to this method were edited accordingly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/320. -18. Fix a bug retrieving in the `best_solution()` method when retrieving the best solution for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/331 +3. `None` value could be assigned to the `mutation_type` and `crossover_type` parameters of the pygad.GA class constructor. When `None`, this means the step is bypassed and has no action. -## PyGAD 3.6.0 +## PyGAD 2.2.1 -Release Date April 8, 2026 +Release Date: 17 May 2020 -1. Support passing a class to the fitness, crossover, and mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/342 -2. A new class called `Validation` is created in the new `pygad/utils/validation.py` script. It has a method called `validate_parameters()` to validate all the parameters passed while instantiating the `pygad.GA` class. -3. Refactoring the `pygad.py` script by moving a lot of functions and methods to other classes in other scripts. - 4. The `summary()` method was moved to `Helper` class in the `pygad/helper/misc.py` script. - 5. The validation code in the `__init__()` method of the `pygad.GA` class is moved to the new `validate_parameters()` method in the new `Validation` class in the new `pygad/utils/validation.py` script. Moreover, the `validate_multi_stop_criteria()` method is also moved to the same class. - 6. The GA main workflow is moved into the new `GAEngine` class in the new `pygad/utils/engine.py` script. Specifically, these methods are moved from the `pygad.GA` class to the new `GAEngine` class: - 1. `run()` - 1. `run_loop_head()` - 2. `run_select_parents()` - 3. `run_crossover()` - 4. `run_mutation()` - 5. `run_update_population()` - 2. `initialize_population()` - 3. `cal_pop_fitness()` - 4. `best_solution()` - 5. `round_genes()` -7. The `pygad.GA` class now extends the two new classes `utils.validation.Validation` and `utils.engine.GAEngine`. -8. The version of the `pygad.utils` submodule is upgraded from `1.3.0` to `1.4.0`. -9. The version of the `pygad.helper` submodule is upgraded from `1.2.0` to `1.3.0`. -10. The version of the `pygad.visualize` submodule is upgraded from `1.1.0` to `1.1.1`. -11. The version of the `pygad.nn` submodule is upgraded from `1.2.1` to `1.2.2`. -12. The version of the `pygad.cnn` submodule is upgraded from `1.1.0` to `1.1.1`. -13. The version of the `pygad.torchga` submodule is upgraded from `1.4.0` to `1.4.1`. -14. The version of the `pygad.kerasga` submodule is upgraded from `1.3.0` to `1.3.1`. -15. Update the elitism after the evolution ends to fix issue where the best solution returned by the `best_solution()` method is not correct. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/337 -16. Fix a bug in calling the `numpy.reshape()` function. The parameter `newshape` is removed since it is no longer supported started from NumPy `2.4.0`. https://numpy.org/doc/stable/release/2.4.0-notes.html#removed-newshape-parameter-from-numpy-reshape -17. A minor change in the documentation is made to replace the `newshape` parameter when calling `numpy.reshape()`. -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`. -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. +1. Adding 2 extra modules (pygad.nn and pygad.gann) for building and training neural networks with the genetic algorithm. -## PyGAD 3.7.0 +## PyGAD 2.1.0 -Release Date June 5, 2026 +Release Date: 14 May 2020 -Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). +1. The `best_solution()` method in the **pygad.GA** class returns a new output representing the index of the best solution within the population. Now, it returns a total of 3 outputs and their order is: best solution, best solution fitness, and best solution index. Here is an example: +```python +solution, solution_fitness, solution_idx = ga_instance.best_solution() +print("Parameters of the best solution :", solution) +print("Fitness value of the best solution :", solution_fitness, "\n") +print("Index of the best solution :", solution_idx, "\n") +``` -```{raw} html - +2. A new attribute named `best_solution_generation` is added to the instances of the **pygad.GA** class. it holds the generation number at which the best solution is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. +Example: +```python +print("Best solution reached after {best_solution_generation} generations.".format(best_solution_generation=ga_instance.best_solution_generation)) ``` -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: - 1. `nn` - 2. `cnn` - 3. `gacnn` - 4. `kerasga` - 5. `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. -35. Version changed in the following modules: - 1. A new submodule `pygad.benchmarks` is added with the version `1.0.0`. - 2. The version of the `pygad.utils` submodule is upgraded from `1.4.0` to `1.5.0`. - 3. The version of the `pygad.helper` submodule is upgraded from `1.3.0` to `1.4.0`. - 4. The version of the `pygad.visualize` submodule is upgraded from `1.1.1` to `1.2.0`. - 5. The version of the `pygad.nn` submodule is upgraded from `1.2.2` to `1.2.3`. - 6. The version of the `pygad.cnn` submodule is upgraded from `1.1.1` to `1.1.2`. - 7. The version of the `pygad.kerasga` submodule is upgraded from `1.3.1` to `1.3.2`. - 8. The version of the `pygad.torchga` submodule is upgraded from `1.4.1` to `1.4.2`. - 9. The version of the `pygad.gann` submodule is upgraded from `1.0.0` to `1.0.1`. - 10. The version of the `pygad.gacnn` submodule is upgraded from `1.0.0` to `1.0.1`. -36. The PDF report built by `generate_report()` now shows the PyGAD logo on its title page. The logo image ships with the package, so no network access is needed. If the image file is missing, the report is built without it. -37. Two private helper functions are added to the `pygad/utils/report.py` script for the logo. `_pdf_report_read_logo_bytes()` reads the bundled logo file and returns its bytes, or `None` if the file is missing. `_pdf_report_build_logo_image()` builds the image that is placed on the title page, or returns `None` so the report still builds without the logo. -38. The private helper functions in the `pygad/utils/report.py` script are renamed to start with the `_pdf_report_` prefix so their purpose is clear from the name. For example, `_build_title_section()` becomes `_pdf_report_build_title_section()` and `_render_plot_to_png()` becomes `_pdf_report_render_plot_to_png()`. +3. The `best_solution_fitness` attribute is renamed to `best_solutions_fitness` (plural solution). +4. Mutation is applied independently for the genes. -## Unreleased +## PyGAD 2.0.0 -These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. +Release Date: 13 May 2020 -1. Two-point crossover selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). -2. Swap mutation can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). -3. SBX crossover selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). -4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). -5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. -6. Parallel fitness evaluation now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. -7. Serial, thread, and process modes use the same fitness-cache rules and result validation. Adaptive mutation evaluates the actual offspring, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). -8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). -9. `pygad.kerasga.predict()` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). -10. Stochastic universal selection uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). -11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). -12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). -13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. -14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. +1. The fitness function accepts a new argument named `sol_idx` representing the index of the solution within the population. +2. A new parameter to the **pygad.GA** class constructor named `initial_population` is supported to allow the user to use a custom initial population to be used by the genetic algorithm. If not None, then the passed population will be used. If `None`, then the genetic algorithm will create the initial population using the `sol_per_pop` and `num_genes` parameters. +3. The parameters `sol_per_pop` and `num_genes` are optional and set to `None` by default. +4. A new parameter named `callback_generation` is introduced in the **pygad.GA** class constructor. It accepts a function with a single parameter representing the **pygad.GA** class instance. This function is called after each generation. This helps the user to do post-processing or debugging operations after each generation. -15. Duplicate-gene repair now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. -16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. -17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. +## PyGAD 1.0.20 -18. Initial population creation and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. +Release Date: 4 May 2020 -19. Gene-type validation and conversion share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. +1. The **pygad.GA** attributes are moved from the class scope to the instance scope. +2. Raising an exception for incorrect values of the passed parameters. +3. Two new parameters are added to the **pygad.GA** class constructor (`init_range_low` and `init_range_high`) allowing the user to customize the range from which the genes values in the initial population are selected. +4. The code object `__code__` of the passed fitness function is checked to ensure it has the right number of parameters. -20. Constructor validation shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. Stop criteria share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. -21. Only the active mutation control is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. -22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. -23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. +## PyGAD 1.0.17 -24. The new `best_solutions_generations` and `solutions_generations` attributes record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. -25. `saturate_N` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. -26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. -27. Fitness validation is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. -28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. -29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. -30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. +Release Date: 15 April 2020 -The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. +1. The **pygad.GA** class accepts a new argument named `fitness_func` which accepts a function to be used for calculating the fitness values for the solutions. This allows the project to be customized to any problem by building the right fitness function. From 00ddf44497208514a407cf765a644999fd8684db Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 11:13:30 -0400 Subject: [PATCH 10/22] Complete generation guides for randomness and saved histories --- docs/source/generations.md | 49 ++++++++++++++++++++++++++++++++------ docs/source/releases.md | 2 ++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/source/generations.md b/docs/source/generations.md index 4793761d..b957b87a 100644 --- a/docs/source/generations.md +++ b/docs/source/generations.md @@ -35,7 +35,11 @@ The supported words are `reach`, `saturate`, `time`, and `evaluations`. The `reach` word stops the `run()` method if the fitness value is equal to or greater than a given fitness value. An example for `reach` is `"reach_40"` which stops the evolution if the fitness is >= 40. -`saturate` stops the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. +`saturate` stops the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. + +The initial population's best fitness is the baseline. `saturate_1` stops after one completed generation with unchanged best fitness. Any change resets the count, even if a later generation returns to an earlier fitness value. Multi-objective problems compare the entire best-fitness vector. Every `run()` starts a new saturation count while `generations_completed` continues from the previous run. + +The counts for `saturate` and `evaluations` must be positive integers. The `reach` threshold must be finite, and the `time` duration must be finite and non-negative. Scientific notation is accepted, for example `"evaluations_1e3"` or `"time_1e-2"`. `time` stops after the elapsed runtime reaches the specified seconds, for example `"time_30"`. `evaluations` stops after the number of evaluated solutions reaches its threshold, for example `"evaluations_1000"`. This count includes adaptive mutation's offspring evaluations and counts every solution in a fitness batch. Reusing a cached fitness value contributes zero. Both criteria are checked after a generation, so runtime and evaluation count can exceed their thresholds. @@ -200,7 +204,7 @@ In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-1 1. NumPy 2. random -The `random_seed` parameter defaults to `None` which means no seed is used. As a result, different random numbers are generated for each run of PyGAD. +Each GA instance owns its NumPy and Python random generators. Creating or running another GA, or drawing from the global generators, does not change that instance's random state. The `random_seed` parameter accepts Python and NumPy integer seeds. It defaults to `None`, so separate instances can generate different random values. If this parameter is assigned a proper seed, then the results will be reproducible. In the next example, the integer 2 is used as a random seed. @@ -232,17 +236,38 @@ print(best_solution_fitness) This is the best solution found and its fitness value. ``` -[ 2.77249188 -4.06570662 0.04196872 -3.47770796 -0.57502138 -3.22775267] -0.04872203136549972 +[ 2.77249188 -3.36283618 0.62335921 -0.51742086 -0.63705758 -1.35732143] +0.0757422952817227 ``` After running the code again, it will find the same result. ``` -[ 2.77249188 -4.06570662 0.04196872 -3.47770796 -0.57502138 -3.22775267] -0.04872203136549972 +[ 2.77249188 -3.36283618 0.62335921 -0.51742086 -0.63705758 -1.35732143] +0.0757422952817227 +``` + +Reproducibility applies within the same PyGAD version and environment. Changes to operators can produce different results from earlier versions with the same seed. Repeated calls to `run()` continue the existing generator states rather than restarting from the seed, and saving and loading a GA preserves those states. + +### Random Choices in Custom Operators and Callbacks + +Use `ga_instance.numpy_random_generator` (a `numpy.random.RandomState`) or `ga_instance.python_random_generator` (a `random.Random`) for reproducible random choices in user code. For example, this custom mutation selects one gene per offspring and draws its replacement from the GA's NumPy generator: + +```python +def custom_mutation(offspring, ga_instance): + for solution in offspring: + gene_index = ga_instance.numpy_random_generator.randint(ga_instance.num_genes) + solution[gene_index] = ga_instance.numpy_random_generator.uniform(-1.0, 1.0) + return offspring + + +ga_instance = pygad.GA(..., + mutation_type=custom_mutation, + random_seed=2) ``` +The custom operator must choose values appropriate for the problem's gene spaces and constraints. Calls to global `numpy.random` or `random` functions in user code need their own seeds; `random_seed` does not seed these global generators. A complete example of independent seeded instances is available at [`examples/example_constructor_parameters.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_constructor_parameters.py). + ## Continue without Losing Progress In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0), and thanks for [Felix Bernhard](https://github.com/FeBe95) for opening [this GitHub issue](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123#issuecomment-1203035106), the values of these 4 instance attributes are no longer reset after each call to the `run()` method. @@ -290,7 +315,17 @@ loaded_ga_instance.plot_fitness() The plot created by the `plot_fitness()` method will show the data collected from both the runs. -Note that the 2 attributes (`self.best_solutions` and `self.best_solutions_fitness`) only work if the `save_best_solutions` parameter is set to `True`. Also, the 2 attributes (`self.solutions` and `self.solutions_fitness`) only work if the `save_solutions` parameter is `True`. +`best_solutions_fitness` is collected regardless of `save_best_solutions`. Set `save_best_solutions=True` to save the corresponding gene values in `best_solutions`. The `solutions` and `solutions_fitness` histories require `save_solutions=True`. + +### Generation Numbers in Saved Histories + +`num_generations` specifies how many additional generations each `run()` can complete. `generations_completed` records the cumulative count. With `num_generations=2`, two completed runs leave `generations_completed=4`. + +`best_solutions_generations` records the actual generation number for each entry in `best_solutions_fitness`. Both the starting and final snapshots of each run are kept, so two runs of 2 generations produce `[0, 1, 2, 2, 3, 4]`. The two entries for generation 2 represent the final snapshot of the first run and the starting snapshot of the second run. + +With `save_solutions=True`, `solutions_generations` records one generation number per saved population, while `solutions` and `solutions_fitness` keep one entry per solution. `best_solution_generation` reports the actual generation of the best saved fitness, rather than its position in the history. History plots and PDF reports use these generation numbers. + +Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See {ref}`Saved Fitness across Repeated Runs ` for callback behavior and checkpoint compatibility, and [`examples/example_repeated_runs.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_repeated_runs.py) for a complete checkpoint example. ## Change Population Size during Runtime diff --git a/docs/source/releases.md b/docs/source/releases.md index 1b72b39a..c4ee1462 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -46,6 +46,8 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. +32. The generation guide explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. ## PyGAD 3.7.0 From 577ba5f18f6a79d041b238f66c6f187164e3a7f5 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 11:52:43 -0400 Subject: [PATCH 11/22] Connect documentation guides to a shared Python examples catalog --- docs/PYTHON_EXAMPLES.md | 33 + docs/python_example_templates/card.md.jinja | 61 ++ docs/python_example_templates/index.md.jinja | 5 + docs/python_example_templates/table.md.jinja | 5 + docs/python_examples.json | 781 +++++++++++++++++++ docs/python_examples.py | 110 +++ docs/source/_static/custom.css | 26 +- docs/source/benchmarks.md | 36 +- docs/source/cnn.md | 4 + docs/source/conf.py | 17 + docs/source/custom_functions.md | 12 + docs/source/examples.md | 26 + docs/source/fitness_calculation.md | 21 +- docs/source/gacnn.md | 4 + docs/source/gann_image_classification.md | 4 + docs/source/gann_regression_1.md | 4 + docs/source/gann_regression_2.md | 4 + docs/source/gann_xor.md | 4 + docs/source/gene_values.md | 20 +- docs/source/generations.md | 16 +- docs/source/index.md | 1 + docs/source/kerasga_image_conv.md | 4 + docs/source/kerasga_image_datagen.md | 5 + docs/source/kerasga_image_dense.md | 4 + docs/source/kerasga_regression.md | 4 + docs/source/kerasga_xor.md | 4 + docs/source/lifecycle.md | 10 +- docs/source/logging.md | 12 + docs/source/multi_objective.md | 5 + docs/source/nn_image_classification.md | 5 + docs/source/nn_regression_1.md | 4 + docs/source/nn_regression_2.md | 4 + docs/source/nn_xor.md | 4 + docs/source/pygad.md | 21 +- docs/source/releases.md | 2 + docs/source/steps_to_use.md | 4 + docs/source/torchga_image_conv.md | 4 + docs/source/torchga_image_dense.md | 4 + docs/source/torchga_regression.md | 4 + docs/source/torchga_xor.md | 4 + docs/source/user_defined_operators.md | 4 + docs/source/utils.md | 13 +- docs/source/visualize.md | 53 +- examples/data/README.md | 2 +- 44 files changed, 1355 insertions(+), 19 deletions(-) create mode 100644 docs/PYTHON_EXAMPLES.md create mode 100644 docs/python_example_templates/card.md.jinja create mode 100644 docs/python_example_templates/index.md.jinja create mode 100644 docs/python_example_templates/table.md.jinja create mode 100644 docs/python_examples.json create mode 100644 docs/python_examples.py create mode 100644 docs/source/examples.md diff --git a/docs/PYTHON_EXAMPLES.md b/docs/PYTHON_EXAMPLES.md new file mode 100644 index 00000000..8f22dbb2 --- /dev/null +++ b/docs/PYTHON_EXAMPLES.md @@ -0,0 +1,33 @@ +# Connecting Python Examples to the Documentation + +`python_examples.json` is the shared catalog for the Examples index and the Python example cards in the guides. Keep the descriptions and requirements here rather than copying them into each guide. Paths are relative to the repository's `examples/` directory. + +To show one or more examples beside a relevant explanation, use this template in a documentation page: + +```markdown +:::{python-examples} +example_initial_population.py +example_gene_type_conversion.py +::: +``` + +For larger groups, the same directive uses a compact table inside the card. Run instructions remain in a dropdown. The Examples index uses `python-examples-index` to list every entry by topic, with links back to its guide. + +Each catalog entry has these fields: + +- `path`: Existing Python script or notebook under `examples/`. +- `title`: Short descriptive name for the example. +- `description`: What readers will learn from the script. +- `category`: Topic heading in the Examples index. Categories follow their first appearance in the catalog. +- `guide`: Existing Markdown guide, relative to `docs/source/`. +- `requirements`: Libraries or optional extras needed in addition to a matching version of PyGAD. +- `run`: Command to run from the repository root, or an empty string for a notebook. +- `run_note` (optional): Working-directory instructions when the root cannot be used directly. +- `data` (optional): Dataset filenames, expected layout, and any setup limitations. +- `download` (optional, default `true`): Set to `false` when downloading a script alone would omit required data or companion files. Readers receive a folder link instead. + +The shared Markdown templates are in `python_example_templates/`. They use the existing Sphinx Design cards and dropdowns and Sphinx's native download links. The small `python_examples.py` extension resolves catalog paths and renders those templates; it does not execute example scripts. + +The documentation build checks that every Python script appears in the catalog, all catalog paths stay inside `examples/`, and the linked guides exist. Missing entries fail the build so new examples are not silently left out. Unknown paths in a guide also fail the build. Sphinx copies downloadable scripts from the repository into the built documentation; no second source copy needs to be maintained. + +GitHub links use the commit checked out for the documentation build. Without Git, the configured Read the Docs identifier is used, falling back to `master`. This keeps source links aligned with versioned documentation. diff --git a/docs/python_example_templates/card.md.jinja b/docs/python_example_templates/card.md.jinja new file mode 100644 index 00000000..28e00c77 --- /dev/null +++ b/docs/python_example_templates/card.md.jinja @@ -0,0 +1,61 @@ +:::::{card} Python example{% if examples | length > 1 %}s{% endif %} +:class-card: python-example +{% if examples | length > 3 %} + +{% include 'table.md.jinja' %} + +:::{dropdown} Run these examples +{% for example in examples %} + +**{{ example.title }}** — Requires: {{ example.requirements }} +{% if example.data %} + +Data: {{ example.data }} See the [dataset setup instructions]({{ example.data_url }}). +{% endif %} +{% if example.run %} + +{{ example.run_note | default('From the repository root, with the repository version of PyGAD installed:') }} + +```console +{{ example.run }} +``` +{% endif %} +{% endfor %} +::: +{% else %} +{% for example in examples %} + +**{{ example.title }}** + +{{ example.description }} + +`examples/{{ example.path }}` + +:::{dropdown} Run this example + +**Requires:** {{ example.requirements }} +{% if example.data %} + +**Data:** {{ example.data }} See the [dataset setup instructions]({{ example.data_url }}). Use the repository folder layout rather than downloading this script alone. +{% endif %} +{% if example.run %} + +{{ example.run_note | default('From the repository root, with the repository version of PyGAD installed:') }} + +```console +{{ example.run }} +``` +{% endif %} +::: +{% if examples | length == 1 %} + ++++ +{% endif %} +[View script on GitHub]({{ example.source_url }}){% if example.download | default(true) %} · {download}`Download Python script <{{ example.download_path }}>`{% else %} · [Open example folder]({{ example.folder_url }}){% endif %} +{% if not loop.last %} + +--- +{% endif %} +{% endfor %} +{% endif %} +::::: diff --git a/docs/python_example_templates/index.md.jinja b/docs/python_example_templates/index.md.jinja new file mode 100644 index 00000000..8840d91c --- /dev/null +++ b/docs/python_example_templates/index.md.jinja @@ -0,0 +1,5 @@ +{% for category, examples in categories %} +## {{ category }} + +{% include 'table.md.jinja' %} +{% endfor %} diff --git a/docs/python_example_templates/table.md.jinja b/docs/python_example_templates/table.md.jinja new file mode 100644 index 00000000..dbbb30e2 --- /dev/null +++ b/docs/python_example_templates/table.md.jinja @@ -0,0 +1,5 @@ +| Python script | What it shows | Related information | +| --- | --- | --- | +{% for example in examples -%} +| [{{ example.path }}]({{ example.source_url }}) | **{{ example.title }}.** {{ example.description }}{% if example.data %} Requires external data.{% endif %} | [Guide]({{ example.guide }}){% if example.download | default(true) %} · {download}`Download <{{ example.download_path }}>`{% else %} · [Folder]({{ example.folder_url }}) · [Data setup]({{ example.data_url }}){% endif %} | +{% endfor %} diff --git a/docs/python_examples.json b/docs/python_examples.json new file mode 100644 index 00000000..e285ba82 --- /dev/null +++ b/docs/python_examples.json @@ -0,0 +1,781 @@ +[ + { + "path": "example.py", + "title": "First GA run", + "description": "Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA.", + "category": "Getting Started", + "guide": "steps_to_use.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example.py" + }, + { + "path": "example_initial_population.py", + "title": "Initial populations", + "description": "Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_initial_population.py" + }, + { + "path": "example_gene_space.py", + "title": "Gene spaces", + "description": "Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_space.py" + }, + { + "path": "example_gene_constraint.py", + "title": "Gene constraints", + "description": "Filter gene candidates with constraints that depend on other genes.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_constraint.py" + }, + { + "path": "example_duplicate_gene_repair.py", + "title": "Duplicate repair", + "description": "Repair duplicates through a chain of replacements while respecting each gene space.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_duplicate_gene_repair.py" + }, + { + "path": "example_gene_type_conversion.py", + "title": "Gene types and rounding", + "description": "Preserve mixed numeric types, apply precision, and convert custom mutation outputs.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_type_conversion.py" + }, + { + "path": "example_dynamic_population_size.py", + "title": "Changing population size", + "description": "Adjust the population and related runtime settings during evolution.", + "category": "Population and Genes", + "guide": "generations.md", + "requirements": "PyGAD", + "run": "python examples/example_dynamic_population_size.py" + }, + { + "path": "example_custom_operators.py", + "title": "Custom GA operators", + "description": "Implement parent selection, crossover, and mutation functions.", + "category": "Operators and Configuration", + "guide": "user_defined_operators.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_custom_operators.py" + }, + { + "path": "example_constructor_parameters.py", + "title": "Constructor settings and random seeds", + "description": "Use callable fitness signatures, NumPy counts, and independent seeded GA instances.", + "category": "Operators and Configuration", + "guide": "pygad.md", + "requirements": "PyGAD", + "run": "python examples/example_constructor_parameters.py" + }, + { + "path": "example_fitness_batch_size.py", + "title": "Batch fitness", + "description": "Return one fitness result per solution, including a shorter final batch.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/example_fitness_batch_size.py" + }, + { + "path": "example_parallel_processing.py", + "title": "Parallel fitness", + "description": "Evaluate population fitness with process workers and report the run time.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/example_parallel_processing.py" + }, + { + "path": "benchmarks/parallel_processing.py", + "title": "Compare fitness execution modes", + "description": "Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/parallel_processing.py --workload cpu" + }, + { + "path": "example_fitness_wrapper.py", + "title": "Extra fitness arguments", + "description": "Wrap a fitness function to pass additional values while preserving its PyGAD signature.", + "category": "Fitness and Parallel Processing", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_fitness_wrapper.py" + }, + { + "path": "pygad_lifecycle.py", + "title": "Lifecycle callbacks", + "description": "Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks.", + "category": "Lifecycle and Saved Runs", + "guide": "lifecycle.md", + "requirements": "PyGAD", + "run": "python examples/pygad_lifecycle.py" + }, + { + "path": "example_lifecycle_methods.py", + "title": "Callbacks as methods", + "description": "Implement fitness and lifecycle callbacks with bound methods.", + "category": "Lifecycle and Saved Runs", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_lifecycle_methods.py" + }, + { + "path": "example_lifecycle_classes.py", + "title": "Callbacks as callable classes", + "description": "Implement fitness and lifecycle callbacks with callable class instances.", + "category": "Lifecycle and Saved Runs", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_lifecycle_classes.py" + }, + { + "path": "example_summary.py", + "title": "Text lifecycle summary", + "description": "Print the configured GA stages and their parameters.", + "category": "Lifecycle and Saved Runs", + "guide": "pygad_more.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_summary.py" + }, + { + "path": "example_logger.py", + "title": "Logging", + "description": "Send progress and GA messages to a configured logger.", + "category": "Lifecycle and Saved Runs", + "guide": "logging.md", + "requirements": "PyGAD", + "run": "python examples/example_logger.py" + }, + { + "path": "example_repeated_runs.py", + "title": "Repeated runs and checkpoints", + "description": "Continue from a saved GA and inspect the actual generation numbers in its histories.", + "category": "Lifecycle and Saved Runs", + "guide": "generations.md", + "requirements": "PyGAD", + "run": "python examples/example_repeated_runs.py" + }, + { + "path": "example_load_fitness_function.py", + "title": "Change a loaded fitness function", + "description": "Replace the fitness callable after loading, or start fresh when the objective changes.", + "category": "Lifecycle and Saved Runs", + "guide": "pygad.md", + "requirements": "PyGAD", + "run": "python examples/example_load_fitness_function.py" + }, + { + "path": "example_multi_objective.py", + "title": "NSGA-II optimization", + "description": "Optimize two objectives and inspect the resulting trade-offs.", + "category": "Multi-Objective Optimization", + "guide": "multi_objective.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_multi_objective.py" + }, + { + "path": "example_multi_objective_nsga3.py", + "title": "NSGA-III optimization", + "description": "Configure reference points and optimize two objectives with NSGA-III.", + "category": "Multi-Objective Optimization", + "guide": "multi_objective.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_multi_objective_nsga3.py" + }, + { + "path": "plots/example_plot_fitness.py", + "title": "Best-fitness curve", + "description": "Plot best fitness across generations on the Sphere benchmark.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_fitness.py" + }, + { + "path": "plots/example_plot_fitness_band.py", + "title": "Fitness band", + "description": "Plot per-generation minimum, mean, and maximum fitness with a shaded band.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_fitness_band.py" + }, + { + "path": "plots/example_plot_genes.py", + "title": "Gene histories", + "description": "Show how gene values change across saved generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_genes.py" + }, + { + "path": "plots/example_plot_lifecycle.py", + "title": "Configured lifecycle", + "description": "Draw detailed and compact lifecycle charts and export SVG and PNG files.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_lifecycle.py" + }, + { + "path": "plots/example_plot_new_solution_rate.py", + "title": "New-solution rate", + "description": "Count previously unseen solutions in each generation.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_new_solution_rate.py" + }, + { + "path": "plots/example_plot_non_dominated_hypervolume.py", + "title": "Hypervolume history", + "description": "Track the hypervolume of the non-dominated set across generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_non_dominated_hypervolume.py" + }, + { + "path": "plots/example_plot_pareto_front_curve_2d.py", + "title": "2D Pareto front", + "description": "Plot a two-objective Pareto front after NSGA-II optimization.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_curve_2d.py" + }, + { + "path": "plots/example_plot_pareto_front_curve_3d.py", + "title": "3D Pareto front", + "description": "Plot a three-objective Pareto front after NSGA-III optimization.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_curve_3d.py" + }, + { + "path": "plots/example_plot_pareto_front_evolution.py", + "title": "Pareto-front evolution", + "description": "Overlay the non-dominated fronts from selected generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_evolution.py" + }, + { + "path": "plots/example_plot_pareto_front_heatmap.py", + "title": "Pareto heatmap", + "description": "Compare objective values with a solutions-by-objectives heatmap.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_heatmap.py" + }, + { + "path": "plots/example_plot_pareto_front_pcp.py", + "title": "Parallel coordinates", + "description": "Compare Pareto solutions across objective axes.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_pcp.py" + }, + { + "path": "plots/example_plot_pareto_front_scatter_matrix.py", + "title": "Pareto scatter matrix", + "description": "Compare every pair of objectives in a many-objective run.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_scatter_matrix.py" + }, + { + "path": "plots/example_plot_population_diversity.py", + "title": "Population diversity", + "description": "Track mean pairwise distance between solutions across generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_population_diversity.py" + }, + { + "path": "example_generate_report.py", + "title": "PDF report", + "description": "Export the run configuration, summary, best solution, and applicable plots to PDF.", + "category": "Reports", + "guide": "pygad.md", + "requirements": "PyGAD with the report extra (Matplotlib and ReportLab)", + "run": "python examples/example_generate_report.py" + }, + { + "path": "benchmarks/example_classic_sphere.py", + "title": "Sphere", + "description": "Optimize the Sphere single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_sphere.py" + }, + { + "path": "benchmarks/example_classic_rastrigin.py", + "title": "Rastrigin", + "description": "Optimize the Rastrigin single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_rastrigin.py" + }, + { + "path": "benchmarks/example_classic_rosenbrock.py", + "title": "Rosenbrock", + "description": "Optimize the Rosenbrock single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_rosenbrock.py" + }, + { + "path": "benchmarks/example_classic_griewank.py", + "title": "Griewank", + "description": "Optimize the Griewank single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_griewank.py" + }, + { + "path": "benchmarks/example_classic_schwefel.py", + "title": "Schwefel", + "description": "Optimize the Schwefel single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_schwefel.py" + }, + { + "path": "benchmarks/example_classic_ackley.py", + "title": "Ackley", + "description": "Optimize the Ackley single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_ackley.py" + }, + { + "path": "benchmarks/example_classic_himmelblau.py", + "title": "Himmelblau", + "description": "Optimize the Himmelblau single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_himmelblau.py" + }, + { + "path": "benchmarks/example_zdt1.py", + "title": "ZDT1", + "description": "Optimize the ZDT1 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt1.py" + }, + { + "path": "benchmarks/example_zdt2.py", + "title": "ZDT2", + "description": "Optimize the ZDT2 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt2.py" + }, + { + "path": "benchmarks/example_zdt3.py", + "title": "ZDT3", + "description": "Optimize the ZDT3 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt3.py" + }, + { + "path": "benchmarks/example_zdt4.py", + "title": "ZDT4", + "description": "Optimize the ZDT4 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt4.py" + }, + { + "path": "benchmarks/example_zdt6.py", + "title": "ZDT6", + "description": "Optimize the ZDT6 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt6.py" + }, + { + "path": "benchmarks/example_dtlz1.py", + "title": "DTLZ1", + "description": "Optimize the DTLZ1 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz1.py" + }, + { + "path": "benchmarks/example_dtlz2.py", + "title": "DTLZ2", + "description": "Optimize the DTLZ2 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz2.py" + }, + { + "path": "benchmarks/example_dtlz3.py", + "title": "DTLZ3", + "description": "Optimize the DTLZ3 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz3.py" + }, + { + "path": "benchmarks/example_dtlz4.py", + "title": "DTLZ4", + "description": "Optimize the DTLZ4 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz4.py" + }, + { + "path": "benchmarks/example_knapsack.py", + "title": "Knapsack", + "description": "Select items to maximize value within a weight capacity.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_knapsack.py" + }, + { + "path": "benchmarks/example_tsp.py", + "title": "Travelling salesman", + "description": "Find a short tour using a permutation of four cities.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_tsp.py" + }, + { + "path": "quality_indicators/example_hypervolume.py", + "title": "Hypervolume", + "description": "Measure the objective-space volume dominated by the final population.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_hypervolume.py" + }, + { + "path": "quality_indicators/example_inverted_generational_distance.py", + "title": "Inverted generational distance", + "description": "Measure distance from a reference front to the approximation.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_inverted_generational_distance.py" + }, + { + "path": "quality_indicators/example_generational_distance.py", + "title": "Generational distance", + "description": "Measure distance from the approximation to a reference front.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_generational_distance.py" + }, + { + "path": "quality_indicators/example_spacing.py", + "title": "Spacing", + "description": "Measure how evenly the approximation points are spread.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_spacing.py" + }, + { + "path": "nn/example_regression.py", + "title": "Regression", + "description": "Fit a neural network to a small numeric regression problem.", + "category": "Neural Networks", + "guide": "nn_regression_1.md", + "requirements": "PyGAD", + "run": "python examples/nn/example_regression.py" + }, + { + "path": "nn/example_XOR_classification.py", + "title": "XOR classification", + "description": "Train a neural network on the four XOR inputs.", + "category": "Neural Networks", + "guide": "nn_xor.md", + "requirements": "PyGAD", + "run": "python examples/nn/example_XOR_classification.py" + }, + { + "path": "nn/example_classification.py", + "title": "Image classification", + "description": "Classify fruit images from prepared feature vectors.", + "category": "Neural Networks", + "guide": "nn_image_classification.md", + "requirements": "PyGAD", + "run": "cd examples/nn\npython example_classification.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "nn/example_regression_fish.py", + "title": "Fish-weight regression", + "description": "Predict fish weight from numeric measurements.", + "category": "Neural Networks", + "guide": "nn_regression_2.md", + "requirements": "PyGAD, pandas", + "run": "cd examples/nn\npython example_regression_fish.py", + "data": "examples/data/Fish.csv.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "gann/example_regression.py", + "title": "Regression", + "description": "Fit a neural network to a small numeric regression problem.", + "category": "Neural Networks with the GA", + "guide": "gann_regression_1.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/gann/example_regression.py" + }, + { + "path": "gann/example_XOR_classification.py", + "title": "XOR classification", + "description": "Train a neural network on the four XOR inputs.", + "category": "Neural Networks with the GA", + "guide": "gann_xor.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/gann/example_XOR_classification.py" + }, + { + "path": "gann/example_classification.py", + "title": "Image classification", + "description": "Classify fruit images from prepared feature vectors.", + "category": "Neural Networks with the GA", + "guide": "gann_image_classification.md", + "requirements": "PyGAD, Matplotlib", + "run": "cd examples/gann\npython example_classification.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/gann/ so the relative data paths resolve:" + }, + { + "path": "gann/example_regression_fish.py", + "title": "Fish-weight regression", + "description": "Predict fish weight from numeric measurements.", + "category": "Neural Networks with the GA", + "guide": "gann_regression_2.md", + "requirements": "PyGAD, Matplotlib, pandas", + "run": "cd examples/gann\npython example_regression_fish.py", + "data": "examples/data/Fish.csv.", + "download": false, + "run_note": "From the repository root, change to examples/gann/ so the relative data paths resolve:" + }, + { + "path": "nn/extract_features.py", + "title": "Prepare image features", + "description": "Extract fruit-image features and write the arrays used by the dense classifiers.", + "category": "Neural Networks", + "guide": "nn_image_classification.md", + "requirements": "PyGAD, scikit-image", + "run": "cd examples/nn\npython extract_features.py", + "data": "The apple, lemon, mango, and raspberry folders under examples/data/Fruit360/.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "cnn/example_image_classification.py", + "title": "Build a CNN", + "description": "Classify fruit images using prepared image arrays.", + "category": "Convolutional Networks", + "guide": "cnn.md", + "requirements": "PyGAD", + "run": "cd examples/cnn\npython example_image_classification.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/cnn/ so the relative data paths resolve:" + }, + { + "path": "gacnn/example_image_classification.py", + "title": "Optimize a CNN with the GA", + "description": "Classify fruit images using prepared image arrays.", + "category": "Convolutional Networks", + "guide": "gacnn.md", + "requirements": "PyGAD, Matplotlib", + "run": "cd examples/gacnn\npython example_image_classification.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/gacnn/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/regression_example.py", + "title": "Regression", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_regression.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "python examples/KerasGA/regression_example.py" + }, + { + "path": "KerasGA/XOR_classification.py", + "title": "XOR classification", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_xor.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "python examples/KerasGA/XOR_classification.py" + }, + { + "path": "KerasGA/image_classification_Dense.py", + "title": "Dense image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_image_dense.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython image_classification_Dense.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/image_classification_CNN.py", + "title": "Convolutional image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_image_conv.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython image_classification_CNN.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "TorchGA/regression_example.py", + "title": "Regression", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_regression.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "python examples/TorchGA/regression_example.py" + }, + { + "path": "TorchGA/XOR_classification.py", + "title": "XOR classification", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_xor.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "python examples/TorchGA/XOR_classification.py" + }, + { + "path": "TorchGA/image_classification_Dense.py", + "title": "Dense image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_image_dense.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras, PyTorch", + "run": "cd examples/TorchGA\npython image_classification_Dense.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/TorchGA/ so the relative data paths resolve:" + }, + { + "path": "TorchGA/image_classification_CNN.py", + "title": "Convolutional image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_image_conv.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "cd examples/TorchGA\npython image_classification_CNN.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/TorchGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/cancer_dataset.py", + "title": "Image-directory classification", + "description": "Use directory-based image input for a two-class Keras CNN.", + "category": "Keras", + "guide": "kerasga_image_datagen.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython cancer_dataset.py", + "data": "benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/cancer_dataset_generator.py", + "title": "Batched image-directory classification", + "description": "Use directory-based image input for a two-class Keras CNN.", + "category": "Keras", + "guide": "kerasga_image_datagen.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython cancer_dataset_generator.py", + "data": "benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "clustering/example_clustering_2.py", + "title": "2-cluster example", + "description": "Optimize 2 cluster centers for generated two-dimensional data.", + "category": "Clustering", + "guide": "pygad.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/clustering/example_clustering_2.py" + }, + { + "path": "clustering/example_clustering_3.py", + "title": "3-cluster example", + "description": "Optimize 3 cluster centers for generated two-dimensional data.", + "category": "Clustering", + "guide": "pygad.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/clustering/example_clustering_3.py" + }, + { + "path": "example_travelling_salesman.ipynb", + "title": "Travelling-salesman Colab notebook", + "description": "Explore a city-tour problem using a user-supplied CSV and interactive maps.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Google Colab, NumPy, pandas, Plotly, folium, and geopy", + "run": "", + "data": "The notebook reads /content/sample_data/startbucks.csv in Google Colab. Supply a compatible CSV at that path. The original data source was not recorded. For local Jupyter use, adapt the Colab-specific imports and CSV path.", + "download": false + } +] diff --git a/docs/python_examples.py b/docs/python_examples.py new file mode 100644 index 00000000..30b50e55 --- /dev/null +++ b/docs/python_examples.py @@ -0,0 +1,110 @@ +"""Render example cards and their index from the same catalog and templates.""" + +import json +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +from docutils import nodes +from docutils.statemachine import StringList +from jinja2 import Environment, FileSystemLoader +from sphinx.errors import ExtensionError +from sphinx.util.docutils import SphinxDirective + + +def load_example_catalog(app): + """Validate the catalog against the scripts shipped in the repository.""" + documentation_directory = Path(app.confdir).parent + examples_directory = documentation_directory.parent / 'examples' + catalog_path = documentation_directory / 'python_examples.json' + catalog = json.loads(catalog_path.read_text(encoding='utf-8')) + examples_by_path = {} + for example in catalog: + path = example['path'] + if path in examples_by_path: + raise ExtensionError(f"Duplicate Python example in the catalog: {path}") + resolved_path = (examples_directory / path).resolve() + try: + resolved_path.relative_to(examples_directory.resolve()) + except ValueError: + raise ExtensionError(f"Python example must be inside examples/: {path}") + if not resolved_path.is_file(): + raise ExtensionError(f"Python example does not exist in examples/: {path}") + if not (Path(app.confdir) / example['guide']).is_file(): + raise ExtensionError(f"Documentation guide does not exist for Python example: {path}") + examples_by_path[path] = example + + missing_examples = sorted(path.relative_to(examples_directory).as_posix() + for path in examples_directory.rglob('*.py') + if path.relative_to(examples_directory).as_posix() not in examples_by_path) + if missing_examples: + raise ExtensionError('Add these scripts to docs/python_examples.json: ' + ', '.join(missing_examples)) + app.python_examples_catalog = catalog + + +class PythonExamples(SphinxDirective): + """Show cards for catalog paths, or a compact index of all examples.""" + + has_content = True + render_examples_index = False + + def run(self): + documentation_directory = Path(self.env.srcdir).parent + self.env.note_dependency(str(documentation_directory / 'python_examples.json')) + examples_by_path = {example['path']: example for example in self.env.app.python_examples_catalog} + if self.render_examples_index: + examples = list(examples_by_path.values()) + else: + paths = [line.strip() for line in self.content if line.strip()] + if not paths: + raise self.error('List at least one path relative to examples/.') + try: + examples = [examples_by_path[path] for path in paths] + except KeyError as error: + raise self.error(f"Unknown Python example in docs/python_examples.json: {error.args[0]}") + + source_revision = quote(self.config.python_examples_source_revision, safe='') + repository_url = 'https://github.com/ahmedfgad/GeneticAlgorithmPython' + examples_with_links = [] + for example in examples: + folder = PurePosixPath(example['path']).parent.as_posix() + folder_url = f"{repository_url}/tree/{source_revision}/examples" + if folder != '.': + folder_url += '/' + quote(folder) + examples_with_links.append(dict( + example, + source_url=f"{repository_url}/blob/{source_revision}/examples/{quote(example['path'])}", + folder_url=folder_url, + data_url=f"{repository_url}/blob/{source_revision}/examples/data/README.md", + download_path='../../examples/' + example['path'])) + template_name = 'index.md.jinja' if self.render_examples_index else 'card.md.jinja' + templates_directory = documentation_directory / 'python_example_templates' + self.env.note_dependency(str(templates_directory / template_name)) + self.env.note_dependency(str(templates_directory / 'table.md.jinja')) + template_environment = Environment(loader=FileSystemLoader(str(templates_directory)), + autoescape=False, keep_trailing_newline=True) + categories = [(category, [example for example in examples_with_links + if example['category'] == category]) + for category in dict.fromkeys(example['category'] for example in examples_with_links)] + rendered_content = template_environment.get_template(template_name).render( + examples=examples_with_links, categories=categories) + container = nodes.container() + # MyST parses the generated Markdown through its normal Sphinx state. + # Native cards, dropdowns, and downloads also support non-HTML builders. + self.state.nested_parse(StringList(rendered_content.splitlines()), 0, + container, match_titles=self.render_examples_index) + return container.children + + +class PythonExamplesIndex(PythonExamples): + """Show every catalog entry in topic tables, with links back to guides.""" + + render_examples_index = True + + +def setup(app): + """Register the shared example catalog and its two documentation directives.""" + app.add_config_value('python_examples_source_revision', 'master', 'env') + app.connect('builder-inited', load_example_catalog) + app.add_directive('python-examples', PythonExamples) + app.add_directive('python-examples-index', PythonExamplesIndex) + return {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True} diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 37d9fc3f..b9cabe76 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -42,13 +42,37 @@ details.sd-dropdown > .sd-summary-title code { font-weight: 600; } -/* Release-history controls follow the theme in light and dark mode. */ +/* Shared cards connecting documentation topics to complete Python examples. */ +.python-example { + border-left: 3px solid var(--color-brand-primary); +} + +.python-example .sd-card-header { + background: var(--color-background-secondary); + font-weight: 600; +} + +.python-example code { + overflow-wrap: anywhere; + white-space: normal; +} + +.python-example pre code { + white-space: pre; + overflow-wrap: normal; +} + +.python-example .sd-card-footer { + background: var(--color-background-primary); +} + /* Keep the release-page header compact so the newest notes are in view. */ #release-history > p > img[alt="PYGAD-LOGO"] { max-width: 12rem; height: auto; } +/* Release-history controls follow the theme in light and dark mode. */ .release-history-navigation { display: flex; flex-wrap: wrap; diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 6caec365..926731f0 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -10,7 +10,7 @@ Attributes for setting up the GA (some are class attributes and others are set o 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/`. +Complete scripts are linked beside each benchmark family and listed in the [Examples index](examples.md). ## Single-Objective Problems @@ -26,6 +26,16 @@ Available in `pygad.benchmarks.classic`: | `Ackley` | f(0, ..., 0) = 0 | `(-32.768, 32.768)` | | `Himmelblau` | four equal minima at f = 0 (2D only) | `(-5.0, 5.0)` | +:::{python-examples} +benchmarks/example_classic_sphere.py +benchmarks/example_classic_rastrigin.py +benchmarks/example_classic_rosenbrock.py +benchmarks/example_classic_griewank.py +benchmarks/example_classic_schwefel.py +benchmarks/example_classic_ackley.py +benchmarks/example_classic_himmelblau.py +::: + ## Multi-Objective Problems (ZDT family) In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5, 5]` for the rest). @@ -38,6 +48,14 @@ In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5 | `ZDT4` | convex, many local minima in the search space | | `ZDT6` | non-uniform | +:::{python-examples} +benchmarks/example_zdt1.py +benchmarks/example_zdt2.py +benchmarks/example_zdt3.py +benchmarks/example_zdt4.py +benchmarks/example_zdt6.py +::: + ## 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. @@ -49,6 +67,13 @@ In `pygad.benchmarks.dtlz`. Any number of objectives `M`. Decision variables: `M | `DTLZ3` | 3 | unit sphere with hard multimodal g-function | | `DTLZ4` | 3 | unit sphere with strong bias toward one corner | +:::{python-examples} +benchmarks/example_dtlz1.py +benchmarks/example_dtlz2.py +benchmarks/example_dtlz3.py +benchmarks/example_dtlz4.py +::: + ## Combinatorial Problems Two combinatorial benchmarks: 0/1 `Knapsack` and `TSP`. @@ -79,6 +104,10 @@ ga = pygad.GA( ga.run() ``` +:::{python-examples} +benchmarks/example_knapsack.py +::: + ### 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. @@ -125,6 +154,11 @@ ga = pygad.GA( ga.run() ``` +:::{python-examples} +benchmarks/example_tsp.py +example_travelling_salesman.ipynb +::: + ## Example: SOO ```python diff --git a/docs/source/cnn.md b/docs/source/cnn.md index 6cac8c35..5f1f941e 100644 --- a/docs/source/cnn.md +++ b/docs/source/cnn.md @@ -512,3 +512,7 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +:::{python-examples} +cnn/example_image_classification.py +::: diff --git a/docs/source/conf.py b/docs/source/conf.py index f34a04cb..6875575a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,12 +16,29 @@ # -- General configuration --------------------------------------------------- +import os +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Pin example links to the checkout used to build the documentation. This also +# works for release branches and older documentation versions on Read the Docs. +try: + python_examples_source_revision = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], cwd=Path(__file__).resolve().parent, + text=True, stderr=subprocess.DEVNULL).strip() +except (OSError, subprocess.CalledProcessError): + python_examples_source_revision = os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'master') + # The documentation is written in Markdown and read directly by Sphinx # through the MyST parser. There is no Markdown-to-reStructuredText step. extensions = [ 'myst_parser', 'sphinx_design', 'sphinx_copybutton', + 'python_examples', ] # Read both Markdown and reStructuredText. Markdown is the source of truth. diff --git a/docs/source/custom_functions.md b/docs/source/custom_functions.md index 6b3297ec..53fd8a90 100644 --- a/docs/source/custom_functions.md +++ b/docs/source/custom_functions.md @@ -67,6 +67,10 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` +:::{python-examples} +example_fitness_wrapper.py +::: + ## Assign Methods The next example has all the methods defined inside the class `Test`. All of the methods accept an additional parameter representing the method's object of the class `Test`. @@ -118,6 +122,10 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` +:::{python-examples} +example_lifecycle_methods.py +::: + ## Assign a Class Besides functions and methods, you can pass an instance of a class. The class must implement the `__call__()` method, which makes its instances callable like a function. PyGAD calls the instance the same way it calls a function. @@ -191,3 +199,7 @@ ga_instance = pygad.GA(num_generations=10, ga_instance.run() ``` + +:::{python-examples} +example_lifecycle_classes.py +::: diff --git a/docs/source/examples.md b/docs/source/examples.md new file mode 100644 index 00000000..8881a8be --- /dev/null +++ b/docs/source/examples.md @@ -0,0 +1,26 @@ +# Examples + +Find complete Python scripts by topic, open their source on GitHub, or download examples that need no companion files. Each entry links back to its documentation guide. The same examples appear in **Python example** cards beside the relevant explanations in those guides. + +## Running the Examples + +For examples from this documentation revision, use the matching repository version of PyGAD. This is especially important for features in the Unreleased notes. Clone or download that repository revision, then install it from the repository root: + +```console +python -m pip install -e ".[visualize]" +``` + +Run a script using its path, for example: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +For a script downloaded separately, run `python` with the path where you saved it. It still requires a compatible installed version of PyGAD. Plotting examples need Matplotlib; PDF reports need `pygad[report]`. Keras and PyTorch examples need their respective frameworks. The **Run this example** dropdowns in the guides give additional requirements and working directories. + +Examples requiring datasets link to their folders instead of offering a standalone download. Their datasets are not bundled with PyGAD or the repository. Follow the linked data setup instructions and keep the repository directory layout. The TSP notebook is listed alongside the Python scripts; it uses Google Colab and a user-supplied CSV. Adapt its Colab-specific imports and CSV path before running it locally with Jupyter. + +Use documentation search or your browser's find command to locate a topic or filename on this page. + +:::{python-examples-index} +::: diff --git a/docs/source/fitness_calculation.md b/docs/source/fitness_calculation.md index cecaee2a..e8aa4a75 100644 --- a/docs/source/fitness_calculation.md +++ b/docs/source/fitness_calculation.md @@ -19,9 +19,14 @@ When `save_solutions=True`, `solutions_generations` contains one generation numb `on_fitness(ga_instance, population_fitness)` runs before parent selection for each generation. After it returns, PyGAD recomputes the best solution so the saved solution and fitness agree. The final population is saved without an additional `on_fitness` call. Previously saved arrays are independent of later callback edits. Callbacks receive the population fitness after cache reuse, so changes to already cached scores can accumulate if the callback repeatedly adds to them. -Checkpoints preserve the generation numbers and population boundaries. Older checkpoints containing a single-run history recover the generation numbers automatically. Older repeated-run checkpoints did not record run boundaries, so unavailable generation numbers are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. New snapshots have their actual generation numbers. Plots use snapshot positions only for those unknown legacy entries. A complete example is available at [`examples/example_repeated_runs.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_repeated_runs.py). +Checkpoints preserve the generation numbers and population boundaries. Older checkpoints containing a single-run history recover the generation numbers automatically. Older repeated-run checkpoints did not record run boundaries, so unavailable generation numbers are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. New snapshots have their actual generation numbers. Plots use snapshot positions only for those unknown legacy entries. (parallel-processing-guide)= + +:::{python-examples} +example_repeated_runs.py +::: + ## Parallel Processing in PyGAD Starting from [PyGAD 2.17.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-17-0), parallel processing is supported. This section explains how to use parallel processing in PyGAD. @@ -120,6 +125,12 @@ The repository's `examples/benchmarks/parallel_processing.py` measures complete For Keras, calls to `pygad.kerasga.predict()` sharing one model are synchronized; they preserve each solution's weights but run one at a time. Separate models are needed for concurrent predictions. Direct changes to shared models outside that helper require their own synchronization. (non-deterministic-fitness)= + +:::{python-examples} +example_parallel_processing.py +benchmarks/parallel_processing.py +::: + ## Solve Non-Deterministic Problems PyGAD can be used to solve both deterministic and non-deterministic problems. Deterministic problems are those that return the same fitness for the same solution. For non-deterministic problems, a different fitness value may be returned for the same solution. @@ -257,7 +268,11 @@ ga_instance = pygad.GA(num_generations=1, ga_instance.run() ``` -The runnable script is [`examples/example_fitness_batch_size.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_fitness_batch_size.py). The same variable-batch-size contract applies to serial, thread, and process evaluation. +The same variable-batch-size contract applies to serial, thread, and process evaluation. + +:::{python-examples} +example_fitness_batch_size.py +::: ### Example without `fitness_batch_size` Parameter @@ -364,4 +379,4 @@ print(number_of_calls) 30 ``` -When batch fitness calculation is used, then we saved `120 - 30 = 90` calls to the fitness function. +When batch fitness calculation is used, then we saved `120 - 30 = 90` calls to the fitness function. diff --git a/docs/source/gacnn.md b/docs/source/gacnn.md index ab9273b4..b751f96f 100644 --- a/docs/source/gacnn.md +++ b/docs/source/gacnn.md @@ -476,3 +476,7 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +:::{python-examples} +gacnn/example_image_classification.py +::: diff --git a/docs/source/gann_image_classification.md b/docs/source/gann_image_classification.md index 782d66cd..446830db 100644 --- a/docs/source/gann_image_classification.md +++ b/docs/source/gann_image_classification.md @@ -144,3 +144,7 @@ Classification accuracy : 99.94903160040775. The next figure shows how fitness value evolves by generation. ![Training Neural Networks using Genetic Algorithm](images/82152993-21898180-9865-11ea-8387-b995f88b83f7.png) + +:::{python-examples} +gann/example_classification.py +::: diff --git a/docs/source/gann_regression_1.md b/docs/source/gann_regression_1.md index feafb8be..a9abafd7 100644 --- a/docs/source/gann_regression_1.md +++ b/docs/source/gann_regression_1.md @@ -151,3 +151,7 @@ print(f"Absolute error : {abs_error}.") The next figure shows how the fitness value changes for the generations used. ![example_regression](images/92948154-3cf24b00-f459-11ea-94ea-952b66ab2145.png) + +:::{python-examples} +gann/example_regression.py +::: diff --git a/docs/source/gann_regression_2.md b/docs/source/gann_regression_2.md index 0fe0aa9d..e7fbfe15 100644 --- a/docs/source/gann_regression_2.md +++ b/docs/source/gann_regression_2.md @@ -146,3 +146,7 @@ print(f"Absolute error : {abs_error}.") The next figure shows how the fitness value changes for the 500 generations used. ![example_regression_fish](images/92948486-bbe78380-f459-11ea-9e31-0d4c7269d606.png) + +:::{python-examples} +gann/example_regression_fish.py +::: diff --git a/docs/source/gann_xor.md b/docs/source/gann_xor.md index db2c6d1b..0ccf3626 100644 --- a/docs/source/gann_xor.md +++ b/docs/source/gann_xor.md @@ -132,3 +132,7 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +:::{python-examples} +gann/example_XOR_classification.py +::: diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 27af2d94..dc59768f 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -55,7 +55,9 @@ Constraints depending on other genes should follow the dependency order: a gene When `allow_duplicate_genes=False`, duplicate repair follows constraint handling and uses the same converted domains. The search behavior and limits are described in [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). -See [`examples/example_initial_population.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_initial_population.py) for generated and supplied populations. +:::{python-examples} +example_initial_population.py +::: ## Limit the Gene Value Range using the `gene_space` Parameter @@ -204,6 +206,10 @@ If the dictionary has a step like the example below, then it is considered a dis Gene space: {'low': 1, 'high': 5, 'step': 0.5} ``` +:::{python-examples} +example_gene_space.py +::: + ## Gene Constraint In [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0), a new parameter called `gene_constraint` is added to the constructor of the `pygad.GA` class. An instance attribute of the same name is created for any instance of the `pygad.GA` class. @@ -307,7 +313,9 @@ Duplicate repair also checks all constraints against complete candidate solution ### Full Example -For a full example, please check the [`examples/example_gene_constraint.py` script](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_gene_constraint.py). +:::{python-examples} +example_gene_constraint.py +::: ## `sample_size` Parameter @@ -480,7 +488,9 @@ The last gene can only keep 0. Repair moves the third gene from 2 to 3, the seco This behavior also handles third-gene repairs, such as changing `[3, 4, 4, 5]` into `[2, 3, 4, 5]` for `gene_space=[[2, 3], [3, 4], [4, 5], [5, 6]]`. -A runnable example is available in [`examples/example_duplicate_gene_repair.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_duplicate_gene_repair.py). +:::{python-examples} +example_duplicate_gene_repair.py +::: ### Ranges, Types, and Constraints @@ -522,7 +532,9 @@ Floating-point types use binary representations, so a stored value can differ sl When types are specified per gene, population arrays use `dtype=object` so each column can retain its requested Python or NumPy scalar type. Saved best solutions retain these types too. This also preserves large integers when other genes are floating-point values. A NumPy array constructed without `dtype=object` can already lose integer precision through conversion to a shared floating-point type; use a list or an object array for mixed input values that must remain exact. -The new `examples/example_gene_type_conversion.py` demonstrates mixed types, rounding, and a custom mutation function. +:::{python-examples} +example_gene_type_conversion.py +::: ### Data Type for All Genes without Precision diff --git a/docs/source/generations.md b/docs/source/generations.md index b957b87a..052bd4db 100644 --- a/docs/source/generations.md +++ b/docs/source/generations.md @@ -266,7 +266,11 @@ ga_instance = pygad.GA(..., random_seed=2) ``` -The custom operator must choose values appropriate for the problem's gene spaces and constraints. Calls to global `numpy.random` or `random` functions in user code need their own seeds; `random_seed` does not seed these global generators. A complete example of independent seeded instances is available at [`examples/example_constructor_parameters.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_constructor_parameters.py). +The custom operator must choose values appropriate for the problem's gene spaces and constraints. Calls to global `numpy.random` or `random` functions in user code need their own seeds; `random_seed` does not seed these global generators. + +:::{python-examples} +example_constructor_parameters.py +::: ## Continue without Losing Progress @@ -325,7 +329,11 @@ The plot created by the `plot_fitness()` method will show the data collected fro With `save_solutions=True`, `solutions_generations` records one generation number per saved population, while `solutions` and `solutions_fitness` keep one entry per solution. `best_solution_generation` reports the actual generation of the best saved fitness, rather than its position in the history. History plots and PDF reports use these generation numbers. -Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See {ref}`Saved Fitness across Repeated Runs ` for callback behavior and checkpoint compatibility, and [`examples/example_repeated_runs.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_repeated_runs.py) for a complete checkpoint example. +Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See {ref}`Saved Fitness across Repeated Runs ` for callback behavior and checkpoint compatibility. + +:::{python-examples} +example_repeated_runs.py +::: ## Change Population Size during Runtime @@ -350,3 +358,7 @@ These are examples of the instance attributes that might be changed. The user sh 2. `last_generation_parents` and `last_generation_parents_indices`: Two NumPy arrays: 2D array representing the parents and 1D array of the parents indices. 3. `last_generation_elitism` and `last_generation_elitism_indices`: Must be changed if `keep_elitism != 0`. The default value of `keep_elitism` is 1. Two NumPy arrays: 2D array representing the elitism and 1D array of the elitism indices. 2. `pop_size`: The population size. + +:::{python-examples} +example_dynamic_population_size.py +::: diff --git a/docs/source/index.md b/docs/source/index.md index 78a19a7e..07c81810 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -180,6 +180,7 @@ If you used PyGAD, please consider citing its paper with the following details: pygad pygad_more +examples ``` ```{toctree} diff --git a/docs/source/kerasga_image_conv.md b/docs/source/kerasga_image_conv.md index 1f2c8399..f932aa12 100644 --- a/docs/source/kerasga_image_conv.md +++ b/docs/source/kerasga_image_conv.md @@ -152,3 +152,7 @@ To improve the model performance, you can do the following: - Modify the existing layers. - Use different parameters for the layers. - Use different parameters for the genetic algorithm (e.g. number of solution, number of generations, etc) + +:::{python-examples} +KerasGA/image_classification_CNN.py +::: diff --git a/docs/source/kerasga_image_datagen.md b/docs/source/kerasga_image_datagen.md index da3fc1bf..cdc85406 100644 --- a/docs/source/kerasga_image_datagen.md +++ b/docs/source/kerasga_image_datagen.md @@ -88,3 +88,8 @@ ca.update_state(data_outputs, predictions) accuracy = ca.result().numpy() print(f"Accuracy : {accuracy}") ``` + +:::{python-examples} +KerasGA/cancer_dataset.py +KerasGA/cancer_dataset_generator.py +::: diff --git a/docs/source/kerasga_image_dense.md b/docs/source/kerasga_image_dense.md index a4d78ecd..c6f94827 100644 --- a/docs/source/kerasga_image_dense.md +++ b/docs/source/kerasga_image_dense.md @@ -123,3 +123,7 @@ Index of the best solution : 0 Categorical Crossentropy : 0.23823906 Accuracy : 0.9852192 ``` + +:::{python-examples} +KerasGA/image_classification_Dense.py +::: diff --git a/docs/source/kerasga_regression.md b/docs/source/kerasga_regression.md index cffb5507..2a692b95 100644 --- a/docs/source/kerasga_regression.md +++ b/docs/source/kerasga_regression.md @@ -237,3 +237,7 @@ print(f"Absolute Error : {abs_error}") ``` Absolute Error : 0.013740465 ``` + +:::{python-examples} +KerasGA/regression_example.py +::: diff --git a/docs/source/kerasga_xor.md b/docs/source/kerasga_xor.md index d09c67d9..8cf0192f 100644 --- a/docs/source/kerasga_xor.md +++ b/docs/source/kerasga_xor.md @@ -143,3 +143,7 @@ Binary Crossentropy : 0.0013527311 Accuracy : 1.0 ``` + +:::{python-examples} +KerasGA/XOR_classification.py +::: diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index 4365655b..56628cf6 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -40,6 +40,10 @@ ga_instance.plot_lifecycle(title="PyGAD - My Optimization Problem", Drawing the chart does not run the GA or call user functions. See {ref}`plot_lifecycle() ` for the parameters, a sample chart, and a runnable example. To print a text description, use {ref}`summary() `. +:::{python-examples} +plots/example_plot_lifecycle.py +::: + ## Reporting Progress Use `on_generation` to report progress once a generation has completed. There is no need to change the fitness function or the GA operators: @@ -141,4 +145,8 @@ on_generation() on_stop() ``` -The same example is available as [`examples/pygad_lifecycle.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/pygad_lifecycle.py). To stop from `on_generation`, return `"stop"`; otherwise no return value is needed. +To stop from `on_generation`, return `"stop"`; otherwise no return value is needed. + +:::{python-examples} +pygad_lifecycle.py +::: diff --git a/docs/source/logging.md b/docs/source/logging.md index a3bdfe39..a6d169ff 100644 --- a/docs/source/logging.md +++ b/docs/source/logging.md @@ -122,6 +122,10 @@ On Generation on_gen() None ====================================================================== ``` +:::{python-examples} +example_summary.py +::: + ## Plot Lifecycle Chart Use `plot_lifecycle()` to draw the configured lifecycle as a flowchart with operators, callbacks, population replacement, and stopping decisions. It works before or after `run()`. @@ -132,6 +136,10 @@ ga_instance.plot_lifecycle(save_dir="lifecycle.svg") The method returns a matplotlib figure. Use `show_parameters=False` for a compact chart or `show=False` to save without displaying it. See {ref}`plot_lifecycle() ` for the full description and an example. +:::{python-examples} +plots/example_plot_lifecycle.py +::: + ## Logging Outputs In [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0), the `print()` statement is no longer used and the outputs are printed using the [logging](https://docs.python.org/3/library/logging.html) module. A new parameter called `logger` is supported to accept a user-defined logger. @@ -382,3 +390,7 @@ By executing this code, the logged messages are printed to the console and also 2023-04-03 19:04:27 INFO: Generation = 10 2023-04-03 19:04:27 INFO: Fitness = 0.000389832593101348 ``` + +:::{python-examples} +example_logger.py +::: diff --git a/docs/source/multi_objective.md b/docs/source/multi_objective.md index 2779194e..d8302763 100644 --- a/docs/source/multi_objective.md +++ b/docs/source/multi_objective.md @@ -169,3 +169,8 @@ 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. + +:::{python-examples} +example_multi_objective.py +example_multi_objective_nsga3.py +::: diff --git a/docs/source/nn_image_classification.md b/docs/source/nn_image_classification.md index 5e2ca766..3f7eb8a7 100644 --- a/docs/source/nn_image_classification.md +++ b/docs/source/nn_image_classification.md @@ -50,3 +50,8 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +:::{python-examples} +nn/example_classification.py +nn/extract_features.py +::: diff --git a/docs/source/nn_regression_1.md b/docs/source/nn_regression_1.md index 22d18061..dc688750 100644 --- a/docs/source/nn_regression_1.md +++ b/docs/source/nn_regression_1.md @@ -68,3 +68,7 @@ predictions = pygad.nn.predict(last_layer=output_layer, abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` + +:::{python-examples} +nn/example_regression.py +::: diff --git a/docs/source/nn_regression_2.md b/docs/source/nn_regression_2.md index d78971a9..7b9460ee 100644 --- a/docs/source/nn_regression_2.md +++ b/docs/source/nn_regression_2.md @@ -71,3 +71,7 @@ predictions = pygad.nn.predict(last_layer=output_layer, abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` + +:::{python-examples} +nn/example_regression_fish.py +::: diff --git a/docs/source/nn_xor.md b/docs/source/nn_xor.md index 78480c2c..440900c4 100644 --- a/docs/source/nn_xor.md +++ b/docs/source/nn_xor.md @@ -48,3 +48,7 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +:::{python-examples} +nn/example_XOR_classification.py +::: diff --git a/docs/source/pygad.md b/docs/source/pygad.md index 1ff3d383..a5d1484e 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -521,6 +521,10 @@ If both the `mutation_type` and `crossover_type` parameters are `None`, then the The parameters are validated by calling the `validate_parameters()` method of the `utils.validation.Validation` class inside the constructor. If any parameter is not correct, an exception is raised and the `valid_parameters` attribute is set to `False`. +:::{python-examples} +example_constructor_parameters.py +::: + ## Extended Classes To keep the library modular and structured, the code is split into several scripts, where each script has one or more classes. Each class has its own purpose. @@ -861,10 +865,14 @@ Parameters: - `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). +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. The title page shows the PyGAD logo. The image ships with the package, so it works without network access. If the image file is missing, the report is built without it. +:::{python-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()`. @@ -904,7 +912,11 @@ new_ga_instance = pygad.GA( new_ga_instance.run() ``` -Set the constructor options needed by the new problem, including `gene_type`, `gene_space`, constraints, operators, and batch settings. This starts new fitness histories and generation counters; it reuses the chromosomes, not the previous run's fitness values. A complete example is available in [`examples/example_load_fitness_function.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_load_fitness_function.py). +Set the constructor options needed by the new problem, including `gene_type`, `gene_space`, constraints, operators, and batch settings. This starts new fitness histories and generation counters; it reuses the chromosomes, not the previous run's fitness values. + +:::{python-examples} +example_load_fitness_function.py +::: ## Using PyGAD @@ -957,6 +969,11 @@ For a 2-cluster problem, the code is available [here](https://github.com/ahmedfg Soon a tutorial will be published at [Paperspace](https://blog.paperspace.com/author/ahmed) to explain how clustering works using the genetic algorithm with examples in PyGAD. +:::{python-examples} +clustering/example_clustering_2.py +clustering/example_clustering_3.py +::: + ### CoinTex Game Playing using PyGAD The code is available at the [CoinTex GitHub project](https://github.com/ahmedfgad/CoinTex/tree/master/PlayerGA). CoinTex is an Android game written in Python using the Kivy framework. Find CoinTex at [Google Play](https://play.google.com/store/apps/details?id=coin.tex.cointexreactfast): https://play.google.com/store/apps/details?id=coin.tex.cointexreactfast diff --git a/docs/source/releases.md b/docs/source/releases.md index c4ee1462..da31f49e 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -48,6 +48,8 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 32. The generation guide explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. +33. A new Examples index connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. ## PyGAD 3.7.0 diff --git a/docs/source/steps_to_use.md b/docs/source/steps_to_use.md index 1c3bbfc2..d7fcff75 100644 --- a/docs/source/steps_to_use.md +++ b/docs/source/steps_to_use.md @@ -200,3 +200,7 @@ After the instance is loaded, you can use it to run any method or access any pro ```python print(loaded_ga_instance.best_solution()) ``` + +:::{python-examples} +example.py +::: diff --git a/docs/source/torchga_image_conv.md b/docs/source/torchga_image_conv.md index 0e3d2385..babb2738 100644 --- a/docs/source/torchga_image_conv.md +++ b/docs/source/torchga_image_conv.md @@ -163,3 +163,7 @@ Index of the best solution : 0 Crossentropy : 0.7686678 Accuracy : 0.975 ``` + +:::{python-examples} +TorchGA/image_classification_CNN.py +::: diff --git a/docs/source/torchga_image_dense.md b/docs/source/torchga_image_dense.md index 462bea3d..98fdf2cd 100644 --- a/docs/source/torchga_image_dense.md +++ b/docs/source/torchga_image_dense.md @@ -124,3 +124,7 @@ Index of the best solution : 0 Crossentropy : 0.74366045 Accuracy : 1.0 ``` + +:::{python-examples} +TorchGA/image_classification_Dense.py +::: diff --git a/docs/source/torchga_regression.md b/docs/source/torchga_regression.md index a4ff3291..c560203a 100644 --- a/docs/source/torchga_regression.md +++ b/docs/source/torchga_regression.md @@ -231,3 +231,7 @@ print("Absolute Error : ", abs_error.detach().numpy()) ``` Absolute Error : 0.006876422 ``` + +:::{python-examples} +TorchGA/regression_example.py +::: diff --git a/docs/source/torchga_xor.md b/docs/source/torchga_xor.md index e5b777f5..375a9fe7 100644 --- a/docs/source/torchga_xor.md +++ b/docs/source/torchga_xor.md @@ -150,3 +150,7 @@ Binary Crossentropy : 0.0 Accuracy : 1.0 ``` + +:::{python-examples} +TorchGA/XOR_classification.py +::: diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index 760166f5..d633eb90 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -338,3 +338,7 @@ ga_instance = pygad.GA(num_generations=10, ga_instance.run() ga_instance.plot_fitness() ``` + +:::{python-examples} +example_custom_operators.py +::: diff --git a/docs/source/utils.md b/docs/source/utils.md index 616c2f54..98a7b961 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -478,7 +478,11 @@ The `pygad.utils.report` module has a class named `Report` that adds the `genera 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). +See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#generate-report). + +:::{python-examples} +example_generate_report.py +::: ## `pygad.utils.quality_indicators` Submodule @@ -506,7 +510,12 @@ true_front = problem.pareto_front(num_points=100) igd = inverted_generational_distance(fitness, true_front) ``` -A runnable example per indicator lives under `examples/quality_indicators/`. +:::{python-examples} +quality_indicators/example_hypervolume.py +quality_indicators/example_inverted_generational_distance.py +quality_indicators/example_generational_distance.py +quality_indicators/example_spacing.py +::: ## More about the Operators diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 3d90e0da..7ae9cdde 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -2,7 +2,7 @@ The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. `plot_lifecycle()` can also be called before `run()` because it draws the configured execution flow. -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). +Every method returns the `matplotlib.figure.Figure` it created and optionally writes it to disk via `save_dir`. Complete scripts are linked below and listed in the [Examples index](examples.md). ## Plot inventory @@ -58,7 +58,11 @@ ga_instance.plot_lifecycle(show_parameters=False) The method reads the current GA configuration without evaluating fitness, calling operators or callbacks, or changing GA state. It describes the configured flow rather than recording the path taken during a run. Before fitness is available, the objective count is marked as unknown. After a run, the chart can show the known objective count and fitness shape. Each `run()` call uses the configured generation count, including when continuing a previous run. -Install the optional plotting dependency with `pip install pygad[visualize]`. A complete example is available at [`examples/plots/example_plot_lifecycle.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/plots/example_plot_lifecycle.py). +Install the optional plotting dependency with `pip install pygad[visualize]`. + +:::{python-examples} +plots/example_plot_lifecycle.py +::: ## `plot_fitness()` @@ -72,6 +76,10 @@ ga_instance.plot_fitness() ![plot_fitness](figures/plot_fitness.png) +:::{python-examples} +plots/example_plot_fitness.py +::: + ## `plot_new_solution_rate()` 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`. @@ -84,6 +92,10 @@ ga_instance.plot_new_solution_rate() ![plot_new_solution_rate](figures/plot_new_solution_rate.png) +:::{python-examples} +plots/example_plot_new_solution_rate.py +::: + ## `plot_genes()` 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. @@ -98,6 +110,10 @@ ga_instance.plot_genes(graph_type="boxplot") ![plot_genes](figures/plot_genes.png) +:::{python-examples} +plots/example_plot_genes.py +::: + ## `plot_pareto_front_curve()` 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. @@ -116,6 +132,11 @@ For M=3 (NSGA-III on DTLZ2): ![plot_pareto_front_curve_3d](figures/plot_pareto_front_curve_3d.png) +:::{python-examples} +plots/example_plot_pareto_front_curve_2d.py +plots/example_plot_pareto_front_curve_3d.py +::: + ## `plot_pareto_front_pcp()` 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. @@ -128,6 +149,10 @@ ga_instance.plot_pareto_front_pcp() ![plot_pareto_front_pcp](figures/plot_pareto_front_pcp.png) +:::{python-examples} +plots/example_plot_pareto_front_pcp.py +::: + ## `plot_pareto_front_scatter_matrix()` 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. @@ -140,6 +165,10 @@ ga_instance.plot_pareto_front_scatter_matrix() ![plot_pareto_front_scatter_matrix](figures/plot_pareto_front_scatter_matrix.png) +:::{python-examples} +plots/example_plot_pareto_front_scatter_matrix.py +::: + ## `plot_pareto_front_heatmap()` 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. @@ -152,6 +181,10 @@ ga_instance.plot_pareto_front_heatmap(sort_by=0) ![plot_pareto_front_heatmap](figures/plot_pareto_front_heatmap.png) +:::{python-examples} +plots/example_plot_pareto_front_heatmap.py +::: + ## `plot_fitness_band()` 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`. @@ -164,6 +197,10 @@ ga_instance.plot_fitness_band() ![plot_fitness_band](figures/plot_fitness_band.png) +:::{python-examples} +plots/example_plot_fitness_band.py +::: + ## `plot_non_dominated_hypervolume()` 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`. @@ -176,6 +213,10 @@ ga_instance.plot_non_dominated_hypervolume() ![plot_non_dominated_hypervolume](figures/plot_non_dominated_hypervolume.png) +:::{python-examples} +plots/example_plot_non_dominated_hypervolume.py +::: + ## `plot_population_diversity()` Mean pairwise Euclidean distance between solutions per generation. A drop signals the population is converging or collapsing into duplicates. Requires `save_solutions=True`. @@ -188,6 +229,10 @@ ga_instance.plot_population_diversity() ![plot_population_diversity](figures/plot_population_diversity.png) +:::{python-examples} +plots/example_plot_population_diversity.py +::: + ## `plot_pareto_front_evolution()` 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`. @@ -199,3 +244,7 @@ ga_instance.plot_pareto_front_evolution(every_k=20) ``` ![plot_pareto_front_evolution](figures/plot_pareto_front_evolution.png) + +:::{python-examples} +plots/example_plot_pareto_front_evolution.py +::: diff --git a/examples/data/README.md b/examples/data/README.md index aeb955f3..cb4a72be 100644 --- a/examples/data/README.md +++ b/examples/data/README.md @@ -54,4 +54,4 @@ Put the image folders here as `Skin_Cancer_Dataset/benign/` and `Skin_Cancer_Dat ## Travelling salesman notebook -`examples/example_travelling_salesman.ipynb` reads `data/startbucks.csv`. That file is not in the repository and its source was not recorded. Add your own CSV at `examples/data/startbucks.csv` or change the path in the notebook. +`examples/example_travelling_salesman.ipynb` uses Google Colab and reads `/content/sample_data/startbucks.csv`. That file is not in the repository and its source was not recorded. Supply your own compatible CSV at that path in Colab. To run the notebook locally with Jupyter, adapt its Colab-specific imports and change the CSV path, for example to `examples/data/startbucks.csv` when running from the repository root. From 9c2e746a22ee699ad84a1c3294c8a6b60f800acf Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 12:48:04 -0400 Subject: [PATCH 12/22] Link recent release notes to feature documentation --- docs/source/benchmarks.md | 1 + docs/source/fitness_calculation.md | 2 + docs/source/gene_values.md | 5 +++ docs/source/generations.md | 2 + docs/source/kerasga.md | 1 + docs/source/lifecycle.md | 1 + docs/source/multi_objective.md | 1 + docs/source/pygad.md | 3 ++ docs/source/releases.md | 70 +++++++++++++++--------------- docs/source/utils.md | 8 ++++ docs/source/visualize.md | 8 ++++ 11 files changed, 67 insertions(+), 35 deletions(-) diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 926731f0..31da70a9 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -108,6 +108,7 @@ ga.run() benchmarks/example_knapsack.py ::: +(tsp-benchmark)= ### 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. diff --git a/docs/source/fitness_calculation.md b/docs/source/fitness_calculation.md index e8aa4a75..d4201169 100644 --- a/docs/source/fitness_calculation.md +++ b/docs/source/fitness_calculation.md @@ -2,6 +2,7 @@ This page covers how PyGAD calculates the fitness efficiently: parallel processing, non-deterministic problems, reusing fitness values, and batch fitness calculation. +(fitness-output-validation)= ## Fitness Output Validation For a single-objective problem, `fitness_func` returns one numeric value per solution. For a multi-objective problem, it returns a non-empty, one-dimensional list, tuple, or NumPy array of numeric objective values. Every solution must return the same number of objectives throughout a run, including cached solutions and offspring evaluated for adaptive mutation. Empty vectors, nested vectors, non-numeric values, and inconsistent objective counts raise a descriptive error before parent selection. @@ -162,6 +163,7 @@ ga_instance = pygad.GA(..., This way, PyGAD will not save any explored solution, so the fitness function has to be called for each individual solution. +(fitness-cache-reuse)= ## Reuse the Fitness instead of Calling the Fitness Function Saved solutions are indexed by their complete gene values to avoid scanning the entire history for every population member. Built-in evolution indexes new snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Duplicate solutions that have not been evaluated or saved are still evaluated independently. diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index dc59768f..8b035cc1 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -2,6 +2,7 @@ This page covers the parameters that control the values a gene can take: the `gene_space` and `gene_type` parameters, gene constraints, the `sample_size` parameter, and preventing duplicate genes. +(initial-population-guide)= ## Creating the Initial Population PyGAD can generate the initial population or start from a population passed to `initial_population`. @@ -107,6 +108,7 @@ For a 3-gene problem, the next code creates a dictionary for each gene to restri gene_space = [{'low': 1, 'high': 5}, {'low': 0.3, 'high': 1.4}, {'low': -0.2, 'high': 4.5}] ``` +(gene-space-guide)= ## More about the `gene_space` Parameter The `gene_space` parameter customizes the space of values of each gene. @@ -210,6 +212,7 @@ Gene space: {'low': 1, 'high': 5, 'step': 0.5} example_gene_space.py ::: +(gene-constraints-guide)= ## Gene Constraint In [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0), a new parameter called `gene_constraint` is added to the constructor of the `pygad.GA` class. An instance attribute of the same name is created for any instance of the `pygad.GA` class. @@ -339,6 +342,7 @@ For duplicate repair, finite spaces are considered in full. These include lists, When replacement chains do not satisfy a dependent constraint, PyGAD also tries alternative complete assignments. This additional search considers up to `sample_size * num_genes` tentative gene assignments. A larger value allows more alternatives to be checked. The limit prevents arbitrary constraint functions from requiring an unbounded combinatorial search. +(duplicate-gene-repair-guide)= ## Prevent Duplicates in Gene Values In [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0), a new bool parameter called `allow_duplicate_genes` is supported to control whether duplicates are supported in the chromosome or not. In other words, whether 2 or more genes might have the same exact value. @@ -519,6 +523,7 @@ The `gene_type` parameter allows the user to control the data type for all genes Let us look at some examples. +(gene-type-conversion-guide)= ### Conversion and Rounding Rules PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. diff --git a/docs/source/generations.md b/docs/source/generations.md index 052bd4db..7f3be1bb 100644 --- a/docs/source/generations.md +++ b/docs/source/generations.md @@ -16,6 +16,7 @@ def func_generation(ga_instance): return "stop" ``` +(stop-criteria-guide)= ## Stop Criteria In [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0), a new parameter named `stop_criteria` is added to the constructor of the `pygad.GA` class. It helps to stop the evolution based on some criteria. It can be assigned one or more criteria. @@ -195,6 +196,7 @@ Watch the tutorial on [YouTube](https://www.youtube.com/shorts/-uupRJhesjI). ``` +(random-seed-guide)= ## Random Seed In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0), a new parameter called `random_seed` is supported. Its value is used as a seed for the random function generators. diff --git a/docs/source/kerasga.md b/docs/source/kerasga.md index 64b63e19..ea3e8e62 100644 --- a/docs/source/kerasga.md +++ b/docs/source/kerasga.md @@ -119,6 +119,7 @@ The `model_weights_as_matrix()` function accepts the following parameters: It returns the restored model weights after reshaping the vector. +(keras-predict)= ### `pygad.kerasga.predict()` The `predict()` function makes a prediction based on a solution. It accepts the following parameters: diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index 56628cf6..01a3f108 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -44,6 +44,7 @@ Drawing the chart does not run the GA or call user functions. See {ref}`plot_lif plots/example_plot_lifecycle.py ::: +(reporting-progress)= ## Reporting Progress Use `on_generation` to report progress once a generation has completed. There is no need to change the fitness function or the GA operators: diff --git a/docs/source/multi_objective.md b/docs/source/multi_objective.md index d8302763..bbe00707 100644 --- a/docs/source/multi_objective.md +++ b/docs/source/multi_objective.md @@ -120,6 +120,7 @@ This is the figure created by the `plot_fitness()` method. The fitness of the fi ![multi-objective-pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython/assets/16560492/7896f8d8-01c5-4ff9-8d15-52191c309b63) +(nsga3-guide)= ## 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. diff --git a/docs/source/pygad.md b/docs/source/pygad.md index a5d1484e..ebcd3a01 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -8,6 +8,7 @@ With the `pygad` module, you can create, run, save, and load instances of the ge The `pygad` module has a class named `GA` for building the genetic algorithm. This section explains the class constructor, its methods, functions, and attributes. +(ga-constructor)= ### `__init__()` To create an instance of the `pygad.GA` class, the constructor accepts several parameters. These let you adjust the genetic algorithm for different types of applications. @@ -300,6 +301,7 @@ For each parent, a random value between 0.0 and 1.0 is generated. If that value Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. ::: +(mutation-controls)= #### Mutation :::{dropdown} `mutation_type="random"`: How offspring genes are mutated. @@ -888,6 +890,7 @@ Accepts the following parameter: Returns the genetic algorithm instance. +(updating-loaded-fitness-function)= #### Updating the Fitness Function after Loading The checkpoint includes the fitness function assigned when `save()` was called. Editing a function in the script does not automatically replace the function in a loaded GA. Assign the updated callable explicitly: diff --git a/docs/source/releases.md b/docs/source/releases.md index da31f49e..b3f5700c 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -8,47 +8,47 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. -1. Two-point crossover selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). -2. Swap mutation can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). -3. SBX crossover selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). +1. {ref}`Two-point crossover ` selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). +2. {ref}`Swap mutation ` can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). +3. {ref}`SBX crossover ` selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). 4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). 5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. -6. Parallel fitness evaluation now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. -7. Serial, thread, and process modes use the same fitness-cache rules and result validation. Adaptive mutation evaluates the actual offspring, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). +6. {ref}`Parallel fitness evaluation ` now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. +7. Serial, thread, and process modes use the same fitness-cache rules and result validation. {ref}`Adaptive mutation evaluates the actual offspring `, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). 8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). -9. `pygad.kerasga.predict()` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). -10. Stochastic universal selection uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). -11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). -12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). -13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. -14. A new `plot_lifecycle()` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. - -15. Duplicate-gene repair now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. -16. Repair uses each destination gene's type, precision, and range, and validates constraints against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. +9. {ref}`pygad.kerasga.predict() ` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). +10. {ref}`Stochastic universal selection ` uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). +11. {ref}`Scramble mutation ` shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). +12. New examples explain {ref}`replacing a loaded fitness function `, starting fresh when the objective changes, and {ref}`handling short final fitness batches `. The lifecycle guide also explains {ref}`progress reporting ` and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). +13. {ref}`Rank selection ` assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +14. A new {ref}`plot_lifecycle() ` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. + +15. {ref}`Duplicate-gene repair ` now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. +16. Repair uses each destination gene's type, precision, and {ref}`range `, and validates {ref}`constraints ` against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. 17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. -18. Initial population creation and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. +18. {ref}`Initial population creation ` and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. -19. Gene-type validation and conversion share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. +19. {ref}`Gene-type validation and conversion ` share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. -20. Constructor validation shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. Stop criteria share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. -21. Only the active mutation control is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. -22. Each GA owns NumPy and Python random generators. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. +20. {ref}`Constructor validation ` shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. {ref}`Stop criteria ` share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. +21. Only the {ref}`active mutation control ` is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. +22. Each GA owns {ref}`NumPy and Python random generators `. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. 23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. -24. The new `best_solutions_generations` and `solutions_generations` attributes record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. -25. `saturate_N` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. +24. The new {ref}`best_solutions_generations and solutions_generations attributes ` record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +25. {ref}`saturate_N ` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. 26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. -27. Fitness validation is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. -28. Saved fitness uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. +27. {ref}`Fitness validation ` is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. +28. {ref}`Saved fitness ` uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. 29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. 30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. 31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. -32. The generation guide explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. +32. The {doc}`generation guide ` explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. -33. A new Examples index connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. +33. A new {doc}`Examples index ` connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. @@ -77,27 +77,27 @@ Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). 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. +11. Support of multi-objective optimization using the {ref}`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. +15. A new crossover operator: {ref}`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: {ref}`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 {ref}`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 {ref}`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 {doc}`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. +21. A new benchmark {ref}`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). +23. {ref}`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): {ref}`plot_pareto_front_pcp() ` (parallel coordinates, any M >= 2), {ref}`plot_pareto_front_scatter_matrix() ` (M-by-M pairwise scatter, best for M >= 4), and {ref}`plot_pareto_front_heatmap() ` (solutions-by-objectives heatmap). The other four require `save_solutions=True`: {ref}`plot_fitness_band() ` (per-generation min / mean / max with a shaded band), {ref}`plot_non_dominated_hypervolume() ` (hypervolume of the non-dominated set per generation), {ref}`plot_population_diversity() ` (mean pairwise distance per generation), and {ref}`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]`. +31. A new `Report` mixin in `pygad/utils/report.py` adds {ref}`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 98a7b961..7f063237 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -232,6 +232,7 @@ The next subsections list the supported methods for crossover. Applies the single-point crossover. It selects a point randomly at which crossover takes place between the pairs of parents. +(two-points-crossover)= #### `two_points_crossover()` Applies the 2 points crossover. It selects the 2 points randomly at which crossover takes place between the pairs of parents. @@ -248,6 +249,7 @@ Applies the uniform crossover. For each gene, a parent out of the 2 mating paren Applies the scattered crossover. It randomly selects the gene from one of the 2 parents. +(sbx-crossover)= #### `sbx_crossover()` Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` parameter controls the spread: larger values keep children closer to their parents. Bounds come from `init_range_low` and `init_range_high`, which can specify a separate range for each gene. @@ -302,6 +304,7 @@ Each gene participates in at most one fallback swap per offspring per mutation p For each gene, a random value is selected according to the range specified by the 2 attributes `random_mutation_min_val` and `random_mutation_max_val`. The random value is added to the selected gene. +(swap-mutation)= #### `swap_mutation()` Applies the swap mutation which interchanges the values of 2 randomly selected genes. @@ -312,6 +315,7 @@ Any pair of distinct positions can be selected. An offspring with only one gene Applies the inversion mutation which selects a subset of genes and inverts them. +(scramble-mutation)= #### `scramble_mutation()` Applies the scramble mutation which selects a subset of genes and shuffles their order randomly. @@ -324,6 +328,7 @@ Applies the adaptive mutation, which selects the number/percentage of genes to m The count-based and probability-based adaptive mutation methods use the same compatible-swap fallback for permutations as random mutation. Their fitness-based controls select which genes can initiate a mutation; swapped partners are not mutated again in the same pass. +(polynomial-mutation)= #### `polynomial_mutation(offspring)` Applies polynomial mutation to the passed two-dimensional offspring array in place and returns it. Each gene is selected with `mutation_probability`, or with probability `1 / num_genes` when that parameter is `None`. `polynomial_mutation_eta` controls the size of the change; higher values favor smaller changes. Bounds come from `init_range_low` and `init_range_high` for each gene, and mutated values are clipped to those bounds. Genes whose range has effectively zero width are skipped. When `allow_duplicate_genes=False`, the existing random duplicate-resolution helper is applied after changing a gene. @@ -400,6 +405,7 @@ The next subsections list the supported methods for parent selection. Selects the parents using the steady-state selection technique. +(rank-selection)= #### `rank_selection()` Selects the parents using the rank selection technique. @@ -418,6 +424,7 @@ Selects the parents using the tournament selection technique. Selects the parents using the roulette wheel selection technique. +(stochastic-universal-selection)= #### `stochastic_universal_selection()` Selects the parents using the stochastic universal selection technique. @@ -484,6 +491,7 @@ See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#gene example_generate_report.py ::: +(quality-indicators)= ## `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: diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 7ae9cdde..dc98f11b 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -114,6 +114,7 @@ ga_instance.plot_genes(graph_type="boxplot") plots/example_plot_genes.py ::: +(plot-pareto-front-curve)= ## `plot_pareto_front_curve()` 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. @@ -137,6 +138,7 @@ plots/example_plot_pareto_front_curve_2d.py plots/example_plot_pareto_front_curve_3d.py ::: +(plot-pareto-front-pcp)= ## `plot_pareto_front_pcp()` 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. @@ -153,6 +155,7 @@ ga_instance.plot_pareto_front_pcp() plots/example_plot_pareto_front_pcp.py ::: +(plot-pareto-front-scatter-matrix)= ## `plot_pareto_front_scatter_matrix()` 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. @@ -169,6 +172,7 @@ ga_instance.plot_pareto_front_scatter_matrix() plots/example_plot_pareto_front_scatter_matrix.py ::: +(plot-pareto-front-heatmap)= ## `plot_pareto_front_heatmap()` 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. @@ -185,6 +189,7 @@ ga_instance.plot_pareto_front_heatmap(sort_by=0) plots/example_plot_pareto_front_heatmap.py ::: +(plot-fitness-band)= ## `plot_fitness_band()` 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`. @@ -201,6 +206,7 @@ ga_instance.plot_fitness_band() plots/example_plot_fitness_band.py ::: +(plot-non-dominated-hypervolume)= ## `plot_non_dominated_hypervolume()` 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`. @@ -217,6 +223,7 @@ ga_instance.plot_non_dominated_hypervolume() plots/example_plot_non_dominated_hypervolume.py ::: +(plot-population-diversity)= ## `plot_population_diversity()` Mean pairwise Euclidean distance between solutions per generation. A drop signals the population is converging or collapsing into duplicates. Requires `save_solutions=True`. @@ -233,6 +240,7 @@ ga_instance.plot_population_diversity() plots/example_plot_population_diversity.py ::: +(plot-pareto-front-evolution)= ## `plot_pareto_front_evolution()` 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`. From a870fdd50204e0bdfab04b37ae069529d97d30dd Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 12:56:42 -0400 Subject: [PATCH 13/22] Make release links work in Markdown and built documentation --- docs/source/conf.py | 8 ++--- docs/source/releases.md | 72 ++++++++++++++++++++--------------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6875575a..74a7f7f7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -65,10 +65,10 @@ 'dollarmath', ] -# Do NOT set myst_heading_anchors. Leaving it unset keeps Sphinx using the -# docutils section IDs (for example "PyGAD 2.18.0" -> "pygad-2-18-0"), which -# are the anchors the live site already links to. Turning it on would switch -# to GitHub-style slugs and break those links. +# Resolve Markdown links such as [Plot Lifecycle](visualize.md#plot_lifecycle) +# using GitHub-style heading anchors at every heading level. MyST maps these +# anchors to the existing Sphinx section IDs, preserving published links. +myst_heading_anchors = 6 # -- Options for HTML output ------------------------------------------------- diff --git a/docs/source/releases.md b/docs/source/releases.md index b3f5700c..d55b0c1d 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -8,47 +8,47 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. -1. {ref}`Two-point crossover ` selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). -2. {ref}`Swap mutation ` can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). -3. {ref}`SBX crossover ` selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). +1. [Two-point crossover](utils.md#two_points_crossover) selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). +2. [Swap mutation](utils.md#swap_mutation) can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). +3. [SBX crossover](utils.md#sbx_crossover) selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). 4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). 5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. -6. {ref}`Parallel fitness evaluation ` now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. -7. Serial, thread, and process modes use the same fitness-cache rules and result validation. {ref}`Adaptive mutation evaluates the actual offspring `, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). +6. [Parallel fitness evaluation](fitness_calculation.md#parallel-processing-in-pygad) now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. +7. Serial, thread, and process modes use the same fitness-cache rules and result validation. [Adaptive mutation evaluates the actual offspring](utils.md#adaptive_mutation_population_fitnessoffspring), supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). 8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). -9. {ref}`pygad.kerasga.predict() ` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). -10. {ref}`Stochastic universal selection ` uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). -11. {ref}`Scramble mutation ` shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). -12. New examples explain {ref}`replacing a loaded fitness function `, starting fresh when the objective changes, and {ref}`handling short final fitness batches `. The lifecycle guide also explains {ref}`progress reporting ` and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). -13. {ref}`Rank selection ` assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. -14. A new {ref}`plot_lifecycle() ` method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. - -15. {ref}`Duplicate-gene repair ` now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. -16. Repair uses each destination gene's type, precision, and {ref}`range `, and validates {ref}`constraints ` against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. +9. [pygad.kerasga.predict()](kerasga.md#pygadkerasgapredict) synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). +10. [Stochastic universal selection](utils.md#stochastic_universal_selection) uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). +11. [Scramble mutation](utils.md#scramble_mutation) shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). +12. New examples explain [replacing a loaded fitness function](pygad.md#updating-the-fitness-function-after-loading), starting fresh when the objective changes, and [handling short final fitness batches](fitness_calculation.md#why-a-fitness-batch-can-be-smaller). The lifecycle guide also explains [progress reporting](lifecycle.md#reporting-progress) and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). +13. [Rank selection](utils.md#rank_selection) assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +14. A new [plot_lifecycle()](visualize.md#plot_lifecycle) method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. + +15. [Duplicate-gene repair](gene_values.md#prevent-duplicates-in-gene-values) now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. +16. Repair uses each destination gene's type, precision, and [range](gene_values.md#more-about-the-gene_space-parameter), and validates [constraints](gene_values.md#gene-constraint) against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. 17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. -18. {ref}`Initial population creation ` and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. +18. [Initial population creation](gene_values.md#creating-the-initial-population) and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. -19. {ref}`Gene-type validation and conversion ` share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. +19. [Gene-type validation and conversion](gene_values.md#conversion-and-rounding-rules) share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. -20. {ref}`Constructor validation ` shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. {ref}`Stop criteria ` share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. -21. Only the {ref}`active mutation control ` is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. -22. Each GA owns {ref}`NumPy and Python random generators `. NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. +20. [Constructor validation](pygad.md#__init__) shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. [Stop criteria](generations.md#stop-criteria) share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. +21. Only the [active mutation control](pygad.md#mutation) is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. +22. Each GA owns [NumPy and Python random generators](generations.md#random-seed). NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. 23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. -24. The new {ref}`best_solutions_generations and solutions_generations attributes ` record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. -25. {ref}`saturate_N ` checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. +24. The new [best_solutions_generations and solutions_generations attributes](fitness_calculation.md#saved-fitness-across-repeated-runs) record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +25. [saturate_N](generations.md#stop-criteria) checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. 26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. -27. {ref}`Fitness validation ` is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. -28. {ref}`Saved fitness ` uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. +27. [Fitness validation](fitness_calculation.md#fitness-output-validation) is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. +28. [Saved fitness](fitness_calculation.md#reuse-the-fitness-instead-of-calling-the-fitness-function) uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. 29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. 30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. -31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. +31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. Feature links use standard Markdown paths and heading anchors so they work in repository views and built documentation. -32. The {doc}`generation guide ` explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. +32. The [generation guide](generations.md) explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. -33. A new {doc}`Examples index ` connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. +33. A new [Examples index](examples.md) connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. @@ -77,27 +77,27 @@ Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). 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 {ref}`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. +11. Support of multi-objective optimization using the [Non-Dominated Sorting Genetic Algorithm III (NSGA-III)](multi_objective.md#nsga-iii-example). 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: {ref}`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: {ref}`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 {ref}`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 {ref}`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 {doc}`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. +15. A new crossover operator: [Simulated Binary Crossover (SBX)](utils.md#sbx_crossover). 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](utils.md#polynomial_mutationoffspring). 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](generations.md#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](utils.md#pygadutilsquality_indicators-submodule) 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](benchmarks.md) 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 {ref}`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. +21. A new benchmark [pygad.benchmarks.tsp](benchmarks.md#travelling-salesman-problem) 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. {ref}`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): {ref}`plot_pareto_front_pcp() ` (parallel coordinates, any M >= 2), {ref}`plot_pareto_front_scatter_matrix() ` (M-by-M pairwise scatter, best for M >= 4), and {ref}`plot_pareto_front_heatmap() ` (solutions-by-objectives heatmap). The other four require `save_solutions=True`: {ref}`plot_fitness_band() ` (per-generation min / mean / max with a shaded band), {ref}`plot_non_dominated_hypervolume() ` (hypervolume of the non-dominated set per generation), {ref}`plot_population_diversity() ` (mean pairwise distance per generation), and {ref}`plot_pareto_front_evolution() ` (non-dominated set overlaid every k generations). +23. [plot_pareto_front_curve()](visualize.md#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()](visualize.md#plot_pareto_front_pcp) (parallel coordinates, any M >= 2), [plot_pareto_front_scatter_matrix()](visualize.md#plot_pareto_front_scatter_matrix) (M-by-M pairwise scatter, best for M >= 4), and [plot_pareto_front_heatmap()](visualize.md#plot_pareto_front_heatmap) (solutions-by-objectives heatmap). The other four require `save_solutions=True`: [plot_fitness_band()](visualize.md#plot_fitness_band) (per-generation min / mean / max with a shaded band), [plot_non_dominated_hypervolume()](visualize.md#plot_non_dominated_hypervolume) (hypervolume of the non-dominated set per generation), [plot_population_diversity()](visualize.md#plot_population_diversity) (mean pairwise distance per generation), and [plot_pareto_front_evolution()](visualize.md#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 {ref}`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]`. +31. A new `Report` mixin in `pygad/utils/report.py` adds [ga_instance.generate_report(filename, ...)](pygad.md#generate_report) 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. From f6be808eebad035befcb5236683e911d531e87b7 Mon Sep 17 00:00:00 2001 From: Ahmed Gad Date: Fri, 9 Oct 2026 13:14:32 -0400 Subject: [PATCH 14/22] Make documentation readable in Markdown previews before building --- docs/MARKDOWN.md | 41 ++ docs/PYTHON_EXAMPLES.md | 22 +- docs/markdown_compatibility.py | 171 ++++++ .../card-source.md.template | 12 + .../table-source.md.template | 1 + docs/source/adaptive_mutation.md | 2 +- docs/source/benchmarks.md | 263 ++++++++- docs/source/cnn.md | 30 +- docs/source/conf.py | 1 + docs/source/custom_functions.md | 81 ++- docs/source/examples.md | 167 +++++- docs/source/fitness_calculation.md | 114 +++- docs/source/gacnn.md | 30 +- docs/source/gann.md | 30 +- docs/source/gann_image_classification.md | 30 +- docs/source/gann_regression_1.md | 27 +- docs/source/gann_regression_2.md | 30 +- docs/source/gann_xor.md | 27 +- docs/source/gene_values.md | 147 ++++- docs/source/generations.md | 109 +++- docs/source/help.md | 38 +- docs/source/index.md | 60 ++- docs/source/kerasga.md | 38 +- docs/source/kerasga_image_conv.md | 30 +- docs/source/kerasga_image_datagen.md | 52 +- docs/source/kerasga_image_dense.md | 30 +- docs/source/kerasga_regression.md | 27 +- docs/source/kerasga_xor.md | 27 +- docs/source/lifecycle.md | 78 ++- docs/source/logging.md | 85 ++- docs/source/multi_objective.md | 48 +- docs/source/nn.md | 30 +- docs/source/nn_image_classification.md | 52 +- docs/source/nn_regression_1.md | 27 +- docs/source/nn_regression_2.md | 30 +- docs/source/nn_xor.md | 27 +- docs/source/pygad.md | 501 +++++++++++------- docs/source/pygad_more.md | 62 +-- docs/source/releases.md | 4 + docs/source/steps_to_use.md | 27 +- docs/source/torchga.md | 30 +- docs/source/torchga_image_conv.md | 30 +- docs/source/torchga_image_dense.md | 30 +- docs/source/torchga_regression.md | 27 +- docs/source/torchga_xor.md | 27 +- docs/source/user_defined_operators.md | 27 +- docs/source/utils.md | 153 ++++-- docs/source/visualize.md | 361 ++++++++++++- 48 files changed, 2755 insertions(+), 538 deletions(-) create mode 100644 docs/MARKDOWN.md create mode 100644 docs/markdown_compatibility.py create mode 100644 docs/python_example_templates/card-source.md.template create mode 100644 docs/python_example_templates/table-source.md.template diff --git a/docs/MARKDOWN.md b/docs/MARKDOWN.md new file mode 100644 index 00000000..51dc7175 --- /dev/null +++ b/docs/MARKDOWN.md @@ -0,0 +1,41 @@ +# Writing Documentation in Markdown + +Documentation pages should be readable on GitHub and in Markdown previews as well as on Read the Docs. Use ordinary Markdown for headings, links, images, lists, tables, and code blocks. + +For links within the documentation, use a relative `.md` path and the GitHub-style heading anchor: + +```markdown +[Plot Lifecycle](visualize.md#plot_lifecycle) +``` + +MyST resolves these links to the appropriate pages and section IDs during a Sphinx build. Existing published anchors remain available. Builds report missing pages and heading anchors. + +Use HTML `

` and `` for collapsible descriptions. Leave blank lines around their Markdown content. Use `` for code in a summary because Markdown formatting is not processed inside the summary itself: + +```markdown +
+gene_type=float: Data type of the genes. + +The type used to store each gene value. + +
+``` + +The `markdown_compatibility.py` extension converts these blocks into the existing Sphinx Design dropdowns for HTML and other documentation formats. + +Keep Sphinx-only metadata, such as explicit labels and toctrees, inside `` comments. The extension restores this metadata during a build; Markdown previews hide it. A toctree should have visible Markdown navigation beside it, either a list on the home page or a navigation group: + +```markdown + + +- [Controlling Gene Values](gene_values.md) — Set ranges, types, constraints, and duplicate prevention. +- [Controlling Generations](generations.md) — Configure stopping, elitism, and continuation. + + +``` + +The build presents these lists as the existing navigation cards. Their titles, destinations, and descriptions are written only once. + +For diagrams with a preferred display width, put a normal PNG image and its caption between `documentation-figure` comments, following the existing pages. The image works directly in Markdown; the build restores the centered figure, caption, and width and selects the appropriate image format. Embedded videos stay in Sphinx comments with a visible YouTube link beside them. + +Python example sections are generated from the catalog and shared templates. See [Connecting Python Examples to the Documentation](PYTHON_EXAMPLES.md) for the editing and checking commands. Regeneration needs only Python; it does not require Sphinx or execute the examples. diff --git a/docs/PYTHON_EXAMPLES.md b/docs/PYTHON_EXAMPLES.md index 8f22dbb2..fdb4f93a 100644 --- a/docs/PYTHON_EXAMPLES.md +++ b/docs/PYTHON_EXAMPLES.md @@ -2,16 +2,28 @@ `python_examples.json` is the shared catalog for the Examples index and the Python example cards in the guides. Keep the descriptions and requirements here rather than copying them into each guide. Paths are relative to the repository's `examples/` directory. +For the general documentation conventions, see [Writing Documentation in Markdown](MARKDOWN.md). + To show one or more examples beside a relevant explanation, use this template in a documentation page: ```markdown -:::{python-examples} + + + +``` + +After changing the catalog or adding a section, update the checked-in Markdown from the repository root: + +```console +python docs/markdown_compatibility.py --update-examples ``` -For larger groups, the same directive uses a compact table inside the card. Run instructions remain in a dropdown. The Examples index uses `python-examples-index` to list every entry by topic, with links back to its guide. +The generated section contains ordinary Markdown links, descriptions, and expandable run instructions. It is readable on GitHub and in Markdown previews before any build. Do not edit the generated text directly; edit the catalog or shared templates, then regenerate it. To check that the generated sections are current, run `python docs/markdown_compatibility.py`. + +For larger groups, the section uses a compact table with expandable run instructions. The Examples index uses `python-examples-index` comments to list every entry by topic, with links back to its guide. Sphinx presents these sections using the existing example cards, tables, dropdowns, and downloads. Each catalog entry has these fields: @@ -26,8 +38,8 @@ Each catalog entry has these fields: - `data` (optional): Dataset filenames, expected layout, and any setup limitations. - `download` (optional, default `true`): Set to `false` when downloading a script alone would omit required data or companion files. Readers receive a folder link instead. -The shared Markdown templates are in `python_example_templates/`. They use the existing Sphinx Design cards and dropdowns and Sphinx's native download links. The small `python_examples.py` extension resolves catalog paths and renders those templates; it does not execute example scripts. +The shared templates are in `python_example_templates/`. The `*-source.md.template` files use standard Markdown and generate the checked-in sections. The `.md.jinja` files use Sphinx Design cards and dropdowns and Sphinx's native download links. Both presentations use the same catalog. The `markdown_compatibility.py` extension checks the source sections and passes them to `python_examples.py` for the built presentation; neither executes example scripts. -The documentation build checks that every Python script appears in the catalog, all catalog paths stay inside `examples/`, and the linked guides exist. Missing entries fail the build so new examples are not silently left out. Unknown paths in a guide also fail the build. Sphinx copies downloadable scripts from the repository into the built documentation; no second source copy needs to be maintained. +The documentation build checks that every Python script appears in the catalog, all catalog paths stay inside `examples/`, and the linked guides exist. Missing entries, unknown paths, incomplete section comments, and stale generated Markdown fail the build so examples are not silently left out. Sphinx copies downloadable scripts from the repository into the built documentation; no second script copy needs to be maintained. GitHub links use the commit checked out for the documentation build. Without Git, the configured Read the Docs identifier is used, falling back to `master`. This keeps source links aligned with versioned documentation. diff --git a/docs/markdown_compatibility.py b/docs/markdown_compatibility.py new file mode 100644 index 00000000..529c581e --- /dev/null +++ b/docs/markdown_compatibility.py @@ -0,0 +1,171 @@ +"""Keep Markdown readable directly while retaining the Sphinx presentation.""" + +import argparse +import html +import json +from pathlib import Path, PurePosixPath +import re +from string import Template + + +DOCUMENTATION_DIRECTORY = Path(__file__).resolve().parent +EXAMPLE_BLOCK = re.compile( + r'\n' + r'(?P.*?)\n', re.DOTALL) + + +def render_markdown_examples(catalog, paths, render_index=False): + """Render checked-in example links and instructions from the shared catalog.""" + examples_by_path = {example['path']: example for example in catalog} + examples = catalog if render_index else [examples_by_path[path] for path in paths] + if not examples: + raise ValueError('List at least one Python example in the Markdown marker.') + templates_directory = DOCUMENTATION_DIRECTORY / 'python_example_templates' + card_template = Template((templates_directory / 'card-source.md.template').read_text(encoding='utf-8')) + row_template = Template((templates_directory / 'table-source.md.template').read_text(encoding='utf-8')) + + def render_table(group): + rows = ['| Python script | What it shows | Related information |', '| --- | --- | --- |'] + for example in group: + folder = PurePosixPath(example['path']).parent.as_posix() + links = f"[Guide]({example['guide']})" + if not example.get('download', True): + links += f' · [Folder](../../examples/{folder}/) · [Data setup](../../examples/data/README.md)' + rows.append(row_template.substitute( + path=example['path'], title=example['title'], description=example['description'], + links=links).strip()) + return '\n'.join(rows) + + if render_index: + categories = dict.fromkeys(example['category'] for example in examples) + return '\n\n'.join(f'## {category}\n\n' + render_table([ + example for example in examples if example['category'] == category]) for category in categories) + + cards = [] + group_instructions = [] + for example in examples: + data_instructions = '' + if example.get('data'): + data_instructions = (f"**Data:** {example['data']} " + 'See the [dataset setup instructions](../../examples/data/README.md).\n\n') + run_instructions = '' + if example['run']: + run_note = example.get('run_note', 'From the repository root, with the repository version of PyGAD installed:') + run_instructions = f"{run_note}\n\n```console\n{example['run']}\n```\n\n" + cards.append(card_template.substitute( + title=example['title'], path=example['path'], description=example['description'], + requirements=example['requirements'], data_instructions=data_instructions, + run_instructions=run_instructions).strip()) + group_instructions.append((f"**{example['title']}** — Requires: {example['requirements']}\n\n" + + data_instructions + run_instructions).strip()) + if len(examples) > 3: + instructions = '
\nRun these examples\n\n' + instructions += '\n\n'.join(group_instructions).strip() + '\n\n
' + return '**Python examples**\n\n' + render_table(examples) + '\n\n' + instructions + title = '**Python example**' if len(examples) == 1 else '**Python examples**' + return title + '\n\n' + '\n\n'.join(cards) + + +def update_example_blocks(source, catalog, check_only=False): + """Refresh example sections, or reject stale sections during a build.""" + marker_count = len(re.findall(r'', source)) + if marker_count != closing_marker_count or marker_count != len(list(EXAMPLE_BLOCK.finditer(source))): + raise ValueError('Each Python example section needs a matching closing comment.') + + def replace_block(match): + paths = match['paths'].split() + render_index = match['kind'] == 'python-examples-index' + if render_index and paths: + raise ValueError('The Python examples index includes the whole catalog and does not accept paths.') + try: + expected = render_markdown_examples(catalog, paths, render_index) + except KeyError as error: + raise ValueError(f'Unknown Python example in docs/python_examples.json: {error.args[0]}') from error + if check_only and match['body'].strip() != expected: + raise ValueError('Python example content is out of date. Run python docs/markdown_compatibility.py --update-examples.') + if check_only: + return match.group(0) + header = '' + return header + '\n\n' + expected + '\n\n' + return EXAMPLE_BLOCK.sub(replace_block, source) + + +def prepare_sphinx_source(app, document_name, source): + """Restore presentation directives from portable Markdown and hidden metadata.""" + if not (Path(app.srcdir) / (document_name + '.md')).is_file(): + return + from sphinx.errors import ExtensionError + + try: + content = update_example_blocks(source[0], app.python_examples_catalog, check_only=True) + except (KeyError, ValueError) as error: + raise ExtensionError(f'{document_name}: {error}') from error + + def restore_example_section(match): + paths = '\n'.join(match['paths'].split()) + return ':::{' + match['kind'] + '}\n' + paths + '\n:::' + + content = EXAMPLE_BLOCK.sub(restore_example_section, content) + # Labels, toctrees, and embedded videos are build metadata, not visible prose. + content = re.sub(r'', r'\1', content, flags=re.DOTALL) + + def restore_grid(match): + cards = [] + for line in match['body'].splitlines(): + if not line.strip(): + continue + item = re.fullmatch(r'- \[([^]]+)\]\(([^)]+)\.md\)(?: — (.*))?', line) + if item is None: + raise ExtensionError(f'{document_name}: Invalid navigation item: {line}') + title, destination, description = item.groups() + cards.append(f':::{{grid-item-card}} {title}\n:link: {destination}\n:link-type: doc\n\n{description or ""}\n:::') + return '::::{grid} ' + match['dimensions'].strip() + '\n:gutter: 3\n\n' + '\n\n'.join(cards) + '\n::::' + + content = re.sub(r'\n(?P.*?)\n', + restore_grid, content, flags=re.DOTALL) + + def restore_figure(match): + # The PNG works in repository previews; Sphinx can select SVG or PNG. + image_path = str(PurePosixPath(match['path']).with_suffix('.*')) + return (f':::{{figure}} {image_path}\n:alt: {match["alt"]}\n' + f':width: {match["width"]}\n:align: center\n\n{match["caption"]}\n:::') + + content = re.sub( + r'\n\n' + r'!\[(?P.*?)\]\((?P.*?)\)\n\n(?P.*?)\n\n', + restore_figure, content, flags=re.DOTALL) + + def restore_dropdown(match): + title = re.sub(r'(.*?)', r'`\1`', html.unescape(match['title'])) + return f':::{{dropdown}} {title}\n:animate: fade-in-slide-down\n\n{match["body"].strip()}\n:::' + + content = re.sub(r'
\n(?P.*?)</summary>\n(?P<body>.*?)\n</details>', + restore_dropdown, content, flags=re.DOTALL) + for filename in ['card-source.md.template', 'table-source.md.template']: + app.env.note_dependency(str(DOCUMENTATION_DIRECTORY / 'python_example_templates' / filename)) + source[0] = content + + +def setup(app): + """Use readable source Markdown for all Sphinx output formats.""" + app.connect('source-read', prepare_sphinx_source) + return {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True} + + +def main(): + """Update or check generated Markdown without installing documentation tools.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--update-examples', action='store_true', help='Refresh example sections from the catalog.') + arguments = parser.parse_args() + catalog = json.loads((DOCUMENTATION_DIRECTORY / 'python_examples.json').read_text(encoding='utf-8')) + for path in sorted((DOCUMENTATION_DIRECTORY / 'source').glob('*.md')): + source = path.read_text(encoding='utf-8') + updated = update_example_blocks(source, catalog, check_only=not arguments.update_examples) + if arguments.update_examples and updated != source: + path.write_text(updated, encoding='utf-8') + print('Python example Markdown updated.' if arguments.update_examples else 'Python example Markdown is current.') + + +if __name__ == '__main__': + main() diff --git a/docs/python_example_templates/card-source.md.template b/docs/python_example_templates/card-source.md.template new file mode 100644 index 00000000..a36eb74b --- /dev/null +++ b/docs/python_example_templates/card-source.md.template @@ -0,0 +1,12 @@ +**[$title](../../examples/$path)** + +$description + +`examples/$path` + +<details> +<summary>Run this example</summary> + +**Requires:** $requirements + +$data_instructions$run_instructions</details> diff --git a/docs/python_example_templates/table-source.md.template b/docs/python_example_templates/table-source.md.template new file mode 100644 index 00000000..6f84e5b1 --- /dev/null +++ b/docs/python_example_templates/table-source.md.template @@ -0,0 +1 @@ +| [$path](../../examples/$path) | **$title.** $description | $links | diff --git a/docs/source/adaptive_mutation.md b/docs/source/adaptive_mutation.md index 921a8dd6..8a937473 100644 --- a/docs/source/adaptive_mutation.md +++ b/docs/source/adaptive_mutation.md @@ -34,7 +34,7 @@ In [PyGAD 2.10.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-1 1. In the constructor of the `pygad.GA` class, set `mutation_type="adaptive"` to specify that the type of mutation is adaptive. 2. Specify the mutation rates for the low and high quality solutions using one of these 3 parameters according to your preference: `mutation_probability`, `mutation_num_genes`, and `mutation_percent_genes`. Please check the [documentation of each of these parameters](https://pygad.readthedocs.io/en/latest/pygad.html#init) for more information. -For permutations with `allow_duplicate_genes=False` and no unused values in `gene_space`, both adaptive mutation controls use a compatible-swap fallback. The configured rates select the genes that can initiate mutation. A fallback swap changes two positions and can involve a partner that was not selected by the mutation rate. Each gene participates in at most one fallback swap per mutation pass. See {ref}`Mutation Methods <mutation-methods>` for the type, gene-space, and constraint checks. +For permutations with `allow_duplicate_genes=False` and no unused values in `gene_space`, both adaptive mutation controls use a compatible-swap fallback. The configured rates select the genes that can initiate mutation. A fallback swap changes two positions and can involve a partner that was not selected by the mutation rate. Each gene participates in at most one fallback swap per mutation pass. See [Mutation Methods](utils.md#mutation-methods) for the type, gene-space, and constraint checks. When adaptive mutation is used, then the value assigned to any of the 3 parameters can be of any of these data types: diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 31da70a9..f0e1e09b 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -26,7 +26,7 @@ Available in `pygad.benchmarks.classic`: | `Ackley` | f(0, ..., 0) = 0 | `(-32.768, 32.768)` | | `Himmelblau` | four equal minima at f = 0 (2D only) | `(-5.0, 5.0)` | -:::{python-examples} +<!-- python-examples benchmarks/example_classic_sphere.py benchmarks/example_classic_rastrigin.py benchmarks/example_classic_rosenbrock.py @@ -34,7 +34,82 @@ benchmarks/example_classic_griewank.py benchmarks/example_classic_schwefel.py benchmarks/example_classic_ackley.py benchmarks/example_classic_himmelblau.py -::: +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_classic_sphere.py](../../examples/benchmarks/example_classic_sphere.py) | **Sphere.** Optimize the Sphere single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rastrigin.py](../../examples/benchmarks/example_classic_rastrigin.py) | **Rastrigin.** Optimize the Rastrigin single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rosenbrock.py](../../examples/benchmarks/example_classic_rosenbrock.py) | **Rosenbrock.** Optimize the Rosenbrock single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_griewank.py](../../examples/benchmarks/example_classic_griewank.py) | **Griewank.** Optimize the Griewank single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_schwefel.py](../../examples/benchmarks/example_classic_schwefel.py) | **Schwefel.** Optimize the Schwefel single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_ackley.py](../../examples/benchmarks/example_classic_ackley.py) | **Ackley.** Optimize the Ackley single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_himmelblau.py](../../examples/benchmarks/example_classic_himmelblau.py) | **Himmelblau.** Optimize the Himmelblau single-objective benchmark. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**Sphere** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_sphere.py +``` + +**Rastrigin** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_rastrigin.py +``` + +**Rosenbrock** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_rosenbrock.py +``` + +**Griewank** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_griewank.py +``` + +**Schwefel** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_schwefel.py +``` + +**Ackley** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_ackley.py +``` + +**Himmelblau** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_himmelblau.py +``` + +</details> + +<!-- /python-examples --> ## Multi-Objective Problems (ZDT family) @@ -48,13 +123,70 @@ In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5 | `ZDT4` | convex, many local minima in the search space | | `ZDT6` | non-uniform | -:::{python-examples} +<!-- python-examples benchmarks/example_zdt1.py benchmarks/example_zdt2.py benchmarks/example_zdt3.py benchmarks/example_zdt4.py benchmarks/example_zdt6.py -::: +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_zdt1.py](../../examples/benchmarks/example_zdt1.py) | **ZDT1.** Optimize the ZDT1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt2.py](../../examples/benchmarks/example_zdt2.py) | **ZDT2.** Optimize the ZDT2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt3.py](../../examples/benchmarks/example_zdt3.py) | **ZDT3.** Optimize the ZDT3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt4.py](../../examples/benchmarks/example_zdt4.py) | **ZDT4.** Optimize the ZDT4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt6.py](../../examples/benchmarks/example_zdt6.py) | **ZDT6.** Optimize the ZDT6 problem and plot its Pareto front. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**ZDT1** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt1.py +``` + +**ZDT2** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt2.py +``` + +**ZDT3** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt3.py +``` + +**ZDT4** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt4.py +``` + +**ZDT6** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt6.py +``` + +</details> + +<!-- /python-examples --> ## Many-Objective Problems (DTLZ family) @@ -67,12 +199,60 @@ In `pygad.benchmarks.dtlz`. Any number of objectives `M`. Decision variables: `M | `DTLZ3` | 3 | unit sphere with hard multimodal g-function | | `DTLZ4` | 3 | unit sphere with strong bias toward one corner | -:::{python-examples} +<!-- python-examples benchmarks/example_dtlz1.py benchmarks/example_dtlz2.py benchmarks/example_dtlz3.py benchmarks/example_dtlz4.py -::: +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_dtlz1.py](../../examples/benchmarks/example_dtlz1.py) | **DTLZ1.** Optimize the DTLZ1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz2.py](../../examples/benchmarks/example_dtlz2.py) | **DTLZ2.** Optimize the DTLZ2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz3.py](../../examples/benchmarks/example_dtlz3.py) | **DTLZ3.** Optimize the DTLZ3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz4.py](../../examples/benchmarks/example_dtlz4.py) | **DTLZ4.** Optimize the DTLZ4 problem and plot its Pareto front. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**DTLZ1** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz1.py +``` + +**DTLZ2** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz2.py +``` + +**DTLZ3** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz3.py +``` + +**DTLZ4** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz4.py +``` + +</details> + +<!-- /python-examples --> ## Combinatorial Problems @@ -104,11 +284,36 @@ ga = pygad.GA( ga.run() ``` -:::{python-examples} +<!-- python-examples benchmarks/example_knapsack.py -::: +--> + +**Python example** + +**[Knapsack](../../examples/benchmarks/example_knapsack.py)** +Select items to maximize value within a weight capacity. + +`examples/benchmarks/example_knapsack.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_knapsack.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (tsp-benchmark)= +--> ### 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. @@ -155,10 +360,48 @@ ga = pygad.GA( ga.run() ``` -:::{python-examples} +<!-- python-examples benchmarks/example_tsp.py example_travelling_salesman.ipynb -::: +--> + +**Python examples** + +**[Travelling salesman](../../examples/benchmarks/example_tsp.py)** + +Find a short tour using a permutation of four cities. + +`examples/benchmarks/example_tsp.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_tsp.py +``` + +</details> + +**[Travelling-salesman Colab notebook](../../examples/example_travelling_salesman.ipynb)** + +Explore a city-tour problem using a user-supplied CSV and interactive maps. + +`examples/example_travelling_salesman.ipynb` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Google Colab, NumPy, pandas, Plotly, folium, and geopy + +**Data:** The notebook reads /content/sample_data/startbucks.csv in Google Colab. Supply a compatible CSV at that path. The original data source was not recorded. For local Jupyter use, adapt the Colab-specific imports and CSV path. See the [dataset setup instructions](../../examples/data/README.md). + +</details> + +<!-- /python-examples --> ## Example: SOO diff --git a/docs/source/cnn.md b/docs/source/cnn.md index 5f1f941e..9b72c10f 100644 --- a/docs/source/cnn.md +++ b/docs/source/cnn.md @@ -513,6 +513,32 @@ print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` -:::{python-examples} +<!-- python-examples cnn/example_image_classification.py -::: +--> + +**Python example** + +**[Build a CNN](../../examples/cnn/example_image_classification.py)** + +Classify fruit images using prepared image arrays. + +`examples/cnn/example_image_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/cnn/ so the relative data paths resolve: + +```console +cd examples/cnn +python example_image_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/conf.py b/docs/source/conf.py index 74a7f7f7..5e09cf1d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,6 +39,7 @@ 'sphinx_design', 'sphinx_copybutton', 'python_examples', + 'markdown_compatibility', ] # Read both Markdown and reStructuredText. Markdown is the source of truth. diff --git a/docs/source/custom_functions.md b/docs/source/custom_functions.md index 53fd8a90..73ea134f 100644 --- a/docs/source/custom_functions.md +++ b/docs/source/custom_functions.md @@ -67,9 +67,32 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` -:::{python-examples} +<!-- python-examples example_fitness_wrapper.py -::: +--> + +**Python example** + +**[Extra fitness arguments](../../examples/example_fitness_wrapper.py)** + +Wrap a fitness function to pass additional values while preserving its PyGAD signature. + +`examples/example_fitness_wrapper.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_fitness_wrapper.py +``` + +</details> + +<!-- /python-examples --> ## Assign Methods @@ -122,9 +145,32 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` -:::{python-examples} +<!-- python-examples example_lifecycle_methods.py -::: +--> + +**Python example** + +**[Callbacks as methods](../../examples/example_lifecycle_methods.py)** + +Implement fitness and lifecycle callbacks with bound methods. + +`examples/example_lifecycle_methods.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_lifecycle_methods.py +``` + +</details> + +<!-- /python-examples --> ## Assign a Class @@ -200,6 +246,29 @@ ga_instance = pygad.GA(num_generations=10, ga_instance.run() ``` -:::{python-examples} +<!-- python-examples example_lifecycle_classes.py -::: +--> + +**Python example** + +**[Callbacks as callable classes](../../examples/example_lifecycle_classes.py)** + +Implement fitness and lifecycle callbacks with callable class instances. + +`examples/example_lifecycle_classes.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_lifecycle_classes.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/examples.md b/docs/source/examples.md index 8881a8be..9144622d 100644 --- a/docs/source/examples.md +++ b/docs/source/examples.md @@ -22,5 +22,168 @@ Examples requiring datasets link to their folders instead of offering a standalo Use documentation search or your browser's find command to locate a topic or filename on this page. -:::{python-examples-index} -::: +<!-- python-examples-index --> + +## Getting Started + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example.py](../../examples/example.py) | **First GA run.** Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA. | [Guide](steps_to_use.md) | + +## Population and Genes + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_initial_population.py](../../examples/example_initial_population.py) | **Initial populations.** Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions. | [Guide](gene_values.md) | +| [example_gene_space.py](../../examples/example_gene_space.py) | **Gene spaces.** Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries. | [Guide](gene_values.md) | +| [example_gene_constraint.py](../../examples/example_gene_constraint.py) | **Gene constraints.** Filter gene candidates with constraints that depend on other genes. | [Guide](gene_values.md) | +| [example_duplicate_gene_repair.py](../../examples/example_duplicate_gene_repair.py) | **Duplicate repair.** Repair duplicates through a chain of replacements while respecting each gene space. | [Guide](gene_values.md) | +| [example_gene_type_conversion.py](../../examples/example_gene_type_conversion.py) | **Gene types and rounding.** Preserve mixed numeric types, apply precision, and convert custom mutation outputs. | [Guide](gene_values.md) | +| [example_dynamic_population_size.py](../../examples/example_dynamic_population_size.py) | **Changing population size.** Adjust the population and related runtime settings during evolution. | [Guide](generations.md) | + +## Operators and Configuration + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_custom_operators.py](../../examples/example_custom_operators.py) | **Custom GA operators.** Implement parent selection, crossover, and mutation functions. | [Guide](user_defined_operators.md) | +| [example_constructor_parameters.py](../../examples/example_constructor_parameters.py) | **Constructor settings and random seeds.** Use callable fitness signatures, NumPy counts, and independent seeded GA instances. | [Guide](pygad.md) | + +## Fitness and Parallel Processing + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_fitness_batch_size.py](../../examples/example_fitness_batch_size.py) | **Batch fitness.** Return one fitness result per solution, including a shorter final batch. | [Guide](fitness_calculation.md) | +| [example_parallel_processing.py](../../examples/example_parallel_processing.py) | **Parallel fitness.** Evaluate population fitness with process workers and report the run time. | [Guide](fitness_calculation.md) | +| [benchmarks/parallel_processing.py](../../examples/benchmarks/parallel_processing.py) | **Compare fitness execution modes.** Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation. | [Guide](fitness_calculation.md) | +| [example_fitness_wrapper.py](../../examples/example_fitness_wrapper.py) | **Extra fitness arguments.** Wrap a fitness function to pass additional values while preserving its PyGAD signature. | [Guide](custom_functions.md) | + +## Lifecycle and Saved Runs + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [pygad_lifecycle.py](../../examples/pygad_lifecycle.py) | **Lifecycle callbacks.** Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks. | [Guide](lifecycle.md) | +| [example_lifecycle_methods.py](../../examples/example_lifecycle_methods.py) | **Callbacks as methods.** Implement fitness and lifecycle callbacks with bound methods. | [Guide](custom_functions.md) | +| [example_lifecycle_classes.py](../../examples/example_lifecycle_classes.py) | **Callbacks as callable classes.** Implement fitness and lifecycle callbacks with callable class instances. | [Guide](custom_functions.md) | +| [example_summary.py](../../examples/example_summary.py) | **Text lifecycle summary.** Print the configured GA stages and their parameters. | [Guide](pygad_more.md) | +| [example_logger.py](../../examples/example_logger.py) | **Logging.** Send progress and GA messages to a configured logger. | [Guide](logging.md) | +| [example_repeated_runs.py](../../examples/example_repeated_runs.py) | **Repeated runs and checkpoints.** Continue from a saved GA and inspect the actual generation numbers in its histories. | [Guide](generations.md) | +| [example_load_fitness_function.py](../../examples/example_load_fitness_function.py) | **Change a loaded fitness function.** Replace the fitness callable after loading, or start fresh when the objective changes. | [Guide](pygad.md) | + +## Multi-Objective Optimization + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_multi_objective.py](../../examples/example_multi_objective.py) | **NSGA-II optimization.** Optimize two objectives and inspect the resulting trade-offs. | [Guide](multi_objective.md) | +| [example_multi_objective_nsga3.py](../../examples/example_multi_objective_nsga3.py) | **NSGA-III optimization.** Configure reference points and optimize two objectives with NSGA-III. | [Guide](multi_objective.md) | + +## Plots + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [plots/example_plot_fitness.py](../../examples/plots/example_plot_fitness.py) | **Best-fitness curve.** Plot best fitness across generations on the Sphere benchmark. | [Guide](visualize.md) | +| [plots/example_plot_fitness_band.py](../../examples/plots/example_plot_fitness_band.py) | **Fitness band.** Plot per-generation minimum, mean, and maximum fitness with a shaded band. | [Guide](visualize.md) | +| [plots/example_plot_genes.py](../../examples/plots/example_plot_genes.py) | **Gene histories.** Show how gene values change across saved generations. | [Guide](visualize.md) | +| [plots/example_plot_lifecycle.py](../../examples/plots/example_plot_lifecycle.py) | **Configured lifecycle.** Draw detailed and compact lifecycle charts and export SVG and PNG files. | [Guide](visualize.md) | +| [plots/example_plot_new_solution_rate.py](../../examples/plots/example_plot_new_solution_rate.py) | **New-solution rate.** Count previously unseen solutions in each generation. | [Guide](visualize.md) | +| [plots/example_plot_non_dominated_hypervolume.py](../../examples/plots/example_plot_non_dominated_hypervolume.py) | **Hypervolume history.** Track the hypervolume of the non-dominated set across generations. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_curve_2d.py](../../examples/plots/example_plot_pareto_front_curve_2d.py) | **2D Pareto front.** Plot a two-objective Pareto front after NSGA-II optimization. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_curve_3d.py](../../examples/plots/example_plot_pareto_front_curve_3d.py) | **3D Pareto front.** Plot a three-objective Pareto front after NSGA-III optimization. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_evolution.py](../../examples/plots/example_plot_pareto_front_evolution.py) | **Pareto-front evolution.** Overlay the non-dominated fronts from selected generations. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_heatmap.py](../../examples/plots/example_plot_pareto_front_heatmap.py) | **Pareto heatmap.** Compare objective values with a solutions-by-objectives heatmap. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_pcp.py](../../examples/plots/example_plot_pareto_front_pcp.py) | **Parallel coordinates.** Compare Pareto solutions across objective axes. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_scatter_matrix.py](../../examples/plots/example_plot_pareto_front_scatter_matrix.py) | **Pareto scatter matrix.** Compare every pair of objectives in a many-objective run. | [Guide](visualize.md) | +| [plots/example_plot_population_diversity.py](../../examples/plots/example_plot_population_diversity.py) | **Population diversity.** Track mean pairwise distance between solutions across generations. | [Guide](visualize.md) | + +## Reports + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_generate_report.py](../../examples/example_generate_report.py) | **PDF report.** Export the run configuration, summary, best solution, and applicable plots to PDF. | [Guide](pygad.md) | + +## Benchmarks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_classic_sphere.py](../../examples/benchmarks/example_classic_sphere.py) | **Sphere.** Optimize the Sphere single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rastrigin.py](../../examples/benchmarks/example_classic_rastrigin.py) | **Rastrigin.** Optimize the Rastrigin single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rosenbrock.py](../../examples/benchmarks/example_classic_rosenbrock.py) | **Rosenbrock.** Optimize the Rosenbrock single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_griewank.py](../../examples/benchmarks/example_classic_griewank.py) | **Griewank.** Optimize the Griewank single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_schwefel.py](../../examples/benchmarks/example_classic_schwefel.py) | **Schwefel.** Optimize the Schwefel single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_ackley.py](../../examples/benchmarks/example_classic_ackley.py) | **Ackley.** Optimize the Ackley single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_himmelblau.py](../../examples/benchmarks/example_classic_himmelblau.py) | **Himmelblau.** Optimize the Himmelblau single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt1.py](../../examples/benchmarks/example_zdt1.py) | **ZDT1.** Optimize the ZDT1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt2.py](../../examples/benchmarks/example_zdt2.py) | **ZDT2.** Optimize the ZDT2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt3.py](../../examples/benchmarks/example_zdt3.py) | **ZDT3.** Optimize the ZDT3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt4.py](../../examples/benchmarks/example_zdt4.py) | **ZDT4.** Optimize the ZDT4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt6.py](../../examples/benchmarks/example_zdt6.py) | **ZDT6.** Optimize the ZDT6 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz1.py](../../examples/benchmarks/example_dtlz1.py) | **DTLZ1.** Optimize the DTLZ1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz2.py](../../examples/benchmarks/example_dtlz2.py) | **DTLZ2.** Optimize the DTLZ2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz3.py](../../examples/benchmarks/example_dtlz3.py) | **DTLZ3.** Optimize the DTLZ3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz4.py](../../examples/benchmarks/example_dtlz4.py) | **DTLZ4.** Optimize the DTLZ4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_knapsack.py](../../examples/benchmarks/example_knapsack.py) | **Knapsack.** Select items to maximize value within a weight capacity. | [Guide](benchmarks.md) | +| [benchmarks/example_tsp.py](../../examples/benchmarks/example_tsp.py) | **Travelling salesman.** Find a short tour using a permutation of four cities. | [Guide](benchmarks.md) | +| [example_travelling_salesman.ipynb](../../examples/example_travelling_salesman.ipynb) | **Travelling-salesman Colab notebook.** Explore a city-tour problem using a user-supplied CSV and interactive maps. | [Guide](benchmarks.md) · [Folder](../../examples/./) · [Data setup](../../examples/data/README.md) | + +## Quality Indicators + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [quality_indicators/example_hypervolume.py](../../examples/quality_indicators/example_hypervolume.py) | **Hypervolume.** Measure the objective-space volume dominated by the final population. | [Guide](utils.md) | +| [quality_indicators/example_inverted_generational_distance.py](../../examples/quality_indicators/example_inverted_generational_distance.py) | **Inverted generational distance.** Measure distance from a reference front to the approximation. | [Guide](utils.md) | +| [quality_indicators/example_generational_distance.py](../../examples/quality_indicators/example_generational_distance.py) | **Generational distance.** Measure distance from the approximation to a reference front. | [Guide](utils.md) | +| [quality_indicators/example_spacing.py](../../examples/quality_indicators/example_spacing.py) | **Spacing.** Measure how evenly the approximation points are spread. | [Guide](utils.md) | + +## Neural Networks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [nn/example_regression.py](../../examples/nn/example_regression.py) | **Regression.** Fit a neural network to a small numeric regression problem. | [Guide](nn_regression_1.md) | +| [nn/example_XOR_classification.py](../../examples/nn/example_XOR_classification.py) | **XOR classification.** Train a neural network on the four XOR inputs. | [Guide](nn_xor.md) | +| [nn/example_classification.py](../../examples/nn/example_classification.py) | **Image classification.** Classify fruit images from prepared feature vectors. | [Guide](nn_image_classification.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | +| [nn/example_regression_fish.py](../../examples/nn/example_regression_fish.py) | **Fish-weight regression.** Predict fish weight from numeric measurements. | [Guide](nn_regression_2.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | +| [nn/extract_features.py](../../examples/nn/extract_features.py) | **Prepare image features.** Extract fruit-image features and write the arrays used by the dense classifiers. | [Guide](nn_image_classification.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | + +## Neural Networks with the GA + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [gann/example_regression.py](../../examples/gann/example_regression.py) | **Regression.** Fit a neural network to a small numeric regression problem. | [Guide](gann_regression_1.md) | +| [gann/example_XOR_classification.py](../../examples/gann/example_XOR_classification.py) | **XOR classification.** Train a neural network on the four XOR inputs. | [Guide](gann_xor.md) | +| [gann/example_classification.py](../../examples/gann/example_classification.py) | **Image classification.** Classify fruit images from prepared feature vectors. | [Guide](gann_image_classification.md) · [Folder](../../examples/gann/) · [Data setup](../../examples/data/README.md) | +| [gann/example_regression_fish.py](../../examples/gann/example_regression_fish.py) | **Fish-weight regression.** Predict fish weight from numeric measurements. | [Guide](gann_regression_2.md) · [Folder](../../examples/gann/) · [Data setup](../../examples/data/README.md) | + +## Convolutional Networks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [cnn/example_image_classification.py](../../examples/cnn/example_image_classification.py) | **Build a CNN.** Classify fruit images using prepared image arrays. | [Guide](cnn.md) · [Folder](../../examples/cnn/) · [Data setup](../../examples/data/README.md) | +| [gacnn/example_image_classification.py](../../examples/gacnn/example_image_classification.py) | **Optimize a CNN with the GA.** Classify fruit images using prepared image arrays. | [Guide](gacnn.md) · [Folder](../../examples/gacnn/) · [Data setup](../../examples/data/README.md) | + +## Keras + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [KerasGA/regression_example.py](../../examples/KerasGA/regression_example.py) | **Regression.** Optimize neural-network weights with the genetic algorithm. | [Guide](kerasga_regression.md) | +| [KerasGA/XOR_classification.py](../../examples/KerasGA/XOR_classification.py) | **XOR classification.** Optimize neural-network weights with the genetic algorithm. | [Guide](kerasga_xor.md) | +| [KerasGA/image_classification_Dense.py](../../examples/KerasGA/image_classification_Dense.py) | **Dense image classifier.** Train an image classifier with the genetic algorithm. | [Guide](kerasga_image_dense.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/image_classification_CNN.py](../../examples/KerasGA/image_classification_CNN.py) | **Convolutional image classifier.** Train an image classifier with the genetic algorithm. | [Guide](kerasga_image_conv.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/cancer_dataset.py](../../examples/KerasGA/cancer_dataset.py) | **Image-directory classification.** Use directory-based image input for a two-class Keras CNN. | [Guide](kerasga_image_datagen.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/cancer_dataset_generator.py](../../examples/KerasGA/cancer_dataset_generator.py) | **Batched image-directory classification.** Use directory-based image input for a two-class Keras CNN. | [Guide](kerasga_image_datagen.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | + +## PyTorch + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [TorchGA/regression_example.py](../../examples/TorchGA/regression_example.py) | **Regression.** Optimize neural-network weights with the genetic algorithm. | [Guide](torchga_regression.md) | +| [TorchGA/XOR_classification.py](../../examples/TorchGA/XOR_classification.py) | **XOR classification.** Optimize neural-network weights with the genetic algorithm. | [Guide](torchga_xor.md) | +| [TorchGA/image_classification_Dense.py](../../examples/TorchGA/image_classification_Dense.py) | **Dense image classifier.** Train an image classifier with the genetic algorithm. | [Guide](torchga_image_dense.md) · [Folder](../../examples/TorchGA/) · [Data setup](../../examples/data/README.md) | +| [TorchGA/image_classification_CNN.py](../../examples/TorchGA/image_classification_CNN.py) | **Convolutional image classifier.** Train an image classifier with the genetic algorithm. | [Guide](torchga_image_conv.md) · [Folder](../../examples/TorchGA/) · [Data setup](../../examples/data/README.md) | + +## Clustering + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [clustering/example_clustering_2.py](../../examples/clustering/example_clustering_2.py) | **2-cluster example.** Optimize 2 cluster centers for generated two-dimensional data. | [Guide](pygad.md) | +| [clustering/example_clustering_3.py](../../examples/clustering/example_clustering_3.py) | **3-cluster example.** Optimize 3 cluster centers for generated two-dimensional data. | [Guide](pygad.md) | + +<!-- /python-examples-index --> diff --git a/docs/source/fitness_calculation.md b/docs/source/fitness_calculation.md index d4201169..f58d940e 100644 --- a/docs/source/fitness_calculation.md +++ b/docs/source/fitness_calculation.md @@ -2,7 +2,9 @@ This page covers how PyGAD calculates the fitness efficiently: parallel processing, non-deterministic problems, reusing fitness values, and batch fitness calculation. +<!-- sphinx (fitness-output-validation)= +--> ## Fitness Output Validation For a single-objective problem, `fitness_func` returns one numeric value per solution. For a multi-objective problem, it returns a non-empty, one-dimensional list, tuple, or NumPy array of numeric objective values. Every solution must return the same number of objectives throughout a run, including cached solutions and offspring evaluated for adaptive mutation. Empty vectors, nested vectors, non-numeric values, and inconsistent objective counts raise a descriptive error before parent selection. @@ -11,7 +13,9 @@ For a single-objective problem, `fitness_func` returns one numeric value per sol Batch evaluation returns one such fitness value per supplied solution, including a smaller final batch. Sequential, threaded, and process evaluation use the same validation. `on_fitness` outputs are validated too, whether the callback returns replacement values or edits the supplied array in place. +<!-- sphinx (saved-fitness-across-repeated-runs)= +--> ## Saved Fitness across Repeated Runs Calling `run()` again continues from `generations_completed` and extends the existing histories. Each run saves its starting population and final population. For two runs of 2 generations, `best_solutions_generations` contains `[0, 1, 2, 2, 3, 4]`. Both snapshots of generation 2 remain available. The corresponding `best_solutions_fitness` entries have the same positions, and `best_solutions` uses those positions when `save_best_solutions=True`. @@ -22,11 +26,36 @@ When `save_solutions=True`, `solutions_generations` contains one generation numb Checkpoints preserve the generation numbers and population boundaries. Older checkpoints containing a single-run history recover the generation numbers automatically. Older repeated-run checkpoints did not record run boundaries, so unavailable generation numbers are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. New snapshots have their actual generation numbers. Plots use snapshot positions only for those unknown legacy entries. +<!-- sphinx (parallel-processing-guide)= +--> -:::{python-examples} +<!-- python-examples example_repeated_runs.py -::: +--> + +**Python example** + +**[Repeated runs and checkpoints](../../examples/example_repeated_runs.py)** + +Continue from a saved GA and inspect the actual generation numbers in its histories. + +`examples/example_repeated_runs.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_repeated_runs.py +``` + +</details> + +<!-- /python-examples --> ## Parallel Processing in PyGAD @@ -125,12 +154,56 @@ The repository's `examples/benchmarks/parallel_processing.py` measures complete For Keras, calls to `pygad.kerasga.predict()` sharing one model are synchronized; they preserve each solution's weights but run one at a time. Separate models are needed for concurrent predictions. Direct changes to shared models outside that helper require their own synchronization. +<!-- sphinx (non-deterministic-fitness)= +--> -:::{python-examples} +<!-- python-examples example_parallel_processing.py benchmarks/parallel_processing.py -::: +--> + +**Python examples** + +**[Parallel fitness](../../examples/example_parallel_processing.py)** + +Evaluate population fitness with process workers and report the run time. + +`examples/example_parallel_processing.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_parallel_processing.py +``` + +</details> + +**[Compare fitness execution modes](../../examples/benchmarks/parallel_processing.py)** + +Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation. + +`examples/benchmarks/parallel_processing.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/parallel_processing.py --workload cpu +``` + +</details> + +<!-- /python-examples --> ## Solve Non-Deterministic Problems @@ -163,7 +236,9 @@ ga_instance = pygad.GA(..., This way, PyGAD will not save any explored solution, so the fitness function has to be called for each individual solution. +<!-- sphinx (fitness-cache-reuse)= +--> ## Reuse the Fitness instead of Calling the Fitness Function Saved solutions are indexed by their complete gene values to avoid scanning the entire history for every population member. Built-in evolution indexes new snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Duplicate solutions that have not been evaluated or saved are still evaluated independently. @@ -219,7 +294,9 @@ ga_instance = pygad.GA(..., ...) ``` +<!-- sphinx (batch-fitness-calculation)= +--> ## Batch Fitness Calculation In [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0), a new optional parameter called `fitness_batch_size` is supported to calculate the fitness function in batches. Thanks to [Linan Qiu](https://github.com/linanqiu) for opening the [GitHub issue #136](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136). @@ -229,7 +306,9 @@ Its values can be: * `1` or `None`: If the `fitness_batch_size` parameter is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. That is if there are 15 solutions, then the fitness function is called 15 times. * `1 < fitness_batch_size <= sol_per_pop`: If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. +<!-- sphinx (short-fitness-batches)= +--> ### Why a Fitness Batch Can Be Smaller `fitness_batch_size` is the maximum number of solutions passed in one call. The final batch is smaller when the number of solutions needing evaluation is not a multiple of that size. Cached parents, elites, and previously saved solutions can also reduce the number of rows to evaluate. @@ -272,9 +351,32 @@ ga_instance.run() The same variable-batch-size contract applies to serial, thread, and process evaluation. -:::{python-examples} +<!-- python-examples example_fitness_batch_size.py -::: +--> + +**Python example** + +**[Batch fitness](../../examples/example_fitness_batch_size.py)** + +Return one fitness result per solution, including a shorter final batch. + +`examples/example_fitness_batch_size.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_fitness_batch_size.py +``` + +</details> + +<!-- /python-examples --> ### Example without `fitness_batch_size` Parameter diff --git a/docs/source/gacnn.md b/docs/source/gacnn.md index b751f96f..82c87c92 100644 --- a/docs/source/gacnn.md +++ b/docs/source/gacnn.md @@ -477,6 +477,32 @@ print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` -:::{python-examples} +<!-- python-examples gacnn/example_image_classification.py -::: +--> + +**Python example** + +**[Optimize a CNN with the GA](../../examples/gacnn/example_image_classification.py)** + +Classify fruit images using prepared image arrays. + +`examples/gacnn/example_image_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gacnn/ so the relative data paths resolve: + +```console +cd examples/gacnn +python example_image_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann.md b/docs/source/gann.md index e304c7fe..d2834bd1 100644 --- a/docs/source/gann.md +++ b/docs/source/gann.md @@ -384,31 +384,16 @@ Classification accuracy : 100.0. This section gives the complete code of some examples that build and train neural networks using the genetic algorithm. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} XOR Classification -:link: gann_xor -:link-type: doc -::: - -:::{grid-item-card} Image Classification -:link: gann_image_classification -:link-type: doc -::: - -:::{grid-item-card} Regression Example 1 -:link: gann_regression_1 -:link-type: doc -::: - -:::{grid-item-card} Regression Example 2 - Fish Weight Prediction -:link: gann_regression_2 -:link-type: doc -::: +- [XOR Classification](gann_xor.md) +- [Image Classification](gann_image_classification.md) +- [Regression Example 1](gann_regression_1.md) +- [Regression Example 2 - Fish Weight Prediction](gann_regression_2.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -417,3 +402,4 @@ gann_image_classification gann_regression_1 gann_regression_2 ::: +--> diff --git a/docs/source/gann_image_classification.md b/docs/source/gann_image_classification.md index 446830db..28cef326 100644 --- a/docs/source/gann_image_classification.md +++ b/docs/source/gann_image_classification.md @@ -145,6 +145,32 @@ The next figure shows how fitness value evolves by generation. ![Training Neural Networks using Genetic Algorithm](images/82152993-21898180-9865-11ea-8387-b995f88b83f7.png) -:::{python-examples} +<!-- python-examples gann/example_classification.py -::: +--> + +**Python example** + +**[Image classification](../../examples/gann/example_classification.py)** + +Classify fruit images from prepared feature vectors. + +`examples/gann/example_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gann/ so the relative data paths resolve: + +```console +cd examples/gann +python example_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_regression_1.md b/docs/source/gann_regression_1.md index a9abafd7..6f977db8 100644 --- a/docs/source/gann_regression_1.md +++ b/docs/source/gann_regression_1.md @@ -152,6 +152,29 @@ The next figure shows how the fitness value changes for the generations used. ![example_regression](images/92948154-3cf24b00-f459-11ea-94ea-952b66ab2145.png) -:::{python-examples} +<!-- python-examples gann/example_regression.py -::: +--> + +**Python example** + +**[Regression](../../examples/gann/example_regression.py)** + +Fit a neural network to a small numeric regression problem. + +`examples/gann/example_regression.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/gann/example_regression.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_regression_2.md b/docs/source/gann_regression_2.md index e7fbfe15..183834b5 100644 --- a/docs/source/gann_regression_2.md +++ b/docs/source/gann_regression_2.md @@ -147,6 +147,32 @@ The next figure shows how the fitness value changes for the 500 generations used ![example_regression_fish](images/92948486-bbe78380-f459-11ea-9e31-0d4c7269d606.png) -:::{python-examples} +<!-- python-examples gann/example_regression_fish.py -::: +--> + +**Python example** + +**[Fish-weight regression](../../examples/gann/example_regression_fish.py)** + +Predict fish weight from numeric measurements. + +`examples/gann/example_regression_fish.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, pandas + +**Data:** examples/data/Fish.csv. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gann/ so the relative data paths resolve: + +```console +cd examples/gann +python example_regression_fish.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_xor.md b/docs/source/gann_xor.md index 0ccf3626..dfec47f6 100644 --- a/docs/source/gann_xor.md +++ b/docs/source/gann_xor.md @@ -133,6 +133,29 @@ print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` -:::{python-examples} +<!-- python-examples gann/example_XOR_classification.py -::: +--> + +**Python example** + +**[XOR classification](../../examples/gann/example_XOR_classification.py)** + +Train a neural network on the four XOR inputs. + +`examples/gann/example_XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/gann/example_XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 8b035cc1..342d8981 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -2,7 +2,9 @@ This page covers the parameters that control the values a gene can take: the `gene_space` and `gene_type` parameters, gene constraints, the `sample_size` parameter, and preventing duplicate genes. +<!-- sphinx (initial-population-guide)= +--> ## Creating the Initial Population PyGAD can generate the initial population or start from a population passed to `initial_population`. @@ -56,9 +58,32 @@ Constraints depending on other genes should follow the dependency order: a gene When `allow_duplicate_genes=False`, duplicate repair follows constraint handling and uses the same converted domains. The search behavior and limits are described in [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). -:::{python-examples} +<!-- python-examples example_initial_population.py -::: +--> + +**Python example** + +**[Initial populations](../../examples/example_initial_population.py)** + +Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions. + +`examples/example_initial_population.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_initial_population.py +``` + +</details> + +<!-- /python-examples --> ## Limit the Gene Value Range using the `gene_space` Parameter @@ -108,7 +133,9 @@ For a 3-gene problem, the next code creates a dictionary for each gene to restri gene_space = [{'low': 1, 'high': 5}, {'low': 0.3, 'high': 1.4}, {'low': -0.2, 'high': 4.5}] ``` +<!-- sphinx (gene-space-guide)= +--> ## More about the `gene_space` Parameter The `gene_space` parameter customizes the space of values of each gene. @@ -208,11 +235,36 @@ If the dictionary has a step like the example below, then it is considered a dis Gene space: {'low': 1, 'high': 5, 'step': 0.5} ``` -:::{python-examples} +<!-- python-examples example_gene_space.py -::: +--> + +**Python example** + +**[Gene spaces](../../examples/example_gene_space.py)** + +Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries. +`examples/example_gene_space.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_space.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (gene-constraints-guide)= +--> ## Gene Constraint In [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0), a new parameter called `gene_constraint` is added to the constructor of the `pygad.GA` class. An instance attribute of the same name is created for any instance of the `pygad.GA` class. @@ -316,9 +368,32 @@ Duplicate repair also checks all constraints against complete candidate solution ### Full Example -:::{python-examples} +<!-- python-examples example_gene_constraint.py -::: +--> + +**Python example** + +**[Gene constraints](../../examples/example_gene_constraint.py)** + +Filter gene candidates with constraints that depend on other genes. + +`examples/example_gene_constraint.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_constraint.py +``` + +</details> + +<!-- /python-examples --> ## `sample_size` Parameter @@ -342,7 +417,9 @@ For duplicate repair, finite spaces are considered in full. These include lists, When replacement chains do not satisfy a dependent constraint, PyGAD also tries alternative complete assignments. This additional search considers up to `sample_size * num_genes` tentative gene assignments. A larger value allows more alternatives to be checked. The limit prevents arbitrary constraint functions from requiring an unbounded combinatorial search. +<!-- sphinx (duplicate-gene-repair-guide)= +--> ## Prevent Duplicates in Gene Values In [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0), a new bool parameter called `allow_duplicate_genes` is supported to control whether duplicates are supported in the chromosome or not. In other words, whether 2 or more genes might have the same exact value. @@ -473,7 +550,9 @@ Generation 5 [1 2 4 3]] ``` +<!-- sphinx (solve-duplicates-using-a-third-gene)= +--> ### Repair through Other Genes @@ -492,9 +571,32 @@ The last gene can only keep 0. Repair moves the third gene from 2 to 3, the seco This behavior also handles third-gene repairs, such as changing `[3, 4, 4, 5]` into `[2, 3, 4, 5]` for `gene_space=[[2, 3], [3, 4], [4, 5], [5, 6]]`. -:::{python-examples} +<!-- python-examples example_duplicate_gene_repair.py -::: +--> + +**Python example** + +**[Duplicate repair](../../examples/example_duplicate_gene_repair.py)** + +Repair duplicates through a chain of replacements while respecting each gene space. + +`examples/example_duplicate_gene_repair.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_duplicate_gene_repair.py +``` + +</details> + +<!-- /python-examples --> ### Ranges, Types, and Constraints @@ -523,7 +625,9 @@ The `gene_type` parameter allows the user to control the data type for all genes Let us look at some examples. +<!-- sphinx (gene-type-conversion-guide)= +--> ### Conversion and Rounding Rules PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. @@ -537,9 +641,32 @@ Floating-point types use binary representations, so a stored value can differ sl When types are specified per gene, population arrays use `dtype=object` so each column can retain its requested Python or NumPy scalar type. Saved best solutions retain these types too. This also preserves large integers when other genes are floating-point values. A NumPy array constructed without `dtype=object` can already lose integer precision through conversion to a shared floating-point type; use a list or an object array for mixed input values that must remain exact. -:::{python-examples} +<!-- python-examples example_gene_type_conversion.py -::: +--> + +**Python example** + +**[Gene types and rounding](../../examples/example_gene_type_conversion.py)** + +Preserve mixed numeric types, apply precision, and convert custom mutation outputs. + +`examples/example_gene_type_conversion.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_type_conversion.py +``` + +</details> + +<!-- /python-examples --> ### Data Type for All Genes without Precision diff --git a/docs/source/generations.md b/docs/source/generations.md index 7f3be1bb..38c7e0a4 100644 --- a/docs/source/generations.md +++ b/docs/source/generations.md @@ -16,7 +16,9 @@ def func_generation(ga_instance): return "stop" ``` +<!-- sphinx (stop-criteria-guide)= +--> ## Stop Criteria In [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0), a new parameter named `stop_criteria` is added to the constructor of the `pygad.GA` class. It helps to stop the evolution based on some criteria. It can be assigned one or more criteria. @@ -163,13 +165,13 @@ number of offspring = sol_per_pop - (number of kept solutions) The next tree shows how the two parameters decide the number of offspring. -:::{figure} images/offspring_decision_tree.* -:alt: Decision tree showing how keep_elitism and keep_parents decide the number of offspring -:width: 680px -:align: center +<!-- documentation-figure: 680px --> + +![Decision tree showing how keep_elitism and keep_parents decide the number of offspring](images/offspring_decision_tree.png) How `keep_elitism` and `keep_parents` decide the number of offspring. -::: + +<!-- /documentation-figure --> There are four cases: @@ -182,21 +184,25 @@ There are four cases: The kept solutions are placed at the top of the next population, starting at index 0. The offspring fill the slots that remain. -:::{figure} images/population_assembly.* -:alt: The kept solutions sit at the top of the next population and the offspring fill the rest -:width: 620px -:align: center +<!-- documentation-figure: 620px --> + +![The kept solutions sit at the top of the next population and the offspring fill the rest](images/population_assembly.png) The kept solutions are copied to the top of the population. The offspring fill the rest. -::: + +<!-- /documentation-figure --> Watch the tutorial on [YouTube](https://www.youtube.com/shorts/-uupRJhesjI). +<!-- sphinx ```{raw} html <iframe width="315" height="560" src="https://www.youtube.com/embed/-uupRJhesjI" title="keep_elitism vs keep_parents" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> ``` +--> +<!-- sphinx (random-seed-guide)= +--> ## Random Seed In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0), a new parameter called `random_seed` is supported. Its value is used as a seed for the random function generators. @@ -270,9 +276,32 @@ ga_instance = pygad.GA(..., The custom operator must choose values appropriate for the problem's gene spaces and constraints. Calls to global `numpy.random` or `random` functions in user code need their own seeds; `random_seed` does not seed these global generators. -:::{python-examples} +<!-- python-examples example_constructor_parameters.py -::: +--> + +**Python example** + +**[Constructor settings and random seeds](../../examples/example_constructor_parameters.py)** + +Use callable fitness signatures, NumPy counts, and independent seeded GA instances. + +`examples/example_constructor_parameters.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_constructor_parameters.py +``` + +</details> + +<!-- /python-examples --> ## Continue without Losing Progress @@ -331,11 +360,34 @@ The plot created by the `plot_fitness()` method will show the data collected fro With `save_solutions=True`, `solutions_generations` records one generation number per saved population, while `solutions` and `solutions_fitness` keep one entry per solution. `best_solution_generation` reports the actual generation of the best saved fitness, rather than its position in the history. History plots and PDF reports use these generation numbers. -Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See {ref}`Saved Fitness across Repeated Runs <saved-fitness-across-repeated-runs>` for callback behavior and checkpoint compatibility. +Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs) for callback behavior and checkpoint compatibility. -:::{python-examples} +<!-- python-examples example_repeated_runs.py -::: +--> + +**Python example** + +**[Repeated runs and checkpoints](../../examples/example_repeated_runs.py)** + +Continue from a saved GA and inspect the actual generation numbers in its histories. + +`examples/example_repeated_runs.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_repeated_runs.py +``` + +</details> + +<!-- /python-examples --> ## Change Population Size during Runtime @@ -361,6 +413,29 @@ These are examples of the instance attributes that might be changed. The user sh 3. `last_generation_elitism` and `last_generation_elitism_indices`: Must be changed if `keep_elitism != 0`. The default value of `keep_elitism` is 1. Two NumPy arrays: 2D array representing the elitism and 1D array of the elitism indices. 2. `pop_size`: The population size. -:::{python-examples} +<!-- python-examples example_dynamic_population_size.py -::: +--> + +**Python example** + +**[Changing population size](../../examples/example_dynamic_population_size.py)** + +Adjust the population and related runtime settings during evolution. + +`examples/example_dynamic_population_size.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_dynamic_population_size.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/help.md b/docs/source/help.md index 25edf0af..b36d525c 100644 --- a/docs/source/help.md +++ b/docs/source/help.md @@ -2,39 +2,16 @@ This section collects extra information about PyGAD: where to get help, how to contribute, and resources that use or explain PyGAD. Pick a topic: -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Getting Help -:link: help_support -:link-type: doc +- [Getting Help](help_support.md) — Submit issues, request features, ask on Stack Overflow, and contact us. +- [Tutorials and Resources](help_tutorials.md) — Tutorials, articles, and a book about PyGAD. +- [Projects and Research](help_projects.md) — PyGAD projects, projects built with PyGAD, and research papers. +- [PyGAD in Other Languages](help_languages.md) — Read about PyGAD in several languages. -Submit issues, request features, ask on Stack Overflow, and contact us. -::: - -:::{grid-item-card} Tutorials and Resources -:link: help_tutorials -:link-type: doc - -Tutorials, articles, and a book about PyGAD. -::: - -:::{grid-item-card} Projects and Research -:link: help_projects -:link-type: doc - -PyGAD projects, projects built with PyGAD, and research papers. -::: - -:::{grid-item-card} PyGAD in Other Languages -:link: help_languages -:link-type: doc - -Read about PyGAD in several languages. -::: - -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -43,3 +20,4 @@ help_tutorials help_projects help_languages ::: +--> diff --git a/docs/source/index.md b/docs/source/index.md index 07c81810..108dd879 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -10,13 +10,13 @@ > Run PyGAD in the cloud with [Vilvik](https://vilvik.com): push your PyGAD problem to Vilvik, let it run in the cloud, and get the results back. -:::{figure} images/pygad_vilvik_cloud.* -:alt: Run PyGAD in the cloud with Vilvik -:width: 100% -:align: center +<!-- documentation-figure: 100% --> + +![Run PyGAD in the cloud with Vilvik](images/pygad_vilvik_cloud.png) Push your PyGAD problem to [Vilvik](https://vilvik.com) and run it in the cloud. To get started, follow this tutorial: [Push your PyGAD problem to Vilvik in 10 minutes](https://vilvik.com/blog/@vilvik/pygad-to-vilvik-in-10-minutes). -::: + +<!-- /documentation-figure --> [PyGAD](https://github.com/ahmedfgad/GeneticAlgorithmPython) supports different types of crossover, mutation, and parent selection operators. It lets you optimize many types of problems with the genetic algorithm by writing your own fitness function. It works with both single-objective and multi-objective optimization problems. @@ -174,7 +174,15 @@ If you used PyGAD, please consider citing its paper with the following details: } ``` +**Genetic Algorithm** + +- [`pygad` Module](pygad.md) +- [More About PyGAD](pygad_more.md) +- [Examples](examples.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Genetic Algorithm @@ -182,8 +190,17 @@ pygad pygad_more examples ``` +--> +**Operators & Visualization** + +- [`pygad.utils` Module](utils.md) +- [`pygad.visualize` Module](visualize.md) +- [`pygad.helper` Module](helper.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Operators & Visualization @@ -191,8 +208,18 @@ utils visualize helper ``` +--> + +**Neural Networks** +- [`pygad.nn` Module](nn.md) +- [`pygad.gann` Module](gann.md) +- [`pygad.cnn` Module](cnn.md) +- [`pygad.gacnn` Module](gacnn.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Neural Networks @@ -201,25 +228,48 @@ gann cnn gacnn ``` +--> + +**Keras & PyTorch** +- [`pygad.kerasga` Module](kerasga.md) +- [`pygad.torchga` Module](torchga.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Keras & PyTorch kerasga torchga ``` +--> + +**Releases** +- [Release History](releases.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Releases releases ``` +--> + +**Help & Resources** + +- [Help & Resources](help.md) +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Help & Resources help ``` +--> diff --git a/docs/source/kerasga.md b/docs/source/kerasga.md index ea3e8e62..91790372 100644 --- a/docs/source/kerasga.md +++ b/docs/source/kerasga.md @@ -119,7 +119,9 @@ The `model_weights_as_matrix()` function accepts the following parameters: It returns the restored model weights after reshaping the vector. +<!-- sphinx (keras-predict)= +--> ### `pygad.kerasga.predict()` The `predict()` function makes a prediction based on a solution. It accepts the following parameters: @@ -155,36 +157,17 @@ These objects are local to a Python process. They are module attributes, not add This section gives the complete code of some examples that build and train a Keras model using PyGAD. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Example 1: Regression Example -:link: kerasga_regression -:link-type: doc -::: - -:::{grid-item-card} Example 2: XOR Binary Classification -:link: kerasga_xor -:link-type: doc -::: - -:::{grid-item-card} Example 3: Image Multi-Class Classification (Dense Layers) -:link: kerasga_image_dense -:link-type: doc -::: - -:::{grid-item-card} Example 4: Image Multi-Class Classification (Conv Layers) -:link: kerasga_image_conv -:link-type: doc -::: - -:::{grid-item-card} Example 5: Image Classification using Data Generator -:link: kerasga_image_datagen -:link-type: doc -::: +- [Example 1: Regression Example](kerasga_regression.md) +- [Example 2: XOR Binary Classification](kerasga_xor.md) +- [Example 3: Image Multi-Class Classification (Dense Layers)](kerasga_image_dense.md) +- [Example 4: Image Multi-Class Classification (Conv Layers)](kerasga_image_conv.md) +- [Example 5: Image Classification using Data Generator](kerasga_image_datagen.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -194,3 +177,4 @@ kerasga_image_dense kerasga_image_conv kerasga_image_datagen ::: +--> diff --git a/docs/source/kerasga_image_conv.md b/docs/source/kerasga_image_conv.md index f932aa12..dbf6ec2a 100644 --- a/docs/source/kerasga_image_conv.md +++ b/docs/source/kerasga_image_conv.md @@ -153,6 +153,32 @@ To improve the model performance, you can do the following: - Use different parameters for the layers. - Use different parameters for the genetic algorithm (e.g. number of solution, number of generations, etc) -:::{python-examples} +<!-- python-examples KerasGA/image_classification_CNN.py -::: +--> + +**Python example** + +**[Convolutional image classifier](../../examples/KerasGA/image_classification_CNN.py)** + +Train an image classifier with the genetic algorithm. + +`examples/KerasGA/image_classification_CNN.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python image_classification_CNN.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_image_datagen.md b/docs/source/kerasga_image_datagen.md index cdc85406..00c1e056 100644 --- a/docs/source/kerasga_image_datagen.md +++ b/docs/source/kerasga_image_datagen.md @@ -89,7 +89,55 @@ accuracy = ca.result().numpy() print(f"Accuracy : {accuracy}") ``` -:::{python-examples} +<!-- python-examples KerasGA/cancer_dataset.py KerasGA/cancer_dataset_generator.py -::: +--> + +**Python examples** + +**[Image-directory classification](../../examples/KerasGA/cancer_dataset.py)** + +Use directory-based image input for a two-class Keras CNN. + +`examples/KerasGA/cancer_dataset.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python cancer_dataset.py +``` + +</details> + +**[Batched image-directory classification](../../examples/KerasGA/cancer_dataset_generator.py)** + +Use directory-based image input for a two-class Keras CNN. + +`examples/KerasGA/cancer_dataset_generator.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python cancer_dataset_generator.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_image_dense.md b/docs/source/kerasga_image_dense.md index c6f94827..6ce22e37 100644 --- a/docs/source/kerasga_image_dense.md +++ b/docs/source/kerasga_image_dense.md @@ -124,6 +124,32 @@ Categorical Crossentropy : 0.23823906 Accuracy : 0.9852192 ``` -:::{python-examples} +<!-- python-examples KerasGA/image_classification_Dense.py -::: +--> + +**Python example** + +**[Dense image classifier](../../examples/KerasGA/image_classification_Dense.py)** + +Train an image classifier with the genetic algorithm. + +`examples/KerasGA/image_classification_Dense.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python image_classification_Dense.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_regression.md b/docs/source/kerasga_regression.md index 2a692b95..5eec8103 100644 --- a/docs/source/kerasga_regression.md +++ b/docs/source/kerasga_regression.md @@ -238,6 +238,29 @@ print(f"Absolute Error : {abs_error}") Absolute Error : 0.013740465 ``` -:::{python-examples} +<!-- python-examples KerasGA/regression_example.py -::: +--> + +**Python example** + +**[Regression](../../examples/KerasGA/regression_example.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/KerasGA/regression_example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/KerasGA/regression_example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_xor.md b/docs/source/kerasga_xor.md index 8cf0192f..4d8236ba 100644 --- a/docs/source/kerasga_xor.md +++ b/docs/source/kerasga_xor.md @@ -144,6 +144,29 @@ Binary Crossentropy : 0.0013527311 Accuracy : 1.0 ``` -:::{python-examples} +<!-- python-examples KerasGA/XOR_classification.py -::: +--> + +**Python example** + +**[XOR classification](../../examples/KerasGA/XOR_classification.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/KerasGA/XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/KerasGA/XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index 01a3f108..46db1ce9 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -2,23 +2,23 @@ The next figure shows the main steps in the life cycle of a `pygad.GA` instance. The genetic algorithm evaluates its initial population, then repeats parent selection, crossover, mutation, population update, and fitness evaluation for each generation. It can reuse cached fitness values. PyGAD stops when all generations are done, a stopping criterion is met, or the function passed to `on_generation` returns the string `stop`. -:::{figure} images/ga_lifecycle.* -:alt: The PyGAD genetic algorithm life cycle -:width: 480px -:align: center +<!-- documentation-figure: 480px --> + +![The PyGAD genetic algorithm life cycle](images/ga_lifecycle.png) The main steps of the genetic algorithm in PyGAD. -::: + +<!-- /documentation-figure --> The next figure shows the same life cycle in more detail, including the callback functions that PyGAD calls at each stage. -:::{figure} images/pygad_lifecycle.* -:alt: The PyGAD life cycle with callback functions -:width: 480px -:align: center +<!-- documentation-figure: 480px --> + +![The PyGAD life cycle with callback functions](images/pygad_lifecycle.png) The PyGAD life cycle in detail, including the callback functions called at each stage. -::: + +<!-- /documentation-figure --> ## Plotting the Configured Lifecycle @@ -38,13 +38,38 @@ ga_instance.plot_lifecycle(title="PyGAD - My Optimization Problem", show=False) ``` -Drawing the chart does not run the GA or call user functions. See {ref}`plot_lifecycle() <plot-lifecycle>` for the parameters, a sample chart, and a runnable example. To print a text description, use {ref}`summary() <print-lifecycle-summary>`. +Drawing the chart does not run the GA or call user functions. See [plot_lifecycle()](visualize.md#plot_lifecycle) for the parameters, a sample chart, and a runnable example. To print a text description, use [summary()](logging.md#print-lifecycle-summary). -:::{python-examples} +<!-- python-examples plots/example_plot_lifecycle.py -::: +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` +</details> + +<!-- /python-examples --> + +<!-- sphinx (reporting-progress)= +--> ## Reporting Progress Use `on_generation` to report progress once a generation has completed. There is no need to change the fitness function or the GA operators: @@ -148,6 +173,29 @@ on_stop() To stop from `on_generation`, return `"stop"`; otherwise no return value is needed. -:::{python-examples} +<!-- python-examples pygad_lifecycle.py -::: +--> + +**Python example** + +**[Lifecycle callbacks](../../examples/pygad_lifecycle.py)** + +Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks. + +`examples/pygad_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/pygad_lifecycle.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/logging.md b/docs/source/logging.md index a6d169ff..19eccbd0 100644 --- a/docs/source/logging.md +++ b/docs/source/logging.md @@ -2,7 +2,9 @@ This page covers how to see what PyGAD is doing: printing a lifecycle summary and logging the outputs. +<!-- sphinx (print-lifecycle-summary)= +--> ## Print Lifecycle Summary In [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0), a new method called `summary()` is supported. It prints a Keras-like summary of the PyGAD lifecycle showing the steps, callback functions, parameters, etc. @@ -122,9 +124,32 @@ On Generation on_gen() None ====================================================================== ``` -:::{python-examples} +<!-- python-examples example_summary.py -::: +--> + +**Python example** + +**[Text lifecycle summary](../../examples/example_summary.py)** + +Print the configured GA stages and their parameters. + +`examples/example_summary.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_summary.py +``` + +</details> + +<!-- /python-examples --> ## Plot Lifecycle Chart @@ -134,11 +159,34 @@ Use `plot_lifecycle()` to draw the configured lifecycle as a flowchart with oper ga_instance.plot_lifecycle(save_dir="lifecycle.svg") ``` -The method returns a matplotlib figure. Use `show_parameters=False` for a compact chart or `show=False` to save without displaying it. See {ref}`plot_lifecycle() <plot-lifecycle>` for the full description and an example. +The method returns a matplotlib figure. Use `show_parameters=False` for a compact chart or `show=False` to save without displaying it. See [plot_lifecycle()](visualize.md#plot_lifecycle) for the full description and an example. -:::{python-examples} +<!-- python-examples plots/example_plot_lifecycle.py -::: +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +</details> + +<!-- /python-examples --> ## Logging Outputs @@ -391,6 +439,29 @@ By executing this code, the logged messages are printed to the console and also 2023-04-03 19:04:27 INFO: Fitness = 0.000389832593101348 ``` -:::{python-examples} +<!-- python-examples example_logger.py -::: +--> + +**Python example** + +**[Logging](../../examples/example_logger.py)** + +Send progress and GA messages to a configured logger. + +`examples/example_logger.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_logger.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/multi_objective.md b/docs/source/multi_objective.md index bbe00707..e95f510c 100644 --- a/docs/source/multi_objective.md +++ b/docs/source/multi_objective.md @@ -120,7 +120,9 @@ This is the figure created by the `plot_fitness()` method. The fitness of the fi ![multi-objective-pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython/assets/16560492/7896f8d8-01c5-4ff9-8d15-52191c309b63) +<!-- sphinx (nsga3-guide)= +--> ## 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. @@ -171,7 +173,49 @@ 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. -:::{python-examples} +<!-- python-examples example_multi_objective.py example_multi_objective_nsga3.py -::: +--> + +**Python examples** + +**[NSGA-II optimization](../../examples/example_multi_objective.py)** + +Optimize two objectives and inspect the resulting trade-offs. + +`examples/example_multi_objective.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_multi_objective.py +``` + +</details> + +**[NSGA-III optimization](../../examples/example_multi_objective_nsga3.py)** + +Configure reference points and optimize two objectives with NSGA-III. + +`examples/example_multi_objective_nsga3.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_multi_objective_nsga3.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn.md b/docs/source/nn.md index 8688cb6a..50009bdb 100644 --- a/docs/source/nn.md +++ b/docs/source/nn.md @@ -429,31 +429,16 @@ It is very important to note that it is not expected that the classification acc This section gives the complete code of some examples that build neural networks using `pygad.nn`. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} XOR Classification -:link: nn_xor -:link-type: doc -::: - -:::{grid-item-card} Image Classification -:link: nn_image_classification -:link-type: doc -::: - -:::{grid-item-card} Regression Example 1 -:link: nn_regression_1 -:link-type: doc -::: - -:::{grid-item-card} Regression Example 2 - Fish Weight Prediction -:link: nn_regression_2 -:link-type: doc -::: +- [XOR Classification](nn_xor.md) +- [Image Classification](nn_image_classification.md) +- [Regression Example 1](nn_regression_1.md) +- [Regression Example 2 - Fish Weight Prediction](nn_regression_2.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -462,3 +447,4 @@ nn_image_classification nn_regression_1 nn_regression_2 ::: +--> diff --git a/docs/source/nn_image_classification.md b/docs/source/nn_image_classification.md index 3f7eb8a7..98a1df90 100644 --- a/docs/source/nn_image_classification.md +++ b/docs/source/nn_image_classification.md @@ -51,7 +51,55 @@ print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` -:::{python-examples} +<!-- python-examples nn/example_classification.py nn/extract_features.py -::: +--> + +**Python examples** + +**[Image classification](../../examples/nn/example_classification.py)** + +Classify fruit images from prepared feature vectors. + +`examples/nn/example_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python example_classification.py +``` + +</details> + +**[Prepare image features](../../examples/nn/extract_features.py)** + +Extract fruit-image features and write the arrays used by the dense classifiers. + +`examples/nn/extract_features.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, scikit-image + +**Data:** The apple, lemon, mango, and raspberry folders under examples/data/Fruit360/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python extract_features.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_regression_1.md b/docs/source/nn_regression_1.md index dc688750..565ff3f5 100644 --- a/docs/source/nn_regression_1.md +++ b/docs/source/nn_regression_1.md @@ -69,6 +69,29 @@ abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` -:::{python-examples} +<!-- python-examples nn/example_regression.py -::: +--> + +**Python example** + +**[Regression](../../examples/nn/example_regression.py)** + +Fit a neural network to a small numeric regression problem. + +`examples/nn/example_regression.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/nn/example_regression.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_regression_2.md b/docs/source/nn_regression_2.md index 7b9460ee..83bea213 100644 --- a/docs/source/nn_regression_2.md +++ b/docs/source/nn_regression_2.md @@ -72,6 +72,32 @@ abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` -:::{python-examples} +<!-- python-examples nn/example_regression_fish.py -::: +--> + +**Python example** + +**[Fish-weight regression](../../examples/nn/example_regression_fish.py)** + +Predict fish weight from numeric measurements. + +`examples/nn/example_regression_fish.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, pandas + +**Data:** examples/data/Fish.csv. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python example_regression_fish.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_xor.md b/docs/source/nn_xor.md index 440900c4..a1de725c 100644 --- a/docs/source/nn_xor.md +++ b/docs/source/nn_xor.md @@ -49,6 +49,29 @@ print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` -:::{python-examples} +<!-- python-examples nn/example_XOR_classification.py -::: +--> + +**Python example** + +**[XOR classification](../../examples/nn/example_XOR_classification.py)** + +Train a neural network on the four XOR inputs. + +`examples/nn/example_XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/nn/example_XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/pygad.md b/docs/source/pygad.md index ebcd3a01..66d09275 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -8,7 +8,9 @@ With the `pygad` module, you can create, run, save, and load instances of the ge The `pygad` module has a class named `GA` for building the genetic algorithm. This section explains the class constructor, its methods, functions, and attributes. +<!-- sphinx (ga-constructor)= +--> ### `__init__()` To create an instance of the `pygad.GA` class, the constructor accepts several parameters. These let you adjust the genetic algorithm for different types of applications. @@ -19,32 +21,36 @@ The `pygad.GA` class constructor supports the parameters below, grouped by purpo #### Population and Generations -:::{dropdown} `num_generations`: Number of generations to run. -:animate: fade-in-slide-down +<details> +<summary><code>num_generations</code>: Number of generations to run.</summary> Number of generations per `run()` call. Must be a non-negative integer; `0` evaluates the initial population without evolving it. -::: -:::{dropdown} `num_parents_mating`: How many solutions are selected as parents. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>num_parents_mating</code>: How many solutions are selected as parents.</summary> Number of solutions to be selected as parents. Must be an integer between `1` and `sol_per_pop`, inclusive. -::: -:::{dropdown} `sol_per_pop`: Number of solutions in the population. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>sol_per_pop</code>: Number of solutions in the population.</summary> Number of solutions (i.e. chromosomes) within the population. This parameter has no action if `initial_population` parameter exists. -::: -:::{dropdown} `num_genes`: Number of genes in each solution. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>num_genes</code>: Number of genes in each solution.</summary> Number of genes in the solution/chromosome. This parameter is not needed if the user feeds the initial population to the `initial_population` parameter. -::: -:::{dropdown} `initial_population`: Start from your own population. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>initial_population</code>: Start from your own population.</summary> A population you provide yourself to start the run instead of a random one. It defaults to `None`, in which case PyGAD builds the initial population from the `sol_per_pop` and `num_genes` parameters. @@ -53,10 +59,11 @@ Pass a non-empty rectangular 2D list, tuple, or NumPy array of numeric values. P If `initial_population` is `None` and either `sol_per_pop` or `num_genes` is also `None`, an exception is raised. Introduced in [PyGAD 2.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-0-0) and higher. -::: -:::{dropdown} `stop_criteria=None`: Stop early when a condition is met. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>stop_criteria=None</code>: Stop early when a condition is met.</summary> 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"`. @@ -74,12 +81,13 @@ The counts for `saturate` and `evaluations` must be positive integers. Fractiona `saturate_N` counts consecutive completed generations whose best fitness equals the preceding population's best fitness, including the initial population as the baseline. `saturate_1` stops after one unchanged generation. Any change resets the count, so matching endpoints with changes in between do not count as saturation. Multi-objective problems compare the whole best-fitness vector. Each `run()` starts a new saturation count while `generations_completed` continues increasing across runs. 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. -::: + +</details> #### Fitness Function -:::{dropdown} `fitness_func`: Function that scores each solution. -:animate: fade-in-slide-down +<details> +<summary><code>fitness_func</code>: Function that scores each solution.</summary> The function, bound method, or callable instance that calculates the fitness of a solution. This is the one parameter you almost always need to set. @@ -96,10 +104,11 @@ A callable instance's `__call__(self, ga_instance, solution, solution_idx)` uses Return a single number for a single-objective problem, or a `list`, `tuple`, or `numpy.ndarray` for a multi-objective problem (supported since [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0)). See [Preparing the fitness_func Parameter](https://pygad.readthedocs.io/en/latest/steps_to_use.html#preparing-the-fitness-func-parameter) for how to build one. -::: -:::{dropdown} `fitness_batch_size=None`: Score the solutions in batches. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>fitness_batch_size=None</code>: Score the solutions in batches.</summary> Calculates the fitness in batches instead of one solution at a time. @@ -109,12 +118,13 @@ Calculates the fitness in batches instead of one solution at a time. In batch mode, the second fitness argument is a two-dimensional array of solutions and the third is a list of their population indices. Adaptive mutation passes `None` as the third argument instead. Return a `list`, `tuple`, or NumPy array with one fitness value per solution (a scalar for each single-objective solution, or an objective vector for each multi-objective solution). Cached rows are skipped, so batches can contain non-contiguous indices and the final batch can be smaller than the configured size. See [Batch Fitness Calculation](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#batch-fitness-calculation) for details and examples. Added in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). -::: + +</details> #### Genes: Values and Types -:::{dropdown} `gene_type=float`: Data type (and precision) of the genes. -:animate: fade-in-slide-down +<details> +<summary><code>gene_type=float</code>: Data type (and precision) of the genes.</summary> Sets the data type (and optional precision) of the genes. It defaults to `float`, so every gene is a `float`. @@ -133,10 +143,11 @@ Version history: - [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0): a single numeric type can be used. - [PyGAD 2.14.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-14-0): a type per gene can be used. - [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0): a precision can be set for `float` types. -::: -:::{dropdown} `gene_space=None`: Allowed values or range for each gene. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>gene_space=None</code>: Allowed values or range for each gene.</summary> Sets the allowed values for each gene, so you can limit the search space to a range or to a set of discrete values. @@ -153,38 +164,43 @@ Version history: - [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0): NumPy arrays can be used. - [PyGAD 2.11.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-11-0): a dictionary can set the low and high limits. - [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0): the `"step"` key was added. -::: -:::{dropdown} `gene_constraint=None`: Functions that restrict gene values. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>gene_constraint=None</code>: Functions that restrict gene values.</summary> A list of callables (functions), one per gene, that restrict the values a gene can take. Before a value is chosen for a gene, its callable checks that the candidate value is valid. A list or tuple must contain exactly one callable or `None` per gene. Each callable must accept the solution and candidate values as 2 positional arguments; bound methods, callable instances, and partial functions are supported. Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. -::: -:::{dropdown} `init_range_low=-4`: Lower bound for the initial gene values. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>init_range_low=-4</code>: Lower bound for the initial gene values.</summary> The lower value of the random range from which the gene values in the initial population are selected. `init_range_low` defaults to `-4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). -::: -:::{dropdown} `init_range_high=4`: Upper bound for the initial gene values. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>init_range_high=4</code>: Upper bound for the initial gene values.</summary> The upper value of the random range from which the gene values in the initial population are selected. `init_range_high` defaults to `+4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). -::: -:::{dropdown} `allow_duplicate_genes=True`: Allow repeated values within a solution. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>allow_duplicate_genes=True</code>: Allow repeated values within a solution.</summary> Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, PyGAD tries to give each gene a different numeric value after conversion and rounding. Repair can follow chains of replacements using each destination gene's space, range, type, precision, and constraint. If no usable alternative is found, duplicates remain with a warning unless warnings are suppressed. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). -For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See {ref}`Mutation Methods <mutation-methods>`. -::: +For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See [Mutation Methods](utils.md#mutation-methods). -:::{dropdown} `sample_size=100`: Sample size used when searching for a valid value. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>sample_size=100</code>: Sample size used when searching for a valid value.</summary> The size of the sample of candidate values PyGAD draws when it needs to pick a gene value. It defaults to `100`. @@ -193,12 +209,13 @@ It is useful when `allow_duplicate_genes=False` or `gene_constraint` is used. If Duplicate repair considers finite spaces in full. For constraints depending on other genes, an additional search checks up to `sample_size * num_genes` tentative assignments when replacement chains do not satisfy all constraints. Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. -::: + +</details> #### Parent Selection -:::{dropdown} `parent_selection_type="sss"`: How the parents are selected. -:animate: fade-in-slide-down +<details> +<summary><code>parent_selection_type="sss"</code>: How the parents are selected.</summary> How the parents are selected. It defaults to `"sss"`. @@ -216,26 +233,29 @@ The built-in types are: - `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). -::: -:::{dropdown} `K_tournament=3`: Contestants per tournament selection. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>K_tournament=3</code>: Contestants per tournament selection.</summary> For `tournament`, `tournament_nsga2`, and `tournament_nsga3`, this is the number of contestants per tournament. It must be a positive integer and defaults to `3`. Values larger than `sol_per_pop` are clipped to the population size with a warning unless warnings are suppressed. Other selection types do not use this parameter. -::: -:::{dropdown} `nsga3_num_divisions=None`: Number of divisions per objective axis for NSGA-III. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>nsga3_num_divisions=None</code>: Number of divisions per objective axis for NSGA-III.</summary> 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. -::: + +</details> #### Keeping Solutions -:::{dropdown} `keep_elitism=1`: Keep the best solutions each generation. -:animate: fade-in-slide-down +<details> +<summary><code>keep_elitism=1</code>: Keep the best solutions each generation.</summary> The number of best solutions (the elitism) to keep in the next generation. It defaults to `1`, so only the best solution is kept. @@ -245,10 +265,11 @@ The number of best solutions (the elitism) to keep in the next generation. It de If this parameter is not `0`, then `keep_parents` has no effect. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). To see how `keep_elitism` and `keep_parents` work together, see [How the Number of Offspring Is Decided](https://pygad.readthedocs.io/en/latest/generations.html#how-the-number-of-offspring-is-decided). -::: -:::{dropdown} `keep_parents=-1`: Keep the parents in the next generation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>keep_parents=-1</code>: Keep the parents in the next generation.</summary> The number of parents to keep in the next population. It defaults to `-1`. @@ -261,12 +282,13 @@ The value must be an integer from `-1` through `num_parents_mating`. Passing `No This parameter has an effect only when `keep_elitism=0` (since [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0)). Since PyGAD 2.20.0, the parents' fitness from the last generation is not re-used if `keep_parents=0`. To see how `keep_parents` and `keep_elitism` work together, see [How the Number of Offspring Is Decided](https://pygad.readthedocs.io/en/latest/generations.html#how-the-number-of-offspring-is-decided). -::: + +</details> #### Crossover -:::{dropdown} `crossover_type="single_point"`: How parents are combined into offspring. -:animate: fade-in-slide-down +<details> +<summary><code>crossover_type="single_point"</code>: How parents are combined into offspring.</summary> The type of crossover. It defaults to `"single_point"`. @@ -281,31 +303,36 @@ The built-in types are: 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 +</details> + +<details> +<summary><code>sbx_crossover_eta=30</code>: Distribution index for SBX crossover.</summary> 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 finite positive number. Defaults to `30`. Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds come from each gene's `gene_space`, falling back to its initialization range for a `None` space. Reversed initialization bounds are sorted for calculation. Supplied parent values outside these bounds are clipped before the SBX calculation. Results apply the destination type, precision, space, constraints, and duplicate policy; an invalid proposed child falls back to its parent. -::: -:::{dropdown} `crossover_probability=None`: Chance a parent is used for crossover. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>crossover_probability=None</code>: Chance a parent is used for crossover.</summary> The probability of selecting a parent for crossover. Its value must be between 0.0 and 1.0. For each parent, a random value between 0.0 and 1.0 is generated. If that value is less than `crossover_probability`, the parent is selected. Setting `0` copies parents without crossing them. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. -::: +</details> + +<!-- sphinx (mutation-controls)= +--> #### Mutation -:::{dropdown} `mutation_type="random"`: How offspring genes are mutated. -:animate: fade-in-slide-down +<details> +<summary><code>mutation_type="random"</code>: How offspring genes are mutated.</summary> The type of mutation. It defaults to `"random"`. @@ -323,28 +350,31 @@ You can also pass your own mutation function (since [PyGAD 2.16.0](https://pygad Permutation mutations (`swap`, `inversion`, and `scramble`) check the complete proposed solution against destination gene spaces, types, constraints, and the duplicate policy. An incompatible permutation is retried; if no acceptable proposal is found, the solution is retained. With no explicit mutation probability, count, or percentage, swap selects one pair and inversion/scramble use their usual half-length segment. When a control is explicitly supplied, it selects the eligible positions: swap exchanges pairs, inversion reverses the selected values, and scramble shuffles them. Fewer than 2 eligible positions leave the solution unchanged; an odd number in swap leaves one eligible position unpaired. 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 +</details> + +<details> +<summary><code>polynomial_mutation_eta=20</code>: Distribution index for polynomial mutation.</summary> Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A higher value means a smaller change. Must be a finite positive number. Defaults to `20`. Polynomial mutation uses the gene-space bounds, falling back to the initialization range for a `None` space. It supports probabilities, explicit counts, and percentages. When none is explicitly supplied, its default per-gene probability is `1 / num_genes`. It clips supplied values before calculation, converts and rounds the results, and checks spaces, constraints, and duplicates before accepting the complete solution. -::: -:::{dropdown} `mutation_probability=None`: Per-gene chance of mutation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>mutation_probability=None</code>: Per-gene chance of mutation.</summary> The probability of selecting a gene for mutation. Its value must be between 0.0 and 1.0. For each gene, a random value between 0.0 and 1.0 is generated. If that value is less than `mutation_probability`, the gene is selected. Setting `0` leaves the offspring unchanged, including permutation and polynomial mutation; `1` selects every gene. Adaptive mutation takes 2 probabilities, for below-average and above-average solutions, respectively. Only the active mutation control is validated: `mutation_probability` takes precedence over `mutation_num_genes`, which takes precedence over `mutation_percent_genes`. Values for inactive controls are ignored. Built-in operators apply these controls; custom mutation functions implement their own selection rules. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. -::: -:::{dropdown} `mutation_by_replacement=False`: Replace the gene value instead of adding to it. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>mutation_by_replacement=False</code>: Replace the gene value instead of adding to it.</summary> A bool that controls how `random` and `adaptive` mutation change a gene when drawing random values. Finite gene spaces select replacement values directly. @@ -352,46 +382,51 @@ A bool that controls how `random` and `adaptive` mutation change a gene when dra - `False` (default): add the random value to the gene. Supported in [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) and higher. See the [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) release notes for an example. -::: -:::{dropdown} `mutation_percent_genes="default"`: Percentage of genes to mutate. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>mutation_percent_genes="default"</code>: Percentage of genes to mutate.</summary> The percentage of genes to mutate. It defaults to the string `"default"`, which becomes `10` (10% of the genes). The value must be `> 0` and `<= 100`. PyGAD computes `mutation_num_genes` by multiplying the percentage by `num_genes` and discarding the fractional part. If this gives `0`, it selects `1` gene and warns unless warnings are suppressed. Adaptive mutation requires 2 percentages, for below-average and above-average solutions, respectively. This parameter has no effect if `mutation_probability` or `mutation_num_genes` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `mutation_num_genes=None`: Number of genes to mutate. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>mutation_num_genes=None</code>: Number of genes to mutate.</summary> The number of genes eligible for mutation. It defaults to `None`, meaning no number is set. When active, it must be an integer between `1` and `num_genes`, inclusive. Adaptive mutation requires 2 counts, for below-average and above-average solutions, respectively. Permutation operators may leave selected positions unchanged when a compatible rearrangement is unavailable. This parameter has no effect if `mutation_probability` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `random_mutation_min_val=-1.0`: Lower bound of the random mutation value. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_mutation_min_val=-1.0</code>: Lower bound of the random mutation value.</summary> For `random` mutation, the start of the range from which a random value is drawn and added to the gene. It defaults to `-1`. This parameter has no effect if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `random_mutation_max_val=1.0`: Upper bound of the random mutation value. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_mutation_max_val=1.0</code>: Upper bound of the random mutation value.</summary> For `random` mutation, the end of the range from which a random value is drawn and added to the gene. It defaults to `+1`. This parameter has no effect if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: + +</details> #### Lifecycle Callbacks -:::{dropdown} `on_start=None`: Called once before the run starts. -:animate: fade-in-slide-down +<details> +<summary><code>on_start=None</code>: Called once before the run starts.</summary> A function (or method) called once before the run starts. @@ -399,10 +434,11 @@ A function (or method) called once before the run starts. - As a **method**, it takes a second parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_fitness=None`: Called after the fitness is calculated. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_fitness=None</code>: Called after the fitness is calculated.</summary> A function (or method) called before parent selection after the fitness of all solutions is calculated. It receives `on_fitness(ga_instance, population_fitness)` and may return replacement fitness with the same shape, or modify the supplied array in place and return `None`. Both forms are validated before selection, and saved best solutions are updated to agree with the resulting fitness. @@ -410,10 +446,11 @@ A function (or method) called before parent selection after the fitness of all s - As a **method**, it takes a third parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_parents=None`: Called after the parents are selected. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_parents=None</code>: Called after the parents are selected.</summary> A function (or method) called after the parents are selected. @@ -421,76 +458,85 @@ A function (or method) called after the parents are selected. - As a **method**, it takes a third parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_crossover=None`: Called after crossover. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_crossover=None</code>: Called after crossover.</summary> A function called each time crossover is applied. It takes 2 parameters: the instance of the genetic algorithm, and the offspring generated by crossover. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_mutation=None`: Called after mutation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_mutation=None</code>: Called after mutation.</summary> A function called each time mutation is applied. It takes 2 parameters: the instance of the genetic algorithm, and the offspring after mutation. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_generation=None`: Called after each generation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_generation=None</code>: Called after each generation.</summary> A function called after each generation. It takes 1 parameter: the instance of the genetic algorithm. If it returns the string `"stop"`, the `run()` method stops without completing the remaining generations. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_stop=None`: Called once when the run ends. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_stop=None</code>: Called once when the run ends.</summary> A function called once just before the run ends (or after the last generation). It takes 2 parameters: the instance of the genetic algorithm, and the list of the last population's fitness values. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: + +</details> #### Saving and Logging -:::{dropdown} `save_best_solutions=False`: Save the best solution of each generation. -:animate: fade-in-slide-down +<details> +<summary><code>save_best_solutions=False</code>: Save the best solution of each generation.</summary> When `True`, the best solution of each generation is saved into the `best_solutions` attribute. When `False` (default), nothing is saved and `best_solutions` stays empty. Supported in [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0). -::: -:::{dropdown} `save_solutions=False`: Save every solution of each generation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>save_solutions=False</code>: Save every solution of each generation.</summary> If `True`, then all solutions in each generation are appended into the `solutions` list, with their fitness in `solutions_fitness`. Each run includes its starting and final populations. `solutions_generations` records one generation number per saved population. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). -::: -:::{dropdown} `logger=None`: Custom logger for the outputs. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>logger=None</code>: Custom logger for the outputs.</summary> An instance of the `logging.Logger` class used to log the outputs. When set, messages are logged instead of printed with `print()`. If `None`, PyGAD uses its default logger and adds a console `StreamHandler` only when it has no handlers. Existing handlers are preserved. Invalid logger values raise a `TypeError` naming the parameter. Added in [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0). See [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. -::: -:::{dropdown} `suppress_warnings=False`: Turn warning messages on or off. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>suppress_warnings=False</code>: Turn warning messages on or off.</summary> A bool parameter to control whether the warning messages are printed or not. It defaults to `False`. -::: + +</details> #### Performance and Reproducibility -:::{dropdown} `parallel_processing=None`: Use threads or processes to speed up fitness. -:animate: fade-in-slide-down +<details> +<summary><code>parallel_processing=None</code>: Use threads or processes to speed up fitness.</summary> Runs the fitness calculation in parallel. It defaults to `None` (no parallel processing). @@ -503,17 +549,19 @@ You can set it to: Validation normalizes a positive integer to `["thread", count]` and a zero worker count to `None`. Workers are created only for uncached fitness work and reused during one `run()`, including adaptive offspring evaluation. Completion, early stopping, and exceptions shut down the pool. Calling `cal_pop_fitness()` outside a run uses a temporary pool. Process-based scripts should start the GA under `if __name__ == "__main__":`. Added in [PyGAD 2.17.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-17-0). See [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) for more information. -::: -:::{dropdown} `random_seed=None`: Seed for reproducible runs. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_seed=None</code>: Seed for reproducible runs.</summary> The seed for this instance's NumPy and Python random generators. Accepts `None` or a Python/NumPy integer from `0` through `2**32 - 1`. Setting it makes built-in operations reproducible (for example, `random_seed=2`). With `None`, each instance starts with an automatically initialized state. Each GA owns `numpy_random_generator` (a `numpy.random.RandomState`) and `python_random_generator` (a `random.Random`). Creating or running another GA and drawing from the global generators do not alter its state. Repeated `run()` calls continue the same generator states, and checkpoints preserve them. To reproduce random draws in custom operators or callbacks, use these instance generators, for example `ga_instance.numpy_random_generator.random()`. Global random draws in user code need their own seed. Seeded results may differ between PyGAD versions. See `examples/example_constructor_parameters.py` for an example. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). -::: + +</details> You do not have to set all of these parameters when you create an instance of the `GA` class. The most important one is `fitness_func`, which defines the fitness function. @@ -523,9 +571,32 @@ If both the `mutation_type` and `crossover_type` parameters are `None`, then the The parameters are validated by calling the `validate_parameters()` method of the `utils.validation.Validation` class inside the constructor. If any parameter is not correct, an exception is raised and the `valid_parameters` attribute is set to `False`. -:::{python-examples} +<!-- python-examples example_constructor_parameters.py -::: +--> + +**Python example** + +**[Constructor settings and random seeds](../../examples/example_constructor_parameters.py)** + +Use callable fitness signatures, NumPy counts, and independent seeded GA instances. + +`examples/example_constructor_parameters.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_constructor_parameters.py +``` + +</details> + +<!-- /python-examples --> ## Extended Classes @@ -560,7 +631,7 @@ Here is the list of scripts and the classes that the `pygad.GA` class extends: 13. `visualize/lifecycle.py` 1. Internal helpers that describe the configured lifecycle and draw its flowchart without running the GA or calling user functions. -`utils.engine.GAEngine` also extends `utils.parallel.FitnessEvaluation`, so `pygad.GA` indirectly inherits its fitness dispatch and serialization methods. See the {ref}`pygad.utils.parallel reference <fitness-evaluation>` for all of its methods, the process-worker function, and runtime attributes. +`utils.engine.GAEngine` also extends `utils.parallel.FitnessEvaluation`, so `pygad.GA` indirectly inherits its fitness dispatch and serialization methods. See the [pygad.utils.parallel reference](utils.md#pygadutilsparallel-submodule) for all of its methods, the process-worker function, and runtime attributes. Since the `pygad.GA` class extends such classes, the attributes and methods inside them can be retrieved by instances of the `pygad.GA` class. @@ -597,7 +668,7 @@ Constructor settings and user callables are stored as instance attributes, with - `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). -- `plot_lifecycle(title="PyGAD - Lifecycle", font_size=11, show_parameters=True, save_dir=None, show=True)`: Draws the configured lifecycle with operators, callbacks, population replacement, and stopping decisions. Works before or after `run()` and returns a matplotlib figure. See {ref}`plot_lifecycle() <plot-lifecycle>`. +- `plot_lifecycle(title="PyGAD - Lifecycle", font_size=11, show_parameters=True, save_dir=None, show=True)`: Draws the configured lifecycle with operators, callbacks, population replacement, and stopping decisions. Works before or after `run()` and returns a matplotlib figure. See [plot_lifecycle()](visualize.md#plot_lifecycle). #### Population and Initialization @@ -642,13 +713,13 @@ Constructor settings and user callables are stored as instance attributes, with - `solutions_fitness`: Fitness for every entry in `solutions`. - `solutions_generations`: One generation number per saved population when `save_solutions=True`. The solutions and their fitness retain one entry per solution. - `num_fitness_evaluations`: Number of solutions evaluated during the current `run()`, including adaptive offspring and every solution in returned fitness batches. Cache hits do not count. Each run resets the counter after `on_start`; direct fitness evaluations outside a run increment the existing count. `evaluations_<N>` checks the count at generation boundaries, so the run can exceed the requested budget by a generation's work. -- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See {ref}`Saved Fitness across Repeated Runs <saved-fitness-across-repeated-runs>`. +- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs). ##### 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 in the current population, its fitness, and its population index. Pass the current population's fitness to avoid additional evaluation. See {ref}`best_solution() <current-population-best-solution>` for single-objective and multi-objective selection rules. -- `adaptive_mutation_population_fitness(offspring)`: Return `(average_fitness, offspring_fitness)` using retained solutions and actual offspring before mutation. See the {ref}`adaptive fitness reference <adaptive-offspring-fitness>`. +- `best_solution(pop_fitness=None)`: Return the best solution in the current population, its fitness, and its population index. Pass the current population's fitness to avoid additional evaluation. See [best_solution()](utils.md#best_solution) for single-objective and multi-objective selection rules. +- `adaptive_mutation_population_fitness(offspring)`: Return `(average_fitness, offspring_fitness)` using retained solutions and actual offspring before mutation. See the [adaptive fitness reference](utils.md#adaptive_mutation_population_fitnessoffspring). #### Fitness Worker Lifecycle (internal) @@ -668,7 +739,7 @@ The `FitnessEvaluation` mixin manages these attributes and methods. They may not - `_map_fitness(tasks)`: Yield fitness results in task order using serial calls, threads, or process snapshots. - `_evaluate_fitness(population, indices, adaptive=False)`: Evaluate selected rows, prepare scalar or batch arguments, validate results, and update the evaluation count. -The {ref}`complete fitness dispatch reference <fitness-evaluation>` documents parameters, return values, exceptions, process isolation, and attribute lifetimes. +The [complete fitness dispatch reference](utils.md#pygadutilsparallel-submodule) documents parameters, return values, exceptions, process isolation, and attribute lifetimes. #### Parent Selection (general) @@ -839,9 +910,11 @@ Accepts the following parameter: * `filename`: Name of the file to save the instance. No extension is needed. -The file is written as `filename + ".pkl"` using cloudpickle. Saving during a run excludes live worker handles and the active-run flag through `__getstate__()`. The remaining GA state, including custom attributes, must be serializable. {ref}`pygad.load() <loading-ga-checkpoint>` restores the saved optimization state; the next `run()` creates a new pool when needed. A checkpoint does not contain in-flight worker tasks. +The file is written as `filename + ".pkl"` using cloudpickle. Saving during a run excludes live worker handles and the active-run flag through `__getstate__()`. The remaining GA state, including custom attributes, must be serializable. [pygad.load()](pygad.md#pygadload) restores the saved optimization state; the next `run()` creates a new pool when needed. A checkpoint does not contain in-flight worker tasks. +<!-- sphinx (generate-report)= +--> ### `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: @@ -871,15 +944,40 @@ The report skips any plot whose preconditions are not met. For example, `plot_pa The title page shows the PyGAD logo. The image ships with the package, so it works without network access. If the image file is missing, the report is built without it. -:::{python-examples} +<!-- python-examples example_generate_report.py -::: +--> + +**Python example** + +**[PDF report](../../examples/example_generate_report.py)** + +Export the run configuration, summary, best solution, and applicable plots to PDF. + +`examples/example_generate_report.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD with the report extra (Matplotlib and ReportLab) + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_generate_report.py +``` + +</details> + +<!-- /python-examples --> ## 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()`. +<!-- sphinx (loading-ga-checkpoint)= +--> ### `pygad.load()` Reads a saved instance of the genetic algorithm. This is not a method but a function that is indented under the `pygad` module. So, it could be called by the pygad module as follows: `pygad.load(filename)`. @@ -890,7 +988,9 @@ Accepts the following parameter: Returns the genetic algorithm instance. +<!-- sphinx (updating-loaded-fitness-function)= +--> #### Updating the Fitness Function after Loading The checkpoint includes the fitness function assigned when `save()` was called. Editing a function in the script does not automatically replace the function in a loaded GA. Assign the updated callable explicitly: @@ -917,54 +1017,53 @@ new_ga_instance.run() Set the constructor options needed by the new problem, including `gene_type`, `gene_space`, constraints, operators, and batch settings. This starts new fitness histories and generation counters; it reuses the chromosomes, not the previous run's fitness values. -:::{python-examples} +<!-- python-examples example_load_fitness_function.py -::: +--> -## Using PyGAD +**Python example** -::::{grid} 1 2 2 2 -:gutter: 3 +**[Change a loaded fitness function](../../examples/example_load_fitness_function.py)** -:::{grid-item-card} Steps to Use PyGAD -:link: steps_to_use -:link-type: doc +Replace the fitness callable after loading, or start fresh when the objective changes. -A step-by-step walkthrough to build and run the genetic algorithm. -::: +`examples/example_load_fitness_function.py` -:::{grid-item-card} Life Cycle of PyGAD -:link: lifecycle -:link-type: doc +<details> +<summary>Run this example</summary> -How a generation runs and where each callback is called. -::: +**Requires:** PyGAD -:::: +From the repository root, with the repository version of PyGAD installed: -## Examples +```console +python examples/example_load_fitness_function.py +``` -This section gives the complete code of some examples that use `pygad`. Each subsection builds a different example. +</details> -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- /python-examples --> -:::{grid-item-card} Linear Model - Single Objective -:link: pygad_example_linear -:link-type: doc -::: +## Using PyGAD -:::{grid-item-card} Linear Model - Multi-Objective -:link: pygad_example_multi_objective -:link-type: doc -::: +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Reproducing Images -:link: pygad_example_reproducing_images -:link-type: doc -::: +- [Steps to Use PyGAD](steps_to_use.md) — A step-by-step walkthrough to build and run the genetic algorithm. +- [Life Cycle of PyGAD](lifecycle.md) — How a generation runs and where each callback is called. -:::: +<!-- /navigation-grid --> + +## Examples + +This section gives the complete code of some examples that use `pygad`. Each subsection builds a different example. + +<!-- navigation-grid: 1 2 2 2 --> + +- [Linear Model - Single Objective](pygad_example_linear.md) +- [Linear Model - Multi-Objective](pygad_example_multi_objective.md) +- [Reproducing Images](pygad_example_reproducing_images.md) + +<!-- /navigation-grid --> ### Clustering @@ -972,10 +1071,52 @@ For a 2-cluster problem, the code is available [here](https://github.com/ahmedfg Soon a tutorial will be published at [Paperspace](https://blog.paperspace.com/author/ahmed) to explain how clustering works using the genetic algorithm with examples in PyGAD. -:::{python-examples} +<!-- python-examples clustering/example_clustering_2.py clustering/example_clustering_3.py -::: +--> + +**Python examples** + +**[2-cluster example](../../examples/clustering/example_clustering_2.py)** + +Optimize 2 cluster centers for generated two-dimensional data. + +`examples/clustering/example_clustering_2.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/clustering/example_clustering_2.py +``` + +</details> + +**[3-cluster example](../../examples/clustering/example_clustering_3.py)** + +Optimize 3 cluster centers for generated two-dimensional data. + +`examples/clustering/example_clustering_3.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/clustering/example_clustering_3.py +``` + +</details> + +<!-- /python-examples --> ### CoinTex Game Playing using PyGAD @@ -983,6 +1124,7 @@ The code is available at the [CoinTex GitHub project](https://github.com/ahmedfg Check this [Paperspace tutorial](https://blog.paperspace.com/building-agent-for-cointex-using-genetic-algorithm) for how the genetic algorithm plays CoinTex: https://blog.paperspace.com/building-agent-for-cointex-using-genetic-algorithm. Check also this [YouTube video](https://youtu.be/Sp_0RGjaL-0) showing the genetic algorithm while playing CoinTex. +<!-- sphinx :::{toctree} :hidden: @@ -992,3 +1134,4 @@ pygad_example_linear pygad_example_multi_objective pygad_example_reproducing_images ::: +--> diff --git a/docs/source/pygad_more.md b/docs/source/pygad_more.md index 8fc2e576..7efab296 100644 --- a/docs/source/pygad_more.md +++ b/docs/source/pygad_more.md @@ -2,60 +2,19 @@ This section covers the more advanced features of the `pygad` module. Pick a topic: -::::{grid} 1 2 2 3 -:gutter: 3 +<!-- navigation-grid: 1 2 2 3 --> -:::{grid-item-card} Multi-Objective Optimization -:link: multi_objective -:link-type: doc +- [Multi-Objective Optimization](multi_objective.md) — Optimize several objectives at once using NSGA-II or NSGA-III. +- [Controlling Gene Values](gene_values.md) — Restrict gene values with `gene_space`, `gene_type`, constraints, `sample_size`, and duplicate prevention. +- [Controlling Generations](generations.md) — Elitism, stopping criteria, random seed, saving and continuing, and population size. +- [Fitness Calculation and Performance](fitness_calculation.md) — Parallel processing, batch fitness, reusing fitness, and non-deterministic problems. +- [Logging and the Lifecycle Summary](logging.md) — Print a Keras-like summary and log the outputs. +- [User-Defined Functions, Methods, and Classes](custom_functions.md) — Pass your own functions, methods, or classes for the fitness and callbacks. +- [Benchmark Problems](benchmarks.md) — Built-in single, multi, and many-objective benchmark problems to plug into the GA. -Optimize several objectives at once using NSGA-II or NSGA-III. -::: - -:::{grid-item-card} Controlling Gene Values -:link: gene_values -:link-type: doc - -Restrict gene values with `gene_space`, `gene_type`, constraints, `sample_size`, and duplicate prevention. -::: - -:::{grid-item-card} Controlling Generations -:link: generations -:link-type: doc - -Elitism, stopping criteria, random seed, saving and continuing, and population size. -::: - -:::{grid-item-card} Fitness Calculation and Performance -:link: fitness_calculation -:link-type: doc - -Parallel processing, batch fitness, reusing fitness, and non-deterministic problems. -::: - -:::{grid-item-card} Logging and the Lifecycle Summary -:link: logging -:link-type: doc - -Print a Keras-like summary and log the outputs. -::: - -:::{grid-item-card} User-Defined Functions, Methods, and Classes -:link: custom_functions -:link-type: doc - -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. -::: - -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -67,3 +26,4 @@ logging custom_functions benchmarks ::: +--> diff --git a/docs/source/releases.md b/docs/source/releases.md index d55b0c1d..04df378c 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -50,6 +50,8 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 33. A new [Examples index](examples.md) connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. +34. Documentation guides also render directly on GitHub and in compatible Markdown previews. Internal references use Markdown links; parameter descriptions use expandable details; navigation lists and PNG diagrams remain visible; and Sphinx-only labels, toctrees, and video embeds are hidden from previews. All Python example sections and the Examples index include checked-in Markdown generated from the shared catalog and templates, with script links, requirements, dataset setup, and run instructions. A Python-only command updates these sections, and builds reject stale content or incomplete section comments. Built documentation retains its cards, dropdowns, figures, downloads, navigation, and published anchors. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. ## PyGAD 3.7.0 @@ -58,9 +60,11 @@ Release Date June 5, 2026 Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). +<!-- sphinx ```{raw} html <iframe width="560" height="315" src="https://www.youtube.com/embed/EXMy37crL7c" title="PyGAD 3.7.0 Release" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> ``` +--> 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. diff --git a/docs/source/steps_to_use.md b/docs/source/steps_to_use.md index d7fcff75..fe1ebc7a 100644 --- a/docs/source/steps_to_use.md +++ b/docs/source/steps_to_use.md @@ -201,6 +201,29 @@ After the instance is loaded, you can use it to run any method or access any pro print(loaded_ga_instance.best_solution()) ``` -:::{python-examples} +<!-- python-examples example.py -::: +--> + +**Python example** + +**[First GA run](../../examples/example.py)** + +Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA. + +`examples/example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga.md b/docs/source/torchga.md index 25463621..25dec0c5 100644 --- a/docs/source/torchga.md +++ b/docs/source/torchga.md @@ -110,31 +110,16 @@ It returns the predictions for the data samples. This section gives the complete code of some examples that build and train a PyTorch model using PyGAD. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Example 1: Regression Example -:link: torchga_regression -:link-type: doc -::: - -:::{grid-item-card} Example 2: XOR Binary Classification -:link: torchga_xor -:link-type: doc -::: - -:::{grid-item-card} Example 3: Image Multi-Class Classification (Dense Layers) -:link: torchga_image_dense -:link-type: doc -::: - -:::{grid-item-card} Example 4: Image Multi-Class Classification (Conv Layers) -:link: torchga_image_conv -:link-type: doc -::: +- [Example 1: Regression Example](torchga_regression.md) +- [Example 2: XOR Binary Classification](torchga_xor.md) +- [Example 3: Image Multi-Class Classification (Dense Layers)](torchga_image_dense.md) +- [Example 4: Image Multi-Class Classification (Conv Layers)](torchga_image_conv.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -143,3 +128,4 @@ torchga_xor torchga_image_dense torchga_image_conv ::: +--> diff --git a/docs/source/torchga_image_conv.md b/docs/source/torchga_image_conv.md index babb2738..2fa31d54 100644 --- a/docs/source/torchga_image_conv.md +++ b/docs/source/torchga_image_conv.md @@ -164,6 +164,32 @@ Crossentropy : 0.7686678 Accuracy : 0.975 ``` -:::{python-examples} +<!-- python-examples TorchGA/image_classification_CNN.py -::: +--> + +**Python example** + +**[Convolutional image classifier](../../examples/TorchGA/image_classification_CNN.py)** + +Train an image classifier with the genetic algorithm. + +`examples/TorchGA/image_classification_CNN.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/TorchGA/ so the relative data paths resolve: + +```console +cd examples/TorchGA +python image_classification_CNN.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_image_dense.md b/docs/source/torchga_image_dense.md index 98fdf2cd..e0c90dab 100644 --- a/docs/source/torchga_image_dense.md +++ b/docs/source/torchga_image_dense.md @@ -125,6 +125,32 @@ Crossentropy : 0.74366045 Accuracy : 1.0 ``` -:::{python-examples} +<!-- python-examples TorchGA/image_classification_Dense.py -::: +--> + +**Python example** + +**[Dense image classifier](../../examples/TorchGA/image_classification_Dense.py)** + +Train an image classifier with the genetic algorithm. + +`examples/TorchGA/image_classification_Dense.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras, PyTorch + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/TorchGA/ so the relative data paths resolve: + +```console +cd examples/TorchGA +python image_classification_Dense.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_regression.md b/docs/source/torchga_regression.md index c560203a..926581cf 100644 --- a/docs/source/torchga_regression.md +++ b/docs/source/torchga_regression.md @@ -232,6 +232,29 @@ print("Absolute Error : ", abs_error.detach().numpy()) Absolute Error : 0.006876422 ``` -:::{python-examples} +<!-- python-examples TorchGA/regression_example.py -::: +--> + +**Python example** + +**[Regression](../../examples/TorchGA/regression_example.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/TorchGA/regression_example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/TorchGA/regression_example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_xor.md b/docs/source/torchga_xor.md index 375a9fe7..ccc83a34 100644 --- a/docs/source/torchga_xor.md +++ b/docs/source/torchga_xor.md @@ -151,6 +151,29 @@ Binary Crossentropy : 0.0 Accuracy : 1.0 ``` -:::{python-examples} +<!-- python-examples TorchGA/XOR_classification.py -::: +--> + +**Python example** + +**[XOR classification](../../examples/TorchGA/XOR_classification.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/TorchGA/XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/TorchGA/XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index d633eb90..75ef1119 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -339,6 +339,29 @@ ga_instance.run() ga_instance.plot_fitness() ``` -:::{python-examples} +<!-- python-examples example_custom_operators.py -::: +--> + +**Python example** + +**[Custom GA operators](../../examples/example_custom_operators.py)** + +Implement parent selection, crossover, and mutation functions. + +`examples/example_custom_operators.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_custom_operators.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/utils.md b/docs/source/utils.md index 7f063237..635dedfb 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -24,7 +24,7 @@ The next sections discuss each submodule. ## `pygad.utils.engine` Submodule -The `pygad.utils.engine` module has the `GAEngine` class that implements the engine of the library. It inherits fitness dispatch and serialization methods from {ref}`FitnessEvaluation <fitness-evaluation>`. The main methods defined in `GAEngine` are: +The `pygad.utils.engine` module has the `GAEngine` class that implements the engine of the library. It inherits fitness dispatch and serialization methods from [FitnessEvaluation](utils.md#pygadutilsparallel-submodule). The main methods defined in `GAEngine` are: 1. `initialize_population()` 2. `cal_pop_fitness()` @@ -64,9 +64,9 @@ For each solution, it checks the following sources in order: 4. Retained parents, when `keep_parents != 0`. `last_generation_parents_indices` maps each parent back to its value in `previous_generation_fitness`. 5. The fitness function, for solutions with no cached value. -These cache rules apply in serial, thread, and process modes. Only uncached rows are passed to `_evaluate_fitness()`, which respects `fitness_batch_size`, validates returned values, and increments `num_fitness_evaluations` by the number of evaluated solutions. Cached rows contribute zero to that counter. These rules assume that a solution's fitness can be reused; see {ref}`non-deterministic problems <non-deterministic-fitness>` for settings that disable reuse. +These cache rules apply in serial, thread, and process modes. Only uncached rows are passed to `_evaluate_fitness()`, which respects `fitness_batch_size`, validates returned values, and increments `num_fitness_evaluations` by the number of evaluated solutions. Cached rows contribute zero to that counter. These rules assume that a solution's fitness can be reused; see [non-deterministic problems](fitness_calculation.md#solve-non-deterministic-problems) for settings that disable reuse. -During `run()`, evaluations reuse the run's worker pool. Outside `run()`, parallel evaluation creates and closes a temporary pool. If all fitness values are cached, no pool is created. Fitness-function exceptions and invalid return values propagate to the caller after being logged. See the {ref}`fitness dispatch reference <fitness-evaluation>` for return-value validation. +During `run()`, evaluations reuse the run's worker pool. Outside `run()`, parallel evaluation creates and closes a temporary pool. If all fitness values are cached, no pool is created. Fitness-function exceptions and invalid return values propagate to the caller after being logged. See the [fitness dispatch reference](utils.md#pygadutilsparallel-submodule) for return-value validation. ### `run()` @@ -99,7 +99,9 @@ Note that the `run()` method is calling 5 different methods during the loop: 4. `run_mutation()` 5. `run_update_population()` +<!-- sphinx (current-population-best-solution)= +--> ### `best_solution()` Returns information about the best solution in the **current population**. Single-objective problems use the maximum fitness; multi-objective problems use the first solution in the NSGA-II ordering (non-dominated front, then crowding distance). This method does not search all saved generations. @@ -120,10 +122,12 @@ It returns the following: A method to round the genes in the passed solutions. It loops through each gene across all the passed solutions and rounds their values if applicable. +<!-- sphinx (fitness-evaluation)= +--> ## `pygad.utils.parallel` Submodule -This module contains the `FitnessEvaluation` mixin and the process-worker function `_process_fitness_chunk()`. A mixin is a class that provides methods for another class to inherit. `GAEngine` inherits this mixin, so `pygad.GA` inherits its methods indirectly. Configure evaluation through `fitness_func`, `fitness_batch_size`, and `parallel_processing`; see the {ref}`parallel processing guide <parallel-processing-guide>` for examples and performance tradeoffs. +This module contains the `FitnessEvaluation` mixin and the process-worker function `_process_fitness_chunk()`. A mixin is a class that provides methods for another class to inherit. `GAEngine` inherits this mixin, so `pygad.GA` inherits its methods indirectly. Configure evaluation through `fitness_func`, `fitness_batch_size`, and `parallel_processing`; see the [parallel processing guide](fitness_calculation.md#parallel-processing-in-pygad) for examples and performance tradeoffs. The methods and worker attributes below are internal implementation details, documented for completeness. Their signatures and lifetime are not a stable public API. @@ -151,7 +155,9 @@ Serial evaluation calls `fitness_func(self, solution, index_argument)` directly Internal process grouping preserves the scalar fitness signature. Only `fitness_batch_size` changes the function's input to a batch. Fitness-function and serialization exceptions propagate while results are consumed. +<!-- sphinx (evaluate-selected-fitness)= +--> #### `_evaluate_fitness(population, indices, adaptive=False)` Parameters: @@ -204,13 +210,13 @@ The `pygad.utils.crossover` module has a class named `Crossover` with the suppor Crossover takes two parents and builds a child by mixing their genes. The next figure shows how single-point, two-point, and uniform crossover do this. -:::{figure} images/crossover_types.* -:alt: Single-point, two-point, and uniform crossover -:width: 560px -:align: center +<!-- documentation-figure: 560px --> + +![Single-point, two-point, and uniform crossover](images/crossover_types.png) How single-point, two-point, and uniform crossover build a child from two parents. -::: + +<!-- /documentation-figure --> All crossover methods accept these parameters: @@ -232,7 +238,9 @@ The next subsections list the supported methods for crossover. Applies the single-point crossover. It selects a point randomly at which crossover takes place between the pairs of parents. +<!-- sphinx (two-points-crossover)= +--> #### `two_points_crossover()` Applies the 2 points crossover. It selects the 2 points randomly at which crossover takes place between the pairs of parents. @@ -249,7 +257,9 @@ Applies the uniform crossover. For each gene, a parent out of the 2 mating paren Applies the scattered crossover. It randomly selects the gene from one of the 2 parents. +<!-- sphinx (sbx-crossover)= +--> #### `sbx_crossover()` Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` parameter controls the spread: larger values keep children closer to their parents. Bounds come from `init_range_low` and `init_range_high`, which can specify a separate range for each gene. @@ -269,19 +279,21 @@ The `pygad.utils.mutation` module has a class named `Mutation` with the supporte Mutation makes small random changes to the offspring so the search can explore new values. The next figure shows random mutation, where a few genes are picked at random and their values are changed. -:::{figure} images/mutation.* -:alt: Random mutation changes a few genes -:width: 560px -:align: center +<!-- documentation-figure: 560px --> + +![Random mutation changes a few genes](images/mutation.png) Random mutation changes the values of a few genes that are picked at random. -::: + +<!-- /documentation-figure --> All mutation methods accept this parameter: 1. `offspring`: The offspring to mutate. +<!-- sphinx (mutation-methods)= +--> ### Mutation Methods The `Mutation` class in the `pygad.utils.mutation` module supports several methods for applying mutation. All of these methods accept the same parameter which is: @@ -304,7 +316,9 @@ Each gene participates in at most one fallback swap per offspring per mutation p For each gene, a random value is selected according to the range specified by the 2 attributes `random_mutation_min_val` and `random_mutation_max_val`. The random value is added to the selected gene. +<!-- sphinx (swap-mutation)= +--> #### `swap_mutation()` Applies the swap mutation which interchanges the values of 2 randomly selected genes. @@ -315,7 +329,9 @@ Any pair of distinct positions can be selected. An offspring with only one gene Applies the inversion mutation which selects a subset of genes and inverts them. +<!-- sphinx (scramble-mutation)= +--> #### `scramble_mutation()` Applies the scramble mutation which selects a subset of genes and shuffles their order randomly. @@ -328,7 +344,9 @@ Applies the adaptive mutation, which selects the number/percentage of genes to m The count-based and probability-based adaptive mutation methods use the same compatible-swap fallback for permutations as random mutation. Their fitness-based controls select which genes can initiate a mutation; swapped partners are not mutated again in the same pass. +<!-- sphinx (polynomial-mutation)= +--> #### `polynomial_mutation(offspring)` Applies polynomial mutation to the passed two-dimensional offspring array in place and returns it. Each gene is selected with `mutation_probability`, or with probability `1 / num_genes` when that parameter is `None`. `polynomial_mutation_eta` controls the size of the change; higher values favor smaller changes. Bounds come from `init_range_low` and `init_range_high` for each gene, and mutated values are clipped to those bounds. Genes whose range has effectively zero width are skipped. When `allow_duplicate_genes=False`, the existing random duplicate-resolution helper is applied after changing a gene. @@ -349,7 +367,9 @@ The `pygad.utils.mutation` module has some helper methods to assist applying the 10. `adaptive_mutation_probs_randomly()`: Uses the mutation probabilities to decide which genes to apply the adaptive mutation randomly. 11. `swap_gene_by_space(solution, gene_idx, swapped_genes=None)`: Swap one gene with a compatible partner while preserving gene types, numeric values, gene spaces, uniqueness, and constraints. The solution is modified in place. The optional `swapped_genes` set tracks both positions already swapped in the same offspring's mutation pass; start with a new set for each pass. +<!-- sphinx (adaptive-offspring-fitness)= +--> #### `adaptive_mutation_population_fitness(offspring)` Accepts a two-dimensional NumPy array of offspring before mutation, with one chromosome per row. It builds a temporary population containing retained solutions followed by these actual offspring, without replacing `self.population`. The number of offspring must match the available rows after retention, as prepared by PyGAD's crossover step. @@ -358,7 +378,7 @@ Retention follows the GA configuration: positive `keep_elitism` selects the best Returns `(average_fitness, offspring_fitness)`. For a single objective, the average is a scalar and offspring fitness is a one-dimensional NumPy array. For multiple objectives, the average is a vector and offspring fitness has one row per offspring and one column per objective. The average includes both retained solutions and offspring. NumPy infers a common fitness dtype, preserving fractional offspring values when earlier population fitness was integer-valued. -All execution modes and batch sizes evaluate the same offspring values. The fitness function receives `None` as its index argument in both scalar and batch calls. It must use the supplied chromosomes rather than indexing the current `ga_instance.population`. Every evaluated offspring contributes to `num_fitness_evaluations`; retained fitness does not. During a run, these calls reuse the same executor as ordinary population evaluation. Return validation and propagated exceptions are described in {ref}`_evaluate_fitness() <evaluate-selected-fitness>`. +All execution modes and batch sizes evaluate the same offspring values. The fitness function receives `None` as its index argument in both scalar and batch calls. It must use the supplied chromosomes rather than indexing the current `ga_instance.population`. Every evaluated offspring contributes to `num_fitness_evaluations`; retained fitness does not. During a run, these calls reuse the same executor as ordinary population evaluation. Return validation and propagated exceptions are described in [_evaluate_fitness()](utils.md#_evaluate_fitnesspopulation-indices-adaptivefalse). #### `swap_gene_by_space(solution, gene_idx, swapped_genes=None)` @@ -405,7 +425,9 @@ The next subsections list the supported methods for parent selection. Selects the parents using the steady-state selection technique. +<!-- sphinx (rank-selection)= +--> #### `rank_selection()` Selects the parents using the rank selection technique. @@ -424,7 +446,9 @@ Selects the parents using the tournament selection technique. Selects the parents using the roulette wheel selection technique. +<!-- sphinx (stochastic-universal-selection)= +--> #### `stochastic_universal_selection()` Selects the parents using the stochastic universal selection technique. @@ -487,11 +511,36 @@ pip install pygad[report] See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#generate-report). -:::{python-examples} +<!-- python-examples example_generate_report.py -::: +--> + +**Python example** + +**[PDF report](../../examples/example_generate_report.py)** + +Export the run configuration, summary, best solution, and applicable plots to PDF. + +`examples/example_generate_report.py` +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD with the report extra (Matplotlib and ReportLab) + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_generate_report.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (quality-indicators)= +--> ## `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: @@ -518,37 +567,75 @@ true_front = problem.pareto_front(num_points=100) igd = inverted_generational_distance(fitness, true_front) ``` -:::{python-examples} +<!-- python-examples quality_indicators/example_hypervolume.py quality_indicators/example_inverted_generational_distance.py quality_indicators/example_generational_distance.py quality_indicators/example_spacing.py -::: +--> -## More about the Operators +**Python examples** -::::{grid} 1 2 2 2 -:gutter: 3 +| Python script | What it shows | Related information | +| --- | --- | --- | +| [quality_indicators/example_hypervolume.py](../../examples/quality_indicators/example_hypervolume.py) | **Hypervolume.** Measure the objective-space volume dominated by the final population. | [Guide](utils.md) | +| [quality_indicators/example_inverted_generational_distance.py](../../examples/quality_indicators/example_inverted_generational_distance.py) | **Inverted generational distance.** Measure distance from a reference front to the approximation. | [Guide](utils.md) | +| [quality_indicators/example_generational_distance.py](../../examples/quality_indicators/example_generational_distance.py) | **Generational distance.** Measure distance from the approximation to a reference front. | [Guide](utils.md) | +| [quality_indicators/example_spacing.py](../../examples/quality_indicators/example_spacing.py) | **Spacing.** Measure how evenly the approximation points are spread. | [Guide](utils.md) | -:::{grid-item-card} Adaptive Mutation -:link: adaptive_mutation -:link-type: doc +<details> +<summary>Run these examples</summary> -Change the mutation rate per solution based on its fitness. -::: +**Hypervolume** — Requires: PyGAD -:::{grid-item-card} User-Defined Operators -:link: user_defined_operators -:link-type: doc +From the repository root, with the repository version of PyGAD installed: -Plug in your own crossover, mutation, and parent selection. -::: +```console +python examples/quality_indicators/example_hypervolume.py +``` + +**Inverted generational distance** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_inverted_generational_distance.py +``` + +**Generational distance** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_generational_distance.py +``` + +**Spacing** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_spacing.py +``` + +</details> + +<!-- /python-examples --> + +## More about the Operators + +<!-- navigation-grid: 1 2 2 2 --> + +- [Adaptive Mutation](adaptive_mutation.md) — Change the mutation rate per solution based on its fitness. +- [User-Defined Operators](user_defined_operators.md) — Plug in your own crossover, mutation, and parent selection. -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: adaptive_mutation user_defined_operators ::: +--> diff --git a/docs/source/visualize.md b/docs/source/visualize.md index dc98f11b..a15d2b40 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -25,7 +25,9 @@ Except for `plot_lifecycle()`, every method requires at least one completed gene After repeated `run()` calls, fitness plots, best-solution gene plots, and population diagnostics use the actual generation numbers. Histories retain both snapshots at a run boundary, so two points can have the same generation number. Population diagnostics also retain each snapshot's population size. `plot_new_solution_rate()` uses the latest saved population once per generation and excludes the final population, as in a single run. `plot_pareto_front_evolution(every_k=N)` selects actual generation numbers divisible by `N`, uses the latest snapshot at repeated boundaries, and always includes the final population. These plots also work in generated PDF reports. +<!-- sphinx (plot-lifecycle)= +--> ## `plot_lifecycle()` Draw the lifecycle configured for a GA instance: initial fitness evaluation, parent selection, crossover, mutation, population update, fitness reevaluation, and the generation loop. The chart includes the configured callbacks at their execution points, a generation-limit decision, and early stopping when a stopping criterion is set or `on_generation` can return `"stop"`. @@ -60,9 +62,32 @@ The method reads the current GA configuration without evaluating fitness, callin Install the optional plotting dependency with `pip install pygad[visualize]`. -:::{python-examples} +<!-- python-examples plots/example_plot_lifecycle.py -::: +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +</details> + +<!-- /python-examples --> ## `plot_fitness()` @@ -76,9 +101,32 @@ ga_instance.plot_fitness() ![plot_fitness](figures/plot_fitness.png) -:::{python-examples} +<!-- python-examples plots/example_plot_fitness.py -::: +--> + +**Python example** + +**[Best-fitness curve](../../examples/plots/example_plot_fitness.py)** + +Plot best fitness across generations on the Sphere benchmark. + +`examples/plots/example_plot_fitness.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_fitness.py +``` + +</details> + +<!-- /python-examples --> ## `plot_new_solution_rate()` @@ -92,9 +140,32 @@ ga_instance.plot_new_solution_rate() ![plot_new_solution_rate](figures/plot_new_solution_rate.png) -:::{python-examples} +<!-- python-examples plots/example_plot_new_solution_rate.py -::: +--> + +**Python example** + +**[New-solution rate](../../examples/plots/example_plot_new_solution_rate.py)** + +Count previously unseen solutions in each generation. + +`examples/plots/example_plot_new_solution_rate.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_new_solution_rate.py +``` + +</details> + +<!-- /python-examples --> ## `plot_genes()` @@ -110,11 +181,36 @@ ga_instance.plot_genes(graph_type="boxplot") ![plot_genes](figures/plot_genes.png) -:::{python-examples} +<!-- python-examples plots/example_plot_genes.py -::: +--> + +**Python example** + +**[Gene histories](../../examples/plots/example_plot_genes.py)** + +Show how gene values change across saved generations. +`examples/plots/example_plot_genes.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_genes.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-pareto-front-curve)= +--> ## `plot_pareto_front_curve()` 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. @@ -133,12 +229,56 @@ For M=3 (NSGA-III on DTLZ2): ![plot_pareto_front_curve_3d](figures/plot_pareto_front_curve_3d.png) -:::{python-examples} +<!-- python-examples plots/example_plot_pareto_front_curve_2d.py plots/example_plot_pareto_front_curve_3d.py -::: +--> + +**Python examples** + +**[2D Pareto front](../../examples/plots/example_plot_pareto_front_curve_2d.py)** + +Plot a two-objective Pareto front after NSGA-II optimization. + +`examples/plots/example_plot_pareto_front_curve_2d.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_curve_2d.py +``` + +</details> +**[3D Pareto front](../../examples/plots/example_plot_pareto_front_curve_3d.py)** + +Plot a three-objective Pareto front after NSGA-III optimization. + +`examples/plots/example_plot_pareto_front_curve_3d.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_curve_3d.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-pareto-front-pcp)= +--> ## `plot_pareto_front_pcp()` 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. @@ -151,11 +291,36 @@ ga_instance.plot_pareto_front_pcp() ![plot_pareto_front_pcp](figures/plot_pareto_front_pcp.png) -:::{python-examples} +<!-- python-examples plots/example_plot_pareto_front_pcp.py -::: +--> + +**Python example** + +**[Parallel coordinates](../../examples/plots/example_plot_pareto_front_pcp.py)** + +Compare Pareto solutions across objective axes. + +`examples/plots/example_plot_pareto_front_pcp.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: +```console +python examples/plots/example_plot_pareto_front_pcp.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-pareto-front-scatter-matrix)= +--> ## `plot_pareto_front_scatter_matrix()` 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. @@ -168,11 +333,36 @@ ga_instance.plot_pareto_front_scatter_matrix() ![plot_pareto_front_scatter_matrix](figures/plot_pareto_front_scatter_matrix.png) -:::{python-examples} +<!-- python-examples plots/example_plot_pareto_front_scatter_matrix.py -::: +--> + +**Python example** + +**[Pareto scatter matrix](../../examples/plots/example_plot_pareto_front_scatter_matrix.py)** + +Compare every pair of objectives in a many-objective run. + +`examples/plots/example_plot_pareto_front_scatter_matrix.py` + +<details> +<summary>Run this example</summary> +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_scatter_matrix.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-pareto-front-heatmap)= +--> ## `plot_pareto_front_heatmap()` 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. @@ -185,11 +375,36 @@ ga_instance.plot_pareto_front_heatmap(sort_by=0) ![plot_pareto_front_heatmap](figures/plot_pareto_front_heatmap.png) -:::{python-examples} +<!-- python-examples plots/example_plot_pareto_front_heatmap.py -::: +--> + +**Python example** + +**[Pareto heatmap](../../examples/plots/example_plot_pareto_front_heatmap.py)** + +Compare objective values with a solutions-by-objectives heatmap. +`examples/plots/example_plot_pareto_front_heatmap.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_heatmap.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-fitness-band)= +--> ## `plot_fitness_band()` 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`. @@ -202,11 +417,36 @@ ga_instance.plot_fitness_band() ![plot_fitness_band](figures/plot_fitness_band.png) -:::{python-examples} +<!-- python-examples plots/example_plot_fitness_band.py -::: +--> + +**Python example** + +**[Fitness band](../../examples/plots/example_plot_fitness_band.py)** + +Plot per-generation minimum, mean, and maximum fitness with a shaded band. + +`examples/plots/example_plot_fitness_band.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_fitness_band.py +``` +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-non-dominated-hypervolume)= +--> ## `plot_non_dominated_hypervolume()` 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`. @@ -219,11 +459,36 @@ ga_instance.plot_non_dominated_hypervolume() ![plot_non_dominated_hypervolume](figures/plot_non_dominated_hypervolume.png) -:::{python-examples} +<!-- python-examples plots/example_plot_non_dominated_hypervolume.py -::: +--> + +**Python example** + +**[Hypervolume history](../../examples/plots/example_plot_non_dominated_hypervolume.py)** + +Track the hypervolume of the non-dominated set across generations. + +`examples/plots/example_plot_non_dominated_hypervolume.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: +```console +python examples/plots/example_plot_non_dominated_hypervolume.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-population-diversity)= +--> ## `plot_population_diversity()` Mean pairwise Euclidean distance between solutions per generation. A drop signals the population is converging or collapsing into duplicates. Requires `save_solutions=True`. @@ -236,11 +501,36 @@ ga_instance.plot_population_diversity() ![plot_population_diversity](figures/plot_population_diversity.png) -:::{python-examples} +<!-- python-examples plots/example_plot_population_diversity.py -::: +--> + +**Python example** + +**[Population diversity](../../examples/plots/example_plot_population_diversity.py)** + +Track mean pairwise distance between solutions across generations. + +`examples/plots/example_plot_population_diversity.py` + +<details> +<summary>Run this example</summary> +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_population_diversity.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx (plot-pareto-front-evolution)= +--> ## `plot_pareto_front_evolution()` 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`. @@ -253,6 +543,29 @@ ga_instance.plot_pareto_front_evolution(every_k=20) ![plot_pareto_front_evolution](figures/plot_pareto_front_evolution.png) -:::{python-examples} +<!-- python-examples plots/example_plot_pareto_front_evolution.py -::: +--> + +**Python example** + +**[Pareto-front evolution](../../examples/plots/example_plot_pareto_front_evolution.py)** + +Overlay the non-dominated fronts from selected generations. + +`examples/plots/example_plot_pareto_front_evolution.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_evolution.py +``` + +</details> + +<!-- /python-examples --> From 6f31df251d4faca89571573ffac5a8e53f3ffe1d Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 14:17:30 -0400 Subject: [PATCH 15/22] Prepare PyGAD 3.8.0 and gate publishing on release checks --- .github/workflows/main.yml | 15 +++++- .github/workflows/release.yml | 26 ++++++++- RELEASING.md | 27 +++++++--- docs/RELEASE_READINESS_3.8.0.md | 93 +++++++++++++++++++++++++++++++++ docs/source/conf.py | 7 +-- docs/source/releases.md | 10 +++- pygad/_version.py | 2 +- pyproject.toml | 2 +- 8 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 docs/RELEASE_READINESS_3.8.0.md diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 834f57df..284c4026 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,9 @@ on: - 'examples/**' - 'requirements.txt' - 'pyproject.toml' + - 'setup.py' - '.github/workflows/main.yml' + - '.github/workflows/release.yml' # Test relevant pull requests, including fork contributions, against master. pull_request: branches: @@ -29,9 +31,13 @@ on: - 'examples/**' - 'requirements.txt' - 'pyproject.toml' + - 'setup.py' - '.github/workflows/main.yml' + - '.github/workflows/release.yml' # Allows manual triggering of the workflow from the GitHub Actions tab. workflow_dispatch: + # Release tags run this same matrix before publishing. + workflow_call: jobs: pytest: @@ -114,8 +120,13 @@ jobs: # This includes our new tests for visualization, operators, parallel processing, etc. - name: Run Tests run: | + # Run outside the checkout so imports exercise the installed wheel. + cd "$RUNNER_TEMP" + python -c "import pygad; print('Testing installed package:', pygad.__file__)" if [ "${{ matrix.python-version }}" == "3.14" ] || [ "${{ matrix.python-version }}" == "3.8" ]; then - pytest --ignore=tests/test_kerasga.py --ignore=tests/test_torchga.py + python -m pytest "$GITHUB_WORKSPACE/tests" \ + --ignore="$GITHUB_WORKSPACE/tests/test_kerasga.py" \ + --ignore="$GITHUB_WORKSPACE/tests/test_torchga.py" else - pytest + python -m pytest "$GITHUB_WORKSPACE/tests" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 782a39a8..0e51282c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,14 +11,33 @@ on: tags: - '[0-9]+.[0-9]+.[0-9]+' +permissions: + contents: read + jobs: + tests: + uses: ./.github/workflows/main.yml + permissions: + contents: read + build: + needs: tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Verify release tag matches package version + run: | + python - <<'PY' + import os + import runpy + version = runpy.run_path("pygad/_version.py")["__version__"] + tag = os.environ["GITHUB_REF_NAME"] + if tag != version: + raise SystemExit(f"Release tag {tag} does not match package version {version}") + PY - name: Build distributions run: | pip install build . pytest responses 'vilvik>=0.5.3' @@ -28,6 +47,11 @@ jobs: run: | pip install twine python -m twine check dist/* + - name: Check documentation + run: | + pip install -r docs/requirements.txt + python docs/markdown_compatibility.py + python -m sphinx -b html -W --keep-going docs/source docs/build/html - uses: actions/upload-artifact@v4 with: name: dist @@ -48,7 +72,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 github-release: - needs: build + needs: [build, publish] runs-on: ubuntu-latest permissions: contents: write diff --git a/RELEASING.md b/RELEASING.md index 37ed635a..50c38c19 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,25 +11,36 @@ hand. 2. Update the release notes in the docs if you keep them there. 3. Commit and push: ```bash - git add pygad/_version.py - git commit -m "Release 3.6.1" + git add pygad/_version.py docs/source/releases.md + git commit -m "Prepare PyGAD 3.8.0" git push ``` -4. Wait for the test workflow (`main.yml`) to pass on that commit. + Stage any other intended release changes before committing. Preparation stays + on `github-actions` until the maintainer chooses the final release commit; + these steps do not require changes to `master`. +4. Wait for the test workflow (`main.yml`) to pass on that commit. Confirm the + release notes describe the intended version and replace its pending release + date with the actual publication date. Documentation reads the package version + automatically. Build and check the distributions before tagging: + ```bash + python -m build + python -m twine check dist/* + ``` 5. Tag the release and push the tag: ```bash - git tag 3.6.1 - git push origin 3.6.1 + git tag 3.8.0 + git push origin 3.8.0 ``` -The `release` workflow does the rest: it builds the wheel and sdist, publishes -them to PyPI, and creates a GitHub Release with both files attached. Follow it +The `release` workflow first runs the full Python 3.8 through 3.14 test matrix, +then builds the wheel and sdist, publishes them to PyPI, and creates a GitHub +Release with both files attached after publication succeeds. Follow it with `gh run watch` or the Actions tab. ## Rules - The tag must match `pygad/_version.py` and is the bare version number with no - `v` prefix, for example `3.6.1`. The tag is what triggers the release. + `v` prefix, for example `3.8.0`. The tag is what triggers the release. - Every release needs a new version number. PyPI does not allow re-uploading or overwriting a version that already exists. - Do not run `twine upload` or upload files to the GitHub Release by hand. The diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md new file mode 100644 index 00000000..8d2c9785 --- /dev/null +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -0,0 +1,93 @@ +# PyGAD 3.8.0 release readiness + +Reviewed October 9, 2026. All library preparation is on the +`github-actions` branch. Nothing has been tagged, merged or published. +The `master` branch remains at `92e7c7f`. + +## Version + +The latest public release is **3.7.0**, published June 5, 2026. PyPI has no +3.8.0 distribution as of this review. The next release is **3.8.0** because +it adds public features, including `plot_lifecycle()` and generation metadata, +alongside fixes and internal refactoring. A patch release would understate +those additions. The main `GA` constructor signature is unchanged from 3.7.0; +the reviewed changes do not require a new major version. + +The package version is prepared in `pygad/_version.py`. Sphinx reads that file +directly so documentation and package versions cannot drift. + +## Validation + +- Source checkout: 1,871 tests passed, one skipped, on Python 3.11.13 with + NumPy 2.4.4. The skip is the unavailable Windows `fork` process start method. +- TensorFlow/Keras and PyTorch tests were excluded locally because those + optional frameworks are absent. They are covered by the existing remote + matrix on its supported framework versions. +- Latest remote matrix: all Python 3.8 through 3.14 jobs passed at commit + `577ba5f18f6a79d041b238f66c6f187164e3a7f5`: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37955035861 + Later branch commits changed documentation only before this preparation. +- Wheel and source distribution built as 3.8.0 and both passed `twine check`. + The wheel contains the logo needed by PDF reports and declares its extras. +- Installed wheel: 1,871 tests passed, one skipped, in a separate virtual + environment from outside the repository. Imports resolved to the installed + 3.8.0 wheel, not the source checkout. The PyPI submodule-version check was + required to pass rather than silently skip if unavailable. +- HTML documentation built with `-W --keep-going` and no warnings. +- Generated example Markdown is current; its catalog covers 81 Python scripts + and one notebook. +- Changed workflows passed actionlint 1.7.12. Whitespace checks passed. + +## Publishing safeguards prepared + +The release workflow now runs the same full Python matrix before building +and publishing. Matrix tests run outside the checkout to import the installed +wheel. The workflow rejects mismatches between the tag and package version, +checks distributions, and requires a clean documentation build. The GitHub +Release job waits for successful PyPI publication. + +The GitHub `pypi` environment exists. The prior 3.7.0 release completed through +the trusted-publishing workflow: +https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/27043771194 +Its previous success does not establish that account permissions can never +change; the next release run remains the verification of live publishing. + +## Compatibility notes to retain + +- Seeded results can differ from earlier versions. Reproducibility is within + the same version and environment, with independent generators per instance. +- Invalid fitness values and malformed constructor settings are rejected + earlier and consistently. These corrections are detailed in the release notes. +- Saved histories retain both snapshots at repeated-run boundaries and expose + actual generation numbers. +- Metadata now declares Python 3.8 or newer, matching the minimum tested version. + +## Remaining publication steps + +The reviewed tree passed the local prepublication checks. The changed workflows +have been validated locally. Pushing this preparation to `github-actions` +triggers the GitHub test matrix; its result must be checked before publishing. + +Review the prepared diff and run the changed workflows on the final commit. +Set the actual publication date in `docs/source/releases.md` when releasing. +Choose the final release commit, then create the matching 3.8.0 tag only when +publication is authorized. Pushing that tag publishes automatically. + +## Announcement video + +The previous source project was found at +https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at +`D:/Projects/PyGADReleaseVideo`. New work is on `codex/pygad-3.8.0-video`. +It reuses the original animation kit, fonts, logo, music and sound effects. +Runnable on-screen examples, real output, chapters and draft social posts +are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. + +The landscape (3840x2160), vertical (2160x3840), and square (2160x2160) +videos are complete. The announcement kit is prepared in `PyGAD_3.8.0/` +on that repository's video branch, with MP4 files stored through Git LFS. +Each is 131 seconds at 60 fps, +with H.264 video and AAC stereo audio. Every encoded frame decoded successfully; +contact sheets from all nine segments were visually checked in all orientations. +Thumbnails, a Reel cover, chapter timestamps, title captions, draft social posts, +and machine-readable media validation are included. Social posts remain drafts; +no videos have been published to social platforms. diff --git a/docs/source/conf.py b/docs/source/conf.py index 5e09cf1d..b656f6eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,9 +9,6 @@ copyright = '2026, Ahmed Fawzy Gad' author = 'Ahmed Fawzy Gad' -# The full version, including alpha/beta/rc tags. -release = '3.7.0' - master_doc = 'index' # -- General configuration --------------------------------------------------- @@ -20,6 +17,10 @@ from pathlib import Path import subprocess import sys +import runpy + +# Read the package version without importing optional package dependencies. +release = runpy.run_path(str(Path(__file__).resolve().parents[2] / 'pygad' / '_version.py'))['__version__'] sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/docs/source/releases.md b/docs/source/releases.md index 04df378c..d71f40e0 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -6,7 +6,13 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan ## Unreleased -These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. +No changes yet. + +## PyGAD 3.8.0 + +Release Date: pending publication. + +These changes are prepared on the `github-actions` branch. PyGAD 3.8.0 has not been published yet. 1. [Two-point crossover](utils.md#two_points_crossover) selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). 2. [Swap mutation](utils.md#swap_mutation) can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). @@ -52,6 +58,8 @@ These changes are available in the repository after PyGAD 3.7.0 and will be incl 34. Documentation guides also render directly on GitHub and in compatible Markdown previews. Internal references use Markdown links; parameter descriptions use expandable details; navigation lists and PNG diagrams remain visible; and Sphinx-only labels, toctrees, and video embeds are hidden from previews. All Python example sections and the Examples index include checked-in Markdown generated from the shared catalog and templates, with script links, requirements, dataset setup, and run instructions. A Python-only command updates these sections, and builds reject stale content or incomplete section comments. Built documentation retains its cards, dropdowns, figures, downloads, navigation, and published anchors. +35. Package metadata declares Python 3.8 or newer, matching the minimum version in the test matrix. Documentation reads the package version from `pygad/_version.py`. Release tags must match that version, and publication requires the Python 3.8 through 3.14 test matrix and a documentation build with warnings treated as errors. The matrix imports the installed wheel from outside the checkout. A GitHub Release is created only after PyPI publication succeeds. + The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. ## PyGAD 3.7.0 diff --git a/pygad/_version.py b/pygad/_version.py index 46f67e7f..32a78190 100644 --- a/pygad/_version.py +++ b/pygad/_version.py @@ -1 +1 @@ -__version__ = "3.7.0" +__version__ = "3.8.0" diff --git a/pyproject.toml b/pyproject.toml index d3d83b57..54a273b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ name = "pygad" dynamic = ["version"] description = "PyGAD: A Python Library for Building the Genetic Algorithm and Training Machine Learning Algoithms (Keras & PyTorch)." readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3" +requires-python = ">=3.8" license = {file = "LICENSE"} # license = "BSD-3-Clause" # license-files = ["LICENSE"] From fb6a9360f79b2d2b1ea7314a2c205c7b27185077 Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 14:24:27 -0400 Subject: [PATCH 16/22] Record passing release checks and uploaded video assets --- docs/RELEASE_READINESS_3.8.0.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md index 8d2c9785..baaa01fb 100644 --- a/docs/RELEASE_READINESS_3.8.0.md +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -1,6 +1,6 @@ # PyGAD 3.8.0 release readiness -Reviewed October 9, 2026. All library preparation is on the +Reviewed October 9, 2026. All library preparation is pushed to the `github-actions` branch. Nothing has been tagged, merged or published. The `master` branch remains at `92e7c7f`. @@ -23,10 +23,14 @@ directly so documentation and package versions cannot drift. - TensorFlow/Keras and PyTorch tests were excluded locally because those optional frameworks are absent. They are covered by the existing remote matrix on its supported framework versions. -- Latest remote matrix: all Python 3.8 through 3.14 jobs passed at commit - `577ba5f18f6a79d041b238f66c6f187164e3a7f5`: - https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37955035861 - Later branch commits changed documentation only before this preparation. +- Release preparation matrix: all Python 3.8 through 3.14 jobs passed at commit + `6f31df251d4faca89571573ffac5a8e53f3ffe1d`, using the changed workflow and + installed 3.8.0 wheels: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37972271705 + The following commit only updates this readiness report. +- SDK compatibility: all four minimum/latest SDK jobs on Python 3.11 and 3.12 + passed for that same release preparation commit: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37972271741 - Wheel and source distribution built as 3.8.0 and both passed `twine check`. The wheel contains the logo needed by PDF reports and declares its extras. - Installed wheel: 1,871 tests passed, one skipped, in a separate virtual @@ -64,11 +68,12 @@ change; the next release run remains the verification of live publishing. ## Remaining publication steps -The reviewed tree passed the local prepublication checks. The changed workflows -have been validated locally. Pushing this preparation to `github-actions` -triggers the GitHub test matrix; its result must be checked before publishing. +The reviewed tree passed the local prepublication checks. The changed test +workflow and SDK compatibility workflow also passed on GitHub for the release +preparation commit. The tag-triggered publishing workflow has been checked +locally; its publishing steps have not been executed for 3.8.0. -Review the prepared diff and run the changed workflows on the final commit. +Review the prepared diff. Set the actual publication date in `docs/source/releases.md` when releasing. Choose the final release commit, then create the matching 3.8.0 tag only when publication is authorized. Pushing that tag publishes automatically. @@ -83,8 +88,11 @@ Runnable on-screen examples, real output, chapters and draft social posts are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. The landscape (3840x2160), vertical (2160x3840), and square (2160x2160) -videos are complete. The announcement kit is prepared in `PyGAD_3.8.0/` -on that repository's video branch, with MP4 files stored through Git LFS. +videos are complete. The announcement kit was uploaded to `PyGAD_3.8.0/` +on that repository's video branch, with MP4 files stored through Git LFS: +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/PyGAD_3.8.0 +All four MP4 objects uploaded successfully and local Git LFS integrity checks +passed. A fresh authenticated download of the preview matched its SHA-256 hash. Each is 131 seconds at 60 fps, with H.264 video and AAC stereo audio. Every encoded frame decoded successfully; contact sheets from all nine segments were visually checked in all orientations. From 696c0d166b764fbb7ace85c68e455e631d66d28f Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 15:55:26 -0400 Subject: [PATCH 17/22] Record revised announcement videos and approved sound checks --- docs/RELEASE_READINESS_3.8.0.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md index baaa01fb..d90396b5 100644 --- a/docs/RELEASE_READINESS_3.8.0.md +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -83,7 +83,8 @@ publication is authorized. Pushing that tag publishes automatically. The previous source project was found at https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at `D:/Projects/PyGADReleaseVideo`. New work is on `codex/pygad-3.8.0-video`. -It reuses the original animation kit, fonts, logo, music and sound effects. +It reuses the original animation kit, fonts and logo. The revised soundtrack +uses calm synthesized pads and the user-approved soft wooden tap cues. Runnable on-screen examples, real output, chapters and draft social posts are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. @@ -93,9 +94,17 @@ on that repository's video branch, with MP4 files stored through Git LFS: https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/PyGAD_3.8.0 All four MP4 objects uploaded successfully and local Git LFS integrity checks passed. A fresh authenticated download of the preview matched its SHA-256 hash. -Each is 131 seconds at 60 fps, +The three full videos are 131 seconds each, and the preview is 25.5 seconds. +All four run at 60 fps, with H.264 video and AAC stereo audio. Every encoded frame decoded successfully; contact sheets from all nine segments were visually checked in all orientations. Thumbnails, a Reel cover, chapter timestamps, title captions, draft social posts, and machine-readable media validation are included. Social posts remain drafts; no videos have been published to social platforms. + +The October 9 revisions move section takeaways into larger, high-contrast +callouts above the output and enlarge the history chart in every orientation. +Plots come from 360 dpi exports. Final audio checks passed for all four videos; +the full videos measure -18.39 LUFS, and each reveal tap is at least 8.81 dB +above the preceding music. Layout and audio measurements are included in the +video repository's `PyGAD_3.8.0/layout_review.json` and `audio_validation.json`. From d321ecd5ff818785380c546175908c19aa1c791d Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 16:19:26 -0400 Subject: [PATCH 18/22] Record restored announcement audio and shorter opening --- docs/RELEASE_READINESS_3.8.0.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md index d90396b5..f03832b8 100644 --- a/docs/RELEASE_READINESS_3.8.0.md +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -83,8 +83,9 @@ publication is authorized. Pushing that tag publishes automatically. The previous source project was found at https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at `D:/Projects/PyGADReleaseVideo`. New work is on `codex/pygad-3.8.0-video`. -It reuses the original animation kit, fonts and logo. The revised soundtrack -uses calm synthesized pads and the user-approved soft wooden tap cues. +It reuses the original animation kit, fonts, logo, background music, typing +sounds and transition effects. Only the bell cues are replaced with the +user-approved soft wooden tap. Runnable on-screen examples, real output, chapters and draft social posts are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. @@ -94,7 +95,8 @@ on that repository's video branch, with MP4 files stored through Git LFS: https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/PyGAD_3.8.0 All four MP4 objects uploaded successfully and local Git LFS integrity checks passed. A fresh authenticated download of the preview matched its SHA-256 hash. -The three full videos are 131 seconds each, and the preview is 25.5 seconds. +The three full videos are 127 seconds each, and the preview is 21.5 seconds. +The opening shows the logo and version for 5.5 seconds, down from 9.5 seconds. All four run at 60 fps, with H.264 video and AAC stereo audio. Every encoded frame decoded successfully; contact sheets from all nine segments were visually checked in all orientations. @@ -104,7 +106,8 @@ no videos have been published to social platforms. The October 9 revisions move section takeaways into larger, high-contrast callouts above the output and enlarge the history chart in every orientation. -Plots come from 360 dpi exports. Final audio checks passed for all four videos; -the full videos measure -18.39 LUFS, and each reveal tap is at least 8.81 dB -above the preceding music. Layout and audio measurements are included in the +Plots come from 360 dpi exports. Audio checks verify that the original music +and non-bell effects are preserved, including all 417 typing clicks. Encoded +music, typing transients, tap levels, loudness and peaks are checked in all four +videos. Layout and audio measurements are included in the video repository's `PyGAD_3.8.0/layout_review.json` and `audio_validation.json`. From adde6bfe83f1e8b2ba8a3c8f73205f2dbcab8951 Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 17:45:26 -0400 Subject: [PATCH 19/22] Fit lifecycle charts to content and support transparent exports --- docs/source/releases.md | 2 +- docs/source/visualize.md | 4 +++- pygad/visualize/lifecycle.py | 15 ++++++++++++++ pygad/visualize/plot.py | 17 ++++++++++++---- tests/test_plot_lifecycle.py | 39 ++++++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/source/releases.md b/docs/source/releases.md index d71f40e0..ae4467a2 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -27,7 +27,7 @@ These changes are prepared on the `github-actions` branch. PyGAD 3.8.0 has not b 11. [Scramble mutation](utils.md#scramble_mutation) shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). 12. New examples explain [replacing a loaded fitness function](pygad.md#updating-the-fitness-function-after-loading), starting fresh when the objective changes, and [handling short final fitness batches](fitness_calculation.md#why-a-fitness-batch-can-be-smaller). The lifecycle guide also explains [progress reporting](lifecycle.md#reporting-progress) and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). 13. [Rank selection](utils.md#rank_selection) assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. -14. A new [plot_lifecycle()](visualize.md#plot_lifecycle) method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. +14. A new [plot_lifecycle()](visualize.md#plot_lifecycle) method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. Use `transparent=True` for a transparent background. Charts fit their labels and connectors with small outer margins. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. 15. [Duplicate-gene repair](gene_values.md#prevent-duplicates-in-gene-values) now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. 16. Repair uses each destination gene's type, precision, and [range](gene_values.md#more-about-the-gene_space-parameter), and validates [constraints](gene_values.md#gene-constraint) against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. diff --git a/docs/source/visualize.md b/docs/source/visualize.md index a15d2b40..2457055a 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -44,10 +44,12 @@ Callbacks appear only when supplied. If `crossover_type=None` or `mutation_type= In the detailed view, the `Stop Early?` block lists the configured `stop_criteria` and, when an `on_generation` callback is supplied, the possible condition `on_generation() returns "stop"`. Any one of these conditions ends the run. The chart does not analyze the callback's code or assume that it will return `"stop"`. The block is omitted when neither early stopping mechanism is configured. Built-in block and configuration titles capitalize the first letter of each word; method and handler names retain their original spelling. -Parameters: `title` (default `"PyGAD - Lifecycle"`), `font_size` (default `11`, finite and positive), `show_parameters` (default `True`), `save_dir` (default `None`), `show` (default `True`). +Parameters: `title` (default `"PyGAD - Lifecycle"`), `font_size` (default `11`, finite and positive), `show_parameters` (default `True`), `save_dir` (default `None`), `show` (default `True`), `transparent` (default `False`). Use `show_parameters=False` for a compact chart that keeps handler names and control flow. Set `show=False` to create or save a chart without displaying it. The method always returns the figure, so it can be customized further. +Charts fit their labels, cards, and connectors with small outer margins. Set `transparent=True` to remove the background for embedding in slides, videos, or web pages; the stage cards keep their fill colors. + ```python # Save a detailed chart. The filename extension selects SVG, PNG, or PDF. fig = ga_instance.plot_lifecycle(title="PyGAD - Scheduling Optimization", diff --git a/pygad/visualize/lifecycle.py b/pygad/visualize/lifecycle.py index 75768b5b..9b3051d7 100644 --- a/pygad/visualize/lifecycle.py +++ b/pygad/visualize/lifecycle.py @@ -400,4 +400,19 @@ def wrap_text(text, width_inches, text_font_size, weight="normal"): linespacing=1.2, parse_math=False) configuration_top += 0.20 * len(label_lines) + 0.17 * len(value_lines) + 0.17 + # Axes with axis("off") still occupy their full canvas in a tight export. + # Fit the canvas to the actual labels, cards, and connectors instead. + from matplotlib.transforms import Bbox + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + bounds = Bbox.union([artist.get_window_extent(renderer) + for artist in list(axes.texts) + list(axes.patches)]) + data_bounds = bounds.transformed(axes.transData.inverted()) + margin = 0.08 + left, right = data_bounds.xmin - margin, data_bounds.xmax + margin + top, bottom = data_bounds.ymin - margin, data_bounds.ymax + margin + axes.set_xlim(left, right) + axes.set_ylim(bottom, top) + fig.set_size_inches((right - left) * figure_scale, + (bottom - top) * figure_scale) return fig diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index ae6eb394..25b6f4f8 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -31,7 +31,8 @@ def plot_lifecycle(self, font_size=11, show_parameters=True, save_dir=None, - show=True): + show=True, + transparent=False): """ Draw the configured lifecycle, including active operators, callbacks, the generation loop, and stopping conditions. @@ -58,6 +59,9 @@ def plot_lifecycle(self, If True, display the figure. Set to False when saving charts in scripts, notebooks, or reports without showing a window. The figure is returned in either case. + transparent : bool + If True, use a transparent figure background when displaying + or exporting the chart. Stage cards retain their fill colors. Returns ------- @@ -79,8 +83,8 @@ def plot_lifecycle(self, raise TypeError("The font_size parameter must be a positive number.") if not numpy.isfinite(font_size) or font_size <= 0: raise ValueError("The font_size parameter must be finite and greater than 0.") - if not isinstance(show_parameters, bool) or not isinstance(show, bool): - raise TypeError("The show_parameters and show parameters must be bool values.") + if not all(isinstance(value, bool) for value in (show_parameters, show, transparent)): + raise TypeError("The show_parameters, show, and transparent parameters must be bool values.") # Keep chart construction separate from rendering so its flow # can be checked without importing matplotlib or running a GA. @@ -93,8 +97,13 @@ def plot_lifecycle(self, "pip install pygad[visualize] (or pip install matplotlib).") from exc fig = _draw_lifecycle(lifecycle, matplt, title, font_size) + if transparent: + fig.patch.set_alpha(0) + for axes in fig.axes: + axes.patch.set_alpha(0) if save_dir is not None: - fig.savefig(fname=save_dir, bbox_inches="tight") + fig.savefig(fname=save_dir, bbox_inches="tight", pad_inches=0.02, + transparent=transparent) if show: matplt.show() return fig diff --git a/tests/test_plot_lifecycle.py b/tests/test_plot_lifecycle.py index a2882a2a..29aa795b 100644 --- a/tests/test_plot_lifecycle.py +++ b/tests/test_plot_lifecycle.py @@ -397,6 +397,44 @@ def test_lifecycle_export_and_display_control(tmp_path, monkeypatch, extension): matplt.close(fig) +@pytest.mark.parametrize("show_parameters", [True, False]) +def test_lifecycle_transparent_export_has_small_outer_margins(tmp_path, show_parameters): + from PIL import Image + + path = tmp_path / "transparent.png" + fig = create_ga_instance().plot_lifecycle(show_parameters=show_parameters, + transparent=True, save_dir=path, show=False) + try: + assert fig.patch.get_alpha() == 0 + with Image.open(path) as image: + alpha = image.convert("RGBA").getchannel("A") + left, top, right, bottom = alpha.getbbox() + assert alpha.getextrema() == (0, 255) + # Default exports use 100 dpi; a content crop leaves about ten + # pixels of safety padding, rather than an unused axes canvas. + assert max(left, top, image.width-right, image.height-bottom) <= 15 + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + for text in fig.axes[0].texts: + bounds = text.get_window_extent(renderer) + assert fig.bbox.contains(*bounds.get_points()[0]), text.get_text() + assert fig.bbox.contains(*bounds.get_points()[1]), text.get_text() + finally: + matplt.close(fig) + + +def test_lifecycle_default_export_keeps_opaque_background(tmp_path): + from PIL import Image + + path = tmp_path / "opaque.png" + fig = create_ga_instance().plot_lifecycle(save_dir=path, show=False) + try: + with Image.open(path) as image: + assert image.convert("RGBA").getchannel("A").getextrema() == (255, 255) + finally: + matplt.close(fig) + + @pytest.mark.parametrize("parameters,error", [ ({"title": None}, TypeError), ({"font_size": "large"}, TypeError), @@ -407,6 +445,7 @@ def test_lifecycle_export_and_display_control(tmp_path, monkeypatch, extension): ({"font_size": numpy.inf}, ValueError), ({"show_parameters": "yes"}, TypeError), ({"show": None}, TypeError), + ({"transparent": "yes"}, TypeError), ]) def test_lifecycle_parameter_validation(parameters, error): with pytest.raises(error): From 23ac02343e68f056348e97ebce9d5c2031a32779 Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 18:44:23 -0400 Subject: [PATCH 20/22] Record completed video revisions and saved music library --- docs/RELEASE_READINESS_3.8.0.md | 47 ++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md index f03832b8..346d4263 100644 --- a/docs/RELEASE_READINESS_3.8.0.md +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -18,19 +18,22 @@ directly so documentation and package versions cannot drift. ## Validation -- Source checkout: 1,871 tests passed, one skipped, on Python 3.11.13 with +- Release preparation baseline: 1,871 tests passed, one skipped, on Python 3.11.13 with NumPy 2.4.4. The skip is the unavailable Windows `fork` process start method. - TensorFlow/Keras and PyTorch tests were excluded locally because those optional frameworks are absent. They are covered by the existing remote matrix on its supported framework versions. -- Release preparation matrix: all Python 3.8 through 3.14 jobs passed at commit - `6f31df251d4faca89571573ffac5a8e53f3ffe1d`, using the changed workflow and +- Latest matrix: all seven Python 3.8 through 3.14 jobs passed at commit + `adde6bfe83f1e8b2ba8a3c8f73205f2dbcab8951`, including the lifecycle export fix, + using the changed workflow and installed 3.8.0 wheels: - https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37972271705 - The following commit only updates this readiness report. -- SDK compatibility: all four minimum/latest SDK jobs on Python 3.11 and 3.12 - passed for that same release preparation commit: - https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37972271741 + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37995352872 +- SDK compatibility: all six minimum/latest/development SDK jobs on Python 3.9 + and 3.12 passed for that same commit: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37995352864 +- The lifecycle fix passed all 41 focused lifecycle tests locally, related + report tests, and a strict Sphinx build with `-n -W --keep-going`. + Transparent PNG tests verify alpha, small margins and unclipped chart text. - Wheel and source distribution built as 3.8.0 and both passed `twine check`. The wheel contains the logo needed by PDF reports and declares its extras. - Installed wheel: 1,871 tests passed, one skipped, in a separate virtual @@ -83,9 +86,12 @@ publication is authorized. Pushing that tag publishes automatically. The previous source project was found at https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at `D:/Projects/PyGADReleaseVideo`. New work is on `codex/pygad-3.8.0-video`. -It reuses the original animation kit, fonts, logo, background music, typing -sounds and transition effects. Only the bell cues are replaced with the -user-approved soft wooden tap. +It reuses the original animation kit, fonts, logo, typing sounds and transition +effects. Bell cues use the user-approved soft wooden tap. The revised background +music is the user-approved A: Warm keys. C: Floating ambient and the original +release music are retained as separate three-minute WAV and MP3 tracks, with +their synthesis code, in `Music_Library_3.8.0/`. +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/Music_Library_3.8.0 Runnable on-screen examples, real output, chapters and draft social posts are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. @@ -93,12 +99,14 @@ The landscape (3840x2160), vertical (2160x3840), and square (2160x2160) videos are complete. The announcement kit was uploaded to `PyGAD_3.8.0/` on that repository's video branch, with MP4 files stored through Git LFS: https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/PyGAD_3.8.0 +The revised video commit is `f31aaad8ff7c9bc7b02fa956b6a010848fc9ee16`, +using the library implementation at `adde6bfe`. All four MP4 objects uploaded successfully and local Git LFS integrity checks passed. A fresh authenticated download of the preview matched its SHA-256 hash. -The three full videos are 127 seconds each, and the preview is 21.5 seconds. +The three full videos are 134.13 seconds each, and the preview is 22.5 seconds. The opening shows the logo and version for 5.5 seconds, down from 9.5 seconds. All four run at 60 fps, -with H.264 video and AAC stereo audio. Every encoded frame decoded successfully; +with H.264 video and 48 kHz AAC stereo audio. Every encoded frame decoded successfully; contact sheets from all nine segments were visually checked in all orientations. Thumbnails, a Reel cover, chapter timestamps, title captions, draft social posts, and machine-readable media validation are included. Social posts remain drafts; @@ -106,8 +114,17 @@ no videos have been published to social platforms. The October 9 revisions move section takeaways into larger, high-contrast callouts above the output and enlarge the history chart in every orientation. -Plots come from 360 dpi exports. Audio checks verify that the original music -and non-bell effects are preserved, including all 417 typing clicks. Encoded +Plots come from 360 dpi exports. All section titles are uppercase. Code typing +is reduced from 60 to 44 characters per second, and the documentation command +from 34 to 30. The permutation demo highlights and prints its initial row. +The lifecycle call is fully highlighted, and the library now exports its chart +with small margins and optional transparency. The video uses that actual export. +The worker demo explains a target sum of 20 and illustrates two real process +workers reused over four generations; a pool/PID audit is included. The +documentation demo uses an actual screenshot and its complete example command. + +Audio checks verify the approved music and preserved non-bell effects, +including all 469 typing clicks. Encoded music, typing transients, tap levels, loudness and peaks are checked in all four videos. Layout and audio measurements are included in the video repository's `PyGAD_3.8.0/layout_review.json` and `audio_validation.json`. From d04174b516bbf21a48d49b73e4209b5a8c8dabfc Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 21:28:46 -0400 Subject: [PATCH 21/22] Finalize PyGAD 3.8.0 notes and verify published release assets --- .github/workflows/main.yml | 2 + .github/workflows/release.yml | 36 +++++++++--- README.md | 5 ++ RELEASING.md | 26 ++++++--- docs/RELEASE_READINESS_3.8.0.md | 37 ++++++------ docs/source/examples.md | 8 ++- docs/source/releases.md | 6 +- tests/test_release_tools.py | 89 +++++++++++++++++++++++++++++ tools/release.py | 99 +++++++++++++++++++++++++++++++++ 9 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 tests/test_release_tools.py create mode 100644 tools/release.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 284c4026..07038cbc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,7 @@ on: - 'setup.py' - '.github/workflows/main.yml' - '.github/workflows/release.yml' + - 'tools/release.py' # Test relevant pull requests, including fork contributions, against master. pull_request: branches: @@ -34,6 +35,7 @@ on: - 'setup.py' - '.github/workflows/main.yml' - '.github/workflows/release.yml' + - 'tools/release.py' # Allows manual triggering of the workflow from the GitHub Actions tab. workflow_dispatch: # Release tags run this same matrix before publishing. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e51282c..64087375 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,8 @@ name: release # On a version tag this builds the package once, publishes it to PyPI via # trusted publishing (no API token stored in the repo), and attaches the built -# wheel and sdist to a GitHub Release for the tag. The PyPI project must list +# wheel and sdist downloaded back from PyPI to a GitHub Release for the tag. +# Both downloads must match the checked build. The PyPI project must list # this repo and workflow as a trusted publisher first. on: @@ -52,10 +53,16 @@ jobs: pip install -r docs/requirements.txt python docs/markdown_compatibility.py python -m sphinx -b html -W --keep-going docs/source docs/build/html + - name: Prepare documented release notes + run: python tools/release.py notes "$GITHUB_REF_NAME" release-notes.md - uses: actions/upload-artifact@v4 with: name: dist path: dist/ + - uses: actions/upload-artifact@v4 + with: + name: release-notes + path: release-notes.md publish: needs: build @@ -77,17 +84,30 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Attach the built files to the GitHub release + - uses: actions/download-artifact@v4 + with: + name: release-notes + - name: Download and verify the published PyPI distributions + run: python tools/release.py fetch-pypi "$GITHUB_REF_NAME" dist published-dist + - name: Publish GitHub release with documented notes and PyPI files env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" dist/* \ - --repo "$GITHUB_REPOSITORY" \ - --title "$GITHUB_REF_NAME" \ - --generate-notes \ - || gh release upload "$GITHUB_REF_NAME" dist/* \ - --repo "$GITHUB_REPOSITORY" --clobber + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" published-dist/* \ + --repo "$GITHUB_REPOSITORY" --clobber + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --title "PyGAD $GITHUB_REF_NAME" --notes-file release-notes.md --draft=false --latest + else + gh release create "$GITHUB_REF_NAME" published-dist/* \ + --repo "$GITHUB_REPOSITORY" --verify-tag --latest \ + --title "PyGAD $GITHUB_REF_NAME" --notes-file release-notes.md + fi diff --git a/README.md b/README.md index ef7a0a76..feeebc0a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ The library is under active development and more features are added regularly. I # Installation +The current release is [PyGAD 3.8.0](https://github.com/ahmedfgad/GeneticAlgorithmPython/releases/tag/3.8.0), dated October 9, 2026. Read the [release notes](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/3.8.0/docs/source/releases.md#pygad-380) for its new features, fixes, and compatibility changes. PyGAD requires Python 3.8 or newer. + To install [PyGAD](https://pypi.org/project/pygad), use pip to download and install the library from [PyPI](https://pypi.org/project/pygad) (Python Package Index). The library is available on PyPI at this page: https://pypi.org/project/pygad. Install PyGAD with the following command: @@ -46,6 +48,9 @@ pip install pygad[visualize] # Training Keras/PyTorch models (pygad.kerasga, pygad.torchga): pip install pygad[deep_learning] + +# PDF reports need ReportLab and matplotlib: +pip install pygad[report] ``` To get started with PyGAD, read the documentation at [Read the Docs](https://pygad.readthedocs.io). diff --git a/RELEASING.md b/RELEASING.md index 50c38c19..223ca194 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,14 +1,16 @@ # Releasing Releases are automated. Pushing a version tag builds the package, publishes it to -PyPI, and attaches the built files to a GitHub Release. Nothing is uploaded by -hand. +PyPI, and downloads the published wheel and source distribution for the GitHub +Release. Downloaded files must match the checked build's SHA-256 hashes. +The GitHub Release uses the release notes from `docs/source/releases.md`. ## Steps 1. Bump the version in `pygad/_version.py`. This is the only place the version lives. -2. Update the release notes in the docs if you keep them there. +2. Update the matching `PyGAD <version>` section in `docs/source/releases.md`. + Set `Release Date: Month D, YYYY.` and remove pending-publication text. 3. Commit and push: ```bash git add pygad/_version.py docs/source/releases.md @@ -26,16 +28,26 @@ hand. python -m build python -m twine check dist/* ``` -5. Tag the release and push the tag: +5. Create or update a pull request from `github-actions` to `master`, using the + documented release notes as its description. Generate the description with: ```bash + python tools/release.py notes 3.8.0 docs/build/release-notes-3.8.0.md + ``` + Wait for its checks and merge it. Tag the merged `master` commit and push the tag: + ```bash + git switch master + git pull --ff-only origin master git tag 3.8.0 git push origin 3.8.0 ``` The `release` workflow first runs the full Python 3.8 through 3.14 test matrix, -then builds the wheel and sdist, publishes them to PyPI, and creates a GitHub -Release with both files attached after publication succeeds. Follow it -with `gh run watch` or the Actions tab. +then builds the wheel and sdist, checks documentation and release notes, and +publishes the packages to PyPI. It downloads both published files, verifies +their SHA-256 hashes against the build, and creates a GitHub Release with those +files and the documented notes. Documentation links in the PR and release notes +point to the tagged source. Follow the workflow with `gh run watch` or the Actions tab. +Verify the PyPI version and GitHub assets after it succeeds. ## Rules diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md index 346d4263..c1684201 100644 --- a/docs/RELEASE_READINESS_3.8.0.md +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -1,13 +1,14 @@ # PyGAD 3.8.0 release readiness -Reviewed October 9, 2026. All library preparation is pushed to the -`github-actions` branch. Nothing has been tagged, merged or published. -The `master` branch remains at `92e7c7f`. +Prepublication review completed October 9, 2026. This document records the +preparation and validation performed on `github-actions` before its release +pull request to `master`. The publication date in the release notes is +October 9, 2026. ## Version -The latest public release is **3.7.0**, published June 5, 2026. PyPI has no -3.8.0 distribution as of this review. The next release is **3.8.0** because +At the start of this review, the latest public release was **3.7.0**, published +June 5, 2026. The prepared release is **3.8.0** because it adds public features, including `plot_lifecycle()` and generation metadata, alongside fixes and internal refactoring. A patch release would understate those additions. The main `GA` constructor signature is unchanged from 3.7.0; @@ -51,7 +52,9 @@ The release workflow now runs the same full Python matrix before building and publishing. Matrix tests run outside the checkout to import the installed wheel. The workflow rejects mismatches between the tag and package version, checks distributions, and requires a clean documentation build. The GitHub -Release job waits for successful PyPI publication. +Release job waits for successful PyPI publication, then downloads the published +wheel and source distribution and verifies their SHA-256 hashes against the +build. The documented release notes are used for both the PR and GitHub Release. The GitHub `pypi` environment exists. The prior 3.7.0 release completed through the trusted-publishing workflow: @@ -69,36 +72,34 @@ change; the next release run remains the verification of live publishing. actual generation numbers. - Metadata now declares Python 3.8 or newer, matching the minimum tested version. -## Remaining publication steps +## Publication procedure The reviewed tree passed the local prepublication checks. The changed test workflow and SDK compatibility workflow also passed on GitHub for the release -preparation commit. The tag-triggered publishing workflow has been checked -locally; its publishing steps have not been executed for 3.8.0. - -Review the prepared diff. -Set the actual publication date in `docs/source/releases.md` when releasing. -Choose the final release commit, then create the matching 3.8.0 tag only when -publication is authorized. Pushing that tag publishes automatically. +preparation commit. Publication is authorized by the maintainer. The release +notes have the requested October 9, 2026 date. The release PR uses those notes, +and the merged `master` commit receives the matching 3.8.0 tag. Pushing that tag +publishes automatically. The final verification checks PyPI installation and +confirms that both GitHub assets match the published PyPI packages. ## Announcement video The previous source project was found at https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at -`D:/Projects/PyGADReleaseVideo`. New work is on `codex/pygad-3.8.0-video`. +`D:/Projects/PyGADReleaseVideo`. All video work is consolidated on `main`. It reuses the original animation kit, fonts, logo, typing sounds and transition effects. Bell cues use the user-approved soft wooden tap. The revised background music is the user-approved A: Warm keys. C: Floating ambient and the original release music are retained as separate three-minute WAV and MP3 tracks, with their synthesis code, in `Music_Library_3.8.0/`. -https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/Music_Library_3.8.0 +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/main/Music_Library_3.8.0 Runnable on-screen examples, real output, chapters and draft social posts are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. The landscape (3840x2160), vertical (2160x3840), and square (2160x2160) videos are complete. The announcement kit was uploaded to `PyGAD_3.8.0/` -on that repository's video branch, with MP4 files stored through Git LFS: -https://github.com/ahmedfgad/PyGADReleaseVideo/tree/codex/pygad-3.8.0-video/PyGAD_3.8.0 +on that repository's `main` branch, with MP4 files stored through Git LFS: +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/main/PyGAD_3.8.0 The revised video commit is `f31aaad8ff7c9bc7b02fa956b6a010848fc9ee16`, using the library implementation at `adde6bfe`. All four MP4 objects uploaded successfully and local Git LFS integrity checks diff --git a/docs/source/examples.md b/docs/source/examples.md index 9144622d..5403549d 100644 --- a/docs/source/examples.md +++ b/docs/source/examples.md @@ -4,7 +4,13 @@ Find complete Python scripts by topic, open their source on GitHub, or download ## Running the Examples -For examples from this documentation revision, use the matching repository version of PyGAD. This is especially important for features in the Unreleased notes. Clone or download that repository revision, then install it from the repository root: +For examples from this documentation revision, use the matching version of PyGAD. The PyGAD 3.8.0 examples can use the published package: + +```console +python -m pip install "pygad[visualize]==3.8.0" +``` + +For development examples, including features in the Unreleased notes, clone or download the matching repository revision, then install it from the repository root: ```console python -m pip install -e ".[visualize]" diff --git a/docs/source/releases.md b/docs/source/releases.md index ae4467a2..4a7e418d 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -10,9 +10,7 @@ No changes yet. ## PyGAD 3.8.0 -Release Date: pending publication. - -These changes are prepared on the `github-actions` branch. PyGAD 3.8.0 has not been published yet. +Release Date: October 9, 2026. 1. [Two-point crossover](utils.md#two_points_crossover) selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). 2. [Swap mutation](utils.md#swap_mutation) can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). @@ -58,7 +56,7 @@ These changes are prepared on the `github-actions` branch. PyGAD 3.8.0 has not b 34. Documentation guides also render directly on GitHub and in compatible Markdown previews. Internal references use Markdown links; parameter descriptions use expandable details; navigation lists and PNG diagrams remain visible; and Sphinx-only labels, toctrees, and video embeds are hidden from previews. All Python example sections and the Examples index include checked-in Markdown generated from the shared catalog and templates, with script links, requirements, dataset setup, and run instructions. A Python-only command updates these sections, and builds reject stale content or incomplete section comments. Built documentation retains its cards, dropdowns, figures, downloads, navigation, and published anchors. -35. Package metadata declares Python 3.8 or newer, matching the minimum version in the test matrix. Documentation reads the package version from `pygad/_version.py`. Release tags must match that version, and publication requires the Python 3.8 through 3.14 test matrix and a documentation build with warnings treated as errors. The matrix imports the installed wheel from outside the checkout. A GitHub Release is created only after PyPI publication succeeds. +35. Package metadata declares Python 3.8 or newer, matching the minimum version in the test matrix. Documentation reads the package version from `pygad/_version.py`. Release tags must match that version, and publication requires the Python 3.8 through 3.14 test matrix and a documentation build with warnings treated as errors. The matrix imports the installed wheel from outside the checkout. A GitHub Release is created only after PyPI publication succeeds, using the documented release notes and the published PyPI source distribution and wheel after verifying their SHA-256 hashes against the checked build. The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 00000000..e0eaf559 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,89 @@ +"""Publication must use dated notes and exactly the checked PyPI distributions.""" + +import hashlib +import importlib.util +import io +import json +from pathlib import Path + +import pytest + + +spec = importlib.util.spec_from_file_location( + "release_tools", Path(__file__).resolve().parents[1] / "tools/release.py") +release_tools = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_tools) + + +def test_release_notes_are_scoped_and_links_are_portable(tmp_path, monkeypatch): + source = tmp_path / "docs/source" + source.mkdir(parents=True) + (source / "releases.md").write_text( + "## Unreleased\n\nNo changes.\n\n## PyGAD 3.8.0\n\n" + "Release Date: October 9, 2026.\n\n" + "1. [Lifecycle](visualize.md#plot_lifecycle).\n" + "2. [Issue](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/1).\n\n" + "## PyGAD 3.7.0\n\nOld release.\n", encoding="utf-8") + monkeypatch.setattr(release_tools, "ROOT", tmp_path) + notes = release_tools.release_notes("3.8.0") + assert notes.startswith("# PyGAD 3.8.0\n") + assert "October 9, 2026" in notes + assert "/blob/3.8.0/docs/source/visualize.md#plot_lifecycle" in notes + assert "https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/1" in notes + assert "Unreleased" not in notes and "Old release" not in notes + + +def test_release_notes_reject_pending_date(tmp_path, monkeypatch): + source = tmp_path / "docs/source" + source.mkdir(parents=True) + (source / "releases.md").write_text( + "## PyGAD 3.8.0\n\nRelease Date: pending publication.\n", encoding="utf-8") + monkeypatch.setattr(release_tools, "ROOT", tmp_path) + with pytest.raises(ValueError, match="publication date"): + release_tools.release_notes("3.8.0") + + +def published_files(tmp_path, monkeypatch, change=None): + names = ["pygad-3.8.0-py3-none-any.whl", "pygad-3.8.0.tar.gz"] + contents = [b"checked wheel", b"checked sdist"] + files = [] + for name, data, kind in zip(names, contents, ["bdist_wheel", "sdist"]): + (tmp_path / name).write_bytes(data) + files.append({"filename": name, "packagetype": kind, "yanked": False, + "digests": {"sha256": hashlib.sha256(data).hexdigest()}, + "url": "https://files.pythonhosted.org/" + name}) + metadata = {"info": {"version": "3.8.0"}, "urls": files} + if change: + change(metadata) + replies = iter([json.dumps(metadata).encode()] + contents) + monkeypatch.setattr(release_tools, "urlopen", lambda *args, **kwargs: io.BytesIO(next(replies))) + return names, contents + + +def test_downloads_both_verified_pypi_files(tmp_path, monkeypatch): + names, contents = published_files(tmp_path, monkeypatch) + destination = tmp_path / "published" + release_tools.fetch_pypi("3.8.0", tmp_path, destination) + assert [(destination / name).read_bytes() for name in names] == contents + + +@pytest.mark.parametrize("change, message", [ + (lambda data: data["urls"][0]["digests"].update(sha256="bad"), "differs"), + (lambda data: data["urls"][0].update(yanked=True), "Unexpected published"), + (lambda data: data["urls"][0].update(url="https://example.com/file"), "download host"), + (lambda data: data["info"].update(version="3.7.0"), "different package version"), +]) +def test_rejects_incorrect_pypi_files(tmp_path, monkeypatch, change, message): + published_files(tmp_path, monkeypatch, change) + with pytest.raises(ValueError, match=message): + release_tools.fetch_pypi("3.8.0", tmp_path, tmp_path / "published") + + +def test_rejects_corrupted_download(tmp_path, monkeypatch): + names, contents = published_files(tmp_path, monkeypatch) + first_open = release_tools.urlopen + metadata = first_open().read() + replies = iter([metadata, b"corrupted download"]) + monkeypatch.setattr(release_tools, "urlopen", lambda *args, **kwargs: io.BytesIO(next(replies))) + with pytest.raises(ValueError, match="failed verification"): + release_tools.fetch_pypi("3.8.0", tmp_path, tmp_path / "published") diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 00000000..0ba8790e --- /dev/null +++ b/tools/release.py @@ -0,0 +1,99 @@ +"""Prepare documented release notes and verified copies of PyPI distributions.""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import urlopen + + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "https://github.com/ahmedfgad/GeneticAlgorithmPython" + + +def release_notes(version): + """Extract one published release and make its documentation links portable.""" + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("Use a bare release version, such as 3.8.0.") + source = (ROOT / "docs/source/releases.md").read_text(encoding="utf-8") + heading = "## PyGAD " + version + sections = re.split(r"(?m)^## ", source) + matches = [section for section in sections if section.startswith("PyGAD " + version + "\n")] + if len(matches) != 1: + raise ValueError("Expected exactly one release-notes section for " + version) + notes = "## " + matches[0].strip() + if not re.search(r"(?m)^Release Date: [A-Z][a-z]+ [0-9]{1,2}, [0-9]{4}\.$", notes): + raise ValueError("Set a publication date before creating release notes.") + if "pending publication" in notes or "has not been published" in notes: + raise ValueError("Remove pending-publication text before releasing.") + base = REPOSITORY + "/blob/" + version + "/docs/source/" + notes = re.sub(r"\]\(([^)]+)\)", lambda match: "](" + urljoin(base, match[1]) + ")", notes) + return notes.replace(heading, "# PyGAD " + version, 1) + "\n" + + +def fetch_pypi(version, built_directory, output_directory): + """Download the published wheel and sdist only when their build hashes match.""" + expected = { + "pygad-" + version + "-py3-none-any.whl": "bdist_wheel", + "pygad-" + version + ".tar.gz": "sdist", + } + built_hashes = { + name: hashlib.sha256((built_directory / name).read_bytes()).hexdigest() + for name in expected + } + for attempt in range(30): + try: + with urlopen("https://pypi.org/pypi/pygad/" + version + "/json", timeout=30) as response: + metadata = json.load(response) + if metadata["info"]["version"] != version: + raise ValueError("PyPI returned a different package version.") + files = {item["filename"]: item for item in metadata["urls"]} + if set(files) == set(expected): + break + except (HTTPError, URLError) as error: + if isinstance(error, HTTPError) and error.code != 404: + raise + if attempt == 29: + raise RuntimeError("Both published distributions are not available on PyPI.") + time.sleep(10) + output_directory.mkdir(parents=True, exist_ok=True) + for name, package_type in expected.items(): + item = files[name] + if item["yanked"] or item["packagetype"] != package_type: + raise ValueError("Unexpected published distribution: " + name) + if item["digests"]["sha256"] != built_hashes[name]: + raise ValueError("PyPI distribution differs from the checked build: " + name) + if not item["url"].startswith("https://files.pythonhosted.org/"): + raise ValueError("Unexpected PyPI download host.") + with urlopen(item["url"], timeout=60) as response: + contents = response.read() + if hashlib.sha256(contents).hexdigest() != built_hashes[name]: + raise ValueError("Downloaded PyPI distribution failed verification: " + name) + (output_directory / name).write_bytes(contents) + print(name + " SHA-256 " + built_hashes[name]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + notes = commands.add_parser("notes") + notes.add_argument("version") + notes.add_argument("output", type=Path) + packages = commands.add_parser("fetch-pypi") + packages.add_argument("version") + packages.add_argument("built_directory", type=Path) + packages.add_argument("output_directory", type=Path) + arguments = parser.parse_args() + if arguments.command == "notes": + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(release_notes(arguments.version), encoding="utf-8") + else: + fetch_pypi(arguments.version, arguments.built_directory, arguments.output_directory) + + +if __name__ == "__main__": + main() From 7e21f58b8f260136a2d8f84db4a2b399452a3d6e Mon Sep 17 00:00:00 2001 From: Ahmed Gad <ahmed.f.gad@gmail.com> Date: Fri, 9 Oct 2026 21:29:55 -0400 Subject: [PATCH 22/22] Include release tooling and notes in the source distribution --- .github/workflows/main.yml | 2 ++ MANIFEST.in | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 MANIFEST.in diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 07038cbc..034a8754 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,7 @@ on: - 'requirements.txt' - 'pyproject.toml' - 'setup.py' + - 'MANIFEST.in' - '.github/workflows/main.yml' - '.github/workflows/release.yml' - 'tools/release.py' @@ -33,6 +34,7 @@ on: - 'requirements.txt' - 'pyproject.toml' - 'setup.py' + - 'MANIFEST.in' - '.github/workflows/main.yml' - '.github/workflows/release.yml' - 'tools/release.py' diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b52a4c61 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include tools/release.py +include docs/source/releases.md