diff --git a/pygad/pygad.py b/pygad/pygad.py index 88a2d9d..1a34b9a 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -49,6 +49,7 @@ def __init__(self, random_mutation_min_val=-1.0, random_mutation_max_val=1.0, gene_space=None, + gene_constraint=None, allow_duplicate_genes=True, on_start=None, on_fitness=None, @@ -104,6 +105,8 @@ def __init__(self, gene_space: It accepts a list of all possible values of the gene. This list is used in the mutation step. Should be used only if the gene space is a set of discrete values. No need for the 2 parameters (random_mutation_min_val and random_mutation_max_val) if the parameter gene_space exists. Added in PyGAD 2.5.0. In PyGAD 2.11.0, the gene_space can be assigned a dict. + gene_constraint: It accepts a list of constraints for the genes. Each constraint is a Python function. Added in PyGAD 3.5.0. + on_start: Accepts a function/method to be called only once before the genetic algorithm starts its evolution. If function, then it must accept a single parameter representing the instance of the genetic algorithm. If method, then it must accept 2 parameters where the second one refers to the method's object. Added in PyGAD 2.6.0. on_fitness: Accepts a function/method to be called after calculating the fitness values of all solutions in the population. If function, then it must accept 2 parameters: 1) a list of all solutions' fitness values 2) the instance of the genetic algorithm. If method, then it must accept 3 parameters where the third one refers to the method's object. Added in PyGAD 2.6.0. on_parents: Accepts a function/method to be called after selecting the parents that mates. If function, then it must accept 2 parameters: the first one represents the instance of the genetic algorithm and the second one represents the selected parents. If method, then it must accept 3 parameters where the third one refers to the method's object. Added in PyGAD 2.6.0. @@ -558,13 +561,38 @@ def __init__(self, self.random_mutation_min_val = random_mutation_min_val self.random_mutation_max_val = random_mutation_max_val + # Validate that gene_constraint is a list or tuple and every element inside it is either None or callable. + if gene_constraint: + if type(gene_constraint) in [list, tuple]: + for constraint_idx, item in enumerate(gene_constraint): + # Check whether the element is None or a callable. + if item and callable(item): + if item.__code__.co_argcount == 1: + # Every callable is valid if it receives a single argument. + # This argument represents the solution. + pass + else: + self.valid_parameters = False + raise ValueError(f"Every callable inside the gene_constraint parameter must accept a single argument representing the solution/chromosome. But the callable at index {constraint_idx} named '{item.__code__.co_name}' accepts {item.__code__.co_argcount} argument(s).") + else: + self.valid_parameters = False + raise TypeError(f"The expected type of an element in the 'gene_constraint' parameter is None or a callable (e.g. function). But {item} at index {constraint_idx} of type {type(item)} found.") + else: + self.valid_parameters = False + raise TypeError(f"The expected type of the 'gene_constraint' parameter is either list or tuple. But the value {gene_constraint} of type {type(gene_constraint)} found.") + else: + # It is None. + pass + + self.gene_constraint = gene_constraint + # Validating the number of parents to be selected for mating (num_parents_mating) if num_parents_mating <= 0: self.valid_parameters = False raise ValueError(f"The number of parents mating (num_parents_mating) parameter must be > 0 but ({num_parents_mating}) found. \nThe following parameters must be > 0: \n1) Population size (i.e. number of solutions per population) (sol_per_pop).\n2) Number of selected parents in the mating pool (num_parents_mating).\n") # Validating the number of parents to be selected for mating: num_parents_mating - if (num_parents_mating > self.sol_per_pop): + if num_parents_mating > self.sol_per_pop: self.valid_parameters = False raise ValueError(f"The number of parents to select for mating ({num_parents_mating}) cannot be greater than the number of solutions in the population ({self.sol_per_pop}) (i.e., num_parents_mating must always be <= sol_per_pop).\n") @@ -572,11 +600,11 @@ def __init__(self, # crossover: Refers to the method that applies the crossover operator based on the selected type of crossover in the crossover_type property. # Validating the crossover type: crossover_type - if (crossover_type is None): + if crossover_type is None: self.crossover = None elif inspect.ismethod(crossover_type): # Check if the crossover_type is a method that accepts 4 paramaters. - if (crossover_type.__code__.co_argcount == 4): + if crossover_type.__code__.co_argcount == 4: # The crossover method assigned to the crossover_type parameter is validated. self.crossover = crossover_type else: @@ -584,7 +612,7 @@ def __init__(self, raise ValueError(f"When 'crossover_type' is assigned to a method, then this crossover method must accept 4 parameters:\n1) Expected to be the 'self' object.\n2) The selected parents.\n3) The size of the offspring to be produced.\n4) The instance from the pygad.GA class.\n\nThe passed crossover method named '{crossover_type.__code__.co_name}' accepts {crossover_type.__code__.co_argcount} parameter(s).") elif callable(crossover_type): # Check if the crossover_type is a function that accepts 2 paramaters. - if (crossover_type.__code__.co_argcount == 3): + if crossover_type.__code__.co_argcount == 3: # The crossover function assigned to the crossover_type parameter is validated. self.crossover = crossover_type else: diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index d0ca1b4..4309269 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -10,7 +10,7 @@ class Mutation: - def __init__(): + def __init__(self): pass def random_mutation(self, offspring): @@ -43,6 +43,24 @@ def random_mutation(self, offspring): return offspring + def get_mutation_range(self, gene_index): + + """ + Returns the minimum and maximum values of the mutation range. + It accepts a single parameter: + -gene_index: The index of the gene to mutate. Only used if the gene has a specific mutation range + It returns the minimum and maximum values of the mutation range. + """ + + # We can use either random_mutation_min_val or random_mutation_max_val. + if type(self.random_mutation_min_val) in self.supported_int_float_types: + range_min = self.random_mutation_min_val + range_max = self.random_mutation_max_val + else: + range_min = self.random_mutation_min_val[gene_index] + range_max = self.random_mutation_max_val[gene_index] + return range_min, range_max + def mutation_by_space(self, offspring): """ @@ -57,12 +75,7 @@ def mutation_by_space(self, offspring): mutation_indices = numpy.array(random.sample(range(0, self.num_genes), self.mutation_num_genes)) for gene_idx in mutation_indices: - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if self.gene_space_nested: # Returning the current gene space from the 'gene_space' attribute. @@ -201,12 +214,7 @@ def mutation_probs_by_space(self, offspring): probs = numpy.random.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if probs[gene_idx] <= self.mutation_probability: if self.gene_space_nested: @@ -292,6 +300,52 @@ def mutation_probs_by_space(self, offspring): num_trials=10) return offspring + + def change_random_mutation_value_dtype(self, random_value, gene_index): + """ + Change the data type of the random value used to apply mutation. + It accepts 2 parameters: + -random_value: The random value to change its data type. + -gene_index: The index of the target gene. + It returns the new value after changing the data type. + """ + + # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. + if self.mutation_by_replacement: + if self.gene_type_single == True: + random_value = self.gene_type[0](random_value) + else: + random_value = self.gene_type[gene_index][0](random_value) + if type(random_value) is numpy.ndarray: + random_value = random_value[0] + # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. + else: + if self.gene_type_single == True: + random_value = self.gene_type[0](offspring[offspring_idx, gene_index] + random_value) + else: + random_value = self.gene_type[gene_index][0](offspring[offspring_idx, gene_index] + random_value) + if type(random_value) is numpy.ndarray: + random_value = random_value[0] + return random_value + + def round_random_mutation_value(self, random_value, gene_index): + """ + Round the random value used to apply mutation. + It accepts 2 parameters: + -random_value: The random value to round its value. + -gene_index: The index of the target gene. Only used if nested gene_type is used. + It returns the new value after being rounded. + """ + + # Round the gene + if self.gene_type_single == True: + if not self.gene_type[1] is None: + random_value = numpy.round(random_value, self.gene_type[1]) + else: + if not self.gene_type[gene_index][1] is None: + random_value = numpy.round(random_value, self.gene_type[gene_index][1]) + return random_value + def mutation_randomly(self, offspring): """ @@ -306,41 +360,17 @@ def mutation_randomly(self, offspring): mutation_indices = numpy.array(random.sample(range(0, self.num_genes), self.mutation_num_genes)) for gene_idx in mutation_indices: - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) # Generating a random value. random_value = numpy.random.uniform(low=range_min, high=range_max, size=1)[0] - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - if self.mutation_by_replacement: - if self.gene_type_single == True: - random_value = self.gene_type[0](random_value) - else: - random_value = self.gene_type[gene_idx][0](random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - else: - if self.gene_type_single == True: - random_value = self.gene_type[0](offspring[offspring_idx, gene_idx] + random_value) - else: - random_value = self.gene_type[gene_idx][0](offspring[offspring_idx, gene_idx] + random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] + # Change the random mutation value data type. + random_value = self.change_random_mutation_value_dtype(random_value, gene_idx) - # Round the gene - if self.gene_type_single == True: - if not self.gene_type[1] is None: - random_value = numpy.round(random_value, self.gene_type[1]) - else: - if not self.gene_type[gene_idx][1] is None: - random_value = numpy.round(random_value, self.gene_type[gene_idx][1]) + # Round the gene. + random_value = self.round_random_mutation_value(random_value, gene_idx) offspring[offspring_idx, gene_idx] = random_value @@ -363,47 +393,23 @@ def mutation_probs_randomly(self, offspring): It returns an array of the mutated offspring. """ - # Random mutation changes one or more gene in each offspring randomly. + # Random mutation changes one or more genes in each offspring randomly. for offspring_idx in range(offspring.shape[0]): probs = numpy.random.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if probs[gene_idx] <= self.mutation_probability: # Generating a random value. random_value = numpy.random.uniform(low=range_min, high=range_max, size=1)[0] - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - if self.mutation_by_replacement: - if self.gene_type_single == True: - random_value = self.gene_type[0](random_value) - else: - random_value = self.gene_type[gene_idx][0](random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - else: - if self.gene_type_single == True: - random_value = self.gene_type[0](offspring[offspring_idx, gene_idx] + random_value) - else: - random_value = self.gene_type[gene_idx][0](offspring[offspring_idx, gene_idx] + random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] + # Change the random mutation value data type. + random_value = self.change_random_mutation_value_dtype(random_value, gene_idx) - # Round the gene - if self.gene_type_single == True: - if not self.gene_type[1] is None: - random_value = numpy.round(random_value, self.gene_type[1]) - else: - if not self.gene_type[gene_idx][1] is None: - random_value = numpy.round(random_value, self.gene_type[gene_idx][1]) + # Round the gene. + random_value = self.round_random_mutation_value(random_value, gene_idx) offspring[offspring_idx, gene_idx] = random_value @@ -701,12 +707,7 @@ def adaptive_mutation_by_space(self, offspring): mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) for gene_idx in mutation_indices: - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if self.gene_space_nested: # Returning the current gene space from the 'gene_space' attribute. @@ -780,7 +781,7 @@ def adaptive_mutation_by_space(self, offspring): high=range_max, size=1)[0] - # Assinging the selected value from the space to the gene. + # Assigning the selected value from the space to the gene. if self.gene_type_single == True: if not self.gene_type[1] is None: offspring[offspring_idx, gene_idx] = numpy.round(self.gene_type[0](value_from_space), @@ -844,40 +845,17 @@ def adaptive_mutation_randomly(self, offspring): mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) for gene_idx in mutation_indices: - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) # Generating a random value. random_value = numpy.random.uniform(low=range_min, high=range_max, size=1)[0] - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - if self.mutation_by_replacement: - if self.gene_type_single == True: - random_value = self.gene_type[0](random_value) - else: - random_value = self.gene_type[gene_idx][0](random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - else: - if self.gene_type_single == True: - random_value = self.gene_type[0](offspring[offspring_idx, gene_idx] + random_value) - else: - random_value = self.gene_type[gene_idx][0](offspring[offspring_idx, gene_idx] + random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] + # Change the random mutation value data type. + random_value = self.change_random_mutation_value_dtype(random_value, gene_idx) - if self.gene_type_single == True: - if not self.gene_type[1] is None: - random_value = numpy.round(random_value, self.gene_type[1]) - else: - if not self.gene_type[gene_idx][1] is None: - random_value = numpy.round(random_value, self.gene_type[gene_idx][1]) + # Round the gene. + random_value = self.round_random_mutation_value(random_value, gene_idx) offspring[offspring_idx, gene_idx] = random_value @@ -936,12 +914,7 @@ def adaptive_mutation_probs_by_space(self, offspring): probs = numpy.random.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if probs[gene_idx] <= adaptive_mutation_probability: if self.gene_space_nested: @@ -1079,41 +1052,18 @@ def adaptive_mutation_probs_randomly(self, offspring): probs = numpy.random.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): - if type(self.random_mutation_min_val) in self.supported_int_float_types: - range_min = self.random_mutation_min_val - range_max = self.random_mutation_max_val - else: - range_min = self.random_mutation_min_val[gene_idx] - range_max = self.random_mutation_max_val[gene_idx] + range_min, range_max = self.get_mutation_range(gene_idx) if probs[gene_idx] <= adaptive_mutation_probability: # Generating a random value. random_value = numpy.random.uniform(low=range_min, high=range_max, size=1)[0] - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - if self.mutation_by_replacement: - if self.gene_type_single == True: - random_value = self.gene_type[0](random_value) - else: - random_value = self.gene_type[gene_idx][0](random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - else: - if self.gene_type_single == True: - random_value = self.gene_type[0](offspring[offspring_idx, gene_idx] + random_value) - else: - random_value = self.gene_type[gene_idx][0](offspring[offspring_idx, gene_idx] + random_value) - if type(random_value) is numpy.ndarray: - random_value = random_value[0] + # Change the random mutation value data type. + random_value = self.change_random_mutation_value_dtype(random_value, gene_idx) - if self.gene_type_single == True: - if not self.gene_type[1] is None: - random_value = numpy.round(random_value, self.gene_type[1]) - else: - if not self.gene_type[gene_idx][1] is None: - random_value = numpy.round(random_value, self.gene_type[gene_idx][1]) + # Round the gene. + random_value = self.round_random_mutation_value(random_value, gene_idx) offspring[offspring_idx, gene_idx] = random_value