Skip to content

Repository files navigation

mhcmatch

mhcmatch — which neoantigens are presented, and which ones a T cell will see

PyPI CI docs python license

Pure Python, no compiled extension beyond the seqtree search core, MHC-I and MHC-II, human and mouse. Every reference dataset is fetched from isalgo/pmhc_data on first use, so a fresh pip install runs every example in this file with no manual downloads.

pip install mhcmatch
mhcmatch bootstrap                                   # optional: pre-fetch the ligand panel (~16 MB)

Nothing else has to be downloaded by hand — every reference table is fetched on first use. bootstrap only decides when, which matters on a compute node with no outbound network; the four staging tiers are under Data.

The library examples below run from a plain pip install mhcmatch. The Nextflow pipeline pins its own version; see Deployment.

Optional extras. The base install is seqtree, numpy and huggingface_hub — nothing heavy, and every model that ships by default runs on it.

extra pulls needed for
mhcmatch[esm] torch, transformers only the esm64_glm recognition head. Downloads a ~2.4 GB ESM2 checkpoint on first use
mhcmatch[structure] tcren the structure-based ΔΔG head
mhcmatch[precursor] vdjmatch precursor-frequency estimates
mhcmatch[notebooks] marimo, polars the worked examples in notebooks/
mhcmatch[logo] logomaker, matplotlib, pandas drawing a motif logo (logo.render). logo.motif returns the matrix on the base install
mhcmatch[stats] scipy fitting your own over-dispersion with portfolio.betabinom_rho, and the exact linear program behind portfolio.linearly_supported. The cassette objective itself is numpy
mhcmatch[viz] networkx not imported by the library — declared for the benchmark repository's promiscuity-graph figure
mhcmatch[docs] sphinx, pydata-sphinx-theme building the documentation

torch is not required to score recognition. The default head is the six-block complement score, which is pure numpy, so a user who never installs [esm] gets a complete fitted model rather than a degraded one — mhcmatch.recognition.score() just works. Asking for the ESM head without the extra raises a named error telling you which extra to install; it never silently drops features and returns a number that looks fine.

from mhcmatch import recognition as rec
rec.default_head("human")            # 'complement' -- no torch involved
rec.score(peps)                      # works on the base install
rec.score(peps, head="esm64_glm")    # ImportError unless mhcmatch[esm] is installed
# rank a donor's neoantigen candidates end to end
mhcmatch rank fasta candidates.fasta --alleles donor.alleles --cls mhc1 --tumor SKCM --out ranked.tsv

Pick your entry point

