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