Skip to content

Commit

Permalink
Redesign signatures of compilation targets
Browse files Browse the repository at this point in the history
  • Loading branch information
jakobj committed Apr 22, 2021
1 parent afb3d0a commit f72523b
Show file tree
Hide file tree
Showing 24 changed files with 225 additions and 173 deletions.
100 changes: 66 additions & 34 deletions cgp/cartesian_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import copy
import math # noqa: F401
import re
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Union

import numpy as np # noqa: F401

Expand Down Expand Up @@ -230,24 +230,35 @@ def _fill_parameter_values(self, func_str: str) -> str:
)
return func_str

def to_func(self) -> Callable[[List[float]], List[float]]:
"""Compile the function(s) represented by the graph.
def to_func(self) -> Callable[..., List[float]]:
"""Create a Python callable implementing the function described by
this graph.
Generates a definition of the function in Python code and
executes the function definition to create a Callable.
The returned callable expects as many arguments as the number
of inputs defined in the genome. The function returns a tuple
with length equal to the number of outputs defined in the
genome. For convenience, if only a single output is defined
the function will *not* return a tuple but only its first
element.
Returns
-------
Callable
Callable executing the function(s) represented by the graph.
"""
self._format_output_str_of_all_nodes()
s = ", ".join(node.output_str for node in self.output_nodes)
func_str = f"""\
def _f(x):
def _f(*x):
if len(x) != {self._n_inputs}:
raise ValueError(f'input has length {{len(x)}}, expected {self._n_inputs}')
return [{s}]
res = [{s}]
if len(res) == 1:
return res[0]
else:
return res
"""
func_str = self._fill_parameter_values(func_str)
exec(func_str, {**globals(), **CUSTOM_ATOMIC_OPERATORS}, locals())
Expand All @@ -263,46 +274,56 @@ def _format_output_str_numpy_of_all_nodes(self):
for node in active_nodes[hidden_column_idx]:
node.format_output_str_numpy(self)

def to_numpy(self) -> Callable[[np.ndarray], np.ndarray]:
"""Compile the function(s) represented by the graph to NumPy
expression(s).
def to_numpy(self) -> Callable[..., List[np.ndarray]]:
"""Create a NumPy-array-compatible Python callable implementing the
function described by this graph.
Generates a definition of the function in Python code and
executes the function definition to create a Callable
accepting NumPy arrays.
The returned callable expects as many arguments as the number
of inputs defined in the genome. Every argument needs to be a
NumPy array of equal length. The function returns a tuple with
length equal to the number of outputs defined in the
genome. Each element will have the same length as the input
arrays. For convenience, if only a single output is defined
the function will *not* return a tuple but only its first
element.
Returns
-------
Callable
Callable executing the function(s) represented by the graph.
"""

self._format_output_str_numpy_of_all_nodes()
s = ", ".join(node.output_str for node in self.output_nodes)
func_str = f"""\
def _f(x):
if (len(x.shape) != 2) or (x.shape[1] != {self._n_inputs}):
raise ValueError(
f"input has shape {{tuple(x.shape)}}, expected (<batch_size>, {self._n_inputs})"
)
def _f(*x):
if len(x) != {self._n_inputs}:
raise ValueError(f'input has length {{len(x)}}, expected {self._n_inputs}')
return np.stack([{s}], axis=1)
res = [{s}]
if len(res) == 1:
return res[0]
else:
return res
"""
func_str = self._fill_parameter_values(func_str)
exec(func_str, {**globals(), **CUSTOM_ATOMIC_OPERATORS}, locals())

return locals()["_f"]

def to_torch(self) -> "torch.nn.Module":
"""Compile the function(s) represented by the graph to a Torch class.
"""Create a Torch nn.Module instance implementing the function defined
by this graph.
Generates a definition of the Torch class in Python code and
executes it to create an instance of the class.
The generated instance will have a `forward` method accepting
Torch tensor of dimension (<batch size>, n_inputs) and
returning a tensor of dimension (<batch_size>, n_outputs).
Returns
-------
torch.nn.Module
Instance of the PyTorch class.
"""
if not torch_available:
raise ModuleNotFoundError("No module named 'torch' (extra requirement)")
Expand Down Expand Up @@ -359,10 +380,15 @@ def _format_output_str_sympy_of_all_nodes(self):
for node in active_nodes[hidden_column_idx]:
node.format_output_str_sympy(self)

def to_sympy(self, simplify: Optional[bool] = True) -> List["sympy_expr.Expr"]:
"""Compile the function(s) represented by the graph to a SymPy expression.
def to_sympy(
self, simplify: Optional[bool] = True
) -> Union["sympy_expr.Expr", List["sympy_expr.Expr"]]:
"""Create SymPy expression(s) representing the function(s) described
by this graph.
Generates one SymPy expression for each output node.
Returns a list of SymPy expressions, one for each output
node. For convenience, if only one output node is defined, it
directly returns its expression.
Parameters
----------
Expand All @@ -372,15 +398,17 @@ def to_sympy(self, simplify: Optional[bool] = True) -> List["sympy_expr.Expr"]:
Returns
----------
List[sympy.core.expr.Expr]
List of SymPy expressions.
List[sympy.core.expr.Expr] or sympy.core.expr.Expr
List of SymPy expressions or single expression.
"""

