Skip to content

Commit 3676c5a

Browse files
committed
First draft at polytomy breaking function
1 parent 01c1255 commit 3676c5a

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

python/CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
[0.X.X] - 2020-XX-XX
33
--------------------
44

5+
- Add ``randomly_split_polytomies`` methods for tables and tree sequences
6+
(:user:`hyanwong`, :issue:`809`)
7+
58
- Added ``include_terminal`` parameter to edge diffs iterator, to output the last edges
69
at the end of a tree sequence (:user:`hyanwong`, :issue:`783`, :pr:`787`)
710

python/tests/test_topology.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7843,3 +7843,56 @@ def test_failure_with_migrations(self):
78437843
self.assertRaises(ValueError, ts.ltrim)
78447844
self.assertRaises(ValueError, ts.rtrim)
78457845
self.assertRaises(ValueError, ts.trim)
7846+
7847+
7848+
class TestPolytomySplitting(unittest.TestCase):
7849+
"Test the ability to randomly split polytomies"
7850+
nodes_polytomy_4 = """\
7851+
id is_sample population time
7852+
0 1 0 0.00000000000000
7853+
1 1 0 0.00000000000000
7854+
2 1 0 0.00000000000000
7855+
3 1 0 0.00000000000000
7856+
4 0 0 1.00000000000000
7857+
"""
7858+
edges_polytomy_4 = """\
7859+
id left right parent child
7860+
0 0.00000000 1.00000000 4 0,1,2,3
7861+
"""
7862+
# def test_equiprobable(self):
7863+
# pass
7864+
7865+
def test_nonbinary_tree(self):
7866+
ts = tskit.load_text(
7867+
nodes=io.StringIO(self.nodes_polytomy_4),
7868+
edges=io.StringIO(self.edges_polytomy_4),
7869+
strict=False,
7870+
)
7871+
ts_binary1 = ts.randomly_split_polytomies(squash_edges=True, random_seed=123)
7872+
# Should be no extra edges to squash
7873+
ts_binary2 = ts.randomly_split_polytomies(squash_edges=False, random_seed=123)
7874+
self.assertTrue(ts_equal(ts_binary1, ts_binary2))
7875+
for tree in ts_binary1.trees():
7876+
for node in tree.nodes():
7877+
assert tree.num_children(node) < 3
7878+
7879+
def test_nonbinary_ts(self):
7880+
demographic_events = [
7881+
msprime.SimpleBottleneck(time=1.0, population=0, proportion=0.95)
7882+
]
7883+
ts = msprime.simulate(
7884+
20,
7885+
recombination_rate=10,
7886+
mutation_rate=5,
7887+
demographic_events=demographic_events,
7888+
random_seed=7,
7889+
)
7890+
n_poly = 0
7891+
for e in ts.edgesets():
7892+
if len(e.children) > 2:
7893+
n_poly += 1
7894+
self.assertGreater(n_poly, 3)
7895+
ts_binary = ts.randomly_split_polytomies(random_seed=123)
7896+
for tree in ts_binary.trees():
7897+
for node in tree.nodes():
7898+
assert tree.num_children(node) < 3

