PLMSommelier
The last layer of a protein language model is almost never the best one. This tool finds the layer that is, and hands you back a truncated model.
Implements the tool described in Task- and dataset-specific information in protein language models, which probed 13 PLMs across 15 downstream tasks and found the deepest layer won in only 17.9% of cases.
Install
uv tool install plmsommelier # isolated, no venv to manage yourself
# or: pipx install plmsommelier
# or: pip install plmsommelier # into a virtualenv
CPU vs. GPU torch (Linux)
PLMSommelier itself is CUDA-agnostic -- it never links CUDA directly, so any
torch build works. But on Linux, plain pip install plmsommelier resolves
torch's default index, which is a CUDA build pulling several GB of nvidia-*
packages even on a machine with no GPU. Install torch yourself first if that's
not what you want:
# CPU-only (much smaller download; also what you want on a machine with no GPU)
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install plmsommelier
# GPU: pick the wheel matching your driver, then install as usual
pip install torch --index-url https://download.pytorch.org/whl/cu126 # CUDA 12
pip install torch --index-url https://download.pytorch.org/whl/cu130 # CUDA 13
pip install plmsommelier
macOS wheels are CPU-only already (Apple Silicon uses mps automatically at
runtime instead), so the plain pip install plmsommelier is correct there --
no separate torch step needed. If a cuXXX install fails or a model refuses
to use your GPU, check that your driver actually supports the CUDA version
that wheel targets; a driver too old for the chosen wheel is the usual cause.
Quickstart
examples/fluorescence_sample.csv ships in the repo (a 500-row subsample of
the TAPE fluorescence benchmark -- see examples/README.md), so this runs in
about a minute on CPU with no data of your own needed:
plmsommelier suggest examples/fluorescence_sample.csv facebook/esm2_t6_8M_UR50D \
--task regression --out ./my-esm-truncated
data and model are positional (DATA MODEL); --data/--model work
identically if you prefer named flags.
model facebook/esm2_t6_8M_UR50D
dataset fluorescence_sample (regression, pearson, knn probe)
data 400 train / 100 val
best layer 0 of 6 (0% depth) pearson = 0.4238
last layer 6 pearson = 0.2369
gain over last +78.9%
seed agreement 100% (layers chosen across seeds: [0])
confidence moderate (plateau agreement 100%, separation 1.51x, seed spread 0% of depth)
layer performance (pearson, +/- 1 sd across 5 seeds):
0 +0.4238 ########################################----- +/-0.0537 <- best
1 +0.3764 ####################################--------- +/-0.1012
2 +0.3770 ####################################----- +/-0.0634
3 +0.1572 ###############------ +/-0.0736
4 +0.0432 ####---- +/-0.0519
5 +0.2014 ###################------- +/-0.0824
6 +0.2369 #######################------ +/-0.0710 (last)
(Layer 0 is the token embedding output before any transformer block --
a real, if unglamorous, answer on this tiny 6-layer model and 500-row sample;
larger models and datasets typically pick somewhere mid-stack. See the paper
for what "best layer" looks like at scale.)
./my-esm-truncated is a normal HuggingFace model directory holding only the
chosen number of blocks. It loads anywhere the original did, runs faster, and
scores at least as well on your task:
from transformers import AutoModel, AutoTokenizer
model = AutoModel.from_pretrained("./my-esm-truncated")
tok = AutoTokenizer.from_pretrained("./my-esm-truncated")
T5-encoder checkpoints (Ankh, ProtT5, ProstT5, ...) load via
AutoModelForTextEncoding instead of AutoModel. You don't need to remember
which is which: the exact loading snippet for whatever model you just
truncated is always written into the generated {out}/README.md.
Any HuggingFace protein language model works — just pass its id to model.
Checkpoints that ship custom modeling code need --trust-remote-code. See
Extending to a custom PLM below for models that
need more than that.
Python API
The CLI is a thin wrapper around five importable functions
(plmsommelier/__init__.py), wired together in exactly this order by
cli.py::suggest_layer if you'd rather read one function than five:
from plmsommelier import embed_layers, load_dataset, load_model, save_truncated, select_layer
plm = load_model("facebook/esm2_t6_8M_UR50D")
ds = load_dataset("examples/fluorescence_sample.csv", task="regression")
train_embeddings = embed_layers(plm, ds.train_seqs)
val_embeddings = embed_layers(plm, ds.val_seqs)
result = select_layer(ds, train_embeddings, val_embeddings, model_name=plm.model_id)
print(result.summary()) # the block printed by the CLI
print(result.curve_plot()) # the ASCII per-layer curve
result.to_dict() # what --json writes out
save_truncated(plm, result, "./my-esm-truncated")
Result (plmsommelier.select.Result) carries the full per-layer curve
(result.curve), the per-seed scores behind the stability check
(result.seed_curves, result.layer_sigma), and the confidence verdict --
see Confidence and what to do about it.
Input format
A CSV with sequence and a label column:
| column | required | notes |
|---|---|---|
sequence |
yes | amino acid sequence |
label / labels / Y |
yes | the target (checked in that order, or pass --label-col) |
ID |
no | not used by plmsommelier; keep it for your own bookkeeping if you like |
split |
no | train / valid (or val); generated if absent |
--task (regression, classification or multi-label) is inferred from
the label column when that's unambiguous: multiple label columns ->
multi-label, non-numeric or exactly two distinct values -> classification, a
float dtype -> regression. A whole-number column with more than two values
could be class codes or an integer-valued regression target, and inference
refuses to guess there -- pass --task explicitly.
--max-seq-len (default 2000 residues) drops longer sequences before
splitting or subsampling, so a single outlier protein can't end up alone in
an unbounded batch -- attention memory is quadratic in length. Pass 0 to
disable it.
Device is auto-detected -- CUDA, else Apple Silicon's mps, else CPU -- and
can be forced with --device cpu / --device cuda / --device mps. A
visible CUDA device only counts if the installed torch build actually ships
kernels for it: an older card (e.g. anything pre-Turing) against a build that
dropped support for it would otherwise crash on the first forward pass with
no kernel image is available for execution on the device instead of just
running on the CPU. Auto-detect falls through with a warning in that case;
pass --device cuda explicitly to force it anyway.
CLI reference
plmsommelier suggest --help is always authoritative -- every flag is
generated directly from suggest_layer's Python signature and docstring, so
it can't drift out of sync with this table.
| flag | default | what it does |
|---|---|---|
data / --data |
(required) | CSV path (positional or named) |
model / --model |
(required) | HuggingFace model id or local path |
--task |
inferred | regression / classification / multi-label |
--label-col |
auto-detected | override label column detection |
--probe |
knn |
knn or lr (linear probe) |
--k |
10 |
neighbours for the kNN probe |
--sample |
5000 |
row budget across train+val; >=1 absolute, (0,1) a fraction |
--max-seq-len |
2000 |
drop longer sequences before splitting/subsampling; 0 disables |
--n-seeds |
5 |
resampling repeats for the confidence check; 0 disables it |
--seed |
42 |
random seed |
--tolerance |
0.02 |
relative score band treated as a tied "plateau" |
--device |
auto | cpu / cuda / mps |
--cache-dir |
HF default | HuggingFace cache directory |
--trust-remote-code |
False |
needed for checkpoints with custom modeling code |
--out |
(none) | write the truncated model here |
--progress / --no-progress |
on | embedding progress bar |
Flags shared by every command, handled by the top-level launcher:
| flag | what it does |
|---|---|
--json PATH |
also write the result as JSON |
--quiet |
suppress the rendered summary/curve output |
--traceback |
let exceptions propagate instead of printing error: ... and exiting 1 |
Extending to a custom PLM
There is no model registry -- model takes any HuggingFace id or local path,
and works with zero configuration for most architectures. Reach for the
options below only as far as your checkpoint actually needs, in order of
how rare each one is:
-
Nothing.
load_model(plmsommelier/model.py) is architecture-agnostic: it finds the transformer blocks and final norm by attribute name, detects the sequence-length ceiling from the tokenizer/config/causal-mask buffer, and picks the rightAuto*loading class. Most encoder PLMs on the Hub just work. -
--trust-remote-codefor checkpoints that ship custom modeling code in their own repo (anauto_mapinconfig.json). Same flagtransformersitself uses. -
A pretraining-convention entry in
_QUIRKS(plmsommelier/model.py), keyed onconfig.model_type, for models whose config doesn't record how they were actually pretrained -- HuggingFace has no field for "residues are space-separated tokens", for instance. Supported keys:key effect space_joinjoin residues with spaces before tokenizing (ProtTrans-style) residue_mapa str.translatetable applied to the sequence first (e.g. rare residues ->X)prefix_texttext prepended before tokenizing (e.g. a mode/direction token) dtypeforce this torch dtype instead of the auto-detected default encoder_onlyforce AutoModelForTextEncoding(True) vs.AutoModel/AutoModelForCausalLM(False), overriding theis_encoder_decoderconfig checkT5/BERT/ALBERT (the ProtTrans family) are the worked example already in the table:
"t5": dict( space_join=True, residue_map=str.maketrans("UZOB", "XXXX"), dtype=torch.bfloat16, # T5 activations overflow in fp16 encoder_only=True, # loads T5EncoderModel; the decoder is never built ),
Add a family the same way: an entry keyed on its
model_type(AutoConfig.from_pretrained(your_model).model_typetells you the key), with only the keys it actually needs. -
A
_REGISTRY_PACKAGESentry for architectures that live in a third-party package rather than intransformersitself -- these checkpoints carry noauto_map, so--trust-remote-codecan't reach them; the package must be imported first so it registers itself with theAuto*classes.multimolecule/proteinbertis the current example: installplmsommelier[multimolecule], andmodel.pyimportsmultimoleculebefore loading wheneverconfig.model_type == "proteinbert". Add a new package the same way:"model_type": ("import_name", "pip_extra_name"), plus an extra inpyproject.tomlif it isn't already installed alongsideplmsommelier. -
No fork needed for a one-off.
_QUIRKSand_REGISTRY_PACKAGESare plain module-level dicts -- from your own script,import plmsommelier.model as m; m._QUIRKS["your_model_type"] = {...}before callingload_modelworks without touching this repo. Treat that as a private escape hatch, though (the leading underscore is deliberate) -- if it's a real, reusable family, a PR adding it to the table is the better home for it.
Whatever route you take, verify a new family the way tests/test_model.py
verifies the built-in ones: the truncation round-trip (save_truncated then
reload reproduces the same layer's output), sequence-length detection not
silently truncating valid input, and the final-norm invariant --
see CONTRIBUTING.md's invariants list for what each of
those actually guards against.
Tested models
Checkpoints below were loaded and probed end-to-end (load_model +
embed_layers) against the real weights, not just read from the code — either
by the repo's own real-weight test fixtures (tests/test_model.py) or by a
manual smoke test. Anything not listed still has a good chance of working —
load_model is architecture-agnostic — it just hasn't been verified here yet.
| Model | HuggingFace ID | Status |
|---|---|---|
| ESM-2 | facebook/esm2_t6_8M_UR50D (+ larger) |
✅ confirmed (test suite) |
| ESM-1b | facebook/esm1b_t33_650M_UR50S |
✅ confirmed (manual) |
| IgBert | Exscientia/IgBert |
✅ confirmed (manual) |
| ProGen2 | hugohrban/progen2-small (+ larger) |
✅ confirmed (test suite) |
| proteinbert (multimolecule) | multimolecule/proteinbert |
✅ confirmed (test suite) |
| RITA | lightonai/RITA_s (+ larger) |
❌ fails — tokenizer ships with no pad, eos, or unk token; load_model refuses to guess one |
ProtBert, ProtAlbert, ProtT5, Ankh, ProstT5, and ProtGPT2 are handled by name
in _QUIRKS but have no real-weight test coverage yet — treat them as
likely-to-work, not confirmed.
Confidence and what to do about it
Every run redraws the training data (and, where there's enough of it, the
validation data) --n-seeds times and re-scores every layer, to check
whether the chosen layer survives resampling. That feeds a confidence
verdict -- high, moderate, or low -- printed as part of the summary and
recorded in --json output. unmeasured means the check wasn't run at all
(--n-seeds 0), which is different from low: low means "we checked, and
the pick isn't stable"; unmeasured means "we didn't check".
The verdict is the worst of three independent signals, not an average, so one strong number can't paper over another weak one:
- seed agreement -- how often resampling lands back inside the same plateau (the tolerance-band region of tied-best layers), not just on the exact same layer. Two seeds picking neighbouring, statistically tied layers count as agreement, not disagreement.
- peak margin -- how many (paired) standard deviations separate the plateau from the best layer outside it, across resamples. This is a resampling-stability statistic, not a hypothesis test: seeds are overlapping subsamples of one dataset, not independent draws, so no p-value is implied.
- seed spread -- how far, on average, resampled picks land from the chosen layer, as a fraction of the network's depth.
A low verdict prints a warning block to stderr (not stdout, so --json
and piped output stay clean) with concrete, run-specific hints. The most
important thing to know before reading them: raising --n-seeds measures
confidence more precisely -- it does not raise it. Only one of the following
actually reduces variance:
- Raise
--sample, if the hint says there's headroom (the tool tracks how many rows were available versus used). Score variance falls roughly as1/N, so ~3x the data tightens the estimate by roughly1.7x. If you're already using every row in the file, this can't help -- the hint says so explicitly rather than telling you to raise a flag that won't do anything. - Try
--probe lr(or raise--kfor the kNN probe). A linear probe has much lower variance than kNN at typical sample sizes; two probes agreeing on a layer is stronger evidence than either one's confidence number alone. - Check the validation set. Below roughly 50 val rows, none of these
statistics are trustworthy --
--sampleand the train/val split both drive this. - Check the data for heavy class imbalance, a near-constant label
column, or a
splitcolumn that leaves a tiny validation side. - Accept the plateau. If the curve is genuinely flat, low confidence is the correct answer, not a problem to fix -- the tool already returns the shallowest layer in the tied region, which is the smallest and fastest model at equal performance.
Development
See CONTRIBUTING.md for the dev install and workflow, and RELEASING.md for how a release is cut and published.
pip install -e ".[dev]"
pytest tests -q # full suite
pytest tests -q -m "not weights" # no model downloads needed
Citation
@article{joeres2026taskdataset,
title = {Task- and dataset-specific information in protein language models},
author = {Joeres, Roman and Senatorov, Ilya and Kolchina, Anastasia and
Klakow, Dietrich and Kalinina, Olga V.},
journal = {arXiv preprint arXiv:2608.12090},
year = {2026}
}
See CITATION.cff for the software-citation form.
License
MIT — see LICENSE.
Release files for plmsommelier 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| plmsommelier-1.0.0.tar.gz | 93.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| plmsommelier-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 135.9 kB
Release files / plmsommelier-1.0.0.tar.gz
| Download URL | plmsommelier-1.0.0.tar.gz |
|---|---|
| Size | 93.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f95275c27a7b220e38267fb42904320ef7d78ed6095bdad589f5ae599da00371
|
|
BLAKE2b-256 checksum How to use checksums |
5735bcdf4424eb42192de18f49d07fbe00af4ac37d343a2253e8a45aef723d23
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logRelease files / plmsommelier-1.0.0-py3-none-any.whl
| Download URL | plmsommelier-1.0.0-py3-none-any.whl |
|---|---|
| Size | 42.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3ddf4434648cb870ea2b1eba7737b88746605a2f15066f541bb3bdb4e732b3b1
|
|
BLAKE2b-256 checksum How to use checksums |
feb5e3da4766228bb5cd84f3d964d134ebe5747e319097a14c1f7c31e44cef72
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency log