fix(science): stop scoring evidence that was never looked up

A review of the ranking's arithmetic found four things wrong, all of which
made the score look better informed than it was. Measurements below are from
this repo, not estimates.

**Components now abstain instead of inventing a number.** A run without a VEP
cache returns no allele frequencies, and rarity_score(None) read that as
"absent from gnomAD, therefore maximally rare" and awarded every variant a
free 0.25. jobs.has_frequencies / has_effect_scores record what the run
actually produced, absent components are dropped from the weighted mean, and
the remaining weights are renormalised so the score keeps its meaning. The UI
shows "not looked up" rather than a bar, and the funnel stops calling a step
"rare" when nothing was filtered.

**Allele frequency is no longer a model feature.** It dominated: the same
missense variant scored 0.887 at AF 0 and 0.0003 at AF 0.01. That double-
counted, because the ranking already scores frequency explicitly, putting
~45% of every rank on one measurement; and it was circular, because ACMG
assigns ClinVar's benign labels using frequency (BA1/BS1). Retraining without
it moves missense AUROC from 0.872 to 0.500 — exactly random. The old figure
was allele frequency, not variant-effect knowledge. The model therefore
abstains unless CADD or AlphaMissense is present, since otherwise it only
restates the consequence class.

**Phenotype matching is weighted by information content** and HPO annotations
are propagated up the ontology. Counting terms alike let "global
developmental delay" (IC 0.93) count as much as "dilated left subclavian
artery" (IC 7.88).

**A real bug in the propagation, found by checking it.** The ancestor walk
read a pre-order DFS backwards, which on a DAG lets a term resolve before one
of its parents and inherit that parent alone instead of its lineage. It
dropped 399 terms out of the phenotype branch, Camptodactyly and Chiari
malformation among them. Now a true post-order, tested against a reference
transitive closure.

