Extending the Push Interpreter¶
PyshGP is capable of synthesizing programs which manipulate primitive data types, and simple collections, out-of-the-box. However, it is also common to want to synthesize programs which leverage problem-specific data type. PyshGP’s push insterpreter is extensible, and supports the registration of addition data types and instructions.
This guide will demonstrate how to add support for an additional data type and some
related instructions. These extensions are performed at the user-level and don’t require
any changes to the pyshgp
source code.
We will be registering a Push type that corresponds to the Point
class, defined below.
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self):
return "Point<{x},{y}>".format(x=self.x, y=self.y)
Push Types¶
A PushType
is an object that encapsulates all the information about which values
should be considered the same “type” during Push program evaluation. The behavior of
a PushType
is minimal: is_instance()
and coerce()
.
Typically, a PushType
simply refers to one or more Python types. Type checking and
coercion are delegated to the underlying Python types. For example, the PushInt
object
(an instance of PushType
) defines an integer as any instance of the types
(int, np.int64, np.int32, np.int16, np.int8)
. To coerce a value to a valid PushInt
,
the built-in constructor int
is used.
For our Point
type, we need to define a sub-class of PushType
. In our class
definition, we declare a name for our type and the underlying python types. In this case,
we will name our PushType
as "point"
and the underlying types will be a tuple containing
one element: the Point
class. We also will set some flags that tell the Push interpreter
what kind of runtime constraints apply to the type. For example, if we set is_collection=True
the push interpreter will treat our type as an unbounded collection of values (ie. list, dict) and
will limit it’s size during evolution to avoid resource utilization issues.
The default is_instance
behavior is to check the value against the given underlying
Python types. This behavior is well suited for our Point
type, so we will not override.
The default coerce
behavior is to pass the value to the constructor of the first
underlying Python type. The constructor of Point
requires two arguments, so we
need custom coercion behavior.
Our PushType
sub-class for Point
objects, might look like this:
from pyshgp.push.types import PushType
class PointPushType(PushType):
def __init__(self):
super().__init__(name="point", # The name of the type, and the corresponding stack.
python_types=(Point,), # The underlying Python types
is_collection=False, # Indicates the type is not a data structure of unknown size.
is_numeric=False) # Indicates the type is not a number.
# override
def coerce(self, value):
return Point(float(value[0]), float(value[1]))
The Type Library¶
Before starting executing a Push program, the Push interpreter must be configured with a
set of PushTypes
, called a PushTypeLibrary
. The PushTypeLibrary
is used to
produce the correct stacks before program evaluation and validate that the instructions
specified in the InstructionSet
will supported.
By default, all the core types are registered into a PushTypeLibrary
but that can
be disable using register_core=False
which will result in only the exec and code
stacks getting registered.
from pyshgp.push.type_library import PushTypeLibrary
lib = PushTypeLibrary()
lib.supported_stacks() # {'bool', 'char', 'code', 'exec', 'float', 'int', 'str'}
lib2 = PushTypeLibrary(register_core=False)
lib2.supported_stacks() # {'code', 'exec'}
User defined PushType
objects (such as PointPushType
from above) can be
registered using the register()
and register_list()
methods.
type_lib = PushTypeLibrary()
type_lib.register(PushPoint) # Returns reference to the library for chaining calls to register.
type_lib.supported_stacks() # {'bool', 'char', 'code', 'exec', 'float', 'int', 'point', 'str'}
Custom Push Instructions¶
Once we register our custom Push types into the type library, our Push interpreter will be able to accept instances of our type. However, there will not be any Push instructions to create and manipulate the instances of our type. To address this, we can define custom Push instructions.
To learn more about what Push instructions are, see Push Instructions.
For a guide on how to define custom instructions, see Example Instruction Definitions.
Below we define a couple Push instructions that work with out Point
type.
from pyshgp.push.instruction import SimpleInstruction
def point_distance(p1, p2):
"""Return a tuple containing the distance between two points."""
delta_x = p2.x - p1.x
delta_y = p2.y - p1.y
return sqrt(pow(delta_x, 2.0) + pow(delta_y, 2.0)),
def point_from_floats(f1, f2):
"""Return a tuple containing a Point made from two floats."""
return Point(f1, f2),
point_distance_insrt = SimpleInstruction(
"point_dist", point_distance,
["point", "point"], ["float"], 0
)
point_from_floats_instr = SimpleInstruction(
"point_from_floats", point_from_floats,
["float", "float"], ["point"], 0
)
The Instruction Set¶
When creating Push Interpreter, or genetic programming Spawner
, PyshGP requires an
InstructionSet
that holds all the Push instructions that can appear in Push programs.
To declare an InstructionSet
, we must provide a TypeLibrary
. All instructions that get
registered into the InstructionSet
will be validated against the TypeLibrary
to ensure
that it will be possible to execute the instruction.
When creating a new InstructionSet
, we can automatically register all the core instructions
(built into pyshgp
) that are supported by the TypeLibrary
by using passing register_core=True
.
Additional instructions can be registered using methods like register()
and register_all()
.
Below we create an InstructionSet
that contains our custom instructions.
from pyshgp.push.instruction_set import InstructionSet
i_set = InstructionSet(type_library=type_lib, register_core=True)
i_set.register(point_distance_insrt)
i_set.register(point_from_floats_instr)
To start a genetic programming run with our custom InstructionSet
, we will pass it to the Spawner
and interpreter.
spawner = GeneSpawner(
n_inputs=2,
instruction_set=i_set,
literals=[2.0],
erc_generators=[]
)
est = PushEstimator(
spawner=spawner,
population_size=100,
max_generations=50,
simplification_steps=500,
interpreter=PushInterpreter(instruction_set)
)
# Start the run
est.fit(X, y)