8. \(n\) Queens Genetic Algorithm
The purpose of this topic is to emphasize the similarities between genetic algorithm implementations
Although the first genetic algorithm implementation was solving a different problem, much of the algorithm is the same
8.1. Problem
The optimization version of the \(n\) queens problem is going to be used
Given an \(n \times n\) chess board
Place \(n\) queens on the chess board
While minimizing the number of conflicting/attacking queens
In other words, minimize the number of queens in the same row, column, or diagonal
The reason the optimization version of the problem is being used is because it has a gradient for the search to follow
The all or nothing (valid configuration or not) version of the problem has no gradient
The phenotype space (problem space) includes all possible configurations of the \(n\) queens on an \(n \times n\) board
Assuming queens are not allowed on the same square, this is \(n \times n \choose n\)
However, as previously discussed, by using a permutation encoding, the genotype space (search space) is only \(n!\)
8.2. Initialization
Before evolution can begin, an initial population of candidate solutions
8.2.1. Representation
The permutation representation previously discussed will be used
A chromosome will be a list of length \(n\)
Each of the \(n\) indices correspond to each of the \(n\) queens
Each index will be the queen’s respective column in the chess board
The elements within the list will be a permutation of the integers between \(0\) and \(n-1\)
The value at each index will be the queen’s respective row in the chess board
Since the values and indices are unique, no two queens can be in the same row or column
An example chromosome when \(n=8\) is
<0, 5, 7, 6, 3, 2, 1, 4>
8.2.2. Population
A population is a list of chromosomes
[<0, 5, 7, 6, 3, 2, 1, 4>, <4, 5, 2, 3, 6, 7, 1, 0>, <0, 1, 2, 3, 4, 5, 6, 7>, ... ... <4, 3, 2, 7, 6, 1, 0, 5>]
The chromosomes, and thus the population, will be randomly generated for this implementation of a genetic algorithm
48 population = []
49 population_fitness = []
50 for _ in range(POPULATION_SIZE):
51 chromosome = sample(range(N_QUEENS), k=N_QUEENS)
52 population.append(chromosome)
The number of chromosomes within the population is defined by a hyperparameter called
POPULATION_SIZE
The length of the chromosomes will match the number of queens, which is defined by the hyperparameter
N_QUEENS
The chromosomes are permutations of the sequence of integers from \(0\) to \(n-1\)
Note that
sample
is from Python’srandom
library
8.3. Evaluation
The fitness function will count the number of conflicting queens
Each pair of conflicting queens will only be counted once
With the representation used, it’s not possible for two queens to be in the same row or column
Thus, only the diagonals need to be checked
23def attacking_fitness(chromosome: list) -> int:
24 """
25 Count the number of attacking queens in the provided queen placements. To count each attacking pair only once, the
26 check for attacking only happens in the forward direction (from index 0 towards n). With the permutation
27 representation, attacking can only happen in a diagonal, thus the check for attacking is to check only +/- offset,
28 where offset is the number of indices away the queens are from each other. For example, consider the chromosome
29 [?, ?, 4, 3, 6, 1, ?, ?]. The queen at index 2 is attacking 3 queens: it attacks the queen at index 3 as it is
30 offset 1 and 4-1=3, it is attacking the queen at index 4 as it is offset 2 and 4+2=6, and it is attacking the queen
31 at index 5 as it is offset 3 and 4-3=1.
32
33 :param chromosome: Chromosome (queen placements) to evaluate
34 :return: The number of attacking queens.
35 """
36 total_attackers = 0
37 for attacker_column, attacker_row in enumerate(chromosome):
38 for victim_index in range(attacker_column + 1, len(chromosome)):
39 offset = victim_index - attacker_column
40 victim_row = chromosome[victim_index]
41 if (attacker_row - offset) == victim_row or (attacker_row + offset) == victim_row:
42 total_attackers += 1
43 return total_attackers
For each queen, check the up and down diagonals to the right
There is no need to check to the left as any conflict to the left would already be counted
The basic idea here is
Given a queen at index
attacker_column
with valueattacker_row
In order for a queen
offset
indices away with the valuevictim_row
to be in the same diagonalThe value of
victim_row
must be equal toattacker_row - offset
orattacker_row + offset
Consider the above figure and the chromosome
<?, 6, 3, 4, 0, ?, 1, ?>
The question marks represent irrelevant values for this example
When evaluating the queens from left to right
The queen at index
1
has a value of6
and conflicts with two queensIt conflicts with the queen at index
3
The offset is
2
and the value at index1
equals the value at index3
minus the offset2
6 - 2 == 4
It also conflicts with the queen at index
6
The offset is
5
and the value at index6
equals the value at index6
minus the offset5
6 - 5 == 1
The queen at index
2
also has a conflict with the queen at index3
The offset is
1
and the value at index2
equals the vlaue at index3
plus the offset1
3 + 1 == 4
Lastly, the queen at index
3
conflicts with the queen at index6
4 - 3 == 1
As mentioned above, there is no need to look to the left of any queen as those conflicts would already be counted
For example, the queen at index
6
s conflicts were already counted
With this fitness function, the fitness of each candidate solution within the population can be calculated and stored
58 population_fitness = []
59 for chromosome in population:
60 fitness = attacking_fitness(chromosome)
61 population_fitness.append(fitness)
8.4. Selection
Tournament selection will be used here
It’s simple, and effective
65 mating_pool = []
66 for _ in range(POPULATION_SIZE):
67 tournament_indices = choices(range(POPULATION_SIZE), k=TOURNAMENT_SIZE)
68 chromosome = tournament_selection(population, population_fitness, tournament_indices, direction=-1)
69 mating_pool.append(chromosome)
Mind the use of the argument
direction
This indicates that chromosomes with lower fitness values are better
By default it assumes higher fitness is better
23def tournament_selection(
24 population: list, population_fitness: list, selected_indices: list[int], direction: int = 1
25) -> list:
26 """
27 Select the chromosome with the maximum/minimum fitness value of the chromosomes in the tournament. The chromosomes
28 in the tournament if its index is contained within the selected_indices list. By default, the function selects for
29 maximum fitness.
30
31 :param population: Population of chromosomes to select from
32 :param population_fitness: Fitness values of the population
33 :param selected_indices: List of indices in the tournament
34 :param direction: +1 for maximizing fitness, -1 for minimizing fitness
35 :return: A copy of the selected chromosome
36 :raises ValueError: If the list of selected indices is empty or contains an out-of-bounds index
37 """
38 if len(selected_indices) == 0:
39 raise ValueError(f"List of indices in the tournament must be non empty: {selected_indices}")
40 for index in selected_indices:
41 if index < 0 or index > len(population_fitness) - 1:
42 raise ValueError(f"List of indices in the tournament contains out-of-bounds index: {index}")
43 max_value = population_fitness[selected_indices[0]] * direction
44 max_index = selected_indices[0]
45 for index in selected_indices:
46 if population_fitness[index] * direction > max_value:
47 max_value = population_fitness[index] * direction
48 max_index = index
49 return population[max_index][:]
8.5. Variation Operators
Given the permutation representation, special considerations should be made when implementing the variation operators
The operators should ensure the chromosome remain a permutation
Consider a one point crossover on permutations
v v [0, 1, 2, 3, 4] [0, 1, 2, 1, 0] -> [4, 3, 2, 1, 0] [4, 3, 2, 3, 4] ^ ^
This crossover may destroy the fact that the chromosomes are permutations
8.5.1. Crossover
An order crossover is commonly used for permutation representations
Keep all elements between two randomly selected indices
Then, copy the elements from chromosome \(A\), in order, if they do not appear in chromosome \(B\)
Start copying from after the kept segment
1def order_crossover(parent_1: list, parent_2: list, start_index: int, end_index: int) -> tuple[list, list]:
2 """
3 Order crossover. All elements between the two indices are moved to the children and the remaining missing elements
4 are copied in the order that they appear in the other parent. Copying starts at end_index and returns to index zero
5 when the end of the chromosome is hit. For example, indices of 2 and 5, [0,1,2,3,4,5,6], [4,6,3,5,2,0,1] ->
6 [?,?,2,3,4,?,?], [?,?,3,5,2,?,?] -> [6,5,2,3,4,0,1], [1,4,3,5,2,6,0]. Out of order indices, indices with a
7 difference of 0, and indices covering the whole length of the chromosome are allowed, but result in no change.
8
9 :param parent_1: Chromosome to be used in crossover
10 :param parent_2: Chromosome to be used in crossover
11 :param start_index: Starting index of copied segment
12 :param end_index: Ending index of copied segment (excluded in segment)
13 :return: The two chromosomes after the crossover is applied
14 :raises ValueError: If the parents are not the same length of if an index is out-of-bounds
15 """
16
17 def copy_missing_elements(parent: list, child: list, start_index: int, end_index: int) -> list:
18 """
19 Copy the missing elements from the other parent into the child. Elements are copied in order that they appear
20 in the parent, starting at the end index, and looping to index 0 where necessary.
21
22 :param parent: Chromosome to copy missing elements from
23 :param child: Chromosome to have missing elements copied too
24 :param start_index: Starting index of copied segment (where to stop copying missing elements to)
25 :param end_index: Ending index of copied segment (where to start copying missing elements from)
26 :return: child chromosome with the missing elements copied
27 """
28 source_index = end_index % len(parent_1)
29 target_index = end_index % len(parent_1)
30 while target_index != start_index:
31 if parent[source_index] not in child[start_index:end_index]:
32 child[target_index] = parent[source_index]
33 target_index = (target_index + 1) % len(parent_1)
34 source_index = (source_index + 1) % len(parent_1)
35 return child
36
37 if len(parent_1) != len(parent_2):
38 raise ValueError(f"chromosomes must be the same length: {len(parent_1)}, {len(parent_2)}")
39 if start_index < 0 or end_index < 0 or start_index > len(parent_1) - 1 or end_index > len(parent_1):
40 raise ValueError(f"Index out-of-bounds: {start_index}, {end_index}")
41 child_1 = parent_1[:]
42 child_2 = parent_2[:]
43 if start_index > end_index:
44 return child_1, child_2
45 child_1 = copy_missing_elements(parent_2, child_1, start_index, end_index)
46 child_2 = copy_missing_elements(parent_1, child_2, start_index, end_index)
47 return child_1, child_2
8.5.2. Mutation
Similar to crossover, one must be careful with the choice of mutation when working with permutations
Fortunately, swap mutation is a very simple mutation that is permutation safe
Select two indices and swap the values between them
38def swap_mutation(parent: list, index_1: int, index_2: int) -> list:
39 """
40 Swap mutation. Swap the values between the two specified indices. For example, with swap indices of 0 and 3,
41 [0, 1, 2, 3, 4] -> [3, 1, 2, 0, 4]. Providing the same index for both index values results in no change.
42
43 :param parent: Chromosome to be mutated
44 :param index_1: Index to be swapped with index_2
45 :param index_2: Index to be swapped with index_1
46 :return: The chromosome after the mutation is applied
47 :raises ValueError: If either index is out-of-bounds
48 """
49 if index_1 < 0 or index_2 < 0 or index_1 > len(parent) - 1 or index_2 > len(parent) - 1:
50 raise ValueError(f"Index out-of-bounds: {index_1}, {index_2}")
51 child = parent[:]
52 child[index_1], child[index_2] = child[index_2], child[index_1]
53 return child
8.6. Termination Requirement
A generational GA will be used with a pre set number of generations defined by the hyperparameter
GENERATIONS
56 for generation in range(GENERATIONS):
57 # [begin-evaluation]
58 population_fitness = []
59 for chromosome in population:
60 fitness = attacking_fitness(chromosome)
61 population_fitness.append(fitness)
62 # [end-evaluation]
63
64 # [begin-selection]
65 mating_pool = []
66 for _ in range(POPULATION_SIZE):
67 tournament_indices = choices(range(POPULATION_SIZE), k=TOURNAMENT_SIZE)
68 chromosome = tournament_selection(population, population_fitness, tournament_indices, direction=-1)
69 mating_pool.append(chromosome)
70 # [end-selection]
71
72 # [begin-crossover]
73 for i in range(0, POPULATION_SIZE, 2):
74 if random() < CROSSOVER_RATE:
75 index_one = randrange(N_QUEENS)
76 index_two = randrange(N_QUEENS)
77 start_index = min(index_one, index_two)
78 end_index = max(index_one, index_two)
79 chromosome_1, chromosome_2 = order_crossover(mating_pool[i], mating_pool[i + 1], start_index, end_index)
80 mating_pool[i] = chromosome_1
81 mating_pool[i + 1] = chromosome_2
82 # [end-crossover]
83
84 # [begin-mutation]
85 for i in range(POPULATION_SIZE):
86 if random() < MUTATION_RATE:
87 index_one = randrange(N_QUEENS)
88 index_two = randrange(N_QUEENS)
89 chromosome = swap_mutation(mating_pool[i], index_one, index_two)
90 mating_pool[i] = chromosome
91 # [end-mutation]
92
93 population = mating_pool
This loop performs
Evaluation
Selection
Crossover
Mutation
Once the loop runs to completion, some final results are calculated and reported
Calculate the final populations fitness
Report the population and the population’s fitness
97 population_fitness = []
98 for chromosome in population:
99 fitness = attacking_fitness(chromosome)
100 population_fitness.append(fitness)
101 print(population_fitness)
102 print(population)
8.7. For Next Class
Check out the following scripts