From five million variants to ten candidates
Part two of a series on the 2026 MVA hackathon: what the prioritization pipeline does to the VCF, one invented row followed through every gate, and why the filters set the ceiling.
The problem fits in one sentence: I have a table with 5,012,204 rows, and at most one of them is the reason a child is sick. Find it.
If you have built a search engine or a recommender, the shape of that task should already feel familiar. By the end of this post I want it to feel like work you have done before, with different column names. One note on scope first: this series covers only what the hackathon organizers published and methods that are already public, nothing about the specific child and nothing about variants I have found. The disease background, the data formats and the scoring live in the first post, so I skip them here.
Where the table comes from
A sequencer cannot read a 3.1-billion-letter genome in one go. It reads millions of short fragments, around 150 letters each, every letter carrying its own error estimate, and it has no idea where any fragment belongs. An upstream pipeline fixes that, passing from the full raw FASTQ to the VCF in five steps: check each fragment’s quality and trim the junk, place every fragment on the reference genome, remove duplicates and correct quality scores that drifted for boring instrument reasons, propose every position where this person differs from the reference, and throw out the proposals that look like machine error rather than biology. What survives is a single table, the VCF, with one row per real difference between this person and the reference. The hackathon hands us that table already built, and everything below starts from it.
What the pipeline does with those rows is the rest of this post (Fig. 1):
Making the table smaller
Nobody scores five million variants one at a time. The first move is to throw most of the table away with rules that say, in effect, that a given row cannot be the cause of a rare disease. As we walk the stages I’ll carry one invented row through them: a heterozygous missense change in a placeholder gene, visible in every figure from Fig. 2 onward. Every value attached to it is invented for illustration, and nothing there comes from the hackathon case.
Before the cutting starts, the rows get wider. Each variant is looked up in a few outside catalogs: VEP or Jannovar for what it does to nearby genes, gnomAD for how common it is in a large population of presumably healthy people, dbNSFP for a bundle of precomputed pathogenicity predictions, and RefSeq or Ensembl for the gene models underneath all of it. In database terms this is a join, and in ML terms it is feature enrichment keyed on position and allele: rows gain columns and none disappear. That is exactly what happens to the example row in Fig. 2, which picks up its consequence, its population frequency, and two pathogenicity scores without moving an inch.
Three rules then do the cutting, and no model is involved in any of them:
-
Frequency drops anything more than about 1% of healthy people carry, since a variant that common cannot be causing a rare disease. In Fig. 3 the example row survives that gate at 0.004%, while the neighbour below it, carried by 12% of healthy people, is dropped.
Fig. 3: the frequency gate. The example row is rare enough to pass. The common neighbour goes, and does not come back. -
Consequence keeps only variants that change a protein or disturb splicing, because the rest rarely explain symptoms. In Fig. 4 that keeps our missense row and drops the synonymous neighbour sitting beside it.
Fig. 4: the consequence gate. Missense survives. The synonymous row beside it rarely explains symptoms, so it goes. -
Inheritance asks whether the variant fits the way the disease runs in this family, which includes the compound heterozygous case: two different damaging variants in the same gene, one inherited from each parent. For the example row a second damaging hit in the same gene arrives from the other parent, so both rows fit the pattern and stay, as Fig. 5 shows. A lone damaging hit with nothing behind it does not.
Fig. 5: the inheritance gate. Two damaging hits in one gene, one from each parent, fit the family pattern. A lone hit does not.
In rough numbers, frequency takes the 5,012,204 rows down to the order of , consequence takes that to the order of , and inheritance leaves a few hundred.
What deserves attention is what this stage is. It is the cheap first stage of a two-stage recommender, implemented as deterministic rules rather than learned, and every row it drops is gone for good. The dashed rows in Figs. 3 to 5 do not come back at the scoring stage or at any other point, so the recall of this stage is a ceiling on everything after it. If the causal variant is dropped here, nothing downstream brings it back, however clever the scorer turns out to be. That single fact shapes the whole analysis more than any choice made later, which is why the auditing effort belongs on the filter thresholds rather than on the choice of scorer.
Judging what survives
A few hundred candidates remain, and each one can now be examined properly. Two different questions get asked of them.
The first is whether the variant is damaging on its own molecular merits. CADD, REVEL, SIFT and PolyPhen, together with various splice predictors, answer exactly that, one variant at a time, as frozen pointwise features: pretrained somewhere else, applied here, with the usual transfer risk that implies. None of them has ever heard of this patient.
The second is whether the gene matches what the clinicians observe. The symptoms have been coded as HPO terms, the Human Phenotype Ontology, and three public tools compare them against what is known about genes and diseases. This is retrieval and ranking: the symptoms are the query, genes and diseases are the corpus, and the three tools are three rankers over it. Exomiser measures the similarity between the patient’s symptoms and known gene-to-phenotype relationships in humans and in model organisms, using the structure of the ontology, and returns one combined score. LIRICAL treats every symptom as a diagnostic test: each symptom earns a likelihood ratio, and the ratios are multiplied together with a genotype-based ratio mixed in, symptoms assumed independent. The result is a posttest probability per candidate disease, with each symptom’s contribution still visible. Phen2Gene looks the symptoms up in a precomputed gene index that weights specific findings above vague ones (“seizures” tells you little, a rare specific finding tells you a lot), roughly the role inverse document frequency plays in search. It answers in about a second, leaving variant filtering to a separate companion step.
The example row scores well on both questions, as Fig. 6 puts side by side: the damage predictors put it near the top of their range, and the gene it sits in matches the coded symptoms closely enough to matter. The two panels judge the row independently, which is exactly what makes the fusion step afterwards non-trivial.
Merging into one ranking
The last move is fusion. Variant-level scores roll up to genes and diseases, combinations the inheritance pattern cannot support are pruned, and damage is merged with phenotype fit into one number and one rank per candidate. The output is the top ten. For the example row the two scores merge to 0.88, which lands it at rank two, the highlighted slot in Fig. 7.
The same path as Figs. 2 to 7, compressed into a few lines:
# VCF + HPO terms -> top 10
annotated = annotate(vcf) # VEP/Jannovar, gnomAD, dbNSFP: add columns
rare = filter_frequency(annotated) # common in healthy people -> out
coding = filter_consequence(rare) # protein-altering or splice only
fitting = filter_inheritance(coding) # family pattern, incl. compound het
damage = score_damage(fitting) # CADD, REVEL: fixed, pretrained functions
relevance = score_phenotype(fitting, hpo) # Exomiser / LIRICAL / Phen2Gene
ranked = fuse(damage, relevance) # roll up to gene, merge, calibrate
return top(ranked, 10)
Where fusion gets subtle
The final merge has to satisfy two metrics at once, and they want different things. Rank Points cares only about the order of the top ten. F-max thresholds the raw scores across the whole list. Take a monotone transform of the scores and the top-ten order is untouched, so the rank-2 slot in Fig. 7 stays exactly where it is and Rank Points does not move at all, while the rescaled scores can put the F-max threshold in a very different place. That is why LIRICAL’s calibrated probabilities and a rescaled Exomiser score can produce the same top ten and still behave completely differently under F-max. The last step is therefore a ranking problem and a calibration problem at the same time, and the pipeline has to get both right.
Put together, the shape is the familiar one: a brutal rules-based first stage whose recall caps everything after it, then a ranker that blends frozen pretrained scores with a retrieval-style match to the symptoms, evaluated on ordering and on calibration at once. That is variant prioritization, and it is the system I am building on.