We define a function treecomp
that returns the composition of a list of functions L
structured according to a rooted tree T
, by taking L
and T
as separate arguments:
F = treecomp(T, L)
Unlike the other solutions proposed thus far, it isn't complicated by unnecessary bookkeeping, such as tracking the number of leaves or arguments (which is better handled by decorators, besides).
A simple construction of treecomp
A straightforward realization of treecomp
is the following: it merely generates a symbolic (string) expression of the tree composition. Evaluation on arguments is then just a matter of plugging them in and evaluating the resulting expression.
This naive idea can be implemented using fairly basic data structures: lists for the tree and functions, and a simple class for the function-labeled tree. (Namedtuples would also do. However, by using a class with special comparison methods, we can write more semantically natural code.)
Data structures
The most economical encoding of a rooted tree as a flat list is as a list of "node addresses." In a comment to @JeD, I hinted that this could be done by "drawing" the tree:
T = [(0,),
(0, 0),
(0, 0, 0),
(0, 0, 0, 0), (0, 0, 0, 1), (0, 0, 0, 2),
(0, 1),
(0, 1, 0),
(0, 1, 0, 0),
(0, 1, 1),
(0, 1, 1, 0), (0, 1, 1, 1)]
Here (0,)
is the node corresponding to a0
, (0, 0)
is the node corresponding to b0
, (0, 1)
is the node corresponding to b1
, and so forth, like the numbering of sections in a book. The longest (or "highest") tuples are the leaves.
The list of functions L
can then be given as a list matching the order of nodes in T
:
L = [a0, b0, c0, e0, e1, e2, b1, d0, f0, d1, g0, g1]
Since the nodes of the tree T
are labeled by the functions in L
, it will be convenient to have a data structure for that. We define a class that records a node's address and the literal name of the function labeling it; its methods implement comparisons relative to the partial ordering of the tree (where the root is the minimum element):
class SymbNode:
'''Class that records a node's address and symbol.'''
def __init__(self, addr, symb):
self.addr = addr
self.symb = symb
def __len__(self): # how "high" a node is above the root
return len(self.addr)
def _compare(self, other, segment):
return self.addr == other.addr[:segment]
def __le__(self, other):
return self._compare(other, segment=len(self))
def begets(self, other):
return self._compare(other, segment=-1)
Implementation
The simple two-step mechanism of treecomp
is implemented below. By normalizing the order of the list of SymbNodes, we can build up a symbolic expression by simply "peeling off" each layer of the tree as we move up it.
from functools import partial
from operator import attrgetter
def treecomp(tree, funcs):
'''Returns the composition of a tree of functions.'''
symbtree = makesymbtree(tree, funcs)
symbexp = makesymbexp(symbtree)
return partial(evalsymbexp, symbexp=symbexp)
FUNC_CALL = '{func}({{}})'
def makesymbtree(tree, funcs):
'''Returns the symbolic expression of a tree composition.'''
symbols = [FUNC_CALL.format(func=func.__name__) for func in funcs]
symbtree = sorted((SymbNode(*x) for x in zip(tree, symbols)),
key=attrgetter('addr'))
symbtree.sort(key=len)
return symbtree
def makesymbexp(symbtree):
root = symbtree[0]
if len(symbtree) == 1: # symbtree is a leaf node
return root.symb
symbargs = [makesymbexp(subsymbtree(symbtree, root=node))
for node in symbtree if root.begets(node)]
return root.symb.format(','.join(symbargs))
def subsymbtree(symbtree, root):
subsymbtree = [node for node in symbtree if root <= node]
return subsymbtree
ARGS = 'args[{idx}]'
def evalsymbexp(symbexp, *args):
'''Returns the evaluation of a symbolic expression on arguments.'''
argnames = [ARGS.format(idx=str(n)) for n, _ in enumerate(args)]
return eval(symbexp.format(*argnames))
Verification
Because of the compartmentalization of treecomp
, we only need to verify that the function makesymbexp
generates the correct symbolic expression, and that the function evalsymbexp
properly evaluates symbolic expressions.
The (essentially one-line) function evalsymbexp
is supposed to take a string template and plug in the argument names 'args[0]'
, 'args[1]'
, etc., then evaluate the result. It evidently does that.
As for makesymbexp
, we can gain confidence in its correctness, in lieu of a formal proof (which we eschew), by checking its output on some test data. Take, for example, the following functions:
def D(x): return 2*x
def M(x): return -x
def S(*xs): return sum(xs)
a0 = S
b0, b1 = D, S
c0, d0, d1 = S, D, S
e0, e1, e2, f0, g0, g1 = D, M, D, M, D, M
With T
and L
as above, we can check that we get the right symbolic expression:
makesymbexp(makesymbtree(T, L))
indeed yields the string
'S(D(S(D({}),M({}),D({}))),S(D(M({})),S(D({}),M({}))))'
To check the delegation of treecomp
to evalsymbexp
, as a partial function, I verified that the value of
F = treecomp(T, L)
F(x0, x1, x2, x3, x4, x5)
agreed with the value of
a0(b0(c0(e0(x0), e1(x1), e2(x2))), b1(d0(f0(x3)), d1(g0(x4), g1(x5))))
on 1000 random samples of x0
, … , x5
drawn from the integers between -100 and 100.