Skip to content

Share one masked-marginal engine across the masked models and score the leaderboard with the wild-type fill - #21

Open
Leo-T-Zang wants to merge 18 commits into
v0.2from
four-fill-scoring
Open

Share one masked-marginal engine across the masked models and score the leaderboard with the wild-type fill#21
Leo-T-Zang wants to merge 18 commits into
v0.2from
four-fill-scoring

Conversation

@Leo-T-Zang

@Leo-T-Zang Leo-T-Zang commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What this changes

The four masked language models (RNA-FM, RiNALMo, AIDO.RNA, RNAGenesis) were scored by four
near-duplicate scripts that hardcoded one masked-marginal convention. This replaces them with one
shared engine that implements the four conventions the literature defines, and moves the leaderboard
onto the one the reference implementations use.

The four fill strategies

Notation

For a variant with mutated positions $M$, write $x^{wt}$ for the wild-type sequence and $x^{mt}$ for
the variant's own sequence. At a mutated position $i \in M$, $wt_i$ is the wild-type base and $mt_i$
the mutant base. Write $x_{-i}$ for a sequence with a mask token at position $i$, and $x_{-M}$ for
one with a mask at every position in $M$. Finally $p(x_i = b \mid c)$ is the masked language model's
probability of base $b$ at position $i$ given context $c$.

Every masked-marginal score masks a mutated position, reads a log-odds between the mutant and
wild-type base there, and sums over the variant's mutations. The four conventions differ in exactly
one thing: what the model sees at the variant's OTHER mutated positions while position $i$ is
masked.
All four are defined in Meier et al. 2021 (ESM-1v), supplement Appendix A.

1. wt-fill, wild-type bases at the other mutated sites

$$s_{\text{wt-fill}} = \sum_{i \in M} \Big[ \log p(x_i = mt_i \mid x^{wt}_{-i}) - \log p(x_i = wt_i \mid x^{wt}_{-i}) \Big]$$

Both terms are read from the same context, so each summand is a genuine log-odds ratio. The context
never depends on the variant, only on $i$, so a multi-mutant's score is exactly the sum of its
constituent single-mutant scores: the model is additive by construction and cannot express epistasis.
It is also by far the cheapest, needing one context per distinct mutated position of the whole assay
(1,697 across the 31 ncRNA assays) rather than one per variant.

This is what the ESM authors' examples/variant-prediction/predict.py and ProteinGym's
proteingym/baselines/esm/compute_fitness.py implement under the option name masked-marginals, and
it is the WT-LLR of the RNA zero-shot literature. The leaderboard adopts it on that provenance.

2. mask-fill, masks at the other mutated sites

$$s_{\text{mask-fill}} = \sum_{i \in M} \Big[ \log p(x_i = mt_i \mid x^{wt}_{-M}) - \log p(x_i = wt_i \mid x^{wt}_{-M}) \Big]$$

Every mutated position is masked at once and the rest of the sequence is wild type. Both terms again
share one context, so this too is a log-odds ratio. The model is told that the other mutated
positions changed but not what they changed to, so it marginalises over them rather than conditioning
on them. One context per distinct mutated-position set. This is the formula written in the ESM paper,
and its supplement calls it strategy (a).

3. mut-fill, mutant bases at the other mutated sites

$$s_{\text{mut-fill}} = \sum_{i \in M} \Big[ \log p(x_i = mt_i \mid x^{mt}_{-i}) - \log p(x_i = wt_i \mid x^{mt}_{-i}) \Big]$$

Position $i$ is masked in the variant's own sequence, so the variant's other mutations remain visible.
Both terms share a context, so it is a log-odds ratio, and it is the only one of the four that can
express epistasis
, since the conditional distribution at $i$ depends on the actual genetic
background. It is also the most expensive, needing one context per (variant, position) pair
(2,458,521 across the 31 assays). Supplement strategy (c). This is the convention the previous
leaderboard used.

4. match-fill, the fill matches the allele being scored

$$s_{\text{match-fill}} = \sum_{i \in M} \Big[ \log p(x_i = mt_i \mid x^{mt}_{-i}) - \log p(x_i = wt_i \mid x^{wt}_{-i}) \Big]$$

