Tutorial
To use GeneticEngine to solve a Genetic Programming problem, you need two things:
Define the tree structure of the representation
Define a fitness function for each tree
Let us address each step in order.
Consider the example of Vectorial GP in A Vectorial Approach to Genetic Programming, in which values can be of type Element or type Vectorial.
We will use the following dataset, in which the first two columns are scalars, the following two are vectorial, and the last one is the target value. We will apply regression to the data set.
dataset = [
[1, 5, [1, 2, 3], [1, 5, 6], 0],
[2, 2, [3, 2, 3], [1, 8, 6], 1],
[1, 6, [2, 2, 3], [1, 5, 9], 2],
[2, 8, [3, 2, 3], [1, 6, 6], 3],
]
At the root of our expression, we are looking for an element (because we are doing regression), but not all variables are elements — some are vectorial. To translate vectorial variables to elements, we can use some functions (mean, max, min, etc…). Some other functions convert vectorial into vectorial variables (such as cumulative sum).
Step 0. Installation
You can install the dependencies from the requirements.txt file.
python -m pip install -r requirements.txt
The following snippets are taken from examples/vectorialgp_example.py
, where the full running code, with imports, is available.
Step 1. Object-Oriented Representation
Thus, we can create our class-based representation. We start by our generic types. These classes have no information, they are just used to represent the type of data. They must inherit from Abstract Base Class (ABC), which make these classes as abstract and not instantiable, unlike their child classes.
from abc import ABC
class Scalar(ABC):
pass
class Vectorial(ABC):
pass
Then, we create some terminals:
@dataclass
class Value(Scalar):
value: float
@dataclass
class ScalarVar(Scalar):
index: Annotated[int, IntRange(0, 1)]
@dataclass
class VectorialVar(Vectorial):
index: Annotated[int, IntRange(2, 3)]
The Value class corresponds to a literal scalar, like 3.4 or 3.2. ScalarVar corresponds to the features that are scalar values, which we know from the dataset that are columns at indices 0 and 1. Columns at indices 2 and 3 are vectorial, so we create another tree node that extends the Vectorial class.
We use the dataclass
decorator to have automatic constructors, based on the properties of the class. The Annotated
type allows to refine the type of base types (like int
) with what we call MetaHandlers, which restrict the generation of random values. In this case we are using IntRange
to restrict the possible values of index. We will talk more about metahandlers here.
We now move to the non-terminals:
@dataclass
class Add(Scalar):
right: Scalar
left: Scalar
The Add class represents the sum between two scalars. right
and left
can have any scalar expression, regardless of whether it is terminal or non-terminal (like another instance of Add).
@dataclass
class Mean(Scalar):
arr: Vectorial
Mean is an example of an operation that converts a vectorial expression into a scalar one.
@dataclass
class CumulativeSum(Vectorial):
arr: Vectorial
Similarly, CumulativeSum takes a vectorial expression (arr
) and returns another vectorial expression.
The example we just wrote could have been written in a Grammar-Guided GP approach. In fact, we automate that translation:
g = extract_grammar([Value, ScalarVar, VectorialVar, Mean, CumulativeSum], Scalar)
print("Grammar: {}.".format(repr(g)))
The output should be:
Grammar<Starting=Scalar,Productions=[Scalar -> Value(value: float)|ScalarVar(index: [0...2])|Add(right: Scalar, left: Scalar)|Mean(arr: Vectorial);Vectorial -> VectorialVar(index: [2...4])|CumulativeSum(arr: Vectorial)]>.
which can be reformatted as:
Scalar -> Value(float)
Scalar -> ScalarVar([0...2])
Scalar -> Add(Scalar, Scalar)
Scalar -> Mean(Vectorial)
Vectorial -> VectorialVar([2...4])
Vectorial -> CumulativeSum(Vectorial)
which is a typical grammar in GGGP.
Now we just need to implement a fitness function to have a full, functional GP engine working. But for now, let us fake that fitness function:
def fitness_function(n):
return 0
This fitness function will give all individuals the same fitness (0), turning this problem into a random search. This is helpful to debug the representation, but will have to be replaced by a proper fitness function later.
Now we can run Genetic Engine, parameterized with this grammar and this fitness function:
alg = SimpleGP(
grammar=g,
fitness_function=fitness_function,
minimize=True,
representation="treebased",
population_size=10,
max_evaluations=50)
(b, bf) = alg.search()
print(bf, b)
The output:
BEST at 0 / 5 is 0
BEST at 1 / 5 is 0
BEST at 2 / 5 is 0
BEST at 3 / 5 is 0
BEST at 4 / 5 is 0
0 Mean(arr=VectorialVar(index=2))
Non-surprisingly, you can see that the best fitness in each generation was also 0. The best individual after the 5 generations was the mean of the vectorial var in column 2. Feel free to change the initial seed to see other generated trees. Even if you inspect all possible trees, you will never find a tree that does the Mean of a scalar variable, because that would not be type-safe.
Step 2. Writing Fitness Function
Writing a proper fitness functions is often a challenge in GP. Because we have a tabular dataset, we just have to apply the program to the input data, and return the prediction error:
def compile(p, line):
pass # Not implemented yet
def fitness_function(n: Scalar):
regressor = lambda l: compile(n, l)
y_pred = [regressor(line) for line in dataset]
y = [line[-1] for line in dataset]
r = mean_squared_error(y, y_pred)
return r
Starting by the fitness_function, it takes a program that it will compile into a regressor. That regression will take a line of the dataset and predict the value (we are now predicting 0 for each instance). We will compute the mean squared error (from sklearn.metrics
) between the prediction of each line and the real value in the last column.
If we run again, we will now get a constant fitness value of 3.5, which makes sense because all predictors return 0 for all instances. Let us not improve the compile function to create a predictor that works according to the tree.
Now let us write a proper compile
function:
def compile(p, line):
if isinstance(p, Value):
return p.value
elif isinstance(p, ScalarVar) or isinstance(p, VectorialVar):
return line[p.index]
elif isinstance(p, Add):
return compile(p.left, line) + compile(p.right, line)
elif isinstance(p, Mean):
return np.mean(compile(p.arr, line))
elif isinstance(p, CumulativeSum):
return np.cumsum(compile(p.arr, line))
else:
raise NotImplementedError(str(p))
This version pattern matches on the type of the node and recursively computes the value for the tree, always taking into consideration the dataset line being processed. Note that we are using numpy functions where needed.
Finally, run with a larger population for a longer time, you will see the best fitness decrease over time:
alg = SimpleGP(
grammar=g,
fitness_function=fitness_function,
minimize=True,
representation="treebased",
seed=0,
population_size=200,
max_evaluations=10000)
(b, bf) = alg.search()
print(bf, b)
And the final lines of the output:
BEST at 199 / 200 is 0.69
0.6882625764219203
While our compile function was implemented as a stand-alone function, it could have been implemented in methods inside each element, following a more OOP style.
Another aspect that could be improved, and is left as an exercice to the reader is to implement an __str__()
method in each class, to have a more readable output.
A final note is that the compile function does not have to be an interpreter, and it can use compilers in other languages (such as Python or Java). In the case of Python, we can translate the tree to Python and then (ab)use the eval
function to convert a string into a runnable function.
def translate(p):
if isinstance(p, Value):
return str(p.value)
elif isinstance(p, ScalarVar) or isinstance(p, VectorialVar):
return "line[{}]".format(p.index)
elif isinstance(p, Add):
return "({} + {})".format(translate(p.left), translate(p.right))
elif isinstance(p, Mean):
return "np.mean({})".format(translate(p.arr))
elif isinstance(p, CumulativeSum):
return "np.cumsum({})".format(translate(p.arr))
else:
raise NotImplementedError(str(p))
def fitness_function_alternative(n: Scalar):
code = "lambda line: {}".format(translate(n))
regressor = eval(code)
y_pred = [regressor(line) for line in dataset]
y = [line[-1] for line in dataset]
r = mean_squared_error(y, y_pred)
return r
In this example, the translate
function converts the tree into Python code, and in the beginning of the fitness_function_alternative
function, we use eval to convert that code string into a callable function.