descriptron-core 2.1.2__tar.gz → 2.2.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.1.2 → descriptron_core-2.2.0}/PKG-INFO +1 -2
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/pyproject.toml +1 -1
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_llm_backend.py +5 -0
- descriptron_core-2.2.0/src/descriptron_core/tools/biorag_provenance_v1.py +230 -0
- descriptron_core-2.2.0/src/descriptron_core/tools/descriptron_phylo.py +377 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/run_full_pipeline_v2.py +116 -1
- descriptron_core-2.2.0/src/descriptron_core/tools/validate_phylo_published_waldron2025.py +116 -0
- descriptron_core-2.2.0/src/descriptron_core/tools/validate_phylo_real_tree.py +155 -0
- descriptron_core-2.2.0/src/descriptron_core/tools/validate_phylo_traits.py +186 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/validate_semilandmarks_vs_geomorph.py +5 -0
- descriptron_core-2.1.2/NOTICE.bak_pre_212_20260929_1100 +0 -59
- descriptron_core-2.1.2/pyproject.toml.bak_pre_203_20260924_145320 +0 -81
- descriptron_core-2.1.2/pyproject.toml.bak_pre_204_20260924_165335 +0 -82
- descriptron_core-2.1.2/pyproject.toml.bak_pre_210_20260925_103754 +0 -82
- descriptron_core-2.1.2/pyproject.toml.bak_pre_211_20260925_114912 +0 -82
- descriptron_core-2.1.2/pyproject.toml.bak_pre_keys_20260924_165335 +0 -81
- descriptron_core-2.1.2/src/descriptron_core/cli.py.bak_pre_keys_20260924_165335 +0 -98
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/.gitignore +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/LICENSE +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/NOTICE +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/README.md +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/__init__.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/cli.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/diaphorina_user_prompts.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/ontologies/aism.obo +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/ontologies/pato.obo +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/FHS_and_CLAHE_V21.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_add_discrete_characters_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_annotation_reorient_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_annotation_screen_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_autapomorphy_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_calibrate_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_calibration_figures_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_character_gate_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_character_reliability_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_character_robustness_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_collaborator_forms_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_computed_sets_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_confabulation_checker_v2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_congruence_compare_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_description_refiner_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_descriptive_scoring_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_feature_policy.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_frame_orientation_calibrate_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_frame_orientation_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_gate_compare_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_graph_identify_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_graph_support_figure_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_homology_frame_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_instrument_compare_v3.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_builder_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_feature_filter_v2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_fuzzy_figures_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_fuzzy_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_holdout_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_key_jackknife_support_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_label_conflict_check_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_make_taxon_profile_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_methods_summary_figure_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_novelty_score_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_ontology_annotator_v2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_outgroup_batch_check_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_outline_shape_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/diaphorina_user_prompts.txt +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/ontologies/aism.obo +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/ontologies/pato.obo +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_rebuild_downstream_v1.sh +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_reliability_figures_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_rename_species_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_rerun_descriptive_tables_v1.sh +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_retest_compare_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_specimen_id.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_subjective_checks_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_type_material_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_vlm_characters_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_vlm_heatmap_figures_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_vlm_reliability_figure_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_vlm_roi_figures_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biosyslit_rag_retrieval.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biosyslit_rag_retrieval_v2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/build_monograph_exports_v1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/build_species_treatment_docx_v2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/coco_combiner_V13.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/coco_converter_v24.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/color_extraction_with_names-v8.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/color_phenomics_homology_v2_1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/compile_specimen_data.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_centerline.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_coco_tools.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_convert.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_credentials.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_joints.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_metadata.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_rosetta.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/descriptron_shape_stats.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/landmark_gpa_V1.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/landmark_gpa_V2.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/make_reproducibility_bundle.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV34_GPA.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV42_GPA.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/texture_phenomics_from_coco.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/texture_phenomics_homology.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/validate_centerline_lengths.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/validate_pipeline_gm_vs_geomorph.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/validate_shape_stats_vs_geomorph.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/validate_shape_stats_vs_rrpp.py +0 -0
- {descriptron_core-2.1.2 → descriptron_core-2.2.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.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -9,7 +9,6 @@ Author: Alex R. Van Dam
|
|
|
9
9
|
License: Apache-2.0
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
License-File: NOTICE
|
|
12
|
-
License-File: NOTICE.bak_pre_212_20260929_1100
|
|
13
12
|
Keywords: COCO,morphometrics,phenomics,species description,taxonomy
|
|
14
13
|
Classifier: Intended Audience :: Science/Research
|
|
15
14
|
Classifier: License :: OSI Approved :: Apache Software License
|
{descriptron_core-2.1.2 → descriptron_core-2.2.0}/src/descriptron_core/tools/biorag_llm_backend.py
RENAMED
|
@@ -82,6 +82,11 @@ class LLMResponse:
|
|
|
82
82
|
def _log_call(log_path: Optional[str], record: Dict):
|
|
83
83
|
if not log_path:
|
|
84
84
|
return
|
|
85
|
+
try: # when the call was made, for matching a call to a result
|
|
86
|
+
from datetime import datetime, timezone
|
|
87
|
+
record = {"time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), **record}
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
85
90
|
with _LOG_LOCK:
|
|
86
91
|
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
87
92
|
with open(log_path, "a") as f:
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
biorag_provenance_v1.py — stamp every output with what produced it
|
|
4
|
+
==================================================================
|
|
5
|
+
|
|
6
|
+
A number in a manuscript is only checkable if a reader can get from it back to
|
|
7
|
+
the code and the inputs that made it. `manuscript_numbers_v18.tsv` already maps
|
|
8
|
+
each number to the report file that supplied it; this closes the remaining link,
|
|
9
|
+
from the report back to the script, the commit and the exact inputs.
|
|
10
|
+
|
|
11
|
+
Three lines in a script:
|
|
12
|
+
|
|
13
|
+
from biorag_provenance_v1 import stamp, write_json
|
|
14
|
+
...
|
|
15
|
+
write_json(out_dir / "report.json", payload) # adds "_provenance"
|
|
16
|
+
|
|
17
|
+
or, for anything that is not JSON (a CSV, a figure, a directory of tables):
|
|
18
|
+
|
|
19
|
+
stamp(out_dir, inputs=[args.matrix, args.coco]) # writes provenance.json
|
|
20
|
+
|
|
21
|
+
What is recorded: the script and its SHA-256, the git commit and whether the tree
|
|
22
|
+
was dirty, the full command line, the interpreter and platform, the run time in
|
|
23
|
+
UTC, and a SHA-256 for every named input file. Hashing the inputs is what makes
|
|
24
|
+
the record worth anything — "produced by version X from these exact files" is a
|
|
25
|
+
claim a sceptic can test, "produced by version X" is not.
|
|
26
|
+
|
|
27
|
+
Deliberately: no network, no imports beyond the standard library, and every
|
|
28
|
+
failure is swallowed. A provenance stamp must never be the reason an analysis
|
|
29
|
+
dies.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import platform
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
__all__ = ["provenance", "stamp", "write_json", "sha256"]
|
|
43
|
+
|
|
44
|
+
_MAX_HASH_BYTES = 2_000_000_000 # 2 GB: beyond this, record the size only
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sha256(path) -> str | None:
|
|
48
|
+
"""SHA-256 of a file, or None if it cannot be read. Never raises."""
|
|
49
|
+
try:
|
|
50
|
+
p = Path(path)
|
|
51
|
+
if not p.is_file():
|
|
52
|
+
return None
|
|
53
|
+
if p.stat().st_size > _MAX_HASH_BYTES:
|
|
54
|
+
return f"(not hashed: {p.stat().st_size} bytes)"
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
with open(p, "rb") as fh:
|
|
57
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return h.hexdigest()
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _git(*args, cwd=None):
|
|
65
|
+
try:
|
|
66
|
+
out = subprocess.run(["git", *args], cwd=cwd, capture_output=True,
|
|
67
|
+
text=True, timeout=10)
|
|
68
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def provenance(inputs=None, script=None, extra=None) -> dict:
|
|
74
|
+
"""The record itself. Safe to call from anywhere; never raises."""
|
|
75
|
+
try:
|
|
76
|
+
script_path = Path(script or sys.argv[0]).resolve()
|
|
77
|
+
except Exception:
|
|
78
|
+
script_path = Path(sys.argv[0] if sys.argv else "unknown")
|
|
79
|
+
repo = script_path.parent if script_path.exists() else None
|
|
80
|
+
|
|
81
|
+
# Only report a commit if THIS FILE is tracked by that repository. The
|
|
82
|
+
# Descriptron scripts currently sit untracked inside a clone of
|
|
83
|
+
# facebookresearch/segment-anything-2, so a naive `git rev-parse HEAD` returns
|
|
84
|
+
# SAM2's commit — a hash that says nothing about the code that produced the
|
|
85
|
+
# output, and is worse than no hash because it looks authoritative.
|
|
86
|
+
commit = dirty = tracked = None
|
|
87
|
+
if repo is not None:
|
|
88
|
+
tracked = bool(_git("ls-files", "--error-unmatch", str(script_path), cwd=repo))
|
|
89
|
+
if tracked:
|
|
90
|
+
commit = _git("rev-parse", "HEAD", cwd=repo)
|
|
91
|
+
dirty = bool(_git("status", "--porcelain", "--", str(script_path), cwd=repo))
|
|
92
|
+
|
|
93
|
+
rec = {
|
|
94
|
+
"script": script_path.name,
|
|
95
|
+
"script_path": str(script_path),
|
|
96
|
+
"script_sha256": sha256(script_path),
|
|
97
|
+
"git_commit": commit,
|
|
98
|
+
"git_dirty": dirty,
|
|
99
|
+
# a run from an untracked tree cannot be pinned to a version; say so in
|
|
100
|
+
# the record rather than leaving a reader to infer it
|
|
101
|
+
"git_tracked": tracked,
|
|
102
|
+
"git_note": (None if commit else
|
|
103
|
+
"this script is NOT tracked by any git repository, so the output "
|
|
104
|
+
"cannot be tied to a version of the code"),
|
|
105
|
+
"command": " ".join(sys.argv),
|
|
106
|
+
"argv": list(sys.argv),
|
|
107
|
+
"cwd": os.getcwd(),
|
|
108
|
+
"python": sys.version.split()[0],
|
|
109
|
+
"platform": platform.platform(),
|
|
110
|
+
"hostname": platform.node(),
|
|
111
|
+
"run_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
112
|
+
"inputs": {},
|
|
113
|
+
}
|
|
114
|
+
for item in (inputs or []):
|
|
115
|
+
if item is None:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
rec["inputs"][str(item)] = sha256(item)
|
|
119
|
+
except Exception:
|
|
120
|
+
rec["inputs"][str(item)] = None
|
|
121
|
+
if extra:
|
|
122
|
+
rec["extra"] = extra
|
|
123
|
+
return rec
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def stamp(out_dir, inputs=None, script=None, extra=None, name="provenance.json"):
|
|
127
|
+
"""Write a provenance record beside an output that is not itself JSON."""
|
|
128
|
+
try:
|
|
129
|
+
d = Path(out_dir)
|
|
130
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
rec = provenance(inputs=inputs, script=script, extra=extra)
|
|
132
|
+
(d / name).write_text(json.dumps(rec, indent=2))
|
|
133
|
+
return d / name
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def write_json(path, payload, inputs=None, script=None, extra=None, indent=2):
|
|
139
|
+
"""json.dump with a `_provenance` key added. Drop-in for an existing dump."""
|
|
140
|
+
try:
|
|
141
|
+
rec = provenance(inputs=inputs, script=script, extra=extra)
|
|
142
|
+
if isinstance(payload, dict):
|
|
143
|
+
payload = {**payload, "_provenance": rec}
|
|
144
|
+
else:
|
|
145
|
+
payload = {"data": payload, "_provenance": rec}
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
p = Path(path)
|
|
149
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
p.write_text(json.dumps(payload, indent=indent, default=str))
|
|
151
|
+
return p
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def verify(record_path) -> dict:
|
|
155
|
+
"""
|
|
156
|
+
Re-check a stamped record: does the script still hash the same, do the inputs
|
|
157
|
+
still hash the same? This is what a sceptical reader runs.
|
|
158
|
+
"""
|
|
159
|
+
rec = json.loads(Path(record_path).read_text())
|
|
160
|
+
rec = rec.get("_provenance", rec)
|
|
161
|
+
out = {"script": None, "inputs": {}, "ok": True}
|
|
162
|
+
now = sha256(rec.get("script_path"))
|
|
163
|
+
out["script"] = ("unchanged" if now and now == rec.get("script_sha256")
|
|
164
|
+
else "CHANGED or missing")
|
|
165
|
+
if out["script"] != "unchanged":
|
|
166
|
+
out["ok"] = False
|
|
167
|
+
for path, want in (rec.get("inputs") or {}).items():
|
|
168
|
+
got = sha256(path)
|
|
169
|
+
same = (got is not None and got == want)
|
|
170
|
+
out["inputs"][path] = "unchanged" if same else "CHANGED or missing"
|
|
171
|
+
if not same:
|
|
172
|
+
out["ok"] = False
|
|
173
|
+
# records written by run_full_pipeline_v2 also list what the step wrote
|
|
174
|
+
if isinstance(rec.get("outputs"), dict):
|
|
175
|
+
out["outputs"] = {}
|
|
176
|
+
for path, want in rec["outputs"].items():
|
|
177
|
+
if path.startswith("("):
|
|
178
|
+
continue
|
|
179
|
+
got = sha256(path)
|
|
180
|
+
same = (got is not None and got == want)
|
|
181
|
+
out["outputs"][path] = "unchanged" if same else "CHANGED or missing"
|
|
182
|
+
if not same:
|
|
183
|
+
out["ok"] = False
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def which(result_file, search_dir) -> list:
|
|
188
|
+
"""Which pipeline step wrote this file? Hash it and look for the hash among the outputs of
|
|
189
|
+
every provenance record under search_dir. A match means this exact content was written by
|
|
190
|
+
that step; no match means the file was changed afterwards or made outside the pipeline."""
|
|
191
|
+
want = sha256(result_file)
|
|
192
|
+
hits = []
|
|
193
|
+
if want is None:
|
|
194
|
+
return hits
|
|
195
|
+
for rp in sorted(Path(search_dir).rglob("*provenance*.json")):
|
|
196
|
+
try:
|
|
197
|
+
rec = json.loads(rp.read_text())
|
|
198
|
+
except Exception:
|
|
199
|
+
continue
|
|
200
|
+
rec = rec.get("_provenance", rec)
|
|
201
|
+
outs = rec.get("outputs") or {}
|
|
202
|
+
if isinstance(outs, dict) and want in outs.values():
|
|
203
|
+
hits.append({"record": str(rp),
|
|
204
|
+
"step": (rec.get("extra") or {}).get("pipeline_step"),
|
|
205
|
+
"script": rec.get("script"), "script_sha256": rec.get("script_sha256"),
|
|
206
|
+
"git_commit": rec.get("git_commit"), "run_utc": rec.get("run_utc"),
|
|
207
|
+
"command": rec.get("command")})
|
|
208
|
+
return hits
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
import argparse
|
|
213
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
214
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
215
|
+
ap.add_argument("--verify", help="a provenance.json (or a report with _provenance) to re-check")
|
|
216
|
+
ap.add_argument("--show", action="store_true", help="print a record for this invocation")
|
|
217
|
+
ap.add_argument("--which", help="a result file: find the provenance record of the step that wrote it")
|
|
218
|
+
ap.add_argument("--search", default=".", help="folder to search for provenance records (with --which)")
|
|
219
|
+
a = ap.parse_args()
|
|
220
|
+
if a.verify:
|
|
221
|
+
result = verify(a.verify)
|
|
222
|
+
print(json.dumps(result, indent=2))
|
|
223
|
+
sys.exit(0 if result["ok"] else 1)
|
|
224
|
+
if a.which:
|
|
225
|
+
hits = which(a.which, a.search)
|
|
226
|
+
print(json.dumps(hits, indent=2) if hits else
|
|
227
|
+
"no provenance record lists this exact file content (changed since, or not made by the pipeline)")
|
|
228
|
+
sys.exit(0 if hits else 1)
|
|
229
|
+
if a.show:
|
|
230
|
+
print(json.dumps(provenance(), indent=2))
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
descriptron_phylo.py - phylogenetic comparative analyses of any continuous Descriptron traits
|
|
4
|
+
=============================================================================================
|
|
5
|
+
|
|
6
|
+
Given a Newick tree and one or more trait tables (landmark shape, linear measurements, colour pattern, texture, or
|
|
7
|
+
any other continuous features), computes on species means:
|
|
8
|
+
|
|
9
|
+
signal multivariate phylogenetic signal Kmult (Adams 2014), permutation P and effect size Z,
|
|
10
|
+
as geomorph::physignal
|
|
11
|
+
pgls phylogenetic regression of a trait set on a predictor (for example log centroid size: phylogenetic
|
|
12
|
+
allometry) by generalised least squares under Brownian motion, SS / R2 / F with residual
|
|
13
|
+
randomisation, as geomorph::procD.pgls
|
|
14
|
+
morphospace principal components of the species means with ancestral states estimated under Brownian motion
|
|
15
|
+
(generalised least squares) projected into the same space and the tree drawn through them, as
|
|
16
|
+
geomorph::gm.prcomp(phy = )
|
|
17
|
+
|
|
18
|
+
Trait tables: CSV with one row per specimen (or per species) and numeric columns. The species of each row comes
|
|
19
|
+
from a column (--species_col) or from a regular expression applied to an id column (--species_regex). Rows of the
|
|
20
|
+
same species are averaged. Species absent from the tree, or tree tips without data, are dropped (the tree is pruned).
|
|
21
|
+
|
|
22
|
+
python descriptron_phylo.py --tree tree.nwk --out_dir phylo/ \
|
|
23
|
+
--traits shape=landmark_gpa/forewing/forewing_procrustes_coords.csv \
|
|
24
|
+
--traits colour=color_homology/whole_wing/color_homology_features_whole_wing.csv \
|
|
25
|
+
--species_regex "morph_([A-Za-z]+\\d*?)_?\\d+_" --scale colour=standardize \
|
|
26
|
+
--pgls shape~logCS --analyses signal pgls morphospace
|
|
27
|
+
|
|
28
|
+
Scaling (--scale NAME=none|standardize|log): Kmult and the PGLS statistics weight every column by its variance, so a
|
|
29
|
+
set that mixes units (millimetres with ratios, colour channels with entropies) should be standardised; Procrustes
|
|
30
|
+
shape coordinates are analysed as they are (the default for a set whose columns look like x/y coordinates).
|
|
31
|
+
|
|
32
|
+
Validated against geomorph 4.1.1 and ape on geomorph's plethspecies data (validation/validate_phylo_real_tree.py).
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import csv
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Dict, List, Optional, Tuple
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
# ============================================================================ tree
|
|
47
|
+
class Node:
|
|
48
|
+
__slots__ = ("name", "length", "kids", "parent", "depth", "idx")
|
|
49
|
+
|
|
50
|
+
def __init__(self, name="", length=0.0):
|
|
51
|
+
self.name, self.length, self.kids, self.parent, self.depth, self.idx = name, length, [], None, 0.0, -1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_newick(s: str) -> Node:
|
|
55
|
+
s = s.strip().rstrip(";")
|
|
56
|
+
pos = 0
|
|
57
|
+
|
|
58
|
+
def node() -> Node:
|
|
59
|
+
nonlocal pos
|
|
60
|
+
n = Node()
|
|
61
|
+
if s[pos] == "(":
|
|
62
|
+
pos += 1
|
|
63
|
+
n.kids.append(node())
|
|
64
|
+
while s[pos] == ",":
|
|
65
|
+
pos += 1
|
|
66
|
+
n.kids.append(node())
|
|
67
|
+
pos += 1 # ')'
|
|
68
|
+
m = re.match(r"([^:,();\[]*)(?:\[[^\]]*\])?(?::([0-9.eE+\-]+))?", s[pos:])
|
|
69
|
+
pos += m.end()
|
|
70
|
+
n.name = m.group(1).strip().strip("'\"")
|
|
71
|
+
n.length = float(m.group(2) or 0.0)
|
|
72
|
+
for k in n.kids:
|
|
73
|
+
k.parent = n
|
|
74
|
+
return n
|
|
75
|
+
|
|
76
|
+
root = node()
|
|
77
|
+
root.length = 0.0 # the root's own branch does not count
|
|
78
|
+
_set_depths(root)
|
|
79
|
+
return root
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _set_depths(n: Node, d: float = 0.0):
|
|
83
|
+
n.depth = d
|
|
84
|
+
for k in n.kids:
|
|
85
|
+
_set_depths(k, d + k.length)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tips_of(n: Node) -> List[Node]:
|
|
89
|
+
return [n] if not n.kids else [t for k in n.kids for t in tips_of(k)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def internal_nodes(n: Node) -> List[Node]:
|
|
93
|
+
return ([n] if n.kids else []) + [x for k in n.kids for x in internal_nodes(k)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def prune(root: Node, keep: set) -> Node:
|
|
97
|
+
"""Drop tips not in `keep`; collapse nodes left with one child (their branch lengths add up)."""
|
|
98
|
+
def rec(n: Node) -> Optional[Node]:
|
|
99
|
+
if not n.kids:
|
|
100
|
+
return n if n.name in keep else None
|
|
101
|
+
kids = [c for c in (rec(k) for k in n.kids) if c is not None]
|
|
102
|
+
if not kids:
|
|
103
|
+
return None
|
|
104
|
+
if len(kids) == 1:
|
|
105
|
+
kids[0].length += n.length
|
|
106
|
+
return kids[0]
|
|
107
|
+
n.kids = kids
|
|
108
|
+
for k in kids:
|
|
109
|
+
k.parent = n
|
|
110
|
+
return n
|
|
111
|
+
|
|
112
|
+
r = rec(root)
|
|
113
|
+
r.length, r.parent = 0.0, None
|
|
114
|
+
_set_depths(r)
|
|
115
|
+
return r
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def mrca_depth(a: Node, b: Node) -> float:
|
|
119
|
+
anc = set()
|
|
120
|
+
x = a
|
|
121
|
+
while x is not None:
|
|
122
|
+
anc.add(id(x)); x = x.parent
|
|
123
|
+
y = b
|
|
124
|
+
while id(y) not in anc:
|
|
125
|
+
y = y.parent
|
|
126
|
+
return y.depth
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def vcv(nodes_a: List[Node], nodes_b: List[Node]) -> np.ndarray:
|
|
130
|
+
"""Brownian-motion covariance: shared path length from the root (tips and internal nodes alike)."""
|
|
131
|
+
return np.array([[mrca_depth(a, b) for b in nodes_b] for a in nodes_a])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ============================================================================ statistics
|
|
135
|
+
def _whiten(C: np.ndarray) -> np.ndarray:
|
|
136
|
+
"""P with P'P = C^-1 (any such P gives the same GLS sums of squares)."""
|
|
137
|
+
return np.linalg.inv(np.linalg.cholesky(C))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def kmult(Y: np.ndarray, C: np.ndarray) -> float:
|
|
141
|
+
"""Adams (2014) multivariate K, as geomorph::physignal."""
|
|
142
|
+
n = len(Y)
|
|
143
|
+
Ci = np.linalg.inv(C)
|
|
144
|
+
one = np.ones((n, 1))
|
|
145
|
+
a = np.linalg.solve(one.T @ Ci @ one, one.T @ Ci @ Y)
|
|
146
|
+
R = Y - one @ a
|
|
147
|
+
obs = np.trace(R.T @ R) / np.trace(R.T @ Ci @ R)
|
|
148
|
+
expct = (np.trace(C) - n / Ci.sum()) / (n - 1)
|
|
149
|
+
return float(obs / expct)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def effect_size(obs: float, rand: np.ndarray) -> float:
|
|
153
|
+
r = np.r_[obs, rand]
|
|
154
|
+
sd = r.std(ddof=1)
|
|
155
|
+
return float((obs - r.mean()) / sd) if sd > 0 else float("nan")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def phylo_signal(Y, C, iters, rng) -> Dict:
|
|
159
|
+
K = kmult(Y, C)
|
|
160
|
+
rand = np.array([kmult(Y[rng.permutation(len(Y))], C) for _ in range(iters)])
|
|
161
|
+
return {"Kmult": K, "P": float((1 + (rand >= K - 1e-12).sum()) / (iters + 1)), "Z": effect_size(K, rand)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pgls(Y: np.ndarray, x: np.ndarray, C: np.ndarray, iters: int, rng) -> Dict:
|
|
165
|
+
"""Y ~ x under Brownian motion by GLS; SS from the whitened data, residual randomisation of the null model
|
|
166
|
+
(intercept only), as RRPP / geomorph::procD.pgls with a single predictor."""
|
|
167
|
+
n = len(Y)
|
|
168
|
+
P = _whiten(C)
|
|
169
|
+
Yt = P @ Y
|
|
170
|
+
X0 = P @ np.ones((n, 1))
|
|
171
|
+
X1 = P @ np.column_stack([np.ones(n), x])
|
|
172
|
+
|
|
173
|
+
def rss(X, Yy):
|
|
174
|
+
B, *_ = np.linalg.lstsq(X, Yy, rcond=None)
|
|
175
|
+
R = Yy - X @ B
|
|
176
|
+
return float((R ** 2).sum())
|
|
177
|
+
|
|
178
|
+
B0, *_ = np.linalg.lstsq(X0, Yt, rcond=None)
|
|
179
|
+
fit0, res0 = X0 @ B0, Yt - X0 @ B0
|
|
180
|
+
ss_tot, ss_res = rss(X0, Yt), rss(X1, Yt)
|
|
181
|
+
ss = ss_tot - ss_res
|
|
182
|
+
df, dfr = 1, n - 2
|
|
183
|
+
F = (ss / df) / (ss_res / dfr)
|
|
184
|
+
rand = []
|
|
185
|
+
for _ in range(iters):
|
|
186
|
+
Yp = fit0 + res0[rng.permutation(n)]
|
|
187
|
+
s_t, s_r = rss(X0, Yp), rss(X1, Yp)
|
|
188
|
+
rand.append(((s_t - s_r) / df) / (s_r / dfr))
|
|
189
|
+
rand = np.array(rand)
|
|
190
|
+
return {"Df": df, "SS": ss, "MS": ss / df, "Rsq": ss / ss_tot, "F": F, "Z": effect_size(F, rand),
|
|
191
|
+
"P": float((1 + (rand >= F - 1e-12).sum()) / (iters + 1)), "SS_residual": ss_res, "SS_total": ss_tot}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ancestral_states(Y: np.ndarray, tips: List[Node], nodes: List[Node]) -> np.ndarray:
|
|
195
|
+
"""Ancestral states under Brownian motion by GLS: a = mu + C_nt C_tt^-1 (Y - 1 mu), with mu the GLS root state."""
|
|
196
|
+
Ctt = vcv(tips, tips)
|
|
197
|
+
Cnt = vcv(nodes, tips)
|
|
198
|
+
Ci = np.linalg.inv(Ctt)
|
|
199
|
+
one = np.ones((len(tips), 1))
|
|
200
|
+
mu = np.linalg.solve(one.T @ Ci @ one, one.T @ Ci @ Y)
|
|
201
|
+
return mu + Cnt @ Ci @ (Y - one @ mu)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ============================================================================ data
|
|
205
|
+
def _is_number(v: str) -> bool:
|
|
206
|
+
try:
|
|
207
|
+
float(v); return True
|
|
208
|
+
except ValueError:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_traits(path: str, species_col: Optional[str], species_regex: Optional[str], id_col: Optional[str]):
|
|
213
|
+
rows = list(csv.DictReader(open(path, newline="")))
|
|
214
|
+
if not rows:
|
|
215
|
+
sys.exit(f"{path}: no rows")
|
|
216
|
+
cols = list(rows[0])
|
|
217
|
+
if species_col and species_col in cols:
|
|
218
|
+
sp = [r[species_col] for r in rows]
|
|
219
|
+
else:
|
|
220
|
+
idc = id_col or next((c for c in ("filename", "specimen_id", "specimen", "image", "id", "file") if c in cols), cols[0])
|
|
221
|
+
if not species_regex:
|
|
222
|
+
sys.exit(f"{path}: give --species_col or --species_regex (no species column found)")
|
|
223
|
+
rx = re.compile(species_regex)
|
|
224
|
+
sp = []
|
|
225
|
+
for r in rows:
|
|
226
|
+
m = rx.search(r[idc])
|
|
227
|
+
sp.append(m.group(1) if m else None)
|
|
228
|
+
num = [c for c in cols if c != species_col and all(_is_number(r[c]) or r[c] == "" for r in rows)
|
|
229
|
+
and any(r[c] != "" for r in rows)]
|
|
230
|
+
M = np.array([[float(r[c]) if r[c] != "" else np.nan for c in num] for r in rows])
|
|
231
|
+
by: Dict[str, List[int]] = {}
|
|
232
|
+
for i, s in enumerate(sp):
|
|
233
|
+
if s:
|
|
234
|
+
by.setdefault(s, []).append(i)
|
|
235
|
+
species = sorted(by)
|
|
236
|
+
means = np.array([np.nanmean(M[by[s]], axis=0) for s in species])
|
|
237
|
+
keep = ~np.isnan(means).any(axis=0) # drop columns missing for any species
|
|
238
|
+
return species, [c for c, k in zip(num, keep) if k], means[:, keep], {s: len(v) for s, v in by.items()}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def scale(M: np.ndarray, how: str) -> np.ndarray:
|
|
242
|
+
if how == "standardize":
|
|
243
|
+
sd = M.std(axis=0, ddof=1)
|
|
244
|
+
ok = sd > 0
|
|
245
|
+
return (M[:, ok] - M[:, ok].mean(0)) / sd[ok]
|
|
246
|
+
if how == "log":
|
|
247
|
+
if (M <= 0).any():
|
|
248
|
+
sys.exit("--scale log needs positive values")
|
|
249
|
+
return np.log(M)
|
|
250
|
+
return M
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ============================================================================ plot
|
|
254
|
+
def plot_morphospace(pc_tips, pc_nodes, tips, nodes, ev, path, title):
|
|
255
|
+
import matplotlib
|
|
256
|
+
matplotlib.use("Agg")
|
|
257
|
+
import matplotlib.pyplot as plt
|
|
258
|
+
pos = {id(t): pc_tips[i] for i, t in enumerate(tips)}
|
|
259
|
+
pos.update({id(n): pc_nodes[i] for i, n in enumerate(nodes)})
|
|
260
|
+
fig, ax = plt.subplots(figsize=(6.5, 5.5))
|
|
261
|
+
for n in nodes:
|
|
262
|
+
for k in n.kids:
|
|
263
|
+
a, b = pos[id(n)], pos[id(k)]
|
|
264
|
+
ax.plot([a[0], b[0]], [a[1], b[1]], color="#6b7780", lw=0.9, zorder=1)
|
|
265
|
+
ax.scatter(pc_nodes[:, 0], pc_nodes[:, 1], s=14, color="white", edgecolor="#6b7780", zorder=2, label="ancestral states")
|
|
266
|
+
ax.scatter(pc_tips[:, 0], pc_tips[:, 1], s=30, color="#0072B2", zorder=3, label="species means")
|
|
267
|
+
for i, t in enumerate(tips):
|
|
268
|
+
ax.annotate(t.name, pc_tips[i, :2], xytext=(3, 3), textcoords="offset points", fontsize=7)
|
|
269
|
+
ax.set_xlabel(f"PC1 ({100 * ev[0]:.1f}%)"); ax.set_ylabel(f"PC2 ({100 * ev[1]:.1f}%)")
|
|
270
|
+
ax.set_title(title, fontsize=10); ax.legend(fontsize=7, frameon=False)
|
|
271
|
+
fig.tight_layout(); fig.savefig(path, dpi=200); plt.close(fig)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ============================================================================ main
|
|
275
|
+
def main(argv=None):
|
|
276
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
277
|
+
ap.add_argument("--tree", required=True, help="Newick tree whose tips are species names")
|
|
278
|
+
ap.add_argument("--traits", action="append", required=True, metavar="NAME=CSV",
|
|
279
|
+
help="a trait table; repeat for several sets (shape, measurements, colour, texture, ...)")
|
|
280
|
+
ap.add_argument("--species_col", default=None, help="column holding the species (default: use --species_regex)")
|
|
281
|
+
ap.add_argument("--species_regex", default=None, help="regex with one group extracting the species from the id column")
|
|
282
|
+
ap.add_argument("--id_col", default=None, help="id column for --species_regex (default: filename/specimen_id/...)")
|
|
283
|
+
ap.add_argument("--columns", action="append", default=[], metavar="NAME=REGEX",
|
|
284
|
+
help="keep only the columns of trait set NAME that match REGEX")
|
|
285
|
+
ap.add_argument("--scale", action="append", default=[], metavar="NAME=none|standardize|log")
|
|
286
|
+
ap.add_argument("--size_col", default=r"centroid|csize",
|
|
287
|
+
help="regex for a size column kept out of the trait set and used as the logCS predictor")
|
|
288
|
+
ap.add_argument("--pgls", action="append", default=[], metavar="SET~PREDICTOR",
|
|
289
|
+
help="PREDICTOR is logCS (from a set's centroid-size column) or SET2:COLUMN")
|
|
290
|
+
ap.add_argument("--analyses", nargs="+", default=["signal", "morphospace"], choices=["signal", "pgls", "morphospace"])
|
|
291
|
+
ap.add_argument("--iterations", type=int, default=999); ap.add_argument("--seed", type=int, default=1)
|
|
292
|
+
ap.add_argument("--out_dir", required=True)
|
|
293
|
+
a = ap.parse_args(argv)
|
|
294
|
+
out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
rng = np.random.default_rng(a.seed)
|
|
296
|
+
root = parse_newick(Path(a.tree).read_text())
|
|
297
|
+
colsel = dict(x.split("=", 1) for x in a.columns)
|
|
298
|
+
scales = dict(x.split("=", 1) for x in a.scale)
|
|
299
|
+
|
|
300
|
+
sets = {}
|
|
301
|
+
for spec in a.traits:
|
|
302
|
+
name, path = spec.split("=", 1)
|
|
303
|
+
sp, cols, M, counts = load_traits(path, a.species_col, a.species_regex, a.id_col)
|
|
304
|
+
if name in colsel:
|
|
305
|
+
rx = re.compile(colsel[name]); k = [i for i, c in enumerate(cols) if rx.search(c)]
|
|
306
|
+
cols, M = [cols[i] for i in k], M[:, k]
|
|
307
|
+
# a size column (centroid size) is kept aside: it is a predictor (logCS), not part of the trait set
|
|
308
|
+
szi = [i for i, c in enumerate(cols) if re.search(a.size_col, c, re.I)]
|
|
309
|
+
size = M[:, szi[0]] if szi else None
|
|
310
|
+
keep = [i for i in range(len(cols)) if i not in szi]
|
|
311
|
+
sets[name] = {"species": sp, "cols": [cols[i] for i in keep], "raw": M[:, keep], "size": size,
|
|
312
|
+
"counts": counts}
|
|
313
|
+
|
|
314
|
+
report = ["# Phylogenetic comparative analyses", "", f"Tree: {a.tree}", ""]
|
|
315
|
+
signal_rows, pgls_rows = [], []
|
|
316
|
+
for name, S in sets.items():
|
|
317
|
+
tipnames = {t.name for t in tips_of(root)}
|
|
318
|
+
common = [s for s in S["species"] if s in tipnames]
|
|
319
|
+
if len(common) < 4:
|
|
320
|
+
report.append(f"{name}: only {len(common)} species match the tree - skipped"); continue
|
|
321
|
+
tree = prune(parse_newick(Path(a.tree).read_text()), set(common))
|
|
322
|
+
tips = tips_of(tree)
|
|
323
|
+
idx = [S["species"].index(t.name) for t in tips]
|
|
324
|
+
how = scales.get(name, "none")
|
|
325
|
+
Y = scale(S["raw"][idx], how)
|
|
326
|
+
C = vcv(tips, tips)
|
|
327
|
+
S.update(tree=tree, tips=tips, Y=Y, C=C, idx=idx)
|
|
328
|
+
dropped = sorted(set(S["species"]) - set(common))
|
|
329
|
+
report += [f"## {name}", f"{len(tips)} species, {Y.shape[1]} variables (scale: {how})"
|
|
330
|
+
+ (f"; not in the tree: {', '.join(dropped)}" if dropped else ""), ""]
|
|
331
|
+
if "signal" in a.analyses:
|
|
332
|
+
r = phylo_signal(Y, C, a.iterations, rng)
|
|
333
|
+
signal_rows.append({"trait_set": name, "n_species": len(tips), "n_variables": Y.shape[1], "scale": how, **r})
|
|
334
|
+
report.append(f"Phylogenetic signal: Kmult = {r['Kmult']:.4f}, P = {r['P']:.4f}, Z = {r['Z']:.2f}")
|
|
335
|
+
if "morphospace" in a.analyses:
|
|
336
|
+
nodes = internal_nodes(tree)
|
|
337
|
+
anc = ancestral_states(Y, tips, nodes)
|
|
338
|
+
mu = Y.mean(0)
|
|
339
|
+
U, sv, Vt = np.linalg.svd(Y - mu, full_matrices=False)
|
|
340
|
+
ev = sv ** 2 / (sv ** 2).sum()
|
|
341
|
+
pc_t, pc_n = (Y - mu) @ Vt.T, (anc - mu) @ Vt.T
|
|
342
|
+
np.savetxt(out / f"{name}_morphospace_tips.csv", pc_t[:, :min(5, pc_t.shape[1])], delimiter=",",
|
|
343
|
+
header=",".join(f"PC{i + 1}" for i in range(min(5, pc_t.shape[1]))), comments="")
|
|
344
|
+
plot_morphospace(pc_t, pc_n, tips, nodes, ev, out / f"{name}_phylomorphospace.png",
|
|
345
|
+
f"{name}: phylomorphospace ({len(tips)} species)")
|
|
346
|
+
report.append(f"Phylomorphospace: {name}_phylomorphospace.png (PC1 {100 * ev[0]:.1f}%, PC2 {100 * ev[1]:.1f}%)")
|
|
347
|
+
report.append("")
|
|
348
|
+
|
|
349
|
+
if "pgls" in a.analyses:
|
|
350
|
+
for f in a.pgls:
|
|
351
|
+
ys, pred = [x.strip() for x in f.split("~")]
|
|
352
|
+
S = sets.get(ys)
|
|
353
|
+
if S is None or "Y" not in S:
|
|
354
|
+
report.append(f"PGLS {f}: trait set {ys} not analysed - skipped"); continue
|
|
355
|
+
if pred == "logCS":
|
|
356
|
+
if S["size"] is None:
|
|
357
|
+
report.append(f"PGLS {f}: no size column in {ys} (--size_col) - skipped"); continue
|
|
358
|
+
xv = np.log(np.array([S["size"][S["species"].index(t.name)] for t in S["tips"]]))
|
|
359
|
+
else:
|
|
360
|
+
s2, cc = pred.split(":", 1); src = sets.get(s2)
|
|
361
|
+
if src is None or cc not in src["cols"]:
|
|
362
|
+
report.append(f"PGLS {f}: predictor column not found - skipped"); continue
|
|
363
|
+
xv = np.array([src["raw"][src["species"].index(t.name), src["cols"].index(cc)] for t in S["tips"]])
|
|
364
|
+
r = pgls(S["Y"], xv, S["C"], a.iterations, rng)
|
|
365
|
+
pgls_rows.append({"model": f, **r})
|
|
366
|
+
report.append(f"PGLS {f}: R2 = {r['Rsq']:.4f}, F = {r['F']:.3f}, P = {r['P']:.4f}")
|
|
367
|
+
|
|
368
|
+
for fn, rows in (("phylogenetic_signal.csv", signal_rows), ("pgls.csv", pgls_rows)):
|
|
369
|
+
if rows:
|
|
370
|
+
with open(out / fn, "w", newline="") as fh:
|
|
371
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
|
|
372
|
+
(out / "phylo_report.md").write_text("\n".join(report) + "\n")
|
|
373
|
+
print("\n".join(report))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
main()
|