Understanding PRISM

Phenotype Re-ranking via Interpretable Semantic Matching — architecture, flow, and reading guide

What Is PRISM?

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.

Inputs and Outputs

Inputs (per patient case)

Output: RankedReport

The End-to-End Pipeline

phenopacket.json ──┐ ├──► [Ingest] ──► [Partition] ──► [Age Gate] ──► exomiser.parquet ──┘ │ ▼ ┌──────────────── [Rescore & Rerank] ◄─────────┘ │ ▼ [Mechanism] ──► [Disambiguate] ──► [Callable call] ──► RankedReport
A note on "C1", "C2" etc. — you will see these labels in the source code comments and file docstrings. They are just internal shorthand: C stands for Component, and the numbers are the order in which the components were designed, not the order they run. The actual execution order is Partition → Age Gate → Rescore → Mechanism → Disambiguate (i.e. C1 → C3 → C2 → C5 → C4). The descriptive names are what matter.

Step 0 — Ingest

Step 1 — Knowledge Loading

Partition C1

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

Age Gate C3

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

Rescore & Rerank C2

Computes fit_score = tanh(weighted sum) from FitEvidence. Re-ranks candidates using conservative (default) or aggressive mode. Pure arithmetic — no LLM.
components/rescore.py

Mechanism Congruence C5

Checks whether variant evidence (ACMG class, MOI, allele count) supports the disease. Returns congruent / incongruent / uncertain. Incongruent blocks a callable diagnosis.
components/mechanism.py

Disambiguate C4

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

Partition — What Happens to Each Patient Term

BucketMeaning
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

Rescore — How the Score Is Built Up

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 termEffectMultiplier
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:

Callable Diagnosis Rules

If Disambiguate ran: the best-supported candidate must have fit_score > 0.5 and mechanism ≠ incongruent.
If Disambiguate did not run (no overlapping candidates): the top candidate must have fit_score > 0.6, lead the runner-up by > 0.15, and mechanism ≠ incongruent.
Empty callable_diagnoses is valid — and preferred over a low-confidence call. A missed diagnosis is less harmful than a confident wrong one.

The Fit Score — In Depth

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.

The Two Scaling Factors

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.

1. How diagnostically specific is this HPO term?

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.

specificity score = −log₂( diseases annotated with this term ÷ total diseases )