The ontology arithmetic moved to rarelens_ml.hpo so it is covered by tests,
and rarelens_ml.benchmark measures the whole thing: across 10,178 published
cases the causal gene ranks first 45.9-81.0% of the time against 5,269 genes,
versus 0.02% for chance. docs/data.md reports that with its contamination
(HPO's annotations come from these same case reports), and includes the
measurement showing information-content weighting earns its place while
propagation does not - kept anyway, for a reason the docs argue rather than
assume.
This commit is contained in:
Kemal Yaylali
2026-09-12 11:32:46 +01:00
parent 749b0f8214
commit e76ae847a1
37 changed files with 4324 additions and 195 deletions
+29 -11
View File
@@ -4,13 +4,14 @@ Everything for one job is loaded at once, which is fine for a gene panel or a ch
size of case this demo handles. A whole genome would need the narrowing pushed into SQL.
"""
import uuid
from dataclasses import dataclass
from collections.abc import Sequence
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Case, GenePhenotype, Job, JobStatus, Variant
from app.models import Case, GenePhenotype, HpoTerm, Job, JobStatus, Variant
from app.services import triage
EMPTY_FUNNEL = triage.Funnel(total=0, rare=0, candidates=0, phenotype_matched=0)
@@ -22,6 +23,7 @@ class CaseView:
job: Job | None
funnel: triage.Funnel
candidates: list[triage.Candidate]
evidence: triage.Evidence = field(default_factory=triage.Evidence)
@property
def labels(self) -> dict[str, str]:
@@ -43,19 +45,31 @@ async def latest_job(
return await session.scalar(stmt)
async def gene_terms_for(session: AsyncSession, genes: set[str]) -> dict[str, set[str]]:
"""gene symbol -> the HPO terms annotated to it."""
async def ontology_for(
session: AsyncSession, genes: set[str], case_terms: Sequence[str]
) -> triage.Ontology:
"""The HPO reference data this case needs: each gene's terms, and each case term's specificity.
Only the case's own terms need an information content: they are the denominator of the
phenotype score, and a gene's other annotations never enter it.
"""
if not genes:
return {}
return triage.Ontology()
rows = await session.execute(
select(GenePhenotype.gene_symbol, GenePhenotype.hpo_id).where(
GenePhenotype.gene_symbol.in_(genes)
)
)
index: dict[str, set[str]] = {}
gene_terms: dict[str, set[str]] = {}
for gene, hpo_id in rows:
index.setdefault(gene, set()).add(hpo_id)
return index
gene_terms.setdefault(gene, set()).add(hpo_id)
ic: dict[str, float] = {}
if case_terms:
weights = await session.execute(
select(HpoTerm.hpo_id, HpoTerm.ic).where(HpoTerm.hpo_id.in_(case_terms))
)
ic = {hpo_id: float(value) for hpo_id, value in weights}
return triage.Ontology(gene_terms=gene_terms, ic=ic)
async def build(session: AsyncSession, case: Case) -> CaseView:
@@ -70,10 +84,14 @@ async def build(session: AsyncSession, case: Case) -> CaseView:
)
).all()
case_terms = [p.hpo_id for p in case.phenotypes]
gene_terms = await gene_terms_for(session, {v.gene for v in variants if v.gene})
ontology = await ontology_for(session, {v.gene for v in variants if v.gene}, case_terms)
evidence = triage.Evidence(
frequencies=job.has_frequencies, effect_scores=job.has_effect_scores
)
return CaseView(
case=case,
job=job,
funnel=triage.funnel(variants, case_terms, gene_terms),
candidates=triage.rank(variants, case_terms, gene_terms),
funnel=triage.funnel(variants, case_terms, ontology, evidence),
candidates=triage.rank(variants, case_terms, ontology, evidence),
evidence=evidence,
)
+3 -3
View File
@@ -19,8 +19,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import Prediction, Variant
# Must match rarelens_ml.features.RAW_COLUMNS.
RAW_COLUMNS = ["impact", "consequence", "gnomad_af", "cadd_phred", "am_pathogenicity"]
# Must match rarelens_ml.features.RAW_COLUMNS. Allele frequency is not among them: the ranking
# scores frequency itself, and feeding it here too counted one measurement twice.
RAW_COLUMNS = ["impact", "consequence", "cadd_phred", "am_pathogenicity"]
CHUNK_SIZE = 5000
_models: dict[str, Any] = {} # model version -> loaded pyfunc
@@ -53,7 +54,6 @@ def raw_frame(variants: Sequence[Variant]) -> pd.DataFrame:
{
"impact": [v.impact for v in variants],
"consequence": [v.consequence for v in variants],
"gnomad_af": [v.gnomad_af if v.gnomad_af is not None else float("nan") for v in variants],
"cadd_phred": [v.annotations.get("CADD_PHRED") for v in variants],
"am_pathogenicity": [v.annotations.get("am_pathogenicity") for v in variants],
},
+106 -24
View File
@@ -1,14 +1,27 @@
"""Narrow a case's variants the way a clinical scientist does, and say why.
The rank is a weighted sum of four parts a reviewer can audit. ClinVar is deliberately not one of
them: it is shown beside the result as independent confirmation, so a variant never ranks highly
merely because ClinVar already called it pathogenic.
The rank is a weighted mean of four lines of evidence a reviewer can audit. ClinVar is deliberately
not one of them: it is shown beside the result as independent confirmation, so a variant never
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 it would hide exactly that.
Two rules keep the number honest:
**A line of evidence that was never looked up abstains.** It does not score zero, and it certainly
does not score full marks. Treating "no gnomAD frequency in the annotation run" as "absent from
gnomAD, therefore maximally rare" awarded every variant a free 0.25, which is a guess wearing the
costume of a measurement. `Evidence` says what the run actually produced, and the weights
renormalise over whatever is left, so the score stays on a 0-1 scale and means the same thing.
**Each line of evidence is counted once.** The model used to take allele frequency as a feature
while `rarity` scored the same frequency again, so roughly 45% of the rank was one measurement
double-counted. The model no longer sees frequency (see rarelens_ml.features); it earns its weight
only when it has something the other three do not already say, which means CADD or AlphaMissense.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from app.models import Variant
@@ -19,6 +32,49 @@ CANDIDATE_IMPACTS = frozenset({"HIGH", "MODERATE"})
IMPACT_SEVERITY = {"HIGH": 1.0, "MODERATE": 0.6, "LOW": 0.2, "MODIFIER": 0.0}
# Allele frequency ceiling -> score, rarest first.
RARITY_STEPS = ((0.0, 1.0), (0.0001, 0.8), (0.001, 0.5), (0.01, 0.2))
# A term HPO has never annotated to any gene cannot match anything, so its information content is
# unknown. Treating it as maximally specific keeps it in the denominator and depresses every gene
# equally, which is the neutral choice.
DEFAULT_IC = 10.0
@dataclass(frozen=True)
class Evidence:
"""What the annotation run actually produced, and therefore which components may score.
Decided once per job rather than per variant: components must be in play for every variant in
a case, or two variants would be scored against different denominators and their ranks would
not be comparable.
"""
frequencies: bool = False # did the run look up allele frequencies at all?
effect_scores: bool = False # CADD / AlphaMissense, the only features the model adds
@property
def missing(self) -> list[str]:
absent = []
if not self.frequencies:
absent.append("rarity")
if not self.effect_scores:
absent.append("model")
return absent
@dataclass(frozen=True)
class Ontology:
"""HPO reference data: what each gene is annotated with, and how specific each term is.
`gene_terms` is expected to be propagated up the ontology by scripts/load-hpo.py, so a case
term matches a gene annotated with any of its descendants. `ic` is information content,
-ln(fraction of genes carrying the term): "Bifid uvula" is worth many times "Abnormality of
the head", which nearly every gene in the corpus carries.
"""
gene_terms: Mapping[str, set[str]] = field(default_factory=dict)
ic: Mapping[str, float] = field(default_factory=dict)
def weight(self, term: str) -> float:
return self.ic.get(term, DEFAULT_IC)
@dataclass(frozen=True)
@@ -29,19 +85,21 @@ class Funnel:
rare: int
candidates: int
phenotype_matched: int
frequencies: bool = False # False means the "rare" step filtered nothing, because it could not
@dataclass(frozen=True)
class Candidate:
variant: Variant
score: float
components: dict[str, float]
components: dict[str, float | None] # None: this evidence was not available
matched_terms: list[str]
scored: bool
def rarity_score(af: float | None) -> float:
if af is None: # absent from gnomAD
"""Only meaningful when frequencies were annotated; None then means absent from gnomAD."""
if af is None:
return 1.0
for ceiling, score in RARITY_STEPS:
if af <= ceiling:
@@ -54,14 +112,22 @@ def consequence_score(impact: str | None) -> float:
def phenotype_score(
gene: str | None, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
gene: str | None, case_terms: Sequence[str], ontology: Ontology
) -> tuple[float, list[str]]:
"""What fraction of the patient's terms HPO associates with this gene, and which ones."""
"""How much of the patient's phenotype HPO associates with this gene, weighted by specificity.
Information-content-weighted recall: the share of the *total specificity* of the patient's
terms that this gene accounts for. Plain term counting let a common term like global
developmental delay count as much as a near-pathognomonic one.
"""
if not gene or not case_terms:
return 0.0, []
annotated = gene_terms.get(gene, set())
annotated = ontology.gene_terms.get(gene, set())
matched = [term for term in case_terms if term in annotated]
return len(matched) / len(case_terms), matched
total = sum(ontology.weight(term) for term in case_terms)
if total <= 0:
return 0.0, matched
return sum(ontology.weight(term) for term in matched) / total, matched
def is_rare(variant: Variant) -> bool:
@@ -72,35 +138,51 @@ def is_candidate(variant: Variant) -> bool:
return is_rare(variant) and variant.impact in CANDIDATE_IMPACTS
def funnel(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> Funnel:
def combine(components: Mapping[str, float | None]) -> float:
"""Weighted mean over the components that have evidence, renormalised to 0-1."""
weight = sum(WEIGHTS[name] for name, value in components.items() if value is not None)
if weight <= 0:
return 0.0
return sum(WEIGHTS[name] * value for name, value in components.items() if value is not None) / weight
def weights_in_use(evidence: Evidence) -> dict[str, float]:
"""The weights as actually applied, so the UI never shows a bar the score did not use."""
live = {name: w for name, w in WEIGHTS.items() if name not in evidence.missing}
total = sum(live.values())
return {name: round(w / total, 4) for name, w in live.items()} if total else {}
def funnel(variants: Sequence[Variant], case_terms: Sequence[str], ontology: Ontology,
evidence: Evidence) -> Funnel:
rare = [v for v in variants if is_rare(v)]
candidates = [v for v in rare if v.impact in CANDIDATE_IMPACTS]
matched = sum(1 for v in candidates if phenotype_score(v.gene, case_terms, gene_terms)[1])
return Funnel(len(variants), len(rare), len(candidates), matched)
matched = sum(1 for v in candidates if phenotype_score(v.gene, case_terms, ontology)[1])
return Funnel(len(variants), len(rare), len(candidates), matched, evidence.frequencies)
def evaluate(
variant: Variant, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
variant: Variant, case_terms: Sequence[str], ontology: Ontology, evidence: Evidence
) -> Candidate:
"""Score one variant, whether or not it survived the filters."""
phenotype, matched = phenotype_score(variant.gene, case_terms, gene_terms)
phenotype, matched = phenotype_score(variant.gene, case_terms, ontology)
prediction = variant.prediction
components = {
model: float | None = None
if evidence.effect_scores and prediction is not None:
model = float(prediction.score)
components: dict[str, float | None] = {
"phenotype": phenotype,
"rarity": rarity_score(variant.gnomad_af),
"rarity": rarity_score(variant.gnomad_af) if evidence.frequencies else None,
"consequence": consequence_score(variant.impact),
"model": float(prediction.score) if prediction is not None else 0.0,
"model": model,
}
score = sum(WEIGHTS[name] * value for name, value in components.items())
return Candidate(variant, score, components, matched, prediction is not None)
return Candidate(variant, combine(components), components, matched, prediction is not None)
def rank(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
variants: Sequence[Variant], case_terms: Sequence[str], ontology: Ontology, evidence: Evidence
) -> list[Candidate]:
candidates = [evaluate(v, case_terms, gene_terms) for v in variants if is_candidate(v)]
candidates = [evaluate(v, case_terms, ontology, evidence) for v in variants if is_candidate(v)]
# id breaks ties, so equal scores do not shuffle between requests.
candidates.sort(key=lambda c: (-c.score, c.variant.id))
return candidates