The mutant term is read in the variant's context and the wild-type term in the wild type's, so the
two terms come from different contexts and this is not a log-odds ratio.
It is a difference of two
conditionals, effectively a mutation-site-restricted difference between the variant's and the wild
type's pseudolikelihood contributions. Supplement strategy (b).

A worked example

Wild type ACGUACGUACGU with the double mutant A1C, G3U, so the variant is CCUUACGUACGU.
Scoring position 1, with # the mask token:

strategy context at position 1 position 3 shows terms read there
wt-fill #CGUACGUACGU G, wild type $\log p(C) - \log p(A)$
mask-fill #C#UACGUACGU #, masked $\log p(C) - \log p(A)$
mut-fill #CUUACGUACGU U, mutant $\log p(C) - \log p(A)$
match-fill #CUUACGUACGU and #CGUACGUACGU mutant, then wild type $\log p(C)$ from the first, $\log p(A)$ from the second

Position 3 is then scored the same way, and the two contributions are summed.

Two properties that follow

All four are identical on single mutants. If $|M| = 1$ there are no other mutated positions, so
$x^{mt}{-i} = x^{wt}{-i} = x^{wt}_{-M}$ and the four expressions coincide term by term. This is used
as a fixture test: on Pitt_2010_ribozyme, which is entirely single mutants, all four must return
identical scores, and they do for all eight checkpoints.

They diverge on essentially everything else here, because 99.4% of the ncRNA variants are
multi-mutants.

Why the four are computed together

Write the four half-sums

$$A = \sum_{i \in M} \log p(mt_i \mid x^{mt}_{-i}), \quad B = \sum_{i \in M} \log p(wt_i \mid x^{mt}_{-i}), \quad C = \sum_{i \in M} \log p(mt_i \mid x^{wt}_{-i}), \quad D = \sum_{i \in M} \log p(wt_i \mid x^{wt}_{-i})$$

so that $s_{\text{mut-fill}} = A - B$, $s_{\text{wt-fill}} = C - D$ and $s_{\text{match-fill}} = A - D$.
Knowing $A - B$ and $C - D$ does not determine $A - D$: adding a constant to both $A$ and $B$ leaves
mut-fill unchanged while moving match-fill. match-fill therefore cannot be recovered from the
other strategies' final scores
, only from their per-position halves, which is why the engine
computes all four in one pass rather than offering them as separate runs.

Terminology hazard

The name is overloaded, and the overloading has already produced wrong numbers in this repository.
The ESM and ProteinGym code option called masked-marginals is wt-fill; the formula written in the
ESM paper is mask-fill; and wt-marginals is a different method entirely, a single unmasked
forward pass of the wild type with no masking at all, which is what the previously released RNA-FM
predictions turned out to be.

Leaderboard effect

Every masked model rises. AIDO.RNA (1.6B) 0.1824 to 0.2070, RNAGenesis 0.1498 to 0.1825, RiNALMo
0.1391 to 0.1690, RNA-FM 0.1014 to 0.1293, which passes Nucleotide Transformer, and Orthrus 0.0384 to
0.0717. The autoregressive models, NT and EVmutation are untouched, since masked marginals do not
apply to them, and their rows are byte-identical.

The five AIDO.RNA checkpoints were previously split between the leaderboard, which listed only the
1.6B, and a side table. They are five models scored on the same assays with the same convention, so
all five are now leaderboard entries and aido_rna_scaling.csv is gone.

The top three are a tie, not a ranking. AIDO.RNA (650M) 0.2163, Evo 2 (40B) 0.2120 and AIDO.RNA
(1.6B) 0.2070 span 0.0093, inside the roughly 0.01 band that 31 assays cannot resolve, which the
Notes section on that page already states. The substantive observation is not the order but that a
650M-parameter masked RNA model is level with a 40B autoregressive one.

The size series stops improving after 650M, and whether it is monotone depends on the fill strategy:
mask-fill and mut-fill increase with size, while wt-fill and match-fill put the 650M above
the 1.6B. That is now stated as prose rather than as a second table.

