DEV Community

Cover image for Dots Simulation using Genetic Algorithm - Part 3
Abdulrazaq Ahmad
Abdulrazaq Ahmad

Posted on

Dots Simulation using Genetic Algorithm - Part 3

In this part, we'll code the following features:

  • Add a save and load system
  • Add a way to speed up the evolution

Starting with the save and load system, we'll utilize the pickle package.

pickle allows saving entire Python objects, including lists, dictionaries, and custom objects (like Population class object), making it easier to pause and resume simulations.

An excerpt from the package documentation.

The pickle module implements binary protocols for serializing and de-serializing a Python object structure. “Pickling” is the process whereby a Python object hierarchy is converted into a byte stream, and “unpickling” is the inverse operation, whereby a byte stream (from a binary file or bytes-like object) is converted back into an object hierarchy.

Serialization/pickling is the process used to save the state of an object while de-serialization/unpickling is for loading the state of an object.

Now let's import the package inside the dots.py script. Add the following line.

import pickle
Enter fullscreen mode Exit fullscreen mode

Next, let's add the following methods to the Population class to use the package.

def save(self, file):
    with open(file, 'w+b') as f:
        pickle.dump(self, f)

@staticmethod
def load(file):
    with open(file, 'r+b') as f:
        obj = pickle.load(f)
    return obj
Enter fullscreen mode Exit fullscreen mode

The save method saves the current Population object to a file. It first opens the file in binary write mode ('w+b') and then uses pickle.dump to serialize the current object and store it in the specified file path.

The load method which is a static method (@staticmethod) loads a previously saved Population object from the specified file path. It first opens the file in binary read mode ('r+b') and then uses pickle.load to deserialize and reconstruct the Population object. And then finally returns the loaded object.

The load method is a static method while save is not because save saves a specific instance thereby needing the self argument. While load doesn't depend on an instance, it creates a new one.

I used @classmethod for the crossover method in Dot class but never used the cls argument. In that case too, it is better to use @staticmethod decorator which doesn't have the cls parameter.

You can now call population.save("path/to/file") or Population.load("path/to/file") to save or load respectively.

Let's add a simple system to save the Population periodically after some generations. Add the following code inside the main execution just below the obstacles variable.

run_dir = 'run1'
save_files = True

if save_files:
    os.makedirs(run_dir)
    pop_file_path = os.path.join(run_dir, 'population')
Enter fullscreen mode Exit fullscreen mode

Code break down:

  • run_dir = 'run1': Defines the directory name where population data will be saved. If saving is enabled, a folder named 'run1' will be created to store files.
  • save_files = True: A flag that controls whether the program saves files or not. If False, saving is skipped.
  • if save_files:: This only runs if save_files is True.
  • os.makedirs(run_dir) creates a directory named 'run1' if it does not already exist. This ensures that files can be saved inside it. If it exists, a FileExistsError exception will be raised.
  • pop_file_path = os.path.join(run_dir, 'population') constructs a file path for saving the population.

To use os, you need to import the package using import os.

Add the following code at the end of the main execution block just below print('Generation', i, ...) to do the actual saving,

if save_files and i % 10 == 0:
    population.save(f'{pop_file_path}_{i}')
Enter fullscreen mode Exit fullscreen mode

This piece saves the population every 10 generations (i is the generation number). This prevents saving on every generation, reducing file clutter. f'{pop_file_path}_{i}' constructs a filename like:

  • 'run1/population_0' (for generation 0)
  • 'run1/population_10' (for generation 10)
  • 'run1/population_20' (for generation 20)

This allows tracking progress by keeping separate save files for different generations. You can modify the generations interval before each save by changing the 10 in i % 10 == 0 to your desired interval.

To load a saved population, you use population = Population.load('path/to/file') instead of population = Population(GOAL, POPULATION) which creates a new one.

You can now run the script. A new folder named run1 will be created in the scripts directory. And if you have let the first generation to finish, a file named population_0 will be created inside the new folder. That is the saved population file of generation one.

Sometimes, you might create obstacles that are very hard to deal with, taking thousands of generations before achieving something significant.

A hard set of obstacles
A very hard configuration of obstacles

In that case, if we can find a way to speed up the simulation, that will be of tremendous importance. To speed up the simulation, we can stop rendering the dots and the obstacles. And more importantly, stop limiting the frames. We'll only render the texts to get a sense of what's is happening. By doing all these, the speed of the program won't be intentionally limited, it will only be limited by the calculations it does.

To do this, we'll first create a variable that controls whether or not all objects will be rendered. Add the following code just below this line save_files = True.

render_objects = True
Enter fullscreen mode Exit fullscreen mode

Setting this variable to render or not before running the program seems very static. Instead, it will be better if we can dynamically change the variable while the program is running. This will allow us to render everything or not when ever we want. We will use clicking on the screen to toggle the rendering. Clicking on the screen will turn off rendering everything and clicking it again will turn it on.

