In order to get more accustomed with classes in Python, I have written a genetic algorithm, which takes a level with a start and end point and searches for a route (not necessarily the optimal one). The output shows the basic level and when a solution has been found, the level with the route:
Level:
############
O....#.....#
#.#.#.#.#..#
#........#.O
############
Solution:
############
O*...#.****#
#*#*#*#*#**#
#********#**
############
I would be interested in improvements of the structure of the code (i.e. not of the algorithm itself, only if there is an error), as I would like to improve my general knowledge of programming in Python.
There are some issues I am aware of:
- The parameters at the beginning could be written as enums, but I couldn't convince myself what the advantage would be (apart from polluting the global namespace?) I thought that the more concise way of writing "N" or "WALL" instead of "Direction.N" or "Object.Wall" added to the readability of the code.
- Class "Level": In principle, I would prefer that the attributes are read-only, but I am not sure how to define this properly. Also, I don't see the point of writing getters and setters here.
- In the same class, I didn't want to write __move_dict and __text_map twice in test_route and print_route, so I defined it as class variables. I am not sure if this is idiomatic at all.
- Similarly, test_route and print_route share the same code. I have been thinking if it would be possible to abstract away somehow the common loop, but I have no idea how to do this in Python.
""""Simple implementation of a genetic algorithm:
Searching for a possible route from a given start point
to an end point."""
import random
from dataclasses import dataclass
from typing import List
from collections import namedtuple
from operator import attrgetter
# PARAMETERS
# direction constants
N = 0
E = 1
S = 2
W = 3
# level constants
EMPTY = 0
WALL = 1
DOOR = 2
L1 = [[WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL],
[DOOR, EMPTY, EMPTY, EMPTY, EMPTY, WALL, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, WALL],
[WALL, EMPTY, WALL, EMPTY, WALL, EMPTY, WALL, EMPTY, WALL, EMPTY, EMPTY, WALL],
[WALL, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, WALL, EMPTY, DOOR],
[WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL]]
L1_WIDTH = 12
L1_HEIGHT = 5
# DATATYPES
Point = namedtuple("Point", "x y")
@dataclass
class Level:
"""Class for representing a level with a start and end point."""
map: list
width: int
height: int
start: Point
end: Point
__move_dict = {N: Point(0, 1),
E: Point(1, 0),
S: Point(0, -1),
W: Point(-1, 0)}
__text_map = {WALL: "#", EMPTY: ".", DOOR: "O"}
def test_route(self, genome):
"""Test a route encoded in a genome and return the final distance to the exit."""
def distance(point_a, point_b):
return abs(point_a.x - point_b.x) + abs(point_a.y - point_b.y)
position = self.start
for gene in genome.genes:
delta = self.__move_dict[gene]
new_pos = Point(position.x + delta.x,
position.y + delta.y)
if 0 <= new_pos.x < self.width:
if 0 <= new_pos.y < self.height:
if self.map[new_pos.y][new_pos.x] != WALL:
position = new_pos
if position == self.end:
break
return 1 / (1 + distance(position, self.end))
def print_level(self):
"""Print a text representation of a level."""
for row in self.map:
print("".join((self.__text_map[elem] for elem in row)))
def print_route(self, genome):
"""Print the route through the level."""
text_level = []
for row in self.map:
text_level.append([self.__text_map[elem] for elem in row])
position = self.start
for gene in genome.genes:
delta = self.__move_dict[gene]
new_pos = Point(position.x + delta.x,
position.y + delta.y)
if 0 <= new_pos.x < self.width:
if 0 <= new_pos.y < self.height:
if self.map[new_pos.y][new_pos.x] != WALL:
position = new_pos
text_level[new_pos.y][new_pos.x] = "*"
if position == self.end:
break
for row in text_level:
print("".join(row))
@dataclass
class Genome:
"""Class for representing the genome of running through a level."""
fitness: float
genes: List[int]
class GenomePool:
"""Class implementing the genetic algorithm."""
def __init__(self, level, pool_size, num_genes, crossover_rate, mutation_rate):
self.__level = level
self.__pool_size = pool_size
self.__num_genes = num_genes
self.__crossover_rate = crossover_rate
self.__mutation_rate = mutation_rate
self.__pool = [Genome(0, [random.randint(0, 3) for i in range(0, num_genes)])
for _ in range(self.__pool_size)]
self.__update_fitness()
def __select_genome(self):
"""Do a roulette wheel selection and return a genome."""
total_fitness = sum((genome.fitness for genome in self.__pool))
cut = random.uniform(0, total_fitness)
partial_fitness = 0
idx = 0
while partial_fitness < cut:
partial_fitness += self.__pool[idx].fitness
idx += 1
return self.__pool[idx] if idx < len(self.__pool) else self.__pool[self.__pool_size - 1]
def __crossover(self, mother, father):
"""Do a crossover of two genomes and return an offspring."""
if random.random() > self.__crossover_rate:
return mother
crossover_point = int(random.uniform(0, self.__num_genes))
offspring = Genome(0, [])
offspring.genes = mother.genes[0:crossover_point] + father.genes[crossover_point:]
return offspring
def __mutate(self, genome):
for i in range(self.__num_genes):
if random.random() < self.__mutation_rate:
genome.genes[i] = int(round(random.uniform(0, 3)))
def __update_fitness(self):
"""Update the fitness score of each genome."""
for genome in self.__pool:
genome.fitness = self.__level.test_route(genome)
def get_best_genome(self):
"""Return the genome with the best fitness."""
sorted_pool = sorted(self.__pool, key=attrgetter("fitness"), reverse=True)
return sorted_pool[0]
def run(self, verbose=False):
"""Run the genetic algorithm until a solution has been found."""
iteration = 0
while all((x.fitness != 1 for x in self.__pool)):
if verbose:
best_fitness = self.get_best_genome().fitness
print(f"Iteration {iteration}: Best fitness = {best_fitness}")
iteration += 1
self.step()
def step(self):
"""Run one time step of the evolution."""
new_pool = []
for i in range(self.__pool_size):
mother = self.__select_genome()
father = self.__select_genome()
offspring = self.__crossover(mother, father)
self.__mutate(offspring)
new_pool.append(offspring)
self.__pool = new_pool
self.__update_fitness()
def main():
level_one = Level(L1, L1_WIDTH, L1_HEIGHT, start=Point(0, 1),
end=Point(11, 3))
print("Level:")
level_one.print_level()
genome_pool = GenomePool(level_one, pool_size=30, num_genes=70,
crossover_rate=0.7, mutation_rate=0.01)
genome_pool.run()
print()
print("Solution:")
level_one.print_route(genome_pool.get_best_genome())
if __name__ == "__main__":
main()