Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesign signatures of compilation targets #294

Merged
merged 1 commit into from
Apr 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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