-
Open a terminal and navigate to the directory where you'll store this repository.
-
Run:
git clone https://github.com/Cudderson/natural-selection.git
-
-
The http-server package makes it very easy to run a quick local server
-
The link provided details multiple installation options, but I'll provide a global installation:
-
(in terminal)
npm install --global http-server
-
This will install http-server globally so that it may be run from the command line anywhere
-
-
Once installed, navigate to the directory where you cloned this repository and run:
http-server
-
You should see:
Starting up http-server, serving ./
-
followed by two local URLs (http://127.0.0.1:8080, for example)
-
Enter either URL into your web browser to launch the project
-
-
-
-
Navigate to the root of the directory where you cloned this repository
-
Run either (depending on your python version):
python -m http.server 8000
python3 -m http.server 8000
-
Server is now running on port 8000
-
Enter
http://localhost:8000/
into a web browser to launch the project (You can choose a port other than 8000 if required)
-
-
- A customizable genetic algorithm for unique simulations
- A sibling animation that runs alongside the genetic algorithm for an entertaining user-experience
- Two simulation types, one including a user-drawn path to the target goal!
-
- Javascript
- HTML Canvas
- HTML/CSS
In the context of computer science, genetic algorithms (GAs) are optimization algorithms based on the process of natural selection.
These five phases are repeated until the algorithm no longer produces offspring that are significantly different from the previous generation. This is called convergence.
-
In Simulation Settings, users are able to input their own settings and customize the algorithm. In this section, however, we'll use hard-coded settings for easier understanding.
-
class Organism { constructor (gender) { this.gender = gender; this.genes = []; ... } setRandomGenes() { for (let i = 0; i < 300; i++) { // returns random coordinate pair from [-5, -5] to [5, 5] let random_gene = getRandomGene(-5, 5); this.genes.push(random_gene); } } } function createOrganisms () { let gender; let initial_population = []; // create an equal number of males and females for (let i = 0; i < 100; i++) { if (i % 2) { gender = 'male'; } else { gender = 'female'; } let organism = new Organism(gender); organism.setRandomGenes(); initial_population.push(organism); } return initial_population; } let initial_population = createOrganisms();
-
class Organism { constructor (gender) { this.gender = gender; this.genes = []; this.x = 0; this.y = 0; this.index = 0; ... } update() { if (this.index < 300) { this.x += this.genes[this.index][0]; this.y += this.genes[this.index][1]; this.index++; } } } function updateAndMoveOrganisms(organisms) { for (let i = 0; i < 300; i++) { for (let j = 0; j < organisms.length; j++) { organisms[j].update(); organisms[j].move(); } } } updateAndMoveOrganisms(organisms);
-
The move() method draws an organism on the 2D plane using its
x
andy
attributes as coordinates. We'll see the visual animation for a simulation in the Simulation Demo section.
-
class Organism { constructor (gender, x, y) { this.gender = gender; this.genes = []; this.x = x; this.y = y; this.index = 0; this.distance_to_goal; this.fitness; ... } calcDistanceToGoal() { // c^2 = a^2 + b^2 let horizontal_distance_squared = (this.x - GOAL_X_POS) ** 2; let vertical_distance_squared = (this.y - GOAL_Y_POS) ** 2; let distance_to_goal_squared = vertical_distance_squared + horizontal_distance_squared; let distance_to_goal = Math.sqrt(distance_to_goal_squared); this.distance_to_goal = distance_to_goal; } calcFitness() { let height = SPAWN_Y_POS - GOAL_Y_POS; let normalized_distance_to_goal = this.distance_to_goal / height; this.fitness = 1 - normalized_distance_to_goal; } } function calcPopulationFitness (organisms) { let total_fitness = 0.00; for (let i = 0; i < organisms.length; i++) { organisms[i].calcDistanceToGoal(); organisms[i].calcFitness(); total_fitness += organisms[i].fitness; } let average_fitness = total_fitness / organisms.length; return average_fitness; } let average_fitness = calcPopulationFitness(organisms);
-
To calculate an organism's fitness, we first need to calculate its distance to the goal. Since we're on a 2D plane, all we'll need is the Pythagorean theorem (a2 + b2 = c2)
-
Note: This is the fitness function used in Classic simulations. Later, we'll see that Boundary simulations use a different fitness function.
-
function beginSelectionProcess(organisms, average_fitness) { let selection_factor; if (average_fitness < .1) { selection_factor = 100; } else { selection_factor = 10; } let potential_mothers = []; let potential_fathers = []; for (let i = 0; i < organisms.length; i++) { if (organisms[i].fitness < average_fitness) { if (organisms[i].gender === 'female') { potential_mothers.push(organisms[i]); } else if (organisms[i].gender === 'male') { potential_fathers.push(organisms[i]); } } else { for (let j = 0; j < Math.ceil((organisms[i].fitness * selection_factor) ** 2); j++) { if (organisms[i].gender === 'female') { potential_mothers.push(organisms[i]); } else if (organisms[i].gender === 'male') { potential_fathers.push(organisms[i]); } } } } let potential_parents = { 'potential_mothers': potential_mothers, 'potential_fathers': potential_fathers } return potential_parents; } let potential_parents = beginSelectionProcess(organisms, average_fitness);
-
Note:
selection_factor
is an optimization I implemented to keep array sizes smaller asaverage_fitness
increases, as well as strengthen selection-bias in early generations. However, it is not necessary for the overall-functioning of the algorithm.
-
function selectParentsForReproduction(potential_parents, population_size) { let potential_mothers = potential_parents['potential_mothers']; let potential_fathers = potential_parents['potential_fathers']; let parents = []; for (let i = 0; i < (population_size / 2); i++) { let mother_index = Math.floor(Math.random() * potential_mothers.length); let father_index = Math.floor(Math.random() * potential_fathers.length); let mother = potential_mothers[mother_index]; let father = potential_fathers[father_index]; let new_parents = [mother, father]; parents.push(new_parents); } return parents; } let parents = selectParentsForReproduction(potential_parents, organisms.length);
-
Technical: This particular method of selection would be considered fitness proportionate selection, or 'roulette wheel selection', where all organisms are given a chance of being selected proportionate to their fitness score. It's not guaranteed that the highest-fitness organisms will be selected, nor that the lowest-fitness organisms won't be.
-
function crossover(parents_to_crossover) { let mother = parents_to_crossover[0]; let father = parents_to_crossover[1]; let crossover_genes = []; for (let j = 0; j < 300; j++) { // returns value between 0-1 let random_val = Math.random(); if (random_val < 0.5) { let mother_gene = mother.genes[j]; crossover_genes.push(mother_gene); } else { let father_gene = father.genes[j]; crossover_genes.push(father_gene); } } return crossover_genes; } let offspring_organisms = []; for (let i = 0; i < parents.length; i++) { for (let j = 0; j < 2; j++) { let crossover_genes = crossover(parents[i]); let offspring = new Organism(); offspring.genes = crossover_genes; offspring_organisms.push(offspring); } }
-
Note: This crossover implementation will keep the population size constant from generation to generation. In Simulation Settings, you can optionally choose that your population sizes 'fluctuate' each generation.
-
function mutate(offspring_organisms) { const MUTATION_RATE = 0.03; for (let i = 0; i < offspring_organisms.length; i++) { for (let j = 0; j < 300; j++) { // returns float between 0-1 let random_val = Math.random(); // apply mutation for variance if (random_bool < MUTATION_RATE) { let mutated_gene = getRandomGene(-5, 5); offspring_organisms[i].genes[j] = mutated_gene; } } } } offspring_organisms = mutate(offspring_organisms);
-
We can simulate gene mutation by comparing random numbers to our desired mutation rate. In this example, we mutate a gene if the random value is less than 0.03. This will mutate approximately 3% of all genes in the offspring population.
-
the gene mutation rate of organisms is configurable in Simulation Settings for your own simulations
-
-
Note: In the project files, the mutation phase is woven into crossover() for efficiency, but is separated in this example for readability and understanding.
This is the end of the first generation. In future generations, the same process of evaluating and assigning fitness scores to organisms, selecting parents to reproduce, and crossing-over and mutating parent genes for the next-generation will resume. These steps will be repeated until either an organism from the population reaches the goal, or the algorithm prematurely converges to a suboptimal solution (simulation fails).
- Note: You can choose to continue running your simulations, even after your population succeed in reaching the goal, if you wish to see the algorithm optimize your population further
- Note: Simulations are made using Javascript to manipulate an HTML Canvas to create an animation that follows the algorithm.
-
In Classic simulations, we determine how fit an organism is by calculating its straight-line distance to the goal. The closer an organism is to the goal, the higher its fitness. However, this approach isn't sufficient for Boundary simulations, as most boundary paths are not straight-lines to the goal. Instead, it would be better to measure an organism's fitness based on its ability to progress through the boundary path, as the boundary path is the only way to reach the goal.
-
This is better. We loop over the top/bottom boundary wall coordiantes and draw a line every x iterations until we have 10 lines.
-
With a proper 'distance to the goal' calculation, we can determine an organism's fitness score the same way we do for Classic simulations. (See Algorithm Implementation)
This algorithm isn't perfect and doesn't always produce the best-checkpoints. However, it's consistent-enough to yield appropriate checkpoints for most user-drawn boundaries.
Setting | Description |
---|---|
Initial Population Size | Amount of organisms to create for the first generation of the simulation |
Movement Speed | Relative-maximum distance an organism can travel in one movement |
Mutation Rate | Target percentage of genes to be mutated in offspring organisms |
Resilience | Percent chance an organism will survive if it touches the boundary (Boundary simulation type only) |
Population Growth | 'Contant' vs 'Fluctuate' (toggle) |
Dialogue | When checked, simulation will run will run with additional GA phase highlighting, descriptions, and animations (toggle) |
In this section, we'll walkthrough demonstrations of Classic and Boundary simulations. For readability, I'll be just focusing on the progression of the population/algorithm, specifically, the evaluation phase.
Additionally, here's a rough video demonstrating a Boundary simulation from creation to completion (~3 minutes): https://youtu.be/6w1lepPAuoM
-
The first generation is essentially a random walk, as an organism's genes are completely random. Any progress here is due completely to chance.
-
Success! After only 14 generations, the genetic algorithm has optimized our species of organisms ability to reach the goal!
- Note: Though this simulation succeeded in just 14 generations, it's worth mentioning that all simulations will be different, as different settings and randomness will result in different outcomes. Some may succeed sooner, some longer, and some may prematurely converge and never reach the goal.
-
The first generation is essentially a random walk, as an organism's genes are completely random. Any progress here is due completely to chance. We can see many of the organisms turn gray and 'die' in the spawn location.
-
Allowing the simulation to continue will result in your population to be further optimized, and more organisms should begin to reach the goal.
- Note: Though it took this simulation 71 generations to succeed, it's worth mentioning that all simulations will be different, as different settings, boundaries and randomness will result in different outcomes. Some may succeed sooner, some longer, and some may prematurely converge and never reach the goal.
-
Look at this simulation (below), for example. The algorithm prematurely converged around the 0.45 - 0.50 average fitness level. This could be due to settings that did not benefit the evolution of the population (mutation rate too high, etc.), inefficiency in the genetic algorithm, an error in the boundary checkpoint-creation algorithm, or simply random chance:
-
-
Thinking about the bigger picture, this simple genetic algorithm is incredibly abstract compared to real-life biology and the probably billions of parameters that influence natural selection in the world. However trivial it may seem, though, I still find it magical that we're able to essentially teach some dots on a screen how to find a target goal, starting with nothing but random genes.
-
-
-
The idea and inspiration for this project originates from this awesome blog post from Luke Garrigan. In his post, he details the ideas of natural selection and creates a genetic algorithm that teaches cars to drive around a track! Check him out!
-
Additionally, this introduction to genetic algorithms from https://towardsdatascience.com was helpful for getting started.
-
-
Email: codered1227@gmail.com