phykit 2.1.44__tar.gz → 2.1.46__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 (120) hide show
  1. {phykit-2.1.44 → phykit-2.1.46}/PKG-INFO +1 -1
  2. {phykit-2.1.44 → phykit-2.1.46}/phykit/cli_registry.py +4 -0
  3. {phykit-2.1.44 → phykit-2.1.46}/phykit/phykit.py +157 -0
  4. {phykit-2.1.44 → phykit-2.1.46}/phykit/service_factories.py +2 -0
  5. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/__init__.py +2 -0
  6. phykit-2.1.46/phykit/services/tree/independent_contrasts.py +210 -0
  7. phykit-2.1.46/phykit/services/tree/phylo_heatmap.py +315 -0
  8. phykit-2.1.46/phykit/version.py +1 -0
  9. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/PKG-INFO +1 -1
  10. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/SOURCES.txt +2 -0
  11. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/entry_points.txt +6 -0
  12. {phykit-2.1.44 → phykit-2.1.46}/setup.py +6 -0
  13. phykit-2.1.44/phykit/version.py +0 -1
  14. {phykit-2.1.44 → phykit-2.1.46}/LICENSE.md +0 -0
  15. {phykit-2.1.44 → phykit-2.1.46}/README.md +0 -0
  16. {phykit-2.1.44 → phykit-2.1.46}/phykit/__init__.py +0 -0
  17. {phykit-2.1.44 → phykit-2.1.46}/phykit/__main__.py +0 -0
  18. {phykit-2.1.44 → phykit-2.1.46}/phykit/errors.py +0 -0
  19. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/__init__.py +0 -0
  20. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/boolean_argument_parsing.py +0 -0
  21. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/caching.py +0 -0
  22. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/discrete_models.py +0 -0
  23. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/files.py +0 -0
  24. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/json_output.py +0 -0
  25. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/parallel.py +0 -0
  26. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/plot_config.py +0 -0
  27. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/quartet_utils.py +0 -0
  28. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/stats_summary.py +0 -0
  29. {phykit-2.1.44 → phykit-2.1.46}/phykit/helpers/streaming.py +0 -0
  30. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/__init__.py +0 -0
  31. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/__init__.py +0 -0
  32. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/alignment_entropy.py +0 -0
  33. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/alignment_length.py +0 -0
  34. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
  35. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
  36. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/alignment_recoding.py +0 -0
  37. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/base.py +0 -0
  38. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/column_score.py +0 -0
  39. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/composition_per_taxon.py +0 -0
  40. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
  41. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
  42. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/dna_threader.py +0 -0
  43. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
  44. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/faidx.py +0 -0
  45. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/gc_content.py +0 -0
  46. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/mask_alignment.py +0 -0
  47. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
  48. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/pairwise_identity.py +0 -0
  49. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
  50. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/plot_alignment_qc.py +0 -0
  51. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/rcv.py +0 -0
  52. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/rcvt.py +0 -0
  53. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/rename_fasta_entries.py +0 -0
  54. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
  55. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/alignment/variable_sites.py +0 -0
  56. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/base.py +0 -0
  57. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/ancestral_reconstruction.py +0 -0
  58. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/base.py +0 -0
  59. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/bipartition_support_stats.py +0 -0
  60. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/branch_length_multiplier.py +0 -0
  61. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/collapse_branches.py +0 -0
  62. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/concordance_asr.py +0 -0
  63. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/consensus_network.py +0 -0
  64. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/consensus_tree.py +0 -0
  65. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/cont_map.py +0 -0
  66. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/cophylo.py +0 -0
  67. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
  68. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/density_map.py +0 -0
  69. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/discordance_asymmetry.py +0 -0
  70. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/dvmc.py +0 -0
  71. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/evo_tempo_map.py +0 -0
  72. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/evolutionary_rate.py +0 -0
  73. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/fit_continuous.py +0 -0
  74. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/fit_discrete.py +0 -0
  75. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/hidden_paralogy_check.py +0 -0
  76. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/internal_branch_stats.py +0 -0
  77. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/internode_labeler.py +0 -0
  78. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/kf_distance.py +0 -0
  79. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
  80. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/lb_score.py +0 -0
  81. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/ltt.py +0 -0
  82. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/monophyly_check.py +0 -0
  83. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
  84. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/network_signal.py +0 -0
  85. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/ou_shift_detection.py +0 -0
  86. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/ouwie.py +0 -0
  87. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/patristic_distances.py +0 -0
  88. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phenogram.py +0 -0
  89. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phylogenetic_glm.py +0 -0
  90. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phylogenetic_ordination.py +0 -0
  91. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phylogenetic_regression.py +0 -0
  92. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phylogenetic_signal.py +0 -0
  93. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/phylomorphospace.py +0 -0
  94. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/polytomy_test.py +0 -0
  95. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/print_tree.py +0 -0
  96. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/prune_tree.py +0 -0
  97. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/quartet_network.py +0 -0
  98. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/quartet_pie.py +0 -0
  99. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/rate_heterogeneity.py +0 -0
  100. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/relative_rate_test.py +0 -0
  101. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/rename_tree_tips.py +0 -0
  102. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/rf_distance.py +0 -0
  103. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/root_tree.py +0 -0
  104. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/saturation.py +0 -0
  105. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/spectral_discordance.py +0 -0
  106. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/spurious_sequence.py +0 -0
  107. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/stochastic_character_map.py +0 -0
  108. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/terminal_branch_stats.py +0 -0
  109. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/threshold_model.py +0 -0
  110. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/tip_labels.py +0 -0
  111. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/tip_to_tip_distance.py +0 -0
  112. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
  113. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/total_tree_length.py +0 -0
  114. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/treeness.py +0 -0
  115. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/treeness_over_rcv.py +0 -0
  116. {phykit-2.1.44 → phykit-2.1.46}/phykit/services/tree/vcv_utils.py +0 -0
  117. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/dependency_links.txt +0 -0
  118. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/requires.txt +0 -0
  119. {phykit-2.1.44 → phykit-2.1.46}/phykit.egg-info/top_level.txt +0 -0
  120. {phykit-2.1.44 → phykit-2.1.46}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.44
