CodonAdaptPy 1.0.0__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.
- codonadaptpy/__init__.py +67 -0
- codonadaptpy/__main__.py +17 -0
- codonadaptpy/__version__.py +21 -0
- codonadaptpy/aggregation.py +94 -0
- codonadaptpy/analyzer.py +270 -0
- codonadaptpy/cli.py +409 -0
- codonadaptpy/comparative.py +538 -0
- codonadaptpy/exceptions.py +39 -0
- codonadaptpy/genetic_code.py +115 -0
- codonadaptpy/metrics/__init__.py +28 -0
- codonadaptpy/metrics/adaptation.py +133 -0
- codonadaptpy/metrics/composition.py +173 -0
- codonadaptpy/metrics/evolutionary.py +60 -0
- codonadaptpy/metrics/host.py +145 -0
- codonadaptpy/metrics/pairs.py +107 -0
- codonadaptpy/metrics/usage.py +155 -0
- codonadaptpy/models.py +128 -0
- codonadaptpy/optimizer.py +194 -0
- codonadaptpy/parsers.py +220 -0
- codonadaptpy/phylo_visualization.py +334 -0
- codonadaptpy/phylogenetics.py +602 -0
- codonadaptpy/py.typed +1 -0
- codonadaptpy/references.py +169 -0
- codonadaptpy/reporting.py +174 -0
- codonadaptpy/validation.py +169 -0
- codonadaptpy/visualization.py +1073 -0
- codonadaptpy-1.0.0.dist-info/METADATA +346 -0
- codonadaptpy-1.0.0.dist-info/RECORD +32 -0
- codonadaptpy-1.0.0.dist-info/WHEEL +5 -0
- codonadaptpy-1.0.0.dist-info/entry_points.txt +2 -0
- codonadaptpy-1.0.0.dist-info/licenses/LICENSE +14 -0
- codonadaptpy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Phylogenetic and Temporal Analysis
|
|
3
|
+
|
|
4
|
+
This module provides class-based wrappers for reproducible nucleotide or
|
|
5
|
+
protein phylogenetics and lightweight temporal exploration. External programs
|
|
6
|
+
are invoked with argument lists rather than shell strings, their commands and
|
|
7
|
+
outputs are retained, and failures include captured diagnostic text.
|
|
8
|
+
|
|
9
|
+
The temporal methods intentionally do not claim to perform Bayesian dating. A
|
|
10
|
+
root-to-tip regression estimates an exploratory strict-clock rate and root
|
|
11
|
+
date, while lineage-through-time summaries describe the dated tree topology.
|
|
12
|
+
They provide fast, deterministic tools for quality control and figure
|
|
13
|
+
generation, not Bayesian posterior estimates, relaxed-clock inference, or
|
|
14
|
+
effective-population-size highest-posterior-density intervals.
|
|
15
|
+
|
|
16
|
+
Classes:
|
|
17
|
+
- PhylogeneticAnalyzer: FASTA writing, MAFFT alignment, optional trimAL,
|
|
18
|
+
and IQ-TREE or FastTree inference.
|
|
19
|
+
- TemporalAnalyzer: Root-to-tip temporal signal, deterministic time
|
|
20
|
+
scaling, and lineage-through-time summaries using ETE3 trees.
|
|
21
|
+
- PhylogeneticComparator: Shared-tip Robinson-Foulds topology comparison.
|
|
22
|
+
|
|
23
|
+
:Created: July 20, 2026
|
|
24
|
+
:Updated: July 20, 2026
|
|
25
|
+
:Author: Naveen Duhan
|
|
26
|
+
:Version: 1.0.0
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import random
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
from collections import defaultdict
|
|
36
|
+
from collections.abc import Iterable, Mapping
|
|
37
|
+
from dataclasses import asdict, dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Literal
|
|
40
|
+
|
|
41
|
+
from .models import SequenceRecord
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class PhylogenyResult:
|
|
46
|
+
"""Record the files, method, and commands produced by one tree run."""
|
|
47
|
+
|
|
48
|
+
input_fasta: str
|
|
49
|
+
alignment: str
|
|
50
|
+
tree: str
|
|
51
|
+
method: str
|
|
52
|
+
model: str
|
|
53
|
+
commands: list[list[str]]
|
|
54
|
+
trimmed_alignment: str | None = None
|
|
55
|
+
report: str | None = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Return a JSON-compatible representation of the run."""
|
|
59
|
+
|
|
60
|
+
return asdict(self)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class TemporalSignalResult:
|
|
65
|
+
"""Store root-to-tip regression statistics and sample-level diagnostics."""
|
|
66
|
+
|
|
67
|
+
rate: float
|
|
68
|
+
intercept: float
|
|
69
|
+
root_date: float
|
|
70
|
+
r_value: float
|
|
71
|
+
r_squared: float
|
|
72
|
+
p_value: float
|
|
73
|
+
p_value_kind: str
|
|
74
|
+
regression_p_value_unadjusted: float
|
|
75
|
+
permutation_p_value: float | None
|
|
76
|
+
permutations: int
|
|
77
|
+
random_seed: int | None
|
|
78
|
+
standard_error: float
|
|
79
|
+
sample_dates: dict[str, float]
|
|
80
|
+
root_to_tip: dict[str, float]
|
|
81
|
+
fitted_distances: dict[str, float]
|
|
82
|
+
residuals: dict[str, float]
|
|
83
|
+
rooting: str = "midpoint"
|
|
84
|
+
outgroup_leaves: list[str] | None = None
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
"""Return a JSON-compatible representation of the regression."""
|
|
88
|
+
|
|
89
|
+
return asdict(self)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PhylogeneticAnalyzer:
|
|
93
|
+
"""Run alignment and maximum-likelihood phylogenetic inference.
|
|
94
|
+
|
|
95
|
+
Parameters:
|
|
96
|
+
mafft: MAFFT executable name or path.
|
|
97
|
+
iqtree: IQ-TREE 2 executable name or path. Both ``iqtree2`` and the
|
|
98
|
+
frequently packaged ``iqtree`` name are discovered automatically.
|
|
99
|
+
fasttree: FastTree executable name or path.
|
|
100
|
+
trimal: Optional trimAL executable name or path.
|
|
101
|
+
|
|
102
|
+
Notes:
|
|
103
|
+
External tools remain optional package dependencies. Methods raise an
|
|
104
|
+
informative :class:`RuntimeError` when the selected executable is not
|
|
105
|
+
available. IQ-TREE ultrafast bootstrap values should generally use at
|
|
106
|
+
least 1,000 replicates for a publication analysis.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
mafft: str = "mafft",
|
|
113
|
+
iqtree: str | None = None,
|
|
114
|
+
fasttree: str | None = None,
|
|
115
|
+
trimal: str = "trimal",
|
|
116
|
+
) -> None:
|
|
117
|
+
self.mafft = mafft
|
|
118
|
+
self.iqtree = iqtree or shutil.which("iqtree2") or shutil.which("iqtree") or "iqtree2"
|
|
119
|
+
self.fasttree = fasttree or shutil.which("FastTree") or shutil.which("fasttree") or "FastTree"
|
|
120
|
+
self.trimal = trimal
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def write_fasta(records: Iterable[SequenceRecord], destination: str | Path) -> Path:
|
|
124
|
+
"""Write unique, non-empty sequence records as wrapped FASTA."""
|
|
125
|
+
|
|
126
|
+
output = Path(destination)
|
|
127
|
+
materialized = list(records)
|
|
128
|
+
if len(materialized) < 3:
|
|
129
|
+
raise ValueError("At least three sequences are required for phylogenetic inference.")
|
|
130
|
+
identifiers = [record.identifier for record in materialized]
|
|
131
|
+
if len(set(identifiers)) != len(identifiers):
|
|
132
|
+
raise ValueError("Sequence identifiers must be unique for phylogenetic inference.")
|
|
133
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with output.open("w", encoding="utf-8") as handle:
|
|
135
|
+
for record in materialized:
|
|
136
|
+
sequence = "".join(record.sequence.split()).upper()
|
|
137
|
+
if not sequence:
|
|
138
|
+
raise ValueError(f"Sequence {record.identifier!r} is empty.")
|
|
139
|
+
handle.write(f">{record.identifier}\n")
|
|
140
|
+
for index in range(0, len(sequence), 80):
|
|
141
|
+
handle.write(sequence[index : index + 80] + "\n")
|
|
142
|
+
return output
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _execute(command: list[str], *, stdout: Path | None = None) -> None:
|
|
146
|
+
"""Execute one external program and raise a diagnostic-rich error."""
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
if stdout is None:
|
|
150
|
+
completed = subprocess.run(command, check=False, capture_output=True, text=True)
|
|
151
|
+
else:
|
|
152
|
+
stdout.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
with stdout.open("w", encoding="utf-8") as handle:
|
|
154
|
+
completed = subprocess.run(command, check=False, stdout=handle, stderr=subprocess.PIPE, text=True)
|
|
155
|
+
except FileNotFoundError as exc:
|
|
156
|
+
raise RuntimeError(f"Required executable was not found: {command[0]}") from exc
|
|
157
|
+
if completed.returncode != 0:
|
|
158
|
+
diagnostic = (completed.stderr or completed.stdout or "No diagnostic output.").strip()
|
|
159
|
+
raise RuntimeError(f"Command failed ({completed.returncode}): {' '.join(command)}\n{diagnostic}")
|
|
160
|
+
|
|
161
|
+
def align(
|
|
162
|
+
self,
|
|
163
|
+
input_fasta: str | Path,
|
|
164
|
+
output_fasta: str | Path,
|
|
165
|
+
*,
|
|
166
|
+
method: Literal["auto", "linsi", "einsi", "fftnsi", "fftns"] = "auto",
|
|
167
|
+
threads: int = 1,
|
|
168
|
+
) -> tuple[Path, list[str]]:
|
|
169
|
+
"""Align sequences with MAFFT and return the output path and command."""
|
|
170
|
+
|
|
171
|
+
methods = {
|
|
172
|
+
"auto": ["--auto"],
|
|
173
|
+
"linsi": ["--localpair", "--maxiterate", "1000"],
|
|
174
|
+
"einsi": ["--genafpair", "--maxiterate", "1000"],
|
|
175
|
+
"fftnsi": ["--fftnsi"],
|
|
176
|
+
"fftns": ["--fftns"],
|
|
177
|
+
}
|
|
178
|
+
if method not in methods:
|
|
179
|
+
raise ValueError(f"Unsupported MAFFT method: {method}")
|
|
180
|
+
if threads < 1:
|
|
181
|
+
raise ValueError("threads must be at least one.")
|
|
182
|
+
command = [self.mafft, *methods[method], "--thread", str(threads), "--inputorder", str(input_fasta)]
|
|
183
|
+
output = Path(output_fasta)
|
|
184
|
+
self._execute(command, stdout=output)
|
|
185
|
+
return output, command
|
|
186
|
+
|
|
187
|
+
def trim(
|
|
188
|
+
self,
|
|
189
|
+
alignment: str | Path,
|
|
190
|
+
destination: str | Path,
|
|
191
|
+
*,
|
|
192
|
+
mode: Literal["automated1", "gappyout", "strict"] = "automated1",
|
|
193
|
+
required: bool = False,
|
|
194
|
+
) -> tuple[Path, list[str] | None]:
|
|
195
|
+
"""Trim an alignment with trimAL, or retain it when trimming is optional."""
|
|
196
|
+
|
|
197
|
+
source = Path(alignment)
|
|
198
|
+
output = Path(destination)
|
|
199
|
+
executable = shutil.which(self.trimal)
|
|
200
|
+
if executable is None:
|
|
201
|
+
if required:
|
|
202
|
+
raise RuntimeError(f"Required executable was not found: {self.trimal}")
|
|
203
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
shutil.copyfile(source, output)
|
|
205
|
+
return output, None
|
|
206
|
+
command = [executable, f"-{mode}", "-in", str(source), "-out", str(output), "-fasta"]
|
|
207
|
+
self._execute(command)
|
|
208
|
+
return output, command
|
|
209
|
+
|
|
210
|
+
def infer_tree(
|
|
211
|
+
self,
|
|
212
|
+
alignment: str | Path,
|
|
213
|
+
output_prefix: str | Path,
|
|
214
|
+
*,
|
|
215
|
+
method: Literal["iqtree", "fasttree"] = "iqtree",
|
|
216
|
+
sequence_type: Literal["nt", "aa"] = "nt",
|
|
217
|
+
model: str = "MFP",
|
|
218
|
+
bootstrap: int = 1000,
|
|
219
|
+
threads: int = 1,
|
|
220
|
+
) -> tuple[Path, str | None, list[str]]:
|
|
221
|
+
"""Infer an ML tree with IQ-TREE ModelFinder or approximate FastTree."""
|
|
222
|
+
|
|
223
|
+
source = Path(alignment)
|
|
224
|
+
prefix = Path(output_prefix)
|
|
225
|
+
prefix.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
if method == "iqtree":
|
|
227
|
+
if bootstrap < 1000:
|
|
228
|
+
raise ValueError("IQ-TREE ultrafast bootstrap must be at least 1,000.")
|
|
229
|
+
command = [
|
|
230
|
+
self.iqtree,
|
|
231
|
+
"-s",
|
|
232
|
+
str(source),
|
|
233
|
+
"--prefix",
|
|
234
|
+
str(prefix),
|
|
235
|
+
"-m",
|
|
236
|
+
model,
|
|
237
|
+
"-B",
|
|
238
|
+
str(bootstrap),
|
|
239
|
+
"-T",
|
|
240
|
+
str(threads),
|
|
241
|
+
"--redo",
|
|
242
|
+
]
|
|
243
|
+
self._execute(command)
|
|
244
|
+
tree = Path(f"{prefix}.treefile")
|
|
245
|
+
return tree, str(Path(f"{prefix}.iqtree")), command
|
|
246
|
+
if method != "fasttree":
|
|
247
|
+
raise ValueError(f"Unsupported tree method: {method}")
|
|
248
|
+
tree = Path(f"{prefix}.tree")
|
|
249
|
+
command = (
|
|
250
|
+
[self.fasttree, "-nt", "-gtr", str(source)]
|
|
251
|
+
if sequence_type == "nt"
|
|
252
|
+
else [self.fasttree, "-lg", str(source)]
|
|
253
|
+
)
|
|
254
|
+
self._execute(command, stdout=tree)
|
|
255
|
+
return tree, None, command
|
|
256
|
+
|
|
257
|
+
def run(
|
|
258
|
+
self,
|
|
259
|
+
records: Iterable[SequenceRecord],
|
|
260
|
+
output_directory: str | Path,
|
|
261
|
+
*,
|
|
262
|
+
name: str = "phylogeny",
|
|
263
|
+
align_method: Literal["auto", "linsi", "einsi", "fftnsi", "fftns"] = "auto",
|
|
264
|
+
tree_method: Literal["iqtree", "fasttree"] = "iqtree",
|
|
265
|
+
trim: bool = True,
|
|
266
|
+
trim_required: bool = False,
|
|
267
|
+
model: str = "MFP",
|
|
268
|
+
bootstrap: int = 1000,
|
|
269
|
+
threads: int = 1,
|
|
270
|
+
) -> PhylogenyResult:
|
|
271
|
+
"""Run FASTA export, alignment, optional trimming, and tree inference."""
|
|
272
|
+
|
|
273
|
+
directory = Path(output_directory)
|
|
274
|
+
fasta = self.write_fasta(records, directory / f"{name}.fasta")
|
|
275
|
+
alignment, align_command = self.align(
|
|
276
|
+
fasta,
|
|
277
|
+
directory / f"{name}.aligned.fasta",
|
|
278
|
+
method=align_method,
|
|
279
|
+
threads=threads,
|
|
280
|
+
)
|
|
281
|
+
commands = [align_command]
|
|
282
|
+
analysis_alignment = alignment
|
|
283
|
+
trimmed: Path | None = None
|
|
284
|
+
if trim:
|
|
285
|
+
trimmed, trim_command = self.trim(
|
|
286
|
+
alignment,
|
|
287
|
+
directory / f"{name}.trimmed.fasta",
|
|
288
|
+
required=trim_required,
|
|
289
|
+
)
|
|
290
|
+
analysis_alignment = trimmed
|
|
291
|
+
if trim_command:
|
|
292
|
+
commands.append(trim_command)
|
|
293
|
+
tree, report, tree_command = self.infer_tree(
|
|
294
|
+
analysis_alignment,
|
|
295
|
+
directory / name,
|
|
296
|
+
method=tree_method,
|
|
297
|
+
model=model,
|
|
298
|
+
bootstrap=bootstrap,
|
|
299
|
+
threads=threads,
|
|
300
|
+
)
|
|
301
|
+
commands.append(tree_command)
|
|
302
|
+
return PhylogenyResult(
|
|
303
|
+
input_fasta=str(fasta),
|
|
304
|
+
alignment=str(alignment),
|
|
305
|
+
trimmed_alignment=str(trimmed) if trimmed else None,
|
|
306
|
+
tree=str(tree),
|
|
307
|
+
method=tree_method,
|
|
308
|
+
model=model if tree_method == "iqtree" else "GTR/CAT approximation",
|
|
309
|
+
commands=commands,
|
|
310
|
+
report=report,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TemporalAnalyzer:
|
|
315
|
+
"""Estimate temporal signal and deterministic dated-tree summaries.
|
|
316
|
+
|
|
317
|
+
ETE3 is imported lazily so codon-only workflows remain dependency free.
|
|
318
|
+
The input tree should contain branch lengths in substitutions per site.
|
|
319
|
+
Sampling dates are decimal calendar years keyed by exact leaf names.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _import_ete3() -> Any:
|
|
324
|
+
"""Import ETE3 Tree with Python >= 3.13 cgi compatibility."""
|
|
325
|
+
import sys
|
|
326
|
+
import types
|
|
327
|
+
|
|
328
|
+
if "cgi" not in sys.modules:
|
|
329
|
+
dummy_cgi = types.ModuleType("cgi")
|
|
330
|
+
dummy_cgi.escape = (
|
|
331
|
+
lambda text, quote=True: text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
332
|
+
)
|
|
333
|
+
sys.modules["cgi"] = dummy_cgi
|
|
334
|
+
from ete3 import Tree
|
|
335
|
+
|
|
336
|
+
return Tree
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def load_tree(cls, tree: str | Path | Any, *, midpoint_root: bool = True) -> Any:
|
|
340
|
+
"""Load or copy an ETE3 tree and optionally apply midpoint rooting."""
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
Tree = cls._import_ete3()
|
|
344
|
+
except (ImportError, ModuleNotFoundError) as exc:
|
|
345
|
+
raise RuntimeError("Temporal phylogenetics requires ete3; install the 'phylo' extra.") from exc
|
|
346
|
+
tree_object: Any = tree
|
|
347
|
+
if hasattr(tree_object, "traverse"):
|
|
348
|
+
loaded = tree_object.copy(method="deepcopy")
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
loaded = Tree(str(tree), format=0)
|
|
352
|
+
except Exception:
|
|
353
|
+
loaded = Tree(str(tree), format=1)
|
|
354
|
+
if midpoint_root and len(loaded) > 2:
|
|
355
|
+
outgroup = loaded.get_midpoint_outgroup()
|
|
356
|
+
if outgroup is not None:
|
|
357
|
+
loaded.set_outgroup(outgroup)
|
|
358
|
+
return loaded
|
|
359
|
+
|
|
360
|
+
def temporal_signal(
|
|
361
|
+
self,
|
|
362
|
+
tree: str | Path | Any,
|
|
363
|
+
sample_dates: Mapping[str, float | int],
|
|
364
|
+
*,
|
|
365
|
+
strata: Mapping[str, Any] | None = None,
|
|
366
|
+
midpoint_root: bool = True,
|
|
367
|
+
optimize_root: bool = False,
|
|
368
|
+
permutations: int = 0,
|
|
369
|
+
random_seed: int = 1,
|
|
370
|
+
) -> TemporalSignalResult:
|
|
371
|
+
"""Regress root-to-tip divergence on sampling date.
|
|
372
|
+
|
|
373
|
+
A positive slope is interpreted as an exploratory strict-clock rate.
|
|
374
|
+
When ``permutations`` is positive, sampling dates are randomized and
|
|
375
|
+
the complete root-selection procedure is repeated for every replicate.
|
|
376
|
+
When ``strata`` is provided, dates are permuted strictly within each
|
|
377
|
+
stratum (e.g. genotype or lineage) to avoid confounding between group
|
|
378
|
+
membership and sampling date. The resulting empirical p value therefore
|
|
379
|
+
accounts for optimization over candidate roots. At least three dated tips
|
|
380
|
+
and two distinct sampling years are required.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
loaded = self.load_tree(tree, midpoint_root=False)
|
|
384
|
+
usable = {
|
|
385
|
+
leaf.name: float(sample_dates[leaf.name])
|
|
386
|
+
for leaf in loaded.iter_leaves()
|
|
387
|
+
if leaf.name in sample_dates and math.isfinite(float(sample_dates[leaf.name]))
|
|
388
|
+
}
|
|
389
|
+
if len(usable) < 3 or len(set(usable.values())) < 2:
|
|
390
|
+
raise ValueError("Temporal analysis requires at least three tips across two sampling dates.")
|
|
391
|
+
if permutations < 0:
|
|
392
|
+
raise ValueError("permutations cannot be negative.")
|
|
393
|
+
try:
|
|
394
|
+
from scipy.stats import linregress
|
|
395
|
+
except ImportError as exc:
|
|
396
|
+
raise RuntimeError("Temporal regression requires scipy; install the 'analysis' extra.") from exc
|
|
397
|
+
candidates: list[tuple[str, list[str] | None, Any]] = [("current", None, loaded)]
|
|
398
|
+
if midpoint_root and len(loaded) > 2:
|
|
399
|
+
midpoint_tree = loaded.copy(method="deepcopy")
|
|
400
|
+
outgroup = midpoint_tree.get_midpoint_outgroup()
|
|
401
|
+
if outgroup is not None:
|
|
402
|
+
leaves = [leaf.name for leaf in outgroup.iter_leaves()]
|
|
403
|
+
midpoint_tree.set_outgroup(outgroup)
|
|
404
|
+
candidates.append(("midpoint", leaves, midpoint_tree))
|
|
405
|
+
if optimize_root:
|
|
406
|
+
for index, node in enumerate(loaded.traverse("preorder")):
|
|
407
|
+
if node.is_root():
|
|
408
|
+
continue
|
|
409
|
+
leaf_names = [leaf.name for leaf in node.iter_leaves()]
|
|
410
|
+
candidate_tree = loaded.copy(method="deepcopy")
|
|
411
|
+
candidate = (
|
|
412
|
+
candidate_tree & leaf_names[0]
|
|
413
|
+
if len(leaf_names) == 1
|
|
414
|
+
else candidate_tree.get_common_ancestor(*leaf_names)
|
|
415
|
+
)
|
|
416
|
+
candidate_tree.set_outgroup(candidate)
|
|
417
|
+
candidates.append((f"optimized_branch_{index}", leaf_names, candidate_tree))
|
|
418
|
+
|
|
419
|
+
evaluated: list[tuple[float, str, list[str] | None, Any, Any, dict[str, float]]] = []
|
|
420
|
+
for rooting, outgroup_leaves, candidate_tree in candidates:
|
|
421
|
+
distances = {name: float(candidate_tree.get_distance(name)) for name in usable}
|
|
422
|
+
regression = linregress(list(usable.values()), [distances[name] for name in usable])
|
|
423
|
+
if not math.isfinite(regression.slope) or regression.slope <= 0:
|
|
424
|
+
continue
|
|
425
|
+
root_date = -float(regression.intercept) / float(regression.slope)
|
|
426
|
+
if root_date > min(usable.values()):
|
|
427
|
+
continue
|
|
428
|
+
evaluated.append(
|
|
429
|
+
(float(regression.rvalue**2), rooting, outgroup_leaves, candidate_tree, regression, distances)
|
|
430
|
+
)
|
|
431
|
+
if not evaluated:
|
|
432
|
+
raise ValueError("No tested root produced a positive, temporally valid root-to-tip regression.")
|
|
433
|
+
_, rooting, outgroup_leaves, loaded, regression, distances = max(evaluated, key=lambda item: item[0])
|
|
434
|
+
if not math.isfinite(regression.slope) or regression.slope <= 0:
|
|
435
|
+
raise ValueError("The rooted tree has no positive temporal signal; a time scale cannot be estimated.")
|
|
436
|
+
root_date = -float(regression.intercept) / float(regression.slope)
|
|
437
|
+
fitted = {name: float(regression.intercept + regression.slope * date) for name, date in usable.items()}
|
|
438
|
+
residuals = {name: distances[name] - fitted[name] for name in usable}
|
|
439
|
+
permutation_p_value: float | None = None
|
|
440
|
+
if permutations:
|
|
441
|
+
observed_r_squared = float(regression.rvalue**2)
|
|
442
|
+
ordered_names = list(usable)
|
|
443
|
+
observed_dates = [usable[name] for name in ordered_names]
|
|
444
|
+
candidate_distances = [
|
|
445
|
+
[candidate_distances[name] for name in ordered_names]
|
|
446
|
+
for _, _, _, _, _, candidate_distances in evaluated
|
|
447
|
+
]
|
|
448
|
+
generator = random.Random(random_seed)
|
|
449
|
+
exceedances = 0
|
|
450
|
+
|
|
451
|
+
stratum_indices: dict[Any, list[int]] = defaultdict(list)
|
|
452
|
+
if strata:
|
|
453
|
+
for idx, name in enumerate(ordered_names):
|
|
454
|
+
stratum_indices[strata.get(name, "default")].append(idx)
|
|
455
|
+
|
|
456
|
+
for _ in range(permutations):
|
|
457
|
+
shuffled_dates = observed_dates.copy()
|
|
458
|
+
if strata:
|
|
459
|
+
for group_idxs in stratum_indices.values():
|
|
460
|
+
sub_dates = [shuffled_dates[i] for i in group_idxs]
|
|
461
|
+
generator.shuffle(sub_dates)
|
|
462
|
+
for i, d in zip(group_idxs, sub_dates, strict=True):
|
|
463
|
+
shuffled_dates[i] = d
|
|
464
|
+
else:
|
|
465
|
+
generator.shuffle(shuffled_dates)
|
|
466
|
+
best_null = 0.0
|
|
467
|
+
for candidate_values in candidate_distances:
|
|
468
|
+
null_regression = linregress(shuffled_dates, candidate_values)
|
|
469
|
+
if not math.isfinite(null_regression.slope) or null_regression.slope <= 0:
|
|
470
|
+
continue
|
|
471
|
+
null_root_date = -float(null_regression.intercept) / float(null_regression.slope)
|
|
472
|
+
if null_root_date > min(shuffled_dates):
|
|
473
|
+
continue
|
|
474
|
+
best_null = max(best_null, float(null_regression.rvalue**2))
|
|
475
|
+
if best_null >= observed_r_squared:
|
|
476
|
+
exceedances += 1
|
|
477
|
+
permutation_p_value = (exceedances + 1) / (permutations + 1)
|
|
478
|
+
reported_p_value = permutation_p_value if permutation_p_value is not None else float(regression.pvalue)
|
|
479
|
+
return TemporalSignalResult(
|
|
480
|
+
rate=float(regression.slope),
|
|
481
|
+
intercept=float(regression.intercept),
|
|
482
|
+
root_date=root_date,
|
|
483
|
+
r_value=float(regression.rvalue),
|
|
484
|
+
r_squared=float(regression.rvalue**2),
|
|
485
|
+
p_value=reported_p_value,
|
|
486
|
+
p_value_kind=(
|
|
487
|
+
"stratified_date_randomization"
|
|
488
|
+
if permutation_p_value is not None and strata
|
|
489
|
+
else "root_optimized_date_randomization"
|
|
490
|
+
if permutation_p_value is not None
|
|
491
|
+
else "unadjusted_regression"
|
|
492
|
+
),
|
|
493
|
+
regression_p_value_unadjusted=float(regression.pvalue),
|
|
494
|
+
permutation_p_value=permutation_p_value,
|
|
495
|
+
permutations=permutations,
|
|
496
|
+
random_seed=random_seed if permutations else None,
|
|
497
|
+
standard_error=float(regression.stderr),
|
|
498
|
+
sample_dates=usable,
|
|
499
|
+
root_to_tip=distances,
|
|
500
|
+
fitted_distances=fitted,
|
|
501
|
+
residuals=residuals,
|
|
502
|
+
rooting=rooting,
|
|
503
|
+
outgroup_leaves=outgroup_leaves,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def date_tree(
|
|
507
|
+
self,
|
|
508
|
+
tree: str | Path | Any,
|
|
509
|
+
signal: TemporalSignalResult,
|
|
510
|
+
*,
|
|
511
|
+
midpoint_root: bool = True,
|
|
512
|
+
) -> Any:
|
|
513
|
+
"""Attach deterministic calendar dates to every ETE3 node.
|
|
514
|
+
|
|
515
|
+
Internal dates follow the fitted strict-clock transform of root
|
|
516
|
+
distance. Tips retain an additional ``observed_date`` feature when a
|
|
517
|
+
sampling date is available. The returned topology is suitable for
|
|
518
|
+
exploratory time-tree plotting and lineage-through-time summaries.
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
loaded = self.load_tree(tree, midpoint_root=False)
|
|
522
|
+
if signal.outgroup_leaves:
|
|
523
|
+
outgroup = (
|
|
524
|
+
loaded & signal.outgroup_leaves[0]
|
|
525
|
+
if len(signal.outgroup_leaves) == 1
|
|
526
|
+
else loaded.get_common_ancestor(*signal.outgroup_leaves)
|
|
527
|
+
)
|
|
528
|
+
loaded.set_outgroup(outgroup)
|
|
529
|
+
elif midpoint_root:
|
|
530
|
+
outgroup = loaded.get_midpoint_outgroup()
|
|
531
|
+
if outgroup is not None:
|
|
532
|
+
loaded.set_outgroup(outgroup)
|
|
533
|
+
for node in loaded.traverse("preorder"):
|
|
534
|
+
distance = float(loaded.get_distance(node))
|
|
535
|
+
fitted_date = signal.root_date + distance / signal.rate
|
|
536
|
+
node.add_feature("calendar_date", fitted_date)
|
|
537
|
+
if node.is_leaf() and node.name in signal.sample_dates:
|
|
538
|
+
node.add_feature("observed_date", signal.sample_dates[node.name])
|
|
539
|
+
node.add_feature("fitted_date", fitted_date)
|
|
540
|
+
node.calendar_date = signal.sample_dates[node.name]
|
|
541
|
+
epsilon = 1e-6
|
|
542
|
+
for node in loaded.traverse("postorder"):
|
|
543
|
+
if not node.is_leaf():
|
|
544
|
+
latest_allowed = min(float(child.calendar_date) for child in node.children) - epsilon
|
|
545
|
+
node.calendar_date = min(float(node.calendar_date), latest_allowed)
|
|
546
|
+
return loaded
|
|
547
|
+
|
|
548
|
+
@staticmethod
|
|
549
|
+
def lineage_through_time(tree: Any, *, points: int = 200) -> dict[str, list[float]]:
|
|
550
|
+
"""Count branches crossing an evenly spaced calendar-time grid."""
|
|
551
|
+
|
|
552
|
+
if points < 2:
|
|
553
|
+
raise ValueError("points must be at least two.")
|
|
554
|
+
nodes = [node for node in tree.traverse() if not node.is_root()]
|
|
555
|
+
if not nodes or any(not hasattr(node, "calendar_date") for node in tree.traverse()):
|
|
556
|
+
raise ValueError("Every tree node must contain a calendar_date feature.")
|
|
557
|
+
start = float(tree.calendar_date)
|
|
558
|
+
end = max(float(node.calendar_date) for node in tree.iter_leaves())
|
|
559
|
+
if end <= start:
|
|
560
|
+
raise ValueError("Dated tree must span a positive calendar interval.")
|
|
561
|
+
step = (end - start) / (points - 1)
|
|
562
|
+
times = [start + index * step for index in range(points)]
|
|
563
|
+
lineages = []
|
|
564
|
+
for time in times:
|
|
565
|
+
count = sum(
|
|
566
|
+
1
|
|
567
|
+
for node in nodes
|
|
568
|
+
if float(node.up.calendar_date) <= time <= float(node.calendar_date)
|
|
569
|
+
)
|
|
570
|
+
lineages.append(float(count))
|
|
571
|
+
return {"time": times, "lineages": lineages}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class PhylogeneticComparator:
|
|
575
|
+
"""Compare whole-genome and gene-tree topologies on their shared tips."""
|
|
576
|
+
|
|
577
|
+
def robinson_foulds(self, first: str | Path | Any, second: str | Path | Any) -> dict[str, Any]:
|
|
578
|
+
"""Calculate unrooted Robinson-Foulds distance after shared-tip pruning.
|
|
579
|
+
|
|
580
|
+
The normalized distance is zero for identical shared-tip bipartitions
|
|
581
|
+
and approaches one as topologies become maximally discordant. At least
|
|
582
|
+
four shared tips are required for an informative unrooted comparison.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
loader = TemporalAnalyzer()
|
|
586
|
+
first_tree = loader.load_tree(first, midpoint_root=False)
|
|
587
|
+
second_tree = loader.load_tree(second, midpoint_root=False)
|
|
588
|
+
shared = sorted(set(first_tree.get_leaf_names()) & set(second_tree.get_leaf_names()))
|
|
589
|
+
if len(shared) < 4:
|
|
590
|
+
raise ValueError("Robinson-Foulds comparison requires at least four shared tips.")
|
|
591
|
+
first_tree.prune(shared, preserve_branch_length=True)
|
|
592
|
+
second_tree.prune(shared, preserve_branch_length=True)
|
|
593
|
+
comparison = first_tree.robinson_foulds(second_tree, unrooted_trees=True)
|
|
594
|
+
distance = int(comparison[0])
|
|
595
|
+
maximum = int(comparison[1])
|
|
596
|
+
return {
|
|
597
|
+
"shared_tips": len(shared),
|
|
598
|
+
"tip_names": shared,
|
|
599
|
+
"distance": distance,
|
|
600
|
+
"maximum_distance": maximum,
|
|
601
|
+
"normalized_distance": distance / maximum if maximum else 0.0,
|
|
602
|
+
}
|
codonadaptpy/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|