All four strategies are computed for every masked checkpoint and published on the leaderboard page,
so the choice can be checked rather than taken on trust. wt-fill has the highest observed macro on
8 of the 9 checkpoints, and it is the convention the leaderboard uses.
The differences sit almost
entirely in the tRNA assays, which the equal-category weighting amplifies.

RNA-ERNIE is the one entry not on this convention. Its scorer runs a single unmasked forward pass
over the wild type, applies a softmax rather than a log-softmax, and sums raw probability differences,
so it is not comparable with the masked checkpoints. It needs a paddlepaddle environment we do not
have. The leaderboard page says so.

Orthrus joins the masked models. It had been excluded on the grounds that it has no mask token,
but its MLM checkpoint documents the convention directly, "positions to score should be masked
(nucleotide channels set to zero) before calling", and its published score already relied on that
operation. It now runs through the same engine as the others, and its scorer drops from 308 lines to
109 while gaining the mutant-base validation it lacked.

Implementation

fitness/baselines/masked_lm builds one deduplicated context bank covering all four strategies and
accumulates them in a single pass. The four share most of their contexts, so computing all four costs
about 19% more unique context examples than mut-fill alone (2,929,196 against 2,458,521 over the 31
assays).

Each model script is now an adapter of about 100 lines supplying an alphabet, a tokenization and one
forward pass; the four lose 1,559 lines of duplicated masking, batching and IO.

Guards, because a wrong masked score looks plausible: a tokenizer that maps a base to the unknown
token is rejected (that is the bug the released RiNALMo scores carried), every scored position is
asserted to be masked, the context-to-token offset is asserted rather than assumed, the four
strategies must cover the same variants, and the wild type is taken from RAW_CONSTRUCT_SEQ and
independently recovered by reverting each variant's mutations, with disagreement fatal.

Each prediction file gets a manifest recording the strategies, columns, alphabet, dtype, checkpoint,
counts, a hash of the scoring source and the GPU. The GPU matters: the same code and checkpoint in
bfloat16 on an L40S and an H100 differ by up to 0.2 in score and about 0.001 in per-assay Spearman.
Every number here was produced on H100s.

Verification

  • --strategies mut-fill reproduces the pre-refactor predictions to 1.5e-7 (RNA-FM), 4.7e-7
    (RiNALMo), 9.2e-7 (RNAGenesis) and 7.1e-7 (AIDO.RNA, on its original L40S), with identical NaN
    masks, so the refactor changed no model's numbers.
  • The mut-fill column of the new runs reproduces the previously published mut-fill leaderboard to
    within 0.0008.
  • All four strategies return identical scores on Pitt_2010_ribozyme for all eight checkpoints,
    which is the fixture they must satisfy by construction.
  • tests/test_masked_lm.py: 26 tests, CPU only, no checkpoint needed. Each strategy is checked
    against a per-variant reference implementation, and each adapter's alphabet, column, batching and
    dtype defaults are pinned, since those are what the released predictions were produced with.
  • fitness/analyze_fill_strategies.py reproduces the sensitivity table, the category spreads and the
    bootstrap intervals from the prediction files.

Reviewing

The three commits are separable: the engine and adapters, the merge registry change, then the
leaderboard. fitness/merge_scoring_files.py keeps its previous behaviour for every existing entry;
a bare column name still means "a folder named after the model".

