diff --git a/docs/md/pygad_more.md b/docs/md/pygad_more.md index def0fc4..631be60 100644 --- a/docs/md/pygad_more.md +++ b/docs/md/pygad_more.md @@ -337,9 +337,17 @@ Out of the range of *1000* numbers, all the 100 values might not be satisfying t > PyGAD does not yet handle the **dependencies** among the genes in the `gene_constraint` parameter. > -> For example, gene 0 might depend on gene 1. To efficiently enforce the constraints, the constraint for gene 1 must be enforced first (if not `None`) then the constraint for gene 0. +> This is an example where gene 0 depends on gene 1. To efficiently enforce the constraints, the constraint for gene 1 must be enforced first (if not `None`) then the constraint for gene 0. > -> PyGAD applies constraints sequentially, starting from the first gene to the last. To ensure correct behavior when genes depend on each other, structure your GA problem so that if gene X depends on gene Y, then gene Y appears earlier in the chromosome (solution) than gene X. +> ```python +> gene_constraint= +> [ +> lambda solution,values: [val for val in values if val lambda solution,values: [val for val in values if val>10] +> ] +> ``` +> +> PyGAD applies constraints sequentially, starting from the first gene to the last. To ensure correct behavior when genes depend on each other, structure your GA problem so that if gene X depends on gene Y, then gene Y appears earlier in the chromosome (solution) than gene X. As a result, its gene constraint will be earlier in the list. ## Full Example diff --git a/docs/md/releases.md b/docs/md/releases.md index 291d69e..7a2c450 100644 --- a/docs/md/releases.md +++ b/docs/md/releases.md @@ -566,7 +566,7 @@ Release Date 07 January 2025 6. Created a new method called `unique_float_gene_from_range()` inside the `pygad.helper.unique.Unique` class to find a unique floating-point number from a range. 7. Fix a bug in the `pygad.helper.unique.Unique.unique_gene_by_space()` method to return the numeric value only instead of a NumPy array. 8. Refactoring the `pygad/helper/unique.py` script to remove duplicate codes and reformatting the docstrings. -9. The plot_pareto_front_curve() method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 +9. The `plot_pareto_front_curve()` method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 11. Fix a bug converting a nested NumPy array to a nested list. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/300 12. The `Matplotlib` library is only imported when a method inside the `pygad/visualize/plot.py` script is used. This is more efficient than using `import matplotlib.pyplot` at the module level as this causes it to be imported when `pygad` is imported even when it is not needed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/292 13. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter (e.g. `stop_criteria=["saturate_10", "reach_-0.5"]`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/296 @@ -575,9 +575,9 @@ Release Date 07 January 2025 ## PyGAD 3.5.0 -Release Date 07 July 2025 +Release Date 08 July 2025 -1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 +1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/323 2. Fix a bug when the `stop_criteria` parameter is passed as an iterable (e.g. list) for multi-objective problems (e.g. `['reach_50_60', 'reach_20, 40']`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 3. Call the `get_matplotlib()` function from the `plot_genes()` method inside the `pygad.visualize.plot.Plot` class to import the matplotlib library. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/315 4. Create a new helper method called `select_unique_value()` inside the `pygad/helper/unique.py` script to select a unique gene from an array of values. @@ -598,12 +598,14 @@ Release Date 07 July 2025 11. `filter_gene_values_by_constraint()`: Receives a list of values for a gene. Then it filters such values using the gene constraint. 12. `get_valid_gene_constraint_values()`: Selects one valid gene value that satisfy the gene constraint. It simply calls `generate_gene_value()` to generate some gene values then it filters such values using `filter_gene_values_by_constraint()`. 9. Create a new helper method called `mutation_process_random_value()` inside the `pygad/utils/mutation.py` script that generates constrained random values for mutation. It calls either `generate_gene_value()` or `get_valid_gene_constraint_values()` based on whether the `gene_constraint` parameter is used or not. -10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/pygad_more.html#gene-constraint) section for more information. +10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/pygad_more.html#gene-constraint) section for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/119 11. A new parameter called `sample_size` is added. To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either `allow_duplicate_genes` or `gene_constraint` is used. An instance attribute of the same name is created in the instances of the `pygad.GA` class. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/pygad_more.html#sample-size-parameter) section for more information. 12. Use the `sample_size` parameter instead of `num_trials` in the methods `solve_duplicate_genes_randomly()` and `unique_float_gene_from_range()` inside the `pygad/helper/unique.py` script. It is the maximum number of values to generate as the search space when looking for a unique float value out of a range. 13. Fixed a bug in population initialization when `allow_duplicate_genes=False`. Previously, gene values were checked for duplicates before rounding, which could allow near-duplicates like 7.61 and 7.62 to pass. After rounding (e.g., both becoming 7.6), this resulted in unintended duplicates. The fix ensures gene values are now rounded before duplicate checks, preventing such cases. 14. More tests are created. 15. More examples are created. +16. Edited the `sort_solutions_nsga2()` method in the `pygad/utils/nsga2.py` script to accept an optional parameter called `find_best_solution` when calling this method just to find the best solution. +17. Fixed a bug while applying the non-dominated sorting in the `get_non_dominated_set()` method inside the `pygad/utils/nsga2.py` script. It was swapping the non-dominated and dominated sets. In other words, it used the non-dominated set as if it is the dominated set and vice versa. All the calls to this method were edited accordingly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/320. # PyGAD Projects at GitHub diff --git a/docs/md/utils.md b/docs/md/utils.md index 6859d97..bf6873f 100644 --- a/docs/md/utils.md +++ b/docs/md/utils.md @@ -190,7 +190,7 @@ It has the following helper methods: The `pygad.utils.nsga2` module has a class named `NSGA2` that implements NSGA-II. The methods inside this class are: 1. `non_dominated_sorting()`: Returns all the pareto fronts by applying non-dominated sorting over the solutions. -2. `get_non_dominated_set()`: Returns the set of non-dominated solutions from the passed solutions. +2. `get_non_dominated_set()`: Returns the 2 sets of non-dominated solutions and dominated solutions from the passed solutions. Note that the Pareto front consists of the solutions in the non-dominated set. 3. `crowding_distance()`: Calculates the crowding distance for all solutions in the current pareto front. 4. `sort_solutions_nsga2()`: Sort the solutions. If the problem is single-objective, then the solutions are sorted by sorting the fitness values of the population. If it is multi-objective, then non-dominated sorting and crowding distance are applied to sort the solutions. diff --git a/example_multi_objective.py b/example_multi_objective.py new file mode 100644 index 0000000..b08db04 --- /dev/null +++ b/example_multi_objective.py @@ -0,0 +1,65 @@ +import pygad +import numpy + +""" +Given these 2 functions: + y1 = f(w1:w6) = w1x1 + w2x2 + w3x3 + w4x4 + w5x5 + 6wx6 + y2 = f(w1:w6) = w1x7 + w2x8 + w3x9 + w4x10 + w5x11 + 6wx12 + where (x1,x2,x3,x4,x5,x6)=(4,-2,3.5,5,-11,-4.7) and y=50 + and (x7,x8,x9,x10,x11,x12)=(-2,0.7,-9,1.4,3,5) and y=30 +What are the best values for the 6 weights (w1 to w6)? We are going to use the genetic algorithm to optimize these 2 functions. +This is a multi-objective optimization problem. + +PyGAD considers the problem as multi-objective if the fitness function returns: + 1) List. + 2) Or tuple. + 3) Or numpy.ndarray. +""" + +function_inputs1 = [4,-2,3.5,5,-11,-4.7] # Function 1 inputs. +function_inputs2 = [-2,0.7,-9,1.4,3,5] # Function 2 inputs. +desired_output1 = 50 # Function 1 output. +desired_output2 = 30 # Function 2 output. + +def fitness_func(ga_instance, solution, solution_idx): + output1 = numpy.sum(solution*function_inputs1) + output2 = numpy.sum(solution*function_inputs2) + fitness1 = 1.0 / (numpy.abs(output1 - desired_output1) + 0.000001) + fitness2 = 1.0 / (numpy.abs(output2 - desired_output2) + 0.000001) + return [fitness1, fitness2] + +num_generations = 1 # Number of generations. +num_parents_mating = 5 # Number of solutions to be selected as parents in the mating pool. + +sol_per_pop = 10 # Number of solutions in the population. +num_genes = len(function_inputs1) + +ga_instance = pygad.GA(num_generations=num_generations, + num_parents_mating=num_parents_mating, + sol_per_pop=sol_per_pop, + num_genes=num_genes, + fitness_func=fitness_func, + random_seed=3, + parent_selection_type='tournament_nsga2') + +# Running the GA to optimize the parameters of the function. +ga_instance.run() + +""" +ga_instance.plot_fitness(label=['Obj 1', 'Obj 2']) +ga_instance.plot_pareto_front_curve() + +# 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}") + +prediction = numpy.sum(numpy.array(function_inputs1)*solution) +print(f"Predicted output 1 based on the best solution : {prediction}") +prediction = numpy.sum(numpy.array(function_inputs2)*solution) +print(f"Predicted output 2 based on the best solution : {prediction}") + +if ga_instance.best_solution_generation != -1: + print(f"Best fitness value reached after {ga_instance.best_solution_generation} generations.") +""" diff --git a/pygad/utils/nsga2.py b/pygad/utils/nsga2.py index 2b56b34..e904fed 100644 --- a/pygad/utils/nsga2.py +++ b/pygad/utils/nsga2.py @@ -3,7 +3,7 @@ class NSGA2: - def __init__(): + def __init__(self): pass def get_non_dominated_set(self, curr_solutions): @@ -26,10 +26,11 @@ def get_non_dominated_set(self, curr_solutions): # List of the members of the current dominated pareto front/set. dominated_set = [] # List of the non-members of the current dominated pareto front/set. + # The non-dominated set is the pareto front set. non_dominated_set = [] for idx1, sol1 in enumerate(curr_solutions): # Flag indicates whether the solution is a member of the current dominated set. - is_dominated = True + is_not_dominated = True for idx2, sol2 in enumerate(curr_solutions): if idx1 == idx2: continue @@ -45,28 +46,30 @@ def get_non_dominated_set(self, curr_solutions): gr_eq = two_solutions[:, 1] >= two_solutions[:, 0] gr = two_solutions[:, 1] > two_solutions[:, 0] - # If the 2 conditions hold, then a solution dominates the current solution. - # The current solution is not considered a member of the dominated set. + # If the 2 conditions hold, then a solution (sol2) dominates the current solution (sol1). + # The current solution (sol1) is not considered a member of the non-dominated set. if gr_eq.all() and gr.any(): - # Set the is_dominated flag to False to NOT insert the current solution in the current dominated set. - # Instead, insert it into the non-dominated set. - is_dominated = False - non_dominated_set.append(sol1) + # Set the is_not_dominated flag to False because another solution dominates the current solution (sol1) + is_not_dominated = False + # DO NOT insert the current solution in the current non-dominated set. + # Instead, insert it into the dominated set. + dominated_set.append(sol1) break else: # Reaching here means the solution does not dominate the current solution. pass # If the flag is True, then no solution dominates the current solution. - if is_dominated: - dominated_set.append(sol1) + # Insert the current solution (sol1) into the non-dominated set. + if is_not_dominated: + non_dominated_set.append(sol1) # Return the dominated and non-dominated sets. return dominated_set, non_dominated_set - + def non_dominated_sorting(self, fitness): """ - Apply non-dominant sorting over the fitness to create the pareto fronts based on non-dominaned sorting of the solutions. + Apply non-dominant sorting over the fitness to create the pareto fronts based on non-dominated sorting of the solutions. Parameters ---------- @@ -101,7 +104,6 @@ def non_dominated_sorting(self, fitness): # Each element has: # 1) The index of the solution. # 2) An array of the fitness values of this solution across all objectives. - # remaining_set = numpy.array(list(zip(range(0, fitness.shape[0]), non_dominated_set))) remaining_set = list(zip(range(0, fitness.shape[0]), remaining_set)) # A list mapping the index of each pareto front to the set of solutions in this front. @@ -112,15 +114,15 @@ def non_dominated_sorting(self, fitness): front_index = -1 while len(remaining_set) > 0: front_index += 1 - + # Get the current non-dominated set of solutions. - pareto_front, remaining_set = self.get_non_dominated_set(curr_solutions=remaining_set) + remaining_set, pareto_front = self.get_non_dominated_set(curr_solutions=remaining_set) pareto_front = numpy.array(pareto_front, dtype=object) pareto_fronts.append(pareto_front) solutions_indices = pareto_front[:, 0].astype(int) solutions_fronts_indices[solutions_indices] = front_index - + return pareto_fronts, solutions_fronts_indices def crowding_distance(self, pareto_front, fitness): @@ -200,7 +202,7 @@ def crowding_distance(self, pareto_front, fitness): # Insert the crowding distance back into the list to override the initial zero. obj_sorted[idx][2] = crowding_dist - # Sort the objective by the original index at index 0 of the each child list. + # Sort the objective by the original index at index 0 of each child list. obj_sorted = sorted(obj_sorted, key=lambda x: x[0]) obj_crowding_dist_list.append(obj_sorted) @@ -225,8 +227,10 @@ def crowding_distance(self, pareto_front, fitness): crowding_dist_pop_sorted_indices = crowding_dist_pop_sorted_indices.astype(int) return obj_crowding_dist_list, crowding_dist_sum, crowding_dist_front_sorted_indices, crowding_dist_pop_sorted_indices - - def sort_solutions_nsga2(self, fitness): + + def sort_solutions_nsga2(self, + fitness, + find_best_solution=False): """ Sort the solutions based on the fitness. The sorting procedure differs based on whether the problem is single-objective or multi-objective optimization. @@ -237,9 +241,9 @@ def sort_solutions_nsga2(self, fitness): Parameters ---------- - fitness : TYPE - The fitness of the entire population. - + fitness: The fitness of the entire population. + find_best_solution: Whether the method is called only to find the best solution or as part of the PyGAD lifecycle. This is to decide whether the pareto_fronts instance attribute is edited or not. + Returns ------- solutions_sorted : TYPE @@ -251,7 +255,13 @@ def sort_solutions_nsga2(self, fitness): solutions_sorted = [] # Split the solutions into pareto fronts using non-dominated sorting. pareto_fronts, solutions_fronts_indices = self.non_dominated_sorting(fitness) - self.pareto_fronts = pareto_fronts.copy() + if find_best_solution: + # Do not edit the pareto_fronts instance attribute when just getting the best solution. + pass + else: + # The method is called within the regular GA lifecycle. + # We have to edit the pareto_fronts to be assigned the latest pareto front. + self.pareto_fronts = pareto_fronts.copy() for pareto_front in pareto_fronts: # Sort the solutions in the front using crowded distance. _, _, _, crowding_dist_pop_sorted_indices = self.crowding_distance(pareto_front=pareto_front.copy(), @@ -264,5 +274,7 @@ def sort_solutions_nsga2(self, fitness): solutions_sorted = sorted(range(len(fitness)), key=lambda k: fitness[k]) # Reverse the sorted solutions so that the best solution comes first. solutions_sorted.reverse() - + else: + raise TypeError(f'Each element in the fitness array must be of a number of an iterable (list, tuple, numpy.ndarray). But the type {type(fitness[0])} found') + return solutions_sorted diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index ac293c2..3ea4577 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -107,7 +107,7 @@ def tournament_selection(self, fitness, num_parents): """ Selects the parents using the tournament selection technique. Later, these parents will mate to produce the offspring. - It accepts 2 parameters: + It accepts: -fitness: The fitness values of the solutions in the current population. -num_parents: The number of parents to be selected. It returns: @@ -127,10 +127,8 @@ def tournament_selection(self, fitness, num_parents): parents_indices = [] for parent_num in range(num_parents): - # Generate random indices for the candiadate solutions. - rand_indices = numpy.random.randint(low=0.0, high=len(fitness), size=self.K_tournament) - # K_fitnesses = fitness[rand_indices] - # selected_parent_idx = numpy.where(K_fitnesses == numpy.max(K_fitnesses))[0][0] + # Generate random indices for the candidate solutions. + rand_indices = numpy.random.randint(low=0, high=len(fitness), size=self.K_tournament) # Find the rank of the candidate solutions. The lower the rank, the better the solution. rand_indices_rank = [fitness_sorted.index(rand_idx) for rand_idx in rand_indices] @@ -275,8 +273,6 @@ def stochastic_universal_selection(self, fitness, num_parents): probs_start[min_probs_idx] = curr curr = curr + probs[min_probs_idx] probs_end[min_probs_idx] = curr - # Replace 99999999999 by float('inf') - # probs[min_probs_idx] = 99999999999 probs[min_probs_idx] = float('inf') pointers_distance = 1.0 / self.num_parents_mating # Distance between different pointers. @@ -304,8 +300,7 @@ def stochastic_universal_selection(self, fitness, num_parents): def tournament_selection_nsga2(self, fitness, - num_parents - ): + num_parents): """ Select the parents using the tournament selection technique for NSGA-II. @@ -347,85 +342,97 @@ def tournament_selection_nsga2(self, self.pareto_fronts = pareto_fronts.copy() # Randomly generate pairs of indices to apply for NSGA-II tournament selection for selecting the parents solutions. - rand_indices = numpy.random.randint(low=0.0, + rand_indices = numpy.random.randint(low=0, high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) - + for parent_num in range(num_parents): # Return the indices of the current 2 solutions. current_indices = rand_indices[parent_num] # Return the front index of the 2 solutions. parent_fronts_indices = solutions_fronts_indices[current_indices] - - if parent_fronts_indices[0] < parent_fronts_indices[1]: - # If the first solution is in a lower pareto front than the second, then select it. - selected_parent_idx = current_indices[0] - elif parent_fronts_indices[0] > parent_fronts_indices[1]: - # If the second solution is in a lower pareto front than the first, then select it. - selected_parent_idx = current_indices[1] + parent_fronts_indices_unique = numpy.unique(parent_fronts_indices) + + # Here are the possible cases: + # 1) All the solutions are in the same front (e.g. [0, 0, 0, 0]). Select a solution randomly. + # 2) Each solution is in a different front but there is only one solution in the pareto front with the lowest index (e.g. [0, 1, 2, 3]). Use the solution in the best front (lower front index). + # 3) The solutions are split into groups in different pareto fronts and there are more than one solution in the pareto front with the lowest index (e.g. [0, 0, 1, 1]). Filter the solutions in the lowest rank pareto front and randomly select a solution from this filtered list. + + # If no single solution found, then store the unique solutions indices. + current_indices_unique = None + # If a single solution found, store its index here. + selected_parent_index = None + # The pareto front where the filtered solutions exists. + selected_pareto_front_index = None + if len(parent_fronts_indices_unique) == 1: + # CASE 1 + # There are multiple solutions at the same front. + # Use crowding distance to select a solution. + selected_pareto_front_index = parent_fronts_indices_unique[0] + current_indices_unique = numpy.unique(current_indices[parent_fronts_indices == selected_pareto_front_index]) else: - # The 2 solutions are in the same pareto front. + best_pareto_front = min(parent_fronts_indices_unique) + best_pareto_front_count = list(parent_fronts_indices).count(best_pareto_front) + if best_pareto_front_count == 1: + # CASE 2 + #### DONE + # Use the single solution at the best pareto front directly as parent. + selected_parent_index = current_indices[parent_fronts_indices == best_pareto_front][0] + else: + # CASE 3 + current_indices_unique = numpy.unique(current_indices[parent_fronts_indices == best_pareto_front]) + if len(current_indices_unique) == 1: + #### DONE + # There is only one solution in the best pareto front. Just select it as a parent. + # The same solution index was randomly generated more than once using the numpy.random.randint() + selected_parent_index = current_indices_unique[0] + else: + # There are different solutions at the same front. + # Use crowding distance to select a solution. + selected_pareto_front_index = best_pareto_front + + if selected_parent_index is not None: + pass + else: + # Use crowding distance to select between 1 or more solutions within the same pareto front. # The selection is made using the crowding distance. - - # A list holding the crowding distance of the current 2 solutions. It is initialized to -1. - solutions_crowding_distance = [-1, -1] - + # If more than 1 solution has the same crowding distance, select a solution randomly. + # Fetch the current pareto front. - pareto_front = pareto_fronts[parent_fronts_indices[0]] # Index 1 can also be used. + pareto_front = pareto_fronts[selected_pareto_front_index] # If there is only 1 solution in the pareto front, just return it without calculating the crowding distance (it is useless). if pareto_front.shape[0] == 1: - selected_parent_idx = current_indices[0] # Index 1 can also be used. + selected_parent_index = current_indices[0] # Index 1 can also be used. else: - # Reaching here means the pareto front has more than 1 solution. - + # Reaching here means the selected pareto front has more than 1 solution. # Calculate the crowding distance of the solutions of the pareto front. obj_crowding_distance_list, crowding_distance_sum, crowding_dist_front_sorted_indices, crowding_dist_pop_sorted_indices = self.crowding_distance(pareto_front=pareto_front.copy(), - fitness=fitness) - - # This list has the sorted front-based indices for the solutions in the current pareto front. - crowding_dist_front_sorted_indices = list(crowding_dist_front_sorted_indices) + fitness=fitness) # This list has the sorted population-based indices for the solutions in the current pareto front. crowding_dist_pop_sorted_indices = list(crowding_dist_pop_sorted_indices) - - # Return the indices of the solutions from the pareto front. - solution1_idx = crowding_dist_pop_sorted_indices.index(current_indices[0]) - solution2_idx = crowding_dist_pop_sorted_indices.index(current_indices[1]) - + + # Return the indices of the solutions from the pareto front based on the crowding distance. + # If there is more than one solution, select the solution that has a better crowding distance. + # This solution comes first in the order in the crowding_dist_pop_sorted_indices list. + solutions_indices = [crowding_dist_pop_sorted_indices.index(rand_sol_idx) for rand_sol_idx in current_indices_unique] + # Fetch the crowding distance using the indices. - solutions_crowding_distance[0] = crowding_distance_sum[solution1_idx][1] - solutions_crowding_distance[1] = crowding_distance_sum[solution2_idx][1] - - # # Instead of using the crowding distance, we can select the solution that comes first in the list. - # # Its limitation is that it is biased towards the low indexed solution if the 2 solutions have the same crowding distance. - # if solution1_idx < solution2_idx: - # # Select the first solution if it has higher crowding distance. - # selected_parent_idx = current_indices[0] - # else: - # # Select the second solution if it has higher crowding distance. - # selected_parent_idx = current_indices[1] - - if solutions_crowding_distance[0] > solutions_crowding_distance[1]: - # Select the first solution if it has higher crowding distance. - selected_parent_idx = current_indices[0] - elif solutions_crowding_distance[1] > solutions_crowding_distance[0]: - # Select the second solution if it has higher crowding distance. - selected_parent_idx = current_indices[1] + solutions_crowding_distance = [crowding_distance_sum[rand_sol_idx][1] for rand_sol_idx in solutions_indices] + max_crowding_distance = max(solutions_crowding_distance) + + if solutions_crowding_distance.count(max_crowding_distance) == 1: + # There is only a single solution with the maximum crowding distance. Just select it. + selected_parent_index = current_indices_unique[solutions_crowding_distance.index(max_crowding_distance)] else: - # If the crowding distance is equal, select the parent randomly. - rand_num = numpy.random.uniform() - if rand_num < 0.5: - # If the random number is < 0.5, then select the first solution. - selected_parent_idx = current_indices[0] - else: - # If the random number is >= 0.5, then select the second solution. - selected_parent_idx = current_indices[1] + # If the crowding distance is equal across multiple solutions, select a solution randomly as a parent. + selected_parent_index = numpy.random.choice(current_indices_unique) # Insert the selected parent index. - parents_indices.append(selected_parent_idx) + parents_indices.append(selected_parent_index) # Insert the selected parent. - parents[parent_num, :] = self.population[selected_parent_idx, :].copy() - + parents[parent_num, :] = self.population[selected_parent_index, :].copy() + # Make sure the parents indices is returned as a NumPy array. return parents, numpy.array(parents_indices) diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index ab6bd25..623c0d9 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -455,11 +455,12 @@ def plot_pareto_front_curve(self, # Plot the pareto front curve. remaining_set = list(zip(range(0, self.last_generation_fitness.shape[0]), self.last_generation_fitness)) + # The non-dominated set is the pareto front set. dominated_set, non_dominated_set = self.get_non_dominated_set(remaining_set) # Extract the fitness values (objective values) of the non-dominated solutions for plotting. - pareto_front_x = [self.last_generation_fitness[item[0]][0] for item in dominated_set] - pareto_front_y = [self.last_generation_fitness[item[0]][1] for item in dominated_set] + pareto_front_x = [self.last_generation_fitness[item[0]][0] for item in non_dominated_set] + pareto_front_y = [self.last_generation_fitness[item[0]][1] for item in non_dominated_set] # Sort the Pareto front solutions (optional but can make the plot cleaner) sorted_pareto_front = sorted(zip(pareto_front_x, pareto_front_y))