Skip to content

Commit 9cb5dbb

Browse files
committed
First draft at polytomy breaking function
1 parent 740ec41 commit 9cb5dbb

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
@@ -4,6 +4,9 @@
44

55
**Features**
66

7+
- Add ``randomly_split_polytomies`` methods for tables and tree sequences
8+
(:user:`hyanwong`, :issue:`809`)
9+
710
- Genomic intervals returned by python functions are now namedtuples, allowing ``.left``
811
``.right`` and ``.span`` usage (:user:`hyanwong`, :issue:`784`, :pr:`786`, :pr:`811`)
912

python/tests/test_topology.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7857,3 +7857,56 @@ def test_failure_with_migrations(self):
78577857
self.assertRaises(ValueError, ts.ltrim)
78587858
self.assertRaises(ValueError, ts.rtrim)
78597859
self.assertRaises(ValueError, ts.trim)
7860+
7861+
7862+
class TestPolytomySplitting(unittest.TestCase):
7863+
"Test the ability to randomly split polytomies"
7864+
nodes_polytomy_4 = """\
7865+
id is_sample population time
7866+
0 1 0 0.00000000000000
7867+
1 1 0 0.00000000000000
7868+
2 1 0 0.00000000000000
7869+
3 1 0 0.00000000000000
7870+
4 0 0 1.00000000000000
7871+
"""
7872+
edges_polytomy_4 = """\
7873+
id left right parent child
7874+
0 0.00000000 1.00000000 4 0,1,2,3
7875+
"""
7876+
# def test_equiprobable(self):
7877+
# pass
7878+
7879+
def test_nonbinary_tree(self):
7880+
ts = tskit.load_text(
7881+
nodes=io.StringIO(self.nodes_polytomy_4),
7882+
edges=io.StringIO(self.edges_polytomy_4),
7883+
strict=False,
7884+
)
7885+
ts_binary1 = ts.randomly_split_polytomies(squash_edges=True, random_seed=123)
7886+
# Should be no extra edges to squash
7887+
ts_binary2 = ts.randomly_split_polytomies(squash_edges=False, random_seed=123)
7888+
self.assertTrue(ts_equal(ts_binary1, ts_binary2))
7889+
for tree in ts_binary1.trees():
7890+
for node in tree.nodes():
7891+
assert tree.num_children(node) < 3
7892+
7893+
def test_nonbinary_ts(self):
7894+
demographic_events = [
7895+
msprime.SimpleBottleneck(time=1.0, population=0, proportion=0.95)
7896+
]
7897+
ts = msprime.simulate(
7898+
20,
7899+
recombination_rate=10,
7900+
mutation_rate=5,
7901+
demographic_events=demographic_events,
7902+
random_seed=7,
7903+
)
7904+
n_poly = 0
7905+
for e in ts.edgesets():
7906+
if len(e.children) > 2:
7907+
n_poly += 1
7908+
self.assertGreater(n_poly, 3)
7909+
ts_binary = ts.randomly_split_polytomies(random_seed=123)
7910+
for tree in ts_binary.trees():
7911+
for node in tree.nodes():
7912+
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
@@ -2781,3 +2782,204 @@ def union(
27812782
self.provenances.add_row(
27822783
record=json.dumps(provenance.get_provenance_dict(parameters))
27832784
)
2785+
2786+
def randomly_split_polytomies(
2787+
self,
2788+
*,
2789+
epsilon=None,
2790+
squash_edges=True,
2791+
record_provenance=True,
2792+
random_seed=None,
2793+
):
2794+
"""
2795+
Modifies the table collection in place, adding extra nodes and edges
2796+
so that any node with greater than 2 children (i.e. a multifurcation
2797+
or "polytomy") is resolved into successive bifurcations. This is identical
2798+
to :meth:`TreeSequence.randomly_split_polytomies` but acts *in place* to
2799+
alter the data in this :class:`TableCollection`. Please see
2800+
:meth:`TreeSequence.randomly_split_polytomies` for a fuller description,
2801+
and details of parameters.
2802+
"""
2803+
if epsilon is None:
2804+
epsilon = 1e-10
2805+
rng = np.random.default_rng(seed=random_seed)
2806+
2807+
def is_unknown_time_array(a):
2808+
np_unknown_time = np.float64(UNKNOWN_TIME)
2809+
return a.view(np.uint64) == np_unknown_time.view(np.uint64)
2810+
2811+
def resolve_polytomy(parent_node_id, child_ids, new_nodes_by_time_desc):
2812+
"""
2813+
For a polytomy and list of child node ids, return a list of (child, parent)
2814+
tuples, describing a bifurcating tree, rooted at parent_node_id, where the
2815+
new_nodes_by_time_desc have been used to break polytomies. All possible
2816+
topologies should be equiprobable.
2817+
"""
2818+
nonlocal rng
2819+
assert len(child_ids) == len(new_nodes_by_time_desc) + 2
2820+
# Polytomies broken by sequentially splicing onto edges, so an initial edge
2821+
# is required. This will always remain above the top node & is removed later
2822+
edges = [
2823+
[child_ids[0], None],
2824+
]
2825+
# We know beforehand how many random ints are needed: generate them all now
2826+
edge_choice = rng.integers(0, np.arange(1, len(child_ids) * 2 - 1, 2))
2827+
tmp_new_node_lab = [parent_node_id] + new_nodes_by_time_desc
2828+
assert len(edge_choice) == len(child_ids) - 1
2829+
for node_lab, child_id, target_edge_id in zip(
2830+
tmp_new_node_lab, child_ids[1:], edge_choice
2831+
):
2832+
target_edge = edges[target_edge_id]
2833+
# Insert in the right place, to keep edges in parent time order
2834+
edges.insert(target_edge_id, [child_id, node_lab])
2835+
edges.insert(target_edge_id, [target_edge[0], node_lab])
2836+
target_edge[0] = node_lab
2837+
top_edge = edges.pop() # remove the edge above the top node
2838+
assert top_edge[1] is None
2839+
2840+
# Re-map the internal nodes IDs so they are used in time order
2841+
real_node = iter(new_nodes_by_time_desc)
2842+
node_map = {c: c for c in child_ids}
2843+
node_map[edges[-1][1]] = parent_node_id # last edge == oldest parent
2844+
for e in reversed(edges):
2845+
# Reversing along the edges, parents are in inverse time order
2846+
for idx in (1, 0): # look at parent (1) then child (0)
2847+
if e[idx] not in node_map:
2848+
node_map[e[idx]] = next(real_node)
2849+
e[idx] = node_map[e[idx]]
2850+
assert len(node_map) == len(new_nodes_by_time_desc) + len(child_ids) + 1
2851+
return edges
2852+
2853+
edge_table = self.edges
2854+
node_table = self.nodes
2855+
# Store existing left, so we can change it if the edge is split
2856+
existing_edges_left = edge_table.left
2857+
# Keep other edge arrays etc. for fast read access
2858+
existing_edges_right = edge_table.right
2859+
existing_edges_parent = edge_table.parent
2860+
existing_edges_child = edge_table.child
2861+
existing_node_time = node_table.time
2862+
2863+
# We can save a lot of effort if we don't need to check the time of mutations
2864+
# We definitely don't need to check on the first iteration, a
2865+
check_mutations = np.any(
2866+
np.logical_not(is_unknown_time_array(self.mutations.time))
2867+
)
2868+
ts = self.tree_sequence() # Only needed to check mutations
2869+
tree_iter = ts.trees() # ditto
2870+
2871+
edge_table.clear()
2872+
2873+
edges_from_node = collections.defaultdict(set) # Active descendant edge ids
2874+
nodes_changed = set()
2875+
2876+
for interval, e_out, e_in in ts.edge_diffs(include_terminal=True):
2877+
pos = interval[0]
2878+
prev_tree = None if pos == 0 else next(tree_iter)
2879+
2880+
for edge in itertools.chain(e_out, e_in):
2881+
if edge.parent != tskit.NULL:
2882+
nodes_changed.add(edge.parent)
2883+
2884+
if check_mutations and prev_tree is not None:
2885+
# This is grim. There must be a more efficient way.
2886+
# It would also help if mutations were sorted such that all mutations
2887+
# above the same node appeared consecutively, with oldest first.
2888+
oldest_mutation_for_node = {}
2889+
for site in prev_tree.sites():
2890+
for mutation in site.mutations:
2891+
if not util.is_unknown_time(mutation.time):
2892+
oldest_mutation_for_node[mutation.node] = max(
2893+
oldest_mutation_for_node[mutation.node], mutation.time
2894+
)
2895+
2896+
for parent_node in nodes_changed:
2897+
child_edge_ids = edges_from_node[parent_node]
2898+
if len(child_edge_ids) >= 3:
2899+
# We have a previous polytomy to break
2900+
parent_time = existing_node_time[parent_node]
2901+
new_nodes = []
2902+
child_ids = existing_edges_child[list(child_edge_ids)]
2903+
left = None
2904+
max_time = 0
2905+
# Split existing edges
2906+
for edge_id, child_id in zip(child_edge_ids, child_ids):
2907+
max_time = max(max_time, existing_node_time[child_id])
2908+
if check_mutations and child_id in oldest_mutation_for_node:
2909+
max_time = max(max_time, oldest_mutation_for_node[child_id])
2910+
if left is None:
2911+
left = existing_edges_left[edge_id]
2912+
else:
2913+
assert left == existing_edges_left[edge_id]
2914+
if existing_edges_right[edge_id] > interval[0]:
2915+
# make sure we carry on the edge after this polytomy
2916+
existing_edges_left[edge_id] = pos
2917+
# Arbitrarily, if epsilon is not small enough, use half the min dist
2918+
dt = min((parent_time - max_time) / (len(child_ids) * 2), epsilon)
2919+
# Break this N-degree polytomy. This requires N-2 extra nodes to be
2920+
# introduced: create them here in order of decreasing time
2921+
new_nodes = [
2922+
node_table.add_row(time=parent_time - (i * dt))
2923+
for i in range(1, len(child_ids) - 1)
2924+
]
2925+
# print("New nodes:", new_nodes, node_table.time[new_nodes])
2926+
for new_edge in resolve_polytomy(parent_node, child_ids, new_nodes):
2927+
edge_table.add_row(
2928+
left=left, right=pos, child=new_edge[0], parent=new_edge[1],
2929+
)
2930+
# print("new_edge: left={}, right={}, child={}, parent={}"
2931+
# .format(left, pos, new_edge[0], new_edge[1]))
2932+
else:
2933+
# Previous node was not a polytomy - just add the edges_out
2934+
for edge_id in child_edge_ids:
2935+
if existing_edges_right[edge_id] == pos: # is an out edge
2936+
edge_table.add_row(
2937+
left=existing_edges_left[edge_id],
2938+
right=pos,
2939+
parent=parent_node,
2940+
child=existing_edges_child[edge_id],
2941+
)
2942+
2943+
for edge in e_out:
2944+
if edge.parent != tskit.NULL:
2945+
# print("REMOVE", edge.id)
2946+
edges_from_node[edge.parent].remove(edge.id)
2947+
for edge in e_in:
2948+
if edge.parent != tskit.NULL:
2949+
# print("ADD", edge.id)
2950+
edges_from_node[edge.parent].add(edge.id)
2951+
2952+
# Chop if we have created a polytomy: the polytomy itself will be resolved
2953+
# at a future iteration, when any edges move into or out of the polytomy
2954+
while nodes_changed:
2955+
node = nodes_changed.pop()
2956+
edge_ids = edges_from_node[node]
2957+
# print("Looking at", node)
2958+
2959+
if len(edge_ids) == 0:
2960+
del edges_from_node[node]
2961+
# if this node has changed *to* a polytomy, we need to cut all of the
2962+
# child edges that were previously present by adding the previous
2963+
# segment and left-truncating
2964+
elif len(edge_ids) >= 3:
2965+
for edge_id in edge_ids:
2966+
if existing_edges_left[edge_id] < interval[0]:
2967+
self.edges.add_row(
2968+
left=existing_edges_left[edge_id],
2969+
right=interval[0],
2970+
parent=existing_edges_parent[edge_id],
2971+
child=existing_edges_child[edge_id],
2972+
)
2973+
existing_edges_left[edge_id] = interval[0]
2974+
assert len(edges_from_node) == 0
2975+
self.sort()
2976+
2977+
if squash_edges:
2978+
self.edges.squash()
2979+
self.sort() # Bug: https://github.com/tskit-dev/tskit/issues/808
2980+
2981+
if record_provenance:
2982+
parameters = {"command": "randomly_split_polytomies"}
2983+
self.provenances.add_row(
2984+
record=json.dumps(provenance.get_provenance_dict(parameters))
2985+
)

python/tskit/trees.py

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

62206220
yield from combinatorics.treeseq_count_topologies(self, sample_sets)
62216221

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

0 commit comments

Comments
 (0)