Every masked-marginal score masks a mutated position and sums a log-odds over
the variant's mutations; the published conventions differ in what fills the
variant's other mutated positions while one is masked, which Meier et al. 2021
(ESM-1v) supplement Appendix A defines as wild-type, mask, mutant and
allele-matched fills.
Add fitness/baselines/masked_lm, which builds a deduplicated context bank for
all four at once and accumulates them in a single pass, so the four together
cost about 19% more unique context examples than the mutant fill alone
(2,929,196 against 2,458,521 on the 31 ncRNA assays).
Compute the four together because match-fill cannot be recovered from the final
mut-fill and wt-fill scores: it needs their per-position halves.
Reduce each model script to an adapter of about 100 lines supplying an alphabet,
a tokenization and one forward pass; the four scorers lose 1,559 lines of
duplicated masking, batching and IO.
Take the wild type from RAW_CONSTRUCT_SEQ and independently recover it by
reverting each variant's mutations, and stop if they disagree, since three of
the four strategies condition on it.
Reject a tokenizer that maps a base to the unknown token, which is the bug the
released RiNALMo scores carried, and assert that a scored position is masked,
that context and token positions differ by a constant shift, and that all four
strategies cover the same variants.
Write a manifest beside each prediction file recording the strategies, columns,
alphabet, dtype, checkpoint, counts, a hash of the scoring source and the GPU,
because bfloat16 scores differ between GPU models.
Add tests/test_masked_lm.py, which checks each strategy against a per-variant
reference implementation on a stand-in model, needs no checkpoint or GPU, and
pins each adapter's alphabet, column, batching and dtype defaults.
One scoring run now writes all four fill strategies into one folder as separate
columns, but combine_csv_data used the model name as folder, lookup key and
output column at once, so a folder could hold only one score.
Resolve each entry through resolve_source: a bare column name keeps the previous
meaning, and a {folder, column} entry lets several entries read different
columns of the same prediction files.
Register the eight masked checkpoints' four strategies with four_fill_entries,
kept out of ALL_MODELS so a default merge still produces one row per model.
Add --models to merge a subset, and fail on an unknown entry or on a prediction
file that lacks its configured column.
Adopt wt-fill, the convention the ESM and ProteinGym reference implementations
use under the name masked-marginals, in place of the mutant fill the previous
table used.
Choose it on that provenance rather than on the scores: wt-fill has the highest
observed macro on 7 of 8 checkpoints, but a paired assay bootstrap separates it
from mask-fill on only 2 and from match-fill on only 1, while mut-fill is the
weakest of the four on every checkpoint.
Rescore AIDO.RNA, RNAGenesis, RiNALMo and RNA-FM, which all rise: AIDO.RNA takes
second place from RNA-ERNIE and Evo 2 (7B), and RNA-FM passes Nucleotide
Transformer.
Note that RNA-ERNIE is the one masked model not on this convention, since it
needs a paddlepaddle environment, so its score is carried over unverified.
Publish all four strategies in four_fill_sensitivity.csv, and record that their
differences sit almost entirely in the tRNA assays, which the macro weighting
amplifies: under an unweighted assay mean wt-fill leads on only 3 of 8.
Replace the claim that AIDO.RNA improves at every size step, since under wt-fill
the 650M checkpoint scores above the 1.6B.
Add fitness/analyze_fill_strategies.py, which reproduces the sensitivity table,
the category spreads and the bootstrap intervals from the prediction files.
@Leo-T-Zang
Leo-T-Zang requested a review from murfalo August 20, 2026 01:25
The page said to reproduce the aggregate with performance_fitness.py, but that
script returns the absolute Spearman, a direction-folded AUC and an absolute
MCC, so it cannot produce the signed values the leaderboard publishes.
Say so, and point at analyze_fill_strategies.py, which computes the signed
per-assay Spearman and the category macro from the prediction files.
Leave performance_fitness.py itself alone: adding a signed option changes a
shared aggregation used by the other tables and belongs in its own change.
The five AIDO.RNA sizes were split between the leaderboard, which carried only
the 1.6B, and a side table, which is an odd division for five models scored on
the same assays with the same convention.
Fold all five into leaderboard_signed_3ncRNA.csv and drop aido_rna_scaling.csv.
Say that the top three entries are a tie: AIDO.RNA (650M), Evo 2 (40B) and
AIDO.RNA (1.6B) span 0.0093, inside the roughly 0.01 band that 31 assays cannot
resolve, which the Notes section already states.
Keep the observation that the size series stops improving after 650M, and that
whether it is monotone depends on the fill strategy, as prose rather than as a
second table.
Now that all five AIDO.RNA sizes are leaderboard entries, eight of the sixteen
rows are masked checkpoints, so calling them the four rescored rows was
ambiguous.
The script returned the absolute Spearman, so it credited a model whose scores
anti-correlate with fitness exactly as much as one that correlates, which is the
distinction the signed metric adopted in v0.1.1 exists to make, and it therefore
could not produce the numbers the leaderboard publishes.
Return the signed value. Its per-category means now reproduce this page's
category columns for all eight masked checkpoints to floating-point precision,
with the macro their unweighted mean.
Leave AUC and MCC folded onto their better direction for now, and say so in the
docstring: it is the same conflation, but unfolding them changes published
numbers this change is not otherwise touching.
Drop four_fill_sensitivity.csv, whose contents are the table on the leaderboard
page and are regenerated by analyze_fill_strategies.py.

Note that tests/test_fitness.py compares against result files published before
this fix, so it will fail on any assay where a model anti-correlates until those
artifacts are regenerated.
fitness/README.md still described the Spearman as absolute, which was the last
place in the repository saying the active metric ignores direction.
Add a test that a reversed prediction returns a negative Spearman equal in
magnitude to the forward one, so that reverting to an absolute value fails
rather than passing silently.
The canonical entries still pointed at the superseded prediction folders and
columns, so a default merge followed by performance_fitness.py reproduced the
old mutant-fill numbers rather than the leaderboard, which is the kind of quiet
disagreement this change set exists to remove.
Point RNA-FM, RiNALMo, AIDO.RNA at all five sizes and RNAGenesis at their
{name}_4fill folder and their wt_fill column. The {name}_{strategy} entries
still read the other three fills from those same files.
Verified end to end: merging the released predictions with these folders and
running performance_fitness.py --type all reproduces every one of the sixteen
leaderboard rows to at most 8.3e-17.
Note that the masked models were rescored on the 31 non-coding assays only, so
they now report NaN on the mRNA assays rather than a score from a superseded
convention.
The Spearman became signed in this branch, but the AUC was still folded with
max(auc, 1 - auc) and the MCC was still absolute, so one row could report a
model as badly wrong by Spearman and moderately good by the other two at the
same time.
On the non-coding assays 190 of 496 model and assay pairs rank variants
backwards, and every one of them was credited with an AUC above 0.5 and a
positive MCC; the worst reads Spearman -0.528 alongside AUC 0.747.
Return the AUC and the MCC as computed. An AUC below 0.5 or an MCC below 0 now
means the model ranks variants the wrong way round, higher stays better for all
three, and the leaderboard is unaffected because it reports the Spearman.
Extend the metric test to pin all three directions, so a reversed prediction has
to score worse than random rather than the same as a correct one.
The AUC scores continuous predictions against median-binarised assay labels,
while the MCC binarises both sides, which the shared phrase obscured.
Note in the metric test why it uses an even, tie-free sample: an MCC of exactly
-1 needs the median to split both classes evenly.
Two default paths could finish successfully while doing the wrong thing.

merge_scoring_files.py logged and continued past a missing prediction file, and
performance_fitness.py then dropped any model absent from every merged file, so
a misspelled folder produced a complete set of merged assays with a model
silently gone; a folder-name mistake during this work produced 31 merged files
carrying no scores at all and reported success. Count what each model
contributes and raise when a requested model contributes nothing, which is a
configuration error rather than the partial coverage the non-coding runs
legitimately have. Add --allow_incomplete for the permissive case, and log the
per-model assay counts either way.

The masked scoring scripts advertised the full 0-69 array while defaulting to
all four strategies, which the runner refuses on any assay needing windowing, so
the documented command failed on the mRNA assays. Point the scripts at the 31
non-coding rows the v0.2 leaderboard reports, and say why the four strategies
are offered only where the whole construct fits: a windowed context drops
mutations from the conditioning sequence, so the fills stop estimating the same
quantity. Document the same limit in the engine README.

Pin the registry to the leaderboard, since that disagreement nearly shipped: one
test asserts every masked model resolves to its {name}_4fill folder and wt_fill
column, another that every leaderboard row is a registered model and the table is
sorted. Reverting one entry to its old form fails the first.

Compare a windowed score against an independently computed reference rather than
only checking that masks survive and scores are finite, and stop tests/
test_fitness.py skipping a result file missing from the published archive, which
let it report success while checking nothing.
The page said RNA-ERNIE's convention was never verified. It can be read off its
scorer: one forward pass over the unmasked wild type, a softmax rather than a
log-softmax, and a sum of raw probability differences at the mutated positions.
That is neither a masked marginal nor a log-likelihood ratio, and for
multi-mutants no rank-preserving transformation relates the two, so its fourth
place is not comparable with the checkpoints scored under wt-fill.
Orthrus masks one position at a time in the variant's own sequence, which is
mut-fill; say so rather than leaving it implicitly on the page's convention.
Replace the claim that differences under about 0.01 are not meaningful, which
nothing in the repository establishes: the bootstrap_se columns are standard
errors of an assay-weighted difference from the best model, not of the
equal-weight category macro. Give the measured interval for the top pair
instead, +0.0043 with a 95% interval of [-0.0224, +0.0314].
Orthrus was excluded from the strategy comparison on the grounds that it has no
mask token, so masking would mean zeroing continuous channels and would modify
the convention rather than apply it. That premise was wrong. The
antichronology/orthrus-mlm-6-track checkpoint is dual-objective pretrained,
contrastive plus masked LM, its README describes predict_tokens as variant
scoring, and its docstring specifies the masking convention directly: "Positions
to score should be masked (nucleotide channels set to zero) before calling."
Zeroing is therefore Orthrus's masking operation, not a substitute for one, and
the published Orthrus score already relied on it for a single position.

Score it through the shared engine, which leaves it on the same context
construction, deduplication, batching and accumulation as every other masked
model. Orthrus has no token vocabulary, so it is handed integer base codes, 0 to
3 for A, C, G, T with 4 for a masked position, and logits_at expands those into
the 6-track tensor; no special case is needed in the engine.

This also gives Orthrus the validation the other scorers have. Its own
build_masking_tasks never checked that the variant sequence carries the declared
mutant base, so a mismatch would have been scored silently rather than dropped.

The CDS and splice channels remain zero throughout, because DMS constructs carry
no transcript annotation. That is a limitation of applying the model to this
data rather than of the masking, and it is unchanged from before.
The comment claimed lengths are passed so the backbone ignores padding, but
this checkpoint's forward discards lengths and they affect pooled
representations only. Padding is kept out by refusing mixed-length batches.
Orthrus was the last entry still published under mut-fill. Its four-strategy run
puts wt-fill at 0.0717 against the 0.0384 it had, nearly double, and the whole
difference is in tRNA, from -0.0068 to +0.0922: the deepest-mutation category,
which is where the fills diverge.
Move its leaderboard row and its registry entry to wt-fill, and add it to the
sensitivity table as a ninth checkpoint.
Correct two claims that this run breaks. mut-fill is no longer weakest on every
checkpoint: for Orthrus match-fill edges below it by 0.0028, so it is lowest on
8 of 9. wt-fill has the highest observed macro on 8 of 9 rather than 7 of 8.
Note also that none of Orthrus's own bootstrap intervals excludes zero, so it is
consistent with the pattern rather than independent evidence for it, despite the
size of the point estimate.
Its rank is unchanged at 14 of 16.
The sensitivity section argued a case about the other three fills. Say instead
that wt-fill has the highest observed macro on 8 of the 9 checkpoints and is the
convention the leaderboard uses, and leave the table to be read.
Keep the two caveats a reader needs: the differences sit almost entirely in the
tRNA assays, which the equal-category weighting amplifies, and the model
ordering is far more stable than the absolute numbers.
The stricter missing-model check turned a latent inconsistency into a failure:
EVmutation is in ALL_MODELS, so a default merge requests it, but it has never
been part of a prediction release and it never contributed a column to any
output. Every run simply logged one warning per assay and moved on.
It also has its own merge mode, --assays_with_MSAs_only, because it only scores
assays with MSAs. Remove it from the default list and keep its SCORE_COLS entry,
so that mode still works and the default pipeline no longer requests predictions
that do not exist.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant