descriptron-core 2.2.0__tar.gz → 2.3.0__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 (136) hide show
  1. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/PKG-INFO +1 -1
  2. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/pyproject.toml +1 -1
  3. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_phylo.py +37 -4
  4. descriptron_core-2.3.0/src/descriptron_core/tools/descriptron_trait_stats.py +528 -0
  5. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/run_full_pipeline_v2.py +161 -5
  6. descriptron_core-2.3.0/src/descriptron_core/tools/validate_trait_stats.py +134 -0
  7. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/.gitignore +0 -0
  8. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/LICENSE +0 -0
  9. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/NOTICE +0 -0
  10. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/README.md +0 -0
  11. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/__init__.py +0 -0
  12. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/cli.py +0 -0
  13. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
  14. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
  15. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/diaphorina_user_prompts.txt +0 -0
  16. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/ontologies/aism.obo +0 -0
  17. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/ontologies/pato.obo +0 -0
  18. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
  19. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
  20. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
  21. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
  22. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
  23. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
  24. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
  25. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
  26. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
  27. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
  28. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
  29. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
  30. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
  31. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
  32. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
  33. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/FHS_and_CLAHE_V21.py +0 -0
  34. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_add_discrete_characters_v1.py +0 -0
  35. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_annotation_reorient_v1.py +0 -0
  36. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_annotation_screen_v1.py +0 -0
  37. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_autapomorphy_v1.py +0 -0
  38. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_calibrate_v1.py +0 -0
  39. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_calibration_figures_v1.py +0 -0
  40. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_character_gate_v1.py +0 -0
  41. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_character_reliability_v1.py +0 -0
  42. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_character_robustness_v1.py +0 -0
  43. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_collaborator_forms_v1.py +0 -0
  44. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_computed_sets_v1.py +0 -0
  45. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_confabulation_checker_v2.py +0 -0
  46. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_congruence_compare_v1.py +0 -0
  47. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_description_refiner_v1.py +0 -0
  48. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_descriptive_scoring_v1.py +0 -0
  49. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_feature_policy.py +0 -0
  50. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_frame_orientation_calibrate_v1.py +0 -0
  51. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_frame_orientation_v1.py +0 -0
  52. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_gate_compare_v1.py +0 -0
  53. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_graph_identify_v1.py +0 -0
  54. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_graph_support_figure_v1.py +0 -0
  55. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_homology_frame_v1.py +0 -0
  56. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_instrument_compare_v3.py +0 -0
  57. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_builder_v1.py +0 -0
  58. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_feature_filter_v2.py +0 -0
  59. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_fuzzy_figures_v1.py +0 -0
  60. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_fuzzy_v1.py +0 -0
  61. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_holdout_v1.py +0 -0
  62. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_key_jackknife_support_v1.py +0 -0
  63. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_label_conflict_check_v1.py +0 -0
  64. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_llm_backend.py +0 -0
  65. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_make_taxon_profile_v1.py +0 -0
  66. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_methods_summary_figure_v1.py +0 -0
  67. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_novelty_score_v1.py +0 -0
  68. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_ontology_annotator_v2.py +0 -0
  69. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_outgroup_batch_check_v1.py +0 -0
  70. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_outline_shape_v1.py +0 -0
  71. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
  72. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
  73. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/diaphorina_user_prompts.txt +0 -0
  74. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/ontologies/aism.obo +0 -0
  75. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/ontologies/pato.obo +0 -0
  76. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
  77. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
  78. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
  79. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
  80. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
  81. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
  82. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
  83. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
  84. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
  85. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
  86. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
  87. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
  88. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
  89. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
  90. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
  91. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_provenance_v1.py +0 -0
  92. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_rebuild_downstream_v1.sh +0 -0
  93. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_reliability_figures_v1.py +0 -0
  94. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_rename_species_v1.py +0 -0
  95. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_rerun_descriptive_tables_v1.sh +0 -0
  96. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_retest_compare_v1.py +0 -0
  97. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_specimen_id.py +0 -0
  98. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_subjective_checks_v1.py +0 -0
  99. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_type_material_v1.py +0 -0
  100. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_vlm_characters_v1.py +0 -0
  101. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_vlm_heatmap_figures_v1.py +0 -0
  102. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_vlm_reliability_figure_v1.py +0 -0
  103. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biorag_vlm_roi_figures_v1.py +0 -0
  104. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biosyslit_rag_retrieval.py +0 -0
  105. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/biosyslit_rag_retrieval_v2.py +0 -0
  106. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/build_monograph_exports_v1.py +0 -0
  107. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/build_species_treatment_docx_v2.py +0 -0
  108. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/coco_combiner_V13.py +0 -0
  109. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/coco_converter_v24.py +0 -0
  110. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/color_extraction_with_names-v8.py +0 -0
  111. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/color_phenomics_homology_v2_1.py +0 -0
  112. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/compile_specimen_data.py +0 -0
  113. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_centerline.py +0 -0
  114. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_coco_tools.py +0 -0
  115. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_convert.py +0 -0
  116. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_credentials.py +0 -0
  117. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_joints.py +0 -0
  118. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_metadata.py +0 -0
  119. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_rosetta.py +0 -0
  120. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/descriptron_shape_stats.py +0 -0
  121. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/landmark_gpa_V1.py +0 -0
  122. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/landmark_gpa_V2.py +0 -0
  123. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/make_reproducibility_bundle.py +0 -0
  124. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV34_GPA.py +0 -0
  125. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV42_GPA.py +0 -0
  126. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/texture_phenomics_from_coco.py +0 -0
  127. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/texture_phenomics_homology.py +0 -0
  128. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_centerline_lengths.py +0 -0
  129. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_phylo_published_waldron2025.py +0 -0
  130. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_phylo_real_tree.py +0 -0
  131. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_phylo_traits.py +0 -0
  132. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_pipeline_gm_vs_geomorph.py +0 -0
  133. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_semilandmarks_vs_geomorph.py +0 -0
  134. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_shape_stats_vs_geomorph.py +0 -0
  135. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_shape_stats_vs_rrpp.py +0 -0
  136. {descriptron_core-2.2.0 → descriptron_core-2.3.0}/src/descriptron_core/tools/validate_texture_glcm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: descriptron-core
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Evidence-tiered species descriptions, keys and audits from phenomic matrices
5
5
  Project-URL: Homepage, https://github.com/alexrvandam/Descriptron
