diff --git a/docs/source/conf.py b/docs/source/conf.py index 9dccbea..6619c49 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,11 +18,11 @@ # -- Project information ----------------------------------------------------- project = 'PyGAD' -copyright = '2023, Ahmed Fawzy Gad' +copyright = '2024, Ahmed Fawzy Gad' author = 'Ahmed Fawzy Gad' # The full version, including alpha/beta/rc tags -release = '3.2.0' +release = '3.3.0' master_doc = 'index' diff --git a/docs/source/pygad.rst b/docs/source/pygad.rst index 33a3bb3..174c62e 100644 --- a/docs/source/pygad.rst +++ b/docs/source/pygad.rst @@ -920,9 +920,7 @@ It accepts the following parameters: - ``pop_fitness=None``: An optional parameter that accepts a list of the fitness values of the solutions in the population. If ``None``, then the ``cal_pop_fitness()`` method is called to calculate the - fitness values of the ``self.population``. Use - ``ga_instance.last_generation_fitness`` to use latest fitness value - and skip recalculation of the population fitness. + fitness values of the population. It returns the following: @@ -1060,15 +1058,15 @@ optimization problem is single-objective or multi-objective. ``pygad.GA`` class. - If the fitness function returns a ``list``, ``tuple``, or - ``numpy.ndarray``, then the problem is multi-objective. Even if - there is only one element, the problem is still considered - multi-objective. Each element represents the fitness value of its - corresponding objective. + ``numpy.ndarray``, then the problem is multi-objective. Even if there + is only one element, the problem is still considered multi-objective. + Each element represents the fitness value of its corresponding + objective. Using a user-defined fitness function allows the user to freely use -PyGAD solves any problem by passing the appropriate fitness -function/method. It is very important to understand the problem well before -creating it. +PyGAD to solve any problem by passing the appropriate fitness +function/method. It is very important to understand the problem well +before creating it. Let's discuss an example: diff --git a/docs/source/pygad_more.rst b/docs/source/pygad_more.rst index e7c7a8e..69fa342 100644 --- a/docs/source/pygad_more.rst +++ b/docs/source/pygad_more.rst @@ -344,10 +344,13 @@ is applied based on this parameter. How Mutation Works with the ``gene_space`` Parameter? ----------------------------------------------------- -If a gene has its static space defined in the ``gene_space`` parameter, -then mutation works by replacing the gene value by a value randomly -selected from the gene space. This happens for both ``int`` and -``float`` data types. +Mutation changes based on whether the ``gene_space`` has a continuous +range or discrete set of values. + +If a gene has its **static/discrete space** defined in the +``gene_space`` parameter, then mutation works by replacing the gene +value by a value randomly selected from the gene space. This happens for +both ``int`` and ``float`` data types. For example, the following ``gene_space`` has the static space ``[1, 2, 3]`` defined for the first gene. So, this gene can only have a @@ -377,6 +380,39 @@ If its current value is 5 and the random value is ``-0.5``, then the new value is 4.5. If the gene type is integer, then the value will be rounded. +On the other hand, if a gene has a **continuous space** defined in the +``gene_space`` parameter, then mutation occurs by adding a random value +to the current gene value. + +For example, the following ``gene_space`` has the continuous space +defined by the dictionary ``{'low': 1, 'high': 5}``. This applies to all +genes. So, mutation is applied to one or more selected genes by adding a +random value to the current gene value. + +.. code:: python + + Gene space: {'low': 1, 'high': 5} + Solution: [1.5, 3.4] + +Assuming ``random_mutation_min_val=-1`` and +``random_mutation_max_val=1``, then a random value such as ``0.3`` can +be added to the gene(s) participating in mutation. If only the first +gene is mutated, then its new value changes from ``1.5`` to +``1.5+0.3=1.8``. Note that PyGAD verifies that the new value is within +the range. In the worst scenarios, the value will be set to either +boundary of the continuous range. For example, if the gene value is 1.5 +and the random value is -0.55, then the new value is 0.95 which smaller +than the lower boundary 1. Thus, the gene value will be rounded to 1. + +If the dictionary has a step like the example below, then it is +considered a discrete range and mutation occurs by randomly selecting a +value from the set of values. In other words, no random value is added +to the gene value. + +.. code:: python + + Gene space: {'low': 1, 'high': 5, 'step': 0.5} + Stop at Any Generation ====================== @@ -596,7 +632,6 @@ After running the code again, it will find the same result. 0.04872203136549972 Continue without Losing Progress -================================= In `PyGAD 2.18.0 `__, @@ -663,6 +698,70 @@ Note that the 2 attributes (``self.best_solutions`` and attributes (``self.solutions`` and ``self.solutions_fitness``) only work if the ``save_solutions`` parameter is ``True``. +Change Population Size during Runtime +===================================== + +Starting from `PyGAD +3.3.0 `__, +the population size can changed during runtime. In other words, the +number of solutions/chromosomes and number of genes can be changed. + +The user has to carefully arrange the list of *parameters* and *instance +attributes* that have to be changed to keep the GA consistent before and +after changing the population size. Generally, change everything that +would be used during the GA evolution. + + CAUTION: If the user failed to change a parameter or an instance + attributes necessary to keep the GA running after the population size + changed, errors will arise. + +These are examples of the parameters that the user should decide whether +to change. The user should check the `list of +parameters `__ +and decide what to change. + +1. ``population``: The population. It *must* be changed. + +2. ``num_offspring``: The number of offspring to produce out of the + crossover and mutation operations. Change this parameter if the + number of offspring have to be changed to be consistent with the new + population size. + +3. ``num_parents_mating``: The number of solutions to select as parents. + Change this parameter if the number of parents have to be changed to + be consistent with the new population size. + +4. ``fitness_func``: If the way of calculating the fitness changes after + the new population size, then the fitness function have to be + changed. + +5. ``sol_per_pop``: The number of solutions per population. It is not + critical to change it but it is recommended to keep this number + consistent with the number of solutions in the ``population`` + parameter. + +These are examples of the instance attributes that might be changed. The +user should check the `list of instance +attributes `__ +and decide what to change. + +1. All the ``last_generation_*`` parameters + + 1. ``last_generation_fitness``: A 1D NumPy array of fitness values of + the population. + + 2. ``last_generation_parents`` and + ``last_generation_parents_indices``: Two NumPy arrays: 2D array + representing the parents and 1D array of the parents indices. + + 3. ``last_generation_elitism`` and + ``last_generation_elitism_indices``: Must be changed if + ``keep_elitism != 0``. The default value of ``keep_elitism`` is 1. + Two NumPy arrays: 2D array representing the elitism and 1D array + of the elitism indices. + +2. ``pop_size``: The population size. + Prevent Duplicates in Gene Values ================================= diff --git a/docs/source/releases.rst b/docs/source/releases.rst index 8fbaa7a..2674eb1 100644 --- a/docs/source/releases.rst +++ b/docs/source/releases.rst @@ -1464,6 +1464,74 @@ Release Date 7 September 2023 class is removed. Instead, please use the ``plot_fitness()`` if you did not upgrade yet. +.. _pygad-330: + +PyGAD 3.3.0 +----------- + +Release Date 29 January 2024 + +1. Solve bugs when multi-objective optimization is used. + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 + +2. When the ``stop_ciiteria`` parameter is used with the ``reach`` + keyword, then multiple numeric values can be passed when solving a + multi-objective problem. For example, if a problem has 3 objective + functions, then ``stop_criteria="reach_10_20_30"`` means the GA + stops if the fitness of the 3 objectives are at least 10, 20, and + 30, respectively. The number values must match the number of + objective functions. If a single value found (e.g. + ``stop_criteria=reach_5``) when solving a multi-objective problem, + then it is used across all the objectives. + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 + +3. The ``delay_after_gen`` parameter is now deprecated and will be + removed in a future release. If it is necessary to have a time delay + after each generation, then assign a callback function/method to the + ``on_generation`` parameter to pause the evolution. + +4. Parallel processing now supports calculating the fitness during + adaptive mutation. + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201 + +5. The population size can be changed during runtime by changing all + the parameters that would affect the size of any thing used by the + GA. For more information, check the `Change Population Size during + Runtime `__ + section. + https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/234 + +6. When a dictionary exists in the ``gene_space`` parameter without a + step, then mutation occurs by adding a random value to the gene + value. The random vaue is generated based on the 2 parameters + ``random_mutation_min_val`` and ``random_mutation_max_val``. For + more information, check the `How Mutation Works with the gene_space + Parameter? `__ + section. + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/229 + +7. Add ``object`` as a supported data type for int + (GA.supported_int_types) and float (GA.supported_float_types). + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/174 + +8. Use the ``raise`` clause instead of the ``sys.exit(-1)`` to + terminate the execution. + https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/213 + +9. Fix a bug when multi-objective optimization is used with batch + fitness calculation (e.g. ``fitness_batch_size`` set to a non-zero + number). + +10. Fix a bug in the ``pygad.py`` script when finding the index of the + best solution. It does not work properly with multi-objective + optimization where ``self.best_solutions_fitness`` have multiple + columns. + +.. code:: python + + self.best_solution_generation = numpy.where(numpy.array( + self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + PyGAD Projects at GitHub ======================== diff --git a/examples/example_dynamic_population_size.py b/examples/example_dynamic_population_size.py index 190e61f..8a724b3 100644 --- a/examples/example_dynamic_population_size.py +++ b/examples/example_dynamic_population_size.py @@ -3,44 +3,83 @@ """ This is an example to dynamically change the population size (i.e. number of solutions/chromosomes per population) during runtime. -The following 2 instance attributes must be changed to meet the new desired population size: - 1) population: This is a NumPy array holding the population. - 2) num_offspring: This represents the number of offspring to produce during crossover. -For example, if the population initially has 20 solutions and 6 genes. To change it to have 30 solutions, then: - 1)population: Create a new NumPy array with the desired size (30, 6) and assign it to the population instance attribute. - 2)num_offspring: Set the num_offspring attribute accordingly (e.g. 29 assuming that keep_elitism has the default value of 1). + +The user has to carefully inspect the parameters and instance attributes to select those that must be changed to be consistent with the new population size. +Check this link for more information: https://pygad.readthedocs.io/en/latest/pygad_more.html#change-population-size-during-runtime """ +def update_GA(ga_i, + pop_size): + """ + Update the parameters and instance attributes to match the new population size. + + Parameters + ---------- + ga_i : TYPE + The pygad.GA instance. + pop_size : TYPE + The new population size. + + Returns + ------- + None. + """ + + ga_i.pop_size = pop_size + ga_i.sol_per_pop = ga_i.pop_size[0] + ga_i.num_parents_mating = int(ga_i.pop_size[0]/2) + + # Calculate the new value for the num_offspring parameter. + if ga_i.keep_elitism != 0: + ga_i.num_offspring = ga_i.sol_per_pop - ga_i.keep_elitism + elif ga_i.keep_parents != 0: + if ga_i.keep_parents == -1: + ga_i.num_offspring = ga_i.sol_per_pop - ga_i.num_parents_mating + else: + ga_i.num_offspring = ga_i.sol_per_pop - ga_i.keep_parents + + ga_i.num_genes = ga_i.pop_size[1] + ga_i.population = numpy.random.uniform(low=ga_i.init_range_low, + high=ga_i.init_range_low, + size=ga_i.pop_size) + fitness = [] + for solution, solution_idx in enumerate(ga_i.population): + fitness.append(fitness_func(ga_i, solution, solution_idx)) + ga_i.last_generation_fitness = numpy.array(fitness) + parents, parents_fitness = ga_i.steady_state_selection(ga_i.last_generation_fitness, + ga_i.num_parents_mating) + ga_i.last_generation_elitism = parents[:ga_i.keep_elitism] + ga_i.last_generation_elitism_indices = parents_fitness[:ga_i.keep_elitism] + + ga_i.last_generation_parents = parents + ga_i.last_generation_parents_indices = parents_fitness + def fitness_func(ga_instance, solution, solution_idx): - return [numpy.random.rand(), numpy.random.rand()] + return numpy.sum(solution) def on_generation(ga_i): # The population starts with 20 solutions. - print(ga_i.generations_completed, ga_i.num_offspring, ga_i.population.shape) - # At generation 15, increase the population size to 40 solutions. + print(ga_i.generations_completed, ga_i.population.shape) + # At generation 15, set the population size to 30 solutions and 10 genes. if ga_i.generations_completed >= 15: - ga_i.num_offspring = 49 - new_population = numpy.zeros(shape=(ga_i.num_offspring+1, ga_i.population.shape[1]), dtype=ga_i.population.dtype) - new_population[:ga_i.population.shape[0], :] = ga_i.population - ga_i.population = new_population + ga_i.pop_size = (30, 10) + update_GA(ga_i=ga_i, + pop_size=(30, 10)) + # At generation 10, set the population size to 15 solutions and 8 genes. elif ga_i.generations_completed >= 10: - ga_i.num_offspring = 39 - new_population = numpy.zeros(shape=(ga_i.num_offspring+1, ga_i.population.shape[1]), dtype=ga_i.population.dtype) - new_population[:ga_i.population.shape[0], :] = ga_i.population - ga_i.population = new_population - # At generation 10, increase the population size to 30 solutions. + update_GA(ga_i=ga_i, + pop_size=(15, 8)) + # At generation 5, set the population size to 10 solutions and 3 genes. elif ga_i.generations_completed >= 5: - ga_i.num_offspring = 29 - new_population = numpy.zeros(shape=(ga_i.num_offspring+1, ga_i.population.shape[1]), dtype=ga_i.population.dtype) - new_population[:ga_i.population.shape[0], :] = ga_i.population - ga_i.population = new_population + update_GA(ga_i=ga_i, + pop_size=(10, 3)) ga_instance = pygad.GA(num_generations=20, sol_per_pop=20, num_genes=6, num_parents_mating=10, fitness_func=fitness_func, - on_generation=on_generation, - parent_selection_type='nsga2') + on_generation=on_generation) ga_instance.run() + diff --git a/examples/example_parallel_processing.py b/examples/example_parallel_processing.py new file mode 100644 index 0000000..9efd1ea --- /dev/null +++ b/examples/example_parallel_processing.py @@ -0,0 +1,39 @@ +import pygad +import numpy + +function_inputs = [4,-2,3.5,5,-11,-4.7] # Function inputs. +desired_output = 44 # Function output. + +def fitness_func(ga_instance, solution, solution_idx): + output = numpy.sum(solution*function_inputs) + fitness = 1.0 / (numpy.abs(output - desired_output) + 0.000001) + return fitness + +last_fitness = 0 +def on_generation(ga_instance): + global last_fitness + print(f"Generation = {ga_instance.generations_completed}") + print(f"Fitness = {ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1]}") + print(f"Change = {ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1] - last_fitness}") + last_fitness = ga_instance.best_solution(pop_fitness=ga_instance.last_generation_fitness)[1] + +if __name__ == '__main__': + ga_instance = pygad.GA(num_generations=100, + num_parents_mating=10, + sol_per_pop=20, + num_genes=len(function_inputs), + fitness_func=fitness_func, + on_generation=on_generation, + # parallel_processing=['process', 2], + parallel_processing=['thread', 2] + ) + + # Running the GA to optimize the parameters of the function. + ga_instance.run() + + # Returning the details of the best solution. + solution, solution_fitness, solution_idx = ga_instance.best_solution(ga_instance.last_generation_fitness) + print(f"Parameters of the best solution : {solution}") + print(f"Fitness value of the best solution = {solution_fitness}") + print(f"Index of the best solution : {solution_idx}") + diff --git a/pygad/__init__.py b/pygad/__init__.py index c098928..e674105 100644 --- a/pygad/__init__.py +++ b/pygad/__init__.py @@ -1,3 +1,3 @@ from .pygad import * # Relative import. -__version__ = "3.2.0" +__version__ = "3.3.0" diff --git a/pygad/pygad.py b/pygad/pygad.py index 53b1bfa..d439ce6 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -9,7 +9,6 @@ from pygad import utils from pygad import helper from pygad import visualize -import sys # Extend all the classes so that they can be referenced by just the `self` object of the `pygad.GA` class. class GA(utils.parent_selection.ParentSelection, @@ -20,9 +19,10 @@ class GA(utils.parent_selection.ParentSelection, visualize.plot.Plot): supported_int_types = [int, numpy.int8, numpy.int16, numpy.int32, numpy.int64, - numpy.uint, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64] - supported_float_types = [ - float, numpy.float16, numpy.float32, numpy.float64] + numpy.uint, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, + object] + supported_float_types = [float, numpy.float16, numpy.float32, numpy.float64, + object] supported_int_float_types = supported_int_types + supported_float_types def __init__(self, @@ -1332,7 +1332,8 @@ def __init__(self, self.pareto_fronts = None except Exception as e: self.logger.exception(e) - sys.exit(-1) + # sys.exit(-1) + raise e def round_genes(self, solutions): for gene_idx in range(self.num_genes): @@ -1866,7 +1867,8 @@ def cal_pop_fitness(self): pop_fitness = numpy.array(pop_fitness) except Exception as ex: self.logger.exception(ex) - sys.exit(-1) + # sys.exit(-1) + raise ex return pop_fitness def run(self): @@ -2241,7 +2243,8 @@ def run(self): # self.solutions = numpy.array(self.solutions) except Exception as ex: self.logger.exception(ex) - sys.exit(-1) + # sys.exit(-1) + raise ex def best_solution(self, pop_fitness=None): """ @@ -2276,7 +2279,8 @@ def best_solution(self, pop_fitness=None): best_solution_fitness = pop_fitness[best_match_idx] except Exception as ex: self.logger.exception(ex) - sys.exit(-1) + # sys.exit(-1) + raise ex return best_solution, best_solution_fitness, best_match_idx diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index 95bf6e5..093cf85 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -3,4 +3,4 @@ from pygad.utils import mutation from pygad.utils import nsga2 -__version__ = "1.1.0" \ No newline at end of file +__version__ = "1.1.1" \ No newline at end of file diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 9aac12f..d0ca1b4 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -89,14 +89,40 @@ def mutation_by_space(self, offspring): # The numpy.random.choice() and numpy.random.uniform() functions return a NumPy array as the output even if the array has a single value. # We have to return the output at index 0 to force a numeric value to be returned not an object of type numpy.ndarray. # If numpy.ndarray is returned, then it will cause an issue later while using the set() function. + # Randomly select a value from a discrete range. value_from_space = numpy.random.choice(numpy.arange(start=curr_gene_space['low'], stop=curr_gene_space['high'], step=curr_gene_space['step']), size=1)[0] else: - value_from_space = numpy.random.uniform(low=curr_gene_space['low'], - high=curr_gene_space['high'], - size=1)[0] + # Return the current gene value. + value_from_space = offspring[offspring_idx, gene_idx] + # Generate a random value to be added to the current gene value. + rand_val = numpy.random.uniform(low=range_min, + high=range_max, + size=1)[0] + # The objective is to have a new gene value that respects the gene_space boundaries. + # The next if-else block checks if adding the random value keeps the new gene value within the gene_space boundaries. + temp_val = value_from_space + rand_val + if temp_val < curr_gene_space['low']: + # Restrict the new value to be > curr_gene_space['low'] + # If subtracting the random value makes the new gene value outside the boundaries [low, high), then use the lower boundary the gene value. + if curr_gene_space['low'] <= value_from_space - rand_val < curr_gene_space['high']: + # Because subtracting the random value keeps the new gene value within the boundaries [low, high), then use such a value as the gene value. + temp_val = value_from_space - rand_val + else: + # Because subtracting the random value makes the new gene value outside the boundaries [low, high), then use the lower boundary as the gene value. + temp_val = curr_gene_space['low'] + elif temp_val >= curr_gene_space['high']: + # Restrict the new value to be < curr_gene_space['high'] + # If subtracting the random value makes the new gene value outside the boundaries [low, high), then use such a value as the gene value. + if curr_gene_space['low'] <= value_from_space - rand_val < curr_gene_space['high']: + # Because subtracting the random value keeps the new value within the boundaries [low, high), then use such a value as the gene value. + temp_val = value_from_space - rand_val + else: + # Because subtracting the random value makes the new gene value outside the boundaries [low, high), then use the lower boundary as the gene value. + temp_val = curr_gene_space['low'] + value_from_space = temp_val else: # Selecting a value randomly based on the current gene's space in the 'gene_space' attribute. # If the gene space has only 1 value, then select it. The old and new values of the gene are identical. diff --git a/pyproject.toml b/pyproject.toml index 4f5055e..7289a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "pygad" -version = "3.2.0" +version = "3.3.0" description = "PyGAD: A Python Library for Building the Genetic Algorithm and Training Machine Learning Algoithms (Keras & PyTorch)." readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3" diff --git a/setup.py b/setup.py index 9017723..5b24838 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="pygad", - version="3.2.0", + version="3.3.0", author="Ahmed Fawzy Gad", install_requires=["numpy", "matplotlib", "cloudpickle",], author_email="ahmed.f.gad@gmail.com",