1
Ranking
Kemal Yaylali edited this page 2026-09-12 13:44:49 +01:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Ranking

api/app/services/triage.py. The rank is a weighted mean of four components a reviewer can audit, not a black box.

Component Weight Scores when
phenotype fit 0.35 always
rarity in gnomAD 0.25 the run looked up frequencies
consequence severity 0.20 always
model P(pathogenic) 0.20 the run has CADD or AlphaMissense

ClinVar is deliberately not an input. It is displayed beside the result as independent confirmation, so nothing ranks highly merely because ClinVar already called it pathogenic.

Rarity and consequence filter — the usual first pass. Phenotype only ranks, because a real diagnosis can sit in a gene nobody has annotated yet, and filtering on phenotype would hide exactly that case.

Evidence that was never looked up abstains

This is the most important rule in the file, and it replaced a real bug.

Run without a VEP cache there are no allele frequencies. rarity_score(None) read that as absent from gnomAD, therefore maximally rare and gave every variant a free 0.25. The model separately returned the same 0.887 for every variant, from features it had never been given. Two of four components were fiction and the total looked fully informed.

Now jobs.has_frequencies and jobs.has_effect_scores record what the run actually produced, a component with no evidence returns None rather than a number, and combine() renormalises the weights over whatever is left. The score stays on 01 and means the same thing; the UI prints "not looked up" instead of drawing a bar.

def combine(components):
    weight = sum(WEIGHTS[n] for n, v in components.items() if v is not None)
    return sum(WEIGHTS[n] * v for n, v in components.items() if v is not None) / weight

If you add a component, decide what makes it abstain before you decide its weight.

Phenotype fit

Information-content-weighted recall: the share of the total specificity of the patient's terms that this gene accounts for.

score = Σ IC(matched terms) / Σ IC(all the patient's terms)

IC(term) = -ln(fraction of annotated genes carrying it), computed at load time. It matters: "Dilated left subclavian artery" scores 7.88, "Global developmental delay" 0.93 — an 8.5× gap that plain term counting threw away.

A term HPO has never annotated to any gene gets DEFAULT_IC, keeping it in the denominator so it depresses every gene equally. That is the neutral choice, not an oversight.

The model

Trained on ClinVar, held out by gene rather than by variant. Allele frequency is deliberately not a feature — it double-counted against rarity_score and was circular, since ACMG assigns ClinVar's benign labels using frequency. Removing it dropped missense AUROC from 0.872 to 0.500, exactly random, which is why the model now abstains without CADD or AlphaMissense. Full working in Benchmarks.

Worked example

The published LoeysDietz case, run in VEP database mode:

score phenotype (0.64) rarity consequence (0.36) model
TGFBR2 3:30672252 missense 0.855 1.00 (30/30) not looked up 0.60 not looked up
OSBPL10 3:31748090 missense 0.218 0.00 not looked up 0.60 not looked up

Both are rare missense variants, identical on every piece of evidence this run holds except one. The phenotype is what separates a published diagnosis from an incidental variant.