your question command Python
Which of these peptides does an allele present? mhcmatch predict f.fasta --alleles 'HLA-A*02:01' --cls mhc1 predict.predict_fasta
…keeping only conventional binders mhcmatch predict ... --rank-threshold wb predict.resolve_rank_threshold
…never dropping candidates in a driver gene mhcmatch predict ... --keep-genes 'TP53,KRAS' predict.Keep
…never dropping a validated immunogenic epitope mhcmatch predict ... --keep-epitopes builtin predict.Keep
Which allele presents this peptide? mhcmatch restriction PEP --calibrated store.restriction
Is it a binder at all, one number? mhcmatch binder PEP store.binder_score
What is the IC50, and vs its wild type? mhcmatch affinity PEP --allele A --wt WTPEP store.affinity_model
Will a T cell respond to it? mhcmatch complement --peptides p.txt complement.score
Rank neoantigen candidates for a donor mhcmatch rank fasta ... rank.rank_fasta
How many of the donor's own allotypes present it? (a rank column) predict.Prediction.n_alleles_presenting
…with mimicry risk and what each one resembles mhcmatch rank ... --extended --annotate mimicry.score
Why did this candidate rank there? mhcmatch explain PEP --allele A
What self / viral / bacterial peptide does it mimic? mhcmatch mimics --peptides p.txt mimics.neighbours
Does that mimicry raise or lower the risk, and why? mhcmatch mimicry --peptides p.txt mimicry.score
Has this, or something near it, already been tested? mhcmatch neoag --peptides p.txt mimicry.annotate
Where in the proteome does it come from? mhcmatch source --peptides p.txt --proteome human Proteome.find_sources
Which gene is it from, when the deposit did not say? mhcmatch genes pairs.tsv --out annotated.tsv Proteome.assign_genes
Is this gene on in a normal tissue, and where else? mhcmatch expression GENE --tissue TISSUE --safety expression.lookup
What does this allele's motif look like? mhcmatch logo 'HLA-A*02:01' logo.motif
Which peptides in this protein are presented? mhcmatch scan p.fasta --correction bh store.scan_protein
What is the full MHC-II ligand around this core? mhcmatch span CORE --protein p.fasta ligand.presented_span
Which k of this donor's candidates go in the cassette? mhcmatch cassette select --candidates pool.tsv -k 20 --tol 3 cassette.select
What is this cassette worth, against one from another donor of another size? mhcmatch cassette score --cassettes c.tsv --pool pool.tsv cassette.score / cassette.lam
Build the cassette from ranked candidates mhcmatch cassette build --candidates units.tsv --n0 8 --screen vector.select / vector.order
…spread over allotype and mechanism, not just allotype vector.select(block=…)
Order units I have already chosen, and pick the spacer mhcmatch cassette order --candidates chosen.tsv vector.order
…with the linker already decided, not swept mhcmatch cassette order ... --linker GS10 vector.order(linker=) / vector.assemble
Which linkers are there, and what is each for? mhcmatch cassette linkers vector.LINKERS
Turn the finished cassette into an mRNA mhcmatch cassette build ... --linker GS10 --mrna c.fa vector.mrna
…and a map of it a viewer can draw mhcmatch cassette build ... --map c.tsv --map-json c.json vector.epitope_map
How many independent shots is it worth? (a cassette score column) portfolio.p_at_least / n_effective
Are my own response counts over-dispersed? portfolio.betabinom_rho
Which candidates can no weighted score ever pick? portfolio.linearly_supported
Strip frameshift-prone motifs from the CDS mhcmatch cassette deslip cassette.fa vector.slippery_sites
Split a peptide into anchor / TCR-facing parts mhcmatch decompose PEP store.decompose
How viral-like is it, as a soft sum not a cutoff? luksza.viral_r

Full command reference, grouped by task: the CLI page.

predict and rank fasta drop nothing by default. --rank-threshold takes sb / wb / none / a percentage, and the tiers are class-aware because a bare number cannot be: 2.0 is the weak cut for class I and the strong cut for class II. Measured on class II, a flat 2.0 keeps 0 of 56 scored pairs, discarding the best window at %rank 2.364 — an empty table, returncode 0.

Two whitelists, because they make two different claims. --keep-genes 'TP53,KRAS' keeps every candidate in a driver gene; --keep-epitopes builtin keeps every candidate that is one of the 23,299 peptides an assay has called immunogenic; --keep-mismatch 1 widens that to one substitution. Neither is dropped by any --rank-threshold, and matched rows carry keep = 1 plus keep_reason (gene / epitope / epitope~1) — because a row kept for its gene is not evidence about its peptide, and one flag cannot say which claim held. builtin is a pre-built seqtree index: it reloads in ~1 ms, so a thousand-sample run pays a read rather than a build and has no cache to race on.

Two module names for the cassette, because they are two jobs. cassette chooses the units and scores a finished construct (select, score, lam); vector assembles one that has been chosen (select over allotype slots, order, assemble, mrna, epitope_map, LINKERS). The CLI hides the split behind one mhcmatch cassette verb; the Python column above does not, which is why both names appear in it.

Chaining rank into a cassette takes two flags--prefix renames the column the next step looks for, and rank's peptide is the minimal epitope where the assembly wants the long window. The tested four-command chain is on the CLI page.

