Automated Phenotype Concept Recognition by Prompting, packaged as a Python library.
AutoPCR finds phenotype mentions in free text (clinical notes, case reports, abstracts) and links each one to a concept in the Human Phenotype Ontology (HPO). It needs no ontology-specific training: you point it at an .obo file, it builds a dense index once, and a large language model makes the final call on ambiguous matches.
This package wraps the method published by Tao et al. in Bioinformatics (10.1093/bioinformatics/btag304). The research code and experiment scripts behind that paper are at yctao7/AutoPCR. This repository turns them into an installable package with a small API you can import.
from pathlib import Path
from autopcr.autopcr import AutoPCR
from autopcr.config import AutoPCRConfig
pcr = AutoPCR(AutoPCRConfig(working_dir=Path("autopcr_wd")))
pcr.initialize(Path("hp.obo"))
pcr.extract("The patient showed poor eye contact and reported ongoing hallucinations.")
# [{'start': 21, 'end': 37, 'text': 'poor eye contact', 'hpo_id': 'HP:0000817', 'score': ...},
# {'start': 61, 'end': 75, 'text': 'hallucinations', 'hpo_id': 'HP:0000738', 'score': ...}](The output above is illustrative. Exact IDs and scores depend on your HPO release and LLM.)
AutoPCR runs every input text through three stages:
text ──► 1. Entity extraction ──► 2. Candidate retrieval ──► 3. Entity linking ──► hits
BioNER + syntax SapBERT + FAISS LLM picks among
(spans that could be (top-k nearest HPO the top-k candidates
phenotypes) terms per span) (only when unsure)
- Entity extraction. A clinical NER model (Stanza, i2b2/MIMIC) proposes spans. Constituency parsing (spaCy + benepar) adds noun phrases and splits coordinated phrases. For example, "short and broad fingers" becomes "short fingers" and "broad fingers". Abbreviation resolution is optional.
- Candidate concept retrieval. Each span is embedded with SapBERT and searched against a FAISS index of all HPO labels and synonyms.
- Similarity ≥
accept_threshold(τ₁, default 0.95): the top hit is accepted directly. - Similarity ≥
candidate_threshold(τ₂, default 0.85): the topnum_candidates(k = 5) concepts go to stage 3. - Anything lower is dropped.
- Similarity ≥
- Entity linking. An LLM receives the sentence, the span and the k candidates, and chooses one of them or none. Spans accepted in stage 2 never reach the LLM, so only the hard cases cost LLM calls.
In the paper, this setup had the best average mention-level F1 across four benchmarks (BIOC-GS, GSC-2024, ID-68 and NCBI). It also transferred to a new ontology (MEDIC) without retraining. See the paper for full results.
Requires Python ≥ 3.11.
pip install git+https://github.com/SmartMonkey-git/AutoPCR.gitTo add optional abbreviation detection through scispaCy:
pip install "autopcr[abbreviations] @ git+https://github.com/SmartMonkey-git/AutoPCR.git"
pip install https://s3-us-west-2.amazonaws.com/ai2-s2-scispacy/releases/v0.5.4/en_core_sci_scibert-0.5.4.tar.gzThe first import or run fetches these, so expect a few GB of downloads and a slow first start:
| Model | Used for | Source |
|---|---|---|
en_core_web_trf |
Parsing and tokenisation | spaCy (downloaded automatically) |
benepar_en3_large |
Constituency parsing | benepar (downloaded automatically) |
Stanza mimic + i2b2 NER |
Clinical entity extraction | Stanza |
cambridgeltl/SapBERT-from-PubMedBERT-fulltext |
Span and concept embeddings | Hugging Face |
A CUDA GPU is used automatically if one is available. Otherwise everything runs on CPU.
Download an HPO release in OBO format:
curl -LO https://github.com/obophenotype/human-phenotype-ontology/releases/latest/download/hp.oboTo skip the LLM and use retrieval only, set linking_model="none".
from pathlib import Path
from autopcr.autopcr import AutoPCR
from autopcr.config import AutoPCRConfig
config = AutoPCRConfig(working_dir=Path("autopcr_wd"))
pcr = AutoPCR(config)
pcr.initialize(Path("hp.obo")) # builds the dictionary and SapBERT indexinitialize() parses the ontology below root_node, which defaults to HP:0000118 (Phenotypic abnormality). It writes the lookup tables and FAISS indices into working_dir. Later calls reuse those files. Pass overwrite=True to rebuild them, for example after you update hp.obo.
hits = pcr.extract(
"During the interview the patient displayed marked autistic behaviors, "
"including poor eye contact and rigid, repetitive speech."
)
for h in hits:
print(f"{h['hpo_id']}\t{h['text']!r}\t[{h['start']}:{h['end']}]\t{h['score']:.2f}")Each result is a Hit (a TypedDict):
| Field | Type | Meaning |
|---|---|---|
start, end |
int |
Character offsets into the input text |
text |
str |
The matched span (text[start:end]) |
hpo_id |
str |
The linked concept ID, e.g. HP:0000817 |
score |
float |
Confidence of the link |
Calling extract() before initialize() raises autopcr.autopcr.UninitializedError.
All options live on the frozen dataclass AutoPCRConfig:
| Option | Default | Description |
|---|---|---|
working_dir |
required | Where ontology tables and indices are stored |
span_extractor |
"neural++" |
"rule": PhenoTagger-style dictionary · "neural": BioNER · "neural+": + benepar noun phrases · "neural++": + coordination splitting |
detect_abbreviations |
False |
Resolve abbreviations with scispaCy (needs the abbreviations extra) |
retrieval_model |
cambridgeltl/SapBERT-from-PubMedBERT-fulltext |
Embedding model for candidate retrieval |
accept_threshold |
0.95 |
τ₁: link directly at or above this similarity |
candidate_threshold |
0.85 |
τ₂: send to the LLM at or above this similarity |
num_candidates |
5 |
k: candidates shown to the LLM |
linking_model |
ttempvnn/Huihui-Qwen3-Next-80B-A3B-Instruct-abliterated-Q4-K-M |
LLM model name on the chosen provider, or "none" |
provider |
PROVIDERS["ollama"] |
LLM backend (see Setting up an LLM) |
use_finetuning_prompt |
False |
Use the short prompt for the fine-tuned linker |
seed |
42 |
Sampling seed passed to the LLM (temperature is fixed at 0) |
use_cache |
False |
Cache LLM decisions per span within a session |
keep_longest_span_only |
False |
Drop nested matches and keep only the longest |
device |
None |
Torch device ("cuda", "cpu", "mps" …); auto-detects if unset |
0 ≤ candidate_threshold ≤ accept_threshold ≤ 1 is checked on construction.
PyTorch, FAISS and scikit-learn wheels on macOS each bundle their own OpenMP runtime, which can cause hangs or segfaults. On import, AutoPCR sets OMP_NUM_THREADS=1 and TOKENIZERS_PARALLELISM=false and limits OpenMP to one thread. For this to work, import autopcr before torch, faiss or sklearn.
If you use AutoPCR in your work, please cite the original paper:
Tao Y, Huang Y, Wang Y, Luo X, Liu J. AutoPCR: Automated Phenotype Concept Recognition by Prompting. Bioinformatics, 2026. doi:10.1093/bioinformatics/btag304
@article{tao2026autopcr,
title = {AutoPCR: Automated Phenotype Concept Recognition by Prompting},
author = {Tao, Yicheng and Huang, Yuanhao and Wang, Yiqun and Luo, Xin and Liu, Jie},
journal = {Bioinformatics},
year = {2026},
doi = {10.1093/bioinformatics/btag304}
}Preprint: arXiv:2507.19315
The algorithm and most of the underlying code are by Yicheng Tao and colleagues at the University of Michigan (yctao7/AutoPCR). Their code in turn adapts parts of PhenoTagger, PhenoBERT and REAL. Thanks to all of them for open-sourcing their work.