if not sympy_available:
raise ModuleNotFoundError("No module named 'sympy' (extra requirement)")

self._format_output_str_sympy_of_all_nodes()

sympy_exprs = []
sympy_exprs: List = []
for output_node in self.output_nodes:

# replace all input-variable strings with sympy-compatible symbol
Expand All @@ -396,12 +424,16 @@ def to_sympy(self, simplify: Optional[bool] = True) -> List["sympy_expr.Expr"]:
# sympy should not automatically simplify the expression
sympy_exprs.append(sympy.sympify(s, evaluate=False))

if not simplify:
return sympy_exprs
else: # simplify expression if desired and possible
if simplify:
for i, expr in enumerate(sympy_exprs):
try:
sympy_exprs[i] = expr.simplify()
except TypeError:
RuntimeWarning(f"SymPy could not simplify expression: {expr}")

# if the genome encodes only a single function we directly
# return the sympy expression instead of a list of length 1
if len(sympy_exprs) == 1:
return sympy_exprs[0]
else:
return sympy_exprs
4 changes: 2 additions & 2 deletions cgp/node_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class ConstantFloat(OperatorNode):

_arity = 0
_def_output = "1.0"
_def_numpy_output = "np.ones(x.shape[0]) * 1.0"
_def_numpy_output = "np.ones(len(x[0])) * 1.0"
_def_torch_output = "torch.ones(1).expand(x.shape[0]) * 1.0"


Expand Down Expand Up @@ -57,7 +57,7 @@ class Parameter(OperatorNode):
_arity = 0
_initial_values = {"<p>": lambda: 1.0}
_def_output = "<p>"
_def_numpy_output = "np.ones(x.shape[0]) * <p>"
_def_numpy_output = "np.ones(len(x[0])) * <p>"
_def_torch_output = "torch.ones(1).expand(x.shape[0]) * <p>"


Expand Down
2 changes: 1 addition & 1 deletion cgp/node_input_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def format_output_str(self, graph: "CartesianGraph") -> None:
self._output_str = f"x[{self._idx}]"

def format_output_str_numpy(self, graph: "CartesianGraph") -> None:
self._output_str = f"x[:, {self._idx}]"
self.format_output_str(graph)

def format_output_str_torch(self, graph: "CartesianGraph") -> None:
self._output_str = f"x[:, {self._idx}]"
Expand Down
12 changes: 6 additions & 6 deletions cgp/node_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import numpy as np

try:
import sympy # noqa: F401
from sympy.core import expr as sympy_expr # noqa: F401

sympy_available = True
Expand Down Expand Up @@ -51,8 +50,8 @@ def check_to_func(cls: Type["OperatorNode"]) -> None:
genome = _create_genome(cls)

f = CartesianGraph(genome).to_func()
x = [1.0]
f(x)[0]
x = 1.0
f(x)


def check_to_numpy(cls: Type["OperatorNode"]) -> None:
Expand All @@ -62,8 +61,8 @@ def check_to_numpy(cls: Type["OperatorNode"]) -> None:
genome = _create_genome(cls)

f = CartesianGraph(genome).to_numpy()
x = np.ones((3, 1))
f(x)[0]
x = np.ones(3)
f(x)


def check_to_torch(cls: Type["OperatorNode"]) -> None:
Expand Down Expand Up @@ -91,6 +90,7 @@ def check_to_sympy(cls: Type["OperatorNode"]) -> None:

genome = _create_genome(cls)

