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:
@@ -100,6 +100,8 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned
|
||||
.components { border: none; background: none; }
|
||||
.components th { font-weight: 400; color: var(--ink-soft); padding: 0.15rem 0.5rem 0.15rem 0; }
|
||||
.components td { padding: 0.15rem 0; font-size: 0.82rem; text-align: right; }
|
||||
/* A component with no evidence behind it is stated, not drawn as a number. */
|
||||
.components tr.abstained th, .components tr.abstained td { color: var(--ink-soft); font-style: italic; }
|
||||
.evidence { display: grid; grid-template-columns: 7rem 1fr; gap: 0.2rem 0.5rem; margin: 0; font-size: 0.88rem; }
|
||||
.evidence dt { color: var(--ink-soft); }
|
||||
.evidence dd { margin: 0; }
|
||||
|
||||
+13
-2
@@ -31,17 +31,28 @@ export type Decision = {
|
||||
export type Candidate = {
|
||||
variant: Variant;
|
||||
score: number;
|
||||
components: Record<string, number>;
|
||||
// null means that evidence was never looked up, so it did not enter the score. Not a zero.
|
||||
components: Record<string, number | null>;
|
||||
matched_terms: PhenotypeTerm[];
|
||||
scored: boolean;
|
||||
decision: Decision | null;
|
||||
};
|
||||
|
||||
export type VariantDetail = Candidate & { annotations: Record<string, string> };
|
||||
export type Funnel = { total: number; rare: number; candidates: number; phenotype_matched: number };
|
||||
export type Funnel = {
|
||||
total: number;
|
||||
rare: number;
|
||||
candidates: number;
|
||||
phenotype_matched: number;
|
||||
frequencies: boolean;
|
||||
};
|
||||
|
||||
/** What the annotation run produced, and so which components were allowed to score. */
|
||||
export type Evidence = { frequencies: boolean; effect_scores: boolean; missing: string[] };
|
||||
|
||||
export type CandidatePage = {
|
||||
funnel: Funnel;
|
||||
evidence: Evidence;
|
||||
weights: Record<string, number>;
|
||||
items: Candidate[];
|
||||
total: number;
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { barWidth, candidateQuery, evidenceChips, funnelSteps, needsScoring, plural, scoreBarPercent } from './candidates';
|
||||
import type { Candidate, Funnel } from './api';
|
||||
import {
|
||||
barWidth,
|
||||
candidateQuery,
|
||||
evidenceChips,
|
||||
funnelSteps,
|
||||
missingEvidenceNote,
|
||||
needsScoring,
|
||||
plural,
|
||||
scoreBarPercent
|
||||
} from './candidates';
|
||||
import type { Candidate, Evidence, Funnel } from './api';
|
||||
|
||||
// A run with a VEP cache and plugins; and one in VEP's database mode, which has neither.
|
||||
const FULL: Evidence = { frequencies: true, effect_scores: true, missing: [] };
|
||||
const DATABASE_ONLY: Evidence = {
|
||||
frequencies: false,
|
||||
effect_scores: false,
|
||||
missing: ['rarity', 'model']
|
||||
};
|
||||
|
||||
const candidate = (over: Partial<Candidate> = {}): Candidate => ({
|
||||
variant: {
|
||||
@@ -55,7 +72,9 @@ describe('evidenceChips', () => {
|
||||
|
||||
describe('funnelSteps', () => {
|
||||
it('describes each narrowing step in order', () => {
|
||||
const funnel: Funnel = { total: 1284, rare: 41, candidates: 12, phenotype_matched: 6 };
|
||||
const funnel: Funnel = {
|
||||
total: 1284, rare: 41, candidates: 12, phenotype_matched: 6, frequencies: true
|
||||
};
|
||||
expect(funnelSteps(funnel).map((s) => `${s.label} ${s.value}`)).toEqual([
|
||||
'variants called 1284',
|
||||
'rare (<0.1%) 41',
|
||||
@@ -63,6 +82,43 @@ describe('funnelSteps', () => {
|
||||
'in phenotype-matched genes 6'
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the step "rare" when no frequency was ever looked up', () => {
|
||||
const funnel: Funnel = {
|
||||
total: 21, rare: 21, candidates: 2, phenotype_matched: 1, frequencies: false
|
||||
};
|
||||
expect(funnelSteps(funnel)[1]).toEqual({ label: 'rarity not checked', value: 21 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing evidence', () => {
|
||||
it('says nothing when every line of evidence was looked up', () => {
|
||||
expect(missingEvidenceNote(FULL)).toBeNull();
|
||||
});
|
||||
|
||||
it('names what is missing and why, rather than showing a silent zero', () => {
|
||||
const note = missingEvidenceNote(DATABASE_ONLY) ?? '';
|
||||
expect(note).toContain('no allele frequencies');
|
||||
expect(note).toContain('no CADD or AlphaMissense');
|
||||
expect(note).toContain('reweighted');
|
||||
});
|
||||
|
||||
it('will not claim a variant is absent from gnomAD when gnomAD was never consulted', () => {
|
||||
const chips = evidenceChips(candidate(), 3, DATABASE_ONLY);
|
||||
expect(chips.map((c) => c.label)).toContain('frequency not checked');
|
||||
expect(chips.map((c) => c.label)).not.toContain('absent from gnomAD');
|
||||
});
|
||||
|
||||
it('still reports a real gnomAD absence when frequencies were looked up', () => {
|
||||
const chips = evidenceChips(candidate(), 3, FULL);
|
||||
expect(chips.map((c) => c.label)).toContain('absent from gnomAD');
|
||||
});
|
||||
|
||||
it('does not offer scoring when the model would abstain anyway', () => {
|
||||
const items = [candidate({ scored: false })];
|
||||
expect(needsScoring(items, FULL)).toBe(true);
|
||||
expect(needsScoring(items, DATABASE_ONLY)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreBarPercent', () => {
|
||||
|
||||
+43
-16
@@ -1,4 +1,4 @@
|
||||
import type { Candidate, Funnel } from './api';
|
||||
import type { Candidate, Evidence, Funnel } from './api';
|
||||
|
||||
export type Chip = {
|
||||
label: string;
|
||||
@@ -20,23 +20,36 @@ const formatAf = (af: number) => af.toExponential(1);
|
||||
* The reasons this variant is a candidate, in the order a reviewer reads them. ClinVar comes last
|
||||
* and is styled apart, because it confirms the ranking rather than feeding it.
|
||||
*/
|
||||
export function evidenceChips(candidate: Candidate, caseTermCount: number): Chip[] {
|
||||
export function evidenceChips(
|
||||
candidate: Candidate,
|
||||
caseTermCount: number,
|
||||
evidence?: Evidence
|
||||
): Chip[] {
|
||||
const { variant } = candidate;
|
||||
const chips: Chip[] = [
|
||||
candidate.matched_terms.length
|
||||
? { label: `${candidate.matched_terms.length}/${caseTermCount} phenotype terms`, tone: 'match' }
|
||||
: { label: 'no phenotype match', tone: 'muted' },
|
||||
variant.gnomad_af === null
|
||||
? { label: 'absent from gnomAD', tone: 'rare' }
|
||||
: { label: `gnomAD ${formatAf(variant.gnomad_af)}`, tone: 'rare' },
|
||||
{
|
||||
label: (variant.consequence ?? 'unknown consequence').split('&')[0].replace(/_/g, ' '),
|
||||
tone: 'impact'
|
||||
},
|
||||
candidate.scored && variant.prediction
|
||||
? { label: `model ${variant.prediction.score.toFixed(2)}`, tone: 'model' }
|
||||
: { label: 'unscored', tone: 'muted' }
|
||||
: { label: 'no phenotype match', tone: 'muted' }
|
||||
];
|
||||
// "absent from gnomAD" is a claim about gnomAD. Only make it if gnomAD was actually consulted.
|
||||
if (evidence && !evidence.frequencies) {
|
||||
chips.push({ label: 'frequency not checked', tone: 'muted' });
|
||||
} else if (variant.gnomad_af === null) {
|
||||
chips.push({ label: 'absent from gnomAD', tone: 'rare' });
|
||||
} else {
|
||||
chips.push({ label: `gnomAD ${formatAf(variant.gnomad_af)}`, tone: 'rare' });
|
||||
}
|
||||
chips.push({
|
||||
label: (variant.consequence ?? 'unknown consequence').split('&')[0].replace(/_/g, ' '),
|
||||
tone: 'impact'
|
||||
});
|
||||
if (evidence && !evidence.effect_scores) {
|
||||
chips.push({ label: 'model not used', tone: 'muted' });
|
||||
} else if (candidate.scored && variant.prediction) {
|
||||
chips.push({ label: `model ${variant.prediction.score.toFixed(2)}`, tone: 'model' });
|
||||
} else {
|
||||
chips.push({ label: 'unscored', tone: 'muted' });
|
||||
}
|
||||
if (variant.clinvar_sig) chips.push({ label: `ClinVar: ${variant.clinvar_sig}`, tone: 'clinvar' });
|
||||
return chips;
|
||||
}
|
||||
@@ -45,12 +58,22 @@ export function evidenceChips(candidate: Candidate, caseTermCount: number): Chip
|
||||
export function funnelSteps(funnel: Funnel): { label: string; value: number }[] {
|
||||
return [
|
||||
{ label: 'variants called', value: funnel.total },
|
||||
{ label: 'rare (<0.1%)', value: funnel.rare },
|
||||
// Without frequencies this step cannot filter; saying "rare" would claim it had.
|
||||
{ label: funnel.frequencies ? 'rare (<0.1%)' : 'rarity not checked', value: funnel.rare },
|
||||
{ label: 'coding candidates', value: funnel.candidates },
|
||||
{ label: 'in phenotype-matched genes', value: funnel.phenotype_matched }
|
||||
];
|
||||
}
|
||||
|
||||
/** Why a component is absent from the score, in the reviewer's language rather than the schema's. */
|
||||
export function missingEvidenceNote(evidence: Evidence): string | null {
|
||||
if (!evidence.missing.length) return null;
|
||||
const reasons: string[] = [];
|
||||
if (!evidence.frequencies) reasons.push('no allele frequencies (VEP ran without its cache)');
|
||||
if (!evidence.effect_scores) reasons.push('no CADD or AlphaMissense scores');
|
||||
return `This run has ${reasons.join(' and ')}, so ${evidence.missing.join(' and ')} did not score. The remaining evidence was reweighted to make up the difference.`;
|
||||
}
|
||||
|
||||
export const scoreBarPercent = (score: number): number =>
|
||||
Math.max(0, Math.min(100, Math.round(score * 100)));
|
||||
|
||||
@@ -77,5 +100,9 @@ export function barWidth(value: number, total: number): number {
|
||||
|
||||
export const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
||||
|
||||
/** True when a case was analysed before a model existed: scoring can be run on its own. */
|
||||
export const needsScoring = (items: Candidate[]): boolean => items.some((c) => !c.scored);
|
||||
/**
|
||||
* True when a case was analysed before a model existed and scoring is worth running on its own.
|
||||
* Pointless when the model has no feature the ranking lacks, because it would abstain anyway.
|
||||
*/
|
||||
export const needsScoring = (items: Candidate[], evidence?: Evidence): boolean =>
|
||||
(!evidence || evidence.effect_scores) && items.some((c) => !c.scored);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Candidate } from '$lib/api';
|
||||
import type { Candidate, Evidence } from '$lib/api';
|
||||
import { evidenceChips, scoreBarPercent } from '$lib/candidates';
|
||||
import Chips from './Chips.svelte';
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
candidate,
|
||||
rank,
|
||||
termCount,
|
||||
evidence,
|
||||
selected = false,
|
||||
onselect
|
||||
}: {
|
||||
candidate: Candidate;
|
||||
rank: number;
|
||||
termCount: number;
|
||||
evidence?: Evidence;
|
||||
selected?: boolean;
|
||||
onselect: () => void;
|
||||
} = $props();
|
||||
@@ -27,7 +29,7 @@
|
||||
<span class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</span>
|
||||
{#if v.hgvsp ?? v.hgvsc}<span class="hgvs">{v.hgvsp ?? v.hgvsc}</span>{/if}
|
||||
</span>
|
||||
<Chips chips={evidenceChips(candidate, termCount)} />
|
||||
<Chips chips={evidenceChips(candidate, termCount, evidence)} />
|
||||
<span class="rankscore">
|
||||
<span class="scorebar"><span style="width: {scoreBarPercent(candidate.score)}%"></span></span>
|
||||
<span class="scorenum">{candidate.score.toFixed(2)}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api, type DecisionState, type VariantDetail } from '$lib/api';
|
||||
import { api, type DecisionState, type Evidence, type VariantDetail } from '$lib/api';
|
||||
import { evidenceChips } from '$lib/candidates';
|
||||
import Chips from './Chips.svelte';
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
detail,
|
||||
termCount,
|
||||
weights,
|
||||
evidence,
|
||||
onclose,
|
||||
ondecided
|
||||
}: {
|
||||
detail: VariantDetail;
|
||||
termCount: number;
|
||||
weights: Record<string, number>;
|
||||
evidence?: Evidence;
|
||||
onclose: () => void;
|
||||
ondecided: (updated: VariantDetail) => void;
|
||||
} = $props();
|
||||
@@ -66,16 +68,20 @@
|
||||
<button class="quiet" onclick={onclose} aria-label="Close panel">✕</button>
|
||||
</header>
|
||||
|
||||
<Chips chips={evidenceChips(detail, termCount)} />
|
||||
<Chips chips={evidenceChips(detail, termCount, evidence)} />
|
||||
|
||||
<h4>Why it ranks {detail.score.toFixed(2)}</h4>
|
||||
<table class="components">
|
||||
<tbody>
|
||||
{#each Object.entries(detail.components) as [name, value] (name)}
|
||||
<tr>
|
||||
<tr class:abstained={value === null}>
|
||||
<th>{name}</th>
|
||||
<td class="coord">{value.toFixed(2)} × {(weights[name] ?? 0).toFixed(2)}</td>
|
||||
<td class="coord">{(value * (weights[name] ?? 0)).toFixed(3)}</td>
|
||||
{#if value === null}
|
||||
<td class="coord" colspan="2">not looked up — did not score</td>
|
||||
{:else}
|
||||
<td class="coord">{value.toFixed(2)} × {(weights[name] ?? 0).toFixed(2)}</td>
|
||||
<td class="coord">{(value * (weights[name] ?? 0)).toFixed(3)}</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
|
||||
import { poll } from '$lib/poll';
|
||||
import { formatElapsed, latestStep } from '$lib/progress';
|
||||
import { needsScoring, plural } from '$lib/candidates';
|
||||
import { missingEvidenceNote, needsScoring, plural } from '$lib/candidates';
|
||||
import CandidateRow from '$lib/components/CandidateRow.svelte';
|
||||
import Funnel from '$lib/components/Funnel.svelte';
|
||||
import VariantPanel from '$lib/components/VariantPanel.svelte';
|
||||
@@ -33,7 +33,8 @@
|
||||
const termCount = $derived(kase?.phenotypes.length ?? 0);
|
||||
// Annotation is expensive and scoring is not: a case annotated before a model existed should
|
||||
// not need a five-minute re-run of VEP to get its score.
|
||||
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items));
|
||||
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items, page.evidence));
|
||||
const evidenceNote = $derived(page ? missingEvidenceNote(page.evidence) : null);
|
||||
|
||||
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
||||
$effect(() => {
|
||||
@@ -146,7 +147,10 @@
|
||||
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
{#if scoreNote}
|
||||
<p class="note">Variants are unscored, so the model term contributes 0 to every rank. {scoreNote}</p>
|
||||
<p class="note">{scoreNote}</p>
|
||||
{/if}
|
||||
{#if evidenceNote}
|
||||
<p class="note">{evidenceNote}</p>
|
||||
{/if}
|
||||
|
||||
{#if running}
|
||||
@@ -196,6 +200,7 @@
|
||||
{candidate}
|
||||
rank={i + 1}
|
||||
{termCount}
|
||||
evidence={page.evidence}
|
||||
selected={detail?.variant.id === candidate.variant.id}
|
||||
onselect={() => select(candidate)} />
|
||||
{:else}
|
||||
@@ -207,6 +212,7 @@
|
||||
{detail}
|
||||
{termCount}
|
||||
weights={page.weights}
|
||||
evidence={page.evidence}
|
||||
onclose={() => (detail = null)}
|
||||
ondecided={decided} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user