predict is the presentation axis (is it presented at all, the NetMHCpan %Rank_EL analogue); restriction is the specificity axis (which allele). They answer different questions and a peptide can top one and not the other — NLVPMVATV is unambiguously A*02:01-restricted yet bands mid-pack against A*02:01's own ligands.

Model names

EPIC — the shipped model, one letter per block

Expression, Presentation, Immunogenic Complementarity. Four letters, four blocks, entered in the pipeline order the table below gives — presentation first, then expression — so a later block's coefficient is what that term is worth after the earlier ones, not in competition with them. Ridge with an unpenalised per-screen intercept at tau = 0.25; sd, z, p and the 95 % CI are a 400-resample cluster bootstrap over (patient, screen).

letter block columns
P presentation binder, log10a
E expression expr_lvl, expr_norm
I immunogenic — physchem C_phys_buried, C_phys_charge
C complementarity — corpus C_corpus_thymus, C_corpus_self, C_corpus_viral

The coefficients are not written down here. They moved with every refit and this table went on quoting a superseded set for a full release each time. They are printed in full, with bootstrap intervals, in docs/models.rstgenerated from the artifacts on every docs build, which is the only form of that table that has ever stayed correct. Or ask the artifact directly, which is the record:

mhcmatch rank --coefficients                         # every term, its block, its coefficient
mhcmatch rank --holdout                              # held-out AUROC, the grouped CVs, the corpus
mhcmatch rank --coefficients --species mouse         # the mouse class-I fit, not the human one
mhcmatch rank --coefficients --cls mhc2 --species mouse
from mhcmatch import rank

rank.models()                       # every shipped fit: model_id, version, release, rows, positives
a = rank.aggregate("mhc1", "human")  # the artifact itself
a["model_id"], a["version"], a["release"], a["features"], a["coef"], a["fit"]["rows"]

Eight shipped fits, one per cell, and a missing one still refuses

There is one artifact per (cls, species, mode) and no fallback: asking for a combination that was never fitted raises rather than scoring it with another fit's coefficients. From 1.15.0 every cell is fitted, so nothing refuses today --- the branch stays because a cell can leave the registry again and the refusal is what has to survive that.

The two mode values are two questions, not two settings, and from 1.15.0 the four pathogen fits are themselves drawn from two corpora with two definitions of a negative: mhc1.human.pathogen keeps eluted ligands with no positive record, and the other three use peptides that were assayed and did not respond. Read fit.deposit on the artifact rather than guessing from the mode.

model_id model version release terms rows positives intercepts AUROC how that AUROC is measured
mhc1.human.neoantigen 12 1.15.0 9 339,595 594 7 per screen 0.7094 leave-one-screen-out, mean
mhc1.mouse.neoantigen 6 1.15.0 9 921 379 61 per reference 0.6335 in-sample, within reference
mhc2.human.neoantigen 2 1.15.0 6 1,112 656 157 per reference 0.6020 in-sample, within reference
mhc2.mouse.neoantigen 4 1.15.0 6 468 177 30 per reference 0.5741 in-sample, within reference
mhc1.human.pathogen 2 1.15.0 2 16,790 7,002 1 per corpus, global 0.5988 in-sample, pooled off the logit
mhc1.mouse.pathogen 2 1.15.0 2 10,404 2,196 1 per corpus, global 0.5561 in-sample, pooled off the logit
mhc2.human.pathogen 2 1.15.0 3 7,946 5,148 1 per corpus, global 0.5824 in-sample, pooled off the logit
mhc2.mouse.pathogen 2 1.15.0 3 11,725 3,324 1 per corpus, global 0.6446 in-sample, pooled off the logit

The AUROC column is three protocols, not one. Human class-I neoantigen spans seven independent screens, so it holds one out whole and is scored on it. The three single-deposit neoantigen fits have no second screen to hold out, so what they record is an in-sample within-reference figure — the slope term alone, with the fitted per-reference intercepts excluded from the score, macro-averaged over the references carrying at least three of each class. The four pathogen fits are whole-corpus GLMs with one global intercept and no grouping unit at all, so they have neither a screen to hold out nor a per-reference intercept to exclude, and report in-sample, pooled off the logit — each read against its own prevalence, which runs from 0.0691 to 0.6479 across the four. Do not read the column down, and do not average it.

