| """ |
| PBDB taxonomic hierarchy, built from the SAME verified endpoint as |
| data_pbdb.py (occs/list with show=classext), rather than the separate |
| `/data1.2/taxa/list` endpoint an earlier draft plan sketched — that |
| endpoint's field names (oid/nam/rnk/par) were explicitly marked "example; |
| finalize after --discover" in that sketch, i.e. unverified even by its |
| own author. Reusing occs/list is lower-risk: its schema (for this |
| purpose) is now backed by an actual live-data example found this session |
| (see data_pbdb.py's module docstring) rather than a guess. |
| |
| CONFIRMED this session (real example row from the paleobioDB R package |
| docs, not just prose): show=classext returns `phylum`, `class`, `order`, |
| `family`, `genus` as direct columns on each occurrence record. Standard |
| Linnaean rank order (phylum > class > order > family > genus) is used to |
| derive parent-child edges from each occurrence's classification path. |
| |
| This is real taxonomic signal (whatever PBDB's curators have assigned), |
| not synthesized — but it is also incomplete in a specific, honest way: |
| it only contains ranks that co-occur on real fossil occurrence records, |
| so higher clades with no occurrences at a given rank won't appear, and a |
| genus is linked to whichever family/order/class/phylum it was reported |
| under (which is occasionally inconsistent between occurrences for |
| taxonomically disputed groups — this module does not attempt to resolve |
| those disputes, it records them as-observed and lets a human decide, per |
| the project's no-fabrication rule). |
| """ |
| from __future__ import annotations |
| import hashlib |
| import json |
| from typing import Any, Dict, List, Optional, Sequence, Tuple |
|
|
| from torch.utils.data import Dataset |
|
|
| from .data_pbdb import fetch_occurrences |
| from .provenance import DataLoadError, SchemaValidationError |
|
|
| RANK_CHAIN = ("phylum", "class", "order", "family", "genus") |
|
|
| TAXONOMY_REQUIRED_FIELDS = ("occurrence_no",) |
|
|
|
|
| def discover_taxonomy_schema(base_name: str = "Canidae", limit: int = 5) -> Dict[str, Any]: |
| from .data_pbdb import discover_schema |
| result = discover_schema(base_name=base_name, limit=limit, show="classext") |
| observed = set(result["observed_fields"]) |
| result["rank_fields_present"] = [f for f in RANK_CHAIN if f in observed] |
| result["rank_fields_missing"] = [f for f in RANK_CHAIN if f not in observed] |
| return result |
|
|
|
|
| def build_edge_list( |
| records: List[Dict[str, Any]], |
| min_rank: Optional[str] = None, |
| ) -> Tuple[List[Tuple[str, str]], Dict[str, Dict[str, Any]]]: |
| chain = RANK_CHAIN |
| if min_rank is not None: |
| if min_rank not in RANK_CHAIN: |
| raise ValueError(f"min_rank must be one of {RANK_CHAIN}, got {min_rank!r}") |
| chain = RANK_CHAIN[: RANK_CHAIN.index(min_rank) + 1] |
|
|
| edges = set() |
| node_attrs: Dict[str, Dict[str, Any]] = {} |
| for r in records: |
| path = [r.get(rank) for rank in chain] |
| |
| |
| |
| for i in range(len(path) - 1): |
| parent, child = path[i], path[i + 1] |
| if not parent or not child: |
| continue |
| if not isinstance(parent, str) or not isinstance(child, str): |
| continue |
| edges.add((child, parent)) |
| node_attrs[parent] = {"rank": chain[i]} |
| node_attrs[child] = {"rank": chain[i + 1]} |
|
|
| edge_list = sorted(edges) |
| if len(edge_list) < 2: |
| raise SchemaValidationError( |
| f"only {len(edge_list)} usable (child, parent) edge(s) could be " |
| f"built from {len(records)} occurrence records — not enough " |
| f"classification coverage to build a meaningful tree. Try a " |
| f"broader base_name or check discover_taxonomy_schema() output.", |
| outcome_code="INSUFFICIENT_TAXONOMY_EDGES", |
| ) |
| return edge_list, node_attrs |
|
|
|
|
| def hash_edge_list(edges: List[Tuple[str, str]]) -> str: |
| blob = json.dumps(sorted(edges), sort_keys=True).encode() |
| return hashlib.sha256(blob).hexdigest() |
|
|
|
|
| class TaxonomyEdgeDataset(Dataset): |
|
|
| def __init__(self, edges: List[Tuple[str, str]], node_attrs: Dict[str, Dict[str, Any]]): |
| self.edges = edges |
| self.node_attrs = node_attrs |
| nodes = sorted(node_attrs.keys()) |
| self.node_to_idx = {n: i for i, n in enumerate(nodes)} |
| self.idx_to_node = {i: n for n, i in self.node_to_idx.items()} |
| self.num_nodes = len(nodes) |
| self.edge_idx = [(self.node_to_idx[c], self.node_to_idx[p]) for c, p in edges] |
|
|
| def __len__(self): |
| return len(self.edge_idx) |
|
|
| def __getitem__(self, idx): |
| child_idx, parent_idx = self.edge_idx[idx] |
| return {"child_idx": child_idx, "parent_idx": parent_idx} |
|
|
|
|
| def get_pbdb_taxonomy_dataset( |
| base_names: Sequence[str] = ("Dinosauria", "Mammalia"), |
| max_taxa_per_group: int = 8000, |
| min_rank: Optional[str] = None, |
| cache_dir: Optional[str] = "data/pbdb_taxonomy_cache", |
| ): |
| all_records = [] |
| for base_name in base_names: |
| recs = fetch_occurrences( |
| base_name=base_name, max_records=max_taxa_per_group, |
| show="classext", cache_dir=cache_dir, |
| required_fields=TAXONOMY_REQUIRED_FIELDS, |
| ) |
| all_records.extend(recs) |
|
|
| edges, node_attrs = build_edge_list(all_records, min_rank=min_rank) |
| dataset = TaxonomyEdgeDataset(edges, node_attrs) |
| edge_hash = hash_edge_list(edges) |
| meta = { |
| "num_nodes": dataset.num_nodes, |
| "num_edges": len(edges), |
| "base_names": list(base_names), |
| "edge_hash": edge_hash, |
| "node_to_idx": dataset.node_to_idx, |
| } |
| print(f"[data] REAL_PBDB_TAXONOMY: {dataset.num_nodes} nodes, " |
| f"{len(edges)} edges from {len(all_records)} occurrence records " |
| f"across {list(base_names)}") |
| return dataset, meta, "REAL_PBDB_TAXONOMY" |
|
|