descriptron-core 2.5.2__tar.gz → 2.7.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.
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/PKG-INFO +1 -1
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/pyproject.toml +1 -1
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biosyslit_rag_retrieval_v2.py +42 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/coi_species_tree_v1.py +151 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_character_signal_v1.py +346 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_check_cross_image_copies_v1.py +200 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_labelled_plates_v1.py +290 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_phylo.py +5 -1
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_phylo_figure_v1.py +258 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_reexamine_v1.py +448 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_reexamine_v2.py +923 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_trait_stats.py +43 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/dna_vs_morphology_v1.py +200 -0
- descriptron_core-2.7.0/src/descriptron_core/tools/generate_species_plates.py +299 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/run_full_pipeline_v2.py +128 -19
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/.gitignore +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/LICENSE +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/NOTICE +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/README.md +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/__init__.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/cli.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/diaphorina_user_prompts.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/ontologies/aism.obo +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/ontologies/pato.obo +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/FHS_and_CLAHE_V21.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_add_discrete_characters_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_annotation_reorient_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_annotation_screen_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_autapomorphy_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_calibrate_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_calibration_figures_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_gate_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_reliability_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_robustness_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_collaborator_forms_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_computed_sets_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_confabulation_checker_v2.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_congruence_compare_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_description_refiner_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_descriptive_scoring_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_feature_policy.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_frame_orientation_calibrate_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_frame_orientation_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_gate_compare_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_graph_identify_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_graph_support_figure_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_homology_frame_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_instrument_compare_v3.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_builder_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_feature_filter_v2.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_fuzzy_figures_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_fuzzy_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_holdout_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_jackknife_support_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_label_conflict_check_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_llm_backend.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_make_taxon_profile_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_methods_summary_figure_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_novelty_score_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_ontology_annotator_v2.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_outgroup_batch_check_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_outline_shape_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/diaphorina_user_prompts.txt +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/ontologies/aism.obo +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/ontologies/pato.obo +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_provenance_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rebuild_downstream_v1.sh +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_reliability_figures_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rename_species_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rerun_descriptive_tables_v1.sh +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_retest_compare_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_specimen_id.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_subjective_checks_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_type_material_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_characters_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_heatmap_figures_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_reliability_figure_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_roi_figures_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biosyslit_rag_retrieval.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/build_monograph_exports_v1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/build_species_treatment_docx_v2.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/coco_combiner_V13.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/coco_converter_v24.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/color_extraction_with_names-v8.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/color_phenomics_homology_v2_1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/compile_specimen_data.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_centerline.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_coco_tools.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_convert.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_credentials.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_joints.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_metadata.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_rosetta.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_shape_stats.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/landmark_gpa_V1.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/landmark_gpa_V2.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/make_reproducibility_bundle.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV34_GPA.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV42_GPA.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/texture_phenomics_from_coco.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/texture_phenomics_homology.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_centerline_lengths.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_published_waldron2025.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_real_tree.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_traits.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_pipeline_gm_vs_geomorph.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_semilandmarks_vs_geomorph.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_shape_stats_vs_geomorph.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_shape_stats_vs_rrpp.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_texture_glcm.py +0 -0
- {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_trait_stats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: descriptron-core
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.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
|
|
@@ -64,6 +64,48 @@ from biorag_llm_backend import load_prompt_library, make_llm_client # noqa: E40
|
|
|
64
64
|
|
|
65
65
|
V2_VERSION = "2.0"
|
|
66
66
|
|
|
67
|
+
# ── retrieval log (opt-in): BIORAG_RETRIEVAL_LOG=<file.jsonl> records every literature search and every passage
|
|
68
|
+
# returned (source, DOI, section, score), so a run can show WHERE in the literature the model was primed from.
|
|
69
|
+
# BIORAG_RETRIEVAL_CONTEXT (e.g. the species code) is copied into each record. v1 is not modified: the two
|
|
70
|
+
# functions are wrapped here.
|
|
71
|
+
_RLOG = os.environ.get("BIORAG_RETRIEVAL_LOG")
|
|
72
|
+
if _RLOG:
|
|
73
|
+
import datetime as _dt
|
|
74
|
+
import json as _json
|
|
75
|
+
|
|
76
|
+
def _rlog_write(rec):
|
|
77
|
+
rec = {"time": _dt.datetime.now().isoformat(timespec="seconds"),
|
|
78
|
+
"context": os.environ.get("BIORAG_RETRIEVAL_CONTEXT", ""), **rec}
|
|
79
|
+
with open(_RLOG, "a") as _f:
|
|
80
|
+
_f.write(_json.dumps(rec, ensure_ascii=False) + "\n")
|
|
81
|
+
|
|
82
|
+
_orig_retrieve = base.TreatmentIndex.retrieve
|
|
83
|
+
|
|
84
|
+
def _logged_retrieve(self, query, _orig=_orig_retrieve):
|
|
85
|
+
res = _orig(self, query)
|
|
86
|
+
_rlog_write({"kind": "index_retrieve",
|
|
87
|
+
"query": {k: getattr(query, k, "") for k in ("taxon_name", "taxon_family", "body_region",
|
|
88
|
+
"description_mode", "free_text", "k")},
|
|
89
|
+
"index_chunks": len(getattr(self, "chunks", []) or []),
|
|
90
|
+
"results": [{"rank": i + 1, "score": round(float(r.score), 4), "chunk_id": r.chunk.chunk_id,
|
|
91
|
+
"source_type": r.chunk.source_type, "source_id": r.chunk.source_id,
|
|
92
|
+
"source_doi": r.chunk.source_doi, "source_title": r.chunk.source_title,
|
|
93
|
+
"section": r.chunk.description_section, "body_region": r.chunk.body_region,
|
|
94
|
+
"match_reasons": list(r.match_reasons or []),
|
|
95
|
+
"text_preview": (r.chunk.text or "")[:240]} for i, r in enumerate(res)]})
|
|
96
|
+
return res
|
|
97
|
+
base.TreatmentIndex.retrieve = _logged_retrieve
|
|
98
|
+
|
|
99
|
+
_orig_search = base.BioSysLitIngester.search
|
|
100
|
+
|
|
101
|
+
def _logged_search(self, query, *a, _orig=_orig_search, **kw):
|
|
102
|
+
recs = _orig(self, query, *a, **kw)
|
|
103
|
+
_rlog_write({"kind": "biosyslit_search", "query": query,
|
|
104
|
+
"records": [{"id": r.get("id"), "doi": r.get("doi", ""),
|
|
105
|
+
"title": (r.get("metadata", {}) or {}).get("title", "")} for r in (recs or [])]})
|
|
106
|
+
return recs
|
|
107
|
+
base.BioSysLitIngester.search = _logged_search
|
|
108
|
+
|
|
67
109
|
SUBCOMMAND_MODULES = {
|
|
68
110
|
"build-key": "biorag_key_builder_v1",
|
|
69
111
|
"refine-descriptions": "biorag_description_refiner_v1",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
coi_species_tree_v1.py - a species tree for the phylogenetic comparative analyses, from a barcode gene tree
|
|
4
|
+
==========================================================================================================
|
|
5
|
+
|
|
6
|
+
1. midpoint-root the gene tree (e.g. IQ-TREE .treefile; support values on internal nodes are kept)
|
|
7
|
+
2. per morphospecies: is it monophyletic in the rooted tree? (species with >= 2 sequences)
|
|
8
|
+
3. keep one sequence per morphospecies and name the tip by the species code, so the tree can be passed to
|
|
9
|
+
descriptron_phylo.py --tree with the group labels. The sequence kept is the first, in this order, of:
|
|
10
|
+
a sequence whose DNA nearest neighbour is its own species and that is also a matrix specimen; one whose
|
|
11
|
+
nearest neighbour is its own species; any. Sequences are mapped to species with the table written by
|
|
12
|
+
dna_vs_morphology_v1.py (specimens_dna_vs_matrix.tsv); other sequences (references) are dropped.
|
|
13
|
+
|
|
14
|
+
python coi_species_tree_v1.py --tree coi.treefile --specimens dna/specimens_dna_vs_matrix.tsv --out_dir tree/
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
# measure/ in the source tree; the same folder in the installed package (tools/)
|
|
24
|
+
for _d in (Path(__file__).resolve().parent.parent, Path(__file__).resolve().parent):
|
|
25
|
+
sys.path.insert(0, str(_d))
|
|
26
|
+
from descriptron_phylo import Node, parse_newick, prune, tips_of, _set_depths # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_newick(n: Node, support: bool = True) -> str:
|
|
30
|
+
if not n.kids:
|
|
31
|
+
return f"{n.name}:{n.length:.6g}"
|
|
32
|
+
lab = n.name if support else ""
|
|
33
|
+
return "(" + ",".join(to_newick(k, support) for k in n.kids) + f"){lab}:{n.length:.6g}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def midpoint_root(root: Node) -> Node:
|
|
37
|
+
"""re-root at the midpoint of the longest tip-to-tip path."""
|
|
38
|
+
nb = {} # undirected graph: node -> [(node, length)]
|
|
39
|
+
stack = [root]
|
|
40
|
+
while stack:
|
|
41
|
+
n = stack.pop(); nb.setdefault(n, [])
|
|
42
|
+
for k in n.kids:
|
|
43
|
+
nb[n].append((k, k.length)); nb.setdefault(k, []).append((n, k.length)); stack.append(k)
|
|
44
|
+
|
|
45
|
+
def far(src):
|
|
46
|
+
dist, prev, st = {src: 0.0}, {src: None}, [src]
|
|
47
|
+
while st:
|
|
48
|
+
u = st.pop()
|
|
49
|
+
for v, w in nb[u]:
|
|
50
|
+
if v not in dist:
|
|
51
|
+
dist[v] = dist[u] + w; prev[v] = u; st.append(v)
|
|
52
|
+
t = max((x for x in dist if not x.kids), key=dist.get)
|
|
53
|
+
return t, dist, prev
|
|
54
|
+
a, _, _ = far(next(x for x in nb if not x.kids))
|
|
55
|
+
b, dist, prev = far(a)
|
|
56
|
+
half = dist[b] / 2
|
|
57
|
+
x = b # walk back from b towards a until past the midpoint
|
|
58
|
+
while dist[prev[x]] > half:
|
|
59
|
+
x = prev[x]
|
|
60
|
+
u, v = prev[x], x # the midpoint lies on edge u-v
|
|
61
|
+
new = Node("", 0.0)
|
|
62
|
+
w = next(l for y, l in nb[u] if y is v)
|
|
63
|
+
nb[u] = [(y, l) for y, l in nb[u] if y is not v]; nb[v] = [(y, l) for y, l in nb[v] if y is not u]
|
|
64
|
+
nb[new] = [(u, half - dist[u]), (v, dist[v] - half)]
|
|
65
|
+
nb[u].append((new, half - dist[u])); nb[v].append((new, dist[v] - half))
|
|
66
|
+
del w
|
|
67
|
+
# rebuild the parent/child structure from the new root; support labels stay with the clade below each node
|
|
68
|
+
old_parent = {}
|
|
69
|
+
st = [root]
|
|
70
|
+
while st:
|
|
71
|
+
n = st.pop()
|
|
72
|
+
for k in n.kids:
|
|
73
|
+
old_parent[k] = n; st.append(k)
|
|
74
|
+
|
|
75
|
+
def build(n, parent, length):
|
|
76
|
+
n.parent, n.length = parent, length
|
|
77
|
+
kids = [(y, l) for y, l in nb[n] if y is not parent]
|
|
78
|
+
if kids and parent is not None and old_parent.get(parent) is n:
|
|
79
|
+
n.name = "" # direction reversed: the old support no longer applies
|
|
80
|
+
n.kids = []
|
|
81
|
+
for y, l in kids:
|
|
82
|
+
n.kids.append(build(y, n, l))
|
|
83
|
+
if n.kids and len(n.kids) == 1: # the old root, left with one child: collapse it
|
|
84
|
+
c = n.kids[0]; c.length += n.length; c.parent = parent
|
|
85
|
+
return c
|
|
86
|
+
return n
|
|
87
|
+
r = build(new, None, 0.0)
|
|
88
|
+
_set_depths(r)
|
|
89
|
+
return r
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clade_tips(n):
|
|
93
|
+
return {t.name for t in tips_of(n)}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv=None):
|
|
97
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
98
|
+
ap.add_argument("--tree", required=True)
|
|
99
|
+
ap.add_argument("--specimens", required=True, help="specimens_dna_vs_matrix.tsv from dna_vs_morphology_v1.py")
|
|
100
|
+
ap.add_argument("--min_tip_length", type=float, default=1e-4,
|
|
101
|
+
help="floor for tip branch lengths in the species tree: identical sequences of two species give a "
|
|
102
|
+
"zero-length branch and a singular phylogenetic covariance matrix (default 1e-4 subst./site)")
|
|
103
|
+
ap.add_argument("--out_dir", required=True)
|
|
104
|
+
a = ap.parse_args(argv)
|
|
105
|
+
out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
root = midpoint_root(parse_newick(Path(a.tree).read_text()))
|
|
107
|
+
(out / "gene_tree_midpoint.nwk").write_text(to_newick(root) + ";\n")
|
|
108
|
+
S = pd.read_csv(a.specimens, sep="\t")
|
|
109
|
+
sp_of = dict(zip(S["sequence"], S["species"]))
|
|
110
|
+
tips = clade_tips(root)
|
|
111
|
+
# monophyly: the smallest clade holding all sequences of the species, and what else it holds
|
|
112
|
+
mono = []
|
|
113
|
+
for s, g in S.groupby("species"):
|
|
114
|
+
seqs = set(g["sequence"]) & tips
|
|
115
|
+
if len(seqs) < 2:
|
|
116
|
+
continue
|
|
117
|
+
best = None
|
|
118
|
+
st = [root]
|
|
119
|
+
while st:
|
|
120
|
+
n = st.pop()
|
|
121
|
+
ct = clade_tips(n)
|
|
122
|
+
if seqs <= ct and (best is None or len(ct) < len(best[1])):
|
|
123
|
+
best = (n, ct)
|
|
124
|
+
st.extend(n.kids)
|
|
125
|
+
others = sorted(sp_of.get(t, t) for t in best[1] - seqs)
|
|
126
|
+
mono.append({"species": s, "sequences": len(seqs), "monophyletic": not others,
|
|
127
|
+
"support": best[0].name if not others else "", "intruding": ", ".join(sorted(set(others)))})
|
|
128
|
+
M = pd.DataFrame(mono, columns=["species", "sequences", "monophyletic", "support", "intruding"]); M.to_csv(out / "morphospecies_monophyly.tsv", sep="\t", index=False)
|
|
129
|
+
# one tip per species
|
|
130
|
+
S["rank"] = [0 if (r.dna_agrees_with_label is True or r.dna_agrees_with_label == "True") and isinstance(r.matrix_named, str) and r.matrix_named
|
|
131
|
+
else 1 if (r.dna_agrees_with_label is True or r.dna_agrees_with_label == "True") else 2 for r in S.itertuples()]
|
|
132
|
+
keep = S[S["sequence"].isin(tips)].sort_values(["species", "rank", "sequence"]).groupby("species").head(1)
|
|
133
|
+
rep = dict(zip(keep["sequence"], keep["species"]))
|
|
134
|
+
sp_tree = prune(root, set(rep))
|
|
135
|
+
floored = []
|
|
136
|
+
for t in tips_of(sp_tree):
|
|
137
|
+
t.name = rep[t.name]
|
|
138
|
+
if t.length < a.min_tip_length:
|
|
139
|
+
floored.append(t.name); t.length = a.min_tip_length
|
|
140
|
+
_set_depths(sp_tree)
|
|
141
|
+
(out / "species_tree.nwk").write_text(to_newick(sp_tree, support=False) + ";\n")
|
|
142
|
+
keep[["species", "sequence", "specimen_id"]].to_csv(out / "species_tree_tips.tsv", sep="\t", index=False)
|
|
143
|
+
summ = {"gene_tree_tips": len(tips), "species_tips": len(rep), "tips_given_min_length": floored,
|
|
144
|
+
"morphospecies_tested": len(M), "monophyletic": int(M["monophyletic"].astype(bool).sum()),
|
|
145
|
+
"not_monophyletic": M.loc[~M["monophyletic"].astype(bool), ["species", "intruding"]].to_dict("records")}
|
|
146
|
+
json.dump(summ, open(out / "species_tree_summary.json", "w"), indent=2)
|
|
147
|
+
print(json.dumps(summ, indent=1))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
descriptron_character_signal_v1.py - which characters separate species, and which carry phylogenetic signal?
|
|
4
|
+
===========================================================================================================
|
|
5
|
+
|
|
6
|
+
For every character of the matrix (compiled_key_tier: specimen_matrix_long.csv + feature_dictionary.tsv):
|
|
7
|
+
species separation Kruskal-Wallis across species (species with >= 2 specimens), effect size
|
|
8
|
+
eta^2 = (H - k + 1) / (n - k), Benjamini-Hochberg q over all characters
|
|
9
|
+
phylogenetic signal with --tree: Blomberg's K of the species means (Adams' Kmult on one variable, as
|
|
10
|
+
geomorph::physignal), permutation P (--iterations), on the tree pruned to the species measured
|
|
11
|
+
|
|
12
|
+
Outputs (--out_dir):
|
|
13
|
+
character_signal_all.tsv every character
|
|
14
|
+
character_signal_top.tsv per structure, the --top characters with the largest eta^2 (all of them if the
|
|
15
|
+
structure has fewer)
|
|
16
|
+
character_signal_top.md the same as a readable table, one block per structure
|
|
17
|
+
character_phylo_signal.png/.pdf with --tree and --robustness: the companion to fig_character_robustness
|
|
18
|
+
(same family colours): a, every character's Blomberg's K by structure; b, naming against K
|
|
19
|
+
character_signal_volcano.png/.pdf only with --figure
|
|
20
|
+
a volcano plot: eta^2 against -log10 q, coloured by phylogenetic signal, the strongest characters labelled
|
|
21
|
+
b species separation against phylogenetic signal (eta^2 against K)
|
|
22
|
+
character_signal_summary.json
|
|
23
|
+
|
|
24
|
+
python descriptron_character_signal_v1.py --matrix_dir <out>/compiled_key_tier --tree tree.nwk --out_dir <out>/character_signal
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import matplotlib
|
|
34
|
+
matplotlib.use("Agg")
|
|
35
|
+
import matplotlib.pyplot as plt
|
|
36
|
+
from scipy.stats import kruskal
|
|
37
|
+
|
|
38
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
39
|
+
import descriptron_phylo as dp # noqa: E402
|
|
40
|
+
|
|
41
|
+
INK, MUTED, GRID = "#1d2327", "#6b7780", "#d0d7de"
|
|
42
|
+
C_SIG, C_NS = "#0072B2", "#9aa5ad"
|
|
43
|
+
C_ROBUST, C_WEAK = "#0b57d0", "#7d878f" # label text: robust (named >= --robust_x times chance) / not
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bh(p):
|
|
47
|
+
p = np.asarray(p, float); q = np.full(len(p), np.nan); ok = ~np.isnan(p)
|
|
48
|
+
if ok.sum():
|
|
49
|
+
pp = p[ok]; o = np.argsort(pp); r = pp[o] * ok.sum() / np.arange(1, ok.sum() + 1)
|
|
50
|
+
r = np.minimum.accumulate(r[::-1])[::-1]; qq = np.empty_like(r); qq[o] = np.minimum(r, 1); q[ok] = qq
|
|
51
|
+
return q
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def label_column(ax, xs, ys, labels, side_x=1.02, fontsize=5.6, gap_frac=1 / 34, colors=None):
|
|
55
|
+
"""labels in a column right of the axes, spaced so they never overlap, each joined to its point by a thin line"""
|
|
56
|
+
if not len(xs):
|
|
57
|
+
return
|
|
58
|
+
lo, hi = ax.get_ylim(); gap = (hi - lo) * gap_frac
|
|
59
|
+
order = np.argsort(ys)[::-1]
|
|
60
|
+
pos, last = [], hi + gap
|
|
61
|
+
for i in order: # top-down, never closer than `gap`
|
|
62
|
+
y = min(ys[i], last - gap); pos.append((i, y)); last = y
|
|
63
|
+
shift = max(0.0, lo - min(y for _, y in pos)) # pushed below the axis: move the column up
|
|
64
|
+
to_axes = ax.transAxes.inverted()
|
|
65
|
+
for i, y in pos:
|
|
66
|
+
px, py = to_axes.transform(ax.transData.transform((xs[i], ys[i])))
|
|
67
|
+
_, ty = to_axes.transform(ax.transData.transform((xs[i], y + shift)))
|
|
68
|
+
# a leader line of (near) zero length breaks matplotlib's arrow drawing: no line when label sits on the point
|
|
69
|
+
line = abs(px - side_x) > 0.01 or abs(py - ty) > 0.01
|
|
70
|
+
ax.annotate(labels[i], (xs[i], ys[i]), xytext=(side_x, y + shift), textcoords=("axes fraction", "data"),
|
|
71
|
+
fontsize=fontsize, color=colors[i] if colors is not None else INK, va="center", annotation_clip=False,
|
|
72
|
+
arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.4, shrinkA=0, shrinkB=2) if line else None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def short_label(structure, label):
|
|
76
|
+
"""the label alone when it already names its structure (e.g. antenna area), else structure: label"""
|
|
77
|
+
st = str(structure).lower().replace("_", " ")
|
|
78
|
+
first = str(label).split()[0].rstrip(":").lower() if str(label).split() else ""
|
|
79
|
+
return str(label) if first and (first in st or st.startswith(first)) else f"{structure}: {label}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def blomberg_k_perm(y, C, iters, rng):
|
|
83
|
+
"""Blomberg's K (= Kmult for one variable) and its permutation P, vectorised over permutations"""
|
|
84
|
+
n = len(y); Ci = np.linalg.inv(C); s1 = Ci.sum(); expct = (np.trace(C) - n / s1) / (n - 1)
|
|
85
|
+
Y = np.column_stack([y] + [y[rng.permutation(n)] for _ in range(iters)]) # n x (1 + iters)
|
|
86
|
+
a = (Ci.sum(0) @ Y) / s1
|
|
87
|
+
R = Y - a
|
|
88
|
+
obs = (R * R).sum(0) / (R * (Ci @ R)).sum(0)
|
|
89
|
+
K = obs / expct
|
|
90
|
+
return float(K[0]), float((1 + (K[1:] >= K[0] - 1e-12).sum()) / (iters + 1))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _eta_figure(D, out, tree_txt, a):
|
|
94
|
+
D = D.copy(); D["mlq"] = -np.log10(D.q.clip(lower=1e-300))
|
|
95
|
+
|
|
96
|
+
sig = (D.K_P <= 0.05) if "K_P" in D else pd.Series(False, index=D.index)
|
|
97
|
+
fig, axs = plt.subplots(1, 2 if tree_txt else 1, figsize=(13.5 if tree_txt else 7.5, 4.8), squeeze=False,
|
|
98
|
+
gridspec_kw={"wspace": 1.0})
|
|
99
|
+
ax = axs[0, 0]
|
|
100
|
+
ax.scatter(D.eta2[~sig], D.mlq[~sig], s=9, color=C_NS, alpha=0.7, lw=0, label="no phylogenetic signal (P > 0.05)" if tree_txt else "character")
|
|
101
|
+
if tree_txt:
|
|
102
|
+
ax.scatter(D.eta2[sig], D.mlq[sig], s=12, color=C_SIG, lw=0, label="phylogenetic signal (P ≤ 0.05)")
|
|
103
|
+
ax.axhline(-np.log10(a.fdr), color=MUTED, lw=0.6, ls=":"); ax.text(0.01, -np.log10(a.fdr) + 0.1, f"q = {a.fdr}", fontsize=6.5, color=MUTED)
|
|
104
|
+
top = D.sort_values("eta2", ascending=False).head(a.label_top)
|
|
105
|
+
label_column(ax, top.eta2.values, top.mlq.values, list(top.label))
|
|
106
|
+
ax.set_xlabel("species separation (Kruskal–Wallis η²)", fontsize=8); ax.set_ylabel("−log10 q (FDR)", fontsize=8)
|
|
107
|
+
ax.set_title(f"a {len(D)} characters: separation and significance", loc="left", fontsize=9, weight="bold", color=INK)
|
|
108
|
+
ax.legend(fontsize=6.5, frameon=False, loc="upper left"); ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
|
|
109
|
+
if tree_txt:
|
|
110
|
+
ax = axs[0, 1]; E = D.dropna(subset=["K"])
|
|
111
|
+
s2 = E.K_P <= 0.05
|
|
112
|
+
ax.scatter(E.eta2[~s2], E.K[~s2], s=9, color=C_NS, alpha=0.7, lw=0)
|
|
113
|
+
ax.scatter(E.eta2[s2], E.K[s2], s=12, color=C_SIG, lw=0)
|
|
114
|
+
both = E[s2 & (E.q <= a.fdr)].sort_values("eta2", ascending=False).head(a.label_top)
|
|
115
|
+
label_column(ax, both.eta2.values, both.K.values, list(both.label))
|
|
116
|
+
ax.axhline(1, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 1.02, "K = 1 (Brownian motion)", fontsize=6.5, color=MUTED, ha="right", transform=ax.get_yaxis_transform())
|
|
117
|
+
ax.set_xlabel("species separation (Kruskal–Wallis η²)", fontsize=8); ax.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
|
|
118
|
+
ax.set_title(f"b separation and phylogenetic signal ({int(s2.sum())} with P ≤ 0.05)", loc="left", fontsize=9, weight="bold", color=INK)
|
|
119
|
+
ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
|
|
120
|
+
for ext in ("png", "pdf"):
|
|
121
|
+
fig.savefig(out / f"character_signal_volcano.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _robust_figure(A, out, tree_txt, a):
|
|
125
|
+
"""a: hold-out naming against novelty AUC (the character-robustness plot); b: naming against Blomberg's K"""
|
|
126
|
+
D = A.dropna(subset=["top1", "novelty_auc"]).copy()
|
|
127
|
+
sig = (D.K_P <= 0.05) if "K_P" in D else pd.Series(False, index=D.index)
|
|
128
|
+
fig, axs = plt.subplots(1, 2 if tree_txt else 1, figsize=(13.5 if tree_txt else 7.5, 4.8), squeeze=False,
|
|
129
|
+
gridspec_kw={"wspace": 1.0})
|
|
130
|
+
ax = axs[0, 0]
|
|
131
|
+
ax.scatter(100 * D.top1[~sig], D.novelty_auc[~sig], s=9, color=C_NS, alpha=0.7, lw=0,
|
|
132
|
+
label="no phylogenetic signal (P > 0.05)" if tree_txt else "character")
|
|
133
|
+
if tree_txt:
|
|
134
|
+
ax.scatter(100 * D.top1[sig], D.novelty_auc[sig], s=12, color=C_SIG, lw=0, label="phylogenetic signal (P ≤ 0.05)")
|
|
135
|
+
ax.axhline(0.5, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 0.505, "AUC 0.5 = chance", fontsize=6.5, color=MUTED,
|
|
136
|
+
ha="right", transform=ax.get_yaxis_transform())
|
|
137
|
+
top = D.assign(sc=D.top1 + D.novelty_auc).sort_values("sc", ascending=False).head(a.label_top)
|
|
138
|
+
label_column(ax, 100 * top.top1.values, top.novelty_auc.values, [short_label(st, lb) for st, lb in zip(top.structure, top.label)])
|
|
139
|
+
ax.set_xlabel("names a withheld specimen (%, this character alone)", fontsize=8)
|
|
140
|
+
ax.set_ylabel("tells a described species from an unseen one (AUC)", fontsize=8)
|
|
141
|
+
ax.set_title(f"a {len(D)} characters: naming against novelty (hold-outs)", loc="left", fontsize=9, weight="bold", color=INK)
|
|
142
|
+
ax.legend(fontsize=6.5, frameon=False, loc="upper left"); ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
|
|
143
|
+
if tree_txt:
|
|
144
|
+
ax = axs[0, 1]; E = D.dropna(subset=["K"]); s2 = E.K_P <= 0.05
|
|
145
|
+
ax.scatter(100 * E.top1[~s2], E.K[~s2], s=9, color=C_NS, alpha=0.7, lw=0)
|
|
146
|
+
ax.scatter(100 * E.top1[s2], E.K[s2], s=12, color=C_SIG, lw=0)
|
|
147
|
+
both = E[s2].sort_values("top1", ascending=False).head(a.label_top)
|
|
148
|
+
label_column(ax, 100 * both.top1.values, both.K.values, [short_label(st, lb) for st, lb in zip(both.structure, both.label)])
|
|
149
|
+
ax.axhline(1, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 1.02, "K = 1 (Brownian motion)", fontsize=6.5, color=MUTED, ha="right", transform=ax.get_yaxis_transform())
|
|
150
|
+
ax.set_xlabel("names a withheld specimen (%, this character alone)", fontsize=8); ax.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
|
|
151
|
+
ax.set_title(f"b naming and phylogenetic signal ({int(s2.sum())} with P ≤ 0.05)", loc="left", fontsize=9, weight="bold", color=INK)
|
|
152
|
+
ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
|
|
153
|
+
for ext in ("png", "pdf"):
|
|
154
|
+
fig.savefig(out / f"character_signal_volcano.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _phylo_figure(A, out, a):
|
|
158
|
+
"""companion to the character-robustness figure, same family colours: a, every character's phylogenetic signal per
|
|
159
|
+
structure; b, naming a withheld specimen against Blomberg's K. Filled = P <= 0.05, open = not."""
|
|
160
|
+
D = A.dropna(subset=["K", "top1"]).copy()
|
|
161
|
+
fams = sorted(A["family"].dropna().astype(str).unique()) # same order and palette as fig_character_robustness
|
|
162
|
+
pal = dict(zip(fams, ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#D55E00", "#000000", "#56B4E9", "#F0E442",
|
|
163
|
+
"#999999", "#7B3294"][:len(fams)] + ["#555555"] * max(0, len(fams) - 10)))
|
|
164
|
+
sig = D.K_P <= 0.05
|
|
165
|
+
share = D.groupby("structure").apply(lambda g: (g.K_P <= 0.05).mean())
|
|
166
|
+
order = share.sort_values().index.tolist()
|
|
167
|
+
fig, (ax, ax2) = plt.subplots(1, 2, figsize=(15, max(6, 0.3 * len(order) + 2)), gridspec_kw={"width_ratios": [1.2, 1], "wspace": 0.3})
|
|
168
|
+
ypos = {st: i for i, st in enumerate(order)}
|
|
169
|
+
jit = pd.Series(np.random.default_rng(0).uniform(-0.25, 0.25, len(D)), index=D.index)
|
|
170
|
+
for (f, filled), g in D.assign(_s=sig, _j=jit).groupby(["family", "_s"]):
|
|
171
|
+
ys = g.structure.map(ypos) + g._j
|
|
172
|
+
ax.scatter(g.K, ys, s=16 if filled else 12, facecolor=pal[str(f)] if filled else "none", edgecolor=pal[str(f)],
|
|
173
|
+
lw=0.7, alpha=0.9 if filled else 0.55, zorder=3 if filled else 2)
|
|
174
|
+
for st, i in ypos.items():
|
|
175
|
+
g = D[D.structure == st]
|
|
176
|
+
ax.text(1.0, i, f" {int((g.K_P <= 0.05).sum())}/{len(g)}", transform=ax.get_yaxis_transform(), va="center", fontsize=6, color="#546e7a")
|
|
177
|
+
ax.axvline(1, color="#b71c1c", lw=0.8, ls="--")
|
|
178
|
+
ax.set_yticks(range(len(order))); ax.set_yticklabels(order, fontsize=6.8)
|
|
179
|
+
ax.set_xlabel("phylogenetic signal of each character (Blomberg's K; 1 = Brownian motion)", fontsize=8)
|
|
180
|
+
ax.set_title("a Phylogenetic signal of every character, by structure\n(filled: P ≤ 0.05; ringed: P ≤ 0.05 and K > 1; "
|
|
181
|
+
"right: characters with signal / tested)",
|
|
182
|
+
loc="left", fontsize=9.5)
|
|
183
|
+
for f in fams:
|
|
184
|
+
ax.scatter([], [], s=16, color=pal[f], label=f)
|
|
185
|
+
ax.scatter([], [], s=14, facecolor="none", edgecolor="#555555", label="P > 0.05 (open)")
|
|
186
|
+
ax.legend(fontsize=6.5, frameon=False, loc="lower right", ncol=2)
|
|
187
|
+
for (f, filled), g in D.assign(_s=sig).groupby(["family", "_s"]):
|
|
188
|
+
ax2.scatter(100 * g.top1, g.K, s=16 if filled else 11, facecolor=pal[str(f)] if filled else "none", edgecolor=pal[str(f)],
|
|
189
|
+
lw=0.7, alpha=0.9 if filled else 0.5, zorder=3 if filled else 2)
|
|
190
|
+
ax2.axhline(1, color="#b71c1c", lw=0.8, ls="--")
|
|
191
|
+
# every character with signal (P <= 0.05) and K > 1: labelled inside the empty upper part, short labels
|
|
192
|
+
above = D[sig & (D.K > 1)].sort_values("K", ascending=False)
|
|
193
|
+
if len(above):
|
|
194
|
+
ax2.scatter(100 * above.top1, above.K, s=40, facecolor="none", edgecolor=INK, lw=0.6, zorder=4)
|
|
195
|
+
ax.scatter(above.K, above.structure.map(ypos) + jit.loc[above.index], s=40, facecolor="none", edgecolor=INK, lw=0.6, zorder=4)
|
|
196
|
+
short = [short_label(st, lb).replace("forewing: distance ", "").replace("distance ", "")
|
|
197
|
+
for st, lb in zip(above.structure, above.label)]
|
|
198
|
+
label_column(ax2, 100 * above.top1.values, above.K.values, short, side_x=0.56, fontsize=4.8, gap_frac=1 / 75,
|
|
199
|
+
colors=[C_ROBUST if x >= a.robust_x else C_WEAK for x in (above.top1 / above.chance_top1)])
|
|
200
|
+
lab = D[sig & ~D.index.isin(above.index)].assign(sc=D.top1 / D.chance_top1).sort_values("sc", ascending=False).head(a.label_top)
|
|
201
|
+
label_column(ax2, 100 * lab.top1.values, lab.K.values, [short_label(st, lb) for st, lb in zip(lab.structure, lab.label)],
|
|
202
|
+
colors=[C_ROBUST if x >= a.robust_x else C_WEAK for x in (lab.top1 / lab.chance_top1)])
|
|
203
|
+
ax2.set_xlabel("names a withheld specimen (%)", fontsize=8); ax2.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
|
|
204
|
+
ax2.set_title(f"b All {len(D)} characters: naming against phylogenetic signal ({int(sig.sum())} with P ≤ 0.05)",
|
|
205
|
+
loc="left", fontsize=9.5)
|
|
206
|
+
# the key: which characters are labelled, and why (both sets require phylogenetic signal)
|
|
207
|
+
unl = D[~sig]
|
|
208
|
+
by_rel = unl.assign(sc=unl.top1 / unl.chance_top1).sort_values("sc", ascending=False).head(3)
|
|
209
|
+
by_raw = unl.sort_values("top1", ascending=False).head(3)
|
|
210
|
+
fmt = lambda g: "; ".join(f"{short_label(r.structure, r.label)} ({100 * r.top1:.0f}% named, chance {100 * r.chance_top1:.0f}%, "
|
|
211
|
+
f"K {r.K:.2f}, P {r.K_P:.2f})" for r in g.itertuples()) # noqa: E731
|
|
212
|
+
nrob = int(((D.top1 / D.chance_top1) >= a.robust_x).sum())
|
|
213
|
+
key = (f"Labels. Blue text: the character names withheld specimens at least {a.robust_x:g}× chance ({nrob} of {len(D)} "
|
|
214
|
+
"characters do); grey text: less.\n"
|
|
215
|
+
f" In the plot, ringed: every character with phylogenetic signal (P ≤ 0.05) and K > 1 ({len(above)}).\n"
|
|
216
|
+
f" Right column: the {len(lab)} other characters with signal that name withheld specimens best relative to chance\n"
|
|
217
|
+
" (% named ÷ % expected by chance, from the character-robustness analysis; fair across structures with few and many species).\n"
|
|
218
|
+
" Both sets require phylogenetic signal, so the most robust characters WITHOUT signal are not labelled:\n"
|
|
219
|
+
f" by % named (as ranked in the character-robustness figure): {fmt(by_raw)}\n"
|
|
220
|
+
f" by % named relative to chance: {fmt(by_rel)}\n"
|
|
221
|
+
"P: permutation test of phylogenetic signal (K larger than with species shuffled across the tree); it does not test K against 1.")
|
|
222
|
+
ax2.text(-1.25, -0.09, key, transform=ax2.transAxes, ha="left", va="top", fontsize=6.6, color=INK, linespacing=1.5)
|
|
223
|
+
for s_ in ("top", "right"):
|
|
224
|
+
ax.spines[s_].set_visible(False); ax2.spines[s_].set_visible(False)
|
|
225
|
+
for ext in ("png", "pdf"):
|
|
226
|
+
fig.savefig(out / f"character_phylo_signal.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
|
|
227
|
+
plt.close(fig)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv=None):
|
|
231
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
232
|
+
ap.add_argument("--matrix_dir", required=True)
|
|
233
|
+
ap.add_argument("--tree", default=None, help="Newick tree, tips = species codes (adds phylogenetic signal)")
|
|
234
|
+
ap.add_argument("--robustness", default=None,
|
|
235
|
+
help="character_robustness.tsv from biorag_character_robustness_v1.py: rank characters by hold-out "
|
|
236
|
+
"naming (top-1) and novelty AUC instead of the in-sample eta^2 (recommended)")
|
|
237
|
+
ap.add_argument("--top", type=int, default=25, help="characters per structure in the table (default 25)")
|
|
238
|
+
ap.add_argument("--iterations", type=int, default=9999,
|
|
239
|
+
help="permutations for K (default 9999: with hundreds of characters, 999 cannot reach FDR q < 0.05)")
|
|
240
|
+
ap.add_argument("--min_species", type=int, default=4, help="species needed to test a character (default 4)")
|
|
241
|
+
ap.add_argument("--fdr", type=float, default=0.05)
|
|
242
|
+
ap.add_argument("--figure", action="store_true",
|
|
243
|
+
help="also draw character_signal_volcano.png/.pdf (off by default: the pipeline's character figure "
|
|
244
|
+
"is biorag_character_robustness_v1's fig_character_robustness)")
|
|
245
|
+
ap.add_argument("--label_top", type=int, default=12, help="characters labelled in the volcano plot")
|
|
246
|
+
ap.add_argument("--robust_x", type=float, default=3.0,
|
|
247
|
+
help="in the phylogenetic figure, labels of characters that name withheld specimens at least this many "
|
|
248
|
+
"times chance are drawn in blue (default 3)")
|
|
249
|
+
ap.add_argument("--seed", type=int, default=0)
|
|
250
|
+
ap.add_argument("--out_dir", required=True)
|
|
251
|
+
a = ap.parse_args(argv)
|
|
252
|
+
md = Path(a.matrix_dir); out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
L = pd.read_csv(md / "specimen_matrix_long.csv")
|
|
254
|
+
fd = pd.read_csv(md / "feature_dictionary.tsv", sep="\t").set_index("feature_id")
|
|
255
|
+
tree_txt = Path(a.tree).read_text() if a.tree else None
|
|
256
|
+
tips = {t.name for t in dp.tips_of(dp.parse_newick(tree_txt))} if tree_txt else set()
|
|
257
|
+
rng = np.random.default_rng(a.seed); ccache = {}; rows = []
|
|
258
|
+
for fid, g in L.groupby("feature_id"):
|
|
259
|
+
g = g.dropna(subset=["value"])
|
|
260
|
+
per = g.groupby("specimen_id").agg(species=("species", "first"), value=("value", "mean"))
|
|
261
|
+
cnt = per.species.value_counts(); multi = cnt[cnt >= 2].index
|
|
262
|
+
row = {"feature_id": fid, "structure": fd.loc[fid, "category"] if fid in fd.index else fid.split(".")[0],
|
|
263
|
+
"label": fd.loc[fid, "label"] if fid in fd.index else fid, "family": fd.loc[fid, "family"] if fid in fd.index else "",
|
|
264
|
+
"tier": fd.loc[fid, "tier"] if fid in fd.index else "", "specimens": len(per), "species": per.species.nunique()}
|
|
265
|
+
sub = per[per.species.isin(multi)]
|
|
266
|
+
if len(multi) >= max(2, a.min_species) and sub.value.nunique() > 1:
|
|
267
|
+
groups = [v.values for _, v in sub.groupby("species").value]
|
|
268
|
+
H, p = kruskal(*groups); n, k = len(sub), len(groups)
|
|
269
|
+
row.update(H=H, p=p, eta2=max(0.0, (H - k + 1) / (n - k)) if n > k else np.nan, kw_species=k)
|
|
270
|
+
if tree_txt:
|
|
271
|
+
means = per.groupby("species").value.mean()
|
|
272
|
+
sp = sorted(set(means.index) & tips)
|
|
273
|
+
if len(sp) >= a.min_species and means.loc[sp].nunique() > 1:
|
|
274
|
+
key = frozenset(sp)
|
|
275
|
+
if key not in ccache:
|
|
276
|
+
t = dp.prune(dp.parse_newick(tree_txt), set(sp)); tp = dp.tips_of(t)
|
|
277
|
+
ccache[key] = ([x.name for x in tp], dp.vcv(tp, tp))
|
|
278
|
+
order, C = ccache[key]
|
|
279
|
+
K, P = blomberg_k_perm(means.loc[order].values.astype(float), C, a.iterations, rng)
|
|
280
|
+
row.update(K=K, K_P=P, tree_species=len(order))
|
|
281
|
+
rows.append(row)
|
|
282
|
+
A = pd.DataFrame(rows)
|
|
283
|
+
A["q"] = bh(A.get("p", pd.Series(np.nan, index=A.index)).values)
|
|
284
|
+
if "K_P" in A:
|
|
285
|
+
A["K_q"] = bh(A["K_P"].values)
|
|
286
|
+
rob = a.robustness is not None
|
|
287
|
+
if rob:
|
|
288
|
+
Rb = pd.read_csv(a.robustness, sep="\t")
|
|
289
|
+
keep = ["feature_id", "top1", "top1_ci_low", "top1_ci_high", "chance_top1", "times_chance", "novelty_auc", "species_tested"]
|
|
290
|
+
A = A.merge(Rb[[c for c in keep if c in Rb.columns]], on="feature_id", how="left")
|
|
291
|
+
A = A.sort_values(["structure", "top1", "novelty_auc"], ascending=[True, False, False])
|
|
292
|
+
rank_col = "top1"
|
|
293
|
+
else:
|
|
294
|
+
A = A.sort_values(["structure", "eta2"], ascending=[True, False])
|
|
295
|
+
rank_col = "eta2"
|
|
296
|
+
A.to_csv(out / "character_signal_all.tsv", sep="\t", index=False)
|
|
297
|
+
T = A.dropna(subset=[rank_col]).groupby("structure", group_keys=False).head(a.top)
|
|
298
|
+
T.to_csv(out / "character_signal_top.tsv", sep="\t", index=False)
|
|
299
|
+
|
|
300
|
+
# readable table
|
|
301
|
+
how = ("hold-out naming (each specimen withheld; share whose own species is nearest on this character alone)"
|
|
302
|
+
if rob else "species separation (Kruskal-Wallis eta^2)")
|
|
303
|
+
lines = [f"# Most informative characters per structure (top {a.top} by {how})", "",
|
|
304
|
+
("named: % of withheld specimens named (95% interval), chance in brackets; novelty AUC: tells a described "
|
|
305
|
+
"species from an unseen one (0.5 = no better than chance) - the measures drawn in the character-robustness "
|
|
306
|
+
"figure (fig_character_robustness); " if rob else "")
|
|
307
|
+
+ "eta2: Kruskal-Wallis effect size across species; q: Benjamini-Hochberg over all characters"
|
|
308
|
+
+ ("; K: Blomberg's K on the tree (P: permutation, " + str(a.iterations) + ")" if tree_txt else ""), ""]
|
|
309
|
+
for st, g in T.groupby("structure", sort=False):
|
|
310
|
+
lines += [f"## {st} ({len(g)} of {int((A.structure == st).sum())} characters)", "",
|
|
311
|
+
"| character | species |" + (" named % (95% CI) [chance] | novelty AUC |" if rob else "") + " eta2 | q |"
|
|
312
|
+
+ (" K | P |" if tree_txt else ""),
|
|
313
|
+
"|---|---|" + ("---|---|" if rob else "") + "---|---|" + ("---|---|" if tree_txt else "")]
|
|
314
|
+
for r in g.itertuples():
|
|
315
|
+
s = f"| {r.label} | {r.species} |"
|
|
316
|
+
if rob:
|
|
317
|
+
s += (f" {100 * r.top1:.0f} ({100 * r.top1_ci_low:.0f}–{100 * r.top1_ci_high:.0f}) [{100 * r.chance_top1:.0f}] |"
|
|
318
|
+
f" {r.novelty_auc:.2f} |") if pd.notna(r.top1) else " - | - |"
|
|
319
|
+
s += f" {r.eta2:.2f} | {r.q:.1e} |" if pd.notna(r.eta2) else " - | - |"
|
|
320
|
+
if tree_txt:
|
|
321
|
+
s += f" {r.K:.2f} | {r.K_P:.3f} |" if pd.notna(getattr(r, "K", np.nan)) else " - | - |"
|
|
322
|
+
lines.append(s)
|
|
323
|
+
lines.append("")
|
|
324
|
+
(out / "character_signal_top.md").write_text("\n".join(lines))
|
|
325
|
+
|
|
326
|
+
# figure
|
|
327
|
+
if rob and tree_txt: # the phylogenetic companion to the character-robustness figure
|
|
328
|
+
_phylo_figure(A, out, a)
|
|
329
|
+
if a.figure and rob:
|
|
330
|
+
_robust_figure(A, out, tree_txt, a)
|
|
331
|
+
D = A.dropna(subset=["eta2", "q"]).copy()
|
|
332
|
+
if a.figure and not rob:
|
|
333
|
+
_eta_figure(D, out, tree_txt, a)
|
|
334
|
+
summ = {"characters": int(len(A)), "ranked_by": rank_col, "tested_for_separation": int(A.eta2.notna().sum()),
|
|
335
|
+
"separate_species_q_le_fdr": int((A.q <= a.fdr).sum()), "top_per_structure": a.top,
|
|
336
|
+
"structures": int(A.structure.nunique())}
|
|
337
|
+
if tree_txt:
|
|
338
|
+
summ.update(tested_for_signal=int(A.K.notna().sum()), signal_P_le_0_05=int((A.K_P <= 0.05).sum()),
|
|
339
|
+
signal_q_le_fdr=int((A.K_q <= a.fdr).sum()), iterations=a.iterations,
|
|
340
|
+
separate_and_signal=int(((A.q <= a.fdr) & (A.K_P <= 0.05)).sum()))
|
|
341
|
+
json.dump(summ, open(out / "character_signal_summary.json", "w"), indent=2)
|
|
342
|
+
print(json.dumps(summ))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
main()
|