3
+ Version: 2.1.46
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -80,12 +80,16 @@ ALIAS_TO_HANDLER: Dict[str, str] = {
80
80
  "phylo_dimreduce": "phylogenetic_ordination",
81
81
  "dimreduce": "phylogenetic_ordination",
82
82
  "pdr": "phylogenetic_ordination",
83
+ "pheatmap": "phylo_heatmap",
84
+ "ph": "phylo_heatmap",
83
85
  "phylomorpho": "phylomorphospace",
84
86
  "phmo": "phylomorphospace",
85
87
  "phylo_regression": "phylogenetic_regression",
86
88
  "pgls": "phylogenetic_regression",
87
89
  "phylo_glm": "phylogenetic_glm",
88
90
  "pglm": "phylogenetic_glm",
91
+ "pic": "independent_contrasts",
92
+ "phylo_contrasts": "independent_contrasts",
89
93
  "asr": "ancestral_state_reconstruction",
90
94
  "anc_recon": "ancestral_state_reconstruction",
91
95
  "conc_asr": "concordance_asr",
@@ -161,6 +161,8 @@ class Phykit:
161
161
 
162
162
  Tree-based commands
163
163
  ===================
164
+ independent_contrasts (alias: pic; phylo_contrasts)
165
+ - Felsenstein's phylogenetically independent contrasts
164
166
  ancestral_state_reconstruction (alias: asr; anc_recon)
165
167
  - estimate ancestral states for continuous traits using
166
168
  ML (fast or VCV-based) with optional contMap plot
@@ -207,6 +209,8 @@ class Phykit:
207
209
  phylo_pca; phyl_pca; ppca; phylo_dimreduce; dimreduce; pdr)
208
210
  - phylogenetic ordination (PCA, t-SNE, or UMAP) on
209
211
  continuous multi-trait data
212
+ phylo_heatmap (alias: pheatmap; ph)
213
+ - phylogeny alongside a heatmap of numeric trait values
210
214
  phylomorphospace (alias: phylomorpho; phmo)
211
215
  - plot raw traits with phylogeny overlaid via ancestral
212
216
  reconstruction
@@ -1675,6 +1679,57 @@ class Phykit:
1675
1679
  _run_service(parser, argv, VariableSites)
1676
1680
 
1677
1681
  ## Tree functions
