Download scripts/assemble_task1_predictions.py from SocrAIte-Mosaic/patientphex-task1: direct link, hf CLI and curl.
- Browser
- Download file 6.7 kB
-
https://huggingface.co/SocrAIte-Mosaic/patientphex-task1/resolve/main/scripts/assemble_task1_predictions.py
- Command line
-
hf download hf://SocrAIte-Mosaic/patientphex-task1/scripts/assemble_task1_predictions.py
-
curl -L -o assemble_task1_predictions.py https://huggingface.co/SocrAIte-Mosaic/patientphex-task1/resolve/main/scripts/assemble_task1_predictions.py
6.7 kB
| from __future__ import annotations | |
| import argparse | |
| from collections import defaultdict | |
| import glob | |
| import json | |
| from pathlib import Path | |
| import sys | |
| from typing import Any | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| import numpy as np | |
| from patientphex.ontology import HPOOntology | |
| from patientphex.sapbert import _unique_alias_records, encode_cls, rank_concepts | |
| def load_jsonl(path: Path) -> list[dict[str, Any]]: | |
| return [json.loads(line) for line in path.open(encoding="utf-8") if line.strip()] | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Ensemble verifier scores and normalize selected A-set spans.") | |
| parser.add_argument("--data", type=Path, required=True) | |
| parser.add_argument("--ontology", type=Path, required=True) | |
| parser.add_argument("--verifier-glob", required=True) | |
| parser.add_argument("--sapbert-model", type=Path, required=True) | |
| parser.add_argument("--output", type=Path, required=True) | |
| parser.add_argument("--threshold", type=float, default=0.66) | |
| parser.add_argument( | |
| "--target-fraction", type=float, | |
| help="If supplied, replace the raw threshold by the ensemble-score quantile that keeps this fraction.", | |
| ) | |
| parser.add_argument("--lexicon-threshold", type=float, default=0.0) | |
| parser.add_argument("--phenotagger-threshold", type=float, default=0.90) | |
| parser.add_argument("--device", default="cuda") | |
| args = parser.parse_args() | |
| paths = sorted(glob.glob(args.verifier_glob)) | |
| if not paths: | |
| raise FileNotFoundError(f"no verifier outputs matched {args.verifier_glob!r}") | |
| grouped: dict[tuple[str, int, int], list[dict[str, Any]]] = defaultdict(list) | |
| for path in paths: | |
| result = json.loads(Path(path).read_text(encoding="utf-8")) | |
| for record in result["predictions"]: | |
| grouped[(str(record["pmc_id"]), int(record["start"]), int(record["end"]))].append(record) | |
| expected_models = len(paths) | |
| incomplete = [key for key, records in grouped.items() if len(records) != expected_models] | |
| if incomplete: | |
| raise RuntimeError(f"{len(incomplete)} candidates are missing ensemble scores") | |
| ensemble_records: list[dict[str, Any]] = [] | |
| for records in grouped.values(): | |
| record = dict(records[0]) | |
| record["ensemble_score"] = float(np.mean([float(item["score"]) for item in records])) | |
| ensemble_records.append(record) | |
| threshold = args.threshold | |
| if args.target_fraction is not None: | |
| if not 0.0 < args.target_fraction <= 1.0: | |
| parser.error("--target-fraction must be in (0, 1]") | |
| threshold = float(np.quantile( | |
| [record["ensemble_score"] for record in ensemble_records], 1.0 - args.target_fraction | |
| )) | |
| selected = [record for record in ensemble_records if record["ensemble_score"] >= threshold] | |
| ontology = HPOOntology.from_obo(args.ontology) | |
| def valid_identifier(value: Any) -> str | None: | |
| if value is None: | |
| return None | |
| identifier = ontology.canonical_id(str(value)) | |
| return identifier if ontology.classify_id(identifier) == "valid_branch_hp" else None | |
| unresolved = sorted({ | |
| str(record["text"]).lower() for record in selected | |
| if not ( | |
| valid_identifier(record.get("lexicon_identifier")) is not None | |
| and float(record.get("lexicon_quality", 0.0)) >= args.lexicon_threshold | |
| ) and not ( | |
| valid_identifier(record.get("phenotagger_identifier")) is not None | |
| and float(record.get("phenotagger_score", 0.0)) >= args.phenotagger_threshold | |
| ) | |
| }) | |
| ranking_by_text: dict[str, list[str]] = {} | |
| if unresolved: | |
| from transformers import AutoModel, AutoTokenizer | |
| tokenizer = AutoTokenizer.from_pretrained(args.sapbert_model, local_files_only=True) | |
| model = AutoModel.from_pretrained(args.sapbert_model, local_files_only=True).to(args.device) | |
| alias_ids, alias_texts = _unique_alias_records(ontology) | |
| alias_embeddings = encode_cls(alias_texts, tokenizer, model, device=args.device, batch_size=256) | |
| query_embeddings = encode_cls(unresolved, tokenizer, model, device=args.device, batch_size=256) | |
| rankings = rank_concepts(query_embeddings, alias_embeddings, alias_ids, device=args.device, max_concepts=5) | |
| ranking_by_text = dict(zip(unresolved, rankings, strict=True)) | |
| entities_by_doc: dict[str, dict[tuple[int, int, str], dict[str, Any]]] = defaultdict(dict) | |
| source_counts: dict[str, int] = defaultdict(int) | |
| for record in selected: | |
| lexicon_identifier = valid_identifier(record.get("lexicon_identifier")) | |
| phenotagger_identifier = valid_identifier(record.get("phenotagger_identifier")) | |
| if ( | |
| lexicon_identifier is not None | |
| and float(record.get("lexicon_quality", 0.0)) >= args.lexicon_threshold | |
| ): | |
| identifier = lexicon_identifier | |
| source = "lexicon" | |
| elif ( | |
| phenotagger_identifier is not None | |
| and float(record.get("phenotagger_score", 0.0)) >= args.phenotagger_threshold | |
| ): | |
| identifier = phenotagger_identifier | |
| source = "phenotagger" | |
| else: | |
| identifier = ranking_by_text[str(record["text"]).lower()][0] | |
| source = "sapbert" | |
| source_counts[source] += 1 | |
| start, end = int(record["start"]), int(record["end"]) | |
| entities_by_doc[str(record["pmc_id"])][(start, end, identifier)] = { | |
| "identifier": identifier, "type": "Phenotype", "offset": start, | |
| "length": end - start, "text": record["text"], "note": None, | |
| } | |
| documents = load_jsonl(args.data) | |
| args.output.parent.mkdir(parents=True, exist_ok=True) | |
| with args.output.open("w", encoding="utf-8", newline="\n") as handle: | |
| for document in documents: | |
| entities = sorted( | |
| entities_by_doc[str(document["pmc_id"])].values(), | |
| key=lambda item: (int(item["offset"]), int(item["length"]), str(item["identifier"])), | |
| ) | |
| output = { | |
| "pmc_id": document["pmc_id"], "pmid": document.get("pmid"), | |
| "entities": entities, "association": [], | |
| } | |
| handle.write(json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n") | |
| print(json.dumps({ | |
| "models": expected_models, "threshold": threshold, "target_fraction": args.target_fraction, | |
| "selected_candidates": len(selected), "entities": sum(len(x) for x in entities_by_doc.values()), | |
| "normalization_sources": dict(source_counts), "output": str(args.output), | |
| })) | |
| if __name__ == "__main__": | |
| main() | |