docs/models.rst is the full record — every coefficient with its bootstrap interval, the per-screen held-out table, what each model was fitted on, and the caveats that come with each one. It is generated from the artifacts on every docs build; this table is generated by the same code and pinned by tests/test_modeldoc.py.

Four are neoantigen fits and four are not. mhcmatch rank --epitope pathogen selects a second immunological mode for a peptide the host does not encode — a viral or bacterial epitope. It drops the expression block, which is undefined rather than missing with no host transcript, and reads which corpus channels it carries off the artifact's own features list rather than off the mode. mhc1.human.pathogen ships from 1.14.0 and the other three cells from 1.15.0.

The three 1.15.0 fits need a candidate set. Each was fitted on the best-presenting allotype among those the peptide's own record supports, so rank fasta --alleles A,B and rank pairs --allele-panel A,B work and a bare rank pairs is refused by name rather than scoring a shifted column. See docs/models.rst.

release is not the running library version. It is the package version the fit was accepted in, and it is stored rather than derived, because a manuscript pins a fit while the library keeps moving underneath it: mhc1.human.neoantigen v12 (release 1.15.0) is a citation and mhcmatch 1.13.0 is not. That fit is pinned and does not get regenerated — its coefficients, bootstrap, loo and both grouped CVs are what the manuscript cites.

All four (cls, species) neoantigen cells are fitted from 1.12.0, and five of the eight (cls, species, mode) cells ship from 1.14.0. An unregistered cell still refuses rather than being served a neighbour's coefficients, which is the mistake the lookup exists to prevent — and mhcmatch models --all prints which cells ship, marking an unfitted one -- rather than leaving it looking like a broken install. A tumour neoantigen and a pathogen epitope are two mechanisms rather than two values of one covariate, which is why mode is a key and not a covariate.

mhcmatch rank --native-corpus overrides this for the two host components (self, thymus), scoring them against the query species' own tables. All twelve tables ship, so it is a routing switch and nothing is fetched. It is off by default and warns on every run: the mouse thymic table is the H-2b motif and correlates with the human one at r = 0.3245 with thinness ruled out, and every shipped mouse artifact was fitted against the human tables, so under the flag its coefficients meet a column they never saw. viral is not a host compartment and stays human.

A mouse run reads the human corpus references — all three components, both classes. mimicry.reference_species(species, component) routes all three mouse components to human, so a mouse query is matched against the identical mhc1|{thymus,self,viral}|human|3 tables the human artifact scores against. The mouse deposits are too small and too groove-skewed to be a reference: the thymic one is one haplotype (every one of its 2,663 annotated class-I peptides is H-2Db or H-2Kb), the viral one samples 9 allotypes against human's 129, and self agrees across species at r = 0.9990 anyway. Nothing is trained on human data — a corpus channel is a k-mer density lookup, and all nine coefficients in aggregate_mhc1_mouse.json are fitted on mouse neoantigens. Human paths are unchanged. Expression is not covered by this and must not be: human and mouse organs and tumours are different tissues, so expression.py stays species-keyed at every rung. See docs/corpus.rst.

What "mouse" means, component by component. Only presentation/binding and expression rest on mouse-derived models and mouse references. Physicochemistry is species-free by construction, the whole corpus block reads human tables, and the affinity head is pseudosequence-conditioned rather than per-species — the coefficients are fitted on mouse throughout, but most of what they index is not. docs/models.rst carries the table, one row per component, and it is the single place that statement lives.

