Personal blog written from scratch using Node.js, Bootstrap, and MySQL. https://jrtechs.net
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

17 KiB

Live Simulation

Background and Theory

Since you stumbled upon this article, you might be wondering what the heck genetic algorithms are. To put it simply: genetic algorithms employ the same tactics used in natural selection to find an optimal solution to an optimization problem. Genetic algorithms are often used in high dimensional problems where the optimal solutions are not apparent. Genetic algorithms are commonly used to tune the hyper-parameters of a program. However, this algorithm can be used in any scenario where you have a function which defines how well a solution is. Many people have used genetic algorithms in video games to auto learn the weaknesses of players.

The beautiful part about Genetic Algorithms are their simplicity; you need absolutely no knowledge of linear algebra or calculus. To implement a genetic algorithm from scratch you only need very basic algebra and a general grasp of evolution.

Genetic Algorithm

All genetic algorithms typically have a single cycle where you continuously mutate, breed, and select the most optimal solutions. I will dive into each section of this algorithm using simple JavaScript code snippets. The algorithm which I present is very generic and modular so it should be easy to port into other programming languages and applications.

Genetic Algorithms Flow Chart

Population Creation

The very first thing we need to do is specify a data-structure for storing our genetic information. In biology, chromosomes are composed of sequences of genes. Many people run genetic algorithms on binary arrays since they more closely represent DNA. However, as computer scientists, it is often easier to model problems using continuous numbers. In this approach, every gene will be a single floating point number ranging between zero and one. Every type of gene will have a max and min value which represents the absolute extremes of that gene. This works well for optimization because it allows us to easily limit our search space. For example, we can specify that "height" gene can only vary between 0 and 90. To get the actual value of the gene from its [0-1] value we simple de-normalize it.

$$ g_{real value} = (g_{high}- g_{low})g_{norm} + g_{low} $$

class Gene
{
    /**
     * Constructs a new Gene to store in a chromosome.
     * @param min minimum value that this gene can store
     * @param max value this gene can possibly be
     * @param value normalized value
     */
    constructor(min, max, value)
    {
        this.min = min;
        this.max = max;
        this.value = value;
    }

    /**
     * De-normalizes the value of the gene
     * @returns {*}
     */
    getRealValue()
    {
        return (this.max - this.min) * this.value + this.min;
    }

    getValue()
    {
        return this.value;
    }

    setValue(val)
    {
        this.value = val;
    }

    makeClone()
    {
        return new Gene(this.min, this.max, this.value);
    }

    makeRandomGene()
    {
        return new Gene(this.min, this.max, Math.random());
    }
}

Now that we have genes, we can create chromosomes. Chromosomes are simply collections of genes. Whatever language you make this in, make sure that when you create a new chromosome it is has a deep copy of the original genetic information rather than a shallow copy. A shallow copy is when you simple copy the object pointer where a deep copy is actually creating a new object. If you fail to do a deep copy, you will have weird issues where multiple chromosomes will share the same DNA.

In this class I added helper functions to clone the chromosome as a random copy. You can only create a new chromosome by cloning because I wanted to keep the program generic and make no assumptions about the domain. Since you only provide the min/max information for the genes once, cloning an existing chromosome is the easiest way of ensuring that all corresponding chromosomes contain genes with identical extrema.

class Chromosome
{
    /**
     * Constructs a chromosome by making a copy of
     * a list of genes.
     * @param geneArray
     */
    constructor(geneArray)
    {
        this.genes = [];
        for(let i = 0; i < geneArray.length; i++)
        {
            this.genes.push(geneArray[i].makeClone());
        }
    }

    getGenes()
    {
        return this.genes;
    }

    /**
     * Mutates a random gene.
     */
    mutate()
    {
        this.genes[Math.round(Math.random() * (this.genes.length-1))].setValue(Math.random());
    }

    /**
     * Creates a totally new chromosome with same
     * genetic structure as this chromosome but different
     * values.
     * @returns {Chromosome}
     */
    createRandomChromosome()
    {
        let geneAr = [];
        for(let i = 0; i < this.genes.length; i++)
        {
            geneAr.push(this.genes[i].makeRandomGene());
        }
        return new Chromosome(geneAr);
    }
}

Creating a random population is pretty straight forward if implemented a method to create a random clone of a chromosome.

/**
 * Creates a totally random population based  on a desired size
 * and a prototypical chromosome.
 *
 * @param geneticChromosome
 * @param populationSize
 * @returns {Array}
 */
const createRandomPopulation = function(geneticChromosome, populationSize)
{
    let population = [];
    for(let i = 0; i < populationSize; i++)
    {
        population.push(geneticChromosome.createRandomChromosome());
    }
    return population;
};

This is where nearly all the domain information is introduced. After you define what types of genes are found on each chromosome, you can create an entire population. In this example all genes contain values ranging between one and ten.