6
6
  Project-URL: Repository, https://github.com/alexrvandam/Descriptron
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "descriptron-core"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Evidence-tiered species descriptions, keys and audits from phenomic matrices"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -209,12 +209,37 @@ def _is_number(v: str) -> bool:
209
209
  return False
210
210
 
211
211
 
212
- def load_traits(path: str, species_col: Optional[str], species_regex: Optional[str], id_col: Optional[str]):
212
+ def _key_forms(s: str) -> List[str]:
213
+ """an id with and without Descriptron's annotation suffix ("x.png_3") and extension."""
214
+ s = str(s).strip(); base = re.sub(r"\.(png|jpe?g|tiff?|bmp)_\d+$", r".\1", s, flags=re.I)
215
+ stem = re.sub(r"\.(png|jpe?g|tiff?|bmp)$", "", base, flags=re.I)
216
+ return [s, base, stem, Path(stem).name]
217
+
218
+
219
+ def load_group_map(path: str, group_col: Optional[str] = None) -> Dict[str, str]:
220
+ """group labels CSV (filename + group_label/species): id -> species."""
221
+ rows = list(csv.DictReader(open(path, newline=""), delimiter="\t" if str(path).endswith(".tsv") else ","))
222
+ lc = {c.strip().lower(): c for c in rows[0]}
223
+ fcol = lc.get("filename") or lc.get("file") or lc.get("specimen") or list(rows[0])[0]
224
+ gcol = group_col or lc.get("group_label") or lc.get("species") or list(rows[0])[1]
225
+ m: Dict[str, str] = {}
226
+ for r in rows:
227
+ for k in _key_forms(r[fcol]):
228
+ m.setdefault(k, r[gcol])
229
+ return m
230
+
231
+
232
+ def load_traits(path: str, species_col: Optional[str], species_regex: Optional[str], id_col: Optional[str],
233
+ groups: Optional[Dict[str, str]] = None):
213
234
  rows = list(csv.DictReader(open(path, newline="")))
214
235
  if not rows:
215
236
  sys.exit(f"{path}: no rows")
216
237
  cols = list(rows[0])
217
- if species_col and species_col in cols:
238
+ if groups is not None and not (species_col and species_col in cols): # species from a group-labels file
239
+ idc = id_col or next((c for c in ("filename", "specimen_id", "specimen", "image", "id", "file") if c in cols), cols[0])
240
+ sp = [next((groups[k] for k in _key_forms(r[idc]) if k in groups), None) for r in rows]
241
+ species_col = idc # the id column is not a trait
242
+ elif species_col and species_col in cols:
218
243
  sp = [r[species_col] for r in rows]
219
244
  else:
220
245
  idc = id_col or next((c for c in ("filename", "specimen_id", "specimen", "image", "id", "file") if c in cols), cols[0])
@@ -279,6 +304,8 @@ def main(argv=None):
279
304
  help="a trait table; repeat for several sets (shape, measurements, colour, texture, ...)")
280
305
  ap.add_argument("--species_col", default=None, help="column holding the species (default: use --species_regex)")
281
306
  ap.add_argument("--species_regex", default=None, help="regex with one group extracting the species from the id column")
307
+ ap.add_argument("--groups", default=None, help="group labels CSV (filename + group_label/species) giving each "
308
+ "specimen's species, as for the rest of the pipeline (instead of --species_col/--species_regex)")
282
309
  ap.add_argument("--id_col", default=None, help="id column for --species_regex (default: filename/specimen_id/...)")
283
310
  ap.add_argument("--columns", action="append", default=[], metavar="NAME=REGEX",
284
311
  help="keep only the columns of trait set NAME that match REGEX")
@@ -295,12 +322,13 @@ def main(argv=None):
295
322
  rng = np.random.default_rng(a.seed)
296
323
  root = parse_newick(Path(a.tree).read_text())
297
324
  colsel = dict(x.split("=", 1) for x in a.columns)
325
+ gmap = load_group_map(a.groups) if a.groups else None
298
326
  scales = dict(x.split("=", 1) for x in a.scale)
299
327
 
300
328
  sets = {}
301
329
  for spec in a.traits:
302
330
  name, path = spec.split("=", 1)
303
- sp, cols, M, counts = load_traits(path, a.species_col, a.species_regex, a.id_col)
331
+ sp, cols, M, counts = load_traits(path, a.species_col, a.species_regex, a.id_col, gmap)
304
332
  if name in colsel:
305
333
  rx = re.compile(colsel[name]); k = [i for i, c in enumerate(cols) if rx.search(c)]
306
334
  cols, M = [cols[i] for i in k], M[:, k]
@@ -323,6 +351,9 @@ def main(argv=None):
323
351
  idx = [S["species"].index(t.name) for t in tips]
324
352
  how = scales.get(name, "none")
325
353
  Y = scale(S["raw"][idx], how)
354
+ if Y.shape[1] == 0 or not np.isfinite(Y).all() or float(Y.var(axis=0).sum()) == 0:
355
+ report += [f"## {name}", "no variable differs among the species (for example empty homology cells) - skipped", ""]
356
+ continue
326
357
  C = vcv(tips, tips)
327
358
  S.update(tree=tree, tips=tips, Y=Y, C=C, idx=idx)
328
359
  dropped = sorted(set(S["species"]) - set(common))
@@ -332,7 +363,9 @@ def main(argv=None):
332
363
  r = phylo_signal(Y, C, a.iterations, rng)
333
364
  signal_rows.append({"trait_set": name, "n_species": len(tips), "n_variables": Y.shape[1], "scale": how, **r})
334
365
  report.append(f"Phylogenetic signal: Kmult = {r['Kmult']:.4f}, P = {r['P']:.4f}, Z = {r['Z']:.2f}")
335
- if "morphospace" in a.analyses:
366
+ if "morphospace" in a.analyses and Y.shape[1] < 2:
367
+ report.append("Phylomorphospace: fewer than two variables - not drawn")
368
+ elif "morphospace" in a.analyses:
336
369
  nodes = internal_nodes(tree)
337
370
  anc = ancestral_states(Y, tips, nodes)
