diff --git a/README.md b/README.md index 7728266..1bdf0e1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ solver = BinaryGenAlgSolver( mutation_rate=0.05, # mutation rate to apply to the population selection_rate=0.5, # percentage of the population to select for mating selection_strategy="roulette_wheel", # strategy to use for selection. see below for more details + fitness_tolerance=(1E-4, 50) # Loop will be exited if the best fitness value does not change more than + # 1E-4 for 50 generations ) solver.solve() @@ -98,6 +100,8 @@ solver = ContinuousGenAlgSolver( mutation_rate=0.1, # mutation rate to apply to the population selection_rate=0.6, # percentage of the population to select for mating selection_strategy="roulette_wheel", # strategy to use for selection. see below for more details + fitness_tolerance=(1E-5, 20) # Loop will be exited if the best fitness value does not change more than + # 1E-5 for 20 generations ) solver.solve() diff --git a/geneal/genetic_algorithms/_binary.py b/geneal/genetic_algorithms/_binary.py index 5bea25a..c8ed023 100644 --- a/geneal/genetic_algorithms/_binary.py +++ b/geneal/genetic_algorithms/_binary.py @@ -21,6 +21,7 @@ def __init__( plot_results: bool = True, excluded_genes: Sequence = None, n_crossover_points: int = 1, + fitness_tolerance=None, random_state: int = None, ): """ @@ -36,6 +37,11 @@ def __init__( :param verbose: whether to print iterations status :param show_stats: whether to print stats at the end :param plot_results: whether to plot results of the run at the end + :param fitness_tolerance: optional. (a, b) tuple consisting of the tolerance on the + change in the best fitness, and the number of generations the condition + holds true. If the best fitness does not change by a value of (a) for a specified + number of iterations (b), the solver stops and exits the loop. + :param random_state: optional. whether the random seed should be set """ GenAlgSolver.__init__( @@ -53,6 +59,7 @@ def __init__( excluded_genes=excluded_genes, n_crossover_points=n_crossover_points, random_state=random_state, + fitness_tolerance=fitness_tolerance ) def initialize_population(self): diff --git a/geneal/genetic_algorithms/_continuous.py b/geneal/genetic_algorithms/_continuous.py index c59b601..c9c8445 100644 --- a/geneal/genetic_algorithms/_continuous.py +++ b/geneal/genetic_algorithms/_continuous.py @@ -23,6 +23,7 @@ def __init__( variables_limits=(-10, 10), problem_type=float, n_crossover_points: int = 1, + fitness_tolerance=None, random_state: int = None, ): """ @@ -39,8 +40,13 @@ def __init__( :param show_stats: whether to print stats at the end :param plot_results: whether to plot results of the run at the end :param variables_limits: limits for each variable [(x1_min, x1_max), (x2_min, x2_max), ...]. - If only one tuple is provided, then it is assumed the same for every variable + If only one tuple is provided, then it is assumed the same for every variable :param problem_type: whether problem is of float or integer type + :param fitness_tolerance: optional. (a, b) tuple consisting of the tolerance on the + change in the best fitness, and the number of generations the condition + holds true. If the best fitness does not change by a value of (a) for a specified + number of iterations (b), the solver stops and exits the loop. + :param random_state: optional. whether the random seed should be set """ GenAlgSolver.__init__( @@ -58,6 +64,7 @@ def __init__( excluded_genes=excluded_genes, n_crossover_points=n_crossover_points, random_state=random_state, + fitness_tolerance=fitness_tolerance ) if not variables_limits: diff --git a/geneal/genetic_algorithms/genetic_algorithm_base.py b/geneal/genetic_algorithms/genetic_algorithm_base.py index 53038ca..4b2d5b7 100644 --- a/geneal/genetic_algorithms/genetic_algorithm_base.py +++ b/geneal/genetic_algorithms/genetic_algorithm_base.py @@ -31,6 +31,7 @@ def __init__( plot_results: bool = True, excluded_genes: Sequence = None, n_crossover_points: int = 1, + fitness_tolerance=None, random_state: int = None, ): """ @@ -47,7 +48,12 @@ def __init__( :param show_stats: whether to print stats at the end :param plot_results: whether to plot results of the run at the end :param n_crossover_points: number of slices to make for the crossover + :param fitness_tolerance: optional. (a, b) tuple consisting of the tolerance on the + change in the best fitness, and the number of generations the condition + holds true. If the best fitness does not change by a value of (a) for a specified + number of iterations (b), the solver stops and exits the loop. :param random_state: optional. whether the random seed should be set + """ if isinstance(random_state, int): @@ -72,6 +78,8 @@ def __init__( self.verbose = verbose self.show_stats = show_stats self.plot_results = plot_results + self.fitness_tolerance = fitness_tolerance + self.periods_same_fitness = 0 self.pop_keep = math.floor(selection_rate * pop_size) @@ -150,6 +158,8 @@ def solve(self): gen_n = 0 while True: + best_fitness = fitness[0] + gen_n += 1 if self.verbose and gen_n % gen_interval == 0: @@ -185,7 +195,7 @@ def solve(self): fitness, population = self.sort_by_fitness(fitness, population) - if gen_n >= self.max_gen: + if gen_n >= self.max_gen or self._check_condition_to_stop(best_fitness, fitness): break self.generations_ = gen_n @@ -457,3 +467,15 @@ def mutate_population(self, population, n_mutations): ) return mutation_rows, mutation_cols + + def _check_condition_to_stop(self, best_fitness, fitness): + + if self.fitness_tolerance is None: + return False + + if np.abs(best_fitness - fitness[0]) < self.fitness_tolerance[0]: + self.periods_same_fitness += 1 + else: + self.periods_same_fitness = 0 + + return self.periods_same_fitness >= self.fitness_tolerance[1] \ No newline at end of file diff --git a/tests/genetic_algorithms/test_binary_genetic_algorithm.py b/tests/genetic_algorithms/test_binary_genetic_algorithm.py index f6c14ae..edb87d7 100644 --- a/tests/genetic_algorithms/test_binary_genetic_algorithm.py +++ b/tests/genetic_algorithms/test_binary_genetic_algorithm.py @@ -87,125 +87,56 @@ def test_mutate_population(self): assert np.equal(mutated_population, expected_mutated_population).all() + @pytest.mark.parametrize( + 'fitness_tolerance', + [ + pytest.param( + None, + id='no_tolerance' + ), + pytest.param( + (10, 2), + id='with_tolerance' + ), + ] + ) @pytest.mark.parametrize( "fitness_function, n_genes, expected_best_fitness, expected_best_individual", [ pytest.param( 1, 50, - 47.0, - np.array( - [ - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - ] - ), + [47.0, 38.0], + [ + np.array([ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, + 1.0, 1.0 + ]), + np.array([ + 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, + 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, + 1, 1, + ]) + ], id="binary_fitness_function=1", ), pytest.param( 2, 48, - -4.0, - np.array( - [ - 1.0, - 0.0, - 1.0, - 1.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 0.0, - 0.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, - 0.0, - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 1.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 0.0, - 0.0, - ] - ), + [-4.0, -18], + [ + np.array([ + 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, + 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0 + ]), + np.array([ + 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, + 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, + ]) + ], id="binary_fitness_function=2", ), ], @@ -218,6 +149,7 @@ def test_solve( n_genes, expected_best_fitness, expected_best_individual, + fitness_tolerance ): solver = BinaryGenAlgSolver( @@ -228,11 +160,20 @@ def test_solve( mutation_rate=0.05, selection_rate=0.5, random_state=42, + fitness_tolerance=fitness_tolerance ) solver.solve() print(solver.best_individual_) + expected_best_fitness = expected_best_fitness[0] \ + if fitness_tolerance is None \ + else expected_best_fitness[1] + + expected_best_individual = expected_best_individual[0] \ + if fitness_tolerance is None \ + else expected_best_individual[1] + assert solver.best_fitness_ == expected_best_fitness assert np.equal(solver.best_individual_, expected_best_individual).all() diff --git a/tests/genetic_algorithms/test_continuous_genetic_algorithm.py b/tests/genetic_algorithms/test_continuous_genetic_algorithm.py index b112c3c..487e642 100644 --- a/tests/genetic_algorithms/test_continuous_genetic_algorithm.py +++ b/tests/genetic_algorithms/test_continuous_genetic_algorithm.py @@ -158,6 +158,19 @@ def test_mutate_population(self): assert np.allclose(mutated_population, expected_mutated_population, rtol=1e-5) + @pytest.mark.parametrize( + 'fitness_tolerance', + [ + pytest.param( + None, + id='no_tolerance' + ), + pytest.param( + (10, 2), + id='with_tolerance' + ), + ] + ) @pytest.mark.parametrize( "fitness_function, n_genes, expected_best_fitness, expected_best_individual", [ @@ -195,13 +208,13 @@ def test_mutate_population(self): ) def test_solve( self, - mocker, mock_matplotlib, mock_logging, fitness_function, n_genes, expected_best_fitness, expected_best_individual, + fitness_tolerance ): solver = ContinuousGenAlgSolver( @@ -212,6 +225,7 @@ def test_solve( mutation_rate=0.05, selection_rate=0.5, random_state=42, + fitness_tolerance=fitness_tolerance ) solver.solve()