let gene1 = new Gene(1,10,10);
let gene2 = new Gene(1,10,0.4);
let geneList = [gene1, gene2];

let exampleOrganism = new Chromosome(geneList);  

let population = createRandomPopulation(genericChromosome, 100);

Evaluate Fitness

Like all optimization problems, you need a way to evaluate the performance of a particular solution. The cost function takes in a chromosome and evaluates how close it got to the ideal solution. This particular example it is just computing the Manhattan Distance to a random 2D point. I chose two dimensions because it is easy to graph, however, real applications may have dozens of genes on each chromosome.

let costx = Math.random() * 10;
let costy = Math.random() * 10;

/** Defines the cost as the "distance" to a 2-d point.
 * @param chromosome
 * @returns {number}
 */
const basicCostFunction = function(chromosome)
{
    return Math.abs(chromosome.getGenes()[0].getRealValue() - costx) +
        Math.abs(chromosome.getGenes()[1].getRealValue() - costy);
};

Selection

Selecting the best performing chromosomes is straightforward after you have a function for evaluating the performance. This code snippet also computes the average and best chromosome of the population to make it easier to graph and define the stopping point for the algorithm's main loop.

/**
 * Function which computes the fitness of everyone in the
 * population and returns the most fit survivors. Method
 * known as elitism.
 *
 * @param population
 * @param keepNumber
 * @param fitnessFunction
 * @returns {{average: number,
 * survivors: Array, bestFit: Chromosome }}
 */
const naturalSelection = function(population, keepNumber, fitnessFunction)
{
    let fitnessArray = [];
    let total = 0;
    for(let i = 0; i < population.length; i++)
    {
        const fitness = fitnessFunction(population[i]);
        fitnessArray.push({fit:fitness, chrom: population[i]});
        total+= fitness;
    }

    fitnessArray.sort(predicateBy("fit"));

    let survivors = [];
    let bestFitness = fitnessArray[0].fit;
    let bestChromosome = fitnessArray[0].chrom;
    for(let i = 0; i < keepNumber; i++)
    {
        survivors.push(fitnessArray[i].chrom);
    }
    return {average: total/population.length, survivors: survivors, bestFit: bestFitness, bestChrom: bestChromosome};
};

You might be wondering how I sorted the list of JSON objects - not a numerical array. I used the following function as a comparator for JavaScript's built in sort function. This comparator will compare objects based on a specific attribute that you give it. This is a very handy function to include in all of your JavaScript projects for easy sorting.

/**
 * Helper function to sort an array
 *
 * @param prop name of JSON property to sort by
 * @returns {function(*, *): number}
 */
function predicateBy(prop)
{
    return function(a,b)
    {
        var result;
        if(a[prop] > b[prop])
        {
            result =  1;
        }
        else if(a[prop] < b[prop])
        {
            result = -1;
        }
        return result;
    }
}

Reproduction

The process of reproduction can be broken down into Pairing and Mating.

Pairing

Pairing is the process of selecting mates to produce offspring. A typical approach will separate the population into two segments of mothers and fathers. You then randomly pick pairs of mothers and fathers to produce offspring. It is ok if one chromosome mates more than once. It is just important that you keep this process random.

/**
 * Randomly  everyone in the population
 *
 * @param population
 * @param desiredPopulationSize
 */
const matePopulation = function(population, desiredPopulationSize)
{
    const originalLength = population.length;
    while(population.length < desiredPopulationSize)
    {
        let index1 = Math.round(Math.random() * (originalLength-1));
        let index2 = Math.round(Math.random() * (originalLength-1));
        if(index1 !== index2)
        {
            const babies = breed(population[index1], population[index2]);
            population.push(babies[0]);
            population.push(babies[1]);
        }
    }
};

Mating

Mating is the actual act of forming new chromosomes/organisms based on your previously selected pairs. From my research, there are two major forms of mating: blending, crossover.

Blending is typically the most preferred approach to mating when dealing with continuous variables. In this approach you combine the genes of both parents based on a random factor.

$$ c_{new} = r * c_{mother} + (1-r) * c_{father} $$

The second offspring simply uses (1-r) for their random factor to adjust the chromosomes.

Crossover is the simplest approach to mating. In this process you clone the parents and then you randomly swap n of their genes. This works fine in some scenarios; however, this severely lacks the genetic diversity of the genes because you now have to solely rely on mutations for changes.

/**
 * Mates two chromosomes using the blending method
 * and returns a list of 2 offspring.
 * @param father
 * @param mother
 * @returns {Chromosome[]}
 */
const breed = function(father, mother)
{
    let son = new Chromosome(father.getGenes());
    let daughter = new Chromosome(mother.getGenes());

    for(let i = 0;i < son.getGenes().length; i++)
    {
        let blendCoef = Math.random();
        blendGene(son.getGenes()[i], daughter.getGenes()[i], blendCoef);
    }
    return [son, daughter];
};