f = CartesianGraph(genome).to_sympy()[0]
f = CartesianGraph(genome).to_sympy()
assert isinstance(f, sympy_expr.Expr)
x = [1.0]
f.subs("x_0", x[0]).evalf()
18 changes: 12 additions & 6 deletions cgp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,22 @@ def compute_key_from_numpy_evaluation_and_args(
ind = args[0]
if isinstance(ind, IndividualSingleGenome):
f_single = ind.to_numpy()
x = rng.uniform(_min_value, _max_value, (_batch_size, ind.genome._n_inputs))
y = f_single(x)
s = np.array_str(y, precision=15)
x = [
rng.uniform(_min_value, _max_value, size=_batch_size)
for _ in range(ind.genome._n_inputs)
]
y = f_single(*x)
s = np.array_str(np.array(y), precision=15)
elif isinstance(ind, IndividualMultiGenome):
f_multi = ind.to_numpy()
s = ""
for i in range(len(ind.genome)):
x = rng.uniform(_min_value, _max_value, (_batch_size, ind.genome[i]._n_inputs))
y = f_multi[i](x)
s += np.array_str(y, precision=15)
x = [
rng.uniform(_min_value, _max_value, size=_batch_size)
for _ in range(ind.genome[i]._n_inputs)
]
y = f_multi[i](*x)
s += np.array_str(np.array(y), precision=15)
else:
assert False # should never be reached

Expand Down
2 changes: 1 addition & 1 deletion examples/example_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def inner_objective(ind):
expr = ind.to_sympy()
loss = []
for x0 in np.linspace(-2.0, 2.0, 100):
y = float(expr[0].subs({"x_0": x0}).evalf())
y = float(expr.subs({"x_0": x0}).evalf())
loss.append((f_target(x0) - y) ** 2)

time.sleep(0.25) # emulate long fitness evaluation
Expand Down
2 changes: 1 addition & 1 deletion examples/example_differential_evo_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def recording_callback(pop):
ax_function.set_xlabel(r"$x$")


print(f"Final expression {pop.champion.to_sympy()[0]} with fitness {pop.champion.fitness}")
print(f"Final expression {pop.champion.to_sympy()} with fitness {pop.champion.fitness}")

history_fitness = np.array(history["fitness_parents"])
ax_fitness.plot(np.max(history_fitness, axis=1), label="Champion")
Expand Down
6 changes: 3 additions & 3 deletions examples/example_evo_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def objective(individual, target_function, seed):
"ignore", message="invalid value encountered in double_scalars"
)
try:
y[i] = f_graph(x_i)[0]
y[i] = f_graph(x_i[0], x_i[1])
except ZeroDivisionError:
individual.fitness = -np.inf
return individual
Expand Down Expand Up @@ -123,7 +123,7 @@ def evolution(f_target):
Individual
Individual with the highest fitness in the last generation
"""
population_params = {"n_parents": 10, "seed": 8188211}
population_params = {"n_parents": 10, "seed": 818821}

genome_params = {
"n_inputs": 2,
Expand Down Expand Up @@ -191,7 +191,7 @@ def recording_callback(pop):
x_0_range = np.linspace(-5.0, 5.0, 20)
x_1_range = np.ones_like(x_0_range) * 2.0
# fix x_1 such than 1d plot makes sense
y = [f_graph([x_0, x_1_range[0]]) for x_0 in x_0_range]
y = [f_graph(x_0, x_1_range[0]) for x_0 in x_0_range]
y_target = target_function(np.hstack([x_0_range.reshape(-1, 1), x_1_range.reshape(-1, 1)]))

ax_function.plot(x_0_range, y_target, lw=2, alpha=0.5, label="Target")
Expand Down
2 changes: 1 addition & 1 deletion examples/example_fec_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def inner_objective(ind):

loss = []
for x_0 in np.linspace(-2.0, 2.0, 100):
y = f([x_0])
y = f(x_0)
loss.append((f_target(x_0) - y) ** 2)

time.sleep(0.25) # emulate long fitness evaluation
Expand Down
14 changes: 7 additions & 7 deletions examples/example_hurdles.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@


def f_target(x):
return x[0] ** 2 + 1.0
return x ** 2 + 1.0


# %%
Expand Down Expand Up @@ -73,8 +73,8 @@ def objective_one(individual):
# the callable returned from `to_func` accepts and returns
# lists; accordingly we need to pack the argument and unpack
# the return value
y = f([x])[0]
loss += (f_target([x]) - y) ** 2
y = f(x)
loss += (f_target(x) - y) ** 2

individual.fitness = -loss / n_function_evaluations

Expand All @@ -97,8 +97,8 @@ def objective_two(individual):
# the callable returned from `to_func` accepts and returns
# lists; accordingly we need to pack the argument and unpack
# the return value
y = f([x])[0]
loss += (f_target([x]) - y) ** 2
y = f(x)
loss += (f_target(x) - y) ** 2

individual.fitness = -loss / n_function_evaluations

Expand Down Expand Up @@ -184,8 +184,8 @@ def recording_callback(pop):

f = pop.champion.to_func()
x = np.linspace(-5.0, 5.0, 20)
y = [f([x_i]) for x_i in x]
y_target = [f_target([x_i]) for x_i in x]
y = [f(x_i) for x_i in x]
y_target = [f_target(x_i) for x_i in x]

ax_function.plot(x, y_target, lw=2, alpha=0.5, label="Target")
ax_function.plot(x, y, "x", label="Champion")
Expand Down
Loading

0 comments on commit f72523b

Please sign in to comment.