Presentation: the P of EPIC#

What this page is for. Two of the nine terms fitted by mhc1.*.neoantigen are presentation, and they are not the same quantity measured twice. This page says what each one asks, why both are needed, and which call produces which.

The one-line version. binder asks whether a peptide out-competes the self peptidome its allele normally loads — allele-relative. log10a asks how many copies reach the surface at a stated free-peptide concentration — absolute. Winning a groove does not imply reaching the copy number a T cell needs, and reaching it does not imply winning the groove: they sit at Spearman \(\rho\) = +0.7431, not 1.

The two terms#

term

what it is

binder

\(-\log_{10}\) of the calibrated combined %rank — the Fisher statistic over the presentation rank and the Potts affinity rank, read as a percentage. Allele-relative. The presentation rank alone is the separate key pres, still computed and not fitted.

log10a

the density axis on its log-odds scale, \(\log_{10} a\) for \(a = [P]/K_d\). Exactly the logit of mhcmatch.rank.occupancy(), since \(\mathrm{occ}/(1-\mathrm{occ}) = a\) identically. Absolute, and defined without a wild type.

Occupancy itself is still computed and emitted; a probability entered linearly into a log-odds model is the mis-specification, not the axis.

Getting a number#

mhcmatch binder NLVPMVATV                       # one number: the combined binder %rank
mhcmatch restriction NLVPMVATV --calibrated     # which allele presents it, ranked
mhcmatch affinity NLVPMVATV --allele 'HLA-A*02:01' --wt NLVPMVATL   # IC50 + agretopicity
mhcmatch explain NLVPMVATV --allele 'HLA-A*02:01'                   # every term, side by side
store = mhcmatch.Store.from_pmhc(tier="shortlist", species="human")
store.binder_score("NLVPMVATV", alleles="HLA-A*02:01,HLA-B*07:02", cls="mhc1")
store.restriction("NLVPMVATV", diffuse=True, calibrated=True)   # %rank / P(present) / band

Prefer binder_score to a raw model score. It is the calibrated, cross-allele-comparable single number, and it is a soft-AND: strong only when a peptide is both presented and binds.

What is behind the rank#

Three pieces, each with its own reason to exist:

A learned anchor model, per allele. Position-specific residue log-odds against a chosen null (mhcmatch.diffusion.AnchorModel). The null is the main per-task knob: "ligand" asks which allele, "proteome" asks is it presented at all.

Cross-allele diffusion, so rare alleles are not guesses. An allele with a handful of ligands borrows its motif from groove-similar frequent ones over the 34-mer pseudosequence. A rare class-II allele’s motif is 67–77 % borrowed. am.score(..., raw=True) disables the borrowing; the shipped path leaves it on.

Per-allele calibration, so two alleles can be compared. A raw score is on its own allele’s scale and nothing more. mhcmatch.calibrate.RankCalibrator turns it into a %rank against a background of that allele’s own distribution, which is what makes P(present), the band and any cross-allele ranking meaningful. Building the backgrounds is the real cost of a cold run — about 5 s for the presentation and affinity calibrators, ~45 s for the binder calibrator — and it is cached for the life of the process and on disk under $MHCMATCH_CALIBRATION_CACHE.

Class-II absolute thresholds require a background conditioned on the query’s peptide length: cal.percent_rank(allele, score, length=len(peptide)). The default 10,000 draws and seeded random stream are unchanged. Calibration uses mhcmatch.diffusion.AnchorModel.score_many() to score bounded batches of core frames, without retaining query vectors on the fitted model. For direct scoring, model.score_many(peptides, allele) returns scalar-identical scores in input order. Its batch_bytes argument bounds the temporary array working set; no Python worker pool or multithreaded BLAS operation is added. mhcmatch.calibrate.RankCalibrator.clear() releases loaded calibration distributions between independent work units.

Native searches default to one thread and accept a single explicit threads budget. Use threads=0 only to opt into available CPUs (including process affinity and SLURM’s task allocation). When processes own parallelism, set numerical-library thread limits in the parent before spawning/importing workers and keep each worker’s native search budget at one. The Nextflow and Snakemake integrations apply these limits before starting the CLI.

Bands and cut-offs are per class#

NetMHCpan calls class I strong at %rank <= 0.5 and weak at <= 2.0; NetMHCIIpan calls class II strong at <= 2.0 and weak at <= 10.0. A single number is therefore the weak cut in one class and the strong cut in the other, which is why the tiers are named rather than numeric, and why band takes the queried class’s own cut-offs. Nothing is dropped by default. Full table: What gets dropped, and what can never be.

Class II: the register is chosen, not assumed#

A class-II ligand is longer than its 9-mer core, so the model must decide which nine positions sit in the groove. AnchorModel fits a mixture over registers by EM and scores the register it chose — and the anchors, tcr_facing and the agretopicity comparison all report from that register rather than from a heuristic one, so the reported core is the core that was scored.

footprint="anchor" is never right on the predict path: MHC2_ANCHORS = (1,4,6,9) reaches only the restriction / vote helpers, while build_scorer ships adaptive, which maps all nine core positions. A benchmark arm left at anchor understates mhcmatch.

Where to go next#