1682
+ @staticmethod
1683
+ def independent_contrasts(argv):
1684
+ parser = _new_parser(
1685
+ description=textwrap.dedent(
1686
+ f"""\
1687
+ {help_header}
1688
+
1689
+ Compute Felsenstein's (1985) phylogenetically independent
1690
+ contrasts (PIC) for a continuous trait on a phylogeny.
1691
+
1692
+ Each internal node yields one standardized contrast,
1693
+ producing n-1 contrasts for n tips. Contrasts are
1694
+ computed by postorder traversal, dividing the trait
1695
+ difference between sister clades by the square root of
1696
+ the sum of their branch lengths.
1697
+
1698
+ Multifurcations are automatically resolved by adding
1699
+ zero-length branches.
1700
+
1701
+ Cross-validated against R's ape::pic().
1702
+
1703
+ Aliases:
1704
+ independent_contrasts, pic, phylo_contrasts
1705
+ Command line interfaces:
1706
+ pk_independent_contrasts, pk_pic
1707
+
1708
+ Usage:
1709
+ phykit independent_contrasts -t <tree> -d <trait_data>
1710
+ [--json]
1711
+
1712
+ Options
1713
+ =====================================================
1714
+ -t/--tree tree file (required)
1715
+
1716
+ -d/--trait_data trait data file, two columns:
1717
+ taxon<tab>value (required)
1718
+
1719
+ --json optional argument to output
1720
+ results as JSON
1721
+ """
1722
+ ),
1723
+ )
1724
+ parser.add_argument(
1725
+ "-t", "--tree", type=str, required=True, help=SUPPRESS, metavar=""
1726
+ )
1727
+ parser.add_argument(
1728
+ "-d", "--trait_data", type=str, required=True, help=SUPPRESS, metavar=""
1729
+ )
1730
+ _add_json_argument(parser)
1731
+ _run_service(parser, argv, IndependentContrasts)
1732
+
1678
1733
  @staticmethod
1679
1734
  def ancestral_state_reconstruction(argv):