python/tskit/tables.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Tree sequence IO via the tables API.
2525
"""
2626
import base64
27+
import collections
2728
import datetime
2829
import itertools
2930
import json
@@ -2756,3 +2757,204 @@ def union(
27562757
self.provenances.add_row(
27572758
record=json.dumps(provenance.get_provenance_dict(parameters))
27582759
)
2760+
2761+
def randomly_split_polytomies(
2762+
self,
2763+
*,
2764+
epsilon=None,
2765+
squash_edges=True,
2766+
record_provenance=True,
2767+
random_seed=None,
2768+
):
2769+
"""
2770+
Modifies the table collection in place, adding extra nodes and edges
2771+
so that any node with greater than 2 children (i.e. a multifurcation
2772+
or "polytomy") is resolved into successive bifurcations. This is identical
2773+
to :meth:`TreeSequence.randomly_split_polytomies` but acts *in place* to
2774+
alter the data in this :class:`TableCollection`. Please see
2775+
:meth:`TreeSequence.randomly_split_polytomies` for a fuller description,
2776+
and details of parameters.
2777+
"""
2778+
if epsilon is None:
2779+
epsilon = 1e-10
2780+
rng = np.random.default_rng(seed=random_seed)
2781+
2782+
def is_unknown_time_array(a):
2783+
np_unknown_time = np.float64(UNKNOWN_TIME)
2784+
return a.view(np.uint64) == np_unknown_time.view(np.uint64)
2785+
2786+
def resolve_polytomy(parent_node_id, child_ids, new_nodes_by_time_desc):
2787+
"""
2788+
For a polytomy and list of child node ids, return a list of (child, parent)
2789+
tuples, describing a bifurcating tree, rooted at parent_node_id, where the
2790+
new_nodes_by_time_desc have been used to break polytomies. All possible
2791+
topologies should be equiprobable.
2792+
"""
2793+
nonlocal rng
2794+
assert len(child_ids) == len(new_nodes_by_time_desc) + 2
2795+
# Polytomies broken by sequentially splicing onto edges, so an initial edge
2796+
# is required. This will always remain above the top node & is removed later
2797+
edges = [
2798+
[child_ids[0], None],
2799+
]
2800+
# We know beforehand how many random ints are needed: generate them all now
2801+
edge_choice = rng.integers(0, np.arange(1, len(child_ids) * 2 - 1, 2))
2802+
tmp_new_node_lab = [parent_node_id] + new_nodes_by_time_desc
2803+
assert len(edge_choice) == len(child_ids) - 1
2804+
for node_lab, child_id, target_edge_id in zip(
2805+
tmp_new_node_lab, child_ids[1:], edge_choice
2806+
):
2807+
target_edge = edges[target_edge_id]
2808+
# Insert in the right place, to keep edges in parent time order
2809+
edges.insert(target_edge_id, [child_id, node_lab])
2810+
edges.insert(target_edge_id, [target_edge[0], node_lab])
2811+
target_edge[0] = node_lab
2812+
top_edge = edges.pop() # remove the edge above the top node
2813+
assert top_edge[1] is None
2814+
2815+
# Re-map the internal nodes IDs so they are used in time order
2816+
real_node = iter(new_nodes_by_time_desc)
2817+
node_map = {c: c for c in child_ids}
2818+
node_map[edges[-1][1]] = parent_node_id # last edge == oldest parent
2819+
for e in reversed(edges):
2820+
# Reversing along the edges, parents are in inverse time order
2821+
for idx in (1, 0): # look at parent (1) then child (0)
2822+
if e[idx] not in node_map:
2823+
node_map[e[idx]] = next(real_node)
2824+
e[idx] = node_map[e[idx]]
2825+
assert len(node_map) == len(new_nodes_by_time_desc) + len(child_ids) + 1
2826+
return edges
2827+
2828+
edge_table = self.edges
2829+
node_table = self.nodes
2830+
# Store existing left, so we can change it if the edge is split
2831+
existing_edges_left = edge_table.left
2832+
# Keep other edge arrays etc. for fast read access
2833+
existing_edges_right = edge_table.right
2834+
existing_edges_parent = edge_table.parent
2835+
existing_edges_child = edge_table.child
2836+
existing_node_time = node_table.time
2837+
2838+
# We can save a lot of effort if we don't need to check the time of mutations
2839+
# We definitely don't need to check on the first iteration, a
2840+
check_mutations = np.any(
2841+
np.logical_not(is_unknown_time_array(self.mutations.time))
2842+
)
2843+
ts = self.tree_sequence() # Only needed to check mutations
2844+
tree_iter = ts.trees() # ditto
2845+
2846+
edge_table.clear()
2847+
2848+
edges_from_node = collections.defaultdict(set) # Active descendant edge ids
2849+
nodes_changed = set()
2850+
2851+
for interval, e_out, e_in in ts.edge_diffs(include_terminal=True):
2852+
pos = interval[0]
2853+
prev_tree = None if pos == 0 else next(tree_iter)
2854+
2855+
for edge in itertools.chain(e_out, e_in):
2856+
if edge.parent != tskit.NULL:
2857+
nodes_changed.add(edge.parent)
2858+
2859+
if check_mutations and prev_tree is not None:
2860+
# This is grim. There must be a more efficient way.
2861+
# It would also help if mutations were sorted such that all mutations
2862+
# above the same node appeared consecutively, with oldest first.
2863+
oldest_mutation_for_node = {}
2864+
for site in prev_tree.sites():
2865+
for mutation in site.mutations:
2866+
if not util.is_unknown_time(mutation.time):
2867+
oldest_mutation_for_node[mutation.node] = max(
2868+
oldest_mutation_for_node[mutation.node], mutation.time
2869+
)
2870+
2871+
for parent_node in nodes_changed:
2872+
child_edge_ids = edges_from_node[parent_node]
2873+
if len(child_edge_ids) >= 3:
2874+
# We have a previous polytomy to break
2875+
parent_time = existing_node_time[parent_node]
2876+
new_nodes = []
2877+
child_ids = existing_edges_child[list(child_edge_ids)]
2878+
left = None
2879+
max_time = 0
2880+
# Split existing edges
2881+
for edge_id, child_id in zip(child_edge_ids, child_ids):
2882+
max_time = max(max_time, existing_node_time[child_id])
2883+
if check_mutations and child_id in oldest_mutation_for_node:
2884+
max_time = max(max_time, oldest_mutation_for_node[child_id])
2885+
if left is None:
2886+
left = existing_edges_left[edge_id]
2887+
else:
2888+
assert left == existing_edges_left[edge_id]
2889+
if existing_edges_right[edge_id] > interval[0]:
2890+
# make sure we carry on the edge after this polytomy
2891+
existing_edges_left[edge_id] = pos
2892+
# Arbitrarily, if epsilon is not small enough, use half the min dist
2893+
dt = min((parent_time - max_time) / (len(child_ids) * 2), epsilon)
2894+
# Break this N-degree polytomy. This requires N-2 extra nodes to be
2895+
# introduced: create them here in order of decreasing time
2896+
new_nodes = [
2897+
node_table.add_row(time=parent_time - (i * dt))
2898+
for i in range(1, len(child_ids) - 1)
2899+
]
2900+
# print("New nodes:", new_nodes, node_table.time[new_nodes])
2901+
for new_edge in resolve_polytomy(parent_node, child_ids, new_nodes):
2902+
edge_table.add_row(
2903+
left=left, right=pos, child=new_edge[0], parent=new_edge[1],
2904+
)
2905+
# print("new_edge: left={}, right={}, child={}, parent={}"
2906+
# .format(left, pos, new_edge[0], new_edge[1]))
2907+
else:
2908+
# Previous node was not a polytomy - just add the edges_out
2909+
for edge_id in child_edge_ids:
2910+
if existing_edges_right[edge_id] == pos: # is an out edge
2911+
edge_table.add_row(
2912+
left=existing_edges_left[edge_id],
2913+
right=pos,
2914+
parent=parent_node,
2915+
child=existing_edges_child[edge_id],
2916+
)
2917+
2918+
for edge in e_out:
2919+
if edge.parent != tskit.NULL:
2920+
# print("REMOVE", edge.id)
2921+
edges_from_node[edge.parent].remove(edge.id)
2922+
for edge in e_in:
2923+
if edge.parent != tskit.NULL:
2924+
# print("ADD", edge.id)
2925+
edges_from_node[edge.parent].add(edge.id)
2926+
2927+
# Chop if we have created a polytomy: the polytomy itself will be resolved
2928+
# at a future iteration, when any edges move into or out of the polytomy
2929+
while nodes_changed:
2930+
node = nodes_changed.pop()
2931+
edge_ids = edges_from_node[node]
2932+
# print("Looking at", node)
2933+
2934+
if len(edge_ids) == 0:
2935+
del edges_from_node[node]
2936+
# if this node has changed *to* a polytomy, we need to cut all of the
2937+
# child edges that were previously present by adding the previous
2938+
# segment and left-truncating
2939+
elif len(edge_ids) >= 3:
2940+
for edge_id in edge_ids:
2941+
if existing_edges_left[edge_id] < interval[0]:
2942+
self.edges.add_row(
2943+
left=existing_edges_left[edge_id],
2944+
right=interval[0],
2945+
parent=existing_edges_parent[edge_id],
2946+
child=existing_edges_child[edge_id],
2947+
)
2948+
existing_edges_left[edge_id] = interval[0]
2949+
assert len(edges_from_node) == 0
2950+
self.sort()
2951+
2952+
if squash_edges:
2953+
self.edges.squash()
2954+
self.sort() # Bug: https://github.com/tskit-dev/tskit/issues/808
2955+
2956+
if record_provenance:
2957+
parameters = {"command": "randomly_split_polytomies"}
2958+
self.provenances.add_row(
2959+
record=json.dumps(provenance.get_provenance_dict(parameters))
2960+
)

python/tskit/trees.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6210,6 +6210,62 @@ def count_topologies(self, sample_sets=None):
62106210

62116211
yield from combinatorics.treeseq_count_topologies(self, sample_sets)
62126212

6213+
def randomly_split_polytomies(
6214+
self,
6215+
*,
6216+
epsilon=None,
6217+
squash_edges=True,
6218+
record_provenance=True,
6219+
random_seed=None,
6220+
):
6221+
"""
6222+
Return a tree sequence with extra nodes and edges
6223+
so that any node with greater than 2 children (i.e. a multifurcation
6224+
or "polytomy") is resolved into successive bifurcations. For any
6225+
multifucating node ``u`` with ``n`` children, the :math:`(2n - 3)!!`
6226+
possible bifurcating topologies are produced with equal probability.
6227+
6228+
Polytomies are split per node, not per tree, so that if an identical
6229+
polytomy spans several trees, it will be randomly resolved into a single
6230+
set of bifurcating splits. However, if on shifting to a new genomic
6231+
region, the children of node ``u`` change, either being added or removed,
6232+
an entirely new random resolution of the node will be applied to that
6233+
region.
6234+
6235+
Because a tree sequence requires that
6236+
:ref:`parents be older than children<sec_valid_tree_sequence_requirements>`,
6237+
the newly added nodes are inserted at a time fractionally younger than
6238+
than the time of node ``u``. This can be controlled by the ``epsilon``
6239+
parameter.
6240+
6241+
:param epsilon: The maximum time between each newly inserted node. For a
6242+
given polytomy, if possible, the ``n-2`` extra
6243+
nodes are inserted at time ``epsilon`` apart from each other and from the
6244+
time of node ``u``. By default, this is set to a very small time value
6245+
(:math:`1e-10`). However, if there is a child node or a mutation above
6246+
a child node whose time is very close to the time of ``u``, ``epsilon``
6247+
may not be small enough. In this case, a smaller time interval is used
6248+
so that the tables will still encode a valid tree sequence.
6249+
:param bool squash_edges: If True (default), run :meth:`.squash()` at the
6250+
end of the process. This can help to reduce the total number of extra
6251+
edges produced.
6252+
:param bool record_provenance: If True, add details of this operation to the
6253+
provenance information of the returned tree sequence. (Default: True).
6254+
:param int random_seed: The random seed. If this is None, a random seed will
6255+
be automatically generated. Valid random seeds must be between 1 and
6256+
:math:`2^32 − 1`.
6257+
:return: A new tree sequence with polytomies split into random bifurcations.
6258+
:rtype: .TreeSequence
6259+
"""
6260+
tables = self.dump_tables()
6261+
tables.randomly_split_polytomies(
6262+
epsilon=epsilon,
6263+
squash_edges=squash_edges,
6264+
record_provenance=record_provenance,
6265+
random_seed=random_seed,
6266+
)
6267+
return tables.tree_sequence()
6268+
62136269
############################################
62146270
#
62156271
# Deprecated APIs. These are either already unsupported, or will be unsupported in a

0 commit comments

Comments
 (0)