Symbolic regression with Atomese code in OpenCog

This notebook is an example of symbolic regression, i.e. the search for an algebraic expression that fits well to given data points. It evolves an Atomese program that implements a function in form of a LambdaLink, which takes two values as input (x, y) and returns one value (z) as output. The goal is to minimize the deviation from given data points (x, y, z).

References

  • Wikipedia

  • OpenCog Wiki

    • VariableNode: meant to correspond to the ordinary notion of a "variable" symbol
    • LambdaLink: understood as the OpenCog analog of the lambda of lambda calculus and functional programming
    • Python: there is a Python binding equivalent to cog-execute! called execute_atom
In [1]:
import time
import warnings

import alogos as al
import mevis as mv
import numpy as np
import unified_map as um
import unified_plotting as up
from opencog.bindlink import execute_atom
from opencog.type_constructors import *
from opencog.utilities import set_default_atomspace

Preparation

1) Generate data points

A function f with two input variables x and y and one output variable z is used to generate a list of data points (x, y, z). This is achieved by evaluating the function z = f(x, y) on a list of (x, y) pairs, which are chosen to lie on a regular grid.

As function was chosen the Pagie-1 polynomial, because it was suggested for use in benchmarking in the article Better GP benchmarks: community survey results and proposals.

In [2]:
def pagie1_polynomial(x, y):
    return 1.0/(1.0+x**-4) + 1.0/(1.0+y**-4)

n = 24
a, b = -5, +5

xy_grid = np.array([(xi, yj) for xi in np.linspace(a, b, n) for yj in np.linspace(a, b, n)])
xa = np.array([el[0] for el in xy_grid])
ya = np.array([el[1] for el in xy_grid])
za = np.array([pagie1_polynomial(xi, yi) for xi, yi in xy_grid])
In [3]:
print('Generated {} data points in the form of (x, y, z) triplets.'.format(len(za)))

up.plotly.scatter_3d(xa, ya, za, marker_color='black', marker_size=3)
Generated 576 data points in the form of (x, y, z) triplets.
Out[3]:

Definition of search space and goal

1) Grammar

This grammar defines the search space: an Atomese program that creates an atom that represents a function in form of a LambdaLink and that can be evaluated with a given (x, y) input to give a z output.

In [4]:
ebnf_text = """
ATOMESE_PROGRAM = L0 NL L1 NL L2 NL L3
L0 = "func_atom = LambdaLink("
L1 = "    VariableList(VariableNode('$x'), VariableNode('$y')),"
L2 = "    " EXPR
L3 = ")"
NL = "\n"

EXPR = OP "(" EXPR ", " EXPR ")" | VAR | CONST
OP = "PlusLink" | "MinusLink" | "TimesLink" | "DivideLink"
VAR = "VariableNode('$x')" | "VariableNode('$y')"
CONST = "NumberNode('1.0')"
"""

grammar = al.Grammar(ebnf_text=ebnf_text)

2) Objective function

The objective function gets a candidate solution (=a string of the grammar's language) and returns a fitness value for it. This is done by 1) executing the string as an Atomese program with OpenCog, so that it creates a function in form of a LambdaLink atom, and then 2) evaluate the lambda function for each (x, y) pair in the given grid and compare the resulting z values with the target z values: the smaller the deviation, the better is the candidate.

In [5]:
def string_to_func_atom(atomspace, string):
    local_vars = dict(atomspace=atomspace)
    exec(string, None, local_vars)
    func_atom = local_vars['func_atom']
    return func_atom


def evaluate_func_atom_scalar(atomspace, func_atom, x, y):
    x_atom = NumberNode(str(x))
    y_atom = NumberNode(str(y))
    input_atom = ListLink(x_atom, y_atom)
    exec_atom = ExecutionOutputLink(func_atom, input_atom)
    result_atom = execute_atom(atomspace, exec_atom)
    result_float = float(result_atom.name)
    return result_float


def evaluate_func_atom_vectorial(atomspace, func_atom, x_vec, y_vec):
    x_atom = NumberNode(' '.join(str(xi) for xi in x_vec))
    y_atom = NumberNode(' '.join(str(yi) for yi in y_vec))
    input_atom = ListLink(x_atom, y_atom)
    exec_atom = ExecutionOutputLink(func_atom, input_atom)
    result_atom = execute_atom(atomspace, exec_atom)
    result_floats = [float(el) for el in result_atom.name.split()]
    return result_floats


def objective_function_scalar(string):
    if len(string) > 5000:
        return float('inf')

    atomspace = AtomSpace()
    set_default_atomspace(atomspace)
    func_atom = string_to_func_atom(atomspace, string)
    zf = [evaluate_func_atom_scalar(atomspace, func_atom, x, y) for x, y in xy_grid]
    sse = np.sum((zf - za)**2)
    return sse


def objective_function_vectorial(string):
    if len(string) > 5000:
        return float('inf')

    atomspace = AtomSpace()
    set_default_atomspace(atomspace)
    func_atom = string_to_func_atom(atomspace, string)
    zf = evaluate_func_atom_vectorial(atomspace, func_atom, xa, ya)
    sse = np.sum((zf - za)**2)
    return sse

Generation of a random solution

Check if grammar and objective function work as intended.

In [6]:
random_string = grammar.generate_string()
print(random_string)
func_atom = LambdaLink(
    VariableList(VariableNode('$x'), VariableNode('$y')),
    DivideLink(NumberNode('1.0'), VariableNode('$x'))
)
In [7]:
objective_function_vectorial(random_string)
Out[7]:
2792.892645652415

Search for an optimal solution

Evolutionary optimization with random variation and non-random selection is used to find increasingly better candidate solutions.

1) Parameterization

