phykit 2.1.30__tar.gz → 2.1.31__tar.gz
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.
- {phykit-2.1.30 → phykit-2.1.31}/PKG-INFO +1 -1
- {phykit-2.1.30 → phykit-2.1.31}/phykit/cli_registry.py +1 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/phykit.py +69 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/service_factories.py +1 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/__init__.py +1 -0
- phykit-2.1.31/phykit/services/tree/evo_tempo_map.py +658 -0
- phykit-2.1.31/phykit/version.py +1 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/PKG-INFO +1 -1
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/SOURCES.txt +1 -0
- phykit-2.1.30/phykit/version.py +0 -1
- {phykit-2.1.30 → phykit-2.1.31}/LICENSE.md +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/README.md +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/__init__.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/__main__.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/errors.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/__init__.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/boolean_argument_parsing.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/caching.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/files.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/json_output.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/parallel.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/stats_summary.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/streaming.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/__init__.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/__init__.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_entropy.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_length.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_recoding.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/base.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/column_score.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/composition_per_taxon.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/dna_threader.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/faidx.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/gc_content.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/mask_alignment.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/pairwise_identity.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/plot_alignment_qc.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rcv.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rcvt.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rename_fasta_entries.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/variable_sites.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/base.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ancestral_reconstruction.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/base.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/bipartition_support_stats.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/branch_length_multiplier.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/collapse_branches.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/concordance_asr.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/consensus_network.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/consensus_tree.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/cont_map.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/cophylo.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/density_map.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/dvmc.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/evolutionary_rate.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/fit_continuous.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/hidden_paralogy_check.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/internal_branch_stats.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/internode_labeler.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/lb_score.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ltt.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/monophyly_check.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/network_signal.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ou_shift_detection.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ouwie.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/patristic_distances.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phenogram.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_glm.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_ordination.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_regression.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_signal.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylomorphospace.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/polytomy_test.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/print_tree.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/prune_tree.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/quartet_network.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rate_heterogeneity.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/relative_rate_test.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rename_tree_tips.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rf_distance.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/root_tree.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/saturation.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/spurious_sequence.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/stochastic_character_map.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/terminal_branch_stats.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/threshold_model.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_labels.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_to_tip_distance.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/total_tree_length.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/treeness.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/treeness_over_rcv.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/vcv_utils.py +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/dependency_links.txt +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/entry_points.txt +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/requires.txt +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/top_level.txt +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/setup.cfg +0 -0
- {phykit-2.1.30 → phykit-2.1.31}/setup.py +0 -0
|
@@ -154,6 +154,7 @@ ALIAS_TO_HANDLER: Dict[str, str] = {
|
|
|
154
154
|
"thresh_bayes": "threshold_model",
|
|
155
155
|
"toverr": "treeness_over_rcv",
|
|
156
156
|
"tor": "treeness_over_rcv",
|
|
157
|
+
"etm": "evo_tempo_map",
|
|
157
158
|
# Helper aliases
|
|
158
159
|
"create_concat": "create_concatenation_matrix",
|
|
159
160
|
"cc": "create_concatenation_matrix",
|
|
@@ -4851,6 +4851,75 @@ class Phykit:
|
|
|
4851
4851
|
_add_json_argument(parser)
|
|
4852
4852
|
_run_service(parser, argv, TreenessOverRCV)
|
|
4853
4853
|
|
|
4854
|
+
@staticmethod
|
|
4855
|
+
def evo_tempo_map(argv):
|
|
4856
|
+
parser = _new_parser(
|
|
4857
|
+
description=textwrap.dedent(
|
|
4858
|
+
f"""\
|
|
4859
|
+
{help_header}
|
|
4860
|
+
|
|
4861
|
+
Detect rate-topology associations by comparing branch length
|
|
4862
|
+
distributions between concordant and discordant gene trees at
|
|
4863
|
+
each species tree branch.
|
|
4864
|
+
|
|
4865
|
+
Under the multispecies coalescent, discordant gene trees should
|
|
4866
|
+
have shorter internal branches near the discordant node. Deviations
|
|
4867
|
+
suggest substitution rate heterogeneity correlated with topology
|
|
4868
|
+
(adaptive evolution, different selective pressures, or model
|
|
4869
|
+
misspecification).
|
|
4870
|
+
|
|
4871
|
+
For each internal branch of the species tree, gene trees are
|
|
4872
|
+
classified as concordant or discordant via bipartition matching.
|
|
4873
|
+
The homologous branch length is extracted from each gene tree
|
|
4874
|
+
and the two groups are compared using Mann-Whitney U and
|
|
4875
|
+
permutation tests. P-values are corrected for multiple testing
|
|
4876
|
+
using Benjamini-Hochberg FDR.
|
|
4877
|
+
|
|
4878
|
+
A global treeness (internal/total branch length ratio) comparison
|
|
4879
|
+
is also reported.
|
|
4880
|
+
|
|
4881
|
+
Aliases:
|
|
4882
|
+
evo_tempo_map, etm
|
|
4883
|
+
Command line interfaces:
|
|
4884
|
+
pk_evo_tempo_map, pk_etm
|
|
4885
|
+
|
|
4886
|
+
Usage:
|
|
4887
|
+
phykit evo_tempo_map -t/--tree <tree> -g/--gene-trees <gene_trees>
|
|
4888
|
+
[--plot <output>] [-v/--verbose] [--json]
|
|
4889
|
+
|
|
4890
|
+
Options
|
|
4891
|
+
=====================================================
|
|
4892
|
+
-t/--tree a species tree file
|
|
4893
|
+
|
|
4894
|
+
-g/--gene-trees multi-Newick file of gene trees
|
|
4895
|
+
with branch lengths
|
|
4896
|
+
|
|
4897
|
+
--plot optional output path for
|
|
4898
|
+
box/strip plot (PNG)
|
|
4899
|
+
|
|
4900
|
+
-v/--verbose print per-gene-tree details
|
|
4901
|
+
|
|
4902
|
+
--json optional argument to output
|
|
4903
|
+
results as JSON
|
|
4904
|
+
"""
|
|
4905
|
+
),
|
|
4906
|
+
)
|
|
4907
|
+
parser.add_argument(
|
|
4908
|
+
"-t", "--tree", type=str, required=True, help=SUPPRESS, metavar=""
|
|
4909
|
+
)
|
|
4910
|
+
parser.add_argument(
|
|
4911
|
+
"-g", "--gene-trees", type=str, required=True, help=SUPPRESS, metavar=""
|
|
4912
|
+
)
|
|
4913
|
+
parser.add_argument(
|
|
4914
|
+
"--plot", dest="plot_output", type=str, required=False,
|
|
4915
|
+
default=None, help=SUPPRESS, metavar=""
|
|
4916
|
+
)
|
|
4917
|
+
parser.add_argument(
|
|
4918
|
+
"-v", "--verbose", action="store_true", required=False, help=SUPPRESS
|
|
4919
|
+
)
|
|
4920
|
+
_add_json_argument(parser)
|
|
4921
|
+
_run_service(parser, argv, EvoTempoMap)
|
|
4922
|
+
|
|
4854
4923
|
### Helper commands
|
|
4855
4924
|
@staticmethod
|
|
4856
4925
|
def create_concatenation_matrix(argv):
|
|
@@ -96,6 +96,7 @@ TipToTipNodeDistance = _LazyServiceFactory("phykit.services.tree.tip_to_tip_node
|
|
|
96
96
|
TotalTreeLength = _LazyServiceFactory("phykit.services.tree.total_tree_length", "TotalTreeLength")
|
|
97
97
|
Treeness = _LazyServiceFactory("phykit.services.tree.treeness", "Treeness")
|
|
98
98
|
TreenessOverRCV = _LazyServiceFactory("phykit.services.tree.treeness_over_rcv", "TreenessOverRCV")
|
|
99
|
+
EvoTempoMap = _LazyServiceFactory("phykit.services.tree.evo_tempo_map", "EvoTempoMap")
|
|
99
100
|
|
|
100
101
|
SERVICE_FACTORIES: Dict[str, _LazyServiceFactory] = {
|
|
101
102
|
name: value
|
|
@@ -10,6 +10,7 @@ _EXPORTS = {
|
|
|
10
10
|
"ConsensusTree": "consensus_tree",
|
|
11
11
|
"DVMC": "dvmc",
|
|
12
12
|
"EvolutionaryRate": "evolutionary_rate",
|
|
13
|
+
"EvoTempoMap": "evo_tempo_map",
|
|
13
14
|
"HiddenParalogyCheck": "hidden_paralogy_check",
|
|
14
15
|
"InternalBranchStats": "internal_branch_stats",
|
|
15
16
|
"InternodeLabeler": "internode_labeler",
|
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from Bio import Phylo
|
|
7
|
+
from scipy.stats import mannwhitneyu
|
|
8
|
+
|
|
9
|
+
from .base import Tree
|
|
10
|
+
from ...helpers.json_output import print_json
|
|
11
|
+
from ...errors import PhykitUserError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvoTempoMap(Tree):
|
|
15
|
+
def __init__(self, args) -> None:
|
|
16
|
+
parsed = self.process_args(args)
|
|
17
|
+
super().__init__(tree_file_path=parsed["tree_file_path"])
|
|
18
|
+
self.gene_trees_path = parsed["gene_trees_path"]
|
|
19
|
+
self.verbose = parsed["verbose"]
|
|
20
|
+
self.json_output = parsed["json_output"]
|
|
21
|
+
self.plot_output = parsed["plot_output"]
|
|
22
|
+
|
|
23
|
+
def process_args(self, args) -> Dict:
|
|
24
|
+
return dict(
|
|
25
|
+
tree_file_path=args.tree,
|
|
26
|
+
gene_trees_path=args.gene_trees,
|
|
27
|
+
verbose=getattr(args, "verbose", False),
|
|
28
|
+
json_output=getattr(args, "json", False),
|
|
29
|
+
plot_output=getattr(args, "plot_output", None),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def run(self) -> None:
|
|
33
|
+
species_tree = self.read_tree_file()
|
|
34
|
+
gene_trees = self._parse_and_validate_gene_trees()
|
|
35
|
+
classification = self._classify_gene_trees(species_tree, gene_trees)
|
|
36
|
+
|
|
37
|
+
# Test each branch and collect results
|
|
38
|
+
branch_results = []
|
|
39
|
+
for branch_key in sorted(classification.keys()):
|
|
40
|
+
data = classification[branch_key]
|
|
41
|
+
test_result = self._test_branch(
|
|
42
|
+
data["concordant_lengths"],
|
|
43
|
+
data["discordant_lengths"],
|
|
44
|
+
)
|
|
45
|
+
entry = dict(
|
|
46
|
+
split=data["split"],
|
|
47
|
+
n_concordant=data["n_concordant"],
|
|
48
|
+
n_discordant=data["n_discordant"],
|
|
49
|
+
_concordant_lengths=data["concordant_lengths"],
|
|
50
|
+
_discordant_lengths=data["discordant_lengths"],
|
|
51
|
+
)
|
|
52
|
+
entry.update(test_result)
|
|
53
|
+
branch_results.append(entry)
|
|
54
|
+
|
|
55
|
+
# FDR correction across testable p-values
|
|
56
|
+
testable_indices = []
|
|
57
|
+
testable_pvals = []
|
|
58
|
+
for i, entry in enumerate(branch_results):
|
|
59
|
+
if entry["mann_whitney_p"] is not None:
|
|
60
|
+
testable_indices.append(i)
|
|
61
|
+
testable_pvals.append(entry["mann_whitney_p"])
|
|
62
|
+
|
|
63
|
+
fdr_corrected = self._fdr(testable_pvals)
|
|
64
|
+
for idx, fdr_p in zip(testable_indices, fdr_corrected):
|
|
65
|
+
branch_results[idx]["fdr_p"] = fdr_p
|
|
66
|
+
|
|
67
|
+
# Set fdr_p to None for untestable branches
|
|
68
|
+
for entry in branch_results:
|
|
69
|
+
if "fdr_p" not in entry:
|
|
70
|
+
entry["fdr_p"] = None
|
|
71
|
+
|
|
72
|
+
# Global treeness
|
|
73
|
+
global_stats = self._compute_global_treeness(species_tree, gene_trees)
|
|
74
|
+
global_stats["n_gene_trees"] = len(gene_trees)
|
|
75
|
+
global_stats["n_branches_tested"] = len(testable_indices)
|
|
76
|
+
global_stats["n_significant_fdr05"] = sum(
|
|
77
|
+
1 for entry in branch_results
|
|
78
|
+
if entry["fdr_p"] is not None and entry["fdr_p"] < 0.05
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Output
|
|
82
|
+
if self.json_output:
|
|
83
|
+
self._output_json(branch_results, global_stats)
|
|
84
|
+
else:
|
|
85
|
+
self._output_text(branch_results, global_stats)
|
|
86
|
+
|
|
87
|
+
if self.plot_output:
|
|
88
|
+
self._plot(branch_results, self.plot_output)
|
|
89
|
+
|
|
90
|
+
def _output_text(self, branch_results, global_stats) -> None:
|
|
91
|
+
try:
|
|
92
|
+
# Header
|
|
93
|
+
header = (
|
|
94
|
+
f"{'branch':<30}"
|
|
95
|
+
f"{'n_conc':>8}"
|
|
96
|
+
f"{'n_disc':>8}"
|
|
97
|
+
f"{'med_conc':>12}"
|
|
98
|
+
f"{'med_disc':>12}"
|
|
99
|
+
f"{'U_pval':>12}"
|
|
100
|
+
f"{'perm_pval':>12}"
|
|
101
|
+
f"{'fdr_p':>12}"
|
|
102
|
+
)
|
|
103
|
+
print(header)
|
|
104
|
+
print("-" * len(header))
|
|
105
|
+
|
|
106
|
+
for entry in branch_results:
|
|
107
|
+
branch_label = ",".join(entry["split"])
|
|
108
|
+
med_conc = (
|
|
109
|
+
f"{entry['concordant_median']:.6f}"
|
|
110
|
+
if entry["concordant_median"] is not None
|
|
111
|
+
else "NA"
|
|
112
|
+
)
|
|
113
|
+
med_disc = (
|
|
114
|
+
f"{entry['discordant_median']:.6f}"
|
|
115
|
+
if entry["discordant_median"] is not None
|
|
116
|
+
else "NA"
|
|
117
|
+
)
|
|
118
|
+
u_pval = (
|
|
119
|
+
f"{entry['mann_whitney_p']:.6f}"
|
|
120
|
+
if entry["mann_whitney_p"] is not None
|
|
121
|
+
else "NA"
|
|
122
|
+
)
|
|
123
|
+
perm_pval = (
|
|
124
|
+
f"{entry['permutation_p']:.6f}"
|
|
125
|
+
if entry["permutation_p"] is not None
|
|
126
|
+
else "NA"
|
|
127
|
+
)
|
|
128
|
+
fdr_p = (
|
|
129
|
+
f"{entry['fdr_p']:.6f}"
|
|
130
|
+
if entry["fdr_p"] is not None
|
|
131
|
+
else "NA"
|
|
132
|
+
)
|
|
133
|
+
print(
|
|
134
|
+
f"{branch_label:<30}"
|
|
135
|
+
f"{entry['n_concordant']:>8}"
|
|
136
|
+
f"{entry['n_discordant']:>8}"
|
|
137
|
+
f"{med_conc:>12}"
|
|
138
|
+
f"{med_disc:>12}"
|
|
139
|
+
f"{u_pval:>12}"
|
|
140
|
+
f"{perm_pval:>12}"
|
|
141
|
+
f"{fdr_p:>12}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
print("---")
|
|
145
|
+
|
|
146
|
+
# Global treeness summary
|
|
147
|
+
conc_t = global_stats["treeness_concordant"]
|
|
148
|
+
disc_t = global_stats["treeness_discordant"]
|
|
149
|
+
conc_med = (
|
|
150
|
+
f"{conc_t['median']:.6f}" if conc_t["median"] is not None else "NA"
|
|
151
|
+
)
|
|
152
|
+
disc_med = (
|
|
153
|
+
f"{disc_t['median']:.6f}" if disc_t["median"] is not None else "NA"
|
|
154
|
+
)
|
|
155
|
+
print(
|
|
156
|
+
f"Global treeness: concordant={conc_med} (n={conc_t['n']}), "
|
|
157
|
+
f"discordant={disc_med} (n={disc_t['n']})"
|
|
158
|
+
)
|
|
159
|
+
print(
|
|
160
|
+
f"Branches tested: {global_stats['n_branches_tested']}, "
|
|
161
|
+
f"significant (FDR<0.05): {global_stats['n_significant_fdr05']}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Verbose: print per-branch raw lengths
|
|
165
|
+
if self.verbose:
|
|
166
|
+
print()
|
|
167
|
+
for entry in branch_results:
|
|
168
|
+
branch_label = ",".join(entry["split"])
|
|
169
|
+
print(f"Branch: {branch_label}")
|
|
170
|
+
print(
|
|
171
|
+
f" Concordant lengths: "
|
|
172
|
+
f"{entry['_concordant_lengths']}"
|
|
173
|
+
)
|
|
174
|
+
print(
|
|
175
|
+
f" Discordant lengths: "
|
|
176
|
+
f"{entry['_discordant_lengths']}"
|
|
177
|
+
)
|
|
178
|
+
except BrokenPipeError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def _output_json(self, branch_results, global_stats) -> None:
|
|
182
|
+
result = dict(
|
|
183
|
+
branches=[
|
|
184
|
+
{k: v for k, v in entry.items() if not k.startswith("_")}
|
|
185
|
+
for entry in branch_results
|
|
186
|
+
],
|
|
187
|
+
global_=global_stats,
|
|
188
|
+
)
|
|
189
|
+
print_json(result)
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _plot(branch_results, output_path):
|
|
193
|
+
"""Grouped box/strip plot of concordant vs discordant branch lengths."""
|
|
194
|
+
import matplotlib
|
|
195
|
+
matplotlib.use("Agg")
|
|
196
|
+
import matplotlib.pyplot as plt
|
|
197
|
+
import numpy as np
|
|
198
|
+
|
|
199
|
+
labels = []
|
|
200
|
+
conc_data = []
|
|
201
|
+
disc_data = []
|
|
202
|
+
sig_flags = []
|
|
203
|
+
|
|
204
|
+
for entry in branch_results:
|
|
205
|
+
conc = entry.get("_concordant_lengths", [])
|
|
206
|
+
disc = entry.get("_discordant_lengths", [])
|
|
207
|
+
if not conc and not disc:
|
|
208
|
+
continue
|
|
209
|
+
split_str = "{" + ",".join(entry["split"]) + "}"
|
|
210
|
+
labels.append(split_str)
|
|
211
|
+
conc_data.append(conc if conc else [0]) # need at least 1 value for boxplot
|
|
212
|
+
disc_data.append(disc if disc else [0])
|
|
213
|
+
sig_flags.append(
|
|
214
|
+
entry.get("fdr_p") is not None and entry["fdr_p"] < 0.05
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if not labels:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
n = len(labels)
|
|
221
|
+
fig, ax = plt.subplots(figsize=(max(8, n * 1.5), 5))
|
|
222
|
+
|
|
223
|
+
positions = np.arange(n)
|
|
224
|
+
width = 0.35
|
|
225
|
+
|
|
226
|
+
# Box plots for concordant
|
|
227
|
+
bp_conc = ax.boxplot(
|
|
228
|
+
conc_data, positions=positions - width / 2, widths=width * 0.8,
|
|
229
|
+
patch_artist=True, showfliers=False,
|
|
230
|
+
boxprops=dict(facecolor="#4C72B0", alpha=0.7),
|
|
231
|
+
medianprops=dict(color="black"),
|
|
232
|
+
)
|
|
233
|
+
# Box plots for discordant
|
|
234
|
+
bp_disc = ax.boxplot(
|
|
235
|
+
disc_data, positions=positions + width / 2, widths=width * 0.8,
|
|
236
|
+
patch_artist=True, showfliers=False,
|
|
237
|
+
boxprops=dict(facecolor="#DD8452", alpha=0.7),
|
|
238
|
+
medianprops=dict(color="black"),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Strip (jitter) points
|
|
242
|
+
rng = np.random.default_rng(42)
|
|
243
|
+
for i in range(n):
|
|
244
|
+
if conc_data[i] and conc_data[i] != [0]:
|
|
245
|
+
jitter = rng.uniform(-0.05, 0.05, len(conc_data[i]))
|
|
246
|
+
ax.scatter(
|
|
247
|
+
positions[i] - width / 2 + jitter,
|
|
248
|
+
conc_data[i], color="#4C72B0", alpha=0.6, s=20, zorder=3,
|
|
249
|
+
)
|
|
250
|
+
if disc_data[i] and disc_data[i] != [0]:
|
|
251
|
+
jitter = rng.uniform(-0.05, 0.05, len(disc_data[i]))
|
|
252
|
+
ax.scatter(
|
|
253
|
+
positions[i] + width / 2 + jitter,
|
|
254
|
+
disc_data[i], color="#DD8452", alpha=0.6, s=20, zorder=3,
|
|
255
|
+
)
|
|
256
|
+
if sig_flags[i]:
|
|
257
|
+
max_val = max(
|
|
258
|
+
max(conc_data[i]) if conc_data[i] else 0,
|
|
259
|
+
max(disc_data[i]) if disc_data[i] else 0,
|
|
260
|
+
)
|
|
261
|
+
ax.text(positions[i], max_val * 1.05, "*", ha="center", fontsize=14, fontweight="bold")
|
|
262
|
+
|
|
263
|
+
ax.set_xticks(positions)
|
|
264
|
+
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=9)
|
|
265
|
+
ax.set_ylabel("Branch length (subs/site)", fontsize=12)
|
|
266
|
+
ax.set_xlabel("Species tree branch", fontsize=12)
|
|
267
|
+
ax.legend(
|
|
268
|
+
[bp_conc["boxes"][0], bp_disc["boxes"][0]],
|
|
269
|
+
["Concordant", "Discordant"],
|
|
270
|
+
loc="upper right",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
fig.tight_layout()
|
|
274
|
+
fig.savefig(output_path, dpi=300, bbox_inches="tight")
|
|
275
|
+
plt.close(fig)
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Gene tree parsing
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def _parse_gene_trees(self, path: str) -> list:
|
|
282
|
+
try:
|
|
283
|
+
lines = Path(path).read_text().splitlines()
|
|
284
|
+
except FileNotFoundError:
|
|
285
|
+
raise PhykitUserError(
|
|
286
|
+
[
|
|
287
|
+
f"{path} corresponds to no such file or directory.",
|
|
288
|
+
"Please check filename and pathing",
|
|
289
|
+
],
|
|
290
|
+
code=2,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
cleaned = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")]
|
|
294
|
+
trees = []
|
|
295
|
+
for line in cleaned:
|
|
296
|
+
if line.startswith("("):
|
|
297
|
+
trees.append(Phylo.read(StringIO(line), "newick"))
|
|
298
|
+
else:
|
|
299
|
+
tree_path = Path(path).parent / line
|
|
300
|
+
trees.append(Phylo.read(str(tree_path), "newick"))
|
|
301
|
+
return trees
|
|
302
|
+
|
|
303
|
+
def _parse_and_validate_gene_trees(self) -> list:
|
|
304
|
+
gene_trees = self._parse_gene_trees(self.gene_trees_path)
|
|
305
|
+
|
|
306
|
+
if len(gene_trees) < 2:
|
|
307
|
+
raise PhykitUserError(
|
|
308
|
+
[
|
|
309
|
+
"At least 2 gene trees are required for evolutionary",
|
|
310
|
+
"tempo mapping. Please provide more gene trees.",
|
|
311
|
+
],
|
|
312
|
+
code=2,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
for i, gt in enumerate(gene_trees):
|
|
316
|
+
for clade in gt.find_clades():
|
|
317
|
+
if clade == gt.root:
|
|
318
|
+
continue
|
|
319
|
+
if clade.branch_length is None:
|
|
320
|
+
raise PhykitUserError(
|
|
321
|
+
[
|
|
322
|
+
f"Gene tree {i + 1} has branches without branch lengths.",
|
|
323
|
+
"All gene tree branches must have branch lengths.",
|
|
324
|
+
],
|
|
325
|
+
code=2,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return gene_trees
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Bipartition extraction and concordance classification
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _canonical_split(taxa_side, all_taxa):
|
|
336
|
+
"""Normalize a bipartition to canonical form.
|
|
337
|
+
|
|
338
|
+
Returns the smaller side as a frozenset; ties are broken
|
|
339
|
+
lexicographically.
|
|
340
|
+
"""
|
|
341
|
+
complement = all_taxa - taxa_side
|
|
342
|
+
if len(taxa_side) < len(complement):
|
|
343
|
+
return frozenset(taxa_side)
|
|
344
|
+
elif len(taxa_side) > len(complement):
|
|
345
|
+
return frozenset(complement)
|
|
346
|
+
else:
|
|
347
|
+
return min(frozenset(taxa_side), frozenset(complement),
|
|
348
|
+
key=lambda s: sorted(s))
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _build_parent_map(tree) -> Dict:
|
|
352
|
+
"""Build a dict mapping child id -> parent clade."""
|
|
353
|
+
parent_map = {}
|
|
354
|
+
for clade in tree.find_clades(order="preorder"):
|
|
355
|
+
for child in clade.clades:
|
|
356
|
+
parent_map[id(child)] = clade
|
|
357
|
+
return parent_map
|
|
358
|
+
|
|
359
|
+
def _get_four_groups(self, tree, node, parent_map, all_taxa_fs):
|
|
360
|
+
"""Identify the four subtree groups around an internal branch.
|
|
361
|
+
|
|
362
|
+
For the branch connecting *node* to its parent:
|
|
363
|
+
C1 = tips of node's first child
|
|
364
|
+
C2 = tips of node's second child (extra children merged for polytomies)
|
|
365
|
+
S = tips of node's sibling under parent
|
|
366
|
+
D = remaining tips (everything above parent)
|
|
367
|
+
|
|
368
|
+
Returns (C1, C2, S, D) as frozensets, or None if decomposition
|
|
369
|
+
is not possible (e.g., node is root, leaf, or has <2 children).
|
|
370
|
+
"""
|
|
371
|
+
if node.is_terminal() or len(node.clades) < 2:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
C1 = frozenset(t.name for t in node.clades[0].get_terminals())
|
|
375
|
+
C2 = frozenset(t.name for t in node.clades[1].get_terminals())
|
|
376
|
+
# If node has >2 children (polytomy), merge extras into C2
|
|
377
|
+
for extra_child in node.clades[2:]:
|
|
378
|
+
C2 = C2 | frozenset(t.name for t in extra_child.get_terminals())
|
|
379
|
+
|
|
380
|
+
parent = parent_map.get(id(node))
|
|
381
|
+
if parent is None:
|
|
382
|
+
# node is root — no branch above it
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
# Get siblings of node under parent
|
|
386
|
+
siblings = [c for c in parent.clades if id(c) != id(node)]
|
|
387
|
+
if not siblings:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
S = frozenset(t.name for t in siblings[0].get_terminals())
|
|
391
|
+
# D = everything else (other siblings + above parent)
|
|
392
|
+
D = all_taxa_fs - C1 - C2 - S
|
|
393
|
+
|
|
394
|
+
return C1, C2, S, D
|
|
395
|
+
|
|
396
|
+
def _extract_bipartitions_with_lengths(self, tree, all_taxa_fs):
|
|
397
|
+
"""Extract non-trivial bipartitions from a tree with branch lengths.
|
|
398
|
+
|
|
399
|
+
Returns a dict mapping canonical_split (frozenset) -> branch_length.
|
|
400
|
+
"""
|
|
401
|
+
bp_to_length = {}
|
|
402
|
+
for clade in tree.get_nonterminals():
|
|
403
|
+
tips = frozenset(t.name for t in clade.get_terminals())
|
|
404
|
+
if len(tips) <= 1 or tips == all_taxa_fs:
|
|
405
|
+
continue
|
|
406
|
+
bp = self._canonical_split(tips, all_taxa_fs)
|
|
407
|
+
bl = clade.branch_length if clade.branch_length else 0.0
|
|
408
|
+
bp_to_length[bp] = bl
|
|
409
|
+
return bp_to_length
|
|
410
|
+
|
|
411
|
+
def _classify_gene_trees(self, species_tree, gene_trees) -> Dict:
|
|
412
|
+
"""Classify each gene tree as concordant or discordant at each
|
|
413
|
+
species tree branch, and extract the homologous branch lengths.
|
|
414
|
+
|
|
415
|
+
Returns a dict keyed by branch label (comma-joined sorted taxa) with:
|
|
416
|
+
split: list of taxon names in the smaller partition side
|
|
417
|
+
n_concordant: int
|
|
418
|
+
n_discordant: int
|
|
419
|
+
concordant_lengths: list of floats
|
|
420
|
+
discordant_lengths: list of floats
|
|
421
|
+
"""
|
|
422
|
+
all_taxa = sorted(
|
|
423
|
+
set(t.name for t in species_tree.get_terminals())
|
|
424
|
+
& set().union(*(
|
|
425
|
+
set(t.name for t in gt.get_terminals()) for gt in gene_trees
|
|
426
|
+
))
|
|
427
|
+
)
|
|
428
|
+
all_taxa_fs = frozenset(all_taxa)
|
|
429
|
+
parent_map = self._build_parent_map(species_tree)
|
|
430
|
+
|
|
431
|
+
# Extract bipartitions + branch lengths from all gene trees
|
|
432
|
+
gene_tree_bp_lengths = []
|
|
433
|
+
for gt in gene_trees:
|
|
434
|
+
# Prune to shared taxa if needed
|
|
435
|
+
gt_taxa = set(t.name for t in gt.get_terminals())
|
|
436
|
+
if gt_taxa != set(all_taxa):
|
|
437
|
+
taxa_to_remove = gt_taxa - set(all_taxa)
|
|
438
|
+
for taxon in taxa_to_remove:
|
|
439
|
+
gt.prune(taxon)
|
|
440
|
+
gene_tree_bp_lengths.append(
|
|
441
|
+
self._extract_bipartitions_with_lengths(gt, all_taxa_fs)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
result = {}
|
|
445
|
+
for clade in species_tree.find_clades(order="preorder"):
|
|
446
|
+
if clade.is_terminal():
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
groups = self._get_four_groups(
|
|
450
|
+
species_tree, clade, parent_map, all_taxa_fs
|
|
451
|
+
)
|
|
452
|
+
if groups is None:
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
C1, C2, S, D = groups
|
|
456
|
+
|
|
457
|
+
concordant_bp = self._canonical_split(C1 | C2, all_taxa_fs)
|
|
458
|
+
nni_alt1_bp = self._canonical_split(S | C2, all_taxa_fs)
|
|
459
|
+
nni_alt2_bp = self._canonical_split(C1 | S, all_taxa_fs)
|
|
460
|
+
|
|
461
|
+
concordant_lengths = []
|
|
462
|
+
discordant_lengths = []
|
|
463
|
+
|
|
464
|
+
for bp_lengths in gene_tree_bp_lengths:
|
|
465
|
+
if concordant_bp in bp_lengths:
|
|
466
|
+
concordant_lengths.append(bp_lengths[concordant_bp])
|
|
467
|
+
elif nni_alt1_bp in bp_lengths:
|
|
468
|
+
discordant_lengths.append(bp_lengths[nni_alt1_bp])
|
|
469
|
+
elif nni_alt2_bp in bp_lengths:
|
|
470
|
+
discordant_lengths.append(bp_lengths[nni_alt2_bp])
|
|
471
|
+
|
|
472
|
+
# Label by the smaller side of the species tree split
|
|
473
|
+
node_tips = frozenset(
|
|
474
|
+
t.name for t in clade.get_terminals()
|
|
475
|
+
)
|
|
476
|
+
split_label = (
|
|
477
|
+
sorted(node_tips)
|
|
478
|
+
if len(node_tips) <= len(all_taxa_fs) - len(node_tips)
|
|
479
|
+
else sorted(all_taxa_fs - node_tips)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
branch_key = ",".join(split_label)
|
|
483
|
+
result[branch_key] = dict(
|
|
484
|
+
split=split_label,
|
|
485
|
+
n_concordant=len(concordant_lengths),
|
|
486
|
+
n_discordant=len(discordant_lengths),
|
|
487
|
+
concordant_lengths=concordant_lengths,
|
|
488
|
+
discordant_lengths=discordant_lengths,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
return result
|
|
492
|
+
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
# Statistical testing
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def _test_branch(
|
|
498
|
+
self,
|
|
499
|
+
concordant_lengths: List[float],
|
|
500
|
+
discordant_lengths: List[float],
|
|
501
|
+
n_permutations: int = 1000,
|
|
502
|
+
) -> Optional[Dict]:
|
|
503
|
+
"""Compare branch length distributions between concordant and
|
|
504
|
+
discordant gene trees using Mann-Whitney U and a permutation test.
|
|
505
|
+
|
|
506
|
+
Returns a dict with summary statistics and p-values, or a dict with
|
|
507
|
+
None values where tests cannot be computed.
|
|
508
|
+
"""
|
|
509
|
+
conc = np.array(concordant_lengths, dtype=float)
|
|
510
|
+
disc = np.array(discordant_lengths, dtype=float)
|
|
511
|
+
|
|
512
|
+
result = dict(
|
|
513
|
+
concordant_mean=None,
|
|
514
|
+
concordant_median=None,
|
|
515
|
+
concordant_std=None,
|
|
516
|
+
discordant_mean=None,
|
|
517
|
+
discordant_median=None,
|
|
518
|
+
discordant_std=None,
|
|
519
|
+
mann_whitney_U=None,
|
|
520
|
+
mann_whitney_p=None,
|
|
521
|
+
permutation_p=None,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Summary stats for concordant
|
|
525
|
+
if len(conc) > 0:
|
|
526
|
+
result["concordant_mean"] = float(np.mean(conc))
|
|
527
|
+
result["concordant_median"] = float(np.median(conc))
|
|
528
|
+
result["concordant_std"] = float(np.std(conc, ddof=1)) if len(conc) > 1 else None
|
|
529
|
+
|
|
530
|
+
# Summary stats for discordant
|
|
531
|
+
if len(disc) > 0:
|
|
532
|
+
result["discordant_mean"] = float(np.mean(disc))
|
|
533
|
+
result["discordant_median"] = float(np.median(disc))
|
|
534
|
+
result["discordant_std"] = float(np.std(disc, ddof=1)) if len(disc) > 1 else None
|
|
535
|
+
|
|
536
|
+
# Need at least 2 observations in each group for tests
|
|
537
|
+
if len(conc) < 2 or len(disc) < 2:
|
|
538
|
+
return result
|
|
539
|
+
|
|
540
|
+
# Mann-Whitney U test (two-sided)
|
|
541
|
+
U, mw_p = mannwhitneyu(conc, disc, alternative="two-sided")
|
|
542
|
+
result["mann_whitney_U"] = float(U)
|
|
543
|
+
result["mann_whitney_p"] = float(mw_p)
|
|
544
|
+
|
|
545
|
+
# Permutation test on difference in medians
|
|
546
|
+
observed_diff = abs(float(np.median(conc)) - float(np.median(disc)))
|
|
547
|
+
combined = np.concatenate([conc, disc])
|
|
548
|
+
n_conc = len(conc)
|
|
549
|
+
rng = np.random.default_rng(42)
|
|
550
|
+
|
|
551
|
+
count = 0
|
|
552
|
+
for _ in range(n_permutations):
|
|
553
|
+
rng.shuffle(combined)
|
|
554
|
+
perm_conc = combined[:n_conc]
|
|
555
|
+
perm_disc = combined[n_conc:]
|
|
556
|
+
perm_diff = abs(float(np.median(perm_conc)) - float(np.median(perm_disc)))
|
|
557
|
+
if perm_diff >= observed_diff:
|
|
558
|
+
count += 1
|
|
559
|
+
|
|
560
|
+
result["permutation_p"] = count / n_permutations
|
|
561
|
+
return result
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _fdr(p_values: List[float]) -> List[float]:
|
|
565
|
+
"""Benjamini-Hochberg FDR correction."""
|
|
566
|
+
n = len(p_values)
|
|
567
|
+
if n == 0:
|
|
568
|
+
return []
|
|
569
|
+
indexed = sorted(enumerate(p_values), key=lambda x: x[1])
|
|
570
|
+
corrected = [0.0] * n
|
|
571
|
+
prev = 1.0
|
|
572
|
+
for rank_minus_1 in range(n - 1, -1, -1):
|
|
573
|
+
orig_idx, p = indexed[rank_minus_1]
|
|
574
|
+
rank = rank_minus_1 + 1
|
|
575
|
+
adjusted = min(p * n / rank, prev)
|
|
576
|
+
adjusted = min(adjusted, 1.0)
|
|
577
|
+
corrected[orig_idx] = adjusted
|
|
578
|
+
prev = adjusted
|
|
579
|
+
return corrected
|
|
580
|
+
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
# Global treeness comparison
|
|
583
|
+
# ------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
@staticmethod
|
|
586
|
+
def _compute_treeness(tree) -> float:
|
|
587
|
+
"""Compute treeness = sum(internal branch lengths) / sum(all branch lengths).
|
|
588
|
+
|
|
589
|
+
Produces identical results to Tree.calculate_treeness().
|
|
590
|
+
"""
|
|
591
|
+
inter_len = 0.0
|
|
592
|
+
for internal in tree.get_nonterminals():
|
|
593
|
+
if internal.branch_length is not None:
|
|
594
|
+
inter_len += internal.branch_length
|
|
595
|
+
total_len = tree.total_branch_length()
|
|
596
|
+
if total_len == 0:
|
|
597
|
+
return 0.0
|
|
598
|
+
return inter_len / total_len
|
|
599
|
+
|
|
600
|
+
def _compute_global_treeness(self, species_tree, gene_trees) -> Dict:
|
|
601
|
+
"""Classify gene trees as globally concordant or discordant,
|
|
602
|
+
compute treeness for each group, and test for a difference.
|
|
603
|
+
|
|
604
|
+
A gene tree is globally concordant if ALL of its non-trivial
|
|
605
|
+
bipartitions match the species tree bipartitions exactly.
|
|
606
|
+
"""
|
|
607
|
+
all_taxa = sorted(t.name for t in species_tree.get_terminals())
|
|
608
|
+
all_taxa_fs = frozenset(all_taxa)
|
|
609
|
+
|
|
610
|
+
# Species tree bipartitions
|
|
611
|
+
sp_splits = set()
|
|
612
|
+
for clade in species_tree.get_nonterminals():
|
|
613
|
+
tips = frozenset(t.name for t in clade.get_terminals())
|
|
614
|
+
if len(tips) <= 1 or tips == all_taxa_fs:
|
|
615
|
+
continue
|
|
616
|
+
sp_splits.add(self._canonical_split(tips, all_taxa_fs))
|
|
617
|
+
|
|
618
|
+
concordant_treeness = []
|
|
619
|
+
discordant_treeness = []
|
|
620
|
+
|
|
621
|
+
for gt in gene_trees:
|
|
622
|
+
gt_splits = set()
|
|
623
|
+
for clade in gt.get_nonterminals():
|
|
624
|
+
tips = frozenset(t.name for t in clade.get_terminals())
|
|
625
|
+
if len(tips) <= 1 or tips == all_taxa_fs:
|
|
626
|
+
continue
|
|
627
|
+
gt_splits.add(self._canonical_split(tips, all_taxa_fs))
|
|
628
|
+
|
|
629
|
+
treeness = self._compute_treeness(gt)
|
|
630
|
+
|
|
631
|
+
if gt_splits == sp_splits:
|
|
632
|
+
concordant_treeness.append(treeness)
|
|
633
|
+
else:
|
|
634
|
+
discordant_treeness.append(treeness)
|
|
635
|
+
|
|
636
|
+
result = dict(
|
|
637
|
+
treeness_concordant=dict(
|
|
638
|
+
mean=float(np.mean(concordant_treeness)) if concordant_treeness else None,
|
|
639
|
+
median=float(np.median(concordant_treeness)) if concordant_treeness else None,
|
|
640
|
+
n=len(concordant_treeness),
|
|
641
|
+
),
|
|
642
|
+
treeness_discordant=dict(
|
|
643
|
+
mean=float(np.mean(discordant_treeness)) if discordant_treeness else None,
|
|
644
|
+
median=float(np.median(discordant_treeness)) if discordant_treeness else None,
|
|
645
|
+
n=len(discordant_treeness),
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if len(concordant_treeness) >= 2 and len(discordant_treeness) >= 2:
|
|
650
|
+
_, p = mannwhitneyu(
|
|
651
|
+
concordant_treeness, discordant_treeness,
|
|
652
|
+
alternative="two-sided",
|
|
653
|
+
)
|
|
654
|
+
result["treeness_U_p"] = float(p)
|
|
655
|
+
else:
|
|
656
|
+
result["treeness_U_p"] = None
|
|
657
|
+
|
|
658
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.1.31"
|
|
@@ -63,6 +63,7 @@ phykit/services/tree/cophylo.py
|
|
|
63
63
|
phykit/services/tree/covarying_evolutionary_rates.py
|
|
64
64
|
phykit/services/tree/density_map.py
|
|
65
65
|
phykit/services/tree/dvmc.py
|
|
66
|
+
phykit/services/tree/evo_tempo_map.py
|
|
66
67
|
phykit/services/tree/evolutionary_rate.py
|
|
67
68
|
phykit/services/tree/fit_continuous.py
|
|
68
69
|
phykit/services/tree/hidden_paralogy_check.py
|
phykit-2.1.30/phykit/version.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.1.30"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|