Introduction to PonyGE2 for Grammatical Evolution | by Peter Adam | Towards Data Science

Grammatical Evolution is a powerful method for algorithm optimisation. Given an objective function and a search space (grammar), it is possible to use evolutionary computing methods to evolve an algorithm to optimally (or at least efficiently) maximise the objective function.
PonyGE2 is a Python3 implementation which makes GE easy. This walkthrough discusses the pros and cons of GE and provides an example of how to evolve your own algorithms with GE. A paper describing fully the operation of PonyGE2 can be found here.
Unfortunately, Grammatical Evolution with PonyGE2 on current computers isn’t fast enough to evolve functions from large grammars in a reasonable time quite yet, but this technique is definitely one to keep an eye on in the future.
The method was first proposed by Dr. Michael O’Neill (paywall) in 2001 as a way to apply evolutionary computing methods to problems with a Formal Grammatical Structure, such as algorithms.
A Formal Grammar can be thought of as the set of rules that define a language, or just an official way of setting out what combination of words makes sense. In English, we learn these from our parents when learning to speak, and in school. When learning a second language, it can generally be broken down into two steps:
Learn vocabularyLearn the grammar
Learn vocabulary
Learn the grammar
Programming isn’t much different, and the major difference between programming languages isn’t what you’re trying to achieve, but how you tell them computer to do that.
So then how do we teach grammar to computers? And how are these words then used in evolutionary optimisation?
The process works something like this:
Define a Backus Naur Form (BNF) representation of the grammar;Randomly generate linear chromosomes to encode information;Evaluate the fitness of chromosomes by mapping the grammar to the chromosome and testing against the fitness function;Use Genetic Operators such as Crossover, Mutation and Reproduction to generate next generation of chromosomes;Repeat steps 3 and 4 for a certain number of generations.
Define a Backus Naur Form (BNF) representation of the grammar;
Randomly generate linear chromosomes to encode information;
Evaluate the fitness of chromosomes by mapping the grammar to the chromosome and testing against the fitness function;
Use Genetic Operators such as Crossover, Mutation and Reproduction to generate next generation of chromosomes;
Repeat steps 3 and 4 for a certain number of generations.
A BNF grammar is a way of writing grammar that computers can understand (context free). In Python, it is represented by a tuple containing 4 sets:
T: The terminal Set
N: The non-terminal set
P: Set of production rules
S: Start symbol (which is a member of N).
This probably doesn’t clear things up, but it’s good to keep in mind. Let’s look at an example:
T = {+, -, /, *, x, y}
So the terminal set is the set of all operators and variables you want to use in your program.
N = {<e>, <o>, <v>}
The non-terminal set is the set of all production activities that the program can undertake. In this situation, <e> is an expression, <o> is an operator, and <v> is a variable.
P then is the set of production rules:
<e> ::= <e><o><e> | <v><o> ::= + | - | / | *<v> ::= x | y
Notice that all entries inP are entries of either T or N . The pipe operator | represents or . So when an <e> is encountered in the chromosome, it is either replaced with <e><o><e> , or <v> , and likewise for <o> and <v> .
Finally, the start symbol:
S = <e>
This is probably still quite confusing, but the reason for representing the grammar in this form becomes apparent when evaluating a chromosome.
The simplest way of describing chromosomes is as a non-negative integer array. The values don’t matter much, but the length does (more on this later).
So for our example, let us call our chromosome:
C: [4, 15, 75, 8, 41, 12]
Now everything comes together. Remember our starting symbol was:
<e>
Now, according to our Production Rules P , when a <e> is encountered, it is either replaced with <e><o><e> or <v> . But how do we know which one? Well, the chromosome tells us. The first value of the chromosome is 4 , and there are 2 options for <e> in the production rules. 4 mod 2 is 0 , so we take the 0-th (zero-indexed, so really the 1-st entry) option from the production rules, <e><o><e> .
So now our expression has expanded to <e><o><e> , and we’re evaluating the first entry <e>(as it’s not a member of the terminal set, we keep evaluating each entry until it is replaced with a member of the terminal set).
We know there are 2 options for <e> in the Production Rules, so we take the 2nd entry in the chromosome, 15 , and 15 mod 2 gives 1, so we take the 1st (zero-indexed, so really the 2nd entry) option from the production rules, <v> .
We continue with the following steps:
Full Expression: <v><o><e> Current Expression: <v> Number of <v> options in Grammar: 2 Chromosome value: 75 75 mod 2 = 1 Replace <v> with y
Full Expression: y <o><e> Current Expression: <o> Number of <o> options in Grammar: 4Chromosome value: 88 mod 4 = 0Replace <o> with +
Full Expression: y + <e> Current Expression: <e> Number of <e> options in Grammar: 2 Chromosome value: 4141 mod 2 = 1 Replace <e> with <v>
Full Expression: y + <v> Current Expression: <v> Number of <v> options in Grammar: 2 Chromosome value: 1212 mod 2 = 0Replace <v> with x
Final Expression: y + x
So we’ve turned our chromosome (array) into a function using our grammatical representation! Feel free to re-read this a few times, as it takes a while to understand where the expressions and operators come from.
Now we have a grammatically correct function, we can evaluate the performance of this function against some fitness function. Those functions that perform well are then given a greater chance of passing on their DNA to the next generation of chromosomes. From this point, GE doesn’t differ greatly from other genetic algorithms, as the chromosomes are crossed-over and mutated to generate the next generation of chromosomes, which are then mapped to the grammar and tested against the fitness function.
This continues for a user-specified number of generations, at the conclusion of which the most efficient (best score on the fitness function) algorithm is returned to you.
Now that you (hopefully) have a bit more of an understanding of what Grammatical Evolution involves, you might be asking why it’s so good?
First of all, it’s fast, really fast. By passing down information from generation to generation, it vastly improves on random search.
Secondly, because the grammar is written by you, you can easily encode domain knowledge into the function. You might hear this a lot, and ask ‘What does that actually mean?’ (I know I do). Here are some examples:
Allow access to all information you have, regardless of whether it’s useful. This can be achieved using a <v> expression in the grammar and include all variables you want.
Make the program as complicated or simple as required. Including lots of options in the <e> (including duplicated options to alter the chance of a specific operator being chosen) allows easy manipulation of the complexity of the evolved program.
Define level of accuracy of constants by setting <n> ::= 0|1|2|3|4|5|6|7|8|9 and then <const> ::= <n><n><n>.<n><n><n> to set constants between 0 and 999 with 3 decimal places of accuracy.
Include functions with expressions include <fn>(<e>) and <fn> ::= sin | max
This is all pretty cool, but GE does have some drawbacks. First of all, as mentioned above, the length of the chromosome does result in invalid programs. For the example above, if the second to last entry in the chromosome was 42 instead of 41, the <e> would’ve been replaced with <e><o><e> , but with no chromosome data left to evaluate these expressions. As this chromosome is invalid, it can’t be evaluated against the fitness function. So while a chromosome may contain close to a good solution, it will never be known.
The converse of this is when the program is evolved without using the entire chromosome. The leftover data is known as a tail, and can be ignored. As such, it is generally better to construct chromosomes to have long tails, as this reduces the chance of invalid solutions occurring.
Probably the biggest drawback however, is that grammar must be very very carefully constructed, and there is a bit of an art to constructing appropriate grammars and fitness functions for a problem. On top of this, ‘large’ grammars can take a long time to evolve. Grammars that include single-level for loops, if statements, list functions and dictionary operations can take upwards of 12 hours to evolve. Hopefully this improves in the future, but is the main thing stopping Grammatical Evolution from being a replacement for human code optimisers...
So now you hopefully have a bit more of an understanding on how Grammatical Evolution, we can have a go at implementing a simple example in PonyGE2.
PonyGE2 can be cloned or forked from here. There’s no setup required, and the evolution will run from the command line with the appropriate arguments.
The best way to explore the package is to work through an example. We’ll try to evolve a function to find the maximum value in a list. This is easily achieved in Python with max() or functionally:
for val in list:    if val >= current_max:        current_max = val
The first step is to create a fitness function, or what we want to maximise or minimise. The terminology here is a little confusing, as we’re evolving a function, the result of which will optimise the fitness function. For now we’re just talking about the fitness function.
All my code for these examples is contained at https://github.com/Padam-0/NC-GA
There are a bunch of examples in PonyGE2/src/fitness , and to run your own, create a file called max_in_list.py in that folder, with a structure something like this:
from fitness.base_ff_classes.base_ff import base_ffimport randomimport timeclass max_in_list(base_ff):    def __init__(self):        # Initialise base fitness function class.        super().__init__()    def evaluate(self, ind, **kwargs):        p = ind.phenotype        print("\n" + p)        fitness = 0        for trial in range(50):            self.test_list = generate_list()            m = max(self.test_list)            d = {'test_list': self.test_list}            try:                t0 = time.time()                exec(p, d)                t1 = time.time()                guess = d['return_val']                fitness += len(p)                v = abs(m - guess)                if v <= 10**6:                    fitness += v                else:                    fitness = self.default_fitness                    break                if t1 - t0 < 10:                    fitness = self.default_fitness                    break                else:                    fitness += (t1 - t0) * 1000            except:                fitness = self.default_fitness                break        return fitnessdef generate_list():    return [random.randint(0, round(random.random() * 90 + 10, 0)) for i in range(9)]
Let’s step through it line by line.
Lines 1–3:
Imports. base-ff is the class we’ll inherit from, random is used to generate the random list, and time to track execution time
Line 5:
Define the class, max_in_list , inheriting from base_ff . This needs to have the same name as the file you’re working in (which is max_in_list.py in this case).
Line 6–9:
Instance initialisation information for the class.
Line 10:
Create evaluate function with three parameters, self, ind, and kwargs.
Up to this point, this is standard for each fitness function. What comes next is independent for each algorithm being evolved.
Line 11–13:
Returns the current algorithm from the evolution as p . We can print this to the command line (as we do in line 13), and see it.
Line 15:
Initialise fitness to 0
Line 17:
Set a for loop to run 50 times. If we don’t do this, each algorithm will run on the same ‘random’ list, and may evolve a line such as m=49 which is then returned. 49 may happen to be in that list and the maximum value, so the computer thinks it has found the perfect algorithm. Short, fast and correct! But overfit to that one example. On any other list, where 49 is not in it (or not the maximum value), the algorithm would be wrong. So we give the fitness function 50 lists to look at, all with different values (and indeed maximum possible values) to avoid overfitting.
Line 18:
Create the random list self.test_list from the function generate_list()
Jumping forward to the last few lines, this function generates 10 random integers between 0 , and some number between 10 and 100. This is best to avoid overfitting.
Line 19:
Find the maximum of the current list being used. It’s important to do this here and not after the algorithm has been executed, as the list may change during the execution (if list operations have been included in the grammar), so the correct result may not be returned.
At this point you may (correctly) be asking, what’s the point of doing all this if we already have a function to find the maximum value of a list? That’s an excellent question, and the point of GE is to find a potentially more efficient way of doing this. For simple functions it’s not really worth it, but is a good learning exercise.
Line 21:
Create a dictionary d with one entry test_list that holds the current list. This will make more sense when we write the grammar.
Line 23 / 45–47:
Try/Except statement. Generally it’s not a good idea to have an open ended Except statement, as you’ll never catch errors, but in this case, all errors can feasibly occur within the evolved algorithm (because it’s just putting words together at semi-random, so there’s no way they’re all going to be perfect code) so the statement is justified.
If an error is returned, the fitness for that algorithm is set to the default (NA) and the loop broken out of, and the next algorithm tested.
Line 24:
Get the start time.
Line 25:
Execute the algorithm p and pass the parameters within d so they can be accessed. This is where the algorithm is actually evaluated. The value that is returned is written to the dictionary d as return_val (we define this in the grammar).
Line 26:
Get the end time.
Line 28:
Retrieve the maximum value guess from the current algorithm.
Line 30:
Increase the current fitness score by the length of the algorithm. This is to avoid bloat and unnecessary if statements. The evolution tries to keep things as short as possible.
Line 32:
Find the difference between the actual maximum value of the list and the guess. Best case scenario this is 0, but if not, it is a proxy for how close the algorithm was to getting the right answer.
Line 33–37:
There are some algorithms which may produce completely outsize guesses which will crash the evolution, so it’s best to keep an if statement to check the value isn’t above some large (but not too large) number that would never be feasible.
Again, if this check fails, set the fitness to NA and move onto the next algorithm. Otherwise, add the difference between the guess and correct value to the current fitness score.
Line 39–43:
Some algorithms (with multi-level for loops, or for i in range(1000) statements) take a very long time to evaluate. Unless this is expected, this indicates an error, so an if statement to catch those which take more than some time (10 seconds in this case) is recommended.
If the algorithm takes less than this, the time can be added to the fitness score, so that algorithms that work faster are rewarded with a lower fitness. Because the time-scale is so small, scaling this part of the score up by a factor of 100 or 1000 is recommended.
Line 49:
Return the fitness, and we’re done!
That was a lot to take in, and a few bits may not make sense, but we’ll push on a write the grammar, and hopefully with the full picture it will be a bit clearer.
This is the difficult part, as you have to be very precise and thoughtful with what you write. We’ll step through the base grammar required to evolve our max function, but grammar extensions that include additional logic, list functions, and dictionary functions are available at https://github.com/Padam-0/NC-GA.
Once again, PonyGE2 provides grammar examples in the PonyGE2/grammars directory. Create yours in there (it can be called anything, but give it the extensions .pybnf ).
The first line contains the starting point for the program:
<fc> ::= <deff>{::}<callf>
In English, this says:
<fc> is the starting symbol, which contains one option: <deff> followed by a newline ({::} represents a newline, {: is an indent (tab or 4 spaces) and :} closes the indent), followed by <callf> .
The next line, we define <deff> :
<deff> ::= def fun(li):{:m = 0{::}<code>{::}return m:}
This looks a bit more like Python. We can see we have a function definition of a function called fun() with one argument li .
Next we open an indent (like Python requires), initialise our variable m=0 , start a new line, write some <code> , start a new line, return m , then close the indent.
Again, there is only one option for <deff> , so this will definitely be written. Next, <callf> :
<callf> ::= return_val = fun(test_list)
Again, this looks more like Python. And only one option, so regardless of the chromosome, our algorithm will always look something like this:
def fun(li):    m = 0    <code>    return mreturn_val = fun(test_list)
The <code> section is where the evolution gets done, but in most cases we’ll now have a working function. Cool hey! It gets cooler.
Remember in our fitness function, we gave our dictionary d a key called test_list? Well, because we call exec(p, d) , the program can see the contents of that dictionary d , so when it sees test_list in the algorithm, it replaces it with the value corresponding to test_list in the dictionary d , or in our case, the random list we want to find the maximum of! Cool huh, now we have a way of passing data from our fitness function into our evolved function.
And again, in our fitness function, we set guess = d['return_val'] ? Well it turns out not only can we read from d , we can write to it, and the value returned from fun(test_list) is passed to d as return_val , so then we can access the returned value of the evolved algorithm in the fitness function, so we can access its fitness!
So we now can send data in both directions, I recommend going and re-reading the section on the fitness function to see if this all makes a bit more sense now. It definitely does for me.
As for the rest of our grammar:
<code> ::= <stmt> | <stmt>{::}<code>
Code is either a single statement, or a statement followed by some more code on a new line. This way we can give ourselves multi-line functions! This recursive nature of the grammar is very useful, and will come up quite a lot to enable us to add complexity to the evolution.
<stmt> ::= <var> = <expr> | <for> | <if>
A statement is then either assigning an expression to a variable, a for loop, or an if statement. Let’s look at these in reverse order:
<if> ::= if <cond>:<if-opt><if-opt> ::= {:<code>:} | {:<code>:}else:{:<code>:} | {:<code>:}elif <cond>:{:<if-opt>:}<cond> ::= <expr> <c-op> <expr><c-op> ::= ">=" | "<=" | ">" | "<"
So the if statement takes a condition, and then an option. The condition takes the form of an expression, an operator, and an expression.
To make this clearer, it’s worth discussing the <expr> tag now:
<expr> ::= <number> | <var> | <expr> <op> <expr>
So an expression is either a number, a variable, or an operation (more on all of these later).
Back to our if statement, our condition takes the form if <cond>: which expands to:
if <expr> <c-op> <expr>:
Which can then expand to something like:
if <var> >= <number>:
And we have an if statement we recognise. The <if-opt> are a little more complex, as they have to include elif and else functionality, but they all boil down to an if statement, a condition, and then some <code> being written inside them.
Next, for loops:
<for> ::= for i in <list-var>:{:<fl-code>:}<fl-stmt> ::= <var> = <expr> | <fl-if><fl-code> ::= <fl-stmt> | <fl-stmt>{::}<fl-code>
So the for loop takes the form for i in list: then creates an indent (on a new line) for some statement. The statement itself could just be our <stmt> from above, but this would allow nested for loops, which can exponentially increase evaluation time. Unless you think nested for loops are absolutely necessary, avoid them (this is a pretty good rule for programming in general).
So our <fl-stmt> only includes variable assignment, and a special if statement, <fl-if> :
<fl-if> ::= if <cond>:<fl-if-opt><fl-if-opt> ::= {:<fl-code>:} | {:<fl-code>:}else:{:<fl-code>:} | {:<fl-code>:}elif <cond>:{:<fl-if-opt>:}
This is actually identical to the if statement grammar above, but instead of calling <code> , it calls <fl-code> , basically to avoid for loops inside if statements inside for loops. A little cumbersome, but better than allowing nested for loops!
Finally, variable assignment. We’ve already looked at <expr> , so let’s look at parts that make it up:
<var> ::= m | i<list-var> ::= li<number> ::= <num><num><num> | <num><num> | <num><num> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9<op> ::= + | - | * | / | // | %
This should be pretty straightforward. Our variables are m which we initialised at the beginning, and i which is initialised if a for-loop is run. Numbers can go from 0 to 999, and we have all the python operators (except exponential, which can cause integer overflow and a whole bunch of very slow to execute algorithms. Like the nested for loops, only include it if you need it).
And believe it or not, that’s our grammar done! Let’s see how we can go from a <code> tag to our final function. A chromosome like:
[0, 1, 0, 1, 0, 1, 1, 2, 1, 0, 0, 0, 0, 1, 1]
Mapped out, this gives us:
def fun(li):    for i in li:        if i > m:            m = ireturn_val = fun(test_list)
Sweet! Now that we have our fitness function and our grammar, how can we run this with PonyGE2?
Well, if the fitness function is in PonyGE2/src/fitness (and has the same filename as the class it holds, max_in_list in this case), and the grammar is in PonyGE2/grammars, we can run from the command line:
python ponyge.py --fitness_function max_in_list --grammar_file max_in_list.pybnf
This will run your evolution with your grammar and fitness function, with default parameters for other options. There are a lot, and it’s worth reading the reference documentation, but the two really important ones are Population size and Number of generations.
The population size is the number of chromosomes generated at the beginning, and evolved at each generation. The larger this number, the wider the search, but the slower the evolution time.
The number of generations is the number of evolutions that the chromosomes go through. Again, it widens the search but at the cost of evolution speed.
You can set these parameters with:
python ponyge.py --fitness_function max_in_list --grammar_file MIL_e2.pybnf --population_size 500 --generations 100
Now for simple grammars, values for these parameters in the range of 100–500 is sufficient, but as the grammars get more complex, they require more population and generations to ensure a sufficient algorithm is evolved. A good test is to add complexity to your grammar, and run it with the same fitness function as above. If you get a function which finds the maximum in a list, you can be confident that your parameters are appropriate, and then turn it onto your other fitness function for the problem you’re trying to solve.
Unfortunately, run times get large quite quickly. For me, a population of 2000 and 750 generations took around 6 and a half hours, and that wasn’t enough to evolve an appropriate algorithm with the grammar above (but also including list and dictionary operations).
And herein lies the issue with GE. The promise is that if you can write a fitness function, and a robust grammar that encompasses a wide range of Python operations, then GE will be able to evolve a more efficient way of doing things. Unfortunately, the processing power and time this takes at present is infeasible on public machines.
Regardless, this is a field to watch, as if we’re going to teach machines to code, there’s a good bet that Grammatical Evolution will have something to do with it.
The team at UCD Natural Computing Research and Applications Group are the cutting edge of this research, and have published many great papers showing the power of GE (albeit on supercomputers). Some include:
A Search for Improved Performance in Regular Expressions;
Optimising complex pylon structures with grammatical evolution; and
Combining structural analysis and multi-objective criteria for evolutionary architectural design.
If you liked this post, please click the ❤ button to let your followers know, or let me know your thoughts below or on Twitter @padams02.