Repertoire signatures#
A signature is a fixed-order, name-addressed feature vector for one repertoire, on a scale a
downstream model can consume without fitting a scaler of its own. vdjtools emits the
statistics half, vsig; the geometry half, rsig, comes from mirpy and shares this contract exactly.
It is built in three stages, and a corpus — a large published reference collection of repertoires — fixes the last two:
raw features -> winsorization bounds -> rotation + per-PC scaling -> vsig
(this sample (all three from one corpus artifact)
alone) + channels, untouched
In plain terms, reading left to right:
Raw features are the ordinary measurements of one repertoire — how diverse, how clonal, which V and J genes, what junction lengths, which isotypes. They come from this sample and nothing else.
Winsorization clips each measurement to a range measured on the corpus, so that one pathological sample cannot dominate everything downstream. Clipping edits a real number, so how much of the row was clipped is always reported — see
winsor_fracbelow.The rotation re-expresses those correlated measurements as uncorrelated axes (principal component s) ordered by how much variation each carries across the corpus, and each axis is put on a robust z-score scale: how far this sample sits from the corpus’s typical one, in robust deviations. That is what makes your matrix and a collaborator’s directly comparable without either of you fitting a scaler.
Channel s sit outside all of this. They are carried in their own units, never rotated and never clipped, because a number whose job is to tell you whether to trust a row must not be mixed into the row.
If you want the underlying measurements back rather than the rotated axes, ask for them:
--named emits the raw groups the rotation is fitted on under their own names. See
Getting the named statistics back.
Quickstart#
# nine corpora are published; name one and it is fetched on first use
vdjtools signature --corpus blood samples/*.tsv.gz -o sig.tsv
# what exactly will I get?
vdjtools signature --corpus blood --components 32 --describe
# pre-warm the cache instead of fetching lazily -- the install-time step
vdjtools corpus --fetch all
# or build your own synthetic corpus, which needs no cohort at all
vdjtools corpus --corpus synthetic-tissue -o /tmp/st.npz
vdjtools signature --corpus /tmp/st.npz samples/*.tsv.gz -o sig.tsv
from vdjtools.signature import vsig, vsig_cohort
from vdjtools.signature.corpus import Corpus, bundled_path
corpus = Corpus.load(bundled_path("synthetic-blood"))
row = vsig({"TRB": trb, "IGH": igh}, corpus)
frame = vsig_cohort({"S1": sample1, "S2": sample2}, corpus, n_jobs=0)
Pick synthetic-blood or synthetic-tissue by which compartment your samples come from; both are
described below, with what they reproduce and what they do not.
Choosing a corpus#
This is the first decision and the one that matters most, so start here. Match the corpus to how your samples were produced, not to what you are testing for.
Your samples are |
Use |
Why |
|---|---|---|
Bulk RNA-seq of blood or PBMC |
|
Fitted on 11,117 real blood samples across 947 study groups. The default choice for peripheral-blood work. |
Bulk RNA-seq of a solid tissue or tumour |
|
Fitted on 21,131 real non-blood samples across 1,934 study groups. Tissue repertoires are shallower and more skewed than blood, and a blood corpus clamps them harder. |
Targeted or amplicon deep TCR sequencing |
|
Fitted on 3,936 amplicon samples. TRA and TRB only – it has no other locus to offer. |
Any of the above, and you need the per-study cap’s effect |
|
The same populations with no cap at 30 samples per study, so the difference is measurable rather than assumed. |
Something none of these describe |
|
Drawn across the measured depth and clone-size range of that compartment, using no cohort sample at all – so they rebuild bit-for-bit anywhere and carry no population you have to accept. |
A reference point rather than a cohort |
|
The pure regimes: what the recombination model emits, and the same under a Zipf rank-abundance clone-size law. |
Two practical consequences:
A real corpus fits real data better, and by a measurable margin. On four TCGA tissue samples at
--components 32, the fraction of the row that the bounds clamped was 0.199 through tissue
against 0.595 through synthetic-tissue – roughly three times less clamping. deep-tcr on the
same samples clamps 0.483 with a median absolute z of 2.1, correctly far out: amplicon TCR is the
wrong reference for RNA-seq, and the numbers say so rather than quietly absorbing it.
Check ``winsor_frac`` before you trust a matrix. vsig:qc:-:winsor_frac is the fraction of the
row the corpus’s bounds clamped. A high value means your samples sit outside the corpus’s range, so
the standardisation is extrapolating – pick a closer corpus rather than proceeding.
A corpus is required#
There is no default. A signature is comparable to another one only if both were rotated through the same corpus, so the artifact has to be named, and the emitted row records which one it was. Two matrices whose column names match but whose corpora differ are not comparable, and nothing about the numbers says so — which is why the choice is not allowed to be implicit.
All nine corpora are published. Four are synthetic – every receptor drawn from the bundled recombination models, so anyone can rebuild the artifact bit-for-bit and no cohort sample is needed to use one – and five are real, fitted on repertoires. Both kinds exist on purpose: a synthetic corpus spans a measured range by construction, while a real one carries the joint structure the generative model does not produce (selection-shaped V/J usage, isotype and SHM structure, and the cross-locus covariance of libraries prepared together).
corpus |
kind |
fitted on |
what it is |
|---|---|---|---|
|
synthetic |
10,000 drawn |
every clone size 1 – what the recombination model emits |
|
synthetic |
10,000 drawn |
Zipf rank-abundance, sampled by multinomial, zeros dropped |
|
synthetic |
10,000 drawn |
a naive/memory mixture across measured blood ladders (TRB richness 74-3,162, |
|
synthetic |
10,000 drawn |
the same across measured tissue ladders (TRB richness 30-2,977, IGH 40-10,352) |
|
real |
11,117 samples |
public bulk RNA-seq blood, 947 study groups, capped at 30 per study |
|
real |
22,441 samples |
the same population with no cap, so the cap’s effect is measurable |
|
real |
21,131 samples |
public bulk RNA-seq non-blood, 1,934 study groups, capped at 30 per study |
|
real |
33,874 samples |
the same population with no cap |
|
real |
3,936 samples |
targeted/amplicon deep TCR, TRA and TRB only |
Artifacts are fetched, not bundled#
At 256 components a vsig artifact is about 10 MB of float32 rotation, and the nine corpora across
both halves are roughly 110 MB. Making every pip install carry all of that in order to use one of
them is the wrong trade, so the wheel ships a few KB of index – corpora.json, naming every
corpus with its size and SHA-256 – and the artifact itself is a GitHub release asset fetched on first
use into $VDJTOOLS_CORPUS_DIR (default ~/.cache/vdjtools/signature) and verified against that
digest.
The resolution order is a local path, then the wheel, then the cache, then the release. A local path always wins, so a caller who fitted their own corpus and passes its path is never served a download of the same name. A download whose digest does not match is deleted rather than cached: a half-written rotation that loads is worse than one that is missing.
vdjtools corpus --fetch all (and mir corpus --fetch all) pre-warms the cache – the
install-time step, since anything not pre-warmed is fetched lazily. The two halves are published by
the library that owns each and share one cache directory, because a vsig/rsig pair belongs in
one place.
Which one? Use blood or tissue when your samples are bulk RNA-seq of that compartment and
deep-tcr for amplicon TCR: a real corpus is the closest reference to real data. Use
synthetic-blood / synthetic-tissue when you need a reference that is reproducible from the
library alone, with no cohort behind it, or when your depths sit outside what the real corpora cover.
naive and memory are the two pure regimes and neither is a bulk sample – naive has no
clone-size structure at all, and memory at a fixed nominal size has a read count of exactly
size × reads_per_clone in every sample – so they are reference points for what selection does to
a rotation, not descriptions of a cohort.
What comes out#
Column names are four colon-separated parts throughout, so one parse()
reads every kind:
vsig:pc:TRB:PC01 a rotated coordinate
vsig:cov:TRB:cstar a channel, carried through untouched
vsig:qc:-:winsor_frac a channel that is not per-locus
Channels are never rotated and never clamped. A provenance number that has been mixed with the measurements it was supposed to qualify is no longer provenance. Read these three before trusting any rotated column of a row:
channel |
what it tells you |
|---|---|
|
The coverage this sample actually attained, in |
|
Why a column is a hole. “This locus is absent” and “this sample is too shallow to reach the
target” are different facts that would otherwise both render as one |
|
What fraction of this row the corpus’s bounds clamped. See Winsorization: by percentile, and one-sided where the metric is. |
vsig:qc:<locus>:v_fallback_frac and :j_fallback_frac are the other two worth reading: an
unrecognised V or J call raises nowhere, because the germline lookup falls back to the maximum
observed distance, so a cohort on Adaptive nomenclature or an older IMGT release yields a fully
populated, entirely plausible, systematically wrong result. These fractions are the only thing that
says so.
A hole is nan, never 0.
Winsorization: by percentile, and one-sided where the metric is#
Bounds are estimated once, on the corpus, at a percentile — not as a multiple of a robust standard deviation. A robust-SD bound is not a fixed amount of probability: it depends on the distribution’s shape, so an 8-SD bound trims nothing at all on a right-skewed count and a third of the corpus on a near-degenerate column.
The side that gets trimmed comes from each feature’s declared support, and never from its
transform:
support |
trimmed |
because |
|---|---|---|
|
top only |
|
|
bottom only |
|
|
both |
|
|
neither |
|
Three raw features declare transform="none" and have three different supports. Deriving the side
from the transform silently trims the wrong end of two of them, which is why support is a
declared field rather than an inference.
Two modes, and the mode fixes how the rotation was fitted#
|
at emit time |
the corpus PCA was fitted by |
|---|---|---|
|
clamp raw features to the corpus bounds, then rotate |
winsorized corpus, median/MAD, plain SVD. Deterministic and bit-reproducible; the robustness sits in a step you can inspect. |
|
rotate, then clamp the PC scores |
|
|
clamp nothing |
whichever of the two the artifact records |
Both bound sets and both percentiles (0.01 and 0.05) are stored, so --winsorize and
--winsor-p are apply-time choices that need no refit.
The clamp is never silent. Clamping is many-to-one: it overwrites a real measurement of a real
sample, and the sample cannot tell afterwards. In the system this replaces, a reference whose centre
was wrong by 57 robust deviations went unnoticed because every affected sample was clamped to the
bound and looked like an ordinary tail case. So every row carries vsig:qc:-:winsor_frac, and a
value near 1.0 means this sample does not belong to this corpus — not that it is unusual.
How many components#
--components / n_components= takes either kind of answer:
an integer — a fixed count, identical at every locus. At fit time it is a cap: a block narrower than the request (the five cross-locus ratios against a request of 256) gets its full rotation, recorded per locus in the artifact.
a fraction in
(0, 1)— cumulative variance explained. The count then differs per locus and per corpus, because some corpora are genuinely easier to explain. That is biology, not a defect.
The artifact stores the rotation only up to the count it was fitted at, plus the full eigenvalue spectrum. So truncating downward at apply time is free and exact — the components are ordered, so the first n of a longer rotation are the same vectors. A count above the stored one is capped per locus, exactly as the fit capped it; a fraction the stored spectrum cannot reach raises, and the error quotes what it does reach. Neither ever pads: no column appears under a name the rotation does not hold.
The coverage target is a runtime argument#
Hill numbers are read at a coverage level, and that level is not in the artifact. A per-sample quantity has no business in a corpus artifact: in the system this replaces it lived there, and one shipped reference ended up standardising tissue samples to a level measured on blood, identical to 17 significant digits across all seven loci while its location and scale had genuinely been refitted.
|
meaning |
|---|---|
|
This cohort’s own per-locus minimum attained coverage. Comparable across its samples, and a property of this cohort rather than of somebody’s corpus. Costs a cheap extra pass over the count column — and resolves deferred samples twice, so pass a number for a one-pass run. |
a number |
That level for every locus. One pass. |
|
Each sample at its own attained coverage: the diversity features are then observed rather than standardised, and are not comparable across samples. |
Whatever the target, vsig:cov:<locus>:cstar reports what the sample reached and
vsig:mask:<locus>:estimable reports whether the target was reachable without extrapolating.
Raw feature groups#
Every group at a locus is concatenated into one feature vector and rotated together, so the PCA
can find cross-group structure. One rotation per (half, locus), plus one for the cross-locus
group — not one joint rotation over all loci, because under a joint rotation one absent locus
silently moves every component. Per-locus blocking is what keeps a hole a hole.
group |
width |
what |
|---|---|---|
|
6 |
Coverage-standardised Hill numbers |
|
3 |
|
|
3 |
Clone-size composition |
|
3 |
Weighted junction-length |
|
germline |
V and J gene usage over the arda germline vocabulary, clr |
|
germline x 26 |
Spectratype: junction length resolved per V gene, clr |
|
400 |
Junction 2-mer composition, arcsine |
|
20 |
Single-residue composition, arcsine |
|
30 |
Physicochemistry over two junction regions |
|
5 / 1 |
IGH only: isotype composition in clr, and mean V identity |
|
5 |
Cross-locus: five log read-count ratios, depth divided out |
Widths per locus run from 685 (TRD) to 3,744 (IGH), 13,483 raw features in total.
Note
spec is by far the widest group and the sparsest. 121 IGH V genes x 26 lengths is 3,146 cells
against a median 464 IGH clonotypes per real sample — about 0.15 observations per cell, mostly
structural zeros. That sparsity is real and is not hidden: a corpus of 10,000 repertoires puts
~4.6M observations behind the same cells, so the rotation’s directions are well determined even
where one sample’s scores along them are noisy. How noisy is readable per sample from
vsig:cov:<locus>:cstar.
columns= selects output, it does not skip work#
Because a locus is rotated jointly over all its groups, every group must be computed before any of
that locus’s components exist. columns= therefore restricts the emitted frame; it does not avoid
the computation. Declining a whole locus does skip it, and is the way to make a run cheaper.
Building a corpus#
vdjtools corpus --corpus synthetic-blood -o synthetic-blood.npz # 10,000 repertoires
vdjtools corpus --corpus naive --out naive.npz
vdjtools corpus --corpus memory --size n_eff --components 0.95 -o m.npz
vdjtools corpus --smoke -o /tmp/smoke.npz # minutes, for tests
The synthetic corpora use no samples from anybody’s cohort: every receptor is drawn from the bundled recombination models.
The build runs across worker processes, one contiguous block of samples each; --jobs/-j
sets how many and defaults to every core the process may use (-j 1 stays in-process). A sample
is a pure function of its index – its generator is seeded from (seed, locus, index) – so each
worker draws its own repertoires from memory-mapped pools and returns one row of numbers, and
the artifact is identical at every --jobs. Measured on Aldan-3, seven loci at
--samples 10000 --size 10000 on 72 cores: 157 s to generate the receptor pools (4,850 s in one
process) and about 50 samples/s to featurise them.
Note
Two settings, two layers. --jobs is worker processes; POLARS_MAX_THREADS and
OMP_NUM_THREADS are kernel threads. The builder sets its workers to one kernel thread
each, because jobs x cores threads is how a 15 s stage was once measured at 296 s.
The build is a deterministic function of (corpus, loci, samples, size, seed, source) and those
models, and is required to be byte-identical across processes, worker counts and thread counts:
vdjtools corpus --corpus naive --loci TRB,TRG --samples 40 --size 150 -o /tmp/a.npz
OMP_NUM_THREADS=1 POLARS_MAX_THREADS=1 \
vdjtools corpus --corpus naive --loci TRB,TRG --samples 40 --size 150 -o /tmp/b.npz
vdjtools corpus --corpus naive --loci TRB,TRG --samples 40 --size 150 -j 4 -o /tmp/c.npz
cmp /tmp/a.npz /tmp/b.npz && cmp /tmp/a.npz /tmp/c.npz # all three must be identical
Important
Each repertoire’s depth is drawn, log-uniformly over the measured real p05–p95 spread for its
locus, not fixed. A corpus at one fixed depth has exactly zero variance in depth:reads,
depth:richness and all five pair: ratios, so those contribute nothing to the rotation and
the cross-locus block cannot be fitted at all — and it fails quietly, as an artifact that simply
omits them. Real repertoires span 4.1x (TRB) to 11.0x (IGH) between their 5th and 95th
percentiles.
The artifact verifies itself on load: the raw column order is re-derived from the stored vocabulary through the current layout and compared, and the recorded bundled-model version is checked against the installed one. Both raise rather than producing numbers in a different coordinate system that look perfectly reasonable — retraining the bundled models moves the synthetic corpora by 0.1–1.6% per locus.
Parallelism#
--jobs/n_jobs is worker processes. There is exactly one concurrency setting, and its help
text says which layer it reaches; a flag named --threads that started processes is a mistake
this subsystem has made before. Workers are spawned, not forked — polars cannot be combined with
fork — and a pool that cannot start raises rather than falling back to one process, because
a correctness-preserving fallback is what hid a 20x slowdown here once.
Getting the named statistics back#
A signature is a rotation, and a rotated coordinate has no name a domain reader can use. But the rotation is fitted on features that do: diversity, depth, clone-size fractions, junction length, isotype composition, SHM, cross-locus yield. Those are computed for every sample either way.
named= returns them:
from vdjtools.signature import vsig, vsig_cohort
from vdjtools.signature.corpus import Corpus, bundled_path
corpus = Corpus.load(bundled_path("blood"))
vsig(sample, corpus, n_components=32, named=True)
vsig(sample, corpus, n_components=32, named=("div", "depth")) # or pick blocks
vsig_cohort(samples, corpus, n_components=32, named=True)
vdjtools signature --corpus blood --components 32 --named all -o sig.tsv
vdjtools signature --corpus blood --components 32 --named div,depth -o sig.tsv
named=(), the default, emits exactly the rotated columns and the channels – the output is
byte-identical to a run without the argument. The reportable blocks are:
half |
block |
what it holds |
|---|---|---|
|
|
Coverage-standardised Hill numbers |
|
|
|
|
|
Singleton, doubleton and top-clone fractions |
|
|
Junction-length mean, sd, skew |
|
|
IGH isotype composition, and mean V identity |
|
|
Cross-locus yield log-ratios |
|
|
|
Warning
These carry their declared transform, not their natural scale. A log10 diversity comes
back as log10 and a clr composition as a log-ratio with no unique inverse. Do not read
vsig:div:TRB:1D_c = 2.386 as a clone count – it is log10 of one.
channel_table() reports the transform for every feature, and
--describe prints it per emitted column.
The reason this exists rather than being a convenience: the diversity floor of any study using this library is made of exactly these blocks. Without a documented route to them there is no floor, and a signature that beats nothing gets reported as if it beat something.
Fitting a corpus on your own cohort#
The published corpora are one route; fitting the rotation on your own training half is the other, and it is the first thing anybody with a cohort asks for.
from vdjtools.signature.corpus import Corpus, fit_cohort
corpus = fit_cohort(train_samples, sig="vsig", name="my-cohort",
loci=("TRA", "TRB", "IGH"), n_components=64, n_jobs=0)
corpus.save("my-cohort.npz")
# and it is the same artifact type the shipped corpora are
mine = Corpus.load("my-cohort.npz")
vsig(held_out_sample, mine, n_components=64)
Samples are {sample_id: {locus: frame}}, or an iterable of frames, or picklable zero-argument
callables that defer the read into the worker. mir.signature must be imported before
sig="rsig" will resolve; nothing in vdjtools imports mir, so the featuriser reaches the
corpus through a registry rather than an import.
Warning
A cross-validated score computed on a rotation fitted inside the same cohort is not evidence that the rotation generalises.
Measured on raw repertoire embeddings, 5,376 columns over seven loci, one logistic head, with a balanced train/test split of one cohort and a second cohort held out entirely. At matched widths:
read-out |
in-cohort leads at |
median, in-cohort |
median, corpus |
|---|---|---|---|
training out-of-fold (what selects) |
4 of 5 widths |
0.5039 |
0.4968 |
held-out half |
2 of 5 |
0.5222 |
0.5635 |
external cohort |
0 of 5 |
0.4757 |
0.5270 |
Fitting the rotation in-cohort improves the number the configuration is selected on, and neither held-out read-out. A rotation fitted on 612 samples of one trial learns that trial’s covariance, which is what a within-cohort cross-validation rewards and what does not travel.
Both routes are legitimate and they answer different questions. Fit here when you want a basis for this cohort; name a shipped corpus when the number has to travel.
Further reading#
How signatures were fitted is the measurement record: how each corpus’s population was selected, what the quantile ladders are, how far a corpus carries across sequencing depths, and which alternatives were tried and rejected. Channels documents the columns that are never rotated and never clamped.
API#
Repertoire signatures: raw features, a corpus-fitted rotation, and the emitted vector.
Four modules, one per stage:
layout– the column contract. Which raw features exist, what each one’s support is, which columns pass through as channels. Computes nothing, imports nothing heavy.transform– the variance-stabilising transforms, applied where a raw feature is computed so its denominator is still in scope.features– the raw features and channels for one sample. A pure function of that sample plus the germline vocabulary.corpus– the only module that needs a corpus: build one, winsorize it, fit the rotation and the per-PC scaling, write and read the artifact.
vsig() and vsig_cohort() in signature put the four together.
The geometry half (rsig) lives in mir.signature and registers its groups into this same
layout registry; nothing here imports mir.
- class vdjtools.signature.Corpus(sig, name, vocab, fits, meta=<factory>)[source]#
A fitted corpus: one
LocusFitper locus, plus the manifest.A load verifies rather than trusts: the raw column order is re-derived from the stored vocabulary through the current layout and compared, so a layout change that would silently re-index the rotation raises instead. The bundled-model version is checked the same way, because the synthetic corpora are drawn from those models and retraining them moves the result by 0.1-1.6% per locus.
- Parameters:
- property k: dict[str, int]#
{locus: components}. A locus absent here emits no columns – how a corpus that could not fit a locus declares it, rather than shipping a rotation of zeros.
- columns(n_components=None, named=())[source]#
The signature columns this corpus emits: rotated, then channels, then named blocks.
- resolve_k(n_components=None)[source]#
{locus: k}after apply-time truncation.- Parameters:
n_components (int | float | None) –
Nonekeeps what was fitted. Aninttruncates every locus to that count. Afloatin(0, 1)truncates each locus to the fewest components reaching that cumulative variance.- Raises:
ValueError – If an
intexceeds what a locus was fitted with, or afloattarget is not reached by the stored spectrum. Neither ever pads: a count is capped at each locus’s storedk(the same cap the fit applied, so a 5-feature cross-locus block yields 5 whether you asked for 5 or 128), and a variance fraction the stored spectrum cannot reach raises rather than silently returning the whole rotation under the requested name.- Return type:
- classmethod load(path, *, verify=True)[source]#
Read an artifact, and check it is the one the caller thinks it is.
- Parameters:
path (str | Path) – The
.npzpath; the.jsonsidecar must sit beside it.verify (bool) – Re-derive the raw column order from the stored vocabulary through the current layout and compare, and check the recorded bundled-model version against the installed one. Turn it off only to inspect a deliberately stale artifact.
- Raises:
ValueError – On a column-order or model-version mismatch. Both would otherwise produce numbers in a different coordinate system that look perfectly reasonable.
- Return type:
- vdjtools.signature.fit(rows, vocab, *, sig, **kw)[source]#
Fit a corpus from raw feature rows – one dict per repertoire.
Convenient for a small corpus and for tests. For a full-size build use
locus_matrix()/fill_row()/fit_matrices(), which never holds more than one sample’s dict at a time. To fit on your own cohort of clonotype frames rather than on feature rows you assembled yourself, usefit_cohort().
- vdjtools.signature.raw_and_channels(frames, vocab, *, cstar_target=None, weight='log2p1', prefiltered=False, on_duplicate='error')[source]#
Every raw feature and every channel for one sample.
- Parameters:
frames (dict[str, DataFrame]) –
{locus: clonotype frame}, raw (this sanitises).vocab (dict[str, dict[str, list[str]]]) –
{locus: {"vus": [...], "jus": [...], "spec": [...]}}from the corpus artifact, or fromgene_vocab()at fit time.cstar_target (float | dict[str, float] | None) – Coverage level the Hill numbers are read at. A float applies to every locus, a dict is per-locus, and
Noneuses each locus’s own attained coverage – which makes the diversity features observed rather than standardised, and is the only default that does not smuggle in a constant from somewhere the caller cannot see.weight (str) – Clone-size weight, a key of
WEIGHTS.prefiltered (bool) – When the caller already removed non-productive rows, report
nonstd_aa_fracasnanrather than a confident floor of zero.on_duplicate (str) – Forwarded to
sanitise().
- Returns:
(raw, channels)– both{column: value}, holes asnan.- Return type:
- vdjtools.signature.synthesize(corpus_name, *, loci=('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL'), n_samples=10000, size=None, seed=20260927, n_components=128, mode='features', winsor_p=0.01, source='olga', fit_corpus=True, progress=None, n_jobs=1, depth_spread=None)[source]#
Build a synthetic corpus, and fit it.
- Parameters:
corpus_name (str) – One of
SYNTHETIC–"naive"and"memory"are the pure regimes at a nominal size;"synthetic-blood"and"synthetic-tissue"are the naive/memory mixture drawn across the named cohort’s measured per-locus richness, read-depth and singleton-fraction bands (COHORT), and are the two that describe a real bulk cohort rather than a regime.loci (tuple[str, ...]) – Loci to build. All seven by default.
n_samples (int) – Repertoires in the corpus.
size (int | str | None) – Receptors per repertoire.
None, the default, is the corpus’s own – 10,000 for the pure regimes, the geometric centre of the cohort’s measured richness band for asynthetic-*one. Also takes"n_eff"for the per-locus real medians inN_EFF,"p05"/"p95"for the sweep endpoints, or a cohort name.seed (int) – Base seed; every draw is a recorded offset from it.
n_components (int | float) – Components per locus, or a variance fraction.
mode (str) – Winsorization mode to fit at.
winsor_p (float) – Percentile for the fitted bounds. All of
WINSOR_PSare stored.source (str) – Bundled model set –
"olga","learned"or"arda".fit_corpus (bool) –
Falsereturns the raw samples instead of fitting, for scoring a held-out draw against a corpus fitted on another.progress – Optional
callable(locus, done, total).n_jobs (int) – Worker processes (not kernel threads).
0means every available core;1, the default, runs in-process. The result is bit-identical at every value.depth_spread (float | str | None) – Multiplicative depth range each repertoire’s size is drawn log-uniformly across, around
size.Noneis the corpus’s own –DEPTH_SPREADfor a pure regime (2.4x-11.0x), the cohort’s measured richness band for asynthetic-*one (43x on blood TRB, 259x on tissue IGH). Pass a number to override it:1000withsize=3162spans 100 to 100,000 receptors per locus. The bounds, centre and per-PC scaling are all estimated from the draw, so a corpus describes only the depths it was drawn across.
- Returns:
(corpus, mats)when fitting –matsbeing{locus: (matrix, columns)}, the corpus matrix itself – else a list of{locus: frame}samples.
The whole build is a deterministic function of
(corpus_name, loci, n_samples, size, seed, source)and the bundled models, so two machines at different thread counts must produce byte-identical artifacts. That is an acceptance criterion, not a hope:generatewas once irreproducible across processes while being deterministic within one, because a marginal-table aggregation left its group order unspecified.
- vdjtools.signature.vsig(sample, corpus, *, mode=None, winsor_p=None, n_components=None, cstar_target=None, weight='log2p1', prefiltered=False, on_duplicate='error', named=(), columns=None)[source]#
The statistics half of the signature for one sample.
- Parameters:
sample –
{locus: frame}, one frame with alocuscolumn, or a zero-argument callable returning either.mode (str | None) – Winsorization mode override –
"features","pcs"or"none". Defaults to whichever the corpus was fitted with.winsor_p (float | None) – Which stored percentile to clamp at. Defaults to the fitted one.
n_components (int | float | None) – Truncate to this many components, or this cumulative variance fraction.
cstar_target (float | dict[str, float] | None) – Coverage level the Hill numbers are read at.
Noneuses each locus’s own attained coverage, which makes them observed rather than standardised – honest for one sample, and not comparable across samples. Usevsig_cohort()for a cohort, which resolves a shared level.prefiltered (bool) – Report
qc:*:nonstd_aa_fracasnanbecause the caller already filtered.on_duplicate (str) –
"error"or"sum", for a frame repeating an amino-acid clonotype key.named (bool | Sequence[str]) –
Also return the reportable raw blocks –
Truefor all of them, or a sequence such as("div", "depth", "clon").(), the default, emits exactly the rotated columns and channels. Values carry their declared transform (log10,clr,logit, …), whichchannel_table()reports.These are the diversity, depth, clone-size, junction-length, isotype, SHM and cross-locus yield numbers. They are computed either way, because the rotation is fitted on them; without this argument there is no supported route to reading them back, and a study that cannot report its own diversity floor has no floor. See
channel_table()for the full list and its units.Restrict the output to these columns, in layout order.
Unlike the system this replaces, this does not skip work: the rotation for a locus is fitted jointly over every raw group at that locus, so every group must be computed before any of that locus’s components exist. Declining a locus entirely does skip it.
- Returns:
{column: value}in layout order, holes asnan.- Return type:
- vdjtools.signature.vsig_cohort(samples, corpus, *, n_jobs=1, cstar_target='min', columns=None, **kw)[source]#
One row per sample,
sample_idfirst.- Parameters:
samples –
{sample_id: sample}or an iterable of pairs. A sample may be a zero-argument picklable callable, which defers the read into the worker and keeps peak memory atO(n_jobs)samples rather than the whole cohort.corpus (Corpus) – A fitted corpus.
n_jobs (int) – Worker processes.
1stays in-process,0uses every available core.cstar_target (float | dict[str, float] | str | None) –
"min"(default) reads every sample’s attained coverage first and standardises the whole cohort to the per-locus minimum, so the diversity columns are comparable across samples and the level is a property of this cohort rather than of somebody’s corpus. That is a cheap extra pass over the count column – but it does resolve deferred samples twice, so pass an explicit float or dict for a one-pass run.Nonereads each sample at its own coverage: one pass, not comparable across samples.**kw – Forwarded to
vsig()– includingnamed=, which adds the reportable raw blocks in their own units.
- Returns:
A frame whose columns are
sample_idthen the corpus’s signature columns.- Return type:
DataFrame
- class vdjtools.signature.Channel(sig, name, features, loci=None)[source]#
A named family carried through untouched – never winsorized, never rotated.
- class vdjtools.signature.RawGroup(sig, name, features=<factory>, loci=None, dynamic=None, named=False)[source]#
One named family of raw features – an input to the rotation, never an output.
- Parameters:
sig (str) – Owning signature,
"vsig"or"rsig".name (str) – Group name, unique within a
sig.features (dict[str, tuple[str, str]]) –
{name: (transform, support)}, most easily built withfeats(). Empty for a group whose column names are not knowable without the germline; such a group declaresdynamicinstead.loci (tuple[str, ...] | None) – Loci the group is emitted for.
Nonemeans all ofLOCI; an empty tuple means the group is not per-locus and usesNO_LOCUS.dynamic (tuple[str, str] | None) – For a group whose width depends on the germline vocabulary (
vus,jus,spec): one(transform, support)pair shared by every column, with the names resolved at fit time and then recorded in the corpus artifact. Keeping the vocabulary out of here is what letsimport vdjtoolsstay free of arda.named (bool) – This group’s features are interpretable domain scalars – a diversity index, a read count, a singleton fraction – that a caller may legitimately want back in their own units rather than only as a rotation input. Declared here rather than inferred, because “is this number reportable” is a property of the feature and not of its width:
pchemis 30 static columns and is not reportable,shmis one and is. Groups marked here are whatnamed=Trueselects; seenamed_groups().
- property emitted_loci: tuple[str, ...]#
Loci this group emits for;
(NO_LOCUS,)when it is not per-locus.
- vdjtools.signature.arcsine(x, m)[source]#
Anscombe’s variance-stabilising arcsine transform,
asin(sqrt((x·m + 3/8)/(m + 3/4))).The right transform for a sparse composition — residue and gene-usage profiles, where most cells are structurally zero at shallow depth. Unlike a CLR it is defined at zero without any replacement step, and unlike a raw proportion its variance does not collapse near the boundary. Bounded in
[0, π/2], so it cannot produce the heavy tail a log-ratio would.- Parameters:
x – Proportion(s) in
[0, 1].m – Denominator(s) the proportion was observed on.
- vdjtools.signature.channel_columns(sig)[source]#
Every pass-through channel column for one
sig, in emitted order.
- vdjtools.signature.channels(sig=None)[source]#
Registered pass-through channels, in declaration order.
- vdjtools.signature.clr(parts, m=None, *, keys=None)[source]#
Centred log-ratio of a composition, with multiplicative zero replacement.
A CLR is the natural coordinate for a composition whose ratios carry the meaning: it is the log of each part over the geometric mean of all of them, so it is invariant to the total and a difference between two coordinates is a log-ratio of two parts.
Zeros are replaced multiplicatively, not additively: each zero part is set to
delta = 0.5/mand the non-zero parts are scaled down by1 − n_zero·deltaso the composition still closes. Adding a constant to every part instead — the common shortcut — distorts the ratios among the parts that were observed, which are the only ratios the coordinate system is about.Compute the CLR over the whole composition, then select coordinates. A CLR of a sub-composition is a different number from the corresponding coordinate of the full one, so a tier that ships four of six isotype parts must still divide by the six-part geometric mean. Doing it the other way would make the narrower tier stop being a slice of the wider one, which is the contract the layout exists to guarantee.
- Parameters:
parts – Non-negative part values as a mapping
{name: value}or an array. They need not sum to 1; they are closed here.m – Total count the composition was observed on, setting the replacement scale. Defaults to the sum of
partswhen they are counts.keys – Part order when
partsis an array. Ignored for a mapping.
- Returns:
{name: clr}whenpartsis a mapping (orkeysis given), else an array. The coordinates sum to zero by construction, which is why the layout ships all but one of them: the last is exactly determined by the rest and would make any unregularised design matrix singular.- Raises:
ValueError – If fewer than two parts are given, or any part is negative.
- vdjtools.signature.feats(transform, support, *names)[source]#
{name: (transform, support)}for a run of features sharing both.Groups are heterogeneous – a diversity group carries log10 Hill numbers beside a logit evenness – so both properties belong to the feature, not the group. This keeps the homogeneous runs terse anyway.
- vdjtools.signature.gene_vocab(locus, organism='human')[source]#
Column names for the three germline-width raw groups at one locus.
arda’s germline is the single source of truth for V/J vocabulary across this ecosystem, so the usage and spectratype widths are a property of the germline release rather than of any cohort. The corpus artifact records what came back here, and apply time reads it from the artifact – never from arda again, because a germline update would silently re-index the rotation.
- vdjtools.signature.log10(x, floor=1.0)[source]#
log10of a positive quantity, floored so an empty locus maps to 0 rather than -inf.Used for counts and for Hill numbers. The floor is 1 because both are counts of things: one clonotype, one read, one effective species. Zero of them is the same as the floor for every downstream purpose, and the presence mask already records that the locus was empty.
- Parameters:
floor (float)
- vdjtools.signature.log1p(x)[source]#
log(1+x)for a non-negative quantity that genuinely reaches zero.Distinct from
log10()in intent: this is for norms, dispersions and hit counts, where zero is a real, attainable value rather than an empty measurement.
- vdjtools.signature.logit(x, m)[source]#
Haldane–Anscombe logit of a proportion observed on a denominator
m.log((x·m + 1/2) / ((1−x)·m + 1/2)). Adding half an observation to each side is the standard remedy for an empty cell; its side effect is exactly the behaviour wanted here — the transform of0depends on how many chances there were to see something:logit(0, m=3) -> -1.95 a fifth of the repertoire could hide here logit(0, m=500) -> -6.91 it is really absent
- Parameters:
x – Proportion(s) in
[0, 1].m – Denominator(s) the proportion was observed on. Broadcasts against
x.
- Returns:
The transformed value, finite for every input including exactly 0 and exactly 1.
- vdjtools.signature.parse(column)[source]#
Split a column name into
(sig, block, locus, feature).Also the definition of “is this a signature column”, used as an allow-list wherever a frame may carry a caller’s own joined columns – so an
ageorn_readscolumn can never be silently winsorized or rotated.
- vdjtools.signature.pc_columns(sig, k)[source]#
Rotated column names, given the component count per locus.
- vdjtools.signature.raw_columns(sig, locus, vocab=None)[source]#
Every raw feature name for one
(sig, locus), in rotation-input order.This is the column order of one row of the corpus matrix
M_L, so it is also the row order of the rotation. It must be reproduced exactly at apply time, which is why the corpus artifact stores it verbatim rather than recomputing it.
- vdjtools.signature.register_channel(*channels)[source]#
Add pass-through channels to the registry.
- Parameters:
channels (Channel)
- Return type:
None
- vdjtools.signature.register_raw(*groups)[source]#
Add raw groups to the registry. Used by
mir.signaturefor the geometry half.- Parameters:
groups (RawGroup)
- Return type:
None
- vdjtools.signature.sanitise(df, *, strict=True, on_duplicate='error')[source]#
Drop non-productive clonotypes; return the frame and the dropped weight fraction.
Dropped by weight, not by row: losing one dominant clone matters more than losing fifty singletons, and a row fraction would hide that.
- Parameters:
df (DataFrame) – A clonotype frame.
strict (bool) – Raise on unparseable characters (see
assert_parseable()).Falsedrops them, for a corpus known to carry ambiguity codes.on_duplicate (str) – What to do when the frame has no
junction_ntand repeats an amino-acid clonotype key –"error"(default) or"sum". The default refuses because the two readings give different richness and clonality and the frame cannot say which is meant.
- Returns:
(kept_frame, dropped_weight_fraction).- Return type:
- vdjtools.signature.signature_columns(sig, k)[source]#
The emitted signature for one half: rotated columns then channels, in that order.
- vdjtools.signature.support_of(column, vocab=None)[source]#
The declared support of a raw feature or channel column.
- Parameters:
- Raises:
ValueError – If no registered group or channel declares the column.
- Return type:
- vdjtools.signature.work_frame(df, weight='log2p1')[source]#
Overwrite
frequencywith the normalised clone weight, so every profiler agrees.Order matters. Call this after filtering, and never call
filter_functional,downsampleorselect_topafterwards – each recomputesfrequencyfrom the counts and would silently restore read weighting.- Parameters:
df (DataFrame)
weight (str)
- Return type:
DataFrame
The signature column contract: raw feature groups, supports, pass-through channels.
A signature is a fixed-order, name-addressed feature vector for one sample. It is built in three steps, and this module owns the vocabulary of all three while computing none of them:
raw features -> rotation (per locus, from a corpus artifact) -> PC columns
+ pass-through channels
Names are four colon-separated parts throughout, so one parse() reads every kind:
vsig:div:TRB:1D_c a raw feature (an input to the rotation)
vsig:pc:TRB:PC01 a rotated column (an output)
vsig:cov:TRB:cstar a channel (carried through untouched)
vsig:qc:-:n_loci_present a channel that is not per-locus
Two things here exist because getting them wrong produced silent wrong answers before.
``support`` is declared, never inferred. Winsorization trims the tail a metric can run away
into, and which tail that is depends on the metric’s support, not on its transform. Three raw
features declare transform="none" and have three different supports – a log-probability is
bounded above by 0, a standard deviation is bounded below by 0, and a log-ratio is bounded
neither way. Deriving the trimming side from the transform silently trims the wrong end of two of
them, so the side comes from SUPPORTS and nothing else.
Channels are not rotated. A provenance number that went through a rotation is no longer a provenance number: it has been mixed with the measurements it was supposed to qualify. So the fallback fractions, the coverage the sample actually attained, and the masks pass through in their own units, and the rotation never sees them.
vsig (statistics) is declared here. rsig (geometry) is computed in mir.signature and
registers its groups into this same registry with register_raw() / register_channel();
nothing here imports mir.
- vdjtools.signature.layout.LOCI: tuple[str, ...] = ('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL')#
The seven human receptor loci, in canonical signature order.
- vdjtools.signature.layout.NO_LOCUS = '-'#
Placeholder in the locus slot of a column that is not per-locus.
- vdjtools.signature.layout.PC_BLOCK = 'pc'#
Block name reserved for rotated output.
<sig>:pc:<locus>:PC01.
- vdjtools.signature.layout.TRANSFORMS: tuple[str, ...] = ('none', 'log10', 'log1p', 'logit', 'clr', 'arcsine')#
Variance-stabilising transforms a raw feature may declare, applied where the feature is computed, while its denominator is still in scope. See
vdjtools.signature.transform.
- vdjtools.signature.layout.SUPPORTS: dict[str, tuple[bool, bool]] = {'nonneg': (False, True), 'nonpos': (True, False), 'real': (True, True), 'unit': (False, False)}#
Supports a raw feature may declare, and the winsorization side each implies. This is the closed vocabulary the trimming side is read from.
support
trimmed
because
nonnegtop only
[0, inf)– cannot run away downwardnonposbottom only
(-inf, 0]– a log-probability, bounded by 0 aboverealboth
(-inf, inf)– log-ratios, clr, logit, PC scoresunitneither
[a, b]– already bounded on both sides
- vdjtools.signature.layout.AMINO_ACIDS: str = 'ACDEFGHIKLMNPQRSTVWY'#
Amino acids, anchored order. Also the k-mer alphabet.
- vdjtools.signature.layout.SPECTRATYPE_LENGTHS: tuple[int, ...] = (6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31)#
Junction lengths the spectratype resolves, in residues. Fixed rather than data-driven so the raw column set is the same for every sample and every corpus; anything outside falls in the two saturating end bins.
- vdjtools.signature.layout.KMER_K: int = 2#
k for the k-mer group. 2, giving 400 columns per locus over the plain 20-letter alphabet. k=3 is 8,000 cells against roughly 1,200 junction tokens at the corpus median depth – over 85% structural zeros, whose log-ratio coordinates read as a depth measurement wearing a motif label. k=2 is ~1,200 observations over 400 parts, which is thin but genuinely estimated.
- vdjtools.signature.layout.feats(transform, support, *names)[source]#
{name: (transform, support)}for a run of features sharing both.Groups are heterogeneous – a diversity group carries log10 Hill numbers beside a logit evenness – so both properties belong to the feature, not the group. This keeps the homogeneous runs terse anyway.
- class vdjtools.signature.layout.RawGroup(sig, name, features=<factory>, loci=None, dynamic=None, named=False)[source]#
One named family of raw features – an input to the rotation, never an output.
- Parameters:
sig (str) – Owning signature,
"vsig"or"rsig".name (str) – Group name, unique within a
sig.features (dict[str, tuple[str, str]]) –
{name: (transform, support)}, most easily built withfeats(). Empty for a group whose column names are not knowable without the germline; such a group declaresdynamicinstead.loci (tuple[str, ...] | None) – Loci the group is emitted for.
Nonemeans all ofLOCI; an empty tuple means the group is not per-locus and usesNO_LOCUS.dynamic (tuple[str, str] | None) – For a group whose width depends on the germline vocabulary (
vus,jus,spec): one(transform, support)pair shared by every column, with the names resolved at fit time and then recorded in the corpus artifact. Keeping the vocabulary out of here is what letsimport vdjtoolsstay free of arda.named (bool) – This group’s features are interpretable domain scalars – a diversity index, a read count, a singleton fraction – that a caller may legitimately want back in their own units rather than only as a rotation input. Declared here rather than inferred, because “is this number reportable” is a property of the feature and not of its width:
pchemis 30 static columns and is not reportable,shmis one and is. Groups marked here are whatnamed=Trueselects; seenamed_groups().
- property emitted_loci: tuple[str, ...]#
Loci this group emits for;
(NO_LOCUS,)when it is not per-locus.
- class vdjtools.signature.layout.Channel(sig, name, features, loci=None)[source]#
A named family carried through untouched – never winsorized, never rotated.
- vdjtools.signature.layout.register_raw(*groups)[source]#
Add raw groups to the registry. Used by
mir.signaturefor the geometry half.- Parameters:
groups (RawGroup)
- Return type:
None
- vdjtools.signature.layout.register_channel(*channels)[source]#
Add pass-through channels to the registry.
- Parameters:
channels (Channel)
- Return type:
None
- vdjtools.signature.layout.raw_groups(sig=None)[source]#
Registered raw groups, in declaration order.
- vdjtools.signature.layout.channels(sig=None)[source]#
Registered pass-through channels, in declaration order.
- vdjtools.signature.layout.raw_columns(sig, locus, vocab=None)[source]#
Every raw feature name for one
(sig, locus), in rotation-input order.This is the column order of one row of the corpus matrix
M_L, so it is also the row order of the rotation. It must be reproduced exactly at apply time, which is why the corpus artifact stores it verbatim rather than recomputing it.
- vdjtools.signature.layout.channel_columns(sig)[source]#
Every pass-through channel column for one
sig, in emitted order.
- vdjtools.signature.layout.named_groups(sig=None)[source]#
Raw groups whose features are reportable in their own units (
RawGroup.named).
- vdjtools.signature.layout.resolve_named(sig, named)[source]#
Normalise a
named=argument to a tuple of group names.Truemeans every group declarednamed;Falseand()mean none. A sequence is taken literally and checked, so a typo raises here rather than silently emitting nothing – a caller who asks for"diversity"and gets no diversity columns has no way to tell that from a corpus that could not compute them.
- vdjtools.signature.layout.named_columns(sig, named=True, vocab=None)[source]#
Raw column names carried through in natural units, in layout order.
- Parameters:
sig (str) –
"vsig"or"rsig".named (bool | Sequence[str]) –
Truefor every reportable group, or an explicit sequence of group names.vocab (dict[str, dict[str, list[str]]] | None) –
{locus: {group: [names]}}from the corpus. When given it also restricts the loci: a raw feature is only computed for a locus the corpus models, so emittingvsig:div:TRA:*from a TRB-only corpus would hand back a column of holes that never had a chance of being anything else.NO_LOCUSis always kept, because the cross-locus group is computed whatever the corpus models.
- Return type:
- vdjtools.signature.layout.channel_table(sig=None)[source]#
Every feature this half can emit, with what it is and what units it is in.
One row per
(block, feature)with akindsaying how it reaches the output:rotatedA rotation input. You do not get this column; you get
<sig>:pc:<locus>:PCnn.namedA rotation input that is also reportable on its own, via
named=onvsig/rsig. Same number, natural units.channelCarried through untouched – never winsorized, never rotated.
The point of the
kindcolumn is that “what can I get, and in what units” was previously three lookups and a reading ofcorpus.py.
- vdjtools.signature.layout.pc_columns(sig, k)[source]#
Rotated column names, given the component count per locus.
- vdjtools.signature.layout.signature_columns(sig, k)[source]#
The emitted signature for one half: rotated columns then channels, in that order.
- vdjtools.signature.layout.parse(column)[source]#
Split a column name into
(sig, block, locus, feature).Also the definition of “is this a signature column”, used as an allow-list wherever a frame may carry a caller’s own joined columns – so an
ageorn_readscolumn can never be silently winsorized or rotated.
- vdjtools.signature.layout.support_of(column, vocab=None)[source]#
The declared support of a raw feature or channel column.
- Parameters:
- Raises:
ValueError – If no registered group or channel declares the column.
- Return type:
Raw vsig features and pass-through channels, for one sample.
Everything here is a pure function of one sample plus frozen vocabulary. Nothing is fitted, no
corpus is consulted, and no value is standardised, winsorized or rotated – those need a corpus and
live in vdjtools.signature.corpus. That split is the point: two people who never share a
cohort compute the same raw features, and only the artifact they rotate through can differ.
Two axes this module keeps apart, because collapsing them is how wrong answers got shipped:
Raw features feed the rotation. Each carries its declared transform, applied here while its denominator is still in scope, because a proportion separated from the count it was observed on cannot be stabilised afterwards.
Channels never reach the rotation. A provenance number that has been mixed with the measurements it was meant to qualify is no longer provenance, so the fallback fractions, the masks, and the coverage the sample actually attained pass through in their own units.
The coverage target is a runtime argument, not a frozen constant. Carrying a per-sample quantity in a corpus artifact is how one shipped reference ended up standardising tissue samples to a level measured on blood, digit for digit across all seven loci.
- vdjtools.signature.features.VALID_AA = '^[ACDEFGHIKLMNPQRSTVWY]+$'#
The 20 proteinogenic amino acids, anchored. A junction containing anything else – a stop codon, an ambiguity code, the legacy out-of-frame marker – is dropped before anything is computed. Not a crash guard: most downstream code accepts a malformed junction silently and returns a finite, meaningless number, so this filter is the only thing between a contaminated feature and a plausible-looking wrong answer. The dropped fraction is reported as
qc:*:nonstd_aa_frac.
- vdjtools.signature.features.PARSEABLE_AA = '^[ACDEFGHIKLMNPQRSTVWY*_]+$'#
The 20 amino acids plus the two characters a WELL-FORMED AIRR table can legitimately carry:
*for a stop codon,_for the legacy out-of-frame marker. Anything outside this is a broken file, not a kind of receptor – seesanitise().
- vdjtools.signature.features.ISOTYPES: dict[str, tuple[str, ...]] = {'IgA': ('IGHA1', 'IGHA2'), 'IgD': ('IGHD',), 'IgE': ('IGHE',), 'IgG': ('IGHG1', 'IGHG2', 'IGHG3', 'IGHG4'), 'IgM': ('IGHM',)}#
Isotype classes and the constant-gene names that map onto them.
IGHGPis a pseudogene andIGHCis ambiguous, so neither is called.
- vdjtools.signature.features.WEIGHTS = {'anscombe': <function <lambda>>, 'distinct': <function <lambda>>, 'duplicate_count': <function <lambda>>, 'log1p': <ufunc 'log1p'>, 'log2p1': <function <lambda>>}#
Clone-size weights
g, the one definition both halves of the signature use. Concave by default: raw read weighting lets a single dominant clone be the entire profile, presence weighting throws the expansion signal away, andlog2(1+a)sits between.It lives in the shared dependency because
vsigandrsigmust weight the same repertoire identically or they are not describing one measure – and a second copy that drifted would not raise anywhere, it would quietly make the two halves disagree.
- vdjtools.signature.features.PAIRS: tuple[tuple[str, str], ...] = (('TRA', 'TRB'), ('TRG', 'TRB'), ('TRD', 'TRB'), ('IGK', 'IGL'), ('IGH', 'TRB'))#
T-vs-B balance, the gamma-delta share, the light-chain balance. A ratio rather than two counts, because the sequencing depth that drives both cancels.
- Type:
Locus pairs whose read-count ratio is a compartment read-out
- vdjtools.signature.features.assert_parseable(df)[source]#
Raise if any
junction_aais not a parseable amino-acid string.A data-integrity gate, not a filter, and the first of three axes to keep apart: parseable (can we read the string at all – here), productive (does the rearrangement encode a chain – AIRR), functional (is the germline gene real – IMGT F/ORF/P).
Measured across 6,047,716 rows of a clinical AIRR store: zero violations. The gate therefore costs nothing on well-formed data and is only ever reached by a real problem.
- Raises:
ValueError – If any junction carries an unparseable character.
- Parameters:
df (DataFrame)
- Return type:
None
- vdjtools.signature.features.sanitise(df, *, strict=True, on_duplicate='error')[source]#
Drop non-productive clonotypes; return the frame and the dropped weight fraction.
Dropped by weight, not by row: losing one dominant clone matters more than losing fifty singletons, and a row fraction would hide that.
- Parameters:
df (DataFrame) – A clonotype frame.
strict (bool) – Raise on unparseable characters (see
assert_parseable()).Falsedrops them, for a corpus known to carry ambiguity codes.on_duplicate (str) – What to do when the frame has no
junction_ntand repeats an amino-acid clonotype key –"error"(default) or"sum". The default refuses because the two readings give different richness and clonality and the frame cannot say which is meant.
- Returns:
(kept_frame, dropped_weight_fraction).- Return type:
- vdjtools.signature.features.work_frame(df, weight='log2p1')[source]#
Overwrite
frequencywith the normalised clone weight, so every profiler agrees.Order matters. Call this after filtering, and never call
filter_functional,downsampleorselect_topafterwards – each recomputesfrequencyfrom the counts and would silently restore read weighting.- Parameters:
df (DataFrame)
weight (str)
- Return type:
DataFrame
- vdjtools.signature.features.gene_vocab(locus, organism='human')[source]#
Column names for the three germline-width raw groups at one locus.
arda’s germline is the single source of truth for V/J vocabulary across this ecosystem, so the usage and spectratype widths are a property of the germline release rather than of any cohort. The corpus artifact records what came back here, and apply time reads it from the artifact – never from arda again, because a germline update would silently re-index the rotation.
- vdjtools.signature.features.div_group(df, level)[source]#
Hill numbers standardised to a coverage
level, plus distribution shape.Coverage standardisation is what makes a hundred clonotypes and a hundred thousand comparable: both are read at the same completeness rather than at their own depth. It only works where the sample actually reaches
level; beyond that the estimator extrapolates, and extrapolation is not a mild approximation here. Measured on one real repertoire subsampled across a 200x depth range: at a level every subsample attained, Shannon diversity agreed within 4%; at a level the shallow ones extrapolated to, the same statistic inflated roughly tenfold.Whether this sample reached it is reported by
mask:*:estimable, and the level it actually attained bycov:*:cstar. When it did not reach the target the features arenan– a hole a downstream model can see, rather than a confident wrong number.
- vdjtools.signature.features.estimable(df, level, est=None)[source]#
Whether a coverage-standardised diversity is a measurement rather than an extrapolation.
Two necessary conditions: the estimator must not have extrapolated, and the target depth must be within twice the observed one. The second catches the case where it interpolates nominally but is leaning on almost no data.
- vdjtools.signature.features.depth_group(df, n_reads, richness)[source]#
How much was seen, and how much was not.
S_unseenis Chao’s estimate of the clonotypes that exist but were never drawn. Carried explicitly rather than folded into a diversity estimate because it is the honest statement of what the sample could not resolve.
- vdjtools.signature.features.clon_group(df, n_reads, richness)[source]#
Clone-size distribution shape as a composition, plus the top clone’s share.
f1/f2/f3plus– seen once, twice, more – is a three-part composition read in log-ratio coordinates. Two of the three ship because the third is exactly determined by them.
- vdjtools.signature.features.len_group(df)[source]#
Weighted moments of the junction length distribution.
Moments rather than a pooled histogram: the per-V-gene histogram is the
specgroup’s job, and a locus-pooled 26-bin histogram is neither of the two useful things.
- vdjtools.signature.features.aa_group(df)[source]#
Weighted single-residue composition of the junctions, arcsine-stabilised.
- vdjtools.signature.features.kmer_group(df)[source]#
Weighted k-mer composition of the junctions, arcsine-stabilised.
k=2over the plain 20-letter alphabet: 400 parts against roughly 1,200 junction tokens at the corpus median depth. Thin, but genuinely estimated –k=3would be 8,000 cells, over 85% of them structural zeros, whose coordinates read as a depth measurement wearing a motif label.
- vdjtools.signature.features.pchem_group(df, regions=('all', 'center'))[source]#
Weighted mean physicochemistry of the junction, over two regions.
Two regions rather than five, and means rather than four quantiles apiece: at a hundred clonotypes the quantiles of a per-clonotype property are noise, while the weighted mean is a well-behaved average over every residue seen.
- vdjtools.signature.features.usage_group(df, col, genes)[source]#
Weighted gene-usage composition over a fixed germline vocabulary, in clr coordinates.
The vocabulary is fixed by the artifact, not by the sample, so a gene this sample never used is a structural zero of a known composition rather than a missing column. An unrecognised call is different and is not silently folded in here – it lands in the closing residual part and is reported by
qc:*:{v,j}_fallback_frac.The clr is taken over the vocabulary plus that residual, then the residual is dropped: a clr of a sub-composition is a different number from the corresponding coordinate of the full one.
- vdjtools.signature.features.spec_group(df, names, v_genes)[source]#
Spectratype: the junction-length distribution resolved per V gene, in clr coordinates.
A (V gene x length) table, closed by one residual cell for calls or lengths outside the vocabulary. This is the widest group by a wide margin – 121 IGH V genes x 26 lengths is 3,146 cells against a median 464 IGH clonotypes per real sample, so a single sample’s table is ~0.15 observations per cell and the great majority of it is structural zeros.
That sparsity is real and is not hidden: a corpus of 10,000 repertoires puts ~4.6M observations behind the same cells, so the rotation’s directions are well determined even where one sample’s scores along them are noisy. How noisy is readable per sample from
cov:*:cstar, which is why that channel is always emitted.
- vdjtools.signature.features.iso_group(df)[source]#
Isotype composition of an IGH repertoire, in log-ratio coordinates.
The uncalled share is a real part of the composition, not a rounding error – roughly two fifths of IGH reads carry no constant-gene call – so it closes the composition rather than being silently dropped the way a usage profile would drop it.
- vdjtools.signature.features.shm_group(df)[source]#
Mean somatic hypermutation load, via
v_identity.Absent from the canonical schema – vdjtools’ readers narrow to eight columns and
v_identityis not one of them – so this masks out unless the caller kept it explicitly.
- vdjtools.signature.features.pair_group(reads)[source]#
Log read-count ratios between loci – compartment balance, with depth divided out.
- vdjtools.signature.features.coverage_of(df)[source]#
The sample’s own attained Chao coverage at its observed depth, in
[0, 1].- Parameters:
df (DataFrame)
- Return type:
- vdjtools.signature.features.qc_channel(raw, clean, locus, nonstd_frac)[source]#
Whether this sample’s gene calls are in a vocabulary we recognise.
An unrecognised V or J call raises nowhere downstream: the germline distance lookup falls back to the maximum observed distance, so a cohort on Adaptive nomenclature or an older IMGT release yields a fully populated, entirely plausible, systematically wrong result. These fractions are the one number telling a collaborator their vector is not comparable to ours, which is why they are columns and not a warning nobody reads.
Reported as raw fractions in
[0, 1], not logit-transformed: a channel is read by a human deciding whether to trust the row, and a logit is not.
- vdjtools.signature.features.raw_and_channels(frames, vocab, *, cstar_target=None, weight='log2p1', prefiltered=False, on_duplicate='error')[source]#
Every raw feature and every channel for one sample.
- Parameters:
frames (dict[str, DataFrame]) –
{locus: clonotype frame}, raw (this sanitises).vocab (dict[str, dict[str, list[str]]]) –
{locus: {"vus": [...], "jus": [...], "spec": [...]}}from the corpus artifact, or fromgene_vocab()at fit time.cstar_target (float | dict[str, float] | None) – Coverage level the Hill numbers are read at. A float applies to every locus, a dict is per-locus, and
Noneuses each locus’s own attained coverage – which makes the diversity features observed rather than standardised, and is the only default that does not smuggle in a constant from somewhere the caller cannot see.prefiltered (bool) – When the caller already removed non-productive rows, report
nonstd_aa_fracasnanrather than a confident floor of zero.on_duplicate (str) – Forwarded to
sanitise().
- Returns:
(raw, channels)– both{column: value}, holes asnan.- Return type:
The corpus: build one, winsorize it, fit the rotation and the scaling, apply it to a sample.
This is the only module here that needs more than one sample, and everything it fits comes out of
one pass over one matrix of repertoires. That is the whole design, and it is a correction: the
system this replaces fitted its rotation on 10,000 individual clonotypes from a prototype panel
while every column that rotation produced was a repertoire statistic, and its centre and scale
arrived from a separate corpus of real samples. Two independent fits, stitched – which is how one
shipped reference came to pair a centre of exactly 0.0 with a scale plainly fitted from data,
putting a corpus-typical sample 81 robust deviations out.
So, per locus L:
M_L (N, p_L) one row per repertoire, columns in layout order
-> bounds per column, BY PERCENTILE, side from the declared support
-> centre/scale median and 1.4826*MAD, from OBSERVED entries only
-> rotation R_L (p_L, k_L)
-> pc centre/scale/quantiles from the corpus's own PC scores
Nothing per-sample enters the artifact. The coverage a sample attained is a per-sample statistic and ships as a channel; the coverage target is a runtime argument. Carrying a per-sample quantity in a corpus artifact is how one shipped reference ended up standardising tissue to a level measured on blood, digit for digit across all seven loci.
Winsorization is by percentile and one-sided wherever the metric is bounded on that side. A
robust-SD bound is not a fixed amount of probability: it depends on the distribution’s shape, so an
8-SD bound trims nothing at all on a right-skewed count and a third of the corpus on a
near-degenerate column. And the side comes from the declared support, never from the transform –
transform="none" spans three different supports.
- vdjtools.signature.corpus.MAD_TO_SD = 1.4826#
1.4826 * MADestimates the standard deviation of a normal distribution.
- vdjtools.signature.corpus.WINSOR_PS: tuple[float, ...] = (0.01, 0.05)#
Winsorization percentiles the artifact stores, so a caller picks one at apply time without a refit.
0.01trims the 1st/99th percentile,0.05the 5th/95th.
- vdjtools.signature.corpus.MIN_SPREAD = 1e-12#
it is centred, not scaled, and contributes nothing to the rotation. Not a tolerance on a measurement – a structural zero of a composition really is constant, and dividing by its MAD would turn float noise into the corpus’s dominant direction.
- Type:
A column whose corpus spread is at or below this is treated as never having varied
- vdjtools.signature.corpus.DEFAULT_COMPONENTS: int = 128#
Default components per locus. A count rather than a variance fraction, because the artifact stores the rotation only up to the count it was fitted at and a fraction on a wide sparse group resolves to enough components to make the artifact hundreds of megabytes. The full eigenvalue spectrum is stored either way, so the variance a given count reaches is always readable.
- vdjtools.signature.corpus.MODES: tuple[str, ...] = ('features', 'pcs', 'none')#
Modes for
apply().featuresclamps raw features to the corpus bounds before rotating;pcsrotates first and clamps the PC scores;noneclamps nothing.
- vdjtools.signature.corpus.bounds_for(x, supports, p)[source]#
Per-column winsorization bounds at percentile
p, side chosen by declared support.- Parameters:
x (ndarray) –
(N, p_L)corpus matrix; non-finite entries are ignored.supports (list[str]) – One support name per column, from
support_of().p (float) – Tail fraction, e.g.
0.01for the 1st/99th percentile.
- Returns:
(lo, hi), each(p_L,). A side that is not trimmed is-inf/+inf, so the clamp is a no-op there rather than a bound that happens not to bite.- Return type:
tuple[ndarray, ndarray]
- vdjtools.signature.corpus.clamp(x, lo, hi)[source]#
Clamp to
[lo, hi], and report which entries moved.The second return value is what keeps the bound from being silent. Clamping is many-to-one: it overwrites a real measurement of a real sample, and the sample cannot tell afterwards. A reference whose centre was wrong by 57 robust deviations went unnoticed for months precisely because every affected sample was clamped to the bound and looked like an ordinary tail case.
- Parameters:
x (ndarray)
lo (ndarray)
hi (ndarray)
- Return type:
tuple[ndarray, ndarray]
- vdjtools.signature.corpus.robust_loc_scale(x)[source]#
Per-column
(median, 1.4826*MAD)from observed entries only.Observed entries only, before any imputation. Filling holes first and measuring afterwards deflates the scale in proportion to how sparse a column is, so the least-observed locus ends up with the largest apparent values and dominates every distance and every principal component.
- Parameters:
x (ndarray)
- Return type:
tuple[ndarray, ndarray]
- class vdjtools.signature.corpus.LocusFit(columns, supports, loc, scale, rotation, eigenvalues, pc_loc, pc_scale, bounds, pc_bounds, n_obs, n_rows)[source]#
Everything fitted for one locus. Arrays are column-aligned to
columns.- Parameters:
- class vdjtools.signature.corpus.Corpus(sig, name, vocab, fits, meta=<factory>)[source]#
A fitted corpus: one
LocusFitper locus, plus the manifest.A load verifies rather than trusts: the raw column order is re-derived from the stored vocabulary through the current layout and compared, so a layout change that would silently re-index the rotation raises instead. The bundled-model version is checked the same way, because the synthetic corpora are drawn from those models and retraining them moves the result by 0.1-1.6% per locus.
- Parameters:
- property k: dict[str, int]#
{locus: components}. A locus absent here emits no columns – how a corpus that could not fit a locus declares it, rather than shipping a rotation of zeros.
- columns(n_components=None, named=())[source]#
The signature columns this corpus emits: rotated, then channels, then named blocks.
- resolve_k(n_components=None)[source]#
{locus: k}after apply-time truncation.- Parameters:
n_components (int | float | None) –
Nonekeeps what was fitted. Aninttruncates every locus to that count. Afloatin(0, 1)truncates each locus to the fewest components reaching that cumulative variance.- Raises:
ValueError – If an
intexceeds what a locus was fitted with, or afloattarget is not reached by the stored spectrum. Neither ever pads: a count is capped at each locus’s storedk(the same cap the fit applied, so a 5-feature cross-locus block yields 5 whether you asked for 5 or 128), and a variance fraction the stored spectrum cannot reach raises rather than silently returning the whole rotation under the requested name.- Return type:
- classmethod load(path, *, verify=True)[source]#
Read an artifact, and check it is the one the caller thinks it is.
- Parameters:
path (str | Path) – The
.npzpath; the.jsonsidecar must sit beside it.verify (bool) – Re-derive the raw column order from the stored vocabulary through the current layout and compare, and check the recorded bundled-model version against the installed one. Turn it off only to inspect a deliberately stale artifact.
- Raises:
ValueError – On a column-order or model-version mismatch. Both would otherwise produce numbers in a different coordinate system that look perfectly reasonable.
- Return type:
- vdjtools.signature.corpus.fit_locus(x, columns, *, mode='features', n_components=128, winsor_p=0.01)[source]#
Fit bounds, centre, scale, rotation and PC scaling for one locus, in one pass.
- Parameters:
x (ndarray) –
(N, p)raw feature matrix. Rows that observed nothing at this locus must already be dropped; individual holes are fine and are handled without imputing before measuring.columns (list[str]) – Raw column names, defining the rotation’s row order.
mode (str) –
"features"winsorizes the corpus and then takes a plain SVD – the covariance the SVD sees has no tails left to be dragged by, so the robustness sits in a step you can inspect, and the result is deterministic and bit-reproducible."pcs"leaves the corpus untrimmed and uses a robust scaler with sklearn’s PCA, because there the tails are still present at fit time and the estimator has to resist them itself.n_components (int | float) – Count, or a variance fraction in
(0, 1).winsor_p (float) – The percentile whose bounds are used when
mode="features". All ofWINSOR_PSare stored regardless.
- Returns:
The fit, or
Nonewhen the locus cannot support one (fewer rows than 2, or no column that ever varied).Noneis how a corpus declares a locus it could not fit; it is not an error, and it emits no columns.- Return type:
LocusFit | None
- vdjtools.signature.corpus.locus_matrix(vocab, sig, locus, n)[source]#
A preallocated
(n, p_L)buffer and its column order, orNoneif the locus is empty.Preallocated and filled in place rather than built from a list of per-sample dicts. At the shipped corpus size a dict of 13,483 Python floats per sample is 4-5 GB of interpreter objects for a matrix that is 1.08 GB as float64 – and the dicts have to coexist with the array while it is assembled. See
fill_row().
- vdjtools.signature.corpus.fill_row(buf, cols, i, raw)[source]#
Write one sample’s values for one locus into row
iof a preallocated buffer.
- vdjtools.signature.corpus.fit_matrices(mats, vocab, *, sig, name, mode='features', n_components=128, winsor_p=0.01, meta=None)[source]#
Fit a corpus from per-locus matrices – the memory-bounded path.
Each locus is fitted only on the rows that observed it, so a corpus where half the samples have no TRD does not learn TRD from imputed values.
- vdjtools.signature.corpus.register_featuriser(sig, fn)[source]#
Declare how one half turns
{locus: frame}into(raw, channels).Called once at import by each half.
fit_cohort()is the only consumer: it is what lets a caller hand in clonotype frames rather than pre-computed feature rows.- Parameters:
sig (str)
- Return type:
None
- vdjtools.signature.corpus.featuriser(sig)[source]#
The registered featuriser for
sig, or a pointed error naming what to import.- Parameters:
sig (str)
- vdjtools.signature.corpus.fit(rows, vocab, *, sig, **kw)[source]#
Fit a corpus from raw feature rows – one dict per repertoire.
Convenient for a small corpus and for tests. For a full-size build use
locus_matrix()/fill_row()/fit_matrices(), which never holds more than one sample’s dict at a time. To fit on your own cohort of clonotype frames rather than on feature rows you assembled yourself, usefit_cohort().
- vdjtools.signature.corpus.fit_cohort(samples, *, sig='vsig', name='custom', vocab=None, loci=None, organism='human', n_components=128, weight='log2p1', n_jobs=0, **kw)[source]#
Fit a corpus on your own cohort of clonotype frames.
The shipped corpora are one route and this is the other.
fittakes feature rows, so a caller holding repertoires had to run the featuriser per sample and assemble the dicts themselves; this does that, in processes, and hands back the sameCorpustype the published artifacts are.Corpus.save()writes it,Corpus.load()reads it back, and a collaborator can score against it exactly as against a shipped one.- Parameters:
samples –
{sample_id: sample}, an iterable of pairs, or an iterable of samples. A sample is{locus: frame}, one frame with alocuscolumn, or a picklable zero-argument callable returning either.sig (str) – Which half to fit.
"rsig"requiresmir.signatureto have been imported.name (str) – Recorded in the manifest, and reported in the preflight line wherever this corpus is used. Name it after the cohort, not after the analysis.
vocab (dict[str, dict[str, list[str]]] | None) –
{locus: {group: [names]}}. Defaults to the germline vocabulary forloci.loci (tuple[str, ...] | None) – Which loci to model. Defaults to every locus any sample carries.
organism (str) – Germline organism, when
vocabis derived rather than given.n_components (int | float) – Components per locus, as an int or a cumulative variance fraction.
weight (str) – Clone-size weight, forwarded to the featuriser.
n_jobs (int) – Worker processes;
0uses every available core.**kw – Forwarded to
fit_matrices()(mode,winsor_p, …).
- Returns:
A fitted
Corpus.- Return type:
Warning
A cross-validated score computed on a rotation fitted inside the same cohort is not evidence that the rotation generalises. Measured on 5,376 raw columns over seven loci with one logistic head: a rotation fitted on 612 training repertoires beat the shipped
bloodartifact on the training out-of-fold number at 4 of 5 matched widths (median 0.5039 against 0.4968) – and on the external cohort it led at 0 of 5 (median 0.4757 against 0.5270). Fitting in-cohort improves the number the configuration is selected on and neither held-out read-out, because a rotation fitted on one trial learns that trial’s covariance.Both routes are legitimate and they answer different questions. Fit here when you want a basis for this cohort; use a shipped corpus when the number has to travel.
- vdjtools.signature.corpus.apply(raw, chan, corpus, *, mode=None, winsor_p=None, n_components=None, named=())[source]#
Rotate one sample’s raw features through a corpus, and carry its channels through.
- Parameters:
raw (dict[str, float]) – Raw features for one sample, as
raw_and_channels()returns them.chan (dict[str, float]) – That sample’s channels. Copied out untouched apart from
qc:-:winsor_frac.mode (str | None) – Override the artifact’s winsorization mode.
"pcs"on an artifact fitted with"features"is allowed and is a legitimate thing to want, but the bounds it applies were then measured on an already-trimmed corpus; the manifest records which was fitted.winsor_p (float | None) – Which stored percentile to clamp at. Defaults to the fitted one.
n_components (int | float | None) – Truncate to this many components (or this variance fraction). Exact – the components are ordered, so the first
nof a longer rotation are the same vectors.named (bool | Sequence[str]) –
Also carry through the reportable raw blocks, untouched by the winsorization and by the rotation –
Truefor every group declared reportable, or an explicit sequence of group names.(), the default, reproduces the rotation-plus-channels output exactly. These are the same numbers the rotation is fitted on, not a second computation of them:rawalready holds every one.They carry each feature’s declared transform, not its natural scale: a
log10diversity comes back aslog10, and aclrcomposition as a log-ratio that has no unique inverse.channel_table()gives the transform per feature. Do not read one of these as a clone count.
- Returns:
{column: value}insignature_columns()order, followed by the requested named blocks in layout order.- Return type:
- vdjtools.signature.corpus.model_fingerprint(loci, source='olga')[source]#
{locus: "<model source>@<model version>"}for the models a synthetic pool is drawn from.The version is a hash of the germline the generator will actually draw from (every allele’s CDR3-region cut segment of the collapsed model), not the manifest’s declared version string. A declared version does not move when a germline is repaired: correcting
TRBV4-3*02’s anchor changed what every TRB pool contains while leavingolga:human_T_beta@2.0.0identical, so a corpus fitted before the repair would have loaded silently against models that no longer produce it. The hash cannot miss that.This, not the library version, is what a synthetic corpus’s numbers rest on: every pool comes out of these models, and retraining one moves that locus by 0.1-1.6%. Gating on the library version instead was both too strict – a patch release invalidated every corpus for no reason – and wrong:
vdjtools.__version__is read from installed distribution metadata, so a build driven byPYTHONPATHagainst a different installed version records that version rather than the code that ran. The first four cluster artifacts recorded3.6.0for exactly that reason, from a 4.0.0 tree.A real corpus is fitted on real repertoires and no model enters it, so its manifest carries no fingerprint and
Corpus.verify()has nothing to check.
- vdjtools.signature.corpus.CORPUS_RELEASE = 'corpora-v1'#
GitHub release the corpus artifacts are published under, in the repo that owns each half. A tag of its own rather than the library version: an artifact changes far less often than the code, and keying the download on the library version would invalidate every cached corpus on a patch release – the same mistake the germline fingerprint replaced for the load gate.
- vdjtools.signature.corpus.CORPORA_INDEX = 'corpora.json'#
The shipped index of downloadable artifacts. A few KB of JSON rather than ~110 MB of rotations, so a plain install stays small while –corpus can still NAME every corpus, report its size before fetching anything, and verify what it downloaded.
- vdjtools.signature.corpus.corpus_cache_dir()[source]#
Where downloaded corpus artifacts are cached.
VDJTOOLS_CORPUS_DIRoverrides it; otherwise the usual per-user cache. Shared between the two halves on purpose – avsig/rsigpair belongs in one place, and a cluster job that pre-warms one warms both.- Return type:
- vdjtools.signature.corpus.corpora_index(res_dir)[source]#
{name: {"npz": {"sha256", "bytes"}, "json": {...}}}from the shipped index, or{}.
- vdjtools.signature.corpus.fetch_artifact(name, *, sig, res_dir, repo, quiet=False, base_url=None)[source]#
Download one corpus artifact and its manifest into the cache, verified. Returns the
.npz.Both files, because
Corpus.load()reads the.jsonbeside the.npz– fetching only the arrays would leave a corpus that cannot say what it was fitted on.base_urloverrides where the assets come from – an internal mirror, or afile://URL, which is what lets the resolution order be tested without a network.
- vdjtools.signature.corpus.resolve_artifact(name, *, sig, res_dir, repo)[source]#
A corpus artifact from a path, the wheel, the cache, or the release – in that order.
The order is what makes a local file always win: a caller who fitted their own corpus and passes its path must never be silently served a downloaded one of the same name.
- vdjtools.signature.corpus.bundled_names()[source]#
Every corpus name this version knows – installed, cached, or downloadable.
- vdjtools.signature.corpus.bundled_path(name)[source]#
Resolve a corpus name or a filesystem path to a
vsigartifact, elseNone.Downloads on first use if the name is in the shipped index and not yet cached; see
resolve_artifact()for the resolution order.
- vdjtools.signature.corpus.SEED: int = 20260927#
Seed for the shipped synthetic corpora. Every draw is
SEED + offset, recorded in the manifest, so a rebuild is reproducible by anyone who installs the library.
- vdjtools.signature.corpus.ZIPF_A: float = 1.5#
clone
iofngets frequency proportional toi ** -ZIPF_A. Recorded in the artifact rather than assumed, because it is one of the two free parameters of the selected-repertoire regime.NOTE this is the Zipf law over ranks, not
numpy.random.Generator.zipf, which samples integers from a Zipf distribution. Ata = 1.5that distribution has infinite mean, so normalising a draw of it gives one clone almost all the mass: measured here, repertoires collapsed to as few as 1 surviving clonotype, which then made every coverage-standardised diversity feature a hole. The rank form is a well-behaved rank-abundance curve.- Type:
Rank-abundance exponent for the
memorycorpus
- vdjtools.signature.corpus.READS_PER_CLONE: int = 20#
Reads per clone in the
memorymultinomial. Sets how much of the rank-abundance tail survives sampling, which is the realistic part: at 20 reads per clone a 500-clone repertoire keeps 336 of them with 129 singletons, which is the shape a real library has. The zeros are dropped, so amemoryrepertoire has fewer distinct clones than anaiveone at the same nominal size – exactly as a sampled selected repertoire does.
- vdjtools.signature.corpus.DEFAULT_SIZE: int = 10000#
Receptors per synthetic repertoire, and the pool each is drawn from. A pool much larger than a sample is what makes two synthetic repertoires nearly disjoint, the way two donors are.
- vdjtools.signature.corpus.DEPTH_SPREAD: dict[str, float] = {'IGH': 10.981981981981981, 'IGK': 10.407407407407407, 'IGL': 10.027777777777779, 'TRA': 2.9182389937106916, 'TRB': 4.051282051282051, 'TRD': 2.4285714285714284, 'TRG': 4.2727272727272725}#
Multiplicative p05-to-p95 spread of repertoire depth, per locus, from
N_EFF. Each synthetic repertoire’s size is drawn log-uniformly across this range around the nominal size.A corpus at one fixed depth is not usable, and it fails silently. Measured here: with every
naiverepertoire at exactly 10,000 receptors,depth:readsanddepth:richnessare identical in all N samples and all fivepair:log-ratios are constant to the last bit – so their corpus spread is 0, they contribute nothing to the rotation, the cross-locus block cannot be fitted at all, and a real sample’s depth features get standardised against a degenerate reference. Real repertoires span 4.1x (TRB) to 11.0x (IGH) between their 5th and 95th percentiles, so drawing the depth is not an embellishment: it is what makes the corpus describe the axis it is going to be asked about.
- vdjtools.signature.corpus.N_EFF: dict[str, tuple[int, int, int]] = {'IGH': (111, 376, 1219), 'IGK': (54, 203, 562), 'IGL': (36, 123, 361), 'TRA': (159, 276, 464), 'TRB': (195, 401, 790), 'TRD': (14, 34, 34), 'TRG': (22, 49, 94)}#
Per-locus median effective clone count of real bulk blood repertoires, and the p05/p95 of the same, measured on 1,168 samples as
n_eff = 1/sum(w^2)withw = log2(1+count)/sum. Used bysize="n_eff"and by the depth sweep, so the artifact can report how its bounds and its scaling move with repertoire depth instead of leaving that unmeasured.
- vdjtools.signature.corpus.COHORT: dict[str, dict[str, tuple]] = {'blood': {'IGH': (33245, (84, 271, 697, 1711, 6200), (2.11, 2.42, 2.88, 3.93, 12.83), (0.255, 0.528, 0.697, 0.819, 0.927), (0.204, -0.132, -0.717)), 'IGK': (43678, (80, 233, 549, 1172, 3432), (2.29, 2.83, 3.67, 5.41, 15.97), (0.143, 0.384, 0.555, 0.704, 0.846), (0.399, -0.301, -0.759)), 'IGL': (37601, (61, 160, 361, 790, 2468), (2.33, 2.86, 3.63, 5.37, 15.89), (0.122, 0.346, 0.514, 0.671, 0.817), (0.349, -0.259, -0.714)), 'TRA': (32732, (67, 148, 276, 554, 1724), (2.08, 2.38, 2.75, 3.5, 6.98), (0.197, 0.566, 0.745, 0.851, 0.942), (-0.045, 0.142, -0.547)), 'TRB': (34365, (74, 208, 457, 985, 3162), (2.1, 2.4, 2.83, 3.74, 7.91), (0.215, 0.58, 0.759, 0.86, 0.949), (-0.036, 0.128, -0.503)), 'TRD': (5620, (19, 48, 80, 121, 280), (2.25, 2.96, 4.17, 6.75, 17.36), (0.167, 0.418, 0.591, 0.714, 0.856), (-0.547, 0.484, -0.482)), 'TRG': (8688, (21, 48, 72, 108, 220), (2.64, 3.51, 4.74, 7.0, 15.38), (0.167, 0.438, 0.583, 0.686, 0.806), (-0.421, 0.463, -0.394))}, 'tissue': {'IGH': (47031, (40, 164, 602, 2458, 10352), (2.39, 3.3, 5.27, 10.0, 40.36), (0.055, 0.395, 0.561, 0.69, 0.855), (0.017, 0.079, -0.509)), 'IGK': (58706, (34, 150, 519, 1813, 6592), (2.66, 4.09, 7.05, 14.9, 69.34), (0.147, 0.29, 0.41, 0.546, 0.75), (0.134, -0.147, -0.582)), 'IGL': (51010, (29, 113, 343, 1188, 4152), (2.64, 3.88, 6.49, 13.09, 54.7), (0.125, 0.26, 0.377, 0.51, 0.717), (0.075, -0.094, -0.565)), 'TRA': (16799, (18, 84, 148, 417, 1839), (2.1, 2.42, 2.95, 4.68, 120.24), (0.111, 0.476, 0.668, 0.806, 0.93), (-0.32, 0.34, -0.688)), 'TRB': (22298, (30, 89, 158, 498, 2977), (2.11, 2.48, 3.0, 4.6, 94.74), (0.194, 0.517, 0.688, 0.817, 0.928), (-0.233, 0.265, -0.598)), 'TRD': (1180, (6, 21, 54, 96, 253), (2.55, 4.05, 9.29, 53.4, 373.45), (0.12, 0.4, 0.594, 0.728, 0.876), (-0.661, 0.373, -0.219)), 'TRG': (2235, (7, 31, 57, 93, 172), (2.85, 4.42, 6.82, 13.29, 102.0), (0.077, 0.309, 0.5, 0.661, 0.82), (-0.54, 0.462, -0.377))}}#
What a REAL repertoire looks like per locus, in the two compartments the mixture corpora are named after:
{cohort: {locus: (samples, richness, reads per expanded clone, singleton fraction, rank correlations)}}, each of the three quantities being its measured p05/p25/p50/p75/p95ladder.Measured 2026-09-27 in one streaming pass over the harmonized AIRR store, every sample with at least 100 reads in the locus, grouped by
sample_id. Richness is distinct clonotypes; f1 is the fraction of them seen exactly once; reads per expanded clone is(reads - singletons) / (richness - singletons). The sample count leads each row, so a reader can see which loci the table is thin on (tissue TRD, 1,180 samples) and which it is not (tissue IGK, 58,706).These three ladders are the entire parameterisation of a mixture corpus, and the middle one is deliberately not the obvious quantity. Reads per clonotype cannot be drawn independently of the singleton fraction: a repertoire with
f1singletons whose other clones all carry at least 2 reads has at least2 - f1reads per clonotype, so a pair drawn from those two marginals lands in a forbidden region about half the time on blood TRB and has to be clipped back out of it – which silently rewrites the marginal it was drawn from. Drawing the read count itself is worse still: measured here, a copula over (richness, reads, f1) asks for a repertoire below that floor for 21.1% of 20,000 blood TRB draws. Reads per expanded clone is >= 2 whateverf1is, so the trio has no forbidden region at all.Five quantiles, not two, and it is the resolution that makes the corpus land. The read count is a product of all three drawn quantities, so it is the sharpest check on the draw: against blood TRB’s measured median of 763 reads (n = 34,365 samples), a 40,000-draw simulation gives 762 at five quantiles and 846 at three; blood IGH’s 1,298 comes out 1,276 against 1,490 (n = 33,245). Tissue is the harder case and is stated rather than smoothed over: tissue TRB’s median 308 comes out 425 at five quantiles, against 756 at three (n = 22,298). A product of three heavy-tailed factors has a median above the product of their medians unless their joint tails are matched exactly, and no marginals-plus-copula draw does that.
The last field is the Spearman rank correlations among the three, in the order
(richness vs expanded count, richness vs f1, expanded count vs f1), drawn through a Gaussian copula. That is for the rotation, not for the read count: a corpus’s rotation IS its covariance structure, andf1against expansion size is -0.503 on blood TRB and -0.846 on blood IGK (n = 43,678) – fewer singletons, bigger expansions. A corpus that drew the two independently would hand the PCA a correlation the cohort does not have, which no amount of correct marginals repairs.Against all this,
DEPTH_SPREAD– 2.4x on TRD to 11.0x on IGH, from 1,168 deep blood samples, and what thenaive/memorycorpora draw across – covers about a tenth of the real axis: blood TRB richness spans 43x (74 to 3,162 clonotypes) and tissue IGH 259x (40 to 10,352). A corpus estimates its bounds, its centre and its per-PC scaling from its own draw, and not one of the three extrapolates beyond it.
- vdjtools.signature.corpus.COHORT_QS: tuple[float, ...] = (0.05, 0.25, 0.5, 0.75, 0.95)#
The quantiles every
COHORTladder is recorded at.
- vdjtools.signature.corpus.SYNTHETIC: dict[str, tuple[str, str | None]] = {'memory': ('memory', None), 'naive': ('naive', None), 'synthetic-blood': ('mixed', 'blood'), 'synthetic-tissue': ('mixed', 'tissue')}#
{name: (regime, cohort)}. There are four, and the name is the only thing a caller has to know.naiveandmemoryare the two pure regimes at one nominal size. Neither is representative of a real bulk sample and neither can be:naivehas no clone-size structure at all, andmemoryhas no naive background and, at a fixed size, no read-depth variance either – its read count is exactlysize * READS_PER_CLONEin every sample. The twosynthetic-*corpora are the MIXTURE, drawn across the measured per-locus richness, read-depth and singleton-fraction ranges of the cohort they are named after, so they belong in the same set as the realblood/tissue/deep-tcrcorpora and mean what their names say.- Type:
The named synthetic corpora
- vdjtools.signature.corpus.corpus_plan(name, *, size=None, depth_spread=None)[source]#
(regime, cohort, size, depth_spread)for a named corpus – the ONE place a name becomes a draw, so the two halves of a corpus cannot resolve the same name differently.A cohort name stands in for both
sizeanddepth_spread, because both come out of the same measured band: the nominal size is its geometric centre and the spread is its width. An explicit value for either wins, which is what makes a deliberately wider or narrower variant of a named corpus a one-flag change rather than a new table.
- vdjtools.signature.corpus.cohort_bands(cohort, locus)[source]#
One locus of one cohort: the richness, expanded-count and
f1ladders, and their copula.
- vdjtools.signature.corpus.ladder_draw(q, u, log=True)[source]#
Inverse-CDF draw from a measured
COHORT_QSladder, at probabilitiesu.Linear interpolation of the quantile function – in log space for a scale quantity – so the drawn marginal carries all five of the cohort’s recorded quantiles and spans exactly its p05 to p95.
That is the difference from drawing uniformly across the band, and it is not cosmetic: real singleton fractions sit near the top of theirs (blood TRB median 0.759 of a 0.215-0.949 band, n = 34,365 samples), so a uniform draw would centre the corpus at 0.582 and leave a cohort-median sample 0.65 robust SD off centre on every singleton-sensitive feature.
- vdjtools.signature.corpus.copula_uniforms(rank_corr, n, rng)[source]#
(n, 3)probabilities carrying the measured rank correlations – a Gaussian copula.Spearman’s rho converts to the Gaussian correlation it implies by
2 * sin(pi * rho / 6), the three of them are made a matrix, and correlated normals are pushed through the normal CDF. The result is uniform in every column whatever the correlations are, soladder_draw()still reproduces each measured marginal exactly – a copula only decides how they move together, which is what the rotation is fitted on.Measured over all 14 (cohort, locus) entries of
COHORT: the realised rank correlations match their targets to within 0.015 on 20,000 draws.The correlation matrix is factorised in closed form, not by an eigendecomposition, and that is a reproducibility requirement rather than a micro-optimisation.
numpy.linalg.eighreturns eigenvectors whose SIGN is a LAPACK convention, not mathematics: negating a column leaves the covariance – and so every marginal and every correlation – untouched, but it changes the realised sample. A corpus built on a machine whose LAPACK signs a column differently would then differ byte-for-byte from the same build here while being statistically identical, and a corpus whose values depend on the builder’s linear-algebra library cannot be compared with one built anywhere else. The Cholesky factor of a positive-definite matrix is unique, and at 3x3 it is six arithmetic operations, so there is nothing to defer to.Unit diagonal makes the factor’s rows unit-norm by construction, so the normals need no rescaling. An infeasible triple – three correlations that cannot come from one joint distribution – makes the last radicand negative and raises, rather than being clipped into something plausible.
- vdjtools.signature.corpus.depth_spread_of(locus, depth_spread=None)[source]#
The multiplicative depth range for one locus: measured, named, or a caller’s override.
NonemeansDEPTH_SPREAD, the p05-p95 spread of that locus among 1,168 deep blood samples, which is whatnaiveandmemorydraw across – 2.4x on TRD to 11.0x on IGH, so under one decade everywhere. A cohort name fromCOHORTmeans that cohort’s own measured richness band, which is 43x on blood TRB and 259x on tissue IGH. A number widens or narrows it deliberately: a corpus meant to describe samples across decades of sequencing depth has to be drawn across those decades, because the bounds, the centre and the per-PC scaling are all estimated from the draw and none of them extrapolates.
- vdjtools.signature.corpus.draw_pool(locus, n, *, seed, source='olga')[source]#
nproductive rearrangements from the bundled model for one locus.Measured 2026-09-27,
generate(load_bundled("IGH"), 50_000, productive_only=True)on one core of a 16-core M-series laptop: 4,200 sequences/s, and identical at 1 and 16 polars threads – the sampler is a per-sequence Python/numpy loop, so it does not thread. Pool generation is therefore the dominant serial cost of a synthetic build (about 15M sequences atsize=10,000across seven loci) and is whybuild_pools()fans out over processes.NOTE an earlier docstring here claimed 20,372 seq/s on IGH. That figure is 4.8x the measured rate and it is what made a 77 s stage look like a 16 s one when the build was planned.
- vdjtools.signature.corpus.draw_sizes(nominal, n, spread, rng)[source]#
nrepertoire sizes, log-uniform over aspread-fold range aroundnominal.Log-uniform rather than normal because depth is a scale, and the geometric centre is the nominal size so the corpus is centred where the caller asked for it. See
DEPTH_SPREADfor why a fixed depth is not an option.
- vdjtools.signature.corpus.draw_sample(pool, size, regime, rng, frac=None, mexp=None)[source]#
One synthetic repertoire:
sizereceptors drawn frompool, with clone sizes.naivegives every clone a count of 1, which is what an unselected repertoire looks like and what the generator produces.memorydraws Zipf frequencies, takes a multinomial sample at the same total, and drops the zeros – so a memory repertoire has fewer distinct clones than a naive one at the same nominal size, exactly as a selected repertoire does.mixedis what a real bulk sample is – a singleton naive background plus an expanded memory component – and it needsfrac(the singleton fraction) andmexp(reads per expanded clone), both drawn per sample bydraw_plan()from the cohort’s measured ladders. Neither pure regime can stand in for it:naivehas no clone-size structure at all, andmemoryat a fixed size has a read count of exactlysize * READS_PER_CLONEin every sample, so its depth does not vary either.
- vdjtools.signature.corpus.resolved_size(locus, size)[source]#
The per-locus receptor count a
sizeasks for – an integer, or a measured depth name.Separate from
sample_stream()because the manifest has to record what each locus was actually drawn at:size="n_eff"means seven different depths, and a manifest saying only"n_eff"cannot tell a reader which.
- vdjtools.signature.corpus.draw_plan(loci, *, n_samples, size, seed, depth_spread=None, cohort=None)[source]#
Every sample’s drawn size, singleton fraction and expanded count:
{locus: {name: array}}.Drawn in the parent, once, from one generator per locus seeded at
seed + 1000 + i, so every worker is handed the same plan rather than reproducing it – and so the plan itself can be inspected, which is how the realised corpus gets checked against the cohort it is named after.cohort=Noneleaves the two mixture knobs unset, which is correct fornaiveandmemoryand keeps their draw byte-identical to what the shipped artifacts were built from.
- vdjtools.signature.corpus.draw_one(pools, plan, regime, j, seed)[source]#
Sample
jof a synthetic corpus – a pure function ofj.Each sample gets its own generator, seeded from
(seed, locus index, j), so any process can draw any sample in any order and get the same repertoire. That is what lets the build run across processes while staying bit-identical at everyn_jobs: the drawing is the only part of the pipeline carrying RNG state, and here it carries none between samples.
- vdjtools.signature.corpus.pool_target(locus, size, n_samples, depth_spread=None, cohort=None)[source]#
How many receptors a locus’s pool needs: the largest repertoire, times
POOL_FACTOR.The pool has to cover the LARGEST depth the draw can ask for, since a sample is drawn from it without replacement. For a cohort corpus that is the top of the measured richness ladder, scaled the same way
draw_plan()scales it; for a pure regime it is the nominal size times the half-spread.
- vdjtools.signature.corpus.build_pools(loci, *, size, n_samples, seed, source, tmp, n_jobs=1, progress=None, depth_spread=None, cohort=None)[source]#
Generate every locus’s pool across processes; return
{locus: [chunk path, ...]}.Generation is the single most expensive stage of a synthetic build and it is embarrassingly parallel – measured on Aldan-3, seven pools at
size=10,000took 4,850 s in one process, which is 81 minutes of a build whose featurisation is the part anyone cares about.Written as uncompressed IPC and read back memory-mapped, so N workers share one physical copy of a multi-gigabyte pool through the page cache instead of each pickling its own.
- vdjtools.signature.corpus.read_pools(paths)[source]#
Memory-map the pool chunks and present one frame per locus, without copying them.
rechunk=Falseis load-bearing: a rechunk would materialise the whole pool in this process, which is the per-worker copy this design exists to avoid.
- vdjtools.signature.corpus.POOL_CHUNKS: int = 64#
Chunks each locus’s pool is generated in. Fixed, so the pool is a function of the build and not of the machine: a chunk count taken from the core count would make an artifact built on 64 cores differ from the same build on 16. Sixty-four because ONE CHUNK IS THE FLOOR on wall time – it cannot be split further, however many cores the machine has. At 16 chunks IGH’s 3.3M-sequence pool is 207k per chunk, which is 49 s at the measured 4,200 seq/s; at 64 it is 12 s. Changing this value changes the pools and so the artifact, which is why it is a recorded constant.
- vdjtools.signature.corpus.build_matrices(regime, *, sig, featurise, vocab, loci=('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL'), n_samples=10000, size=10000, seed=20260927, source='olga', n_jobs=1, progress=None, tmp=None, depth_spread=None, cohort=None)[source]#
The corpus matrix,
{locus: (array, columns)}, built acrossn_jobsprocesses.Shared by both halves of the signature:
vsigandrsigdiffer only infeaturiseandsig, and the samples they see are identical – same pools, same per-sample seeds, same depths – which is what makes the two artifacts joinable onsample_id.Parallel across samples, never inside one. A sample’s featurisation is a pure function of that sample, so a worker needs no coordination; and because every worker draws its own samples from memory-mapped pools, the only thing that crosses a process boundary is one row of numbers per sample. Shipping drawn repertoires instead would be ~38 GB of pickling at the shipped size.
- Parameters:
featurise – A picklable callable mapping
{locus: frame}to(raw, channels)– a module-level function or afunctools.partialover one, never a lambda.n_jobs (int) – Worker processes;
0means every core available.1runs in-process and is the default, so that a doctest or a 12-sample test does not spawn a pool.tmp (Path | None) – Scratch directory for the pool files. A private temporary directory by default, removed when the build finishes.
regime (str)
sig (str)
vocab (dict)
n_samples (int)
seed (int)
source (str)
cohort (str | None)
- Returns:
{locus: (matrix, columns)}, the matrix being(n_samples, p_locus)float64.- Return type:
- vdjtools.signature.corpus.single_threaded_children()[source]#
Make spawned workers take ONE kernel thread each, by setting the env they inherit.
n_jobsprocesses each starting a kernel sized off the core count iscores x coresthreads. Measured here: seven pool workers on a 16-core box, each with a 16-thread polars, turned 15 s of receptor generation into 296 s. The variables have to be set in the parent, before any child exists – a spawned child reads them while importing polars, which is strictly earlier than any initializer of ours can run. The parent’s own kernel is already up, so this does not throttle it.- Return type:
None
- vdjtools.signature.corpus.sample_stream(regime, *, loci=('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL'), n_samples=10000, size=10000, seed=20260927, source='olga', progress=None, tmp=None, n_jobs=1, depth_spread=None, cohort=None)[source]#
Set up the pools in this process and return
(ordered_loci, draw);draw(j)is sample j.The in-process path, for callers that want the repertoires themselves rather than a corpus matrix.
drawmay be called in any order – seedraw_one().
- vdjtools.signature.corpus.synthesize(corpus_name, *, loci=('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL'), n_samples=10000, size=None, seed=20260927, n_components=128, mode='features', winsor_p=0.01, source='olga', fit_corpus=True, progress=None, n_jobs=1, depth_spread=None)[source]#
Build a synthetic corpus, and fit it.
- Parameters:
corpus_name (str) – One of
SYNTHETIC–"naive"and"memory"are the pure regimes at a nominal size;"synthetic-blood"and"synthetic-tissue"are the naive/memory mixture drawn across the named cohort’s measured per-locus richness, read-depth and singleton-fraction bands (COHORT), and are the two that describe a real bulk cohort rather than a regime.loci (tuple[str, ...]) – Loci to build. All seven by default.
n_samples (int) – Repertoires in the corpus.
size (int | str | None) – Receptors per repertoire.
None, the default, is the corpus’s own – 10,000 for the pure regimes, the geometric centre of the cohort’s measured richness band for asynthetic-*one. Also takes"n_eff"for the per-locus real medians inN_EFF,"p05"/"p95"for the sweep endpoints, or a cohort name.seed (int) – Base seed; every draw is a recorded offset from it.
n_components (int | float) – Components per locus, or a variance fraction.
mode (str) – Winsorization mode to fit at.
winsor_p (float) – Percentile for the fitted bounds. All of
WINSOR_PSare stored.source (str) – Bundled model set –
"olga","learned"or"arda".fit_corpus (bool) –
Falsereturns the raw samples instead of fitting, for scoring a held-out draw against a corpus fitted on another.progress – Optional
callable(locus, done, total).n_jobs (int) – Worker processes (not kernel threads).
0means every available core;1, the default, runs in-process. The result is bit-identical at every value.depth_spread (float | str | None) – Multiplicative depth range each repertoire’s size is drawn log-uniformly across, around
size.Noneis the corpus’s own –DEPTH_SPREADfor a pure regime (2.4x-11.0x), the cohort’s measured richness band for asynthetic-*one (43x on blood TRB, 259x on tissue IGH). Pass a number to override it:1000withsize=3162spans 100 to 100,000 receptors per locus. The bounds, centre and per-PC scaling are all estimated from the draw, so a corpus describes only the depths it was drawn across.
- Returns:
(corpus, mats)when fitting –matsbeing{locus: (matrix, columns)}, the corpus matrix itself – else a list of{locus: frame}samples.
The whole build is a deterministic function of
(corpus_name, loci, n_samples, size, seed, source)and the bundled models, so two machines at different thread counts must produce byte-identical artifacts. That is an acceptance criterion, not a hope:generatewas once irreproducible across processes while being deterministic within one, because a marginal-table aggregation left its group order unspecified.
- vdjtools.signature.corpus.corpus_meta(regime, cohort, loci, *, n_samples, size, seed, source, depth_spread, **extra)[source]#
The manifest of a synthetic build – everything needed to reproduce the draw, per locus.
Shared by both halves, because a manifest that disagrees between
vsig_<name>andrsig_<name>about the depths, the singleton fractions or the germline they were drawn from is a joinability claim nobody can check.
vsig and vsig_cohort: raw features, a corpus rotation, and the emitted vector.
Three steps, each owned elsewhere and joined here:
features.raw_and_channels(sample, vocab) -> raw features + channels
corpus.apply(raw, chan, corpus) -> rotated columns + channels carried through
A corpus is required. There is no default, deliberately: a silently chosen rotation is the mixed-units failure this rewrite exists to end, and two matrices rotated through different corpora are not comparable no matter how alike their column names look.
- vdjtools.signature.signature.vsig(sample, corpus, *, mode=None, winsor_p=None, n_components=None, cstar_target=None, weight='log2p1', prefiltered=False, on_duplicate='error', named=(), columns=None)[source]#
The statistics half of the signature for one sample.
- Parameters:
sample –
{locus: frame}, one frame with alocuscolumn, or a zero-argument callable returning either.mode (str | None) – Winsorization mode override –
"features","pcs"or"none". Defaults to whichever the corpus was fitted with.winsor_p (float | None) – Which stored percentile to clamp at. Defaults to the fitted one.
n_components (int | float | None) – Truncate to this many components, or this cumulative variance fraction.
cstar_target (float | dict[str, float] | None) – Coverage level the Hill numbers are read at.
Noneuses each locus’s own attained coverage, which makes them observed rather than standardised – honest for one sample, and not comparable across samples. Usevsig_cohort()for a cohort, which resolves a shared level.prefiltered (bool) – Report
qc:*:nonstd_aa_fracasnanbecause the caller already filtered.on_duplicate (str) –
"error"or"sum", for a frame repeating an amino-acid clonotype key.named (bool | Sequence[str]) –
Also return the reportable raw blocks –
Truefor all of them, or a sequence such as("div", "depth", "clon").(), the default, emits exactly the rotated columns and channels. Values carry their declared transform (log10,clr,logit, …), whichchannel_table()reports.These are the diversity, depth, clone-size, junction-length, isotype, SHM and cross-locus yield numbers. They are computed either way, because the rotation is fitted on them; without this argument there is no supported route to reading them back, and a study that cannot report its own diversity floor has no floor. See
channel_table()for the full list and its units.Restrict the output to these columns, in layout order.
Unlike the system this replaces, this does not skip work: the rotation for a locus is fitted jointly over every raw group at that locus, so every group must be computed before any of that locus’s components exist. Declining a locus entirely does skip it.
- Returns:
{column: value}in layout order, holes asnan.- Return type:
- vdjtools.signature.signature.attained_coverage(sample, *, on_duplicate='error')[source]#
Per-locus attained Chao coverage for one sample – the cheap first pass.
Only the count column is touched, so this is a small fraction of the cost of the full feature set and is what lets a cohort agree on one coverage level without anybody choosing a constant.
- vdjtools.signature.signature.vsig_cohort(samples, corpus, *, n_jobs=1, cstar_target='min', columns=None, **kw)[source]#
One row per sample,
sample_idfirst.- Parameters:
samples –
{sample_id: sample}or an iterable of pairs. A sample may be a zero-argument picklable callable, which defers the read into the worker and keeps peak memory atO(n_jobs)samples rather than the whole cohort.corpus (Corpus) – A fitted corpus.
n_jobs (int) – Worker processes.
1stays in-process,0uses every available core.cstar_target (float | dict[str, float] | str | None) –
"min"(default) reads every sample’s attained coverage first and standardises the whole cohort to the per-locus minimum, so the diversity columns are comparable across samples and the level is a property of this cohort rather than of somebody’s corpus. That is a cheap extra pass over the count column – but it does resolve deferred samples twice, so pass an explicit float or dict for a one-pass run.Nonereads each sample at its own coverage: one pass, not comparable across samples.**kw – Forwarded to
vsig()– includingnamed=, which adds the reportable raw blocks in their own units.
- Returns:
A frame whose columns are
sample_idthen the corpus’s signature columns.- Return type:
DataFrame
Variance-stabilising transforms.
Every signature column is a different kind of number — a read count, a Hill number, a share of
a composition, a coordinate of an embedding — and a downstream model should not have to know
which. Each raw feature therefore declares one transform
(vdjtools.signature.layout), applied where the feature is computed, so that what reaches
the rotation is dimensionless and roughly symmetric.
Standardisation, winsorization and rotation are not here – they need a corpus, and they live
in vdjtools.signature.corpus. This module is a pure function of one sample.
Small denominators are the whole problem. At the depths this signature has to work at — a
median of order a hundred clonotypes, a quarter of samples below ten — a “fraction” is very
often 0/3. A transform that sees only the ratio cannot tell 0/3 from 0/500, and maps
both to the same place, asserting a precision the data does not have. So every transform of a
proportion takes the denominator as well as the value, and shrinks toward the middle in
proportion to how little was counted. That is the Haldane–Anscombe correction for a logit, the
Anscombe correction for an arcsine, and a count-scaled multiplicative replacement for a CLR.
None of these are free parameters: 1/2 and 3/8 are the standard bias-minimising choices.
- vdjtools.signature.transform.log10(x, floor=1.0)[source]#
log10of a positive quantity, floored so an empty locus maps to 0 rather than -inf.Used for counts and for Hill numbers. The floor is 1 because both are counts of things: one clonotype, one read, one effective species. Zero of them is the same as the floor for every downstream purpose, and the presence mask already records that the locus was empty.
- Parameters:
floor (float)
- vdjtools.signature.transform.log1p(x)[source]#
log(1+x)for a non-negative quantity that genuinely reaches zero.Distinct from
log10()in intent: this is for norms, dispersions and hit counts, where zero is a real, attainable value rather than an empty measurement.
- vdjtools.signature.transform.logit(x, m)[source]#
Haldane–Anscombe logit of a proportion observed on a denominator
m.log((x·m + 1/2) / ((1−x)·m + 1/2)). Adding half an observation to each side is the standard remedy for an empty cell; its side effect is exactly the behaviour wanted here — the transform of0depends on how many chances there were to see something:logit(0, m=3) -> -1.95 a fifth of the repertoire could hide here logit(0, m=500) -> -6.91 it is really absent
- Parameters:
x – Proportion(s) in
[0, 1].m – Denominator(s) the proportion was observed on. Broadcasts against
x.
- Returns:
The transformed value, finite for every input including exactly 0 and exactly 1.
- vdjtools.signature.transform.arcsine(x, m)[source]#
Anscombe’s variance-stabilising arcsine transform,
asin(sqrt((x·m + 3/8)/(m + 3/4))).The right transform for a sparse composition — residue and gene-usage profiles, where most cells are structurally zero at shallow depth. Unlike a CLR it is defined at zero without any replacement step, and unlike a raw proportion its variance does not collapse near the boundary. Bounded in
[0, π/2], so it cannot produce the heavy tail a log-ratio would.- Parameters:
x – Proportion(s) in
[0, 1].m – Denominator(s) the proportion was observed on.
- vdjtools.signature.transform.clr(parts, m=None, *, keys=None)[source]#
Centred log-ratio of a composition, with multiplicative zero replacement.
A CLR is the natural coordinate for a composition whose ratios carry the meaning: it is the log of each part over the geometric mean of all of them, so it is invariant to the total and a difference between two coordinates is a log-ratio of two parts.
Zeros are replaced multiplicatively, not additively: each zero part is set to
delta = 0.5/mand the non-zero parts are scaled down by1 − n_zero·deltaso the composition still closes. Adding a constant to every part instead — the common shortcut — distorts the ratios among the parts that were observed, which are the only ratios the coordinate system is about.Compute the CLR over the whole composition, then select coordinates. A CLR of a sub-composition is a different number from the corresponding coordinate of the full one, so a tier that ships four of six isotype parts must still divide by the six-part geometric mean. Doing it the other way would make the narrower tier stop being a slice of the wider one, which is the contract the layout exists to guarantee.
- Parameters:
parts – Non-negative part values as a mapping
{name: value}or an array. They need not sum to 1; they are closed here.m – Total count the composition was observed on, setting the replacement scale. Defaults to the sum of
partswhen they are counts.keys – Part order when
partsis an array. Ignored for a mapping.
- Returns:
{name: clr}whenpartsis a mapping (orkeysis given), else an array. The coordinates sum to zero by construction, which is why the layout ships all but one of them: the last is exactly determined by the rest and would make any unregularised design matrix singular.- Raises:
ValueError – If fewer than two parts are given, or any part is negative.
- vdjtools.signature.transform.apply(code, x, m=None)[source]#
Apply the transform named by
codeto one value or array.- Parameters:
code (str) – A transform code from
vdjtools.signature.layout.TRANSFORMS.x – The value(s).
m – The denominator, required by
logitandarcsine.
- Raises:
ValueError – If
codeis unknown, if a denominator is missing for a transform that needs one, or ifcodeis"clr"(callclr()with the whole composition).