phytreon 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phytreon/__init__.py +49 -0
- phytreon/comparative/__init__.py +5 -0
- phytreon/comparative/ace.py +223 -0
- phytreon/comparative/stochastic_mapping.py +134 -0
- phytreon/core/__init__.py +5 -0
- phytreon/core/io.py +177 -0
- phytreon/core/tree.py +243 -0
- phytreon/datasets.py +48 -0
- phytreon/infer/__init__.py +27 -0
- phytreon/infer/_search.py +28 -0
- phytreon/infer/align.py +299 -0
- phytreon/infer/bootstrap.py +149 -0
- phytreon/infer/distance.py +82 -0
- phytreon/infer/ml.py +68 -0
- phytreon/infer/ml_native.py +389 -0
- phytreon/infer/parsimony.py +114 -0
- phytreon/infer/pipeline.py +122 -0
- phytreon/infer/trim.py +71 -0
- phytreon/layout/__init__.py +37 -0
- phytreon/layout/base.py +99 -0
- phytreon/layout/circular.py +82 -0
- phytreon/layout/rectangular.py +78 -0
- phytreon/layout/unrooted.py +143 -0
- phytreon/plot/__init__.py +14 -0
- phytreon/plot/backends.py +403 -0
- phytreon/plot/elements.py +854 -0
- phytreon/plot/figure.py +333 -0
- phytreon/plot/palettes.py +141 -0
- phytreon/scene.py +147 -0
- phytreon/treeops.py +318 -0
- phytreon-0.1.1.dist-info/METADATA +438 -0
- phytreon-0.1.1.dist-info/RECORD +35 -0
- phytreon-0.1.1.dist-info/WHEEL +5 -0
- phytreon-0.1.1.dist-info/licenses/LICENSE +21 -0
- phytreon-0.1.1.dist-info/top_level.txt +1 -0
phytreon/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""phytreon -- phylogenetic trees and publication figures in Python.
|
|
2
|
+
|
|
3
|
+
Quick start
|
|
4
|
+
-----------
|
|
5
|
+
>>> import phytreon as pt
|
|
6
|
+
>>> tr = pt.datasets.primates()
|
|
7
|
+
>>> (pt.TreeFigure(tr)
|
|
8
|
+
... .tip_labels()
|
|
9
|
+
... .support_labels()).save("tree.pdf")
|
|
10
|
+
|
|
11
|
+
The package is layered:
|
|
12
|
+
|
|
13
|
+
* ``phytreon.core`` -- ``Tree`` / ``Node`` data model and I/O
|
|
14
|
+
* ``phytreon.layout`` -- topology -> display coordinates
|
|
15
|
+
* ``phytreon.infer`` -- distance-based inference (NJ / UPGMA), ML, parsimony
|
|
16
|
+
* ``phytreon.comparative`` -- ancestral state reconstruction
|
|
17
|
+
* ``phytreon.plot`` -- the ``TreeFigure`` builder + matplotlib/plotly backends
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from . import core, layout, infer, comparative, plot, datasets, treeops
|
|
22
|
+
from .core import Tree, Node
|
|
23
|
+
from .plot import TreeFigure
|
|
24
|
+
from .infer import (
|
|
25
|
+
neighbor_joining, upgma, tree_from_alignment, distance_matrix,
|
|
26
|
+
Alignment, align, read_fasta, trim, bootstrap_support, build_tree, infer_ml,
|
|
27
|
+
ml_tree, log_likelihood, model_finder, parsimony_tree, parsimony_score,
|
|
28
|
+
)
|
|
29
|
+
from .comparative import ace_parsimony, ace_ml, ace_continuous, stochastic_map
|
|
30
|
+
from .treeops import (
|
|
31
|
+
rotate, flip, swap_children, ladderize, collapse_low_support,
|
|
32
|
+
scale_clade, cut_tree, midpoint_root, group_clade, group_otu,
|
|
33
|
+
robinson_foulds,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.1"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"core", "layout", "infer", "comparative", "plot", "datasets",
|
|
40
|
+
"Tree", "Node",
|
|
41
|
+
"TreeFigure",
|
|
42
|
+
"neighbor_joining", "upgma", "tree_from_alignment", "distance_matrix",
|
|
43
|
+
"Alignment", "align", "read_fasta", "trim", "bootstrap_support",
|
|
44
|
+
"build_tree", "infer_ml", "ml_tree", "log_likelihood", "model_finder",
|
|
45
|
+
"parsimony_tree", "parsimony_score", "robinson_foulds",
|
|
46
|
+
"ace_parsimony", "ace_ml", "ace_continuous", "stochastic_map",
|
|
47
|
+
"rotate", "flip", "swap_children", "ladderize", "collapse_low_support",
|
|
48
|
+
"scale_clade", "cut_tree", "midpoint_root", "group_clade", "group_otu",
|
|
49
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Ancestral character estimation.
|
|
2
|
+
|
|
3
|
+
Two discrete-trait reconstructions are provided:
|
|
4
|
+
|
|
5
|
+
* :func:`ace_parsimony` -- Fitch parsimony (fast, model-free).
|
|
6
|
+
* :func:`ace_ml` -- marginal maximum likelihood under an equal-rates
|
|
7
|
+
Mk model (Felsenstein pruning for the likelihood, an up/down pass for
|
|
8
|
+
the marginal posterior at every internal node, single rate estimated
|
|
9
|
+
by ML).
|
|
10
|
+
|
|
11
|
+
Both write results onto ``node.data`` so the plotting layer can map them
|
|
12
|
+
to aesthetics (e.g. pie charts / coloured node points at ancestors).
|
|
13
|
+
Continuous-trait reconstruction (PIC / REML) is a documented extension
|
|
14
|
+
point -- see :func:`ace_continuous`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from ..core.tree import Node, Tree
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------
|
|
24
|
+
# Fitch parsimony
|
|
25
|
+
# --------------------------------------------------------------------------
|
|
26
|
+
def ace_parsimony(tree: Tree, trait: Dict[str, str]) -> Dict[str, object]:
|
|
27
|
+
"""Fitch parsimony reconstruction of a discrete trait.
|
|
28
|
+
|
|
29
|
+
``trait`` maps tip name -> state. Tips missing from the dict are
|
|
30
|
+
treated as fully ambiguous. Each node gets ``data['ace_state']``;
|
|
31
|
+
the function returns ``{"score": int, "states": [...]}``.
|
|
32
|
+
"""
|
|
33
|
+
states = sorted({v for v in trait.values()})
|
|
34
|
+
full = set(states)
|
|
35
|
+
|
|
36
|
+
# down-pass: state sets + score
|
|
37
|
+
score = 0
|
|
38
|
+
for node in tree.traverse("postorder"):
|
|
39
|
+
if node.is_leaf:
|
|
40
|
+
s = trait.get(node.name)
|
|
41
|
+
node.data["_pset"] = {s} if s is not None else set(full)
|
|
42
|
+
else:
|
|
43
|
+
sets = [c.data["_pset"] for c in node.children]
|
|
44
|
+
inter = set.intersection(*sets) if sets else set(full)
|
|
45
|
+
if inter:
|
|
46
|
+
node.data["_pset"] = inter
|
|
47
|
+
else:
|
|
48
|
+
node.data["_pset"] = set.union(*sets)
|
|
49
|
+
score += 1
|
|
50
|
+
|
|
51
|
+
# up-pass: resolve, preferring the parent's chosen state
|
|
52
|
+
for node in tree.traverse("preorder"):
|
|
53
|
+
pset = node.data.pop("_pset")
|
|
54
|
+
if node.is_root:
|
|
55
|
+
chosen = sorted(pset)[0]
|
|
56
|
+
else:
|
|
57
|
+
parent_state = node.parent.data["ace_state"]
|
|
58
|
+
chosen = parent_state if parent_state in pset else sorted(pset)[0]
|
|
59
|
+
node.data["ace_state"] = chosen
|
|
60
|
+
|
|
61
|
+
return {"score": score, "states": states}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
# Marginal ML under an equal-rates Mk model
|
|
66
|
+
# --------------------------------------------------------------------------
|
|
67
|
+
def mk_q_builder(k: int, model: str = "ER"):
|
|
68
|
+
"""Return ``(build_Q, n_params, init)`` for an Mk rate matrix.
|
|
69
|
+
|
|
70
|
+
``model``: ``"ER"`` (equal rates, 1 param), ``"SYM"`` (symmetric,
|
|
71
|
+
k(k-1)/2 params), ``"ARD"`` (all rates different, k(k-1) params).
|
|
72
|
+
"""
|
|
73
|
+
import numpy as np
|
|
74
|
+
if model == "ER":
|
|
75
|
+
def build(p):
|
|
76
|
+
Q = np.full((k, k), p[0], dtype=float)
|
|
77
|
+
np.fill_diagonal(Q, 0.0)
|
|
78
|
+
for i in range(k):
|
|
79
|
+
Q[i, i] = -Q[i].sum()
|
|
80
|
+
return Q
|
|
81
|
+
return build, 1, [1.0]
|
|
82
|
+
if model == "SYM":
|
|
83
|
+
ut = [(i, j) for i in range(k) for j in range(i + 1, k)]
|
|
84
|
+
|
|
85
|
+
def build(p):
|
|
86
|
+
Q = np.zeros((k, k))
|
|
87
|
+
for v, (i, j) in zip(p, ut):
|
|
88
|
+
Q[i, j] = Q[j, i] = v
|
|
89
|
+
for i in range(k):
|
|
90
|
+
Q[i, i] = -Q[i].sum()
|
|
91
|
+
return Q
|
|
92
|
+
return build, len(ut), [1.0] * len(ut)
|
|
93
|
+
if model == "ARD":
|
|
94
|
+
pairs = [(i, j) for i in range(k) for j in range(k) if i != j]
|
|
95
|
+
|
|
96
|
+
def build(p):
|
|
97
|
+
Q = np.zeros((k, k))
|
|
98
|
+
for v, (i, j) in zip(p, pairs):
|
|
99
|
+
Q[i, j] = v
|
|
100
|
+
for i in range(k):
|
|
101
|
+
Q[i, i] = -Q[i].sum()
|
|
102
|
+
return Q
|
|
103
|
+
return build, len(pairs), [1.0] * len(pairs)
|
|
104
|
+
raise ValueError(f"unknown Mk model {model!r}; use ER/SYM/ARD")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ace_ml(tree: Tree, trait: Dict[str, str], model: str = "ER",
|
|
108
|
+
default_length: float = 1.0) -> Dict[Node, Dict[str, float]]:
|
|
109
|
+
"""Marginal ML ancestral states under an Mk model (``ER``/``SYM``/``ARD``).
|
|
110
|
+
|
|
111
|
+
Returns ``{node: {state: posterior_prob}}`` for internal nodes and writes
|
|
112
|
+
``data['ace_probs']`` / ``data['ace_state']`` onto every node. Fitted
|
|
113
|
+
rates are stored on ``tree.root.data['_ace_model']``.
|
|
114
|
+
"""
|
|
115
|
+
import numpy as np
|
|
116
|
+
from scipy.linalg import expm
|
|
117
|
+
from scipy.optimize import minimize, minimize_scalar
|
|
118
|
+
|
|
119
|
+
states = sorted({v for v in trait.values()})
|
|
120
|
+
k = len(states)
|
|
121
|
+
idx = {s: i for i, s in enumerate(states)}
|
|
122
|
+
pi = np.full(k, 1.0 / k)
|
|
123
|
+
nodes = tree.nodes("postorder")
|
|
124
|
+
build_Q, nparams, init = mk_q_builder(k, model)
|
|
125
|
+
|
|
126
|
+
def branch_p(params, t):
|
|
127
|
+
return expm(build_Q(params) * t)
|
|
128
|
+
|
|
129
|
+
def down_partials(params) -> Dict[Node, "np.ndarray"]:
|
|
130
|
+
D: Dict[Node, np.ndarray] = {}
|
|
131
|
+
for node in nodes:
|
|
132
|
+
if node.is_leaf:
|
|
133
|
+
vec = np.zeros(k)
|
|
134
|
+
s = trait.get(node.name)
|
|
135
|
+
if s is None:
|
|
136
|
+
vec[:] = 1.0
|
|
137
|
+
else:
|
|
138
|
+
vec[idx[s]] = 1.0
|
|
139
|
+
D[node] = vec
|
|
140
|
+
else:
|
|
141
|
+
vec = np.ones(k)
|
|
142
|
+
for c in node.children:
|
|
143
|
+
vec = vec * (branch_p(params, c.length or default_length) @ D[c])
|
|
144
|
+
D[node] = vec
|
|
145
|
+
return D
|
|
146
|
+
|
|
147
|
+
def neg_log_lik(params) -> float:
|
|
148
|
+
if np.any(np.asarray(params) <= 0):
|
|
149
|
+
return 1e18
|
|
150
|
+
D = down_partials(params)
|
|
151
|
+
return -np.log(float(pi @ D[tree.root]) + 1e-300)
|
|
152
|
+
|
|
153
|
+
if nparams == 1:
|
|
154
|
+
r = float(minimize_scalar(lambda x: neg_log_lik([x]),
|
|
155
|
+
bounds=(1e-4, 100.0), method="bounded").x)
|
|
156
|
+
params = [r]
|
|
157
|
+
else:
|
|
158
|
+
params = list(minimize(neg_log_lik, init, method="Nelder-Mead",
|
|
159
|
+
options={"xatol": 1e-3, "fatol": 1e-3,
|
|
160
|
+
"maxiter": 400}).x)
|
|
161
|
+
|
|
162
|
+
D = down_partials(params)
|
|
163
|
+
U: Dict[Node, np.ndarray] = {tree.root: pi.copy()}
|
|
164
|
+
for node in tree.nodes("preorder"):
|
|
165
|
+
if node.is_root:
|
|
166
|
+
continue
|
|
167
|
+
p = node.parent
|
|
168
|
+
msg = U[p].copy()
|
|
169
|
+
for sib in p.children:
|
|
170
|
+
if sib is node:
|
|
171
|
+
continue
|
|
172
|
+
msg = msg * (branch_p(params, sib.length or default_length) @ D[sib])
|
|
173
|
+
U[node] = branch_p(params, node.length or default_length).T @ msg
|
|
174
|
+
|
|
175
|
+
result: Dict[Node, Dict[str, float]] = {}
|
|
176
|
+
for node in tree.traverse():
|
|
177
|
+
post = D[node] * U[node]
|
|
178
|
+
total = post.sum()
|
|
179
|
+
post = post / total if total > 0 else np.full(k, 1.0 / k)
|
|
180
|
+
probs = {states[i]: float(post[i]) for i in range(k)}
|
|
181
|
+
node.data["ace_probs"] = probs
|
|
182
|
+
node.data["ace_state"] = max(probs, key=probs.get)
|
|
183
|
+
if not node.is_leaf:
|
|
184
|
+
result[node] = probs
|
|
185
|
+
|
|
186
|
+
tree.root.data["_ace_model"] = {
|
|
187
|
+
"model": model, "rates": [float(p) for p in params], "states": states,
|
|
188
|
+
"rate": float(params[0]) if nparams == 1 else None,
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# Continuous traits -- extension point
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
def ace_continuous(tree: Tree, trait: Dict[str, float]) -> Dict[Node, float]:
|
|
197
|
+
"""Ancestral states for a continuous trait via Felsenstein's
|
|
198
|
+
independent-contrasts weighted averaging (Brownian motion).
|
|
199
|
+
|
|
200
|
+
Returns ``{node: estimated_value}`` and writes ``data['ace_value']``.
|
|
201
|
+
"""
|
|
202
|
+
# postorder: weighted average of children, weights = 1 / (branch len + accumulated var)
|
|
203
|
+
var: Dict[Node, float] = {}
|
|
204
|
+
val: Dict[Node, float] = {}
|
|
205
|
+
for node in tree.traverse("postorder"):
|
|
206
|
+
if node.is_leaf:
|
|
207
|
+
if node.name not in trait:
|
|
208
|
+
raise KeyError(f"missing trait value for tip {node.name!r}")
|
|
209
|
+
val[node] = float(trait[node.name])
|
|
210
|
+
var[node] = 0.0
|
|
211
|
+
else:
|
|
212
|
+
ws, xs, vs = [], [], []
|
|
213
|
+
for c in node.children:
|
|
214
|
+
v = (c.length or 1.0) + var[c]
|
|
215
|
+
ws.append(1.0 / v)
|
|
216
|
+
xs.append(val[c])
|
|
217
|
+
vs.append(v)
|
|
218
|
+
wsum = sum(ws)
|
|
219
|
+
val[node] = sum(w * x for w, x in zip(ws, xs)) / wsum
|
|
220
|
+
var[node] = 1.0 / wsum
|
|
221
|
+
for node in tree.traverse():
|
|
222
|
+
node.data["ace_value"] = val[node]
|
|
223
|
+
return val
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Stochastic character mapping.
|
|
2
|
+
|
|
3
|
+
Simulates discrete-trait histories along the branches under an Mk model,
|
|
4
|
+
conditioned on the observed tip states (Nielsen 2002; Huelsenbeck 2003):
|
|
5
|
+
|
|
6
|
+
1. fit the rate by ML and compute the down-pass conditional likelihoods,
|
|
7
|
+
2. for each replicate, sample internal node states (preorder, conditioned)
|
|
8
|
+
then simulate the CTMC path along every branch (rejection sampling),
|
|
9
|
+
3. summarise over replicates: per-node posterior state probabilities and
|
|
10
|
+
per-branch average dwell-time fractions.
|
|
11
|
+
|
|
12
|
+
Results are written to ``node.data`` (``ace_probs``, ``ace_state``,
|
|
13
|
+
``paint_segments``) so :meth:`TreeFigure.painted_branches` can paint the
|
|
14
|
+
branches and :meth:`TreeFigure.node_pies` can chart node posteriors.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Dict, Optional
|
|
19
|
+
|
|
20
|
+
from ..core.tree import Tree
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stochastic_map(tree: Tree, trait: Dict[str, str], n: int = 200,
|
|
24
|
+
model: str = "ER", rate: Optional[float] = None,
|
|
25
|
+
seed: Optional[int] = None) -> Tree:
|
|
26
|
+
"""Stochastic character map a discrete ``trait`` ({tip: state}) over ``tree``.
|
|
27
|
+
|
|
28
|
+
``model`` is the Mk model (``ER``/``SYM``/``ARD``); ``n`` is the number of
|
|
29
|
+
simulated histories (default 200 -- raise for smoother posteriors).
|
|
30
|
+
"""
|
|
31
|
+
import numpy as np
|
|
32
|
+
from scipy.linalg import expm
|
|
33
|
+
from scipy.optimize import minimize, minimize_scalar
|
|
34
|
+
from .ace import mk_q_builder
|
|
35
|
+
|
|
36
|
+
states = sorted({v for v in trait.values()})
|
|
37
|
+
k = len(states)
|
|
38
|
+
idx = {s: i for i, s in enumerate(states)}
|
|
39
|
+
pi = np.full(k, 1.0 / k)
|
|
40
|
+
post = tree.nodes("postorder")
|
|
41
|
+
build_Q, nparams, init = mk_q_builder(k, model)
|
|
42
|
+
|
|
43
|
+
def down(params):
|
|
44
|
+
Q = build_Q(params)
|
|
45
|
+
D = {}
|
|
46
|
+
for node in post:
|
|
47
|
+
if node.is_leaf:
|
|
48
|
+
v = np.zeros(k)
|
|
49
|
+
s = trait.get(node.name)
|
|
50
|
+
if s is None:
|
|
51
|
+
v[:] = 1.0
|
|
52
|
+
else:
|
|
53
|
+
v[idx[s]] = 1.0
|
|
54
|
+
D[id(node)] = v
|
|
55
|
+
else:
|
|
56
|
+
v = np.ones(k)
|
|
57
|
+
for c in node.children:
|
|
58
|
+
v = v * (expm(Q * max(c.length or 0.0, 1e-6)) @ D[id(c)])
|
|
59
|
+
D[id(node)] = v
|
|
60
|
+
return D
|
|
61
|
+
|
|
62
|
+
if rate is not None:
|
|
63
|
+
params = [rate] if nparams == 1 else [rate] * nparams
|
|
64
|
+
else:
|
|
65
|
+
def negll(params):
|
|
66
|
+
if np.any(np.asarray(params) <= 0):
|
|
67
|
+
return 1e18
|
|
68
|
+
return -np.log(pi @ down(params)[id(tree.root)] + 1e-300)
|
|
69
|
+
if nparams == 1:
|
|
70
|
+
params = [float(minimize_scalar(lambda r: negll([r]),
|
|
71
|
+
bounds=(1e-4, 100.0),
|
|
72
|
+
method="bounded").x)]
|
|
73
|
+
else:
|
|
74
|
+
params = list(minimize(negll, init, method="Nelder-Mead",
|
|
75
|
+
options={"maxiter": 400}).x)
|
|
76
|
+
|
|
77
|
+
D = down(params)
|
|
78
|
+
Q = build_Q(params)
|
|
79
|
+
rate = float(params[0])
|
|
80
|
+
P = {id(n): expm(Q * max(n.length or 0.0, 1e-6))
|
|
81
|
+
for n in tree.traverse() if not n.is_root}
|
|
82
|
+
rng = np.random.default_rng(seed)
|
|
83
|
+
|
|
84
|
+
counts = {id(n): np.zeros(k) for n in tree.traverse()}
|
|
85
|
+
dwell = {id(n): np.zeros(k) for n in tree.traverse() if not n.is_root}
|
|
86
|
+
|
|
87
|
+
def sim_branch(a, b, t):
|
|
88
|
+
for _ in range(50):
|
|
89
|
+
segs, cur, rem = [], a, t
|
|
90
|
+
while True:
|
|
91
|
+
q = -Q[cur, cur]
|
|
92
|
+
dt = rng.exponential(1.0 / q) if q > 0 else rem
|
|
93
|
+
if dt >= rem:
|
|
94
|
+
segs.append((cur, rem))
|
|
95
|
+
break
|
|
96
|
+
segs.append((cur, dt))
|
|
97
|
+
rem -= dt
|
|
98
|
+
pr = Q[cur].copy()
|
|
99
|
+
pr[cur] = 0.0
|
|
100
|
+
cur = int(rng.choice(k, p=pr / pr.sum()))
|
|
101
|
+
if cur == b:
|
|
102
|
+
return segs
|
|
103
|
+
return [(a, t / 2), (b, t / 2)] if a != b else [(a, t)]
|
|
104
|
+
|
|
105
|
+
pre = tree.nodes("preorder")
|
|
106
|
+
for _ in range(n):
|
|
107
|
+
s = {}
|
|
108
|
+
pr = pi * D[id(tree.root)]
|
|
109
|
+
s[id(tree.root)] = int(rng.choice(k, p=pr / pr.sum()))
|
|
110
|
+
counts[id(tree.root)][s[id(tree.root)]] += 1
|
|
111
|
+
for node in pre:
|
|
112
|
+
if node.is_root:
|
|
113
|
+
continue
|
|
114
|
+
sp = s[id(node.parent)]
|
|
115
|
+
pr = P[id(node)][sp] * D[id(node)]
|
|
116
|
+
si = int(rng.choice(k, p=pr / pr.sum()))
|
|
117
|
+
s[id(node)] = si
|
|
118
|
+
counts[id(node)][si] += 1
|
|
119
|
+
for st, dt in sim_branch(sp, si, max(node.length or 0.0, 1e-6)):
|
|
120
|
+
dwell[id(node)][st] += dt
|
|
121
|
+
|
|
122
|
+
for node in tree.traverse():
|
|
123
|
+
c = counts[id(node)]
|
|
124
|
+
node.data["ace_probs"] = {states[i]: float(c[i] / n) for i in range(k)}
|
|
125
|
+
node.data["ace_state"] = states[int(c.argmax())]
|
|
126
|
+
for node in tree.traverse():
|
|
127
|
+
if node.is_root:
|
|
128
|
+
continue
|
|
129
|
+
d = dwell[id(node)]
|
|
130
|
+
tot = d.sum() or 1.0
|
|
131
|
+
node.data["paint_segments"] = [(states[i], float(d[i] / tot))
|
|
132
|
+
for i in range(k) if d[i] > 0]
|
|
133
|
+
tree.data["stochastic_map"] = {"rate": rate, "states": states, "n": n}
|
|
134
|
+
return tree
|
phytreon/core/io.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Tree input/output.
|
|
2
|
+
|
|
3
|
+
We reuse Biopython's robust parsers/writers for the standard formats
|
|
4
|
+
(Newick, Nexus, PhyloXML, NeXML) and convert to/from our :class:`Tree`.
|
|
5
|
+
A tiny self-contained Newick parser is also provided so that the package
|
|
6
|
+
has a zero-config fast path for the common case (and so the data model
|
|
7
|
+
can be used even if Biopython is unavailable).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io as _io
|
|
12
|
+
import re
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .tree import Node, Tree
|
|
16
|
+
|
|
17
|
+
_BIO_FORMATS = {"newick", "nexus", "phyloxml", "nexml", "cdao"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --------------------------------------------------------------------------
|
|
21
|
+
# Biopython bridge
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
def from_biopython(bp_tree) -> Tree:
|
|
24
|
+
"""Convert a ``Bio.Phylo`` tree into a phytreon :class:`Tree`."""
|
|
25
|
+
def convert(clade) -> Node:
|
|
26
|
+
node = Node(
|
|
27
|
+
name=clade.name,
|
|
28
|
+
length=clade.branch_length,
|
|
29
|
+
support=getattr(clade, "confidence", None),
|
|
30
|
+
comment=getattr(clade, "comment", None),
|
|
31
|
+
)
|
|
32
|
+
for child in clade.clades:
|
|
33
|
+
node.add_child(convert(child))
|
|
34
|
+
return node
|
|
35
|
+
|
|
36
|
+
root = convert(bp_tree.root)
|
|
37
|
+
return Tree(root=root, name=getattr(bp_tree, "name", None))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_biopython(tree: Tree):
|
|
41
|
+
from Bio.Phylo.BaseTree import Clade, Tree as BioTree
|
|
42
|
+
|
|
43
|
+
def convert(node: Node) -> Clade:
|
|
44
|
+
clade = Clade(
|
|
45
|
+
branch_length=node.length,
|
|
46
|
+
name=node.name,
|
|
47
|
+
confidence=node.support,
|
|
48
|
+
)
|
|
49
|
+
clade.clades = [convert(c) for c in node.children]
|
|
50
|
+
return clade
|
|
51
|
+
|
|
52
|
+
return BioTree(root=convert(tree.root), name=tree.name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read(path: str, fmt: str = "newick") -> Tree:
|
|
56
|
+
if fmt not in _BIO_FORMATS:
|
|
57
|
+
raise ValueError(f"unsupported format {fmt!r}; choose from {_BIO_FORMATS}")
|
|
58
|
+
from Bio import Phylo
|
|
59
|
+
return from_biopython(Phylo.read(path, fmt))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write(tree: Tree, path: Optional[str] = None, fmt: str = "newick") -> Optional[str]:
|
|
63
|
+
"""Write to ``path``; if ``path`` is ``None`` return the string."""
|
|
64
|
+
if fmt == "newick" and path is None:
|
|
65
|
+
return to_newick(tree)
|
|
66
|
+
from Bio import Phylo
|
|
67
|
+
bp = to_biopython(tree)
|
|
68
|
+
if path is None:
|
|
69
|
+
buf = _io.StringIO()
|
|
70
|
+
Phylo.write(bp, buf, fmt)
|
|
71
|
+
return buf.getvalue()
|
|
72
|
+
Phylo.write(bp, path, fmt)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
# Lightweight self-contained Newick parser / writer
|
|
78
|
+
# --------------------------------------------------------------------------
|
|
79
|
+
_TOKEN = re.compile(r"\s*([(),;])\s*|\s*([^(),;]+)\s*")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_newick(newick: str) -> Tree:
|
|
83
|
+
"""Parse a Newick string into a :class:`Tree` (no external deps).
|
|
84
|
+
|
|
85
|
+
Supports branch lengths (``name:0.1``), internal labels / support
|
|
86
|
+
values, and quoted names. Square-bracket comments (e.g. NHX) are
|
|
87
|
+
captured verbatim onto ``node.comment``.
|
|
88
|
+
"""
|
|
89
|
+
s = newick.strip()
|
|
90
|
+
if not s.endswith(";"):
|
|
91
|
+
s += ";"
|
|
92
|
+
|
|
93
|
+
pos = 0
|
|
94
|
+
n = len(s)
|
|
95
|
+
|
|
96
|
+
def parse_clade() -> Node:
|
|
97
|
+
nonlocal pos
|
|
98
|
+
node = Node()
|
|
99
|
+
if s[pos] == "(":
|
|
100
|
+
pos += 1 # consume '('
|
|
101
|
+
while True:
|
|
102
|
+
node.add_child(parse_clade())
|
|
103
|
+
if s[pos] == ",":
|
|
104
|
+
pos += 1
|
|
105
|
+
continue
|
|
106
|
+
if s[pos] == ")":
|
|
107
|
+
pos += 1
|
|
108
|
+
break
|
|
109
|
+
_parse_label(node)
|
|
110
|
+
return node
|
|
111
|
+
|
|
112
|
+
def _parse_label(node: Node) -> None:
|
|
113
|
+
nonlocal pos
|
|
114
|
+
# optional name/support
|
|
115
|
+
start = pos
|
|
116
|
+
while pos < n and s[pos] not in "():,;[":
|
|
117
|
+
pos += 1
|
|
118
|
+
label = s[start:pos].strip().strip("'\"")
|
|
119
|
+
if label:
|
|
120
|
+
if node.is_leaf:
|
|
121
|
+
node.name = label
|
|
122
|
+
else:
|
|
123
|
+
# internal label is usually a support value
|
|
124
|
+
try:
|
|
125
|
+
node.support = float(label)
|
|
126
|
+
except ValueError:
|
|
127
|
+
node.name = label
|
|
128
|
+
# optional comment [...]
|
|
129
|
+
if pos < n and s[pos] == "[":
|
|
130
|
+
depth, cstart = 0, pos + 1
|
|
131
|
+
while pos < n:
|
|
132
|
+
if s[pos] == "[":
|
|
133
|
+
depth += 1
|
|
134
|
+
elif s[pos] == "]":
|
|
135
|
+
depth -= 1
|
|
136
|
+
if depth == 0:
|
|
137
|
+
node.comment = s[cstart:pos]
|
|
138
|
+
pos += 1
|
|
139
|
+
break
|
|
140
|
+
pos += 1
|
|
141
|
+
# optional branch length
|
|
142
|
+
if pos < n and s[pos] == ":":
|
|
143
|
+
pos += 1
|
|
144
|
+
start = pos
|
|
145
|
+
while pos < n and s[pos] not in "():,;[":
|
|
146
|
+
pos += 1
|
|
147
|
+
try:
|
|
148
|
+
node.length = float(s[start:pos])
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
root = parse_clade()
|
|
153
|
+
return Tree(root=root)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def to_newick(tree: Tree, with_support: bool = True) -> str:
|
|
157
|
+
"""Serialise a :class:`Tree` back to a Newick string."""
|
|
158
|
+
def fmt(node: Node) -> str:
|
|
159
|
+
if node.is_leaf:
|
|
160
|
+
label = node.name or ""
|
|
161
|
+
else:
|
|
162
|
+
inner = ",".join(fmt(c) for c in node.children)
|
|
163
|
+
sup = ""
|
|
164
|
+
if with_support and node.support is not None:
|
|
165
|
+
sup = _num(node.support)
|
|
166
|
+
elif node.name:
|
|
167
|
+
sup = node.name
|
|
168
|
+
label = f"({inner}){sup}"
|
|
169
|
+
if node.length is not None:
|
|
170
|
+
label += f":{_num(node.length)}"
|
|
171
|
+
return label
|
|
172
|
+
|
|
173
|
+
return fmt(tree.root) + ";"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _num(x: float) -> str:
|
|
177
|
+
return f"{x:g}"
|