import random from math import ceil def function_info(decorator): """Recovers the name and doc-string for decorators throughout EasyGA for documentation purposes.""" def new_decorator(method): # Apply old decorator new_method = decorator(method) # Recover name and doc-string new_method.__name__ = method.__name__ new_method.__doc__ = method.__doc__ # Return new method with proper name and doc-string return new_method return new_decorator #=======================# # Crossover decorators: # #=======================# @function_info def _check_weight(individual_method): """Checks if the weight is between 0 and 1 before running. Exception may occur when using ga.adapt, which will catch the error and try again with valid weight. """ def new_method(ga, parent_1, parent_2, *, weight = individual_method.__kwdefaults__.get('weight', None)): if weight is None: individual_method(ga, parent_1, parent_2) elif 0 < weight < 1: individual_method(ga, parent_1, parent_2, weight = weight) else: raise ValueError(f"Weight must be between 0 and 1 when using {individual_method.__name__}.") return new_method @function_info def _gene_by_gene(individual_method): """Perform crossover by making a single new chromosome by combining each gene by gene.""" def new_method(ga, parent_1, parent_2, *, weight = individual_method.__kwdefaults__.get('weight', 'None')): ga.population.add_child( individual_method(ga, value_1, value_2) if weight == 'None' else individual_method(ga, value_1, value_2, weight = weight) for value_1, value_2 in zip(parent_1.gene_value_iter, parent_2.gene_value_iter) ) return new_method #====================# # Parent decorators: # #====================# @function_info def _check_selection_probability(selection_method): """Raises a ValueError if the selection_probability is not between 0 and 1 inclusively. Otherwise runs the selection method.""" def new_method(ga): if 0 <= ga.selection_probability <= 1: selection_method(ga) else: raise ValueError("Selection probability must be between 0 and 1 to select parents.") return new_method @function_info def _check_positive_fitness(selection_method): """Raises a ValueError if the population contains a chromosome with negative fitness. Otherwise runs the selection method.""" def new_method(ga): if ga.get_chromosome_fitness(0) > 0 and ga.get_chromosome_fitness(-1) >= 0: selection_method(ga) else: raise ValueError("Converted fitness values can't have negative values or be all 0." + " Consider using rank selection or stochastic selection instead.") return new_method @function_info def _ensure_sorted(selection_method): """Sorts the population by fitness and then runs the selection method.""" def new_method(ga): ga.sort_by_best_fitness() selection_method(ga) return new_method @function_info def _compute_parent_amount(selection_method): """Computes the amount of parents needed to be selected, and passes it as another argument for the method.""" def new_method(ga): parent_amount = max(2, round(len(ga.population)*ga.parent_ratio)) selection_method(ga, parent_amount) return new_method #======================# # Mutation decorators: # #======================# @function_info def _check_chromosome_mutation_rate(population_method): """Checks if the chromosome mutation rate is a float between 0 and 1 before running.""" def new_method(ga): if not isinstance(ga.chromosome_mutation_rate, float): raise TypeError("Chromosome mutation rate must be a float.") elif 0 < ga.chromosome_mutation_rate < 1: population_method(ga) else: raise ValueError("Chromosome mutation rate must be between 0 and 1.") return new_method @function_info def _check_gene_mutation_rate(individual_method): """Checks if the gene mutation rate is a float between 0 and 1 before running.""" def new_method(ga, index): if not isinstance(ga.gene_mutation_rate, float): raise TypeError("Gene mutation rate must be a float.") elif 0 < ga.gene_mutation_rate <= 1: individual_method(ga, index) else: raise ValueError("Gene mutation rate must be between 0 and 1.") return new_method @function_info def _reset_fitness(individual_method): """Resets the fitness value of the chromosome.""" def new_method(ga, chromosome): chromosome.fitness = None individual_method(ga, chromosome) return new_method @function_info def _loop_random_mutations(individual_method): """Runs the individual method until enough genes are mutated on the indexed chromosome.""" # Change input to include the gene index being mutated. def new_method(ga, chromosome): sample_space = range(len(chromosome)) sample_size = ceil(len(chromosome)*ga.gene_mutation_rate) # Loop the individual method until enough genes are mutated. for index in random.sample(sample_space, sample_size): individual_method(ga, chromosome, index) return new_method #======================# # Survivor decorators: # #======================# #=========================# # Termination decorators: # #=========================# @function_info def _add_by_fitness_goal(termination_impl): """Adds termination by fitness goal to the method.""" def new_method(ga): # Try to check the fitness goal try: # If minimum fitness goal reached, stop ga. if ga.target_fitness_type == 'min' and ga.population[0].fitness <= ga.fitness_goal: return False # If maximum fitness goal reached, stop ga. elif ga.target_fitness_type == 'max' and ga.population[0].fitness >= ga.fitness_goal: return False # Fitness or fitness goals are None, or Population not initialized except (TypeError, AttributeError): pass # Check other termination methods return termination_impl(ga) return new_method @function_info def _add_by_generation_goal(termination_impl): """Adds termination by generation goal to the method.""" def new_method(ga): # If generation goal is set, check it. if ga.generation_goal is not None and ga.current_generation >= ga.generation_goal: return False # Check other termination methods return termination_impl(ga) return new_method @function_info def _add_by_tolerance_goal(termination_impl): """Adds termination by tolerance goal to the method.""" def new_method(ga): # If tolerance is set, check it, if possible. try: best_fitness = ga.population[0].fitness threshhold_fitness = ga.population[round(ga.percent_converged*len(ga.population))].fitness tol = ga.tolerance_goal * (1 + abs(best_fitness)) # Terminate if the specified amount of the population has converged to the specified tolerance if abs(best_fitness - threshhold_fitness) < tol: return False # Fitness or tolerance goals are None, or population is not initialized except (TypeError, AttributeError): pass # Check other termination methods return termination_impl(ga) return new_method