338
371
  mu = Y.mean(0)
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env python3
2
+ """descriptron_trait_stats.py - do the species differ, in which traits, and which species does each trait set separate?
3
+
4
+ For every trait set given (colour pattern, texture, outline or landmark shape, measurements, descriptive categorical
5
+ characters, or any other table with one row per specimen), against the species (or any group) labels:
6
+
7
+ PCA principal components of the standardised features; PC1 x PC2 plot with each species' convex hull
8
+ PERMANOVA species effect on PC1-k (Euclidean; = MANOVA sums of squares), R2, pseudo-F, permutation P;
9
+ optionally again after removing covariates - each specimen's mean colour (automatic for
10
+ colour tables: "does the pattern differ beyond overall colour?") and/or log size
11
+ pairwise PERMANOVA for every pair of species, Benjamini-Hochberg corrected: which species each trait set
12
+ separates, and which it does not
13
+ identification leave-one-out nearest-neighbour species assignment (PC1-5 and all PCs), overall and per species,
14
+ with Wilson 95% intervals; exact McNemar tests between trait sets on the same specimens
15
+ allometry trait set ~ log size (permutation P), when a size column is given
16
+ categorical for descriptive characters: association of each character with species (Cramer's V, permutation
17
+ P); the characters also enter PERMANOVA and identification as one-hot codes
18
+
19
+ The statistics are the ones checked against R: PERMANOVA against vegan::adonis2 (validation/validate_trait_stats.py),
20
+ and the benchmark's colour results (Supplementary Text S13.5) are reproduced exactly.
21
+
22
+ Trait tables: CSV with a specimen id column (--id_col, default: filename / file / specimen / id) and numeric columns.
23
+ Descriptron's own tables are read as they are: colour/texture homology features, measurements (all_metrics.csv),
24
+ and aligned outlines (aligned_coco.json from the semilandmark step). The id may carry Descriptron's "_<n>" annotation
25
+ suffix ("wing01.png_3"); it is matched to the group labels with and without it, and with and without extension.
26
+
27
+ python descriptron_trait_stats.py --groups group_labels.csv --out_dir trait_stats/ \
28
+ --traits colour=color_homology/forewing/color_homology_features_forewing_combined_hw1.csv \
29
+ --traits texture=texture_homology/forewing/texture_homology_features_forewing.csv \
30
+ --traits shape=semilandmarks/forewing/aligned_coco.json \
31
+ --categorical states=descriptive_states.csv --size meas=measurements/all_metrics.csv:area_mm2
32
+ """
33
+ import argparse, csv, itertools, json, math, re, sys
34
+ from pathlib import Path
35
+ import numpy as np
36
+
37
+ COLOUR_MEAN_RX = {"L": r"_L_mean_abs$", "a": r"_a_mean_abs$", "b": r"_b_mean_abs$"}
38
+
39
+
40
+ # ============================================================================ statistics
41
+ def _ssb(X, y, labs):
42
+ """between-group sum of squares of column-centred X for labels y (vectorised)."""
43
+ s = 0.0
44
+ for l in labs:
45
+ m = y == l
46
+ if m.any():
47
+ s += m.sum() * float((X[m].mean(0) ** 2).sum())
48
+ return s
49
+
50
+
51
+ def permanova(X, y, n_perm=999, seed=1, exact_max=None):
52
+ """Euclidean PERMANOVA (one factor): R2, pseudo-F, P. Exact enumeration when the number of distinct label
53
+ arrangements is at most n_perm (two groups only), otherwise random permutations."""
54
+ X = np.asarray(X, float); y = np.asarray(y)
55
+ X = X - X.mean(0); sst = float((X ** 2).sum()); labs = np.unique(y); g, n = len(labs), len(y)
56
+ if g < 2 or n <= g or sst == 0:
57
+ return float("nan"), float("nan"), float("nan")
58
+ b = _ssb(X, y, labs); f = (b / (g - 1)) / max((sst - b) / (n - g), 1e-300)
59
+ fstat = lambda bb: (bb / (g - 1)) / max((sst - bb) / (n - g), 1e-300)
60
+ if g == 2:
61
+ n1 = int((y == labs[0]).sum())
62
+ total = math.comb(n, n1)
63
+ if total <= (exact_max or n_perm):
64
+ cnt = 0
65
+ for idx in itertools.combinations(range(n), n1):
66
+ yp = np.full(n, labs[1], dtype=y.dtype); yp[list(idx)] = labs[0]
67
+ cnt += fstat(_ssb(X, yp, labs)) >= f - 1e-12 * abs(f)
68
+ return b / sst, f, cnt / total
69
+ rng = np.random.default_rng(seed); cnt = 0
70
+ for _ in range(n_perm):
71
+ cnt += fstat(_ssb(X, rng.permutation(y), labs)) >= f - 1e-12 * abs(f)
72
+ return b / sst, f, (cnt + 1) / (n_perm + 1)
73
+
74
+
75
+ def residualise(S, C):
76
+ C1 = np.column_stack([np.ones(len(C)), C]); beta, *_ = np.linalg.lstsq(C1, S, rcond=None)
77
+ return S - C1 @ beta
78
+
79
+
80
+ def loo_1nn(S, y):
81
+ """leave one specimen out, give it the species of its nearest remaining specimen. Returns per-specimen
82
+ (scored, correct, assigned) for specimens whose species has >= 2 specimens."""
83
+ out = []
84
+ for i in range(len(y)):
85
+ if (y == y[i]).sum() < 2:
86
+ out.append((False, False, None)); continue
87
+ d = np.linalg.norm(S - S[i], axis=1); d[i] = np.inf; j = int(d.argmin())
88
+ out.append((True, bool(y[j] == y[i]), y[j]))
89
+ return out
90
+
91
+
92
+ def wilson(k, n, z=1.959963984540054): # qnorm(0.975), as R
93
+ if n == 0:
94
+ return float("nan"), float("nan")
95
+ p = k / n; c = (p + z * z / (2 * n)) / (1 + z * z / n)
96
+ h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
97
+ return c - h, c + h
98
+
99
+
100
+ def mcnemar_exact(a, b):
101
+ """exact two-sided McNemar test on paired right/wrong calls; returns P, n(a right only), n(b right only)."""
102
+ from scipy.stats import binomtest
103
+ a = np.asarray(a, bool); b = np.asarray(b, bool)
104
+ n01 = int((a & ~b).sum()); n10 = int((~a & b).sum())
105
+ return (1.0 if n01 + n10 == 0 else binomtest(n01, n01 + n10, 0.5).pvalue), n01, n10
106
+
107
+
108
+ def bh(p):
109
+ p = np.asarray(p, float); n = len(p); q = np.full(n, np.nan)
110
+ ok = ~np.isnan(p); pv = p[ok]
111
+ if not len(pv):
112
+ return q
113
+ o = np.argsort(pv); r = pv[o] * len(pv) / (np.arange(len(pv)) + 1)
114
+ r = np.minimum.accumulate(r[::-1])[::-1]; qq = np.empty_like(r); qq[o] = np.minimum(r, 1)
115
+ q[ok] = qq
116
+ return q
117
+
118
+
119
+ def regression_perm(S, x, n_perm=999, seed=1):
120
+ """multivariate regression of S on one predictor: R2 and permutation P (predictor permuted)."""
121
+ S = S - S.mean(0); x = np.asarray(x, float) - np.mean(x); sst = float((S ** 2).sum())
122
+ def r2(xx):
123
+ beta = (xx @ S) / (xx @ xx); return float(((np.outer(xx, beta)) ** 2).sum()) / sst
124
+ obs = r2(x); rng = np.random.default_rng(seed)
125
+ cnt = sum(r2(rng.permutation(x)) >= obs - 1e-12 for _ in range(n_perm))
126
+ return obs, (cnt + 1) / (n_perm + 1)
127
+
128
+
129
+ def cramers_v_perm(col, y, n_perm=999, seed=1):
130
+ """association of one categorical character with the groups: Cramer's V and permutation P (chi-square)."""
131
+ import pandas as pd
132
+ ok = pd.notna(col) & (col.astype(str) != "")
133
+ c, yy = col[ok].astype(str).values, np.asarray(y)[ok.values]
134
+ if len(set(c)) < 2 or len(set(yy)) < 2:
135
+ return float("nan"), float("nan"), int(ok.sum())
136
+ def chi(yv):
137
+ t = pd.crosstab(c, yv).values.astype(float); e = t.sum(1, keepdims=True) * t.sum(0, keepdims=True) / t.sum()
138
+ return float(((t - e) ** 2 / np.where(e > 0, e, 1)).sum()), t.shape
139
+ x2, (r, k) = chi(yy); v = math.sqrt(x2 / (len(c) * max(1, min(r, k) - 1)))
140
+ rng = np.random.default_rng(seed)
141
+ cnt = sum(chi(rng.permutation(yy))[0] >= x2 - 1e-9 for _ in range(n_perm))
142
+ return v, (cnt + 1) / (n_perm + 1), int(ok.sum())
143
+
144
+
145
+ # ============================================================================ input
146
+ def _key_forms(s):
147
+ s = str(s).strip(); base = re.sub(r"\.(png|jpe?g|tiff?|bmp)_\d+$", r".\1", s, flags=re.I)
148
+ base = re.sub(r"_\d+$", "", base) if base == s and re.search(r"\.[A-Za-z]{2,4}_\d+$", s) else base
149
+ stem = re.sub(r"\.(png|jpe?g|tiff?|bmp)$", "", base, flags=re.I)
150
+ return [s, base, stem, Path(stem).name, Path(base).name]
151
+
152
+
153
+ def load_groups(path, col=None):
154
+ import pandas as pd
155
+ d = pd.read_csv(path, sep="\t" if str(path).endswith(".tsv") else ",")
156
+ lc = {c.strip().lower(): c for c in d.columns}
157
+ fcol = lc.get("filename") or lc.get("file") or lc.get("specimen") or d.columns[0]
158
+ gcol = col or lc.get("group_label") or lc.get("species") or lc.get("group") or d.columns[1]
159
+ m = {}
160
+ for f, g in zip(d[fcol].astype(str), d[gcol].astype(str)):
161
+ for k in _key_forms(f):
162
+ m.setdefault(k, g)
163
+ return m
164
+
165
+
166
+ def group_of(sid, gmap):
167
+ for k in _key_forms(sid):
168
+ if k in gmap:
169
+ return gmap[k]
170
+ return None
171
+
172
+
173
+ def load_table(path, id_col=None):
174
+ """returns (ids, columns, matrix as DataFrame of raw values)."""
175
+ import pandas as pd
176
+ p = Path(path)
177
+ if p.suffix.lower() == ".json": # aligned outline / landmark COCO from the semilandmark step
178
+ d = json.load(open(p)); names = {im["id"]: im["file_name"] for im in d["images"]}
179
+ rows, ids = [], []
180
+ for a in d["annotations"]:
181
+ pts = a.get("keypoints") or (a.get("segmentation") or [[]])[0]
182
+ if a.get("keypoints"):
183
+ xy = np.array(pts, float).reshape(-1, 3)[:, :2]
184
+ else:
185
+ xy = np.array(pts, float).reshape(-1, 2)
186
+ if len(xy) > 2 and np.allclose(xy[0], xy[-1]):
187
+ xy = xy[:-1]
188
+ rows.append(xy.flatten()); ids.append(names[a["image_id"]])
189
+ k = min(len(r) for r in rows)
190
+ cols = [f"{c}{i // 2 + 1}" for i, c in zip(range(k), itertools.cycle("xy"))]
191
+ return ids, pd.DataFrame([r[:k] for r in rows], columns=cols)
192
+ d = pd.read_csv(p, sep="\t" if p.suffix.lower() in (".tsv", ".txt") else ",", low_memory=False)
193
+ icol = id_col if id_col in (d.columns if id_col else []) else next(
194
+ (c for c in ("filename", "file", "image_filename", "specimen", "specimen_id", "image", "id") if c in d.columns), d.columns[0])
195
+ ids = d[icol].astype(str).tolist()
196
+ return ids, d.drop(columns=[icol])
197
+
198
+
199
+ def numeric_block(D, min_fill=0.9):
200
+ import pandas as pd
201
+ num = D.apply(pd.to_numeric, errors="coerce")
202
+ num = num.loc[:, num.notna().mean() >= min_fill]
203
+ num = num.fillna(num.mean())
204
+ return num.loc[:, num.std() > 0]
205
+
206
+
207
+ # ============================================================================ plots
208
+ PALETTE = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22",
209
+ "#17becf", "#393b79", "#637939", "#8c6d31", "#843c39", "#7b4173", "#3182bd", "#e6550d", "#31a354",
210
+ "#756bb1", "#636363"]
211
+
212
+
213
+ def plot_pca(P, ev, y, title, path):
214
+ import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
215
+ from scipy.spatial import ConvexHull
216
+ labs = sorted(set(y), key=_natural)
217
+ fig, ax = plt.subplots(figsize=(6.2, 5))
218
+ for i, l in enumerate(labs):
219
+ c = PALETTE[i % len(PALETTE)]; m = y == l; pts = P[m, :2]
220
+ ax.scatter(pts[:, 0], pts[:, 1], s=16, color=c, label=l, zorder=3, edgecolor="white", lw=0.3)
221
+ if len(pts) >= 3:
222
+ try:
223
+ h = ConvexHull(pts); ax.fill(pts[h.vertices, 0], pts[h.vertices, 1], color=c, alpha=0.15, lw=0.8, ec=c)
224
+ except Exception:
225
+ pass
226
+ elif len(pts) == 2:
227
+ ax.plot(pts[:, 0], pts[:, 1], color=c, lw=0.8)
228
+ ax.set_xlabel(f"PC1 ({100 * ev[0]:.1f}%)"); ax.set_ylabel(f"PC2 ({100 * ev[1]:.1f}%)" if len(ev) > 1 else "PC2")
229
+ ax.set_title(title, fontsize=8.5)
230
+ ax.legend(fontsize=6, ncol=2 if len(labs) > 12 else 1, frameon=False, bbox_to_anchor=(1.01, 1), loc="upper left")
231
+ ax.spines[["top", "right"]].set_visible(False)
232
+ fig.tight_layout(); fig.savefig(path, dpi=200); plt.close(fig)
233
+
234
+
235
+ def plot_overview(rows, path):
236
+ import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
237
+ fig, ax = plt.subplots(figsize=(max(4, 1.3 * len(rows) + 1.5), 3.6))
238
+ x = np.arange(len(rows)); acc = [r["loo_acc_PC1to5"] for r in rows]
239
+ lo = [r["loo_acc_PC1to5"] - r["loo_ci_low_PC1to5"] for r in rows]; hi = [r["loo_ci_high_PC1to5"] - r["loo_acc_PC1to5"] for r in rows]
240
+ ax.bar(x, acc, 0.6, color="#0072B2", yerr=[lo, hi], capsize=3, zorder=3)
241
+ for i, r in enumerate(rows):
242
+ ax.text(i, acc[i] + hi[i] + 0.02, f"{acc[i]:.2f}", ha="center", fontsize=7)
243
+ ch = rows[0]["chance"] if rows else 0
244
+ ax.axhline(ch, ls="--", color="grey", lw=0.8); ax.text(len(rows) - 0.5, ch + 0.01, "chance", fontsize=6, color="grey", ha="right")
245
+ ax.set_xticks(x); ax.set_xticklabels([r["trait_set"] for r in rows], fontsize=7, rotation=20, ha="right")
246
+ ax.set_ylim(0, 1.12); ax.set_ylabel("leave-one-out species accuracy (PC1-5)", fontsize=7.5)
247
+ ax.set_title("Species identified by each trait set (95% CI)", fontsize=8.5); ax.spines[["top", "right"]].set_visible(False)
248
+ fig.tight_layout(); fig.savefig(path, dpi=200); plt.close(fig)
249
+
250
+
251
+ def plot_separation(sp, sets, sepmat, path):
252
+ """species x species: how many trait sets separate each pair (pairwise PERMANOVA, q < alpha)."""
253
+ import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
254
+ n = len(sp); fig, ax = plt.subplots(figsize=(0.32 * n + 2.5, 0.32 * n + 1.8))
255
+ im = ax.imshow(sepmat, cmap="Blues", vmin=0, vmax=max(1, len(sets)))
256
+ ax.set_xticks(range(n)); ax.set_yticks(range(n)); ax.set_xticklabels(sp, rotation=90, fontsize=6); ax.set_yticklabels(sp, fontsize=6)
257
+ for i in range(n):
258
+ for j in range(n):
259
+ if i != j:
260
+ ax.text(j, i, int(sepmat[i, j]), ha="center", va="center", fontsize=5.5,
261
+ color="white" if sepmat[i, j] > len(sets) / 2 else "black")
262
+ cb = fig.colorbar(im, ax=ax, fraction=0.04); cb.set_label(f"trait sets separating the pair (of {len(sets)})", fontsize=7)
263
+ ax.set_title("Which species pairs are separated, and by how many trait sets", fontsize=8.5)
264
+ fig.tight_layout(); fig.savefig(path, dpi=200); plt.close(fig)
265
+
266
+
267
+ def _natural(s):
268
+ return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", str(s))]
269
+
270
+
271
+ # ============================================================================ main
272
+ def analyse_set(name, S_raw, y, ids, a, out, covs):
273
+ """one trait set -> summary row, per-specimen hits, per-species rows, pairwise rows."""
274
+ from sklearn.decomposition import PCA
275
+ Z = S_raw
276
+ P = PCA().fit_transform(Z); ev = np.var(P, axis=0, ddof=1); ev = ev / ev.sum()
277
+ k = min(a.n_pcs, P.shape[1]); Pk = P[:, :k]
278
+ r2, f, p = permanova(Pk, y, a.permutations, a.seed)
279
+ row = dict(trait_set=name, n_specimens=len(y), n_species=len(set(y)), n_features=Z.shape[1], n_pcs_tested=k,
280
+ PC1_var=float(ev[0]), PC2_var=float(ev[1]) if len(ev) > 1 else float("nan"),
281
+ permanova_R2=r2, permanova_F=f, permanova_P=p)
282
+ beyond_txt = []
283
+ for cname, C in covs.items():
284
+ rr, ff, pp = permanova(residualise(Pk, C), y, a.permutations, a.seed)
285
+ row[f"permanova_R2_beyond_{cname}"] = rr; row[f"permanova_P_beyond_{cname}"] = pp
286
+ beyond_txt.append(f"beyond {cname.replace('_', ' ')} R2 {rr:.2f}, P = {pp:.3g}")
287
+ hits5 = loo_1nn(Pk[:, :min(5, k)], y); hitsA = loo_1nn(P, y)
288
+ sc = [h[0] for h in hits5]; c5 = sum(h[1] for h in hits5 if h[0]); cA = sum(h[1] for h in hitsA if h[0]); ns = sum(sc)
289
+ lo5, hi5 = wilson(c5, ns); loA, hiA = wilson(cA, ns)
290
+ scored_species = sorted({yy for yy, s in zip(y, sc) if s})
291
+ row.update(loo_scored=ns, loo_correct_PC1to5=c5, loo_acc_PC1to5=c5 / ns if ns else float("nan"),
292
+ loo_ci_low_PC1to5=lo5, loo_ci_high_PC1to5=hi5, loo_correct_allPCs=cA,
293
+ loo_acc_allPCs=cA / ns if ns else float("nan"), loo_ci_low_allPCs=loA, loo_ci_high_allPCs=hiA,
294
+ chance=1 / len(scored_species) if scored_species else float("nan"))
295
+ if a.size_values is not None:
296
+ ok = ~np.isnan(a.size_values)
297
+ if ok.sum() > 3:
298
+ ar2, ap = regression_perm(Pk[ok], np.log(a.size_values[ok]), a.permutations, a.seed)
299
+ row.update(allometry_R2=ar2, allometry_P=ap)
300
+ title = (f"{name}: PC1 vs PC2\nPERMANOVA (PC1-{k}): species R2 {r2:.2f}, P = {p:.3g}"
301
+ + ("".join("\n" + t for t in beyond_txt)))
302
+ plot_pca(P, ev, y, title, out / f"pca_{_safe(name)}.png")
303
+ # per species
304
+ sp_rows = []
305
+ means = {s: Pk[y == s].mean(0) for s in sorted(set(y), key=_natural)}
306
+ for s in means:
307
+ idx = [i for i in range(len(y)) if y[i] == s and hits5[i][0]]
308
+ kk = sum(hits5[i][1] for i in idx); lo, hi = wilson(kk, len(idx))
309
+ conf = sorted({hits5[i][2] for i in idx if not hits5[i][1]}, key=_natural)
310
+ d = sorted(((float(np.linalg.norm(means[s] - m)), o) for o, m in means.items() if o != s))
311
+ sp_rows.append(dict(trait_set=name, species=s, n=int((y == s).sum()), loo_scored=len(idx), loo_correct=kk,
312
+ loo_ci_low=lo, loo_ci_high=hi, misassigned_to="; ".join(conf),
313
+ nearest_species="; ".join(o for _, o in d[:2])))
314
+ # pairwise
315
+ pw = []
316
+ if a.pairwise:
317
+ labs = sorted(set(y), key=_natural)
318
+ for s1, s2 in itertools.combinations(labs, 2):
319
+ m = (y == s1) | (y == s2)
320
+ if (y == s1).sum() < 2 or (y == s2).sum() < 2:
321
+ continue
322
+ rr, ff, pp = permanova(Pk[m], y[m], a.permutations, a.seed)
323
+ pw.append(dict(trait_set=name, species_a=s1, species_b=s2, n_a=int((y == s1).sum()), n_b=int((y == s2).sum()),
324
+ R2=rr, F=ff, P=pp))
325
+ q = bh([r["P"] for r in pw])
326
+ for r, qq in zip(pw, q):
327
+ r["q_BH"] = qq; r["separated"] = bool(qq < a.alpha)
328
+ r["min_attainable_P"] = 1 / math.comb(r["n_a"] + r["n_b"], r["n_a"])
329
+ return row, hits5, sp_rows, pw
330
+
331
+
332
+ def _safe(s):
333
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", s)
334
+
335
+
336
+ def main(argv=None):
337
+ import pandas as pd
338
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
339
+ ap.add_argument("--traits", action="append", default=[], metavar="NAME=TABLE", help="continuous trait table (repeatable)")
340
+ ap.add_argument("--categorical", action="append", default=[], metavar="NAME=TABLE",
341
+ help="descriptive categorical characters, one row per specimen (repeatable)")
342
+ ap.add_argument("--groups", required=True, help="group labels: CSV with filename and group_label (or species) columns")
343
+ ap.add_argument("--group_col", default=None, help="column of --groups holding the label (default group_label/species)")
344
+ ap.add_argument("--id_col", default=None, help="specimen id column of the trait tables (default: filename/file/...)")
345
+ ap.add_argument("--scale", action="append", default=[], metavar="NAME=standardize|none",
346
+ help="default standardize (z-scores); use none for Procrustes shape coordinates")
347
+ ap.add_argument("--mean_colour", default="auto", choices=["auto", "off"],
348
+ help="for colour tables with *_L/_a/_b_mean_abs columns, also test the pattern beyond each "
349
+ "specimen's mean colour (default auto)")
350
+ ap.add_argument("--size", default=None, metavar="TABLE:COLUMN[:CATEGORY]",
351
+ help="size per specimen (e.g. measurements/all_metrics.csv:area_mm2:forewing); adds allometry "
352
+ "and the test beyond log size")
353
+ ap.add_argument("--n_pcs", type=int, default=10, help="principal components entering PERMANOVA (default 10)")
354
+ ap.add_argument("--permutations", type=int, default=999)
355
+ ap.add_argument("--alpha", type=float, default=0.05, help="FDR level for pairwise separation (default 0.05)")
356
+ ap.add_argument("--no_pairwise", dest="pairwise", action="store_false")
357
+ ap.add_argument("--min_per_group", type=int, default=2, help="species with fewer specimens are left out (default 2)")
358
+ ap.add_argument("--seed", type=int, default=1)
359
+ ap.add_argument("--out_dir", required=True)
360
+ a = ap.parse_args(argv)
361
+ if not a.traits and not a.categorical:
362
+ ap.error("give at least one --traits or --categorical table")
363
+ out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
364
+ gmap = load_groups(a.groups, a.group_col)
365
+ scales = dict(s.split("=", 1) for s in a.scale)
366
+ size_map = None
367
+ if a.size:
368
+ parts = a.size.split(":")
369
+ tp, col, cat = (parts + [None])[:3] if len(parts) >= 2 else (a.size, None, None)
370
+ sids, SD = load_table(tp, a.id_col)
371
+ if cat: # measurements tables hold every structure: keep one
372
+ ccol = next((c for c in ("category_name", "category") if c in SD.columns), None)
373
+ keepc = (SD[ccol].astype(str) == cat).values if ccol else np.ones(len(SD), bool)
374
+ sids = [i for i, k in zip(sids, keepc) if k]; SD = SD.loc[keepc]
375
+ vals = pd.to_numeric(SD[col], errors="coerce")
376
+ size_map = {}
377
+ for sid, v in zip(sids, vals):
378
+ for kf in _key_forms(sid):
379
+ size_map.setdefault(kf, v)
380
+ rows, sp_all, pw_all, hits_by_set, cat_rows, notes = [], [], [], {}, [], []
381
+ sets = [(t, False) for t in a.traits] + [(t, True) for t in a.categorical]
382
+ for spec, is_cat in sets:
383
+ name, path = spec.split("=", 1)
384
+ ids, D = load_table(path, a.id_col)
385
+ y = np.array([group_of(i, gmap) for i in ids], dtype=object)
386
+ keep = np.array([g is not None for g in y])
387
+ if (~keep).any():
388
+ notes.append(f"{name}: {int((~keep).sum())} of {len(ids)} specimens have no group label and were left out")
389
+ ids = [i for i, k in zip(ids, keep) if k]; D = D.loc[keep].reset_index(drop=True); y = y[keep].astype(str)
390
+ cnt = pd.Series(y).value_counts(); small = cnt[cnt < a.min_per_group].index
391
+ if len(small):
392
+ notes.append(f"{name}: left out (fewer than {a.min_per_group} specimens): {', '.join(sorted(small, key=_natural))}")
393
+ k2 = ~np.isin(y, small); ids = [i for i, k in zip(ids, k2) if k]; D = D.loc[k2].reset_index(drop=True); y = y[k2]
394
+ if len(set(y)) < 2:
395
+ notes.append(f"{name}: fewer than two groups with data - skipped"); continue
396
+ if is_cat:
397
+ Dc = D.astype(str).replace({"nan": np.nan, "": np.nan})
398
+ Dc = Dc.loc[:, Dc.notna().mean() >= 0.5]
399
+ for c in Dc.columns:
400
+ v, p, n = cramers_v_perm(Dc[c], y, a.permutations, a.seed)
401
+ cat_rows.append(dict(trait_set=name, character=c, n_specimens=n, cramers_V=v, P=p))
402
+ q = bh([r["P"] for r in cat_rows if r["trait_set"] == name])
403
+ for r, qq in zip([r for r in cat_rows if r["trait_set"] == name], q):
404
+ r["q_BH"] = qq
405
+ X = pd.get_dummies(Dc.fillna("NA")).astype(float).values
406
+ else:
407
+ num = numeric_block(D)
408
+ covs = {}
409
+ if a.mean_colour == "auto":
410
+ raw = D.apply(pd.to_numeric, errors="coerce") # every cell's mean colour, gaps skipped
411
+ parts = [raw[[c for c in raw.columns if re.search(rx, c)]] for rx in COLOUR_MEAN_RX.values()]
412
+ if all(p_.shape[1] for p_ in parts):
413
+ C = np.column_stack([p_.mean(1) for p_ in parts])
414
+ C = C[:, np.isfinite(C).all(0) & (np.nanstd(C, 0) > 0)] # constant or missing channels dropped
415
+ if C.shape[1]:
416
+ covs["mean_colour"] = (C - C.mean(0)) / C.std(0)
417
+ X = num.values
418
+ if scales.get(name, "standardize") == "standardize":
419
+ X = (X - X.mean(0)) / X.std(0, ddof=1)
420
+ a.size_values = None
421
+ if size_map is not None and not is_cat:
422
+ sv = np.array([next((size_map[kf] for kf in _key_forms(i) if kf in size_map), np.nan) for i in ids], float)
423
+ if np.isfinite(sv).sum() > 3 and (sv[np.isfinite(sv)] > 0).all():
424
+ a.size_values = sv
425
+ if np.isfinite(sv).all() and not is_cat:
426
+ covs["log_size"] = np.log(sv)[:, None]
427
+ if X.shape[1] == 0 or not np.isfinite(X).all():
428
+ notes.append(f"{name}: no feature varies among the specimens (for example empty homology cells) - skipped"); continue
429
+ row, hits, sp_rows, pw = analyse_set(name, X, y, ids, a, out, covs if not is_cat else {})
430
+ rows.append(row); sp_all += sp_rows; pw_all += pw; hits_by_set[name] = (ids, hits)
431
+ print(f"{name}: {row['n_specimens']} specimens, {row['n_species']} species; PERMANOVA R2 {row['permanova_R2']:.3f} "
432
+ f"P {row['permanova_P']:.3g}; LOO {row['loo_correct_PC1to5']}/{row['loo_scored']}", flush=True)
433
+ if not rows:
434
+ sys.exit("no trait set could be analysed: " + "; ".join(notes))
435
+ # McNemar between trait sets, on the specimens scored in both
436
+ mc = []
437
+ for (n1, (i1, h1)), (n2, (i2, h2)) in itertools.combinations(hits_by_set.items(), 2):
438
+ canon = lambda i: _key_forms(i)[2] # same specimen whatever suffix/extension the table uses
439
+ d1 = {canon(i): h for i, h in zip(i1, h1) if h[0]}; d2 = {canon(i): h for i, h in zip(i2, h2) if h[0]}
440
+ common = sorted(set(d1) & set(d2))
441
+ if len(common) < 5:
442
+ continue
443
+ pm, a_only, b_only = mcnemar_exact([d1[i][1] for i in common], [d2[i][1] for i in common])
444
+ mc.append(dict(set_a=n1, set_b=n2, n_common=len(common), acc_a=np.mean([d1[i][1] for i in common]),
445
+ acc_b=np.mean([d2[i][1] for i in common]), only_a_right=a_only, only_b_right=b_only, mcnemar_P=pm))
446
+ write = lambda fn, rr: pd.DataFrame(rr).to_csv(out / fn, index=False) if rr else None
447
+ write("summary.csv", rows); write("per_species.csv", sp_all); write("pairwise_permanova.csv", pw_all)
448
+ write("mcnemar_between_trait_sets.csv", mc); write("categorical_characters.csv", cat_rows)
449
+ plot_overview(rows, out / "identification_by_trait_set.png")
450
+ sep = None
451
+ if pw_all:
452
+ sp = sorted({r["species_a"] for r in pw_all} | {r["species_b"] for r in pw_all}, key=_natural)
453
+ ix = {s: i for i, s in enumerate(sp)}; sep = np.zeros((len(sp), len(sp)))
454
+ names = [r["trait_set"] for r in rows]
455
+ for r in pw_all:
456
+ if r["separated"]:
457
+ sep[ix[r["species_a"]], ix[r["species_b"]]] += 1; sep[ix[r["species_b"]], ix[r["species_a"]]] += 1
458
+ plot_separation(sp, names, sep, out / "species_separation.png")
459
+ sm = []
460
+ for s in sp:
461
+ by = {}
462
+ for r in pw_all:
463
+ if s in (r["species_a"], r["species_b"]):
464
+ o = r["species_b"] if r["species_a"] == s else r["species_a"]
465
+ by.setdefault(o, []).append(r["trait_set"] if r["separated"] else None)
466
+ never = sorted([o for o, v in by.items() if not any(v)], key=_natural)
467
+ sm.append(dict(species=s, species_compared=len(by),
468
+ separated_by_at_least_one_set=sum(1 for v in by.values() if any(v)),
469
+ not_separated_by_any_set="; ".join(never)))
470
+ write("species_validation.csv", sm)
471
+ # which trait set separates which pairs, and the pairs only one set separates
472
+ per_pair = {}
473
+ for r in pw_all:
474
+ per_pair.setdefault((r["species_a"], r["species_b"]), set())
475
+ if r["separated"]:
476
+ per_pair[(r["species_a"], r["species_b"])].add(r["trait_set"])
477
+ by_set = []
478
+ for nm in names:
479
+ only = [f"{x} / {y2}" for (x, y2), v in per_pair.items() if v == {nm}]
480
+ by_set.append(dict(trait_set=nm, pairs_tested=sum(1 for r in pw_all if r["trait_set"] == nm),
481
+ pairs_separated=sum(1 for r in pw_all if r["trait_set"] == nm and r["separated"]),
482
+ pairs_separated_only_by_this_set=len(only), those_pairs="; ".join(only)))
483
+ write("separation_by_trait_set.csv", by_set)
484
+ # report
485
+ L = ["# Morphological statistics by trait set", "",
486
+ f"Groups from `{Path(a.groups).name}`; PERMANOVA on PC1-{a.n_pcs} with {a.permutations} permutations; pairwise tests "
487
+ f"Benjamini-Hochberg corrected at q < {a.alpha}; identification = leave-one-out nearest neighbour.", ""]
488
+ L += ["| Trait set | Specimens | Species | PERMANOVA R2 | P | Beyond covariate | LOO accuracy PC1-5 (95% CI) | all PCs |",
489
+ "|---|---|---|---|---|---|---|---|"]
490
+ for r in rows:
491
+ bey = "; ".join(f"{k.replace('permanova_R2_beyond_', '').replace('_', ' ')}: R2 {v:.2f}, P = {r[k.replace('R2', 'P')]:.3g}"
492
+ for k, v in r.items() if k.startswith("permanova_R2_beyond_")) or "-"
493
+ L.append(f"| {r['trait_set']} | {r['n_specimens']} | {r['n_species']} | {r['permanova_R2']:.2f} | {r['permanova_P']:.3g} | {bey} | "
494
+ f"{r['loo_acc_PC1to5']:.2f} ({r['loo_ci_low_PC1to5']:.2f}-{r['loo_ci_high_PC1to5']:.2f}) | {r['loo_acc_allPCs']:.2f} |")
495
+ if any("allometry_R2" in r for r in rows):
496
+ L += ["", "Allometry (trait set ~ log size): " + "; ".join(
497
+ f"{r['trait_set']} R2 {r['allometry_R2']:.2f}, P = {r['allometry_P']:.3g}" for r in rows if "allometry_R2" in r)]
498
+ if mc:
499
+ L += ["", "## Do trait sets identify species differently? (exact McNemar, same specimens)", "",
500
+ "| Trait set A | Trait set B | Specimens | Accuracy A | Accuracy B | Only A right | Only B right | P |", "|---|---|---|---|---|---|---|---|"]
501
+ L += [f"| {m['set_a']} | {m['set_b']} | {m['n_common']} | {m['acc_a']:.2f} | {m['acc_b']:.2f} | {m['only_a_right']} | "
502
+ f"{m['only_b_right']} | {m['mcnemar_P']:.3g} |" for m in mc]
503
+ if pw_all:
504
+ L += ["", "## Species validation", "",
505
+ "`species_validation.csv`: for each species, how many others it is separated from by at least one trait set "
506
+ "(pairwise PERMANOVA, q < alpha) and which it is not separated from by any. `pairwise_permanova.csv` "
507
+ "gives every pair and trait set. With two or three specimens per species the smallest attainable P "
508
+ "(column min_attainable_P) can exceed alpha, so a pair can be unseparable for lack of specimens, not for "
509
+ "lack of difference."]
510
+ if pw_all:
511
+ L += ["", "| Trait set | Pairs tested | Pairs separated | Separated by this set only |", "|---|---|---|---|"]
512
+ L += [f"| {b['trait_set']} | {b['pairs_tested']} | {b['pairs_separated']} | {b['pairs_separated_only_by_this_set']} |"
513
+ for b in by_set]
514
+ L += ["", "The pairs each set alone separates are listed in `separation_by_trait_set.csv`; a species pair "
515
+ "that no set separates is listed for each species in `species_validation.csv`."]
516
+ if cat_rows:
517
+ L += ["", f"## Descriptive categorical characters", "", "`categorical_characters.csv`: Cramer's V and permutation "
518
+ "P (BH-corrected q) for each character against the groups."]
519
+ if notes:
520
+ L += ["", "## Notes", ""] + [f"- {n}" for n in notes]
521
+ L += ["", "Figures: `pca_<trait set>.png` (species hulls; PERMANOVA in the title), `identification_by_trait_set.png`, "
522
+ "`species_separation.png`."]
523
+ (out / "trait_stats_report.md").write_text("\n".join(L) + "\n")
524
+ print("wrote", out / "trait_stats_report.md")
525
+
526
+
527
+ if __name__ == "__main__":
528
+ main()