2. Implementing a Genetic Algorithm
The purpose of this topic is to see a simple genetic algorithm (GA)
Although simple, it includes all the necessary parts to make it a complete GA
Despite never seeing a GA before, or knowing the intuition behind the underlying ideas, the topic should be manageable
2.1. Problem
Note
The problem being solved is deliberately kept very simple. This is because (a) the purpose of this topic is to see a working GA, not solve a complex problem, and (b) well understood problems are often much simpler to reason about. Knowing what a good/bad solution is, and having an idea of why certain solving strategies work or not, will help with building the intuition around how GAs work.
The problem being solved is to maximize the integer value of an unsigned binary number
Find the largest binary number representable with \(n\) bits
For this problem, the search space is all possible unsigned binary numbers of length \(n\)
Given this problem description, the best solution is obviously a string of \(n\) ones
Consider an example with \(n=10\), how would these candidate solutions be ranked in terms of goodness?
\(1111111111\) (1023)
\(1011001001\) (713)
\(0001111111\) (127)
\(0000000000\) (0)
Thus, the GA being written will evolve bit strings to maximize the integer value of an unsigned binary number
Realistically, one would not use a GA to solve such a problem
GAs are typically used for very challenging problems with no other effective means of solving
However, this problem provides an opportunity to consider how the GA can and must work to find the best solution
2.2. Initialization
Before evolution can begin, an initial population of candidate solutions needs to be created
A single candidate solution is a potential solution to the problem being solved
For example, \(1011001001\) is a valid candidate solution for finding the largest binary value where \(n=10\)
2.2.1. Representation
It is sometimes non-trivial to determine how a candidate solution should be represented, or encoded, in the program
An encoded candidate solution is called a chromosome
For the integer maximization of an unsigned binary number, there are some obvious reasonable and simple encodings
Here, a list of
0
s and1
s will be usedThus, the candidate solution \(1011001001\) can be encoded as the chromosome
[1, 0, 1, 1, 0, 0, 1, 0, 0, 1]
A single chromosome can be created by generating random lists of
0
s and1
s of some predetermined lengthFor the example used here, the length was \(n=10\)
2.2.2. Population
A population is a collection of candidate solutions
A population can be created by creating a list of randomly generated chromosomes
In this example, a single chromosome is a list of
0
s and1
s[1, 0, 1, 1, 0, 0, 1, 0, 0, 1]
A population is a list of chromosomes
[[1, 0, 1, 1, 0, 0, 1, 0, 0, 1], [0, 0, 0, 1, 1, 0, 1, 0, 0, 0], [1, 0, 0, 0, 0, 0, 1, 0, 0, 0], ... ... [0, 1, 1, 1, 1, 0, 0, 0, 0, 1]]
Each chromosome is randomly generated
Below is an example of how one could create a population for this problem
61 population = []
62 for _ in range(POPULATION_SIZE):
63 chromosome = choices([0, 1], k=BIT_STRING_LENGTH)
64 population.append(chromosome)
The number of chromosomes within the population is defined by a hyperparameter called
POPULATION_SIZE
The number of bits in the binary number (\(n\)) is called
BIT_STRING_LENGTH
2.3. Evaluation
After generating the population, the candidate solutions are often not particularly good
However, some will likely be better than others
Regardless, a mechanism for evaluating the quality, or fitness, of candidate solutions is needed
This mechanism is called the fitness function
Below is an example fitness function for this problem
26def value_fitness(chromosome: list) -> int:
27 """
28 Calculate the decimal value of the chromosome (bitstring).
29
30 :param chromosome: Chromosome (bitstring) to evaluate
31 :return: The decimal value of the chromosome (bitstring)
32 :raises ValueError: If the chromosome is empty
33 """
34 if len(chromosome) == 0:
35 raise ValueError(f"Empty chromosome cannot be evaluated: {chromosome}")
36 return int("".join(str(bit) for bit in chromosome), 2)
With this fitness function, the fitness of each candidate solution within the population can be calculated and stored
70 population_fitness = []
71 for chromosome in population:
72 fitness = value_fitness(chromosome)
73 population_fitness.append(fitness)
2.4. Selection
Ideally, more fit candidate solutions should be more likely to persist within the population
And less fit candidate solutions should be more likely to die off
However, it is not as simple as always selecting the best candidate solutions
Doing so would cause the population to converge too early
This can result in finding sub-optimal solutions
This is where it becomes important to think about the population, not individual candidate solutions
The fitness function is a metric for the ultimate goal — finding a candidate solution that best solves the problem
But focusing on this metric is detrimental to the population
Instead, the population should also emphasize diversity
This can be done in any way someone wants
There are no hard rules on how selection is to be done
A simple and popular selection technique is
tournament_selection
Randomly select a subset of chromosomes within the population
Select the best of the subset
Repeat until the next generation’s population is full
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][:]
This
tournament_selection
function returns a single chromosomeThis function must be run multiple times until the next generation’s population is full
81 mating_pool = []
82 for _ in range(POPULATION_SIZE):
83 tournament_indices = choices(range(POPULATION_SIZE), k=TOURNAMENT_SIZE)
84 chromosome = tournament_selection(population, population_fitness, tournament_indices)
85 mating_pool.append(chromosome)
In the above code, the list
mating_pool
will ultimately become the next generation’s populationThe size of the tournament is defined by a hyperparameter called
TOURNAMENT_SIZE
2.5. Variation Operators
Variation operators, sometimes called genetic operators, are used to alter the chromosomes
With only selection, the chromosomes will never change
Like most things with GAs, there are no real hard rules on how this is done
Typically there should be a way to exploit what is already known to be good
And there should be a way to add some new information to the chromosomes to better explore the search space
2.5.1. Crossover
Crossover is a variation operator that acts on two chromosomes
The idea is, if two candidate solutions are relatively good, then mixing them together may produce something good
Again, there is no one correct way to perform crossover
A simple crossover one could use is
one_point_crossover
Given two chromosomes
Select an arbitrary index
Swap the contents of the chromosomes from that index to the end
For example, selecting index
2
for the following chromosomesv v [0, 0, 0, 0, 0] [0, 0, 1, 1, 1] -> [1, 1, 1, 1, 1] [1, 1, 0, 0, 0] ^ ^
50def one_point_crossover(parent_1: list, parent_2: list, index: int) -> tuple[list, list]:
51 """
52 One point crossover. All elements at and after the crossover index are swapped between the two chromosomes.
53 For example, with a crossover index of 2, [0,0,0,0,0], [1,1,1,1,1] -> [0,0,1,1,1], [1,1,0,0,0]. A crossover index of
54 0 is allowed, but results in all contents being swapped.
55
56 :param parent_1: Chromosome to be used in crossover
57 :param parent_2: Chromosome to be used in crossover
58 :param index: The index of the starting point of where all elements will be swapped between chromosomes
59 :return: The two chromosomes after the crossover is applied
60 :raises ValueError: If the parents are not the same length of if the index is out-of-bounds
61 """
62 if len(parent_1) != len(parent_2):
63 raise ValueError(f"chromosomes must be the same length: {len(parent_1)}, {len(parent_2)}")
64 if index < 0 or index > len(parent_1) - 1:
65 raise ValueError(f"Index out-of-bounds: {index}")
66 child_1 = parent_1[:]
67 child_2 = parent_2[:]
68 child_1[index:], child_2[index:] = child_2[index:], child_1[index:]
69 return child_1, child_2
This function only returns two chromosomes, so it must be run multiple times
89 for i in range(0, POPULATION_SIZE, 2):
90 if random() < CROSSOVER_RATE:
91 crossover_point = randrange(BIT_STRING_LENGTH)
92 chromosome_1, chromosome_2 = one_point_crossover(mating_pool[i], mating_pool[i + 1], crossover_point)
93 mating_pool[i] = chromosome_1
94 mating_pool[i + 1] = chromosome_2
Notice that the crossover is applied to adjacent candidate solutions within the
mating_pool
listThe fact that they are adjacent is arbitrary
Also notice
random() < CROSSOVER_RATE
Crossover is typically not always applied
Thus, this provides a way for selected candidate solutions to persist in the next generation unchanged
The probability of crossover being applied is defined by a hyperparameter called
CROSSOVER_RATE
Value would be
0 <= CROSSOVER_RATE <= 1
2.5.1.1. Potential Problem
There is a potential problem with this crossover on this problem
Consider the following population on a smaller version of the problem where \(n=5\)
[[1, 0, 1, 1, 1], [1, 0, 0, 0, 1], [0, 0, 1, 1, 1], [1, 0, 1, 1, 1], [0, 0, 0, 1, 0]]
Notice how there exists no
1
in any of the chromosomes’ index1
Because of this, it is actually not possible to ever find the optimal solution through crossover
This is because there is no way to add new information to the candidate solutions
It is only possible to transfer the existing information between the candidate solutions
2.5.2. Mutation
Mutation is a variation operator that acts on a single chromosome
It is also great for adding new information to the candidate solutions
Like crossover, there is no correct way to perform mutation
Here, a
bit_flip_mutation
will be usedGiven a chromosome
Select an arbitrary index
Flip the bit at the selected index (
0
->1
/1
->0
)
For example, selecting index 2 for the following chromosome
[0, 0, 0, 0, 0] -> [0, 0, 1, 0, 0] ^ ^
1def bit_flip_mutation(parent: list, index: int) -> list:
2 """
3 Bit flip mutation. Flip the bit at the specified index. In other words, if the bit is a 0, change it to be a 1, and
4 if the bit is a 1, change it to be a 0. For example, with mutation index of 2, [0,0,0,0,0] -> [0,0,1,0,0].
5
6 :param parent: Chromosome to be mutated
7 :param index: Index of the bit to be flipped
8 :return: The chromosome after the mutation is applied
9 :raises ValueError: If the index is out-of-bounds
10 """
11 if index < 0 or index > len(parent) - 1:
12 raise ValueError(f"Index out-of-bounds: {index}")
13 child = parent[:]
14 child[index] = (child[index] + 1) % 2
15 return child
Similar to crossover, the application of mutation is probabilistic based on a hyperparameter called
MUTATION_RATE
Where
0 <= MUTATION_RATE <= 1
98 for i in range(POPULATION_SIZE):
99 if random() < MUTATION_RATE:
100 mutation_point = randrange(BIT_STRING_LENGTH)
101 chromosome = bit_flip_mutation(mating_pool[i], mutation_point)
102 mating_pool[i] = chromosome
2.6. Termination Requirement
The above describes a single generation of the GA
For this algorithm to work, many generations will need to be executed
This then begs the question, when does one stop the algorithm
After some set number of generations?
After the optimal solution is found?
After there have been no improvements in the population?
…
Again, like most things with GAs, there really is no correct answer
Here, for ease, the algorithm is run for some number of generations specified by the hyperparameter
GENERATIONS
68 for generation in range(GENERATIONS):
69 # [begin-evaluation]
70 population_fitness = []
71 for chromosome in population:
72 fitness = value_fitness(chromosome)
73 population_fitness.append(fitness)
74 # [end-evaluation]
75
76 # Bookkeeping
77 generation_max_fitness.append(max(population_fitness))
78 generation_average_fitness.append(sum(population_fitness) // len(population_fitness))
79
80 # [begin-selection]
81 mating_pool = []
82 for _ in range(POPULATION_SIZE):
83 tournament_indices = choices(range(POPULATION_SIZE), k=TOURNAMENT_SIZE)
84 chromosome = tournament_selection(population, population_fitness, tournament_indices)
85 mating_pool.append(chromosome)
86 # [end-selection]
87
88 # [begin-crossover]
89 for i in range(0, POPULATION_SIZE, 2):
90 if random() < CROSSOVER_RATE:
91 crossover_point = randrange(BIT_STRING_LENGTH)
92 chromosome_1, chromosome_2 = one_point_crossover(mating_pool[i], mating_pool[i + 1], crossover_point)
93 mating_pool[i] = chromosome_1
94 mating_pool[i + 1] = chromosome_2
95 # [end-crossover]
96
97 # [begin-mutation]
98 for i in range(POPULATION_SIZE):
99 if random() < MUTATION_RATE:
100 mutation_point = randrange(BIT_STRING_LENGTH)
101 chromosome = bit_flip_mutation(mating_pool[i], mutation_point)
102 mating_pool[i] = chromosome
103 # [end-mutation]
104
105 population = mating_pool
Notice that this generation loop performs
Evaluation
Selection
Variation Operators
Also notice the bookkeeping variables storing some values from evolution
generation_max_fitness
andgeneration_average_fitness
These are not required, but help with visualizing what happened
Also notice the tags like
# [begin-evaluation]
These can be ignored
They are only there for making it easier to reference the code in the course notes
Once the loop terminates, the population should be evaluated one last time to find the best candidate solution
109 population_fitness = []
110 for chromosome in population:
111 fitness = value_fitness(chromosome)
112 population_fitness.append(fitness)
113 print(population_fitness)
114 print(population)
115 generation_max_fitness.append(max(population_fitness))
116 generation_average_fitness.append(sum(population_fitness) // len(population_fitness))
2.7. For Next Class
Check out the following scripts