/**
 * Blends two genes together based on a random blend
 * coefficient.
 **/
const blendGene = function(gene1, gene2, blendCoef)
{
    let value1 = (blendCoef * gene1.getValue()) +
        (gene2.getValue() * (1- blendCoef));
    let value2 = ((1-blendCoef) * gene1.getValue()) +
        (gene2.getValue() * blendCoef);

    gene1.setValue(value1);
    gene2.setValue(value2);
};

Mutation

Mutations are random changes to an organisms DNA. In the scope of genetic algorithms, it helps our population converge on the correct solution.

You can either adjust genes by a factor resulting in a smaller change or, you can change the value of the gene to be something completely random. Since we are using the blending technique for reproduction, we already have small incremental changes. I prefer to use mutations to randomly change the entire gene since it helps prevent the algorithm from settling on a local minimum rather than the global minimum.

/**
 * Randomly mutates the population
 **/
const mutatePopulation = function(population, mutatePercentage)
{
    if(population.length >= 2)
    {
        let mutations = mutatePercentage *
                        population.length *
                        population[0].getGenes().length;
        for(let i = 0; i < mutations; i++)
        {
            population[i].mutate();
        }
    }
    else
    {
        console.log("Error, population too small to mutate");
    }
};

Immigration

Immigration or "new blood" is the process of dumping random organisms into your population at each generation. This prevents us from getting stuck in a local minimum rather than the global minimum. There are more advanced techniques to accomplish this same concept. My favorite approach (not implemented here) is raising x populations simultaneously and every y generations you take z organisms from each population and move them to another population.

/**
 * Introduces x random chromosomes to the population.
 * @param population
 * @param immigrationSize
 */
const newBlood = function(population, immigrationSize)
{
    for(let i = 0; i < immigrationSize; i++)
    {
        let geneticChromosome = population[0];
        population.push(geneticChromosome.createRandomChromosome());
    }
};

Putting It All Together

Now that we have all the ingredients for a genetic algorithm we can piece it together in a simple loop.

/**
 * Runs the genetic algorithm by going through the processes of
 * natural selection, mutation, mating, and immigrations. This
 * process will continue until an adequately performing chromosome
 * is found or a generation threshold is passed.
 *
 * @param geneticChromosome Prototypical chromosome: used so algo knows
 *                            what the dna of the population looks like.
 * @param costFunction Function which defines how bad a Chromosome is
 * @param populationSize Desired population size for population
 * @param maxGenerations Cut off level for number of generations to run
 * @param desiredCost Sufficient cost to terminate program at
 * @param mutationRate Number between [0,1] representing proportion of genes
 * to mutate each generation
 * @param keepNumber Number of Organisms which survive each generation
 * @param newBloodNumber Number of random immigrants to introduce into
 * the population each generation.
 * @returns {*}
 */
const runGeneticOptimization = function(geneticChromosome, costFunction,
                                        populationSize, maxGenerations,
                                        desiredCost, mutationRate, keepNumber,
                                        newBloodNumber)
{
    let population = createRandomPopulation(geneticChromosome, populationSize);
    let generation = 0;
    let bestCost = Number.MAX_VALUE;
    let bestChromosome = geneticChromosome;
    do
    {
        matePopulation(population, populationSize);
        newBlood(population, newBloodNumber);
        mutatePopulation(population, mutationRate);
        let generationResult = naturalSelection(population, keepNumber, costFunction);

        if(bestCost > generationResult.bestFit)
        {
            bestChromosome = generationResult.bestChrom;
            bestCost = generationResult.bestFit;
        }
        population = generationResult.survivors;

        generation++;
        console.log("Generation " + generation + " Best Cost: " + bestCost);
    }while(generation < maxGenerations && bestCost > desiredCost);
    return bestChromosome;
};

Running

Running the program is pretty straight forward after you have your genes and cost function defined. You might be wondering if there is an optimal configuration of parameters to use with this algorithm. The answer is that it varies based on the particular problem. Problems like the one graphed by this website perform very well with a low mutation rate and a high population. However, some higher dimensional problems won't even converge on a local answer if you set your mutation rate too low.

let gene1 = new Gene(1,10,10);
...
let geneN = new Gene(1,10,0.4);
let geneList = [gene1,..., geneN];

let exampleOrganism = new Chromosome(geneList);  

costFunction = function(chromosome) {     var d =...;     //compute
cost     return d; }  

runGeneticOptimization(exampleOrganism, costFunction, 100, 50, 0.01, 0.3, 20, 10);

The complete code for the genetic algorithm and the fancy JavaScript graphs can be found in my Random Scripts GitHub Repository. In the future I may package this into an npm package.