From 013aea43347afabe20bb4dda7d827ec1b4594d0a Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Wed, 2 Jul 2025 18:00:02 +0000 Subject: [PATCH 01/32] initial pygad optimizer --- pyproject.toml | 1 + src/optimagic/algorithms.py | 33 +++ src/optimagic/config.py | 8 + src/optimagic/optimizers/pygad_optimizer.py | 210 ++++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 src/optimagic/optimizers/pygad_optimizer.py diff --git a/pyproject.toml b/pyproject.toml index ce6707e6e..46c9eea56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -381,5 +381,6 @@ module = [ "pdbp", "iminuit", "nevergrad", + "pygad", ] ignore_missing_imports = true diff --git a/src/optimagic/algorithms.py b/src/optimagic/algorithms.py index 588514e95..69a1f2745 100644 --- a/src/optimagic/algorithms.py +++ b/src/optimagic/algorithms.py @@ -38,6 +38,7 @@ NloptVAR, ) from optimagic.optimizers.pounders import Pounders +from optimagic.optimizers.pygad_optimizer import Pygad from optimagic.optimizers.pygmo_optimizers import ( PygmoBeeColony, PygmoCmaes, @@ -173,6 +174,7 @@ def Scalar( @dataclass(frozen=True) class BoundedGlobalGradientFreeParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -371,6 +373,7 @@ class BoundedGlobalGradientFreeScalarAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -407,6 +410,7 @@ def Parallel(self) -> BoundedGlobalGradientFreeParallelScalarAlgorithms: @dataclass(frozen=True) class BoundedGlobalGradientFreeParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -463,6 +467,7 @@ def Scalar(self) -> GlobalGradientFreeNonlinearConstrainedParallelScalarAlgorith @dataclass(frozen=True) class GlobalGradientFreeParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -611,6 +616,7 @@ def Scalar(self) -> BoundedGradientFreeNonlinearConstrainedParallelScalarAlgorit @dataclass(frozen=True) class BoundedGradientFreeParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -706,6 +712,7 @@ def Scalar(self) -> BoundedGlobalNonlinearConstrainedParallelScalarAlgorithms: @dataclass(frozen=True) class BoundedGlobalParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1037,6 +1044,7 @@ class BoundedGlobalGradientFreeAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -1101,6 +1109,7 @@ class GlobalGradientFreeScalarAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -1141,6 +1150,7 @@ def Parallel(self) -> GlobalGradientFreeParallelScalarAlgorithms: @dataclass(frozen=True) class GlobalGradientFreeParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1316,6 +1326,7 @@ class BoundedGradientFreeScalarAlgorithms(AlgoSelection): nlopt_newuoa: Type[NloptNEWUOA] = NloptNEWUOA nlopt_neldermead: Type[NloptNelderMead] = NloptNelderMead nlopt_sbplx: Type[NloptSbplx] = NloptSbplx + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -1380,6 +1391,7 @@ def Parallel(self) -> BoundedGradientFreeLeastSquaresParallelAlgorithms: class BoundedGradientFreeParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1461,6 +1473,7 @@ def Scalar(self) -> GradientFreeNonlinearConstrainedParallelScalarAlgorithms: class GradientFreeParallelScalarAlgorithms(AlgoSelection): neldermead_parallel: Type[NelderMeadParallel] = NelderMeadParallel nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1534,6 +1547,7 @@ class BoundedGlobalScalarAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -1579,6 +1593,7 @@ def Parallel(self) -> BoundedGlobalParallelScalarAlgorithms: @dataclass(frozen=True) class BoundedGlobalParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1648,6 +1663,7 @@ def Scalar(self) -> GlobalNonlinearConstrainedParallelScalarAlgorithms: @dataclass(frozen=True) class GlobalParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -1883,6 +1899,7 @@ def Scalar(self) -> BoundedNonlinearConstrainedParallelScalarAlgorithms: @dataclass(frozen=True) class BoundedParallelScalarAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -2146,6 +2163,7 @@ class GlobalGradientFreeAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2240,6 +2258,7 @@ class BoundedGradientFreeAlgorithms(AlgoSelection): nlopt_neldermead: Type[NloptNelderMead] = NloptNelderMead nlopt_sbplx: Type[NloptSbplx] = NloptSbplx pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2337,6 +2356,7 @@ class GradientFreeScalarAlgorithms(AlgoSelection): nlopt_neldermead: Type[NloptNelderMead] = NloptNelderMead nlopt_praxis: Type[NloptPRAXIS] = NloptPRAXIS nlopt_sbplx: Type[NloptSbplx] = NloptSbplx + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2409,6 +2429,7 @@ class GradientFreeParallelAlgorithms(AlgoSelection): neldermead_parallel: Type[NelderMeadParallel] = NelderMeadParallel nevergrad_pso: Type[NevergradPSO] = NevergradPSO pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -2452,6 +2473,7 @@ class BoundedGlobalAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2534,6 +2556,7 @@ class GlobalScalarAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2583,6 +2606,7 @@ def Parallel(self) -> GlobalParallelScalarAlgorithms: @dataclass(frozen=True) class GlobalParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -2863,6 +2887,7 @@ class BoundedScalarAlgorithms(AlgoSelection): nlopt_sbplx: Type[NloptSbplx] = NloptSbplx nlopt_tnewton: Type[NloptTNewton] = NloptTNewton nlopt_var: Type[NloptVAR] = NloptVAR + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -2950,6 +2975,7 @@ def Parallel(self) -> BoundedLeastSquaresParallelAlgorithms: class BoundedParallelAlgorithms(AlgoSelection): nevergrad_pso: Type[NevergradPSO] = NevergradPSO pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -3051,6 +3077,7 @@ def Scalar(self) -> NonlinearConstrainedParallelScalarAlgorithms: class ParallelScalarAlgorithms(AlgoSelection): neldermead_parallel: Type[NelderMeadParallel] = NelderMeadParallel nevergrad_pso: Type[NevergradPSO] = NevergradPSO + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -3170,6 +3197,7 @@ class GradientFreeAlgorithms(AlgoSelection): nlopt_praxis: Type[NloptPRAXIS] = NloptPRAXIS nlopt_sbplx: Type[NloptSbplx] = NloptSbplx pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -3234,6 +3262,7 @@ class GlobalAlgorithms(AlgoSelection): nlopt_direct: Type[NloptDirect] = NloptDirect nlopt_esch: Type[NloptESCH] = NloptESCH nlopt_isres: Type[NloptISRES] = NloptISRES + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -3380,6 +3409,7 @@ class BoundedAlgorithms(AlgoSelection): nlopt_tnewton: Type[NloptTNewton] = NloptTNewton nlopt_var: Type[NloptVAR] = NloptVAR pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -3517,6 +3547,7 @@ class ScalarAlgorithms(AlgoSelection): nlopt_sbplx: Type[NloptSbplx] = NloptSbplx nlopt_tnewton: Type[NloptTNewton] = NloptTNewton nlopt_var: Type[NloptVAR] = NloptVAR + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch @@ -3631,6 +3662,7 @@ class ParallelAlgorithms(AlgoSelection): neldermead_parallel: Type[NelderMeadParallel] = NelderMeadParallel nevergrad_pso: Type[NevergradPSO] = NevergradPSO pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -3696,6 +3728,7 @@ class Algorithms(AlgoSelection): nlopt_tnewton: Type[NloptTNewton] = NloptTNewton nlopt_var: Type[NloptVAR] = NloptVAR pounders: Type[Pounders] = Pounders + pygad: Type[Pygad] = Pygad pygmo_bee_colony: Type[PygmoBeeColony] = PygmoBeeColony pygmo_cmaes: Type[PygmoCmaes] = PygmoCmaes pygmo_compass_search: Type[PygmoCompassSearch] = PygmoCompassSearch diff --git a/src/optimagic/config.py b/src/optimagic/config.py index 643a6f663..13ce4fee9 100644 --- a/src/optimagic/config.py +++ b/src/optimagic/config.py @@ -108,6 +108,14 @@ IS_NEVERGRAD_INSTALLED = True +try: + import pygad # noqa: F401 +except ImportError: + IS_PYGAD_INSTALLED = False +else: + IS_PYGAD_INSTALLED = True + + # ====================================================================================== # Check if pandas version is newer or equal to version 2.1.0 # ====================================================================================== diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py new file mode 100644 index 000000000..dc1a6fbfd --- /dev/null +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -0,0 +1,210 @@ +from dataclasses import dataclass +from typing import Any, Literal, Union + +import numpy as np +from numpy.typing import NDArray + +from optimagic import mark +from optimagic.config import IS_PYGAD_INSTALLED +from optimagic.exceptions import NotInstalledError +from optimagic.optimization.algo_options import get_population_size +from optimagic.optimization.algorithm import Algorithm, InternalOptimizeResult +from optimagic.optimization.internal_optimization_problem import ( + InternalOptimizationProblem, +) +from optimagic.typing import ( + AggregationLevel, + NonNegativeFloat, + PositiveFloat, + PositiveInt, +) + +if IS_PYGAD_INSTALLED: + import pygad + + +@mark.minimizer( + name="pygad", + solver_type=AggregationLevel.SCALAR, + is_available=IS_PYGAD_INSTALLED, + is_global=True, + needs_jac=False, + needs_hess=False, + supports_parallelism=True, + supports_bounds=True, + supports_linear_constraints=False, + supports_nonlinear_constraints=False, + disable_history=False, +) +@dataclass(frozen=True) +class Pygad(Algorithm): + population_size: PositiveInt | None = None + num_parents_mating: PositiveInt = 10 + num_generations: PositiveInt = 100 + + initial_population: NDArray[np.float64] | list[list[float]] | None = None + gene_type: ( + type[int] + | type[float] + | type[np.int8] + | type[np.int16] + | type[np.int32] + | type[np.int64] + | type[np.uint] + | type[np.uint8] + | type[np.uint16] + | type[np.uint32] + | type[np.uint64] + | type[np.float16] + | type[np.float32] + | type[np.float64] + | list[type] + | list[list[type | None]] + ) = float + + parent_selection_type: Literal[ + "sss", "rws", "sus", "rank", "random", "tournament" + ] = "sss" + keep_parents: int = -1 + keep_elitism: PositiveInt = 1 + K_tournament: PositiveInt = 3 + + crossover_type: ( + Literal["single_point", "two_points", "uniform", "scattered"] | None + ) = "single_point" + crossover_probability: NonNegativeFloat | None = None + + mutation_type: ( + Literal["random", "swap", "inversion", "scramble", "adaptive"] | None + ) = "random" + mutation_probability: ( + NonNegativeFloat + | list[NonNegativeFloat] + | tuple[NonNegativeFloat, NonNegativeFloat] + | NDArray[np.float64] + | None + ) = None + mutation_percent_genes: ( + PositiveFloat + | str + | list[PositiveFloat] + | tuple[PositiveFloat, PositiveFloat] + | NDArray[np.float64] + ) = "default" + mutation_num_genes: ( + PositiveInt + | list[PositiveInt] + | tuple[PositiveInt, PositiveInt] + | NDArray[np.int_] + | None + ) = None + mutation_by_replacement: bool = False + random_mutation_min_val: float | list[float] | NDArray[np.float64] = -1.0 + random_mutation_max_val: float | list[float] | NDArray[np.float64] = 1.0 + + allow_duplicate_genes: bool = True + + fitness_batch_size: PositiveInt | None = None + save_best_solutions: bool = False + save_solutions: bool = False + stop_criteria: str | list[str] | None = None + + random_seed: int | None = None + parallel_processing: ( + int + | tuple[Literal["process", "thread"], int | None] + | list[Union[Literal["process", "thread"], int | None]] + | None + ) = None + suppress_warnings: bool = True + + def _solve_internal_problem( + self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] + ) -> InternalOptimizeResult: + if not IS_PYGAD_INSTALLED: + raise NotInstalledError( + "The 'pygad_pygad' algorithm requires the pygad package to be " + "installed. You can install it with 'pip install pygad'." + ) + + if ( + problem.bounds.lower is None + or problem.bounds.upper is None + or not np.isfinite(problem.bounds.lower).all() + or not np.isfinite(problem.bounds.upper).all() + ): + raise ValueError("pygad_pygad requires finite bounds for all parameters.") + + def fitness_func( + ga_instance: Any, solution: NDArray[np.float64], solution_idx: int + ) -> float: + return -float(problem.fun(solution)) + + if self.initial_population is not None: + initial_population = self.initial_population + population_size = len(initial_population) + else: + population_size = get_population_size( + population_size=self.population_size, x=x0 + ) + initial_population = np.random.uniform( + low=problem.bounds.lower, + high=problem.bounds.upper, + size=(population_size, len(x0)), + ) + initial_population[0] = x0 + + gene_space = [ + {"low": problem.bounds.lower[i], "high": problem.bounds.upper[i]} + for i in range(len(x0)) + ] + + ga_instance = pygad.GA( + num_generations=self.num_generations, + num_parents_mating=self.num_parents_mating, + fitness_func=fitness_func, + sol_per_pop=population_size, + num_genes=len(x0), + initial_population=initial_population, + init_range_low=problem.bounds.lower, + init_range_high=problem.bounds.upper, + gene_space=gene_space, + gene_type=self.gene_type, + parent_selection_type=self.parent_selection_type, + keep_parents=self.keep_parents, + keep_elitism=self.keep_elitism, + K_tournament=self.K_tournament, + crossover_type=self.crossover_type, + crossover_probability=self.crossover_probability, + mutation_type=self.mutation_type, + mutation_probability=self.mutation_probability, + mutation_by_replacement=self.mutation_by_replacement, + mutation_percent_genes=self.mutation_percent_genes, + mutation_num_genes=self.mutation_num_genes, + random_mutation_min_val=self.random_mutation_min_val, + random_mutation_max_val=self.random_mutation_max_val, + allow_duplicate_genes=self.allow_duplicate_genes, + save_best_solutions=self.save_best_solutions, + save_solutions=self.save_solutions, + suppress_warnings=self.suppress_warnings, + stop_criteria=self.stop_criteria, + parallel_processing=self.parallel_processing, + random_seed=self.random_seed, + ) + + ga_instance.run() + + solution, solution_fitness, solution_idx = ga_instance.best_solution() + + res = InternalOptimizeResult( + x=solution, + fun=-solution_fitness, + success=True, + message=( + f"Optimization terminated successfully after " + f"{ga_instance.generations_completed} generations." + ), + n_fun_evals=ga_instance.generations_completed * population_size, + ) + + return res From 217a73802ac47a542edf7a6f1bf6b24587716272 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:29:41 +0000 Subject: [PATCH 02/32] add pygad to environment --- .tools/envs/testenv-linux.yml | 1 + .tools/envs/testenv-numpy.yml | 1 + .tools/envs/testenv-others.yml | 1 + .tools/envs/testenv-pandas.yml | 1 + environment.yml | 1 + 5 files changed, 5 insertions(+) diff --git a/.tools/envs/testenv-linux.yml b/.tools/envs/testenv-linux.yml index ec4b969f9..8f15a402c 100644 --- a/.tools/envs/testenv-linux.yml +++ b/.tools/envs/testenv-linux.yml @@ -28,6 +28,7 @@ dependencies: - jinja2 # dev, tests - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests diff --git a/.tools/envs/testenv-numpy.yml b/.tools/envs/testenv-numpy.yml index 9f9fa7d0f..bd0b8710d 100644 --- a/.tools/envs/testenv-numpy.yml +++ b/.tools/envs/testenv-numpy.yml @@ -26,6 +26,7 @@ dependencies: - jinja2 # dev, tests - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests diff --git a/.tools/envs/testenv-others.yml b/.tools/envs/testenv-others.yml index ce9490b7f..982bd14cb 100644 --- a/.tools/envs/testenv-others.yml +++ b/.tools/envs/testenv-others.yml @@ -26,6 +26,7 @@ dependencies: - jinja2 # dev, tests - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests diff --git a/.tools/envs/testenv-pandas.yml b/.tools/envs/testenv-pandas.yml index 7b342240b..da95ae87f 100644 --- a/.tools/envs/testenv-pandas.yml +++ b/.tools/envs/testenv-pandas.yml @@ -26,6 +26,7 @@ dependencies: - jinja2 # dev, tests - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests diff --git a/environment.yml b/environment.yml index 80435b8d7..b733472cc 100644 --- a/environment.yml +++ b/environment.yml @@ -38,6 +38,7 @@ dependencies: - furo # dev, docs - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests From c07ae373eecb3f1b16f950ece750e9e689a0270b Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:24:28 +0000 Subject: [PATCH 03/32] remove unused parameter --- src/optimagic/optimizers/pygad_optimizer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index dc1a6fbfd..ae62652f7 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -164,10 +164,7 @@ def fitness_func( num_parents_mating=self.num_parents_mating, fitness_func=fitness_func, sol_per_pop=population_size, - num_genes=len(x0), initial_population=initial_population, - init_range_low=problem.bounds.lower, - init_range_high=problem.bounds.upper, gene_space=gene_space, gene_type=self.gene_type, parent_selection_type=self.parent_selection_type, From 9861eb8c47b791f56afd080f8559fd4bbd289c81 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:42:48 +0000 Subject: [PATCH 04/32] batch evaluator for fitness function --- src/optimagic/optimizers/pygad_optimizer.py | 24 +++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index ae62652f7..8d2ef54ac 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -135,10 +135,26 @@ def _solve_internal_problem( ): raise ValueError("pygad_pygad requires finite bounds for all parameters.") - def fitness_func( - ga_instance: Any, solution: NDArray[np.float64], solution_idx: int - ) -> float: - return -float(problem.fun(solution)) + if self.fitness_batch_size is not None and self.fitness_batch_size > 1: + + def fitness_function( + _ga_instance: Any, + batch_solutions: NDArray[np.float64], + _batch_indices: list[int] | NDArray[np.int_], + ) -> list[float]: + solution_list = [ + batch_solutions[i] for i in range(batch_solutions.shape[0]) + ] + + batch_results = problem.batch_fun(solution_list, n_cores=1) + + return [-float(result) for result in batch_results] + else: + + def fitness_function( + _ga_instance: Any, solution: NDArray[np.float64], _solution_idx: int + ) -> float: + return -float(problem.fun(solution)) if self.initial_population is not None: initial_population = self.initial_population From 80e6caa57996d0d6044b64d52c1105806bf03f6c Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:44:16 +0000 Subject: [PATCH 05/32] remove save parameters from pygad --- src/optimagic/optimizers/pygad_optimizer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 8d2ef54ac..08e3a819b 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -105,8 +105,6 @@ class Pygad(Algorithm): allow_duplicate_genes: bool = True fitness_batch_size: PositiveInt | None = None - save_best_solutions: bool = False - save_solutions: bool = False stop_criteria: str | list[str] | None = None random_seed: int | None = None @@ -197,8 +195,6 @@ def fitness_function( random_mutation_min_val=self.random_mutation_min_val, random_mutation_max_val=self.random_mutation_max_val, allow_duplicate_genes=self.allow_duplicate_genes, - save_best_solutions=self.save_best_solutions, - save_solutions=self.save_solutions, suppress_warnings=self.suppress_warnings, stop_criteria=self.stop_criteria, parallel_processing=self.parallel_processing, From 1eb2ba8071de8bd38598b606b894c0d8b96c0462 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:59:29 +0000 Subject: [PATCH 06/32] remove gene_type --- src/optimagic/optimizers/pygad_optimizer.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 08e3a819b..a54f0c71f 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -43,24 +43,6 @@ class Pygad(Algorithm): num_generations: PositiveInt = 100 initial_population: NDArray[np.float64] | list[list[float]] | None = None - gene_type: ( - type[int] - | type[float] - | type[np.int8] - | type[np.int16] - | type[np.int32] - | type[np.int64] - | type[np.uint] - | type[np.uint8] - | type[np.uint16] - | type[np.uint32] - | type[np.uint64] - | type[np.float16] - | type[np.float32] - | type[np.float64] - | list[type] - | list[list[type | None]] - ) = float parent_selection_type: Literal[ "sss", "rws", "sus", "rank", "random", "tournament" @@ -176,11 +158,10 @@ def fitness_function( ga_instance = pygad.GA( num_generations=self.num_generations, num_parents_mating=self.num_parents_mating, - fitness_func=fitness_func, + fitness_func=fitness_function, sol_per_pop=population_size, initial_population=initial_population, gene_space=gene_space, - gene_type=self.gene_type, parent_selection_type=self.parent_selection_type, keep_parents=self.keep_parents, keep_elitism=self.keep_elitism, From b70589fa7c797ad5eede8c3ce952a9f6d6990dd7 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:33:21 +0000 Subject: [PATCH 07/32] fix batch processing --- src/optimagic/optimizers/pygad_optimizer.py | 73 +++++++++++++-------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index a54f0c71f..6f935e1dc 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -1,5 +1,6 @@ +import warnings from dataclasses import dataclass -from typing import Any, Literal, Union +from typing import Any, Literal import numpy as np from numpy.typing import NDArray @@ -39,8 +40,8 @@ @dataclass(frozen=True) class Pygad(Algorithm): population_size: PositiveInt | None = None - num_parents_mating: PositiveInt = 10 - num_generations: PositiveInt = 100 + num_parents_mating: PositiveInt | None = None + num_generations: PositiveInt | None = None initial_population: NDArray[np.float64] | list[list[float]] | None = None @@ -89,14 +90,8 @@ class Pygad(Algorithm): fitness_batch_size: PositiveInt | None = None stop_criteria: str | list[str] | None = None + n_cores: PositiveInt = 1 random_seed: int | None = None - parallel_processing: ( - int - | tuple[Literal["process", "thread"], int | None] - | list[Union[Literal["process", "thread"], int | None]] - | None - ) = None - suppress_warnings: bool = True def _solve_internal_problem( self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] @@ -115,39 +110,49 @@ def _solve_internal_problem( ): raise ValueError("pygad_pygad requires finite bounds for all parameters.") - if self.fitness_batch_size is not None and self.fitness_batch_size > 1: + # Determine effective fitness_batch_size for parallel processing + effective_fitness_batch_size = determine_effective_batch_size( + self.fitness_batch_size, self.n_cores + ) + if ( + effective_fitness_batch_size is not None + and effective_fitness_batch_size > 1 + and self.n_cores > 1 + ): def fitness_function( _ga_instance: Any, batch_solutions: NDArray[np.float64], _batch_indices: list[int] | NDArray[np.int_], ) -> list[float]: - solution_list = [ - batch_solutions[i] for i in range(batch_solutions.shape[0]) - ] + solution_list = [solution for solution in batch_solutions] - batch_results = problem.batch_fun(solution_list, n_cores=1) + batch_results = problem.batch_fun(solution_list, n_cores=self.n_cores) return [-float(result) for result in batch_results] else: - def fitness_function( _ga_instance: Any, solution: NDArray[np.float64], _solution_idx: int ) -> float: return -float(problem.fun(solution)) + population_size = get_population_size( + population_size=self.population_size, x=x0, lower_bound=10 + ) + if self.initial_population is not None: - initial_population = self.initial_population + initial_population = np.array(self.initial_population) population_size = len(initial_population) + num_genes = len(initial_population[0]) else: - population_size = get_population_size( - population_size=self.population_size, x=x0 - ) + num_genes = len(x0) + initial_population = np.random.uniform( - low=problem.bounds.lower, - high=problem.bounds.upper, - size=(population_size, len(x0)), + problem.bounds.lower, + problem.bounds.upper, + size=(population_size, num_genes), ) + initial_population[0] = x0 gene_space = [ @@ -159,7 +164,7 @@ def fitness_function( num_generations=self.num_generations, num_parents_mating=self.num_parents_mating, fitness_func=fitness_function, - sol_per_pop=population_size, + fitness_batch_size=effective_fitness_batch_size, initial_population=initial_population, gene_space=gene_space, parent_selection_type=self.parent_selection_type, @@ -176,9 +181,8 @@ def fitness_function( random_mutation_min_val=self.random_mutation_min_val, random_mutation_max_val=self.random_mutation_max_val, allow_duplicate_genes=self.allow_duplicate_genes, - suppress_warnings=self.suppress_warnings, stop_criteria=self.stop_criteria, - parallel_processing=self.parallel_processing, + parallel_processing=None, random_seed=self.random_seed, ) @@ -198,3 +202,20 @@ def fitness_function( ) return res + + +def determine_effective_batch_size( + fitness_batch_size: int | None, n_cores: int +) -> int | None: + if fitness_batch_size is not None: + if fitness_batch_size < n_cores: + warnings.warn( + f"fitness_batch_size ({fitness_batch_size}) is smaller than " + f"n_cores ({n_cores}). This may reduce parallel efficiency. " + f"Consider setting fitness_batch_size >= n_cores." + ) + return fitness_batch_size + elif n_cores > 1: + return n_cores + else: + return None From ac288ca1b4ba7fd4021e9eb44f2cd30552336613 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:09:15 +0000 Subject: [PATCH 08/32] fix: fitness function --- src/optimagic/optimizers/pygad_optimizer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 6f935e1dc..98b7d6ea3 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -120,30 +120,34 @@ def _solve_internal_problem( and effective_fitness_batch_size > 1 and self.n_cores > 1 ): - def fitness_function( + + def _fitness_func_batch( _ga_instance: Any, batch_solutions: NDArray[np.float64], _batch_indices: list[int] | NDArray[np.int_], ) -> list[float]: - solution_list = [solution for solution in batch_solutions] - - batch_results = problem.batch_fun(solution_list, n_cores=self.n_cores) + batch_results = problem.batch_fun( + batch_solutions.tolist(), n_cores=self.n_cores + ) return [-float(result) for result in batch_results] + + fitness_function: Any = _fitness_func_batch else: - def fitness_function( + + def _fitness_func_single( _ga_instance: Any, solution: NDArray[np.float64], _solution_idx: int ) -> float: return -float(problem.fun(solution)) + fitness_function = _fitness_func_single + population_size = get_population_size( population_size=self.population_size, x=x0, lower_bound=10 ) if self.initial_population is not None: initial_population = np.array(self.initial_population) - population_size = len(initial_population) - num_genes = len(initial_population[0]) else: num_genes = len(x0) From bccb162adaba277531e1129ab1f34d277500ba32 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:29:20 +0000 Subject: [PATCH 09/32] add protocol for user-defined functions --- src/optimagic/typing.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/optimagic/typing.py b/src/optimagic/typing.py index 2b400ecd9..b0e11568d 100644 --- a/src/optimagic/typing.py +++ b/src/optimagic/typing.py @@ -156,3 +156,24 @@ class MultiStartIterationHistory(TupleLikeAccess): history: IterationHistory local_histories: list[IterationHistory] | None = None exploration: IterationHistory | None = None + + +class ParentSelectionFunction(Protocol): + def __call__( + self, fitness: NDArray[np.float64], num_parents: int, ga_instance: Any + ) -> tuple[NDArray[np.float64], NDArray[np.int_]]: ... + + +class CrossoverFunction(Protocol): + def __call__( + self, + parents: NDArray[np.float64], + offspring_size: tuple[int, int], + ga_instance: Any, + ) -> NDArray[np.float64]: ... + + +class MutationFunction(Protocol): + def __call__( + self, offspring: NDArray[np.float64], ga_instance: Any + ) -> NDArray[np.float64]: ... From abee7231e81749ab14ea58a5de8503aafd5ab041 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 00:20:16 +0000 Subject: [PATCH 10/32] add user-defined GA operator Protocol types --- src/optimagic/optimizers/pygad_optimizer.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 98b7d6ea3..9973a281f 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -15,7 +15,10 @@ ) from optimagic.typing import ( AggregationLevel, + CrossoverFunction, + MutationFunction, NonNegativeFloat, + ParentSelectionFunction, PositiveFloat, PositiveInt, ) @@ -45,20 +48,25 @@ class Pygad(Algorithm): initial_population: NDArray[np.float64] | list[list[float]] | None = None - parent_selection_type: Literal[ - "sss", "rws", "sus", "rank", "random", "tournament" - ] = "sss" + parent_selection_type: ( + Literal["sss", "rws", "sus", "rank", "random", "tournament"] + | ParentSelectionFunction + ) = "sss" keep_parents: int = -1 keep_elitism: PositiveInt = 1 K_tournament: PositiveInt = 3 crossover_type: ( - Literal["single_point", "two_points", "uniform", "scattered"] | None + Literal["single_point", "two_points", "uniform", "scattered"] + | CrossoverFunction + | None ) = "single_point" crossover_probability: NonNegativeFloat | None = None mutation_type: ( - Literal["random", "swap", "inversion", "scramble", "adaptive"] | None + Literal["random", "swap", "inversion", "scramble", "adaptive"] + | MutationFunction + | None ) = "random" mutation_probability: ( NonNegativeFloat From 2d1ec8f5895f791492dafb4bd4dd4dc9b6d9d369 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 00:52:23 +0000 Subject: [PATCH 11/32] add docstring --- src/optimagic/optimizers/pygad_optimizer.py | 18 ++++++++++ src/optimagic/typing.py | 38 +++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 9973a281f..42140b6b1 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -219,6 +219,24 @@ def _fitness_func_single( def determine_effective_batch_size( fitness_batch_size: int | None, n_cores: int ) -> int | None: + """Determine the effective fitness_batch_size for parallel processing. + + Behavior: + - If `fitness_batch_size` is explicitly provided: + - The value is returned unchanged. + - A warning is issued if it is less than `n_cores`, as this may + underutilize available cores. + - If `fitness_batch_size` is `None`: + - If `n_cores` > 1, defaults to `n_cores`. + - Otherwise, returns None (i.e., single-threaded evaluation). + Args: + fitness_batch_size: User-specified batch size or None + n_cores: Number of cores for parallel processing + + Returns: + Effective batch size for PyGAD, or None for single-threaded processing + + """ if fitness_batch_size is not None: if fitness_batch_size < n_cores: warnings.warn( diff --git a/src/optimagic/typing.py b/src/optimagic/typing.py index b0e11568d..2a20f65af 100644 --- a/src/optimagic/typing.py +++ b/src/optimagic/typing.py @@ -159,12 +159,39 @@ class MultiStartIterationHistory(TupleLikeAccess): class ParentSelectionFunction(Protocol): + """Protocol for user-defined parent selection functions. + + Args: + fitness: Array of fitness values for all solutions in the population. + num_parents: Number of parents to select. + ga_instance: The PyGAD GA instance. + + Returns: + Tuple of (selected_parents, parent_indices) where: + - selected_parents: 2D array of selected parent solutions + - parent_indices: 1D array of indices of selected parents + + """ + def __call__( self, fitness: NDArray[np.float64], num_parents: int, ga_instance: Any ) -> tuple[NDArray[np.float64], NDArray[np.int_]]: ... class CrossoverFunction(Protocol): + """Protocol for user-defined crossover functions. + + Args: + parents: 2D array of parent solutions selected for mating. + offspring_size: Tuple (num_offspring, num_genes) specifying + offspring size. + ga_instance: The PyGAD GA instance. + + Returns: + 2D array of offspring solutions. + + """ + def __call__( self, parents: NDArray[np.float64], @@ -174,6 +201,17 @@ def __call__( class MutationFunction(Protocol): + """Protocol for user-defined mutation functions. + + Args: + offspring: 2D array of offspring solutions to be mutated. + ga_instance: The PyGAD GA instance. + + Returns: + 2D array of mutated offspring solutions. + + """ + def __call__( self, offspring: NDArray[np.float64], ga_instance: Any ) -> NDArray[np.float64]: ... From 2e28118a4bc61a6e82704a2cad80cf6fac577f53 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:31:23 +0000 Subject: [PATCH 12/32] fix: make tests pass --- src/optimagic/optimizers/pygad_optimizer.py | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 42140b6b1..3fba6a039 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -1,6 +1,6 @@ import warnings from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, List, Literal import numpy as np from numpy.typing import NDArray @@ -43,8 +43,8 @@ @dataclass(frozen=True) class Pygad(Algorithm): population_size: PositiveInt | None = None - num_parents_mating: PositiveInt | None = None - num_generations: PositiveInt | None = None + num_parents_mating: PositiveInt | None = 10 + num_generations: PositiveInt | None = 50 initial_population: NDArray[np.float64] | list[list[float]] | None = None @@ -134,9 +134,11 @@ def _fitness_func_batch( batch_solutions: NDArray[np.float64], _batch_indices: list[int] | NDArray[np.int_], ) -> list[float]: - batch_results = problem.batch_fun( - batch_solutions.tolist(), n_cores=self.n_cores - ) + solutions_list: List[NDArray[np.float64]] = [ + np.asarray(batch_solutions[i]) + for i in range(batch_solutions.shape[0]) + ] + batch_results = problem.batch_fun(solutions_list, n_cores=self.n_cores) return [-float(result) for result in batch_results] @@ -154,6 +156,12 @@ def _fitness_func_single( population_size=self.population_size, x=x0, lower_bound=10 ) + num_parents_mating = ( + self.num_parents_mating + if self.num_parents_mating is not None + else max(2, population_size // 2) + ) + if self.initial_population is not None: initial_population = np.array(self.initial_population) else: @@ -174,7 +182,7 @@ def _fitness_func_single( ga_instance = pygad.GA( num_generations=self.num_generations, - num_parents_mating=self.num_parents_mating, + num_parents_mating=num_parents_mating, fitness_func=fitness_function, fitness_batch_size=effective_fitness_batch_size, initial_population=initial_population, From 726bd133d95b1e2b67ffb4f24f15bfb727f3cd99 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:46:53 +0000 Subject: [PATCH 13/32] fix: typo in error message --- src/optimagic/optimizers/pygad_optimizer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 3fba6a039..f8dd30448 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -1,6 +1,6 @@ import warnings from dataclasses import dataclass -from typing import Any, List, Literal +from typing import Any, Literal import numpy as np from numpy.typing import NDArray @@ -106,7 +106,7 @@ def _solve_internal_problem( ) -> InternalOptimizeResult: if not IS_PYGAD_INSTALLED: raise NotInstalledError( - "The 'pygad_pygad' algorithm requires the pygad package to be " + "The 'pygad' algorithm requires the pygad package to be " "installed. You can install it with 'pip install pygad'." ) @@ -116,7 +116,7 @@ def _solve_internal_problem( or not np.isfinite(problem.bounds.lower).all() or not np.isfinite(problem.bounds.upper).all() ): - raise ValueError("pygad_pygad requires finite bounds for all parameters.") + raise ValueError("pygad requires finite bounds for all parameters.") # Determine effective fitness_batch_size for parallel processing effective_fitness_batch_size = determine_effective_batch_size( @@ -134,7 +134,7 @@ def _fitness_func_batch( batch_solutions: NDArray[np.float64], _batch_indices: list[int] | NDArray[np.int_], ) -> list[float]: - solutions_list: List[NDArray[np.float64]] = [ + solutions_list: list[NDArray[np.float64]] = [ np.asarray(batch_solutions[i]) for i in range(batch_solutions.shape[0]) ] From 170335634404ca4be531ffd4ef6d7306126cae7e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:52:03 +0000 Subject: [PATCH 14/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .tools/envs/testenv-plotly.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.tools/envs/testenv-plotly.yml b/.tools/envs/testenv-plotly.yml index 27504174b..6f7f47001 100644 --- a/.tools/envs/testenv-plotly.yml +++ b/.tools/envs/testenv-plotly.yml @@ -26,6 +26,7 @@ dependencies: - jinja2 # dev, tests - annotated-types # dev, tests - iminuit # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - nevergrad # dev, tests - DFO-LS>=1.5.3 # dev, tests From 7e1bd67f50c0b9d20727bdeb0279e1334ac64a44 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:00:00 +0000 Subject: [PATCH 15/32] refactor result processing --- src/optimagic/optimizers/pygad_optimizer.py | 42 +++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index f8dd30448..6771ab56e 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -208,18 +208,7 @@ def _fitness_func_single( ga_instance.run() - solution, solution_fitness, solution_idx = ga_instance.best_solution() - - res = InternalOptimizeResult( - x=solution, - fun=-solution_fitness, - success=True, - message=( - f"Optimization terminated successfully after " - f"{ga_instance.generations_completed} generations." - ), - n_fun_evals=ga_instance.generations_completed * population_size, - ) + res = _process_pygad_result(ga_instance) return res @@ -257,3 +246,32 @@ def determine_effective_batch_size( return n_cores else: return None + + +def _process_pygad_result(ga_instance: Any) -> InternalOptimizeResult: + """Process PyGAD result into InternalOptimizeResult. + + Args: + ga_instance: The PyGAD instance after running the optimization + + Returns: + InternalOptimizeResult: Processed optimization results + + """ + best_solution, best_fitness, _ = ga_instance.best_solution() + + best_criterion = -best_fitness + + success = ga_instance.run_completed + if success: + message = f"Optimization terminated successfully after {ga_instance.generations_completed} generations." + else: + message = f"Optimization failed to complete. Only {ga_instance.generations_completed} generations completed." + + return InternalOptimizeResult( + x=best_solution, + fun=best_criterion, + success=success, + message=message, + n_fun_evals=ga_instance.generations_completed * ga_instance.pop_size[0], + ) From cbb72f406d4bf3a6158a5dd9da6e7be34b97e6d1 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:32:52 +0000 Subject: [PATCH 16/32] fix: ruff --- src/optimagic/optimizers/pygad_optimizer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 6771ab56e..030508221 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -262,11 +262,18 @@ def _process_pygad_result(ga_instance: Any) -> InternalOptimizeResult: best_criterion = -best_fitness + completed_generations = ga_instance.generations_completed success = ga_instance.run_completed if success: - message = f"Optimization terminated successfully after {ga_instance.generations_completed} generations." + message = ( + "Optimization terminated successfully.\n" + f"Generations completed: {completed_generations}" + ) else: - message = f"Optimization failed to complete. Only {ga_instance.generations_completed} generations completed." + message = ( + "Optimization failed to complete.\n" + f"Generations completed: {completed_generations}" + ) return InternalOptimizeResult( x=best_solution, From f28ac40805cb76db4d6ed42be7ca84366f17cd1b Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:56:19 +0000 Subject: [PATCH 17/32] add: docs --- docs/source/algorithms.md | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index d9e43a004..dff093e5b 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4043,6 +4043,58 @@ these optimizers, you need to have initialization for speed. Default is False. ``` +## PyGAD Optimizer + +optimagic supports the [PyGAD](https://github.com/ahmedfgad/GeneticAlgorithmPython) +genetic algorithm optimizer. To use PyGAD, you need to have +[the pygad package](https://github.com/ahmedfgad/GeneticAlgorithmPython) installed +(`pip install pygad`). + +```{eval-rst} +.. dropdown:: pygad + + .. code-block:: + + "pygad" + + Minimize a scalar function using the PyGAD genetic algorithm. + + PyGAD is a Python library for building genetic algorithms and training machine learning algorithms. + Genetic algorithms are metaheuristics inspired by the process of natural selection that belong to + the larger class of evolutionary algorithms. + + The algorithm supports the following options: + + - **population_size** (int): Number of solutions in each generation. Default is None. + - **num_parents_mating** (int): Number of parents selected for mating in each generation. Default is None. + - **num_generations** (int): Number of generations. Default is None. + - **initial_population** (array-like): initial population is a 2D array where + each row represents a solution and each column represents a parameter (gene) value. + The number of rows must equal population_size, and the number of columns must + match the length of the initial parameters (x0). + When None, the population is randomly generated within the parameter bounds using + the specified population_size and the dimensionality from x0. + - **parent_selection_type** (str or callable): Method for selecting parents. Can be a string ("sss", "rws", "sus", "rank", "random", "tournament") or a custom function with signature ``parent_selection_func(fitness, num_parents, ga_instance) -> tuple[NDArray, NDArray]``. Default is "sss". + - **keep_parents** (int): Number of best parents to keep in the next generation. Only has effect when keep_elitism is 0. Default is -1. + - **keep_elitism** (int): Number of best solutions to preserve across generations. If non-zero, keep_parents has no effect. Default is 1. + - **K_tournament** (int): Tournament size for tournament selection. Only used when parent_selection_type is "tournament". Default is 3. + - **crossover_type** (str, callable, or None): Crossover method. Can be a string ("single_point", "two_points", "uniform", "scattered"), a custom function with signature ``crossover_func(parents, offspring_size, ga_instance) -> NDArray``, or None to disable crossover. Default is "single_point". + - **crossover_probability** (float): Probability of applying crossover. Range [0, 1]. Default is None. + - **mutation_type** (str, callable, or None): Mutation method. Can be a string ("random", "swap", "inversion", "scramble", "adaptive"), a custom function with signature ``mutation_func(offspring, ga_instance) -> NDArray``, or None to disable mutation. Default is "random". + - **mutation_probability** (float/list/tuple/array): Probability of mutation. Range [0, 1]. If specified, mutation_percent_genes and mutation_num_genes are ignored. Default is None. + - **mutation_percent_genes** (float/str/list/tuple/array): Percentage of genes to mutate. Default is "default" (equivalent to 10%). Ignored if mutation_probability or mutation_num_genes are specified. + - **mutation_num_genes** (int/list/tuple/array): Exact number of genes to mutate. Ignored if mutation_probability is specified. Default is None. + - **mutation_by_replacement** (bool): Whether to replace gene values during mutation. Only works with mutation_type="random". Default is False. + - **random_mutation_min_val** (float/list/array): Minimum value for random mutation. Only used with mutation_type="random". Default is -1.0. + - **random_mutation_max_val** (float/list/array): Maximum value for random mutation. Only used with mutation_type="random". Default is 1.0. + - **allow_duplicate_genes** (bool): Whether to allow duplicate gene values within a solution. Default is True. + - **fitness_batch_size** (int): Number of solutions to evaluate in parallel batches. When None and n_cores > 1, automatically set to n_cores for optimal parallelization. Default is None. + - **stop_criteria** (str/list): Early stopping criteria. Format: "reach_value" or "saturate_N". Default is None. + - **n_cores** (int): Number of cores for parallel fitness evaluation. Default is 1. + - **random_seed** (int): Random seed for reproducibility. Default is None. + +``` + ## References ```{eval-rst} From f0432e17cceff47275758e9631e6593981799285 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:13:55 +0000 Subject: [PATCH 18/32] improve docs --- docs/source/algorithms.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index dff093e5b..b1b2c5371 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4061,7 +4061,12 @@ genetic algorithm optimizer. To use PyGAD, you need to have PyGAD is a Python library for building genetic algorithms and training machine learning algorithms. Genetic algorithms are metaheuristics inspired by the process of natural selection that belong to - the larger class of evolutionary algorithms. + the larger class of evolutionary algorithms. These algorithms apply biologically inspired + operators such as mutation, crossover, and selection to optimization problems. + + The algorithm maintains a population of candidate solutions and iteratively improves them + through genetic operations, making it ideal for global optimization problems with complex + search spaces that may contain multiple local optima. The algorithm supports the following options: From a260d317ac7db910cd0cc0b75fe17c3f5e86d58e Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:10:16 +0000 Subject: [PATCH 19/32] add needs_bounds=True and supports_infinite_bounds=False to pygad-optimizer --- .tools/envs/testenv-nevergrad.yml | 1 + src/optimagic/optimizers/pygad_optimizer.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.tools/envs/testenv-nevergrad.yml b/.tools/envs/testenv-nevergrad.yml index 874b9fa5e..bc62ac649 100644 --- a/.tools/envs/testenv-nevergrad.yml +++ b/.tools/envs/testenv-nevergrad.yml @@ -27,6 +27,7 @@ dependencies: - annotated-types # dev, tests - iminuit # dev, tests - cma # dev, tests + - pygad # dev, tests - pip: # dev, tests, docs - DFO-LS>=1.5.3 # dev, tests - Py-BOBYQA # dev, tests diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 030508221..ec56923fb 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -34,8 +34,10 @@ is_global=True, needs_jac=False, needs_hess=False, + needs_bounds=True, supports_parallelism=True, supports_bounds=True, + supports_infinite_bounds=False, supports_linear_constraints=False, supports_nonlinear_constraints=False, disable_history=False, From 47e09455e045520948fb1eaf37a543108c9457c1 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Sat, 26 Jul 2025 18:26:33 +0000 Subject: [PATCH 20/32] add: gene_constraint and sample_size --- docs/source/algorithms.md | 2 ++ src/optimagic/optimizers/pygad_optimizer.py | 6 ++++++ src/optimagic/typing.py | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index 6885eb035..e4b562b6f 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4771,6 +4771,8 @@ genetic algorithm optimizer. To use PyGAD, you need to have - **random_mutation_min_val** (float/list/array): Minimum value for random mutation. Only used with mutation_type="random". Default is -1.0. - **random_mutation_max_val** (float/list/array): Maximum value for random mutation. Only used with mutation_type="random". Default is 1.0. - **allow_duplicate_genes** (bool): Whether to allow duplicate gene values within a solution. Default is True. + - **gene_constraint** (list of callables or None): List of constraint functions for gene values. Each function takes (solution, values) and returns filtered values meeting constraints. Functions should have signature ``constraint_func(solution, values) -> list[float] | NDArray``. Use None for genes without constraints. Default is None. + - **sample_size** (int): Number of values to sample when finding unique values or enforcing gene constraints. Used when allow_duplicate_genes=False or when gene_constraint is specified. Default is 100. - **fitness_batch_size** (int): Number of solutions to evaluate in parallel batches. When None and n_cores > 1, automatically set to n_cores for optimal parallelization. Default is None. - **stop_criteria** (str/list): Early stopping criteria. Format: "reach_value" or "saturate_N". Default is None. - **n_cores** (int): Number of cores for parallel fitness evaluation. Default is 1. diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index ec56923fb..d7c456d38 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -16,6 +16,7 @@ from optimagic.typing import ( AggregationLevel, CrossoverFunction, + GeneConstraintFunction, MutationFunction, NonNegativeFloat, ParentSelectionFunction, @@ -97,6 +98,9 @@ class Pygad(Algorithm): allow_duplicate_genes: bool = True + gene_constraint: list[GeneConstraintFunction | None] | None = None + sample_size: PositiveInt = 100 + fitness_batch_size: PositiveInt | None = None stop_criteria: str | list[str] | None = None @@ -203,6 +207,8 @@ def _fitness_func_single( random_mutation_min_val=self.random_mutation_min_val, random_mutation_max_val=self.random_mutation_max_val, allow_duplicate_genes=self.allow_duplicate_genes, + gene_constraint=self.gene_constraint, + sample_size=self.sample_size, stop_criteria=self.stop_criteria, parallel_processing=None, random_seed=self.random_seed, diff --git a/src/optimagic/typing.py b/src/optimagic/typing.py index b509515fc..7e3fdd9e8 100644 --- a/src/optimagic/typing.py +++ b/src/optimagic/typing.py @@ -216,3 +216,11 @@ class MutationFunction(Protocol): def __call__( self, offspring: NDArray[np.float64], ga_instance: Any ) -> NDArray[np.float64]: ... + + +class GeneConstraintFunction(Protocol): + """Protocol for user-defined gene constraint functions.""" + + def __call__( + self, solution: NDArray[np.float64], values: list[float] | NDArray[np.float64] + ) -> list[float] | NDArray[np.float64]: ... From f3113bf9234a608f5f6fceab9499bd522f3c3345 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Sat, 26 Jul 2025 19:34:20 +0000 Subject: [PATCH 21/32] tests: add unit tests for pygad optimizer --- .../optimizers/test_pygad_optimizer.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/optimagic/optimizers/test_pygad_optimizer.py diff --git a/tests/optimagic/optimizers/test_pygad_optimizer.py b/tests/optimagic/optimizers/test_pygad_optimizer.py new file mode 100644 index 000000000..afca401bd --- /dev/null +++ b/tests/optimagic/optimizers/test_pygad_optimizer.py @@ -0,0 +1,58 @@ +"""Test helper functions for PyGAD optimizer.""" + +import warnings + +import pytest + +from optimagic.optimizers.pygad_optimizer import determine_effective_batch_size + + +@pytest.mark.parametrize( + "fitness_batch_size, n_cores, expected", + [ + (None, 1, None), + (None, 4, 4), + (10, 4, 10), + (4, 4, 4), + (2, 4, 2), + (5, 1, 5), + (0, 4, 0), + (None, 100, 100), + (1, 1, 1), + ], +) +def test_determine_effective_batch_size_return_values( + fitness_batch_size, n_cores, expected +): + result = determine_effective_batch_size(fitness_batch_size, n_cores) + assert result == expected + + +@pytest.mark.parametrize( + "fitness_batch_size, n_cores, should_warn", + [ + (2, 4, True), + (1, 8, True), + (0, 4, True), + (4, 4, False), + (8, 4, False), + (None, 4, False), + (5, 1, False), + (None, 1, False), + ], +) +def test_determine_effective_batch_size_warnings( + fitness_batch_size, n_cores, should_warn +): + if should_warn: + warning_pattern = ( + f"fitness_batch_size \\({fitness_batch_size}\\) is smaller than " + f"n_cores \\({n_cores}\\)" + ) + with pytest.warns(UserWarning, match=warning_pattern): + result = determine_effective_batch_size(fitness_batch_size, n_cores) + assert result == fitness_batch_size + else: + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = determine_effective_batch_size(fitness_batch_size, n_cores) From 403c80d5b1c68b59606888e25c0e8336120c7a6b Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:40:04 +0000 Subject: [PATCH 22/32] Refactor pygad optimizer --- src/optimagic/config.py | 9 +- src/optimagic/optimizers/pygad_optimizer.py | 154 +++++++++++++++----- src/optimagic/typing.py | 69 +-------- 3 files changed, 118 insertions(+), 114 deletions(-) diff --git a/src/optimagic/config.py b/src/optimagic/config.py index 186cc0886..fbd938fb8 100644 --- a/src/optimagic/config.py +++ b/src/optimagic/config.py @@ -39,14 +39,7 @@ def _is_installed(module_name: str) -> bool: IS_IMINUIT_INSTALLED = _is_installed("iminuit") IS_NEVERGRAD_INSTALLED = _is_installed("nevergrad") IS_BAYESOPT_INSTALLED = _is_installed("bayes_opt") - - -try: - import pygad # noqa: F401 -except ImportError: - IS_PYGAD_INSTALLED = False -else: - IS_PYGAD_INSTALLED = True +IS_PYGAD_INSTALLED = _is_installed("pygad") # ====================================================================================== diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index d7c456d38..2627198dc 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -1,6 +1,6 @@ import warnings from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, Protocol import numpy as np from numpy.typing import NDArray @@ -15,17 +15,79 @@ ) from optimagic.typing import ( AggregationLevel, - CrossoverFunction, - GeneConstraintFunction, - MutationFunction, NonNegativeFloat, - ParentSelectionFunction, PositiveFloat, PositiveInt, + ProbabilityFloat, + PyTree, ) -if IS_PYGAD_INSTALLED: - import pygad + +class ParentSelectionFunction(Protocol): + """Protocol for user-defined parent selection functions. + + Args: + fitness: Array of fitness values for all solutions in the population. + num_parents: Number of parents to select. + ga_instance: The PyGAD GA instance. + + Returns: + Tuple of (selected_parents, parent_indices) where: + - selected_parents: 2D array of selected parent solutions + - parent_indices: 1D array of indices of selected parents + + """ + + def __call__( + self, fitness: NDArray[np.float64], num_parents: int, ga_instance: Any + ) -> tuple[NDArray[np.float64], NDArray[np.int_]]: ... + + +class CrossoverFunction(Protocol): + """Protocol for user-defined crossover functions. + + Args: + parents: 2D array of parent solutions selected for mating. + offspring_size: Tuple (num_offspring, num_genes) specifying + the shape of the offspring population to be generated. + ga_instance: The PyGAD GA instance. + + Returns: + 2D array of offspring solutions generated from the parents. + + """ + + def __call__( + self, + parents: NDArray[np.float64], + offspring_size: tuple[int, int], + ga_instance: Any, + ) -> NDArray[np.float64]: ... + + +class MutationFunction(Protocol): + """Protocol for user-defined mutation functions. + + Args: + offspring: 2D array of offspring solutions to be mutated. + ga_instance: The PyGAD GA instance. + + Returns: + 2D array of mutated offspring solutions. + + """ + + def __call__( + self, offspring: NDArray[np.float64], ga_instance: Any + ) -> NDArray[np.float64]: ... + + +class GeneConstraintFunction(Protocol): + """Protocol for user-defined gene constraint functions.""" + + def __call__( + self, solution: NDArray[np.float64], values: list[float] | NDArray[np.float64] + ) -> list[float] | NDArray[np.float64]: ... @mark.minimizer( @@ -49,7 +111,7 @@ class Pygad(Algorithm): num_parents_mating: PositiveInt | None = 10 num_generations: PositiveInt | None = 50 - initial_population: NDArray[np.float64] | list[list[float]] | None = None + initial_population: list[PyTree] | None = None parent_selection_type: ( Literal["sss", "rws", "sus", "rank", "random", "tournament"] @@ -72,12 +134,13 @@ class Pygad(Algorithm): | None ) = "random" mutation_probability: ( - NonNegativeFloat - | list[NonNegativeFloat] - | tuple[NonNegativeFloat, NonNegativeFloat] + ProbabilityFloat + | list[ProbabilityFloat] + | tuple[ProbabilityFloat, ProbabilityFloat] | NDArray[np.float64] | None ) = None + mutation_percent_genes: ( PositiveFloat | str @@ -85,6 +148,7 @@ class Pygad(Algorithm): | tuple[PositiveFloat, PositiveFloat] | NDArray[np.float64] ) = "default" + mutation_num_genes: ( PositiveInt | list[PositiveInt] @@ -92,6 +156,7 @@ class Pygad(Algorithm): | NDArray[np.int_] | None ) = None + mutation_by_replacement: bool = False random_mutation_min_val: float | list[float] | NDArray[np.float64] = -1.0 random_mutation_max_val: float | list[float] | NDArray[np.float64] = 1.0 @@ -101,11 +166,11 @@ class Pygad(Algorithm): gene_constraint: list[GeneConstraintFunction | None] | None = None sample_size: PositiveInt = 100 - fitness_batch_size: PositiveInt | None = None + batch_size: PositiveInt | None = None stop_criteria: str | list[str] | None = None n_cores: PositiveInt = 1 - random_seed: int | None = None + seed: int | None = None def _solve_internal_problem( self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] @@ -116,6 +181,8 @@ def _solve_internal_problem( "installed. You can install it with 'pip install pygad'." ) + import pygad + if ( problem.bounds.lower is None or problem.bounds.upper is None @@ -124,14 +191,14 @@ def _solve_internal_problem( ): raise ValueError("pygad requires finite bounds for all parameters.") - # Determine effective fitness_batch_size for parallel processing - effective_fitness_batch_size = determine_effective_batch_size( - self.fitness_batch_size, self.n_cores + # Determine effective batch_size for parallel processing + effective_batch_size = determine_effective_batch_size( + self.batch_size, self.n_cores ) if ( - effective_fitness_batch_size is not None - and effective_fitness_batch_size > 1 + effective_batch_size is not None + and effective_batch_size > 1 and self.n_cores > 1 ): @@ -144,7 +211,11 @@ def _fitness_func_batch( np.asarray(batch_solutions[i]) for i in range(batch_solutions.shape[0]) ] - batch_results = problem.batch_fun(solutions_list, n_cores=self.n_cores) + batch_results = problem.batch_fun( + solutions_list, + n_cores=self.n_cores, + batch_size=effective_batch_size, + ) return [-float(result) for result in batch_results] @@ -169,7 +240,12 @@ def _fitness_func_single( ) if self.initial_population is not None: - initial_population = np.array(self.initial_population) + initial_population = np.array( + [ + problem.converter.params_to_internal(params) + for params in self.initial_population + ] + ) else: num_genes = len(x0) @@ -190,7 +266,7 @@ def _fitness_func_single( num_generations=self.num_generations, num_parents_mating=num_parents_mating, fitness_func=fitness_function, - fitness_batch_size=effective_fitness_batch_size, + fitness_batch_size=effective_batch_size, initial_population=initial_population, gene_space=gene_space, parent_selection_type=self.parent_selection_type, @@ -211,49 +287,49 @@ def _fitness_func_single( sample_size=self.sample_size, stop_criteria=self.stop_criteria, parallel_processing=None, - random_seed=self.random_seed, + random_seed=self.seed, ) ga_instance.run() - res = _process_pygad_result(ga_instance) + result = _process_pygad_result(ga_instance) - return res + return result -def determine_effective_batch_size( - fitness_batch_size: int | None, n_cores: int -) -> int | None: - """Determine the effective fitness_batch_size for parallel processing. +def determine_effective_batch_size(batch_size: int | None, n_cores: int) -> int | None: + """Determine the effective batch_size for parallel processing. Behavior: - - If `fitness_batch_size` is explicitly provided: + - If `batch_size` is explicitly provided: - The value is returned unchanged. - A warning is issued if it is less than `n_cores`, as this may underutilize available cores. - - If `fitness_batch_size` is `None`: + - If `batch_size` is `None`: - If `n_cores` > 1, defaults to `n_cores`. - Otherwise, returns None (i.e., single-threaded evaluation). Args: - fitness_batch_size: User-specified batch size or None + batch_size: User-specified batch size or None n_cores: Number of cores for parallel processing Returns: Effective batch size for PyGAD, or None for single-threaded processing """ - if fitness_batch_size is not None: - if fitness_batch_size < n_cores: + result = None + + if batch_size is not None: + if batch_size < n_cores: warnings.warn( - f"fitness_batch_size ({fitness_batch_size}) is smaller than " + f"batch_size ({batch_size}) is smaller than " f"n_cores ({n_cores}). This may reduce parallel efficiency. " - f"Consider setting fitness_batch_size >= n_cores." + f"Consider setting batch_size >= n_cores." ) - return fitness_batch_size + result = batch_size elif n_cores > 1: - return n_cores - else: - return None + result = n_cores + + return result def _process_pygad_result(ga_instance: Any) -> InternalOptimizeResult: diff --git a/src/optimagic/typing.py b/src/optimagic/typing.py index 0394e7445..db03c24cc 100644 --- a/src/optimagic/typing.py +++ b/src/optimagic/typing.py @@ -122,6 +122,8 @@ def __call__( """Type alias for positive floats (greater than 0).""" NonNegativeFloat = Annotated[float, Ge(0)] """Type alias for non-negative floats (greater than or equal to 0).""" +ProbabilityFloat = Annotated[float, Ge(0), Le(1)] +"""Type alias for probability floats (between 0 and 1, inclusive).""" NegativeFloat = Annotated[float, Lt(0)] """Type alias for negative floats (less than 0).""" GtOneFloat = Annotated[float, Gt(1)] @@ -169,70 +171,3 @@ class MultiStartIterationHistory(TupleLikeAccess): history: IterationHistory local_histories: list[IterationHistory] | None = None exploration: IterationHistory | None = None - - -class ParentSelectionFunction(Protocol): - """Protocol for user-defined parent selection functions. - - Args: - fitness: Array of fitness values for all solutions in the population. - num_parents: Number of parents to select. - ga_instance: The PyGAD GA instance. - - Returns: - Tuple of (selected_parents, parent_indices) where: - - selected_parents: 2D array of selected parent solutions - - parent_indices: 1D array of indices of selected parents - - """ - - def __call__( - self, fitness: NDArray[np.float64], num_parents: int, ga_instance: Any - ) -> tuple[NDArray[np.float64], NDArray[np.int_]]: ... - - -class CrossoverFunction(Protocol): - """Protocol for user-defined crossover functions. - - Args: - parents: 2D array of parent solutions selected for mating. - offspring_size: Tuple (num_offspring, num_genes) specifying - offspring size. - ga_instance: The PyGAD GA instance. - - Returns: - 2D array of offspring solutions. - - """ - - def __call__( - self, - parents: NDArray[np.float64], - offspring_size: tuple[int, int], - ga_instance: Any, - ) -> NDArray[np.float64]: ... - - -class MutationFunction(Protocol): - """Protocol for user-defined mutation functions. - - Args: - offspring: 2D array of offspring solutions to be mutated. - ga_instance: The PyGAD GA instance. - - Returns: - 2D array of mutated offspring solutions. - - """ - - def __call__( - self, offspring: NDArray[np.float64], ga_instance: Any - ) -> NDArray[np.float64]: ... - - -class GeneConstraintFunction(Protocol): - """Protocol for user-defined gene constraint functions.""" - - def __call__( - self, solution: NDArray[np.float64], values: list[float] | NDArray[np.float64] - ) -> list[float] | NDArray[np.float64]: ... From dd8791b68d67b471d88412523e2dafd89a4069d2 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:03:34 +0000 Subject: [PATCH 23/32] refactor pygad tests --- src/optimagic/optimizers/pygad_optimizer.py | 2 +- .../optimizers/test_pygad_optimizer.py | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 2627198dc..668b60ee0 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -126,7 +126,7 @@ class Pygad(Algorithm): | CrossoverFunction | None ) = "single_point" - crossover_probability: NonNegativeFloat | None = None + crossover_probability: ProbabilityFloat | None = None mutation_type: ( Literal["random", "swap", "inversion", "scramble", "adaptive"] diff --git a/tests/optimagic/optimizers/test_pygad_optimizer.py b/tests/optimagic/optimizers/test_pygad_optimizer.py index afca401bd..9eb95c40e 100644 --- a/tests/optimagic/optimizers/test_pygad_optimizer.py +++ b/tests/optimagic/optimizers/test_pygad_optimizer.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "fitness_batch_size, n_cores, expected", + "batch_size, n_cores, expected", [ (None, 1, None), (None, 4, 4), @@ -21,15 +21,13 @@ (1, 1, 1), ], ) -def test_determine_effective_batch_size_return_values( - fitness_batch_size, n_cores, expected -): - result = determine_effective_batch_size(fitness_batch_size, n_cores) +def test_determine_effective_batch_size_return_values(batch_size, n_cores, expected): + result = determine_effective_batch_size(batch_size, n_cores) assert result == expected @pytest.mark.parametrize( - "fitness_batch_size, n_cores, should_warn", + "batch_size, n_cores, should_warn", [ (2, 4, True), (1, 8, True), @@ -41,18 +39,17 @@ def test_determine_effective_batch_size_return_values( (None, 1, False), ], ) -def test_determine_effective_batch_size_warnings( - fitness_batch_size, n_cores, should_warn -): +def test_determine_effective_batch_size_warnings(batch_size, n_cores, should_warn): if should_warn: warning_pattern = ( - f"fitness_batch_size \\({fitness_batch_size}\\) is smaller than " - f"n_cores \\({n_cores}\\)" + f"batch_size \\({batch_size}\\) is smaller than " + f"n_cores \\({n_cores}\\)\\. This may reduce parallel efficiency\\. " + f"Consider setting batch_size >= n_cores\\." ) with pytest.warns(UserWarning, match=warning_pattern): - result = determine_effective_batch_size(fitness_batch_size, n_cores) - assert result == fitness_batch_size + result = determine_effective_batch_size(batch_size, n_cores) + assert result == batch_size else: with warnings.catch_warnings(): warnings.simplefilter("error") - result = determine_effective_batch_size(fitness_batch_size, n_cores) + result = determine_effective_batch_size(batch_size, n_cores) From e84458ff6dfee783c5d2d24992363ac53a7237da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:04:17 +0000 Subject: [PATCH 24/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/optimagic/optimizers/pygad_optimizer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 668b60ee0..720e0ec8f 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -15,7 +15,6 @@ ) from optimagic.typing import ( AggregationLevel, - NonNegativeFloat, PositiveFloat, PositiveInt, ProbabilityFloat, From 3ab0a8e0d003969b2733b48f6e0817cc5d9f7961 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:33:53 +0000 Subject: [PATCH 25/32] refactor docs --- src/optimagic/optimizers/pygad_optimizer.py | 194 +++++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 720e0ec8f..34c1e9cd6 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -82,7 +82,21 @@ def __call__( class GeneConstraintFunction(Protocol): - """Protocol for user-defined gene constraint functions.""" + """Protocol for user-defined gene constraint functions. + + Gene constraint functions are applied to individual genes to enforce specific + constraints on their values. Each function receives the current solution and + a list of candidate values, then returns the constrained values. + + Args: + solution: Current solution array containing all gene values. + values: List or array of candidate values for the gene being constrained. + + Returns: + Constrained values as a list or array, ensuring they satisfy the gene's + specific constraints. + + """ def __call__( self, solution: NDArray[np.float64], values: list[float] | NDArray[np.float64] @@ -106,32 +120,137 @@ def __call__( ) @dataclass(frozen=True) class Pygad(Algorithm): + """Minimize a scalar function using the PyGAD genetic algorithm. + + This optimizer wraps the PyGAD genetic algorithm, a population-based evolutionary + method for global optimization. It maintains a population of candidate solutions and + evolves them over generations using biologically inspired operations: selection + (choosing parents based on fitness), crossover (combining genes from parents), and + mutation (introducing random variations). + + The algorithm is well-suited for global optimization problems with multiple local + optima, black-box optimization where gradients are unavailable or difficult to + compute. + + All variables must have finite bounds. Parallel fitness evaluation is supported via + batch processing. + + For more details, see the + `PyGAD documentation `_. + + """ + population_size: PositiveInt | None = None + """Number of solutions in each generation. + + Larger populations explore the search space more thoroughly but require more + fitness evaluations per generation. If None, defaults to + ``max(10, 10 * (problem_dimension + 1))``. + + """ + num_parents_mating: PositiveInt | None = 10 + """Number of parents selected for mating in each generation. + + Higher values can speed up convergence but may risk premature convergence. If None, + defaults to half the population size. + + """ + num_generations: PositiveInt | None = 50 + """Number of generations to evolve the population.""" initial_population: list[PyTree] | None = None + """Optional initial population as a list of parameter PyTrees. + + If None, the population is initialized randomly within parameter bounds. + + """ parent_selection_type: ( Literal["sss", "rws", "sus", "rank", "random", "tournament"] | ParentSelectionFunction ) = "sss" + """Parent selection strategy used to choose parents for crossover. + + Available methods: + - "sss": Steady-State Selection (selects the best individuals to continue) + - "rws": Roulette Wheel Selection (probabilistic, fitness-proportional) + - "sus": Stochastic Universal Sampling (even sampling across the population) + - "rank": Rank Selection (selects based on rank order) + - "random": Random Selection + - "tournament": Tournament Selection (best from K randomly chosen individuals) + + Alternatively, provide a custom function with signature + ``(fitness, num_parents, ga_instance) -> tuple[NDArray, NDArray]``. + + """ + keep_parents: int = -1 + """Number of best parents to keep in the next generation. + + Only used if ``keep_elitism = 0``. Values: + - -1: Keep all parents in the next generation (default) + - 0: Keep no parents in the next generation + - Positive integer: Keep the specified number of best parents + + """ + keep_elitism: PositiveInt = 1 + """Number of elite (best) solutions preserved each generation. + + Range: 0 to population_size.If nonzero, takes precedence over ``keep_parents``. + + """ + K_tournament: PositiveInt = 3 + """Tournament size for parent selection when + ``parent_selection_type="tournament"``.""" crossover_type: ( Literal["single_point", "two_points", "uniform", "scattered"] | CrossoverFunction | None ) = "single_point" + """Crossover operator for generating offspring. + + Available methods: + - "single_point": Single-point crossover + - "two_points": Two-point crossover + - "uniform": Uniform crossover (randomly mixes genes) + - "scattered": Scattered crossover (random mask) + + Or provide a custom function with signature + ``(parents, offspring_size, ga_instance) -> NDArray``. Set to None to disable + crossover. + + """ + crossover_probability: ProbabilityFloat | None = None + """Probability of applying crossover to selected parents. + + Range [0, 1]. If None, uses PyGAD's default. + + """ mutation_type: ( Literal["random", "swap", "inversion", "scramble", "adaptive"] | MutationFunction | None ) = "random" + """Mutation operator for introducing genetic diversity. + + Available methods: + - "random": Replace with random values + - "swap": Exchange two genes + - "inversion": Reverse a sequence of genes + - "scramble": Shuffle a subset of genes + - "adaptive": Adaptively adjusts mutation rate + + Or provide a custom function with signature + ``(offspring, ga_instance) -> NDArray``. Set to None to disable mutation. + + """ mutation_probability: ( ProbabilityFloat | list[ProbabilityFloat] @@ -139,6 +258,16 @@ class Pygad(Algorithm): | NDArray[np.float64] | None ) = None + """Probability of mutating each gene. + + - Scalar: Fixed probability for all genes (non-adaptive) + - List/tuple/array of 2 values: Adaptive mutation; + [prob_low_fitness, prob_high_fitness] (only with ``mutation_type="adaptive"``) + + When specified, takes precedence over ``mutation_percent_genes`` and + ``mutation_num_genes``. Range [0, 1]. + + """ mutation_percent_genes: ( PositiveFloat @@ -147,6 +276,17 @@ class Pygad(Algorithm): | tuple[PositiveFloat, PositiveFloat] | NDArray[np.float64] ) = "default" + """Percentage of genes to mutate in each solution. + + - "default": Uses 10% of genes (PyGAD default) + - Scalar: Fixed percentage for all generations (0-100) + - List/tuple/array of 2 values: Adaptive mutation; + [percent_low_fitness, percent_high_fitness] (only with + ``mutation_type="adaptive"``) + + Ignored if ``mutation_probability`` is specified. + + """ mutation_num_genes: ( PositiveInt @@ -155,21 +295,73 @@ class Pygad(Algorithm): | NDArray[np.int_] | None ) = None + """Number of genes to mutate per solution. + + - Scalar: Fixed number for all generations + - List/tuple/array of 2 values: Adaptive; + [count_low_fitness, count_high_fitness] (only with ``mutation_type="adaptive"``) + + Takes precedence over ``mutation_percent_genes`` but is ignored if + ``mutation_probability`` is specified. + """ mutation_by_replacement: bool = False + """If True, mutated gene values are replaced with random values. + + Only for ``mutation_type="random"``; if False, random values are added to the + original. + + """ + random_mutation_min_val: float | list[float] | NDArray[np.float64] = -1.0 + random_mutation_max_val: float | list[float] | NDArray[np.float64] = 1.0 + """Minimum and maximum values used for random mutation. + + Can be scalars, arrays/lists (one per gene), or PyTrees matching the parameter + structure. Only used with ``mutation_type="random"``. + + """ allow_duplicate_genes: bool = True + """If True, duplicate gene values are allowed within a solution.""" gene_constraint: list[GeneConstraintFunction | None] | None = None + """Optional list of per-gene constraint functions. + + Each with signature ``(solution, values) -> list[float] | NDArray``. + + """ + sample_size: PositiveInt = 100 + """Number of values to sample when enforcing uniqueness or gene constraints.""" batch_size: PositiveInt | None = None + """Number of solutions to evaluate in parallel batches. + + If None and ``n_cores > 1``, automatically set to ``n_cores``. + + """ stop_criteria: str | list[str] | None = None + """Stopping criteria for the genetic algorithm. + + Can be a string or list of strings. + + Supported criteria: + - "reach_{value}": Stop when fitness reaches the specified value, + e.g. "reach_0.01" + - "saturate_{generations}": Stop if fitness doesn't improve for the given + number of generations, e.g. "saturate_10" + + Can specify multiple criteria as a list. + + """ n_cores: PositiveInt = 1 + """Number of CPU cores for parallel fitness evaluation.""" + seed: int | None = None + """Random seed for reproducibility.""" def _solve_internal_problem( self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] From d58a9d7da102f3d25f44e3d75a430ba41e823896 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:55:03 +0000 Subject: [PATCH 26/32] refactor documentation --- src/optimagic/optimizers/pygad_optimizer.py | 474 +++++++++++++++----- 1 file changed, 365 insertions(+), 109 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 34c1e9cd6..0dfb04359 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -47,8 +47,8 @@ class CrossoverFunction(Protocol): Args: parents: 2D array of parent solutions selected for mating. - offspring_size: Tuple (num_offspring, num_genes) specifying - the shape of the offspring population to be generated. + offspring_size: Tuple (num_offspring, num_genes) specifying the shape + of the offspring population to be generated. ga_instance: The PyGAD GA instance. Returns: @@ -84,25 +84,262 @@ def __call__( class GeneConstraintFunction(Protocol): """Protocol for user-defined gene constraint functions. - Gene constraint functions are applied to individual genes to enforce specific - constraints on their values. Each function receives the current solution and - a list of candidate values, then returns the constrained values. + Gene constraint functions are applied to individual genes to enforce + specific constraints on their values. Each function receives the current + solution and a list of candidate values, then returns the constrained + values. Args: solution: Current solution array containing all gene values. - values: List or array of candidate values for the gene being constrained. + values: List or array of candidate values for the gene being + constrained. Returns: - Constrained values as a list or array, ensuring they satisfy the gene's - specific constraints. + Constrained values as a list or array, ensuring they satisfy the + gene's specific constraints. """ def __call__( - self, solution: NDArray[np.float64], values: list[float] | NDArray[np.float64] + self, + solution: NDArray[np.float64], + values: list[float] | NDArray[np.float64], ) -> list[float] | NDArray[np.float64]: ... +@dataclass(frozen=True) +class BaseMutation: + """Base class for all PyGAD mutation configurations. + + Provides default implementation for converting mutation configurations to PyGAD + parameters. Simple mutations can use this directly, while complex mutations can + override it. + + """ + + def to_pygad_params(self) -> dict[str, Any]: + """Convert mutation configuration to PyGAD parameters. + + Default implementation that works for simple mutations. Complex + mutations (RandomMutation, AdaptiveMutation) should override this. + + Returns: + Dictionary of PyGAD mutation parameters. + + """ + mutation_type = getattr(self, "mutation_type", "random") + + return { + "mutation_type": mutation_type, + "mutation_probability": None, + "mutation_percent_genes": "default", + "mutation_num_genes": None, + "mutation_by_replacement": False, + } + + +@dataclass(frozen=True) +class RandomMutation(BaseMutation): + """Configuration for the random mutation operator in PyGAD. + + The random mutation selects a subset of genes in each solution and either + replaces each selected gene with a new random value or adds a random value + to it. + + The exact behavior depends on the `by_replacement` parameter: If + `by_replacement` is True, the selected genes are replaced with new values; + if False, random values are added to the existing gene values. + + The mutation rate is determined by the mutation probability, the number of + genes, or the percentage of genes (with priority: probability > num_genes + > percent_genes). + + """ + + probability: ProbabilityFloat | None = None + """Probability of mutating each gene. + + If specified, takes precedence over num_genes and percent_genes. Range [0, 1]. + + """ + + num_genes: PositiveInt | None = None + """Number of genes to mutate per solution. + + Takes precedence over percent_genes but is ignored if probability is specified. + + """ + + percent_genes: PositiveFloat | str = "default" + """Percentage of genes to mutate in each solution. + + - "default": Uses 10% of genes (PyGAD default) + - Numeric value: Percentage (0-100) + + Ignored if probability or num_genes are specified. + + """ + + by_replacement: bool = False + """If True, replace gene values with random values. + + If False, add random values to existing gene values. + + """ + + def to_pygad_params(self) -> dict[str, Any]: + """Convert RandomMutation configuration to PyGAD parameters.""" + return { + "mutation_type": "random", + "mutation_probability": self.probability, + "mutation_percent_genes": self.percent_genes, + "mutation_num_genes": self.num_genes, + "mutation_by_replacement": self.by_replacement, + } + + +@dataclass(frozen=True) +class SwapMutation(BaseMutation): + """Configuration for the swap mutation in PyGAD. + + The swap mutation selects two random genes and exchanges their values. This + operation maintains all gene values, altering only their positions within the + chromosome. + + No additional parameters are required for this mutation type. + + """ + + mutation_type: str = "swap" + + +@dataclass(frozen=True) +class InversionMutation(BaseMutation): + """Configuration for the inversion mutation in PyGAD. + + The inversion mutation selects a contiguous segment of genes and reverses their + order. All gene values remain unchanged; only the ordering within the selected + segment is altered. + + No additional parameters are required for this mutation type. + + """ + + mutation_type: str = "inversion" + + +@dataclass(frozen=True) +class ScrambleMutation(BaseMutation): + """Configuration for the scramble mutation in PyGAD. + + The scramble mutation randomly shuffles the genes within a contiguous segment. This + preserves gene values but changes their order within the chosen segment. + + No additional parameters are required for this mutation type. + + """ + + mutation_type: str = "scramble" + + +@dataclass(frozen=True) +class AdaptiveMutation(BaseMutation): + """Configuration for the adaptive mutation in PyGAD. + + The adaptive mutation dynamically adjusts the mutation rate based on the + fitness of solutions. Typically, solutions with below-average fitness + (bad fitness solutions) receive a higher mutation rate to encourage + exploration, while above-average solutions (good fitness solutions) + receive a lower rate to preserve good solutions. + + By default, adaptive mutation uses a 10% mutation rate for bad fitness + solutions and 5% for good fitness solutions. + + The priority for selecting mutation parameters is: + probability > num_genes > percent_genes + + """ + + probability_bad: ProbabilityFloat | None = 0.1 + """Probability of mutating each gene for below-average fitness solutions. + + If specified, takes precedence over num_genes_bad and percent_genes_bad. Range [0, + 1]. Default: 0.1 (10% mutation rate for bad fitness solutions). + + """ + + probability_good: ProbabilityFloat | None = 0.05 + """Probability of mutating each gene for above-average fitness solutions. + + If specified, takes precedence over num_genes_good and percent_genes_good. Range [0, + 1]. Default: 0.05 (5% mutation rate for good fitness solutions). + + """ + + num_genes_bad: PositiveInt | None = None + """Number of genes to mutate for below-average fitness solutions. + + Takes precedence over percent_genes_bad but is ignored if probability_bad is + specified. + + """ + + num_genes_good: PositiveInt | None = None + """Number of genes to mutate for above-average fitness solutions. + + Takes precedence over percent_genes_good but is ignored if probability_good is + specified. + + """ + + percent_genes_bad: PositiveFloat | None = None + """Percentage of genes to mutate for below-average fitness solutions. + + Ignored if probability_bad or num_genes_bad are specified. + + """ + + percent_genes_good: PositiveFloat | None = None + """Percentage of genes to mutate for above-average fitness solutions. + + Ignored if probability_good or num_genes_good are specified. + + """ + + by_replacement: bool = False + """If True, replace gene values with random values. + + If False, add random values to existing gene values. + + """ + + def to_pygad_params(self) -> dict[str, Any]: + """Convert AdaptiveMutation configuration to PyGAD parameters.""" + mutation_probability: list[float] | None = None + mutation_num_genes: list[int] | None = None + mutation_percent_genes: list[float] | str | None = "default" + + if self.probability_bad is not None and self.probability_good is not None: + mutation_probability = [self.probability_bad, self.probability_good] + elif self.num_genes_bad is not None and self.num_genes_good is not None: + mutation_num_genes = [self.num_genes_bad, self.num_genes_good] + elif self.percent_genes_bad is not None and self.percent_genes_good is not None: + mutation_percent_genes = [self.percent_genes_bad, self.percent_genes_good] + else: + mutation_probability = [ + self.probability_bad or 0.1, + self.probability_good or 0.05, + ] + + return { + "mutation_type": "adaptive", + "mutation_probability": mutation_probability, + "mutation_percent_genes": mutation_percent_genes, + "mutation_num_genes": mutation_num_genes, + "mutation_by_replacement": self.by_replacement, + } + + @mark.minimizer( name="pygad", solver_type=AggregationLevel.SCALAR, @@ -143,8 +380,8 @@ class Pygad(Algorithm): population_size: PositiveInt | None = None """Number of solutions in each generation. - Larger populations explore the search space more thoroughly but require more - fitness evaluations per generation. If None, defaults to + Larger populations explore the search space more thoroughly but require + more fitness evaluations per generation. If None, optimagic sets this to ``max(10, 10 * (problem_dimension + 1))``. """ @@ -152,8 +389,8 @@ class Pygad(Algorithm): num_parents_mating: PositiveInt | None = 10 """Number of parents selected for mating in each generation. - Higher values can speed up convergence but may risk premature convergence. If None, - defaults to half the population size. + Higher values can speed up convergence but may risk premature convergence. + If None, defaults to ``max(2, population_size // 2)``. """ @@ -176,10 +413,11 @@ class Pygad(Algorithm): Available methods: - "sss": Steady-State Selection (selects the best individuals to continue) - "rws": Roulette Wheel Selection (probabilistic, fitness-proportional) - - "sus": Stochastic Universal Sampling (even sampling across the population) + - "sus": Stochastic Universal Sampling (even sampling across population) - "rank": Rank Selection (selects based on rank order) - "random": Random Selection - - "tournament": Tournament Selection (best from K randomly chosen individuals) + - "tournament": Tournament Selection (best from K randomly chosen + individuals) Alternatively, provide a custom function with signature ``(fitness, num_parents, ga_instance) -> tuple[NDArray, NDArray]``. @@ -196,10 +434,12 @@ class Pygad(Algorithm): """ - keep_elitism: PositiveInt = 1 + keep_elitism: int = 1 """Number of elite (best) solutions preserved each generation. - Range: 0 to population_size.If nonzero, takes precedence over ``keep_parents``. + Range: 0 to population_size. If greater than 0, takes precedence over + ``keep_parents``. When 0, elitism is disabled and ``keep_parents`` + controls parent retention. """ @@ -221,8 +461,7 @@ class Pygad(Algorithm): - "scattered": Scattered crossover (random mask) Or provide a custom function with signature - ``(parents, offspring_size, ga_instance) -> NDArray``. Set to None to disable - crossover. + ``(parents, offspring_size, ga_instance) -> NDArray``. """ @@ -233,93 +472,35 @@ class Pygad(Algorithm): """ - mutation_type: ( + mutation: ( Literal["random", "swap", "inversion", "scramble", "adaptive"] + | type[BaseMutation] + | BaseMutation | MutationFunction | None ) = "random" """Mutation operator for introducing genetic diversity. - Available methods: - - "random": Replace with random values - - "swap": Exchange two genes - - "inversion": Reverse a sequence of genes - - "scramble": Shuffle a subset of genes - - "adaptive": Adaptively adjusts mutation rate - - Or provide a custom function with signature - ``(offspring, ga_instance) -> NDArray``. Set to None to disable mutation. - - """ - mutation_probability: ( - ProbabilityFloat - | list[ProbabilityFloat] - | tuple[ProbabilityFloat, ProbabilityFloat] - | NDArray[np.float64] - | None - ) = None - """Probability of mutating each gene. - - - Scalar: Fixed probability for all genes (non-adaptive) - - List/tuple/array of 2 values: Adaptive mutation; - [prob_low_fitness, prob_high_fitness] (only with ``mutation_type="adaptive"``) - - When specified, takes precedence over ``mutation_percent_genes`` and - ``mutation_num_genes``. Range [0, 1]. - - """ - - mutation_percent_genes: ( - PositiveFloat - | str - | list[PositiveFloat] - | tuple[PositiveFloat, PositiveFloat] - | NDArray[np.float64] - ) = "default" - """Percentage of genes to mutate in each solution. - - - "default": Uses 10% of genes (PyGAD default) - - Scalar: Fixed percentage for all generations (0-100) - - List/tuple/array of 2 values: Adaptive mutation; - [percent_low_fitness, percent_high_fitness] (only with - ``mutation_type="adaptive"``) - - Ignored if ``mutation_probability`` is specified. - - """ - - mutation_num_genes: ( - PositiveInt - | list[PositiveInt] - | tuple[PositiveInt, PositiveInt] - | NDArray[np.int_] - | None - ) = None - """Number of genes to mutate per solution. - - - Scalar: Fixed number for all generations - - List/tuple/array of 2 values: Adaptive; - [count_low_fitness, count_high_fitness] (only with ``mutation_type="adaptive"``) - - Takes precedence over ``mutation_percent_genes`` but is ignored if - ``mutation_probability`` is specified. + Available options: + 1. String values for default configurations: + * "random": Random mutation with default parameters + * "swap": Swap mutation with default parameters + * "inversion": Inversion mutation with default parameters + * "scramble": Scramble mutation with default parameters + * "adaptive": Adaptive random mutation with default parameters - """ - mutation_by_replacement: bool = False - """If True, mutated gene values are replaced with random values. - - Only for ``mutation_type="random"``; if False, random values are added to the - original. - - """ - - random_mutation_min_val: float | list[float] | NDArray[np.float64] = -1.0 + 2. Mutation classes for default configurations: + * Any mutation class (e.g., ``RandomMutation``, ``SwapMutation``, + ``AdaptiveMutation``, etc.) + * All classes can be used without parameters for default behavior - random_mutation_max_val: float | list[float] | NDArray[np.float64] = 1.0 - """Minimum and maximum values used for random mutation. + 3. Configured mutation instances: + * Any mutation instance (e.g., ``RandomMutation(...)``, + ``SwapMutation()``, etc.) + * All mutation classes inherit from ``BaseMutation`` - Can be scalars, arrays/lists (one per gene), or PyTrees matching the parameter - structure. Only used with ``mutation_type="random"``. + 4. Custom function with signature ``(offspring, ga_instance) -> NDArray`` + 5. None to disable mutation """ @@ -342,6 +523,7 @@ class Pygad(Algorithm): If None and ``n_cores > 1``, automatically set to ``n_cores``. """ + stop_criteria: str | list[str] | None = None """Stopping criteria for the genetic algorithm. @@ -453,6 +635,9 @@ def _fitness_func_single( for i in range(len(x0)) ] + # Convert mutation parameter to PyGAD parameters + mutation_params = self._convert_mutation_to_pygad_params() + ga_instance = pygad.GA( num_generations=self.num_generations, num_parents_mating=num_parents_mating, @@ -466,13 +651,11 @@ def _fitness_func_single( K_tournament=self.K_tournament, crossover_type=self.crossover_type, crossover_probability=self.crossover_probability, - mutation_type=self.mutation_type, - mutation_probability=self.mutation_probability, - mutation_by_replacement=self.mutation_by_replacement, - mutation_percent_genes=self.mutation_percent_genes, - mutation_num_genes=self.mutation_num_genes, - random_mutation_min_val=self.random_mutation_min_val, - random_mutation_max_val=self.random_mutation_max_val, + mutation_type=mutation_params["mutation_type"], + mutation_probability=mutation_params["mutation_probability"], + mutation_by_replacement=mutation_params["mutation_by_replacement"], + mutation_percent_genes=mutation_params["mutation_percent_genes"], + mutation_num_genes=mutation_params["mutation_num_genes"], allow_duplicate_genes=self.allow_duplicate_genes, gene_constraint=self.gene_constraint, sample_size=self.sample_size, @@ -487,24 +670,70 @@ def _fitness_func_single( return result + def _convert_mutation_to_pygad_params(self) -> dict[str, Any]: + """Convert the mutation parameter to PyGAD mutation parameters. + + Handles strings, classes, instances, and custom functions using the + new mutation dataclass system with built-in conversion methods. + + Returns: + Dictionary of PyGAD mutation parameters. + + """ + if self.mutation is None: + return self._get_default_mutation_params(mutation_type=None) + + elif isinstance(self.mutation, str): + mutation_instance = create_mutation_from_string(self.mutation) + return mutation_instance.to_pygad_params() + + elif isinstance(self.mutation, type) and issubclass( + self.mutation, BaseMutation + ): + mutation_instance = self.mutation() + return mutation_instance.to_pygad_params() + + elif isinstance(self.mutation, BaseMutation): + return self.mutation.to_pygad_params() + + elif callable(self.mutation): + return self._get_default_mutation_params(mutation_type=self.mutation) + + else: + raise ValueError(f"Unsupported mutation type: {type(self.mutation)}") + + def _get_default_mutation_params( + self, mutation_type: Any = "random" + ) -> dict[str, Any]: + """Get default PyGAD mutation parameters.""" + return { + "mutation_type": mutation_type, + "mutation_probability": None, + "mutation_percent_genes": "default", + "mutation_num_genes": None, + "mutation_by_replacement": False, + } + def determine_effective_batch_size(batch_size: int | None, n_cores: int) -> int | None: """Determine the effective batch_size for parallel processing. Behavior: - If `batch_size` is explicitly provided: - - The value is returned unchanged. - - A warning is issued if it is less than `n_cores`, as this may + - The value is returned unchanged. + - A warning is issued if it is less than `n_cores`, as this may underutilize available cores. - If `batch_size` is `None`: - - If `n_cores` > 1, defaults to `n_cores`. - - Otherwise, returns None (i.e., single-threaded evaluation). + - If `n_cores` > 1, defaults to `n_cores`. + - Otherwise, returns None (i.e., single-threaded evaluation). + Args: batch_size: User-specified batch size or None n_cores: Number of cores for parallel processing Returns: - Effective batch size for PyGAD, or None for single-threaded processing + Effective batch size for PyGAD, or None for single-threaded + processing """ result = None @@ -557,3 +786,30 @@ def _process_pygad_result(ga_instance: Any) -> InternalOptimizeResult: message=message, n_fun_evals=ga_instance.generations_completed * ga_instance.pop_size[0], ) + + +def create_mutation_from_string(mutation_type: str) -> BaseMutation: + """Create a mutation instance from a string type. + + Args: + mutation_type: String mutation type (e.g., "random", "swap", etc.) + + Returns: + Appropriate mutation instance. + + Raises: + ValueError: If mutation_type is not supported. + + """ + mutation_map = { + "random": RandomMutation, + "swap": SwapMutation, + "inversion": InversionMutation, + "scramble": ScrambleMutation, + "adaptive": AdaptiveMutation, + } + + if mutation_type not in mutation_map: + raise ValueError(f"Unsupported mutation type: {mutation_type}") + + return mutation_map[mutation_type]() From 9dfa99cf9f0c5273ba0a55f9ed1cd740ca31a085 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:08:00 +0000 Subject: [PATCH 27/32] refactor pygad optimizer --- src/optimagic/optimizers/pygad_optimizer.py | 178 +++++++++++--------- 1 file changed, 96 insertions(+), 82 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 0dfb04359..8559fc517 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -1,6 +1,6 @@ import warnings from dataclasses import dataclass -from typing import Any, Literal, Protocol +from typing import Any, ClassVar, Literal, Protocol, runtime_checkable import numpy as np from numpy.typing import NDArray @@ -64,6 +64,7 @@ def __call__( ) -> NDArray[np.float64]: ... +@runtime_checkable class MutationFunction(Protocol): """Protocol for user-defined mutation functions. @@ -117,6 +118,8 @@ class BaseMutation: """ + mutation_type: ClassVar[str] = "random" + def to_pygad_params(self) -> dict[str, Any]: """Convert mutation configuration to PyGAD parameters. @@ -127,10 +130,8 @@ def to_pygad_params(self) -> dict[str, Any]: Dictionary of PyGAD mutation parameters. """ - mutation_type = getattr(self, "mutation_type", "random") - return { - "mutation_type": mutation_type, + "mutation_type": self.mutation_type, "mutation_probability": None, "mutation_percent_genes": "default", "mutation_num_genes": None, @@ -140,7 +141,7 @@ def to_pygad_params(self) -> dict[str, Any]: @dataclass(frozen=True) class RandomMutation(BaseMutation): - """Configuration for the random mutation operator in PyGAD. + """Configuration for the random mutation in PyGAD. The random mutation selects a subset of genes in each solution and either replaces each selected gene with a new random value or adds a random value @@ -156,6 +157,8 @@ class RandomMutation(BaseMutation): """ + mutation_type: ClassVar[str] = "random" + probability: ProbabilityFloat | None = None """Probability of mutating each gene. @@ -190,7 +193,7 @@ class RandomMutation(BaseMutation): def to_pygad_params(self) -> dict[str, Any]: """Convert RandomMutation configuration to PyGAD parameters.""" return { - "mutation_type": "random", + "mutation_type": self.mutation_type, "mutation_probability": self.probability, "mutation_percent_genes": self.percent_genes, "mutation_num_genes": self.num_genes, @@ -210,7 +213,7 @@ class SwapMutation(BaseMutation): """ - mutation_type: str = "swap" + mutation_type: ClassVar[str] = "swap" @dataclass(frozen=True) @@ -225,7 +228,7 @@ class InversionMutation(BaseMutation): """ - mutation_type: str = "inversion" + mutation_type: ClassVar[str] = "inversion" @dataclass(frozen=True) @@ -239,7 +242,7 @@ class ScrambleMutation(BaseMutation): """ - mutation_type: str = "scramble" + mutation_type: ClassVar[str] = "scramble" @dataclass(frozen=True) @@ -252,27 +255,36 @@ class AdaptiveMutation(BaseMutation): exploration, while above-average solutions (good fitness solutions) receive a lower rate to preserve good solutions. - By default, adaptive mutation uses a 10% mutation rate for bad fitness - solutions and 5% for good fitness solutions. + If no mutation rate parameters are specified, this mutation defaults to using + probabilities, with a 10% rate for bad solutions (`probability_bad=0.1`) + and a 5% rate for good solutions (`probability_good=0.05`). - The priority for selecting mutation parameters is: - probability > num_genes > percent_genes + **Parameter Precedence:** + The mutation rate is determined by the first set of parameters found, in the + following order of priority: + 1. `probability_bad` and `probability_good` + 2. `num_genes_bad` and `num_genes_good` + 3. `percent_genes_bad` and `percent_genes_good` """ - probability_bad: ProbabilityFloat | None = 0.1 + mutation_type: ClassVar[str] = "adaptive" + + probability_bad: ProbabilityFloat | None = None """Probability of mutating each gene for below-average fitness solutions. If specified, takes precedence over num_genes_bad and percent_genes_bad. Range [0, - 1]. Default: 0.1 (10% mutation rate for bad fitness solutions). + 1]. If no mutation rate parameters are provided at all, this defaults to + 0.1 (10% mutation rate for bad fitness solutions). """ - probability_good: ProbabilityFloat | None = 0.05 + probability_good: ProbabilityFloat | None = None """Probability of mutating each gene for above-average fitness solutions. If specified, takes precedence over num_genes_good and percent_genes_good. Range [0, - 1]. Default: 0.05 (5% mutation rate for good fitness solutions). + 1]. If no mutation rate parameters are provided at all, this defaults to + 0.05 (5% mutation rate for good fitness solutions). """ @@ -332,7 +344,7 @@ def to_pygad_params(self) -> dict[str, Any]: ] return { - "mutation_type": "adaptive", + "mutation_type": self.mutation_type, "mutation_probability": mutation_probability, "mutation_percent_genes": mutation_percent_genes, "mutation_num_genes": mutation_num_genes, @@ -565,7 +577,7 @@ def _solve_internal_problem( raise ValueError("pygad requires finite bounds for all parameters.") # Determine effective batch_size for parallel processing - effective_batch_size = determine_effective_batch_size( + effective_batch_size = _determine_effective_batch_size( self.batch_size, self.n_cores ) @@ -636,7 +648,7 @@ def _fitness_func_single( ] # Convert mutation parameter to PyGAD parameters - mutation_params = self._convert_mutation_to_pygad_params() + mutation_params = _convert_mutation_to_pygad_params(self.mutation) ga_instance = pygad.GA( num_generations=self.num_generations, @@ -670,52 +682,81 @@ def _fitness_func_single( return result - def _convert_mutation_to_pygad_params(self) -> dict[str, Any]: - """Convert the mutation parameter to PyGAD mutation parameters. - Handles strings, classes, instances, and custom functions using the - new mutation dataclass system with built-in conversion methods. +def _convert_mutation_to_pygad_params(mutation: Any) -> dict[str, Any]: + """Convert the mutation parameter to PyGAD mutation parameters. - Returns: - Dictionary of PyGAD mutation parameters. + Handles strings, classes, instances, and custom functions using the + new mutation dataclass system with built-in conversion methods. - """ - if self.mutation is None: - return self._get_default_mutation_params(mutation_type=None) + Returns: + Dictionary of PyGAD mutation parameters. - elif isinstance(self.mutation, str): - mutation_instance = create_mutation_from_string(self.mutation) - return mutation_instance.to_pygad_params() + """ + params: dict[str, Any] - elif isinstance(self.mutation, type) and issubclass( - self.mutation, BaseMutation - ): - mutation_instance = self.mutation() - return mutation_instance.to_pygad_params() + if mutation is None: + params = _get_default_mutation_params(mutation_type=None) - elif isinstance(self.mutation, BaseMutation): - return self.mutation.to_pygad_params() + elif isinstance(mutation, str): + mutation_instance = _create_mutation_from_string(mutation) + params = mutation_instance.to_pygad_params() - elif callable(self.mutation): - return self._get_default_mutation_params(mutation_type=self.mutation) + elif isinstance(mutation, type) and issubclass(mutation, BaseMutation): + mutation_instance = mutation() + params = mutation_instance.to_pygad_params() - else: - raise ValueError(f"Unsupported mutation type: {type(self.mutation)}") + elif isinstance(mutation, BaseMutation): + params = mutation.to_pygad_params() - def _get_default_mutation_params( - self, mutation_type: Any = "random" - ) -> dict[str, Any]: - """Get default PyGAD mutation parameters.""" - return { - "mutation_type": mutation_type, - "mutation_probability": None, - "mutation_percent_genes": "default", - "mutation_num_genes": None, - "mutation_by_replacement": False, - } + elif isinstance(mutation, MutationFunction): + params = _get_default_mutation_params(mutation_type=mutation) + + else: + raise ValueError(f"Unsupported mutation type: {type(mutation)}") + return params -def determine_effective_batch_size(batch_size: int | None, n_cores: int) -> int | None: + +def _get_default_mutation_params(mutation_type: Any = "random") -> dict[str, Any]: + """Get default PyGAD mutation parameters.""" + return { + "mutation_type": mutation_type, + "mutation_probability": None, + "mutation_percent_genes": None if mutation_type is None else "default", + "mutation_num_genes": None, + "mutation_by_replacement": None if mutation_type is None else False, + } + + +def _create_mutation_from_string(mutation_type: str) -> BaseMutation: + """Create a mutation instance from a string type. + + Args: + mutation_type: String mutation type (e.g., "random", "swap", etc.) + + Returns: + Appropriate mutation instance. + + Raises: + ValueError: If mutation_type is not supported. + + """ + mutation_map = { + "random": RandomMutation, + "swap": SwapMutation, + "inversion": InversionMutation, + "scramble": ScrambleMutation, + "adaptive": AdaptiveMutation, + } + + if mutation_type not in mutation_map: + raise ValueError(f"Unsupported mutation type: {mutation_type}") + + return mutation_map[mutation_type]() + + +def _determine_effective_batch_size(batch_size: int | None, n_cores: int) -> int | None: """Determine the effective batch_size for parallel processing. Behavior: @@ -786,30 +827,3 @@ def _process_pygad_result(ga_instance: Any) -> InternalOptimizeResult: message=message, n_fun_evals=ga_instance.generations_completed * ga_instance.pop_size[0], ) - - -def create_mutation_from_string(mutation_type: str) -> BaseMutation: - """Create a mutation instance from a string type. - - Args: - mutation_type: String mutation type (e.g., "random", "swap", etc.) - - Returns: - Appropriate mutation instance. - - Raises: - ValueError: If mutation_type is not supported. - - """ - mutation_map = { - "random": RandomMutation, - "swap": SwapMutation, - "inversion": InversionMutation, - "scramble": ScrambleMutation, - "adaptive": AdaptiveMutation, - } - - if mutation_type not in mutation_map: - raise ValueError(f"Unsupported mutation type: {mutation_type}") - - return mutation_map[mutation_type]() From 49f5fafad0caa7f26e40d33f8300dd9fcac5de2b Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Thu, 7 Aug 2025 18:58:22 +0000 Subject: [PATCH 28/32] update test --- src/optimagic/optimizers/pygad_optimizer.py | 2 +- .../optimizers/test_pygad_optimizer.py | 237 +++++++++++++++++- 2 files changed, 234 insertions(+), 5 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index 8559fc517..ef1e93122 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -329,7 +329,7 @@ def to_pygad_params(self) -> dict[str, Any]: """Convert AdaptiveMutation configuration to PyGAD parameters.""" mutation_probability: list[float] | None = None mutation_num_genes: list[int] | None = None - mutation_percent_genes: list[float] | str | None = "default" + mutation_percent_genes: list[float] | str | None = None if self.probability_bad is not None and self.probability_good is not None: mutation_probability = [self.probability_bad, self.probability_good] diff --git a/tests/optimagic/optimizers/test_pygad_optimizer.py b/tests/optimagic/optimizers/test_pygad_optimizer.py index 9eb95c40e..52f2f0dd4 100644 --- a/tests/optimagic/optimizers/test_pygad_optimizer.py +++ b/tests/optimagic/optimizers/test_pygad_optimizer.py @@ -4,7 +4,17 @@ import pytest -from optimagic.optimizers.pygad_optimizer import determine_effective_batch_size +from optimagic.optimizers.pygad_optimizer import ( + AdaptiveMutation, + InversionMutation, + RandomMutation, + ScrambleMutation, + SwapMutation, + _convert_mutation_to_pygad_params, + _create_mutation_from_string, + _determine_effective_batch_size, + _get_default_mutation_params, +) @pytest.mark.parametrize( @@ -22,7 +32,7 @@ ], ) def test_determine_effective_batch_size_return_values(batch_size, n_cores, expected): - result = determine_effective_batch_size(batch_size, n_cores) + result = _determine_effective_batch_size(batch_size, n_cores) assert result == expected @@ -47,9 +57,228 @@ def test_determine_effective_batch_size_warnings(batch_size, n_cores, should_war f"Consider setting batch_size >= n_cores\\." ) with pytest.warns(UserWarning, match=warning_pattern): - result = determine_effective_batch_size(batch_size, n_cores) + result = _determine_effective_batch_size(batch_size, n_cores) assert result == batch_size else: with warnings.catch_warnings(): warnings.simplefilter("error") - result = determine_effective_batch_size(batch_size, n_cores) + result = _determine_effective_batch_size(batch_size, n_cores) + + +# Tests for _get_default_mutation_params +@pytest.mark.parametrize( + "mutation_type, expected", + [ + ( + "random", + { + "mutation_type": "random", + "mutation_probability": None, + "mutation_percent_genes": "default", + "mutation_num_genes": None, + "mutation_by_replacement": False, + }, + ), + ( + None, + { + "mutation_type": None, + "mutation_probability": None, + "mutation_percent_genes": None, + "mutation_num_genes": None, + "mutation_by_replacement": None, + }, + ), + ], +) +def test_get_default_mutation_params(mutation_type, expected): + result = _get_default_mutation_params(mutation_type) + assert result == expected + + +# Tests for _create_mutation_from_string +@pytest.mark.parametrize( + "mutation_type, expected_class", + [ + ("random", RandomMutation), + ("swap", SwapMutation), + ("inversion", InversionMutation), + ("scramble", ScrambleMutation), + ("adaptive", AdaptiveMutation), + ], +) +def test_create_mutation_from_string_valid(mutation_type, expected_class): + result = _create_mutation_from_string(mutation_type) + assert isinstance(result, expected_class) + + +def test_create_mutation_from_string_invalid(): + with pytest.raises(ValueError, match="Unsupported mutation type: invalid"): + _create_mutation_from_string("invalid") + + +# Tests for _convert_mutation_to_pygad_params +def test_convert_mutation_none(): + result = _convert_mutation_to_pygad_params(None) + expected = { + "mutation_type": None, + "mutation_probability": None, + "mutation_percent_genes": None, + "mutation_num_genes": None, + "mutation_by_replacement": None, + } + assert result == expected + + +@pytest.mark.parametrize( + "mutation_string", + ["random", "swap", "inversion", "scramble", "adaptive"], +) +def test_convert_mutation_string(mutation_string): + result = _convert_mutation_to_pygad_params(mutation_string) + assert result["mutation_type"] == mutation_string + assert "mutation_probability" in result + assert "mutation_percent_genes" in result + assert "mutation_num_genes" in result + assert "mutation_by_replacement" in result + + +@pytest.mark.parametrize( + "mutation_class", + [ + RandomMutation, + SwapMutation, + InversionMutation, + ScrambleMutation, + AdaptiveMutation, + ], +) +def test_convert_mutation_class(mutation_class): + result = _convert_mutation_to_pygad_params(mutation_class) + assert result["mutation_type"] == mutation_class.mutation_type + assert "mutation_probability" in result + assert "mutation_percent_genes" in result + assert "mutation_num_genes" in result + assert "mutation_by_replacement" in result + + +def test_convert_mutation_instance(): + # Test RandomMutation instance + mutation = RandomMutation(probability=0.2, by_replacement=True) + result = _convert_mutation_to_pygad_params(mutation) + assert result["mutation_type"] == "random" + assert result["mutation_probability"] == 0.2 + assert result["mutation_by_replacement"] is True + + # Test SwapMutation instance + mutation = SwapMutation() + result = _convert_mutation_to_pygad_params(mutation) + assert result["mutation_type"] == "swap" + + # Test AdaptiveMutation instance + mutation = AdaptiveMutation(probability_bad=0.3, probability_good=0.1) + result = _convert_mutation_to_pygad_params(mutation) + assert result["mutation_type"] == "adaptive" + assert result["mutation_probability"] == [0.3, 0.1] + + +def test_convert_mutation_custom_function(): + def custom_mutation(offspring, ga_instance): + return offspring + + result = _convert_mutation_to_pygad_params(custom_mutation) + assert result["mutation_type"] == custom_mutation + + +def test_convert_mutation_invalid_type(): + with pytest.raises(ValueError, match="Unsupported mutation type"): + _convert_mutation_to_pygad_params(123) + + +# Tests for mutation dataclasses +def test_random_mutation_default(): + mutation = RandomMutation() + result = mutation.to_pygad_params() + assert result["mutation_type"] == "random" + assert result["mutation_probability"] is None + assert result["mutation_percent_genes"] == "default" + assert result["mutation_num_genes"] is None + assert result["mutation_by_replacement"] is False + + +def test_random_mutation_with_parameters(): + mutation = RandomMutation( + probability=0.15, num_genes=5, percent_genes=20.0, by_replacement=True + ) + result = mutation.to_pygad_params() + assert result["mutation_type"] == "random" + assert result["mutation_probability"] == 0.15 + assert result["mutation_percent_genes"] == 20.0 + assert result["mutation_num_genes"] == 5 + assert result["mutation_by_replacement"] is True + + +@pytest.mark.parametrize( + "mutation_class, expected_type", + [ + (SwapMutation, "swap"), + (InversionMutation, "inversion"), + (ScrambleMutation, "scramble"), + ], +) +def test_simple_mutations(mutation_class, expected_type): + mutation = mutation_class() + result = mutation.to_pygad_params() + assert result["mutation_type"] == expected_type + assert result["mutation_probability"] is None + assert result["mutation_percent_genes"] == "default" + assert result["mutation_num_genes"] is None + assert result["mutation_by_replacement"] is False + + +def test_adaptive_mutation_default(): + mutation = AdaptiveMutation() + result = mutation.to_pygad_params() + assert result["mutation_type"] == "adaptive" + assert result["mutation_probability"] == [0.1, 0.05] # Default values + assert result["mutation_percent_genes"] == None + assert result["mutation_num_genes"] is None + assert result["mutation_by_replacement"] is False + + +def test_adaptive_mutation_with_probabilities(): + mutation = AdaptiveMutation(probability_bad=0.2, probability_good=0.08) + result = mutation.to_pygad_params() + assert result["mutation_type"] == "adaptive" + assert result["mutation_probability"] == [0.2, 0.08] + assert result["mutation_percent_genes"] == None + assert result["mutation_num_genes"] is None + assert result["mutation_by_replacement"] is False + + +def test_adaptive_mutation_with_num_genes(): + mutation = AdaptiveMutation(num_genes_bad=10, num_genes_good=5) + result = mutation.to_pygad_params() + assert result["mutation_type"] == "adaptive" + assert result["mutation_probability"] is None + assert result["mutation_num_genes"] == [10, 5] + assert result["mutation_percent_genes"] == None + assert result["mutation_by_replacement"] is False + + +def test_adaptive_mutation_with_percent_genes(): + mutation = AdaptiveMutation(percent_genes_bad=25.0, percent_genes_good=10.0) + result = mutation.to_pygad_params() + assert result["mutation_type"] == "adaptive" + assert result["mutation_probability"] is None + assert result["mutation_num_genes"] is None + assert result["mutation_percent_genes"] == [25.0, 10.0] + assert result["mutation_by_replacement"] is False + + +def test_mutation_type_class_variables(): + assert RandomMutation.mutation_type == "random" + assert SwapMutation.mutation_type == "swap" + assert InversionMutation.mutation_type == "inversion" + assert ScrambleMutation.mutation_type == "scramble" + assert AdaptiveMutation.mutation_type == "adaptive" From 93fb67bbcb6eef2e15b9d58a65b6899e36c97975 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Thu, 7 Aug 2025 19:04:08 +0000 Subject: [PATCH 29/32] fix --- tests/optimagic/optimizers/test_pygad_optimizer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/optimagic/optimizers/test_pygad_optimizer.py b/tests/optimagic/optimizers/test_pygad_optimizer.py index 52f2f0dd4..6e1373176 100644 --- a/tests/optimagic/optimizers/test_pygad_optimizer.py +++ b/tests/optimagic/optimizers/test_pygad_optimizer.py @@ -241,7 +241,7 @@ def test_adaptive_mutation_default(): result = mutation.to_pygad_params() assert result["mutation_type"] == "adaptive" assert result["mutation_probability"] == [0.1, 0.05] # Default values - assert result["mutation_percent_genes"] == None + assert result["mutation_percent_genes"] is None assert result["mutation_num_genes"] is None assert result["mutation_by_replacement"] is False @@ -251,7 +251,7 @@ def test_adaptive_mutation_with_probabilities(): result = mutation.to_pygad_params() assert result["mutation_type"] == "adaptive" assert result["mutation_probability"] == [0.2, 0.08] - assert result["mutation_percent_genes"] == None + assert result["mutation_percent_genes"] is None assert result["mutation_num_genes"] is None assert result["mutation_by_replacement"] is False @@ -262,7 +262,7 @@ def test_adaptive_mutation_with_num_genes(): assert result["mutation_type"] == "adaptive" assert result["mutation_probability"] is None assert result["mutation_num_genes"] == [10, 5] - assert result["mutation_percent_genes"] == None + assert result["mutation_percent_genes"] is None assert result["mutation_by_replacement"] is False From 03a4f827ce59219994f04bf1bb919f68967e2dec Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:45:38 +0000 Subject: [PATCH 30/32] refactor docs --- docs/source/algorithms.md | 70 ++++++------------ src/optimagic/optimizers/pygad_optimizer.py | 82 ++++++++++++--------- 2 files changed, 69 insertions(+), 83 deletions(-) diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index 5f09b785c..f37778ebb 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4701,63 +4701,37 @@ package. To use it, you need to have - **seed**: Seed for the random number generator for reproducibility. ``` -## PyGAD Optimizer +## Pygad Optimizer -optimagic supports the [PyGAD](https://github.com/ahmedfgad/GeneticAlgorithmPython) -genetic algorithm optimizer. To use PyGAD, you need to have -[the pygad package](https://github.com/ahmedfgad/GeneticAlgorithmPython) installed -(`pip install pygad`). +We wrap the pygad optimizer. To use it you need to have [pygad](https://pygad.readthedocs.io/en/latest/) installed. -```{eval-rst} -.. dropdown:: pygad - - .. code-block:: - "pygad" +```{eval-rst} +.. dropdown:: pygad - Minimize a scalar function using the PyGAD genetic algorithm. + **How to use this algorithm:** - PyGAD is a Python library for building genetic algorithms and training machine learning algorithms. - Genetic algorithms are metaheuristics inspired by the process of natural selection that belong to - the larger class of evolutionary algorithms. These algorithms apply biologically inspired - operators such as mutation, crossover, and selection to optimization problems. + .. code-block:: - The algorithm maintains a population of candidate solutions and iteratively improves them - through genetic operations, making it ideal for global optimization problems with complex - search spaces that may contain multiple local optima. + import optimagic as om + om.minimize( + ..., + algorithm=om.algos.pygad(num_generations=100, ...) + ) + + or + + .. code-block:: - The algorithm supports the following options: + om.minimize( + ..., + algorithm="pygad", + algo_options={"num_generations": 100, ...} + ) - - **population_size** (int): Number of solutions in each generation. Default is None. - - **num_parents_mating** (int): Number of parents selected for mating in each generation. Default is None. - - **num_generations** (int): Number of generations. Default is None. - - **initial_population** (array-like): initial population is a 2D array where - each row represents a solution and each column represents a parameter (gene) value. - The number of rows must equal population_size, and the number of columns must - match the length of the initial parameters (x0). - When None, the population is randomly generated within the parameter bounds using - the specified population_size and the dimensionality from x0. - - **parent_selection_type** (str or callable): Method for selecting parents. Can be a string ("sss", "rws", "sus", "rank", "random", "tournament") or a custom function with signature ``parent_selection_func(fitness, num_parents, ga_instance) -> tuple[NDArray, NDArray]``. Default is "sss". - - **keep_parents** (int): Number of best parents to keep in the next generation. Only has effect when keep_elitism is 0. Default is -1. - - **keep_elitism** (int): Number of best solutions to preserve across generations. If non-zero, keep_parents has no effect. Default is 1. - - **K_tournament** (int): Tournament size for tournament selection. Only used when parent_selection_type is "tournament". Default is 3. - - **crossover_type** (str, callable, or None): Crossover method. Can be a string ("single_point", "two_points", "uniform", "scattered"), a custom function with signature ``crossover_func(parents, offspring_size, ga_instance) -> NDArray``, or None to disable crossover. Default is "single_point". - - **crossover_probability** (float): Probability of applying crossover. Range [0, 1]. Default is None. - - **mutation_type** (str, callable, or None): Mutation method. Can be a string ("random", "swap", "inversion", "scramble", "adaptive"), a custom function with signature ``mutation_func(offspring, ga_instance) -> NDArray``, or None to disable mutation. Default is "random". - - **mutation_probability** (float/list/tuple/array): Probability of mutation. Range [0, 1]. If specified, mutation_percent_genes and mutation_num_genes are ignored. Default is None. - - **mutation_percent_genes** (float/str/list/tuple/array): Percentage of genes to mutate. Default is "default" (equivalent to 10%). Ignored if mutation_probability or mutation_num_genes are specified. - - **mutation_num_genes** (int/list/tuple/array): Exact number of genes to mutate. Ignored if mutation_probability is specified. Default is None. - - **mutation_by_replacement** (bool): Whether to replace gene values during mutation. Only works with mutation_type="random". Default is False. - - **random_mutation_min_val** (float/list/array): Minimum value for random mutation. Only used with mutation_type="random". Default is -1.0. - - **random_mutation_max_val** (float/list/array): Maximum value for random mutation. Only used with mutation_type="random". Default is 1.0. - - **allow_duplicate_genes** (bool): Whether to allow duplicate gene values within a solution. Default is True. - - **gene_constraint** (list of callables or None): List of constraint functions for gene values. Each function takes (solution, values) and returns filtered values meeting constraints. Functions should have signature ``constraint_func(solution, values) -> list[float] | NDArray``. Use None for genes without constraints. Default is None. - - **sample_size** (int): Number of values to sample when finding unique values or enforcing gene constraints. Used when allow_duplicate_genes=False or when gene_constraint is specified. Default is 100. - - **fitness_batch_size** (int): Number of solutions to evaluate in parallel batches. When None and n_cores > 1, automatically set to n_cores for optimal parallelization. Default is None. - - **stop_criteria** (str/list): Early stopping criteria. Format: "reach_value" or "saturate_N". Default is None. - - **n_cores** (int): Number of cores for parallel fitness evaluation. Default is 1. - - **random_seed** (int): Random seed for reproducibility. Default is None. + **Description and available options:** + .. autoclass:: optimagic.optimizers.pygad_optimizer.Pygad ``` ## References diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index ef1e93122..f6d43408a 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -423,13 +423,13 @@ class Pygad(Algorithm): """Parent selection strategy used to choose parents for crossover. Available methods: - - "sss": Steady-State Selection (selects the best individuals to continue) - - "rws": Roulette Wheel Selection (probabilistic, fitness-proportional) - - "sus": Stochastic Universal Sampling (even sampling across population) - - "rank": Rank Selection (selects based on rank order) - - "random": Random Selection - - "tournament": Tournament Selection (best from K randomly chosen - individuals) + + * ``"sss"``: Steady-State Selection (selects the best individuals to continue) + * ``"rws"``: Roulette Wheel Selection (probabilistic, fitness-proportional) + * ``"sus"``: Stochastic Universal Sampling (even sampling across population) + * ``"rank"``: Rank Selection (selects based on rank order) + * ``"random"``: Random Selection + * ``"tournament"``: Tournament Selection (best from K randomly chosen individuals) Alternatively, provide a custom function with signature ``(fitness, num_parents, ga_instance) -> tuple[NDArray, NDArray]``. @@ -440,9 +440,10 @@ class Pygad(Algorithm): """Number of best parents to keep in the next generation. Only used if ``keep_elitism = 0``. Values: - - -1: Keep all parents in the next generation (default) - - 0: Keep no parents in the next generation - - Positive integer: Keep the specified number of best parents + + * ``-1``: Keep all parents in the next generation (default) + * ``0``: Keep no parents in the next generation + * Positive integer: Keep the specified number of best parents """ @@ -467,10 +468,11 @@ class Pygad(Algorithm): """Crossover operator for generating offspring. Available methods: - - "single_point": Single-point crossover - - "two_points": Two-point crossover - - "uniform": Uniform crossover (randomly mixes genes) - - "scattered": Scattered crossover (random mask) + + * ``"single_point"``: Single-point crossover + * ``"two_points"``: Two-point crossover + * ``"uniform"``: Uniform crossover (randomly mixes genes) + * ``"scattered"``: Scattered crossover (random mask) Or provide a custom function with signature ``(parents, offspring_size, ga_instance) -> NDArray``. @@ -494,25 +496,34 @@ class Pygad(Algorithm): """Mutation operator for introducing genetic diversity. Available options: - 1. String values for default configurations: - * "random": Random mutation with default parameters - * "swap": Swap mutation with default parameters - * "inversion": Inversion mutation with default parameters - * "scramble": Scramble mutation with default parameters - * "adaptive": Adaptive random mutation with default parameters - 2. Mutation classes for default configurations: - * Any mutation class (e.g., ``RandomMutation``, ``SwapMutation``, - ``AdaptiveMutation``, etc.) - * All classes can be used without parameters for default behavior + **String values for default configurations:** + + * ``"random"``: Random mutation with default parameters + * ``"swap"``: Swap mutation with default parameters + * ``"inversion"``: Inversion mutation with default parameters + * ``"scramble"``: Scramble mutation with default parameters + * ``"adaptive"``: Adaptive random mutation with default parameters + + **Mutation classes for default configurations:** + + * Any mutation class (e.g., ``RandomMutation``, ``SwapMutation``, + ``AdaptiveMutation``, etc.) + * All classes can be used without parameters for default behavior - 3. Configured mutation instances: - * Any mutation instance (e.g., ``RandomMutation(...)``, - ``SwapMutation()``, etc.) - * All mutation classes inherit from ``BaseMutation`` + **Configured mutation instances:** - 4. Custom function with signature ``(offspring, ga_instance) -> NDArray`` - 5. None to disable mutation + * Any mutation instance (e.g., ``RandomMutation(...)``, + ``SwapMutation()``, etc.) + * All mutation classes inherit from ``BaseMutation`` + + **Custom function:** + + * Custom function with signature ``(offspring, ga_instance) -> NDArray`` + + **Disable mutation:** + + * ``None`` to disable mutation """ @@ -542,12 +553,13 @@ class Pygad(Algorithm): Can be a string or list of strings. Supported criteria: - - "reach_{value}": Stop when fitness reaches the specified value, - e.g. "reach_0.01" - - "saturate_{generations}": Stop if fitness doesn't improve for the given - number of generations, e.g. "saturate_10" - Can specify multiple criteria as a list. + * ``"reach_{value}"``: Stop when fitness reaches the specified value, e.g. + ``"reach_0.01"`` + * ``"saturate_{generations}"``: Stop if fitness doesn't improve for the given number + of generations, e.g. ``"saturate_10"`` + + Multiple criteria can be specified as a list. """ From 618537a47aaa75c60ee0526f561df4d794861b8c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:46:22 +0000 Subject: [PATCH 31/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/source/algorithms.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index f37778ebb..9fbab6d56 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4703,8 +4703,8 @@ package. To use it, you need to have ## Pygad Optimizer -We wrap the pygad optimizer. To use it you need to have [pygad](https://pygad.readthedocs.io/en/latest/) installed. - +We wrap the pygad optimizer. To use it you need to have +[pygad](https://pygad.readthedocs.io/en/latest/) installed. ```{eval-rst} .. dropdown:: pygad From 3fc18dd43076d81830b6f240755b1f3599461176 Mon Sep 17 00:00:00 2001 From: spline2hg <181270613+spline2hg@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:40:29 +0000 Subject: [PATCH 32/32] rename BaseMutation to _BuiltinMutation and reframe docsting --- src/optimagic/optimizers/pygad_optimizer.py | 54 +++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/optimagic/optimizers/pygad_optimizer.py b/src/optimagic/optimizers/pygad_optimizer.py index f6d43408a..0697d1c20 100644 --- a/src/optimagic/optimizers/pygad_optimizer.py +++ b/src/optimagic/optimizers/pygad_optimizer.py @@ -109,12 +109,15 @@ def __call__( @dataclass(frozen=True) -class BaseMutation: - """Base class for all PyGAD mutation configurations. +class _BuiltinMutation: + """Base class for all built-in PyGAD mutation configurations. - Provides default implementation for converting mutation configurations to PyGAD - parameters. Simple mutations can use this directly, while complex mutations can - override it. + Note: + This is an internal base class. Users should not inherit from it + directly. To configure a built-in mutation, use one of its subclasses + (e.g., `RandomMutation`, `AdaptiveMutation`). To define a custom + mutation, provide a function that conforms to the `MutationFunction` + protocol. """ @@ -140,7 +143,7 @@ def to_pygad_params(self) -> dict[str, Any]: @dataclass(frozen=True) -class RandomMutation(BaseMutation): +class RandomMutation(_BuiltinMutation): """Configuration for the random mutation in PyGAD. The random mutation selects a subset of genes in each solution and either @@ -202,7 +205,7 @@ def to_pygad_params(self) -> dict[str, Any]: @dataclass(frozen=True) -class SwapMutation(BaseMutation): +class SwapMutation(_BuiltinMutation): """Configuration for the swap mutation in PyGAD. The swap mutation selects two random genes and exchanges their values. This @@ -217,7 +220,7 @@ class SwapMutation(BaseMutation): @dataclass(frozen=True) -class InversionMutation(BaseMutation): +class InversionMutation(_BuiltinMutation): """Configuration for the inversion mutation in PyGAD. The inversion mutation selects a contiguous segment of genes and reverses their @@ -232,7 +235,7 @@ class InversionMutation(BaseMutation): @dataclass(frozen=True) -class ScrambleMutation(BaseMutation): +class ScrambleMutation(_BuiltinMutation): """Configuration for the scramble mutation in PyGAD. The scramble mutation randomly shuffles the genes within a contiguous segment. This @@ -246,14 +249,14 @@ class ScrambleMutation(BaseMutation): @dataclass(frozen=True) -class AdaptiveMutation(BaseMutation): +class AdaptiveMutation(_BuiltinMutation): """Configuration for the adaptive mutation in PyGAD. - The adaptive mutation dynamically adjusts the mutation rate based on the - fitness of solutions. Typically, solutions with below-average fitness - (bad fitness solutions) receive a higher mutation rate to encourage - exploration, while above-average solutions (good fitness solutions) - receive a lower rate to preserve good solutions. + The adaptive mutation dynamically adjusts the mutation rate based on + solution quality. Solutions whose objective value is worse than the + current population median receive a higher mutation rate to encourage + exploration, while better-than-median solutions receive a lower rate + to preserve promising traits. If no mutation rate parameters are specified, this mutation defaults to using probabilities, with a 10% rate for bad solutions (`probability_bad=0.1`) @@ -488,8 +491,8 @@ class Pygad(Algorithm): mutation: ( Literal["random", "swap", "inversion", "scramble", "adaptive"] - | type[BaseMutation] - | BaseMutation + | type[_BuiltinMutation] + | _BuiltinMutation | MutationFunction | None ) = "random" @@ -515,7 +518,7 @@ class Pygad(Algorithm): * Any mutation instance (e.g., ``RandomMutation(...)``, ``SwapMutation()``, etc.) - * All mutation classes inherit from ``BaseMutation`` + * All mutation classes inherit from ``_BuiltinMutation`` **Custom function:** @@ -554,11 +557,10 @@ class Pygad(Algorithm): Supported criteria: - * ``"reach_{value}"``: Stop when fitness reaches the specified value, e.g. - ``"reach_0.01"`` - * ``"saturate_{generations}"``: Stop if fitness doesn't improve for the given number - of generations, e.g. ``"saturate_10"`` - + * ``"reach_{value}"``: Stop when the objective value reaches the specified + threshold, e.g. ``"reach_0.01"`` + * ``"saturate_{generations}"``: Stop if the objective value has not improved + for the given number of generations, e.g. ``"saturate_10"`` Multiple criteria can be specified as a list. """ @@ -714,11 +716,11 @@ def _convert_mutation_to_pygad_params(mutation: Any) -> dict[str, Any]: mutation_instance = _create_mutation_from_string(mutation) params = mutation_instance.to_pygad_params() - elif isinstance(mutation, type) and issubclass(mutation, BaseMutation): + elif isinstance(mutation, type) and issubclass(mutation, _BuiltinMutation): mutation_instance = mutation() params = mutation_instance.to_pygad_params() - elif isinstance(mutation, BaseMutation): + elif isinstance(mutation, _BuiltinMutation): params = mutation.to_pygad_params() elif isinstance(mutation, MutationFunction): @@ -741,7 +743,7 @@ def _get_default_mutation_params(mutation_type: Any = "random") -> dict[str, Any } -def _create_mutation_from_string(mutation_type: str) -> BaseMutation: +def _create_mutation_from_string(mutation_type: str) -> _BuiltinMutation: """Create a mutation instance from a string type. Args: