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.
Files changed (110) hide show
  1. {phykit-2.1.30 → phykit-2.1.31}/PKG-INFO +1 -1
  2. {phykit-2.1.30 → phykit-2.1.31}/phykit/cli_registry.py +1 -0
  3. {phykit-2.1.30 → phykit-2.1.31}/phykit/phykit.py +69 -0
  4. {phykit-2.1.30 → phykit-2.1.31}/phykit/service_factories.py +1 -0
  5. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/__init__.py +1 -0
  6. phykit-2.1.31/phykit/services/tree/evo_tempo_map.py +658 -0
  7. phykit-2.1.31/phykit/version.py +1 -0
  8. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/PKG-INFO +1 -1
  9. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/SOURCES.txt +1 -0
  10. phykit-2.1.30/phykit/version.py +0 -1
  11. {phykit-2.1.30 → phykit-2.1.31}/LICENSE.md +0 -0
  12. {phykit-2.1.30 → phykit-2.1.31}/README.md +0 -0
  13. {phykit-2.1.30 → phykit-2.1.31}/phykit/__init__.py +0 -0
  14. {phykit-2.1.30 → phykit-2.1.31}/phykit/__main__.py +0 -0
  15. {phykit-2.1.30 → phykit-2.1.31}/phykit/errors.py +0 -0
  16. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/__init__.py +0 -0
  17. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/boolean_argument_parsing.py +0 -0
  18. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/caching.py +0 -0
  19. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/files.py +0 -0
  20. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/json_output.py +0 -0
  21. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/parallel.py +0 -0
  22. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/stats_summary.py +0 -0
  23. {phykit-2.1.30 → phykit-2.1.31}/phykit/helpers/streaming.py +0 -0
  24. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/__init__.py +0 -0
  25. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/__init__.py +0 -0
  26. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_entropy.py +0 -0
  27. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_length.py +0 -0
  28. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
  29. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
  30. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/alignment_recoding.py +0 -0
  31. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/base.py +0 -0
  32. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/column_score.py +0 -0
  33. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/composition_per_taxon.py +0 -0
  34. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
  35. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
  36. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/dna_threader.py +0 -0
  37. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
  38. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/faidx.py +0 -0
  39. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/gc_content.py +0 -0
  40. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/mask_alignment.py +0 -0
  41. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
  42. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/pairwise_identity.py +0 -0
  43. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
  44. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/plot_alignment_qc.py +0 -0
  45. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rcv.py +0 -0
  46. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rcvt.py +0 -0
  47. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/rename_fasta_entries.py +0 -0
  48. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
  49. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/alignment/variable_sites.py +0 -0
  50. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/base.py +0 -0
  51. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ancestral_reconstruction.py +0 -0
  52. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/base.py +0 -0
  53. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/bipartition_support_stats.py +0 -0
  54. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/branch_length_multiplier.py +0 -0
  55. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/collapse_branches.py +0 -0
  56. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/concordance_asr.py +0 -0
  57. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/consensus_network.py +0 -0
  58. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/consensus_tree.py +0 -0
  59. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/cont_map.py +0 -0
  60. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/cophylo.py +0 -0
  61. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
  62. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/density_map.py +0 -0
  63. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/dvmc.py +0 -0
  64. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/evolutionary_rate.py +0 -0
  65. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/fit_continuous.py +0 -0
  66. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/hidden_paralogy_check.py +0 -0
  67. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/internal_branch_stats.py +0 -0
  68. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/internode_labeler.py +0 -0
  69. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
  70. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/lb_score.py +0 -0
  71. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ltt.py +0 -0
  72. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/monophyly_check.py +0 -0
  73. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
  74. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/network_signal.py +0 -0
  75. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ou_shift_detection.py +0 -0
  76. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/ouwie.py +0 -0
  77. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/patristic_distances.py +0 -0
  78. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phenogram.py +0 -0
  79. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_glm.py +0 -0
  80. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_ordination.py +0 -0
  81. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_regression.py +0 -0
  82. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylogenetic_signal.py +0 -0
  83. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/phylomorphospace.py +0 -0
  84. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/polytomy_test.py +0 -0
  85. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/print_tree.py +0 -0
  86. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/prune_tree.py +0 -0
  87. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/quartet_network.py +0 -0
  88. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rate_heterogeneity.py +0 -0
  89. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/relative_rate_test.py +0 -0
  90. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rename_tree_tips.py +0 -0
  91. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/rf_distance.py +0 -0
  92. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/root_tree.py +0 -0
  93. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/saturation.py +0 -0
  94. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/spurious_sequence.py +0 -0
  95. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/stochastic_character_map.py +0 -0
  96. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/terminal_branch_stats.py +0 -0
  97. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/threshold_model.py +0 -0
  98. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_labels.py +0 -0
  99. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_to_tip_distance.py +0 -0
  100. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
  101. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/total_tree_length.py +0 -0
  102. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/treeness.py +0 -0
  103. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/treeness_over_rcv.py +0 -0
  104. {phykit-2.1.30 → phykit-2.1.31}/phykit/services/tree/vcv_utils.py +0 -0
  105. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/dependency_links.txt +0 -0
  106. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/entry_points.txt +0 -0
  107. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/requires.txt +0 -0
  108. {phykit-2.1.30 → phykit-2.1.31}/phykit.egg-info/top_level.txt +0 -0
  109. {phykit-2.1.30 → phykit-2.1.31}/setup.cfg +0 -0
  110. {phykit-2.1.30 → phykit-2.1.31}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.30
3
+ Version: 2.1.31
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.30
3
+ Version: 2.1.31
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -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
@@ -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