
Trees as data structures: a Node class, traversals, and sequences on a tree
Department of Biostatistics and Computational Biology, University of Rochester Medical Center
Node objects that know their parent and their children.
A phylogeny on four taxa, A to D. The shaded nodes are the observed taxa (leaves); the unshaded nodes are their unobserved ancestors. The number on each branch is its length, which the substitution model will turn into transition probabilities.




A tumour is a population of cells descending from one normal cell by acquiring mutations. The leaves are the cell populations we sequence, the internal nodes are the clones that gave rise to them, and the shape of the tree, linear, branched, or neutral, is a claim about how the cancer grew and what it will do under treatment.

Node classclass Node: defines a new type, the way list and dict are types. Everything indented under it belongs to the type.__init__ runs each time a new Node object is created. It is a function like any other, except for its first argument.self is the object being built. Python supplies it; you never pass it yourself. Every method of a class takes self first.self.<name>. There is no declaration step: the four lines above are the declaration. Every node gets a name, a branch length, an empty list of children, and no parent yet.branch_length=0.0 is a default, so the root can be created without one.root = Node("root")
i1 = Node("i1", 0.10)
print(root.name, root.branch_length, root.children, root.parent)
print(i1.name, i1.branch_length)
rootroot 0.0 [] None
i1 0.1
<__main__.Node at 0x15263bfd0>
Node("root"), creates a fresh object and runs __init__ on it with self set to that object and name="root". The result is what root now holds.i1.branch_length. Each object has its own copy; changing i1.name leaves root.name alone.root.children.append(i1)
i1.parent = root
print(root.children[0].name, i1.parent.name)
print(i1.parent is root)i1 root
True
root.children[0] and i1 are the same node: is checks that.parent points up, each parent’s children list points down.add_child method to handle the bookkeeping.class Node:
def __init__(self, name, branch_length=0.0):
self.name = name
self.branch_length = branch_length
self.children = []
self.parent = None
def is_leaf(self):
return len(self.children) == 0
root = Node("root")
print(root.is_leaf())True
root.is_leaf() is shorthand for Node.is_leaf(root): the object before the dot becomes self, and the parentheses are empty because there is nothing else to pass.self refers to the particular object on which the method was called, so self.children accesses that object’s own children attribute.is_rootAdd a second method to the class on the previous slide, and make the assertions pass.
def line sits inside the class body, at the same depth as is_leaf, or Python will define a plain function that no node can call.assert raises an error when its condition is false and does nothing otherwise. A cell full of passing assertions is how every task today reports success.__repr__class Node:
def __init__(self, name, branch_length=0.0):
self.name = name
self.branch_length = branch_length
self.children = []
self.parent = None
def is_leaf(self):
return len(self.children) == 0
def is_root(self):
return self.parent is None
def __repr__(self):
return f"Node({self.name}, {len(self.children)} children)"
root = Node("root")
root.children.append(Node("i1", 0.10))
rootNode(root, 1 children)
__init__ runs on creation; __repr__ runs whenever Python needs to display the object: at the end of a cell, when the object sits in a list that is printed, or inside print when the class has no __str__.Everything above, plus one more method of your own.
class Node:
def __init__(self, name, branch_length=0.0):
self.name = name
self.branch_length = branch_length # length of the branch to the PARENT
self.children = []
self.parent = None
def add_child(self, child):
"""Attach `child` below this node, set its parent, and return the child."""
# TODO: two pointers to set, one object to return
raise NotImplementedError
def is_leaf(self):
return len(self.children) == 0
def is_root(self):
raise NotImplementedError
def __repr__(self):
return f"Node({self.name}, {len(self.children)} children)"Returning the child from add_child is a small convenience that pays off immediately: i1 = root.add_child(Node("i1", 0.10)) builds, attaches and names the node in one line.
root = Node("root")
i1 = root.add_child(Node("i1", 0.10))
i2 = root.add_child(Node("i2", 0.15))
# TODO: attach A (0.05) and B (0.30) under i1, C (0.20) and D (0.08) under i2,
# keeping the four leaves as variables A, B, C, D
assert len(root.children) == 2
assert i1.parent is root
assert root.is_root() and not root.is_leaf()
assert all(c.parent is i2 for c in i2.children)Then print what you built. This helper is yours to keep:
root (0.0)
i1 (0.1)
A (0.05)
B (0.3)
i2 (0.15)
C (0.2)
D (0.08)
Look at show for a moment. It prints the node, then calls itself on each child. That is already a traversal, and the next task gives it a name.
A traversal visits every node once. The two we need differ only in when a node is visited relative to its children.

A stack is a list that is only touched at one end. append pushes an item on top; pop removes the item on top and returns it. The last item pushed is the first one back out.
stack = []
stack.append("root")
stack.append("i1")
stack.append("i2")
print(stack)
top = stack.pop()
print(top, stack)
print(stack.pop(), stack)['root', 'i1', 'i2']
i2 ['root', 'i1']
i1 ['root']

No recursion here. Keep a list of nodes still to be visited, and loop until it is empty.
def preorder(node):
"""Return a list of nodes, each parent before its children."""
order = []
stack = [node] # nodes waiting to be visited; the last one goes next
while stack:
v = stack.pop()
# TODO: record v in order, then push its children so that the
# FIRST child is the next one popped
raise NotImplementedError
return order
def postorder(node):
"""Return a list of nodes, each node after all of its children."""
# TODO: hint -- run the same loop but push the children in their natural order,
# so the LAST child is popped first; the reverse of that list is a post-order.
raise NotImplementedError
print([v.name for v in preorder(root)])
print([v.name for v in postorder(root)])['root', 'i1', 'A', 'B', 'i2', 'C', 'D']
['A', 'B', 'i1', 'C', 'D', 'i2', 'root']
while stack: runs until the list is empty, since an empty list counts as false.Note
The recursive versions are three lines each, and show from Task 3 is already a recursive pre-order. The reason to learn the stack form is Python’s recursion limit of 1000 frames: a caterpillar shaped tree with a few thousand leaves will hit it, and large sequencing studies produce trees that size.
print("pre-order :", [v.name for v in preorder(root)])
print("post-order:", [v.name for v in postorder(root)])pre-order : ['root', 'i1', 'A', 'B', 'i2', 'C', 'D']
post-order: ['A', 'B', 'i1', 'C', 'D', 'i2', 'root']
Then check the two properties that matter, rather than eyeballing the lists:
pre, post = preorder(root), postorder(root)
assert pre[0] is root and post[-1] is root # root first in pre-order, last in post-order
for v in post:
for c in v.children:
assert post.index(c) < post.index(v) # every child comes before its parent
for v in pre:
for c in v.children:
assert pre.index(c) > pre.index(v) # every child comes after its parent
print("both orders check out")both orders check out
Match the names against the visit numbers on the figure. In post-order, root is last and i1 only appears once A and B have; in pre-order it is the other way around. Those two properties, not the exact left-to-right order, are what the algorithms will rely on.
Write each of these with a traversal, not by reading the picture.
def leaves(node): # list of leaf nodes, in pre-order
...
def total_length(node): # sum of every branch length in the subtree
...
def path_to_root(node): # [node, node.parent, ..., root]
...
assert [v.name for v in leaves(root)] == ["A", "B", "C", "D"]
assert abs(total_length(root) - 0.88) < 1e-12
assert [v.name for v in path_to_root(A)] == ["A", "i1", "root"]leaves and total_length are each a loop over preorder with one condition or one running sum. path_to_root is the one function here that walks up, and it is the reason parent is stored.
A phylogeny is a latent variable model. The evolutionary model is a substitution matrix \(P(t)\): the probabilities of each nucleotide turning into each other along a branch of length \(t\). Sequences of length \(S\) are generated one site at a time. For each site \(s = 1, \ldots, S\):
Under the Jukes-Cantor model, every substitution is equally likely and the matrix has a closed form:
\[ P_{aa}(t) = \tfrac14 + \tfrac34 e^{-4t/3}, \qquad P_{ab}(t) = \tfrac14 - \tfrac14 e^{-4t/3} \;\; (a \neq b). \]
def jc69(t):
e = np.exp(-4 * t / 3)
P = np.full((4, 4), 0.25 - 0.25 * e)
np.fill_diagonal(P, 0.25 + 0.75 * e)
return P
print(np.round(jc69(0.05), 3))
print(np.round(jc69(2.0), 3))[[0.952 0.016 0.016 0.016]
[0.016 0.952 0.016 0.016]
[0.016 0.016 0.952 0.016]
[0.016 0.016 0.016 0.952]]
[[0.302 0.233 0.233 0.233]
[0.233 0.302 0.233 0.233]
[0.233 0.233 0.302 0.233]
[0.233 0.233 0.233 0.302]]
Rows are distributions over the child’s nucleotide. A short branch barely changes anything; a long branch forgets the parent and every row tends to \((\tfrac14, \tfrac14, \tfrac14, \tfrac14)\). Branch length is measured in expected substitutions per site, not in years.
Store the sequence on the node itself, for every node, so that the truth at the ancestors is kept for grading later.
def simulate_sites(root, n_sites, rng):
"""Attach `seq`, an integer array of length n_sites with A,C,G,T = 0,1,2,3,
to every node in the tree."""
for v in preorder(root):
if v.parent is None:
v.seq = rng.choice(4, size=n_sites) # root: uniform
else:
P = jc69(v.branch_length)
# TODO: for each site, draw v's nucleotide from the row of P
# selected by the parent's nucleotide at that site
raise NotImplementedError
return root
def to_string(seq):
return "".join("ACGT"[b] for b in seq)
rng = np.random.default_rng(5)
simulate_sites(root, n_sites=300, rng=rng)
for v in preorder(root):
print(f"{v.name:>5} {to_string(v.seq[:40])}") root GTATCGGCTACCGCAAAAATAGTACCCTATTTACGCGGGA
i1 GTATCCCCTAACGCAAAAATAGTTCGCTACATACGCGGGA
A GTATCTCCTAACGCAAAAATAGTGCGCTACATACGCGGGA
B GTAACCACTACTGCCTTAACGATTCGCTACATACGCGGCA
i2 GTATCGGCTACCGCATAAATACTACCCGATTTAGCAGGGA
C GCAACGGTTACCGGATAAAAACTACCCGGTTTAGCAGGGG
D GTATCGGCTACCGCATAAATACTTCCCAATTGAGCACGTA
A and B share a parent, i1, and short branches lead to A. Columns where A differs from i1 are rare; columns where B differs are less rare, because its branch is six times longer.C and D resemble i2, which in turn resembles root. Every column is a tiny piece of evidence about who is close to whom.Node class with parent, children, and a branch length, and a seven node tree built from it.preorder and postorder, written with a stack so they work on trees of any size.Keep this code. Next lecture adds one attribute per node, a vector over \(\{A, C, G, T\}\), and one rule per traversal, and the likelihood of your alignment falls out of it.
A Quarto document lab4.qmd with your solutions to tasks 1 to 6 with their assertions passing and the alignment printed. Due at the start of next Thursday’s class. Discuss freely; write your own code.
Your simulator draws every site independently and walks the tree once per site. Next lecture computes the likelihood of the leaf rows by summing over the joint ancestral states. Why does that sum factorize over sites, and what about the model would have to change for it not to?
def preorder(node):
order, stack = [], [node]
while stack:
v = stack.pop()
order.append(v)
stack.extend(reversed(v.children)) # push the LAST child first, so the FIRST child pops next
return order
def postorder(node):
order, stack = [], [node]
while stack:
v = stack.pop()
order.append(v)
stack.extend(v.children) # last child pops next: root, then subtrees right to left
return order[::-1] # the reverse of that is: subtrees left to right, then rootorder[::-1] is slice notation, list[start:stop:step], with the start and stop left empty and a step of \(-1\): walk the whole list backwards. It returns a new, reversed list and leaves order as it was. list(reversed(order)) does the same thing in words.