In [8]:
report_time = True
display_results = False

num_gen = 300
num_ind = 600

ea = al.EvolutionaryAlgorithm(
    grammar, objective_function_vectorial, 'min',
    max_generations=num_gen,
    max_or_min_fitness=0.001,
    max_runtime_in_seconds=300,
    population_size=num_ind, offspring_size=num_ind,
    evaluator=um.univariate.parallel.futures,
    verbose=1,
    max_nodes=500,
)

2) Run

The search is performed one generation after another and some intermediate results are reported to see how the solutions improve gradually.

In [9]:
best_ind = ea.run()
Progress         Generations      Evaluations      Runtime (sec)    Best fitness    
..... .....      10               4022             25.5             209.52227426758708
..... .....      20               6692             30.0             135.26436982609766
..... .....      30               9383             34.3             74.83924032007833
..... .....      40               13075            41.0             37.287638296544316
..... .....      50               17582            53.3             21.626858565160777
..... .....      60               22441            72.2             6.6697604036625044
..... .....      70               27403            100.0            5.602641140807647
..... .....      80               32072            129.5            3.6586387322741993
..... .....      90               36772            162.2            2.782906701044019
..... .....      100              41650            197.2            1.624550498769994
..... .....      110              45812            229.2            1.193184362756275
..... .....      120              49063            254.5            1.1400283573930134
..... .....      130              51982            278.3            1.1400283573930134
..... .....      140              54766            299.9            1.1400283573930134


Finished         140              54766            302.1            1.1400283573930134

3) Result

Show the phenotype of the best individual found so far. If more computing time is provided, increasingly better solutions may be discovered.

In [10]:
string = best_ind.phenotype
print(string)
func_atom = LambdaLink(
    VariableList(VariableNode('$x'), VariableNode('$y')),
    DivideLink(PlusLink(NumberNode('1.0'), PlusLink(DivideLink(PlusLink(NumberNode('1.0'), PlusLink(NumberNode('1.0'), TimesLink(NumberNode('1.0'), PlusLink(NumberNode('1.0'), NumberNode('1.0'))))), PlusLink(PlusLink(NumberNode('1.0'), DivideLink(DivideLink(NumberNode('1.0'), TimesLink(NumberNode('1.0'), TimesLink(DivideLink(VariableNode('$x'), PlusLink(NumberNode('1.0'), DivideLink(DivideLink(VariableNode('$y'), VariableNode('$y')), NumberNode('1.0')))), VariableNode('$x')))), TimesLink(NumberNode('1.0'), TimesLink(TimesLink(VariableNode('$x'), NumberNode('1.0')), VariableNode('$x'))))), NumberNode('1.0'))), NumberNode('1.0'))), PlusLink(PlusLink(NumberNode('1.0'), DivideLink(TimesLink(NumberNode('1.0'), DivideLink(PlusLink(NumberNode('1.0'), PlusLink(DivideLink(PlusLink(NumberNode('1.0'), PlusLink(NumberNode('1.0'), PlusLink(NumberNode('1.0'), PlusLink(NumberNode('1.0'), NumberNode('1.0'))))), TimesLink(PlusLink(NumberNode('1.0'), DivideLink(DivideLink(NumberNode('1.0'), DivideLink(NumberNode('1.0'), TimesLink(DivideLink(VariableNode('$x'), NumberNode('1.0')), VariableNode('$x')))), TimesLink(NumberNode('1.0'), NumberNode('1.0')))), NumberNode('1.0'))), NumberNode('1.0'))), PlusLink(PlusLink(PlusLink(PlusLink(TimesLink(VariableNode('$y'), VariableNode('$y')), DivideLink(TimesLink(NumberNode('1.0'), NumberNode('1.0')), TimesLink(VariableNode('$y'), VariableNode('$y')))), NumberNode('1.0')), DivideLink(MinusLink(NumberNode('1.0'), NumberNode('1.0')), PlusLink(VariableNode('$y'), VariableNode('$y')))), TimesLink(VariableNode('$y'), VariableNode('$y'))))), TimesLink(VariableNode('$y'), VariableNode('$y')))), NumberNode('1.0')))
)
In [11]:
atomspace = AtomSpace()
set_default_atomspace(atomspace)
func_atom = string_to_func_atom(atomspace, string)
mv.plot(atomspace, layout_method='dot')
Out[11]:
Details for selected element
General
App state
Display mode
Export
Data selection
Graph
Node label text
Edge label text
Node size
Minimum
Maximum
Edge size
Minimum
Maximum
Nodes
Visibility
Size
Scaling factor
Position
Drag behavior
Hover behavior
Node images
Visibility
Size
Scaling factor
Node labels
Visibility
Size
Scaling factor
Rotation
Angle
Edges
Visibility
Size
Scaling factor
Form
Curvature
Hover behavior
Edge labels
Visibility
Size
Scaling factor
Rotation
Angle
Layout algorithm
Simulation
Algorithm
Parameters
Gravitational constant
Spring length
Spring constant
Avoid overlap
Central gravity
In [12]:
zf = evaluate_func_atom_vectorial(atomspace, func_atom, xa, ya)

up.plotly.scatter_3d(xa, ya, za, marker_color='black', marker_size=3) + \
up.plotly.surface(xa, ya, zf, show_colormap=False)
Out[12]: