Phenotype Re-ranking via Interpretable Semantic Matching — architecture, flow, and reading guide
PRISM is a post-processing layer that sits on top of Exomiser. Exomiser is a clinical genomics tool that takes a patient's HPO phenotype terms and genetic variants and produces a ranked list of candidate gene/disease pairs. PRISM takes that ranked list and re-ranks it using richer phenotype reasoning — matching the patient's symptoms against curated disease profiles, weighing how specific and informative each match is, and optionally asking an LLM to resolve ambiguous cases.
RankedReportreranked — all candidates re-ordered with PRISM fit scorescallable_diagnoses — ≤2 high-confidence diagnosis calls (empty is valid)disambiguation — if two diseases looked similar, which one fits better and whynarratives — LLM-generated plain-English summaries for the top candidatesingest/phenopacket_loader.py parses the phenopacket JSON into a PatientPhenotype.ingest/exomiser_loader.py reads the Exomiser parquet into a list of ExomiserCandidate.hp.obo) is loaded into HPOGraph, which supports
ancestor/descendant lookups and computes information content (IC) per term
(how rare/specific a term is).HpoaRetriever loads phenotype.hpoa — the HPO Annotation database
linking diseases to their HPO features with frequency information.OrphanetRetriever (optional) loads Orphanet XML for supplementary phenotype
profiles, especially for ORPHA-prefixed disease IDs.Compares each patient HPO term against the disease profile. Sorts terms into matched /
partial / unexplained / expected_absent / contradictions. LLM called for broader-term cases.
components/partition.py
Excuses expected_absent features when the disease onset is later than the patient's
current age — removes the Rescore penalty for developmentally premature absences.
components/age_gate.py
Computes fit_score = tanh(weighted sum) from FitEvidence. Re-ranks candidates
using conservative (default) or aggressive mode. Pure arithmetic — no LLM.
components/rescore.py
Checks whether variant evidence (ACMG class, MOI, allele count) supports the disease.
Returns congruent / incongruent / uncertain. Incongruent blocks a callable diagnosis.
components/mechanism.py
Triggered when ≥2 top candidates have overlapping profiles (Jaccard ≥ 0.3). Finds
discriminating features and uses the LLM for ones the patient is silent on.
components/disambiguate.py
| Bucket | Meaning |
|---|---|
| matched | Exact HPO ID match, or patient has a more specific term (descendant of disease feature) |
| partial | Patient has a broader term than the disease expects — LLM decides if it counts |
| unexplained | Patient term has no ontological relationship to any disease feature — penalised in Rescore |
| expected_absent | Disease feature the patient explicitly does NOT have — penalised in Rescore, unless excused by Age Gate |
| contradictions | Disease says a feature should be absent — patient has it — strong penalty in Rescore |
For each candidate disease, PRISM adds and subtracts points based on how well the patient's symptoms match. Each contribution is scaled by two things multiplied together: how common this feature is in the disease (Obligate = 1.0 → VeryRare = 0.05) and how diagnostically specific the HPO term is (rare terms that appear in very few diseases score higher than generic terms that appear in thousands).
| What happened to the term | Effect | Multiplier |
|---|---|---|
| Matched — patient has this disease feature | Add | full credit (×1.0) × how common × how specific |
| Partial — LLM said the patient's broader term counts | Add | half credit (×0.5) × how common × how specific |
| Expected absent — patient explicitly lacks a disease feature | Subtract | half penalty (×0.5) × how common × how specific |
| Unexplained — patient has a symptom the disease doesn't explain | Subtract | light penalty (×0.3) × how specific the patient's term is |
| Contradiction — disease says absent, patient has it | Subtract | fixed penalty (×1.0) — no scaling, always the same |
The running total is then squeezed into a score between −1 and +1 using a compression function (tanh). Above zero = net evidence for the disease; below zero = net evidence against; zero = balanced.
Re-ranking modes:
fit_score > 0.5 and mechanism ≠ incongruent.
fit_score > 0.6, lead the runner-up by > 0.15,
and mechanism ≠ incongruent.
This section walks through every component of the formula, why it was designed that way, and how the numbers interact. The goal is that after reading this you could re-implement it from scratch.
Every contribution to the score is scaled by two numbers multiplied together. Getting these two ideas clear first makes the rest of the score obvious.
Some HPO terms appear in thousands of diseases — "Fatigue", "Abnormality of the nervous system". Matching one of these tells you almost nothing: almost every disease causes them. Others appear in only 3 or 4 diseases — a very precise dysmorphology, or a distinctive pattern of organ involvement. Matching one of those is extremely informative.
PRISM computes a specificity score for every HPO term based on how many diseases it is annotated to across the full database (HPOA). The fewer diseases a term is linked to, the higher its specificity score (roughly 0–10+). General terms near the top of the HPO tree pick up hundreds of diseases and have low specificity; precise terms deep in the tree that only a handful of diseases share have high specificity.
(Technically called Information Content or IC — you'll see that name in the code.)
The specificity score tells you how rare a term is across all diseases globally. The frequency multiplier tells you how expected the feature is for this specific disease: is it present in every patient, or only occasionally?
| Frequency class | How often seen in this disease | Multiplier | Why |
|---|---|---|---|
| Obligate | 100% of patients | 1.0 | Always present — a match is expected, absence is a red flag |
| VeryFrequent | 80–99% | 0.9 | Almost always present |
| Frequent | 30–79% | 0.5 | Present in many but not all patients |
| Occasional | 5–29% | 0.2 | Seen in a minority — matching weakly supports, absence barely penalises |
| VeryRare | 1–4% | 0.05 | Rarely seen — almost irrelevant to the score either way |
Multiplying these two numbers together gives a single value per feature that captures both dimensions at once. An Obligate feature with high specificity is the strongest possible positive signal. A VeryRare feature with low specificity barely moves the score in either direction.
A patient term that exactly matches a disease feature (same HPO ID), or where the patient has a more specific version of what the disease lists — e.g. the disease expects "Seizure" and the patient has "Focal seizure". This is the primary positive signal. Full credit because we're confident the patient's symptom accounts for this disease feature.
The credit is then scaled by: how common this feature is in the disease (Obligate × 1.0, VeryRare × 0.05) and how diagnostically specific the HPO term is (rare terms score higher than general ones). So matching "Broad thumb" — a rare, specific finding seen in very few diseases — adds much more than matching "Fatigue".
The patient has a broader term than the disease expects, and the LLM judged it clinically related — e.g. disease expects "Focal seizure", patient has "Seizure". Half credit because the match is uncertain: the patient's seizures might or might not be the focal type the disease specifically involves. Scaled by the same frequency and specificity factors as a full match.
A disease feature the patient explicitly does not have — the phenopacket records it as excluded by the clinician. Important: a feature simply not mentioned is not penalised. Clinical records are incomplete; not mentioning something just means it wasn't assessed. Only a deliberate "this was checked and not found" counts.
The penalty scales with how expected the feature is:
The penalty is half the weight of a match (×0.5 vs ×1.0) because absence is generally weaker evidence than presence — clinicians are more likely to record what they observed than to systematically check and exclude every possible symptom. Features removed by the Age Gate (disease onset is still in the patient's future) are not penalised here.
The patient has a symptom the disease profile does not account for at all — no feature in the disease profile is even indirectly related to it. The penalty is proportional to how specific the patient's term is:
The lightest weight (×0.3) because annotation gaps are common — a feature might simply not yet be documented in HPOA for this disease even if it genuinely occurs.
The disease's curated profile explicitly records that a feature is not present in this disease (marked as excluded by clinicians in HPOA). The patient has that feature. This is qualitatively different from just being unexpected — it is an active, documented contradiction.
The penalty is fixed at −1.0 per contradiction regardless of how common or specific the feature is. The rationale: any genuine contradiction is a serious red flag, and we do not want a low-specificity term to let the contradiction be dismissed as unimportant.
The raw running total is unbounded — a case with many strong matches could reach +20, one with many penalties could reach −10. That raw number is hard to interpret and hard to set thresholds on. So PRISM passes it through a compression function (mathematically, tanh) that maps any number into the range −1 to +1:
A useful property of this compression is diminishing returns: adding a 15th matching feature moves the score much less than adding the first one. This is clinically sensible — once you have strong evidence, more of the same adds little.
One edge case: if every candidate has many unexplained symptoms driving all their raw totals very negative, the compression can push them all close to −1 and lose the differences between them. Aggressive reranking mode handles this by working with the uncompressed totals when blending Exomiser and PRISM scores.
Patient: age 5, observed Craniosynostosis (IC 5.2) and Hydrocephalus (IC 4.3) and Seizures (IC 6.0). Explicitly excluded Broad thumb (IC 8.1). Candidate disease: Pfeiffer syndrome, positive features Craniosynostosis (Obligate) and Broad thumb (Frequent).
| What happened | Term | How common in disease | Specificity | Calculation |
|---|---|---|---|---|
| matched | Craniosynostosis | Obligate → ×1.0 | 5.2 | full credit × 1.0 × 5.2 = +5.20 |
| expected absent | Broad thumb | Frequent → ×0.5 | 8.1 | half penalty × 0.5 × 8.1 = −2.03 |
| unexplained | Hydrocephalus | — (no disease feature) | 4.3 | light penalty × 4.3 = −1.29 |
| unexplained | Seizures | — (no disease feature) | 6.0 | light penalty × 6.0 = −1.80 |
A score of +0.08 is barely positive — near-neutral. The one strong match (Obligate craniosynostosis) is almost entirely cancelled out by the explicitly absent Frequent feature (broad thumb) and two unexplained symptoms. PRISM is essentially saying: "the evidence for and against Pfeiffer is roughly balanced — I would not call this confidently." This is the correct clinical intuition: a patient with craniosynostosis but no broad thumbs and unexplained seizures is not a typical Pfeiffer presentation.
If the patient also had broad thumb (turning the expected-absent penalty into another matched contribution instead), the running total jumps to roughly +5.2 + 4.05 − 1.29 − 1.80 = +6.16, compressed score ≈ 0.999 — an extremely strong call.
Exomiser's combined score already integrates phenotype and variant evidence through a model trained across many diseases. PRISM's fit_score adds a different signal — richer phenotype matching — but it should not simply override Exomiser.
key = 0.7 × exo_norm + 0.3 × fit_norm. PRISM influences every
ranking decision, not just tiebreaks. Requires more trust in the fit_score.
Uses normalised fit_raw (not tanh-saturated fit_score) to preserve
discrimination when all candidates have negative raw scores.
The LLM is used in two places only, and never directly emits a number that affects scoring. All arithmetic is deterministic in Rescore.
matched / partial / absent_expected /
unexplained / contradiction.
Only matched or partial earns a positive contribution in Rescore.
matched/partial → +1 to that disease's score.
absent_expected/contradiction → −1.
unexplained → neutral.
Mock vs real LLM: The default backend (--llm mock) is
deterministic and requires no model — it always labels non-exact pairs as
partial. Use --llm ollama --llm-model qwen2.5:7b (or any
pulled model) to run with a real local model via Ollama.
| Model | File | Purpose |
|---|---|---|
HpoTerm | models/phenopacket.py | A single HPO term (observed or excluded) |
PatientPhenotype | models/phenopacket.py | Full patient description — the main input to the pipeline |
ExomiserCandidate | models/exomiser.py | One gene/disease candidate from Exomiser with scores and variants |
Variant | models/exomiser.py | A contributing genetic variant for a candidate |
DiseaseFeature | models/candidate.py | One annotated HPO feature from a disease profile, with frequency and IC |
FeatureMatch | models/candidate.py | A patient term paired with a disease feature (exact / subsumed / partial) |
FitEvidence | models/candidate.py | All match buckets for one candidate — produced by Partition, scored by Rescore |
DiseaseProfile | knowledge/hpoa/tool.py | Curated phenotype profile for a disease (features + excluded features) |
MechanismVerdict | models/report.py | Mechanism Congruence output: congruent / incongruent / uncertain |
ReRankedCandidate | models/report.py | A candidate with new rank, fit score, rationale, and mechanism verdict |
DisambiguationReport | models/report.py | Disambiguate output: which overlapping disease is best supported and why |
RankedReport | models/report.py | The final output for one patient case — the top-level pipeline return value |
Read the files in this order to build up understanding from the ground up.
FitEvidence is what
Partition produces and what Rescore scores. DiseaseFeature is one annotated
HPO feature from a disease profile.
ReRankedCandidate, RankedReport,
DisambiguationReport, MechanismVerdict.
PatientPhenotype.
Also handles Family phenopackets (extracts the proband).
phenotype.hpoa) into memory.
For any disease ID (OMIM or ORPHA), returns annotated HPO features with frequency and IC.
LLMClient (abstract),
MockLLMClient (deterministic, for testing), and OllamaLLMClient
(real model via local Ollama). All LLM calls go through resolve_match /
synthesise_narrative.
FitEvidence. Matching priority: exact → subsumed → LLM partial.
Only the broader-term case calls the LLM.
expected_absent features to age_excused when the disease onset
is later than the patient's age.
FitEvidence.
pipeline.py. Loads knowledge bases
once and reuses them across many cases. Use for batch evaluation (e.g. PhEval) to avoid
reloading HPO and HPOA thousands of times.
run (single
case), batch (directory, with --skip-existing for retrying
failures), and pheval-gene-result (export to PhEval format).