(Technically called Information Content or IC — you'll see that name in the code.)

2. How common is this feature in the disease?

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 classHow often seen in this diseaseMultiplierWhy
Obligate100% of patients1.0Always present — a match is expected, absence is a red flag
VeryFrequent80–99%0.9Almost always present
Frequent30–79%0.5Present in many but not all patients
Occasional5–29%0.2Seen in a minority — matching weakly supports, absence barely penalises
VeryRare1–4%0.05Rarely 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.

Every Contribution to the Score, Explained

+ Matched features — full credit (×1.0)

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".

+ Partial matches — half credit (×0.5)

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.

− Expected-absent features — half penalty (×0.5)

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.

− Unexplained patient terms — light penalty (×0.3)

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.

− Contradictions — fixed penalty (×1.0, no scaling)

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.

Why the running total is compressed to a score between −1 and +1

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.

Worked Example

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 happenedTermHow common in diseaseSpecificityCalculation
matched CraniosynostosisObligate → ×1.05.2 full credit × 1.0 × 5.2 = +5.20
expected absent Broad thumbFrequent → ×0.58.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
Running total = 5.20 − 2.03 − 1.29 − 1.80 = +0.08 Final score = compress(+0.08) = +0.08 ← barely positive

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.

Conservative vs Aggressive Re-ranking

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.

Conservative mode (default) — PRISM only intervenes when Exomiser is genuinely uncertain. Two conditions must both hold:
  • The two candidates' Exomiser combined_scores differ by ≤ 0.05 (a near-tie)
  • PRISM's fit_scores differ by > 0.1 (PRISM clearly prefers one)
If Exomiser is confident (large score gap), its ranking stands regardless of what PRISM's fit_score says.
Aggressive mode — blends both scores for every candidate: 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's Role

The LLM is used in two places only, and never directly emits a number that affects scoring. All arithmetic is deterministic in Rescore.

Partition — Partial match resolution
When the patient has a broader HPO term than the disease expects (e.g. patient has "Seizure" but disease expects "Focal seizure"), the LLM labels the pair as matched / partial / absent_expected / unexplained / contradiction. Only matched or partial earns a positive contribution in Rescore.
Disambiguate — Silent discriminator inference
When two similar diseases both lack a patient signal on a discriminating feature (the patient is silent — neither observed nor excluded), the LLM is asked whether that feature is likely present or absent. 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.

Key Data Models

ModelFilePurpose
HpoTermmodels/phenopacket.pyA single HPO term (observed or excluded)
PatientPhenotypemodels/phenopacket.pyFull patient description — the main input to the pipeline
ExomiserCandidatemodels/exomiser.pyOne gene/disease candidate from Exomiser with scores and variants
Variantmodels/exomiser.pyA contributing genetic variant for a candidate
DiseaseFeaturemodels/candidate.pyOne annotated HPO feature from a disease profile, with frequency and IC
FeatureMatchmodels/candidate.pyA patient term paired with a disease feature (exact / subsumed / partial)
FitEvidencemodels/candidate.pyAll match buckets for one candidate — produced by Partition, scored by Rescore
DiseaseProfileknowledge/hpoa/tool.pyCurated phenotype profile for a disease (features + excluded features)
MechanismVerdictmodels/report.pyMechanism Congruence output: congruent / incongruent / uncertain
ReRankedCandidatemodels/report.pyA candidate with new rank, fit score, rationale, and mechanism verdict
DisambiguationReportmodels/report.pyDisambiguate output: which overlapping disease is best supported and why
RankedReportmodels/report.pyThe final output for one patient case — the top-level pipeline return value

Files Reading Order

Read the files in this order to build up understanding from the ground up.

  1. models/phenopacket.py Start here. The shape of the patient input: HPO terms (observed vs explicitly excluded), sex, and age. The absence/excluded distinction is critical — a term not mentioned is "not assessed", not "absent".
  2. models/exomiser.py The shape of what Exomiser produces: one candidate per gene/disease/MOI combination, with variant evidence and combined scores.
  3. models/candidate.py The core intermediate data structure. FitEvidence is what Partition produces and what Rescore scores. DiseaseFeature is one annotated HPO feature from a disease profile.
  4. models/report.py The output structures: ReRankedCandidate, RankedReport, DisambiguationReport, MechanismVerdict.
  5. ontology/frequency.py How disease feature frequency classes (Obligate, Frequent, etc.) map to numeric weights. Obligate features matter much more than occasional ones in Rescore.
  6. ontology/hpo.py The HPO ontology graph. Supports ancestor/descendant lookups (used in Partition for subsumed/partial matching) and computes Resnik information content per term.
  7. ingest/phenopacket_loader.py Parses a GA4GH phenopacket JSON file into a PatientPhenotype. Also handles Family phenopackets (extracts the proband).
  8. ingest/exomiser_loader.py Reads the Exomiser parquet. Aggregates gene-level scores and contributing variants, then picks the right disease per candidate based on mode-of-inheritance matching.
  9. knowledge/hpoa/tool.py Loads the HPO Annotation database (phenotype.hpoa) into memory. For any disease ID (OMIM or ORPHA), returns annotated HPO features with frequency and IC.
  10. knowledge/orphanet/tool.py Supplements HPOA with Orphanet disease profiles from XML. HPOA is primary; Orphanet fills gaps. Especially useful for ORPHA-prefixed disease IDs.
  11. reasoning/llm.py The LLM abstraction layer. Defines LLMClient (abstract), MockLLMClient (deterministic, for testing), and OllamaLLMClient (real model via local Ollama). All LLM calls go through resolve_match / synthesise_narrative.
  12. components/partition.py Partition (internal code: C1). Reads patient terms and a disease profile, produces FitEvidence. Matching priority: exact → subsumed → LLM partial. Only the broader-term case calls the LLM.
  13. components/age_gate.py Age Gate (internal code: C3). Simple post-processing: moves expected_absent features to age_excused when the disease onset is later than the patient's age.
  14. components/rescore.py Rescore (internal code: C2). The fit score formula and re-ranking logic. The mathematical core of PRISM. No LLM — pure arithmetic on FitEvidence.
  15. components/mechanism.py Mechanism Congruence (internal code: C5). Checks variant pathogenicity class and mode-of-inheritance consistency. Fully deterministic, no LLM.
  16. components/disambiguate.py Disambiguate (internal code: C4). The most complex component. Finds overlapping candidate pairs, computes discriminating features, scores them against the patient, and uses the LLM for features the patient is silent on.
  17. pipeline.py The top-level orchestrator for a single case. Loads knowledge bases, runs Partition → Age Gate → Rescore → Mechanism → Disambiguate, decides on callable diagnoses, generates narratives. Best file to read to see how all components connect.
  18. reasoning/agent.py Stateful wrapper around 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.
  19. cli.py The command-line interface. Three subcommands: run (single case), batch (directory, with --skip-existing for retrying failures), and pheval-gene-result (export to PhEval format).
  20. pheval_export.py Converts PRISM JSON report files into PhEval gene result parquet files for standardised benchmarking against other tools.