1680
1735
  parser = _new_parser(
@@ -3013,6 +3068,100 @@ class Phykit:
3013
3068
  def phylogenetic_dimreduce(argv):
3014
3069
  Phykit.phylogenetic_ordination(argv)
3015
3070
 
3071
+ @staticmethod
3072
+ def phylo_heatmap(argv):
3073
+ parser = _new_parser(
3074
+ description=textwrap.dedent(
3075
+ f"""\
3076
+ {help_header}
3077
+
3078
+ Draw a phylogenetic heatmap: a phylogeny alongside a
3079
+ color-coded matrix of numeric trait values. Rows are
3080
+ aligned to tree tips.
3081
+
3082
+ Analogous to R's phytools::phylo.heatmap().
3083
+
3084
+ Aliases:
3085
+ phylo_heatmap, pheatmap, ph
3086
+ Command line interfaces:
3087
+ pk_phylo_heatmap, pk_pheatmap, pk_ph
3088
+
3089
+ Usage:
3090
+ phykit phylo_heatmap -t <tree> -d <data> -o <output>
3091
+ [--split 0.3] [--standardize] [--cmap viridis]
3092
+ [--json]
3093
+ [--fig-width <float>] [--fig-height <float>]
3094
+ [--dpi <int>] [--no-title] [--title <str>]
3095
+ [--ylabel-fontsize <float>] [--xlabel-fontsize <float>]
3096
+
3097
+ Options
3098
+ =====================================================
3099
+ -t/--tree tree file (required)
3100
+
3101
+ -d/--data numeric data matrix in TSV
3102
+ format with header row
3103
+ (required)
3104
+
3105
+ -o/--output output figure path (required;
3106
+ supports .png, .pdf, .svg)
3107
+
3108
+ --split fraction of figure width for
3109
+ the tree panel (default: 0.3)
3110
+
3111
+ --standardize z-score each column before
3112
+ coloring
3113
+
3114
+ --cmap matplotlib colormap name
3115
+ (default: viridis)
3116
+
3117
+ --fig-width figure width in inches
3118
+ (auto-scaled if omitted)
3119
+
3120
+ --fig-height figure height in inches
3121
+ (auto-scaled if omitted)
3122
+
3123
+ --dpi resolution in DPI
3124
+ (default: 300)
3125
+
3126
+ --no-title hide the plot title
3127
+
3128
+ --title custom title text
3129
+
3130
+ --ylabel-fontsize font size for taxon labels;
3131
+ 0 to hide
3132
+
3133
+ --xlabel-fontsize font size for trait column
3134
+ labels; 0 to hide
3135
+
3136
+ --json optional argument to output
3137
+ metadata as JSON
3138
+ """
3139
+ ),
3140
+ )
3141
+ parser.add_argument(
3142
+ "-t", "--tree", type=str, required=True, help=SUPPRESS, metavar=""
3143
+ )
3144
+ parser.add_argument(
3145
+ "-d", "--data", type=str, required=True, help=SUPPRESS, metavar=""
3146
+ )
3147
+ parser.add_argument(
3148
+ "-o", "--output", type=str, required=True, help=SUPPRESS, metavar=""
3149
+ )
3150
+ parser.add_argument(
3151
+ "--split", type=float, required=False, default=0.3,
3152
+ help=SUPPRESS, metavar=""
3153
+ )
3154
+ parser.add_argument(
3155
+ "--standardize", action="store_true", required=False, help=SUPPRESS
3156
+ )
3157
+ parser.add_argument(
3158
+ "--cmap", type=str, required=False, default="viridis",
3159
+ help=SUPPRESS, metavar=""
3160
+ )
3161
+ add_plot_arguments(parser)
3162
+ _add_json_argument(parser)
3163
+ _run_service(parser, argv, PhyloHeatmap)
3164
+
3016
3165
  @staticmethod
3017
3166
  def phylomorphospace(argv):
3018
3167
  parser = _new_parser(
@@ -6576,6 +6725,10 @@ def variable_sites(argv=None):
6576
6725
 
6577
6726
 
6578
6727
  # Tree-based functions
6728
+ def independent_contrasts(argv=None):
6729
+ Phykit.independent_contrasts(sys.argv[1:])
6730
+
6731
+
6579
6732
  def ancestral_state_reconstruction(argv=None):
6580
6733
  Phykit.ancestral_state_reconstruction(sys.argv[1:])
6581
6734
 
@@ -6656,6 +6809,10 @@ def phylogenetic_dimreduce(argv=None):
6656
6809
  Phykit.phylogenetic_ordination(sys.argv[1:])
6657
6810
 
6658
6811
 
6812
+ def phylo_heatmap(argv=None):
6813
+ Phykit.phylo_heatmap(sys.argv[1:])
6814
+
6815
+
6659
6816
  def phylomorphospace(argv=None):
6660
6817
  Phykit.phylomorphospace(sys.argv[1:])
6661
6818
 
@@ -83,10 +83,12 @@ RelativeRateTest = _LazyServiceFactory("phykit.services.tree.relative_rate_test"
83
83
  ThresholdModel = _LazyServiceFactory("phykit.services.tree.threshold_model", "ThresholdModel")
84
84
  PolytomyTest = _LazyServiceFactory("phykit.services.tree.polytomy_test", "PolytomyTest")
85
85
  PrintTree = _LazyServiceFactory("phykit.services.tree.print_tree", "PrintTree")
86
+ PhyloHeatmap = _LazyServiceFactory("phykit.services.tree.phylo_heatmap", "PhyloHeatmap")
86
87
  PruneTree = _LazyServiceFactory("phykit.services.tree.prune_tree", "PruneTree")
87
88
  QuartetPie = _LazyServiceFactory("phykit.services.tree.quartet_pie", "QuartetPie")
88
89
  RenameTreeTips = _LazyServiceFactory("phykit.services.tree.rename_tree_tips", "RenameTreeTips")
89
90
  FitDiscrete = _LazyServiceFactory("phykit.services.tree.fit_discrete", "FitDiscrete")
91
+ IndependentContrasts = _LazyServiceFactory("phykit.services.tree.independent_contrasts", "IndependentContrasts")
90
92
  KuhnerFelsensteinDistance = _LazyServiceFactory("phykit.services.tree.kf_distance", "KuhnerFelsensteinDistance")
91
93
  RobinsonFouldsDistance = _LazyServiceFactory("phykit.services.tree.rf_distance", "RobinsonFouldsDistance")
92
94
  RootTree = _LazyServiceFactory("phykit.services.tree.root_tree", "RootTree")
@@ -27,9 +27,11 @@ _EXPORTS = {
27
27
  "RelativeRateTest": "relative_rate_test",
28
28
  "PolytomyTest": "polytomy_test",
29
29
  "PrintTree": "print_tree",
30
+ "PhyloHeatmap": "phylo_heatmap",
30
31
  "PruneTree": "prune_tree",
31
32
  "QuartetPie": "quartet_pie",
32
33
  "RenameTreeTips": "rename_tree_tips",
34
+ "IndependentContrasts": "independent_contrasts",
33
35
  "KuhnerFelsensteinDistance": "kf_distance",
34
36
  "RobinsonFouldsDistance": "rf_distance",
35
37
  "RootTree": "root_tree",
@@ -0,0 +1,210 @@
1
+ """
2
+ Phylogenetically independent contrasts (PIC).
3
+
4
+ Computes Felsenstein's (1985) phylogenetically independent contrasts
5
+ for a continuous trait on a phylogeny. Each internal node yields one
6
+ contrast (standardized difference), producing n-1 contrasts for n tips.
7
+
8
+ Cross-validated against R's ape::pic().
9
+ """
10
+ import copy
11
+ import sys
12
+ from typing import Dict, List, Tuple
13
+
14
+ import numpy as np
15
+
16
+ from .base import Tree
17
+ from ...helpers.json_output import print_json
18
+ from ...errors import PhykitUserError
19
+
20
+
21
+ class IndependentContrasts(Tree):
22
+ def __init__(self, args) -> None:
23
+ parsed = self.process_args(args)
24
+ super().__init__(tree_file_path=parsed["tree_file_path"])
25
+ self.trait_data_path = parsed["trait_data_path"]
26
+ self.json_output = parsed["json_output"]
27
+
28
+ def run(self) -> None:
29
+ tree = self.read_tree_file()
30
+ tree = copy.deepcopy(tree)
31
+ self._validate_tree(tree)
32
+
33
+ tree_tips = [t.name for t in tree.get_terminals()]
34
+ tip_traits = self._parse_trait_data(self.trait_data_path, tree_tips)
35
+
36
+ # Prune tree to shared taxa
37
+ shared = set(tip_traits.keys())
38
+ tips_to_prune = [t for t in tree_tips if t not in shared]
39
+ if tips_to_prune:
40
+ tree = self.prune_tree_using_taxa_list(tree, tips_to_prune)
41
+
42
+ # Resolve multifurcations (PIC requires fully dichotomous tree)
43
+ self._resolve_polytomies(tree)
44
+
45
+ contrasts, node_labels = self._compute_pic(tree, tip_traits)
46
+
47
+ if self.json_output:
48
+ self._print_json(contrasts, node_labels, tip_traits)
49
+ else:
50
+ self._print_text(contrasts, node_labels)
51
+
52
+ def process_args(self, args) -> Dict:
53
+ return dict(
54
+ tree_file_path=args.tree,
55
+ trait_data_path=args.trait_data,
56
+ json_output=getattr(args, "json", False),
57
+ )
58
+
59
+ def _validate_tree(self, tree) -> None:
60
+ tips = list(tree.get_terminals())
61
+ if len(tips) < 3:
62
+ raise PhykitUserError(
63
+ ["Tree must have at least 3 tips."], code=2
64
+ )
65
+ for clade in tree.find_clades():
66
+ if clade.branch_length is None and clade != tree.root:
67
+ clade.branch_length = 1e-8
68
+
69
+ def _parse_trait_data(
70
+ self, path: str, tree_tips: List[str]
71
+ ) -> Dict[str, float]:
72
+ """Parse two-column trait file (taxon<tab>value)."""
73
+ try:
74
+ with open(path) as f:
75
+ lines = f.readlines()
76
+ except FileNotFoundError:
77
+ raise PhykitUserError(
78
+ [f"{path} not found. Check filename and path."], code=2
79
+ )
80
+
81
+ traits = {}
82
+ for line in lines:
83
+ stripped = line.strip()
84
+ if not stripped or stripped.startswith("#"):
85
+ continue
86
+ parts = stripped.split("\t")
87
+ if len(parts) != 2:
88
+ continue
89
+ try:
90
+ traits[parts[0]] = float(parts[1])
91
+ except ValueError:
92
+ continue
93
+
94
+ shared = set(tree_tips) & set(traits.keys())
95
+ if len(shared) < 3:
96
+ raise PhykitUserError(
97
+ [
98
+ f"Only {len(shared)} shared taxa between tree and trait file.",
99
+ "At least 3 shared taxa are required.",
100
+ ],
101
+ code=2,
102
+ )
103
+
104
+ return {t: traits[t] for t in shared}
105
+
106
+ def _resolve_polytomies(self, tree) -> None:
107
+ """Resolve multifurcations by adding zero-length branches."""
108
+ for clade in tree.find_clades(order="postorder"):
109
+ while len(clade.clades) > 2:
110
+ # Take the last two children and merge them
111
+ from Bio.Phylo import Newick
112
+ child1 = clade.clades.pop()
113
+ child2 = clade.clades.pop()
114
+ new_internal = Newick.Clade(branch_length=0.0)
115
+ new_internal.clades = [child1, child2]
116
+ clade.clades.append(new_internal)
117
+
118
+ def _compute_pic(
119
+ self, tree, tip_traits: Dict[str, float]
120
+ ) -> Tuple[List[float], List[List[str]]]:
121
+ """Compute phylogenetically independent contrasts.
122
+
123
+ Felsenstein (1985) algorithm:
124
+ - Postorder traversal through tree
125
+ - At each internal node with children L, R:
126
+ contrast = (x_L - x_R) / sqrt(v_L + v_R)
127
+ x_node = (x_L/v_L + x_R/v_R) / (1/v_L + 1/v_R)
128
+ v_node += v_L * v_R / (v_L + v_R)
129
+
130
+ Returns (contrasts, node_tip_labels) where node_tip_labels[i]
131
+ is the list of tip names descending from the node that produced
132
+ contrast[i].
133
+ """
134
+ # Store trait values and effective branch lengths per node
135
+ node_val = {}
136
+ node_bl = {} # effective branch length (adjusted during traversal)
137
+
138
+ # Initialize tips
139
+ for tip in tree.get_terminals():
140
+ if tip.name in tip_traits:
141
+ node_val[id(tip)] = tip_traits[tip.name]
142
+ node_bl[id(tip)] = tip.branch_length if tip.branch_length else 1e-8
143
+
144
+ contrasts = []
145
+ node_labels = []
146
+
147
+ for clade in tree.find_clades(order="postorder"):
148
+ if clade.is_terminal():
149
+ continue
150
+ if len(clade.clades) != 2:
151
+ continue
152
+
153
+ left, right = clade.clades
154
+ lid, rid = id(left), id(right)
155
+
156
+ if lid not in node_val or rid not in node_val:
157
+ continue
158
+
159
+ x_l = node_val[lid]
160
+ x_r = node_val[rid]
161
+ v_l = node_bl[lid]
162
+ v_r = node_bl[rid]
163
+
164
+ # Standardized contrast
165
+ contrast = (x_l - x_r) / np.sqrt(v_l + v_r)
166
+ contrasts.append(float(contrast))
167
+
168
+ # Tip labels for this node
169
+ tips_here = sorted(t.name for t in clade.get_terminals())
170
+ node_labels.append(tips_here)
171
+
172
+ # Weighted average trait value for this node
173
+ node_val[id(clade)] = (x_l / v_l + x_r / v_r) / (1.0 / v_l + 1.0 / v_r)
174
+
175
+ # Adjusted branch length
176
+ parent_bl = clade.branch_length if clade.branch_length else 0.0
177
+ node_bl[id(clade)] = parent_bl + (v_l * v_r) / (v_l + v_r)
178
+
179
+ return contrasts, node_labels
180
+
181
+ def _print_text(self, contrasts, node_labels):
182
+ print(f"Number of contrasts: {len(contrasts)}")
183
+ print()
184
+ print(f"{'Node':<6}{'Contrast':>12} Tips")
185
+ print("-" * 60)
186
+ for i, (c, tips) in enumerate(zip(contrasts, node_labels), 1):
187
+ tips_str = ", ".join(tips[:3])
188
+ if len(tips) > 3:
189
+ tips_str += f", ... ({len(tips)} total)"
190
+ print(f"{i:<6}{c:>12.6f} {tips_str}")
191
+ print()
192
+ print(f"Mean absolute contrast: {np.mean(np.abs(contrasts)):.6f}")
193
+ print(f"Variance of contrasts: {np.var(contrasts, ddof=1):.6f}")
194
+
195
+ def _print_json(self, contrasts, node_labels, tip_traits):
196
+ nodes = []
197
+ for i, (c, tips) in enumerate(zip(contrasts, node_labels)):
198
+ nodes.append({
199
+ "node": i + 1,
200
+ "contrast": round(c, 6),
201
+ "tips": tips,
202
+ })
203
+ payload = {
204
+ "n_taxa": len(tip_traits),
205
+ "n_contrasts": len(contrasts),
206
+ "contrasts": nodes,
207
+ "mean_absolute_contrast": round(float(np.mean(np.abs(contrasts))), 6),
208
+ "variance_of_contrasts": round(float(np.var(contrasts, ddof=1)), 6),
209
+ }
210
+ print_json(payload)