We will utilize the event for-loop to do this. But this is just one of the change we will make to the main execution block. We will also:

1- Use the variable inside the main execution block's while-loop to only render everything if the variable is set to True.
2- Use the variable to render only the texts if the variable is set to False.

Modify the block to match the following one in order to add the new features.

if __name__ == '__main__':
    window = pg.display.set_mode((WIDTH, HEIGHT))  # Creates the window
    pg.display.set_caption('Dots Simulation')  # Sets window's caption
    clock = pg.time.Clock()  # Clock for controlling fps
    font = pg.font.SysFont('comicsans', 20)  # font for creating texts

    obstacles = OBSTACLES3
    run_dir = 'run1'
    save_files = True
    render_objects = True

    if save_files:
        os.makedirs(run_dir)
        pop_file_path = os.path.join(run_dir, 'population')

    if GOAL not in obstacles:
        obstacles.append(GOAL)

    population = Population(GOAL, POPULATION)
    #population = Population.load('run1/population_100')
    reached_goal = 0

    for i in range(GENERATIONS):
        while population.alive():
            for e in pg.event.get():
                # Handling window close event
                if e.type == pg.QUIT:
                    pg.quit()
                    quit()
                elif e.type == pg.MOUSEBUTTONDOWN:  # Handling mouse/screen click
                    render_objects = not render_objects

            alive = population.update(obstacles)

            # If render_objects is set to true
            if render_objects:
                # Rendering all objects
                window.fill('white')

                for obstacle in obstacles:
                    obstacle.draw(window)

                population.draw(window)

                gen_text = font.render('Generation: ' + str(i), 1, 'black')
                window.blit(gen_text, (10, 10))

                alive_text = font.render('Alive: ' + str(alive), 1, 'black')
                window.blit(alive_text, (10, gen_text.get_height() + 10))

                reached_goal_text = font.render('Reached: ' + str(reached_goal), 1, 'black')
                window.blit(reached_goal_text, (10, alive_text.get_height() * 2 + 10))

                # Update the display
                pg.display.flip()
                clock.tick(60)  # Limit frames to 60 per second

        best, best_moves, reached_goal = population.generate_next_generation()
        print('Generation', i, 'Best dot moves', best_moves, 'Reached Goal:', reached_goal)

        # If render_objects is set to false
        if not render_objects:
            # Rendering only texts
            window.fill('white')

            gen_text = font.render('Generation: ' + str(i), 1, 'black')
            window.blit(gen_text, (10, 10))

            reached_goal_text = font.render('Reached: ' + str(reached_goal), 1, 'black')
            window.blit(reached_goal_text, (10, gen_text.get_height() + 10))

            # Update the display. Frames not limited
            pg.display.flip()

        # Save population after every 10 generations
        if save_files and i % 10 == 0:
            population.save(f'{pop_file_path}_{i}')
Enter fullscreen mode Exit fullscreen mode

First, this line elif e.type == pg.MOUSEBUTTONDOWN: checks if the user clicked the mouse (or tapped the screen on mobile). If it returns True, then render_objects = not render_objects will toggle the variable on or off depending on its current value. If it is True, it will set it to False, and if it is False, it will set it back to True.

Next, this line if render_objects: checks if render_objects is True. If True, the block proceeds with rendering everything onto the screen otherwise not. The block also limits the program to only 60 frames per second.

Lastly, this line if not render_objects: checks if render_objects is False and if so, the block proceeds with rendering only the 'Generation' and 'Reached' texts onto the screen. When render_objects is set to False, rendering is only done once - after the generation has finished - without limiting the frames to help speed up the program.

Run the program and try the new feature by clicking on the screen.

A window rendering only texts
A window rendering only texts

You may have noticed a little delay before the objects disappear while turning off the display. This is because after clicking the screen to turn off rendering, the already rendered frame won't be updated unless the generation is finished. We can stop this by updating the display immediately after detecting a click event without waiting for the generation to finish. This is left to you, try removing it.

This will be the end of the Genetic Algorithm part of this simulation. In the coming parts (dateless), we will discard the weak chromosomes and give the dots brain and sensors (eyes) using NEAT to traverse the environment even more intelligently.

Image showing next generation dots
Next generation dots

Feel free to experiment with the code by adding new obstacles, changing parameters, or even modifying the fitness function. Share your results or insights from your experiments in the comments.

Download the full source code up to this point from GitHub.

If you've followed this far, it means you're passionate about AI! Take your learning further with my book, where I covered AI search techniques with hands-on projects. Get your copy on Amazon today!

Love diving into the world of AI and creativity? Join our AICraftsLab Discord community and connect with fellow enthusiasts—let’s craft something amazing together!

Top comments (0)