File size: 5,990 Bytes
ae73c7f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | """
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]
# Walk consecutive (parent_rank, child_rank) pairs in this record's
# path; only add an edge where both ends are real, non-empty
# strings taken from this SAME record (never mixed across records).
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"
|