You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Once we have direct numpy access to the tree arrays in Python (tskit-dev/tskit#1299) I think we should be able to do quite performant traversal algorithms in python using numba. We can illustrate this with a couple of examples:
Implement the get_mrca function, using the algorithm given here: more efficient TMRCA computation? tskit#1306 (although we might get hit by the creation of the node_time array if we create it for each function call - we could work around this for the moment by using a copy of the node time array that we keep lying around). This is easy because we only go up the tree.
Implement one of the phylogenetics parsimony algorithms in a simple way. This would illustrate how to do traversals down from the root efficiently. Probably the Sankoff score would be a good one.
I've been working on this: it's absolutely awesome what numba can do, and it works beautifully with the array based tree representation. I'll post some comments here on what I've done.
I'm using a tree sequence with 1 million samples here as the test case:
I'm putting the parent array in the notebook context here because we don't have efficient access to it yet. (I've also taken care to warm up the jit before running any timings)
Timings:
%%timeit
get_mrca_numba(u, v)
186 ns ± 0.714 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%%timeit
tree.get_mrca(u, v)
227 ns ± 2.21 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
Whoa! The numba jit version is a little bit faster than the library version (which is the updated, non malloc version)! This is a fast function though, so the overhead of the Python C interface is probably what's creating the difference. But still - I didn't see that coming.
Let's see how we do with a longer running function that takes a bit more computation. We'll compute the total branch length, but doing a simple top-down traversal. First we get the left_child and right_sib arrays ready, so we can use them.
%%timeit
tree.get_total_branch_length(tree.root)
50.8 ms ± 987 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit
total_branch_length_numba(tree.root)
158 ms ± 1.85 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit
total_branch_length_numba_recursive(tree.root)
122 ms ± 4.77 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
So, the C version in tree.get_total_branch_length is still faster, but not by much! Surprisingly, the recursive version of the numba jit function is also faster than the manual stack.
For the record, here's what the C code looks like:)
Since preorder via recursion was fast, let's try postorder (which is much easier to do via recursion). Propagating a sum up the tree is a fundamental operation.
Here, we just count the number of nodes that are below each node and return this array, but it could be anything. Note in particular that we're using numpy indexing here, and this should work for nd arrays.
Timings:
%%timeit
count_nodes()
107 ms ± 1.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Holy jeebus, we did a recursive postorder traversal summing an array in less time than it took to do a top down sum of a simple value! Again, this is just over twice the time it took for the optimised C code to sum the total branch length.
%%timeit
sankoff_score_numba(genotypes, cost_matrix)
197 ms ± 7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Woo, 0.2 seconds! (This is a tree with 1 million samples, remember). It takes about 30 seconds to run the same calculation in Biopython - but the algorithm is implemented in Python, so it's not a fair comparison.
ts=ts1mtree=ts.first()
parent=np.zeros(ts.num_nodes, dtype=np.int32)
right_child=np.zeros(ts.num_nodes, dtype=np.int32)
left_sib=np.zeros(ts.num_nodes, dtype=np.int32)
time=ts.tables.nodes.timeflags=ts.tables.nodes.flagsforuinrange(ts.num_nodes):
parent[u] =tree.parent(u)
right_child[u] =tree.right_child(u)
left_sib[u] =tree.left_sib(u)
@numba.njit()def_hartigan_postorder(parent, optimal_set):
num_alleles=optimal_set.shape[1]
allele_count=np.zeros(num_alleles, dtype=np.int32)
child=right_child[parent]
whilechild!=tskit.NULL:
_hartigan_postorder(child, optimal_set)
allele_count+=optimal_set[child]
child=left_sib[child]
ifflags[parent] ==0: # Bad! This should just be checking the sample bit.max_allele_count=np.max(allele_count)
forjinrange(num_alleles):
ifallele_count[j] ==max_allele_count:
optimal_set[parent, j] =1@numba.njit()def_hartigan_preorder(node, state, optimal_set):
mutations= []
ifoptimal_set[node, state] ==0:
state=np.argmax(optimal_set[node])
mutations.append((node, state))
v=right_child[node]
whilev!=tskit.NULL:
v_muts=_hartigan_preorder(v, state, optimal_set)
mutations.extend(v_muts)
v=left_sib[v]
returnmutationsdefhartigan_map_mutations_numba(tree, genotypes, alleles):
# Simple version assuming non missing data and one rootnum_alleles=np.max(genotypes) +1num_nodes=tree.tree_sequence.num_nodesoptimal_set=np.zeros((num_nodes+1, num_alleles), dtype=np.int8)
forallele, uinzip(genotypes, tree.tree_sequence.samples()):
optimal_set[u, allele] =1_hartigan_postorder(tree.root, optimal_set)
ancestral_state=np.argmax(optimal_set[tree.root])
ll_mutations=_hartigan_preorder(tree.root, ancestral_state, optimal_set)
mutations= []
fornode, derived_stateinll_mutations:
mutations.append(
tskit.Mutation(
node=node,
derived_state=alleles[derived_state],
# Note we're taking a short-cut here and not bothering with mutation parent. # Could be done easily enough.
)
)
returnalleles[ancestral_state], mutationsgenotypes=np.zeros(ts.num_samples, dtype=np.int8)
# This is an easy one so we won't be allocing a lot of memory.genotypes[1] =1
Timings:
The tskit version takes about 1/3 of second on a million leaf tree
%%timeit
tree.map_mutations(genotypes, ["0", "1"])
292 ms ± 6.95 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
The numba version takes about a second, which is pretty great!
%%timeit
hartigan_map_mutations_numba(tree, genotypes, ["0", "1"])
1.06 s ± 26.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
They was a very easy parismony job though, it only needed one mutation. Lets try something harder so we're doing memory allocations:
genotypes=np.zeros(ts.num_samples, dtype=np.int8)
# Assign something to the genotypes so we're not summing 0sgenotypes[::2] =1genotypesarray([1, 0, 1, ..., 0, 1, 0], dtype=int8)
%%timeit
tree.map_mutations(genotypes, ["0", "1"])
721 ms ± 7.98 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
hartigan_map_mutations_numba(tree, genotypes, ["0", "1"])
1.56 s ± 23.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
So, still within a factor of 2-3 of the highly optimised tskit C code! Wow, numba kicks ass!
An update here: tskit-dev/tskit#1320 adds support for the tree arrays, which works very well. I'll need to do a bit of experimentation to see what's the best way of passing around these array references (i.e., to make sure they are considered "const" by numba), but it's all solid.
Activity
jeromekelleher commentedon Apr 16, 2021
I've been working on this: it's absolutely awesome what numba can do, and it works beautifully with the array based tree representation. I'll post some comments here on what I've done.
I'm using a tree sequence with 1 million samples here as the test case:
MRCAs
First up, compute mrcas using (using the nice new algorithm, tskit-dev/tskit#1313)
I'm putting the
parentarray in the notebook context here because we don't have efficient access to it yet. (I've also taken care to warm up the jit before running any timings)Timings:
Whoa! The numba jit version is a little bit faster than the library version (which is the updated, non malloc version)! This is a fast function though, so the overhead of the Python C interface is probably what's creating the difference. But still - I didn't see that coming.
jeromekelleher commentedon Apr 16, 2021
Total branch length
Let's see how we do with a longer running function that takes a bit more computation. We'll compute the total branch length, but doing a simple top-down traversal. First we get the
left_childandright_sibarrays ready, so we can use them.Timings:
So, the C version in
tree.get_total_branch_lengthis still faster, but not by much! Surprisingly, the recursive version of the numba jit function is also faster than the manual stack.For the record, here's what the C code looks like:)
This isn't in the library currently - I might add the function, if we think it's worth while.
jeromekelleher commentedon Apr 16, 2021
Postorder sum
Since preorder via recursion was fast, let's try postorder (which is much easier to do via recursion). Propagating a sum up the tree is a fundamental operation.
Here, we just count the number of nodes that are below each node and return this array, but it could be anything. Note in particular that we're using numpy indexing here, and this should work for nd arrays.
Timings:
Holy jeebus, we did a recursive postorder traversal summing an array in less time than it took to do a top down sum of a simple value! Again, this is just over twice the time it took for the optimised C code to sum the total branch length.
jeromekelleher commentedon Apr 16, 2021
OK, let's try something a bit more complicated. We can compute the Sankoff parsimony score of an assigment of genotypes to a particular tree.
Timings:
Woo, 0.2 seconds! (This is a tree with 1 million samples, remember). It takes about 30 seconds to run the same calculation in Biopython - but the algorithm is implemented in Python, so it's not a fair comparison.
jeromekelleher commentedon Apr 16, 2021
Parsimonious assignments
Timings:
The tskit version takes about 1/3 of second on a million leaf tree
The numba version takes about a second, which is pretty great!
They was a very easy parismony job though, it only needed one mutation. Lets try something harder so we're doing memory allocations:
So, still within a factor of 2-3 of the highly optimised tskit C code! Wow, numba kicks ass!
jeromekelleher commentedon Apr 20, 2021
An update here: tskit-dev/tskit#1320 adds support for the tree arrays, which works very well. I'll need to do a bit of experimentation to see what's the best way of passing around these array references (i.e., to make sure they are considered "const" by numba), but it's all solid.