The two class-II fits carry six terms and no corpus block. A C_corpus_* channel is a density over a reference set of peptides — thymic, self, viral — and all three deposited sets are class I; contracting a 15-mer class-II register against a 9-mer density asks the wrong question rather than answering it weakly. So the block leaves the design, blocks lists three entries, and the corpus-geometry keys are absent rather than declared-and-unused.

The human class-II fit is a CD4 response model over human self proteins. 143 of its 1,112 rows are a cancer and 260 are healthy donors; the largest single disease is type 1 diabetes at 364 rows. The composition ships inside the artifact as fit.population. Ranking class-II tumour neoantigens with it is an extrapolation from that population.

rank.AGGREGATE_BLOCKS is the same block structure at runtime. The letters are a mnemonic for the blocks, not the fitting order — presentation enters before expression, and every conditional coefficient is reported against that order.

Python

import mhcmatch
from mhcmatch import complement, known, mimics

store = mhcmatch.Store.from_pmhc(tier="shortlist", species="human")   # auto-fetched from HF, cached

store.restriction("NLVPMVATV", calibrated=True)      # ranked alleles + %rank / P(present) / band
store.binder_score("NLVPMVATV")                      # the single-number binder index
store.scan_protein(my_protein, cls="mhc1")
store.decompose("NLVPMVATV")                         # anchor / TCR-facing split, with X masks

aff = store.affinity_model("mhc1")
aff.predict_ic50("NLVPMVATV", "HLA-A*02:01")             # 18.9 nM (shortlist tier)
aff.amplitude("NLVPMVATL", "NLVPMVATV", "HLA-A*02:01")   # Kd_WT/Kd_MT (Łuksza eq. 9)

complement.score(peptides)                           # vectorised: pass the list, not a loop
complement.posterior(peptides, prior=4.2e-4)         # the log-odds carries NO prior; supply yours
complement.score(peptides, species="mouse")          # separate table; the hosts are never pooled

from mhcmatch import expression, rank
expression.gene_level("Trp53", species="mouse")      # FANTOM5 tissues + the syngeneic models
rank.aggregate("mhc1", "mouse")                      # the mouse fit; ("mhc2","human") raises

known.lookup("GILGFVFTL")                            # -> 'neoantigen': the FIRST set in
                                                     # SET_NAMES containing it, not the only one
mimics.neighbours(peptides, ref_sets, threads=0)     # threaded C++ neighbour search

pm = mhcmatch.Proteome.from_hf("human")
pm.find_sources(peptides, max_subs=1, threads=0)     # batch; find_source() is the single-query form
pm.assign_genes(peptides)                            # {peptide: [gene, ...]}, ties kept in full
pm.wildtype("NLVPMVATV")                             # the WT counterpart, for agretopicity

Full API: antigenomics.github.io/mhcmatch. Twelve marimo notebooks in notebooks/ run the workflows end to end on whole published deposits (pip install 'mhcmatch[notebooks]').

The rest

The sections that answer "why does it do that" rather than "how do I run it" are in README_EXT.md, so this page stays the length of a page:

Composition is not ranking why a cassette is a set problem, what lam compares, and reading one patient against a cohort
What rank costs where the time goes, and which stages ship off
Caching calibration across jobs the one environment variable a cluster run wants
Batch and threads read before scripting a loop
The two axes presentation and recognition, and everything each of them carries
Presentation and affinity are not the same term why both are fitted and why binder is neither
Data the four staging tiers, and what each fetches
Deployment the three integrations — Nextflow, Snakemake, and an overlay for a pipeline you already run — containers and SLURM
Benchmarks what is measured where, and naming a class-II restriction

Development

bash setup.sh            # repo-local .venv + editable install (uses a sibling ../seqtree if present)
bash setup.sh --tests    # + pytest
pytest -q

Theory and derivations are in the manuscript repo (latex_sn/, Methods and Supplementary Note 1); what is planned and what is in flight is in ROADMAP.md and CHANGELOG.md.

About

Ultrafast MHC binding prediction and epitope's mimic search. Immunogenicity scorer. Designing personalised cancer vaccine cassettes

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages