Ranking neoantigens#
What you get. One ordered table from a candidate list, scored by the fitted EPIC aggregate:
nine terms in four blocks — Expression, Presentation, Immunogenicity,
Complementarity.
mhcmatch genes pairs.tsv --out annotated.tsv # if your table has no gene column
mhcmatch rank pairs annotated.tsv --tumor SKCM --out ranked.tsv # the score
mhcmatch rank --coefficients # every term and its coefficient
What this page is. What each term is, how it is computed, what it was fitted on, and what it
does not do. One page per block is linked at the bottom and from the sidebar. Every number here is
recorded in the benchmark repository under bench/results/ and cited from there rather than
restated from memory.
Two things to know before reading a score. The blocks enter in pipeline order, so a later block’s coefficient is what that term is worth after the earlier ones — never in competition with them. And a candidate missing a term is scored on the terms it has, flagged, never dropped.
The model#
mhcmatch rank scores each candidate with the fitted aggregate vendored at
data/aggregate_mhc1.json, which declares itself EPIC: nine terms in four
hierarchical blocks, one unpenalised intercept per screen. The artifact carries its own
version — a model version, an integer, distinct from the package version. Read the feature list from
mhcmatch.rank.AGGREGATE_FEATURES and the grouping from
mhcmatch.rank.AGGREGATE_BLOCKS rather than typing either out.
The blocks are entered in pipeline order, each on top of the last, so a recognition coefficient is what that term is worth after presentation and expression rather than in competition with them.
This page is about the terms — what each one is and what it is computed from, for the
nine-term mhc1.human.neoantigen fit. The other seven artifacts are the same machinery on a
different term set: six for both class-II neoantigen cells, and for the pathogen cells two at
class I and three at class II. For the eight fitted cells themselves — their coefficients with bootstrap intervals, what each was fitted
on, how well each scores and what each cannot be asked — see The shipped models.
block |
term |
what it is |
|---|---|---|
|
|
|
|
The density axis on its log-odds scale, |
|
|
|
|
|
|
The same gene’s median in the tumour’s matched normal tissue, on the same floor,
falling back to that gene’s pan-tissue median and never to missing. Free rather than
subtracted: a ratio would need equal and opposite coefficients, and both are positive.
See |
|
|
|
|
the same, on Atchley AF5 (electrostatic charge). v3 paired burial with Kidera KF4 hydropathy instead, and that pair was collinear — r = −0.837 per peptide, one chemistry axis in two columns — so burial was not identified. AF5 is orthogonal to burial at r = +0.008, which is what resolves it. The recognition axis, reduced owns the selection. |
|
|
|
|
|
the same against the host proteome, coefficient negative. Not a tolerance measurement on its own — alone it is p = 0.69 and its marginal AUROC is below chance. It is the corpus block’s background term, the reference level the other two are read against; remove it and both of them fall to non-significant. Corpus complementarity: what the repertoire was shaped by has the subset ladder that shows this. |
|
|
the same against a foreign presented ligandome — a thymocyte never sees this during selection, so a hit is about peripheral priming. |
What changed in v3, and why. C_corpus used to be the thymic channel alone, computed by a
radius-2 trie walk that captured a median 0.4999 of the sum its own definition calls for, from a
cache that had no entry at all for three of the nine fitting screens. It is now the exact sum,
evaluated as a k-mer table contraction, for every row; self and viral came back into the
model because the contraction removed the ~7.5 GB index that had priced them out, not because
anything about them changed. C_corpus_missing left with the cache — there is no gap left to
flag. And C_phys was a sum over a face of width L − 5 on a strictly positive scale, so it
correlated +0.954 with peptide length; averaging it fixed that and made the two scales comparable
for the first time.
An earlier arrangement carried the 30-column mhcmatch.complement.score() as one term, the
Łuksza viral_R as R and the three TCR-face mimicry densities as T. luksza.viral_r
and complement.score still ship and are still computable; they are no longer terms of the
shipped model. Every alternative is re-measured in bench/results/epic_recognition_terms.md.
Standardisation (mu, sigma) travels inside the artifact, so a caller reproduces the
score exactly. A feature you cannot supply contributes its training mean — which is what “no
information” should do — so a candidate with no expression value is scored on the terms it has
rather than dropped.
What the coefficients are#
Standardised, so a coefficient is the log-odds shift per standard deviation of its own column and
the sizes are directly comparable. One unpenalised intercept per screen, ridge \(\tau\) = 0.25;
z, p and sign stability come from a cluster bootstrap over (patient, screen) — 400
resamples — because rows from one patient share tumour, HLA and run.
The values are not typed into these docs. Six pages used to carry their own copy of this table and all six went stale together the first time the model was refitted, because nothing read them. The shipped models prints all eight cells in full — generated from the artifacts on every build, which is the only form of that table that has ever stayed correct. The vendored artifact is the record, and the command line prints it too:
mhcmatch rank --coefficients # every term, its block, its coefficient, z, p, sign stability
mhcmatch rank --holdout # per-screen held-out AUROC, both grouped CVs, the fitted corpus
import json, importlib.resources as R
d = json.loads(R.files("mhcmatch.data").joinpath("aggregate_mhc1.json").read_text())
d["version"], d["features"], d["coef"], d["fit"]["rows"], d["fit"]["screens"]
The manuscript’s tables/epic_terms.tex is generated from that same artifact and is the citable
form. What these docs carry instead is what each term is, which does not move when it is refitted.
term |
block |
what it is |
|---|---|---|
|
expression |
this candidate’s own abundance as |
|
expression |
the same gene in the tumour’s matched normal tissue, on the same floor |
|
presentation |
the calibrated Fisher combination of presentation |
|
presentation |
groove occupancy at |
|
corpus |
danger — density against the thymic immunopeptidome |
|
corpus |
the block’s background — see Corpus complementarity: what the repertoire was shaped by |
|
corpus |
peripheral priming — density against the foreign ligandome |
|
physchem |
Rose burial over the TCR face, per residue |
|
physchem |
Atchley AF5 charge over the TCR face — see The recognition axis, reduced |
binder and log10a are two necessary conditions, not one quantity measured twice. A
%rank asks whether a peptide out-competes the self peptidome its allele normally loads;
occupancy asks how many copies reach the surface at a stated free-peptide concentration. Winning a
groove does not imply reaching the copy number a T cell needs, and reaching it does not imply
winning the groove — which is why they sit at Spearman \(\rho\) = +0.7431 rather than 1.
Two terms are doing something other than what their name suggests, and both are documented rather than tidied away:
C_phys_charge’s own p is not the statistic to read on it. It earns its place by what it does to its partner: burial beside Kidera KF4 was not identified (one axis at r = −0.837), and beside charge (r = +0.008) burial’s standard error halves and it resolves on a smaller coefficient. See The recognition axis, reduced.C_corpus_self’s large negative coefficient is a background subtraction, not tolerance.
Two more things no coefficient table says, so read them here.
A coefficient is conditional on its block being entered. The blocks go in pipeline order, so a recognition coefficient is what the term is worth after presentation and expression. Adding the whole corpus block gives the largest held-out gain of any recognition block.
No term was dropped for being small. The rule is replace-and-recalibrate: a term with a
mechanism stays and gets a better basis, it does not get deleted for a p-value.
C_corpus_viral stays because dropping it costs the other two channels their significance.
Terms that are computed and never scored. pres, occupancy, dai, agretopicity,
d_occupancy, wt_absent and any Kidera scale reachable through
complement.burial(..., scale="KIDERA:KF4")
are all emitted for comparison and none is a fitted term. That separation is asserted in the test
suite, not merely intended: nothing in that list may appear in rank.AGGREGATE_FEATURES.
From a score to a probability#
score is a log-odds ranking, and rank is that ranking as a dense 1-based integer. Neither
is a probability, because the fit deliberately has no shared intercept: every screen got its own,
unpenalised, precisely so prevalence and candidate generation stayed out of the slopes.
The p_response column supplies the missing constant, by the only rule that needs no new data.
Given a pool prevalence \(\pi\) you declare, it picks the single offset b with
and reports \(\sigma(s_i + b)\). See mhcmatch.rank.probability().
$ mhcmatch rank fasta candidates.fasta --alleles donor.txt --prevalence 0.06
It is a prior shift, not a recalibration. b is additive and \(\sigma\) is monotone, so it preserves the ordering exactly — halving \(\pi\) roughly halves every probability and moves no rank. What it buys is portability: a raw-score cut-off means nothing across cohorts whose base rates differ by four orders of magnitude, and “P ≥ 0.2 at an assumed 6 % pool prevalence” is a statement another cohort can be held to.
The default is TESLA’s 37 immunogenic of 615 tested candidates — the community benchmark whose whole design is “a pipeline nominated these; which respond”. It is a prior, not a measurement of your cohort, and it is the single number the emitted probability is most sensitive to. Anchors:
pool |
responds |
what it is |
|---|---|---|
Neopep |
0.0060 % |
19 of 318,197 — an exhaustive scan, nothing filtered |
TESLA (the default) |
6.0 % |
37 of 615 — nominated candidates, tested |
ITSNdb |
59.7 % |
89 of 149 — a curated, positive-enriched set |
assayed vaccine units |
~19 % |
41 of 216 units, 13 patients (Sahin et al., Nature 2026;651:1088–1096) — already through a cassette selection |
Why occupancy and not agretopicity#
The obvious term for “is the mutant better presented than its wild type” is the differential
agretopicity index, log10(Kd_WT / Kd_MT). It does not work, and the reason is worth stating
because the quantity is widely used.
Fitted on the cleaned corpus it carried a negative coefficient with an interval crossing zero,
and the marginal was negative too: within-screen median AUROC 0.4986 against the binder %rank’s
0.6383, below 0.5 in nearly every screen. Seven parameterisations were tried — the raw ratio, the
Łuksza pseudocount at five saturation scales, a logistic squashing, gating on anchor substitutions,
gating on genuine binders, the interaction with binder, and the concentration-free share
A/(1+A). None resolves.
One appears to. Tightening the pseudocount drives the coefficient to z +3.32 — but at that
setting the term is 0.9955 correlated with -log10(Kd_MT). It improves by deleting the
agretopicity from itself, leaving mutant affinity under another name, and the wild-type residual
sits at 0.4526 at every setting. See bench/results/neoag_dai_terms.md.
Occupancy and agretopicity derives what the equilibrium supplies instead. Three things we had
been adding by hand fall out of it rather than being imposed: the binder gate, because a mutant that
does not bind occupies nothing whatever its wild type does; the pseudocount, because the free-MHC
1 in the denominator is Łuksza’s ε — which is why that ε carries units of inverse
concentration; and a bounded scale whose steepness is fixed at 1 rather than fitted.
agretopicity is still emitted as a column. It is not a term of the model.
What the fit rests on#
The corpus is the union of every human and mouse neoantigen deposit, harmonised and cleaned, with each exclusion measured rather than assumed:
host keyed on the MHC genus, never a
host_speciescolumn;pathogen epitopes dropped — a peptide recorded only under Influenza A, M. tuberculosis or SARS-CoV-2 is not a neoantigen whatever its immunogenicity;
peptides identical to a host-proteome window dropped — no somatic alteration, no neoantigen;
label conflicts resolved positive-wins and counted;
CEDAR and Gfeller retained but held out of the fit, their contamination characterised.
Counts, the full filter cascade and the per-cohort breakdown are in
bench/results/neoag_corpus_grand.md and neoag_corpus_noise.md.
What to feed it#
Two entry points with different contracts. rank table re-ranks rows another tool scored;
rank fasta predicts de novo from variant windows.
rank table — the four things it actually reads#
A CSV in the pipeline .scored.csv shape, but only these columns are consulted:
column |
needed? |
what it is |
|---|---|---|
|
yes |
the peptide. Rows with a missing or non-alphabetic value are skipped silently |
|
yes to re-score |
the restricting allotype, in the panel’s own form. A paired class-II molecule is one
composite key, not two columns — |
|
no |
expression. Absent, the fallback below runs and |
|
no |
the parent gene’s HGNC symbol, used to look expression up when |
ref_seq / seq are read when present, and an incoming score is preserved in
components['score_builtin'] so the two rankings can be compared. Everything else in the
57-column schema is ignored — passing it is harmless, omitting it costs nothing.
rank fasta — expression, in three ways#
The window FASTA header is the pipeline’s Somatic: record, and of its thirteen fields exactly
two reach the ranker: tpm and gene_name. When tpm is in the header, it is used and
expr_imputed is 0. When it is not, mhcmatch.rank._expression_for() falls back, in order:
--tumor SKCM— TCGA, keyed on the peptide, so it is specific to the epitope;--tissue pancreas— GTEx, keyed on the gene;neither —
expressionisNaN,expr_imputedis 1, and the row still ranks. A missing covariate never drops a candidate: a non-finite term standardises toz = 0, the fitting corpus’s own mean, which is what “no information” means on that scale. The gap is recorded twice and in two senses —expr_imputedsays the abundance was not measured, and theimputedcolumn names every term that fell back. The retiredexpr_missingfitted indicator is a different thing — its source was very nearly a screen label, so the per-screen intercept already carried it (bench/results/epic_expr_arms.md).
--tumor is worth passing even when the abundance column is present, because it sets c.
A tumour’s floor is roughly half its matched normal’s, so the pooled fallback is not a neutral
choice. Where the origin arrives as free text, mhcmatch.expression.resolve_context() maps it
— "liver", "LIHC" and "hepatocellular" all resolve — and raises on a string it
does not recognise, rather than returning a plausible number computed from the wrong distribution.
Note
TPM or FPKM: the unit cancels, but only while the floor comes from the same column.
log2(1 + x/c) is unit-free whenever c is a quantile of the same measurement as x:
multiply the column by any constant and c moves with it. The shipped ``c`` is a quantile
of the reference, in TPM, so that condition holds for a submitted TPM column and fails for
one in FPKM or in counts — silently, since nothing in the number says which it was.
The repair is mhcmatch.expression.batch_scale(), a median of ratios against the reference
for the tumour type, and it is gated on covering at least half that context’s expressed
genes. Handed a whole transcriptome it recovers a known factor exactly across
\(10^{-3}\) to \(10^{6}\); handed a candidate list it refuses, and should. A mutation
reaches such a list only where the gene was seen in RNA, so the ratio measures that conditioning
rather than the library: on screens whose columns are all deposited as TPM, the ungated
estimator returns 1.78, 2.18 and 3.15, never below 1. Counting more candidates cannot fix it,
which is why the guard is coverage and not count.
The arithmetic below is why the two metrics differ by one constant in the first place.
The two differ by a single library-wide constant, identical for every transcript:
Length enters when going from counts to either metric and cancels in the ratio between them.
Checked on a 20,000-gene simulation: the per-gene TPM/FPKM ratio is constant to
\(2.2\times10^{-15}\) and uncorrelated with transcript length.
Two consequences. Re-ranking one sample is safe either way — a constant factor shifts
every candidate by the same amount and cannot reorder them within a patient. Converting
exactly needs the whole table, not the FASTA: renormalise the sample’s FPKM column to sum to
\(10^6\). If you hold only per-candidate values the constant is not recoverable, and it
matters for cross-sample comparison and for any absolute reading of the score, since expr_lvl
and expr_norm are both fitted with positive weight — run mhcmatch rank --coefficients
for the sizes the installed artifact uses, which move at every refit.
The parent gene, when the deposit does not name one#
Both fitted expression terms are keyed on an HGNC gene symbol: expr_lvl needs it whenever
tpm is absent, and expr_norm needs it always. Most published neoantigen deposits ship the
peptide and not the gene — over the benchmark corpus, 356,387 of 695,811 rows (51.2%) and
5,205 of 5,833 positives (89.2%) carried no symbol. Those rows do not drop out; they all collapse
onto one mean-imputed constant, and a constant cannot order anything. On the VACCIMEL screen
expr_norm had standard deviation exactly 0.0000 and AUROC exactly 0.5000 while carrying
+0.4950 log-odds per standard deviation in the then-shipped EPIC v10 artifact — a fitted
parameter paid for on no information. Repaired, it fits +0.2222 in v12. (Which term is largest moves with every refit; mhcmatch rank
--coefficients prints the current set.)
The symbol is recoverable from the peptide, because a neoantigen is a near-copy of a self peptide:
mhcmatch genes candidates.tsv --species human --max-subs 2 --out annotated.tsv
mhcmatch rank pairs annotated.tsv --tumor SKCM --out ranked.tsv
genes searches the reference proteome for the nearest self peptide
(mhcmatch.proteome.Proteome.assign_genes()), names it by its UniProt GN= field and writes
a gene column back beside every column the table already had. Both rank pairs and
rank table read that column, so the two commands compose with no join and no rename.
Over the same corpus that leaves coverage at 692,349 of 695,811 rows (99.5%), up from 339,424
(48.8%), and 4,511 of the 5,833 positives gain a symbol they were not deposited with — which
is what takes expr_norm’s standard deviation on VACCIMEL from 0.0000 to 2.520, i.e. from
no ordering to an ordering. bench/results/gene_resolution.md.
Three properties of the annotation, each of which is a way the axis would otherwise lose information:
The radius is 2, because a neoantigen can carry more than one mutation. The first shell does nearly all of the work — 349,921 of 695,811 corpus rows find a parent one substitution out — and the second is not rounding: 3,004 further rows need two, and on VACCIMEL it is the difference between 87 of 93 rows resolved and 90 of 93. Those are precisely the rows a radius-1 search would have left on the imputed constant.
Only the nearest shell votes. A radius-2 shell is roughly 85 times the size of the radius-1 shell inside it, so pooling the two lets a distant coincidence outvote a genuine single-substitution parent.
A tie becomes several rows, and an unresolved peptide keeps its row with an empty cell. Which of several equally-near parents a peptide should be scored under is a question the expression reference answers and the search cannot, so every tied gene is emitted and the caller takes the best score per peptide —
group_by(peptide).agg(max(score)). Expect them, and expect them shortest-first: over the corpus’s 345,478 (host, peptide) pairs, 22,172 of 70,485 8-mers (31.5%) name more than one nearest gene against 7,448 of 97,995 11-mers (7.6%), because the shorter the peptide the more of the proteome sits one substitution away. Nothing in the ranker assumes one row per (peptide, allele).
Reading the output#
mhcmatch rank fasta candidates.fasta --alleles donor.txt --tumor SKCM --out ranked.tsv
score is the aggregate; higher is better. rank is that score as a dense 1-based integer and
p_response is it on a probability axis at --prevalence (above). Every one of the model’s
nine features is a column, because a row should report what produced it: binder and log10a,
expr_lvl and expr_norm (with expression, expr_pct and expr_imputed beside
them), the two chemistry scales C_phys_buried and C_phys_charge, and the three corpus
channels C_corpus_thymus / _self / _viral. pres and occupancy are emitted too
and are not fitted.
agretopicity, physchem, variant_type and
n_alleles_presenting / alleles_presenting are reported beside them and are not in the
model.
variant_type is carried for the cassette layer rather than for the score: a frameshift or fusion
product is foreign over a stretch rather than at one position, so it fails differently from a
missense and earns a quota of its own in
mhcmatch.portfolio.compose() (Cassette composition). It is the product class —
missense, frameshift, inframe_deletion, fusion, isoform, cnv — and not
the header’s type field, which is provenance (Somatic) and says nothing about what the
variant makes; see mhcmatch.predict.variant_product(). --extended appends the remaining mimicry channels and --annotate what each candidate
resembles; both add columns only and never change the ordering.
The binding core#
--core appends core, core_offset and core_source to rank, predict and
neoag; the cassette map (cassette build --map) carries core unconditionally, beside the
core_start / core_end it already had. It follows NetMHCpan’s definition — “the minimal 9
amino acid binding core directly in contact with the MHC (i.e. excluding potential insertions)” —
with core_offset its Of, 0-based.
The core is residues, never a padded frame. The parenthesis in that definition is the operative
part: where an alignment to a 9-mer motif needs a gap, the inserted position is not part of the
core. So it is nine residues whenever the peptide can fill nine — every class-II core, and a
class-I 9-, 10- or 11-mer — and the peptide’s own residues when it cannot, so a class-I 8-mer’s
core is the 8-mer. A gap character would not be neutral in an amino-acid column in any case:
B is Asx in IUPAC, and a reader would take it for a real ambiguity code.
Class I holds both anchors and lets the middle give way. The footprint is
mhcmatch.diffusion.MHC1_CORE resolved by mhcmatch.store.mhc1_positions() — the same
mapping the scorer uses, so the reported core is the residues the model actually read. A 9-mer is
its own core. A 10- or 11-mer drops one or two central residues, which is NetMHCpan’s Gp/Gl
deletion. Below nine the +5 and -4 positions collide and the losing slot is dropped —
not a residue — so every residue still appears exactly once and an 8-mer’s core is the 8-mer.
core_offset is 0: the footprint is anchored at both ends, so there is no N-terminal protrusion
to report.
Class II is the register-anchored 9-mer, peptide[Of:Of+9], matching NetMHCIIpan’s Core
and Of. Which register produced it is a column and not a footnote, because the two available
registers disagree often on real ligands: core_source reads model when it came from the
per-allele mhcmatch.diffusion.AnchorModel.best_register() (predict and rank fasta,
where that register was already computed to score with), heuristic when it came from the
allele-agnostic one-pass scan (neoag and rank table, which have no allele), and
footprint for class I, where there is no register to choose.
Reported, never scored — the aggregate reads the peptide, not the core, and --core cannot
move a ranking.
Warning
A model emits the features it used, and refuses to run without them. EPIC’s corpus
term reads three reference deposits as three 64 KB k-mer tables, so an aggregate score builds no
trie at all and --no-self is allowed with --score aggregate. --extended and
--annotate do build the reference index, because they report the self channels — paid
once for the whole candidate list, cached on disk and stageable prebuilt, so once per machine
rather than once per run.
The imputed column names any feature that had to take its training mean for that row — a
candidate with no IC50 has no occupancy, a frameshift has no wild type. Those are candidates with
incomplete data, not a different model, so they are scored and the substitution is declared.
Occupancy and agretopicity#
Both come from the same competitive-binding equilibrium and differ in what the peptide is competing against — the allele’s own self-ligandome for one, its own wild type for the other:
Three properties of \(\theta\) come out of the physics rather than being imposed on it. A
mutant that does not bind occupies nothing whatever its wild type does, so a binder gate is
automatic. The free-MHC 1 in the denominator is exactly the pseudocount Łuksza applies to both
dissociation constants — which is why that \(\varepsilon\) carries units of inverse
concentration. And \(\theta\) is bounded in \([0,1]\), so the four-decade tail of the raw
ratio cannot set a slope.
\(\theta\) is additive to the binder %rank, not redundant with it: a %rank says where a
peptide sits in its allele’s own distribution, occupancy says how much groove it actually holds, and
an allele with a permissive groove has a large self load its candidates must out-compete. Fitted
together on the retired ten-screen grand-corpus fit, binder held z +6.5 while occupancy carried
z +3.6 to +3.8, stable
across \([P]\) from 1 to 1,000 nM.
\(\phi\) does not resolve — z −0.48, and 0.4979 on its own. Neither do the raw ratio, the pseudocount amplitude, a logistic squashing of it, or gating on anchor substitutions and genuine binders; the benchmark records all seven parameterisations. It is emitted as a column and is not a term of the fitted model.
Limits#
Held-out performance is well below in-fit performance. Leave-one-twin-group-out on the
gfellergroup gave 0.5781 under the retired ten-screen fit, because Gfeller and Gfeller-GBM share 96.5 % of their peptides — which is whyGfeller_GBMleft the corpus for v11. Quote the twin-group column: the shipped artifact’scv_twinmean over decided screens is 0.6957.Not every mimicry channel is established in direction.
viral_tcrandthymus_tcrflip sign in 22 % and 35 % of bootstrap resamples, which is whyEPICdoes not carry them. Its own sign stabilities are inbench/results/epic_recognition_terms.md.The prior is a property of your candidate pool, not of biology. The fitted prevalence is 0.18 % (597 positives of 339,599 fitted rows), which is how these screens were assembled. Supply your own.
--score gateis the two-term product-of-sigmoids, kept for when a candidate failing either axis should not be rescuable by the other.
The four blocks, one page each
Each letter of EPIC has its own page. Read this one for how the nine terms combine; read those
for what a single term is and how to get it.