The portable repertoire signature#
One AIRR repertoire in, one fixed, named, positional feature vector out — computable by anyone
who pip install mirpy-lib, on their own samples, and directly comparable with yours. That is
the whole design goal: a matrix you can hand a collaborator that drops into PCA, logistic
regression, random forest, boosting or an MLP with no scaler of their own.
Quickstart — one command#
No Python needed. This is the command to send a collaborator:
pip install mirpy-lib
mir signature --preset classify cohort/*.tsv.gz -o sig.parquet
Files sharing a sample id (the name up to the first dot) are joined into one multi-locus sample, so a donor sequenced on TRA and TRB is one signature with both loci filled rather than two half-empty rows. AIRR Rearrangement, native vdjtools, Parquet and the usual third-party exports are auto-detected.
mir signature --preset classify --describe # the columns, reading no input
mir signature --preset classify --threads 0 cohort/*.tsv.gz -o sig.parquet # every core
mir presets # the named feature sets, ranked
mir presets classify # one in full: what, how, when
Three presets are marked recommended: compact (the smallest vector that still describes a
repertoire, usable at n = 50), classify (general-purpose, the usual random-forest / boosting
input), and transfer (for a model that must work on another lab’s samples). Unlike
vdjtools signature, which serves the vsig half only, every preset resolves here in full —
mirpy is where the two halves meet.
Warning
CDR3 vs junction. The reader prefers AIRR junction_aa (conserved anchors included) and
falls back to IMGT cdr3_aa (anchors excluded). A file carrying only cdr3_aa is two
residues short everywhere, which shifts the length, k-mer and Pgen features. Check your headers
before you trust a matrix.
Two more things worth knowing: --standardize reference (the default) is what makes your vector
comparable with anyone else’s, and you should not PCA-project the result — plain scaling beat
projection at every rank tested.
The Python API#
from mir.signature import signature, signature_cohort
v = signature({"TRB": df}) # {column: value}, standardised, layout order
F = signature_cohort(samples) # one row per sample, positional
F.write_parquet("cohort.parquet")
Two halves, one contract#
The signature is the concatenation of two vectors that answer different questions about the same
sample, joined on sample_id and namespaced so they never collide:
|
|
|
|---|---|---|
basis |
the clone-size vector and the germline vocabulary |
the prototype-sum measure \(\Phi(S) = \sum_\sigma w_\sigma z_\sigma\) |
blocks |
|
|
each column is |
a defined statistic of the clone-size vector |
a linear functional, a norm, or a mixture coefficient of \(\Phi\) |
depth and div appear on both sides deliberately. They are different objects with the
same name family — count-native Hill numbers at a frozen coverage level on one side,
embedding-native effective sample size and Rao dispersion on the other — and which one carries a
given phenotype is itself a result.
Why every column is transformed before you see it#
A learner cannot be handed a log-scaled read count, an isotype fraction and a principal component in one matrix and be expected to weight them sensibly. Each feature therefore carries a variance-stabilising transform chosen from its support, not from taste, and each of those choices is denominator-aware — the alternative silently lies about shallow samples:
transform |
where, and why that one |
|---|---|
|
counts and norms, whose spread scales with their magnitude |
|
a proportion, Haldane–Anscombe corrected, so |
|
Anscombe’s variance-stabiliser for a binomial share; defined at exactly zero |
|
a composition, over the whole composition before any coordinate is selected, shipping k−1 parts because all k are linearly dependent and would put a guaranteed zero eigenvalue in any PCA |
On top of that every column is rescaled against a frozen reference (median and \(1.4826\cdot\mathrm{MAD}\), clipped), which is what makes two people’s matrices comparable rather than each being internally consistent and mutually meaningless.
Geometry is not transformed, and that is not an oversight#
The two halves of the signature end up with almost opposite transform tables, because they hold almost opposite kinds of object:
|
|
|
|---|---|---|
dominant transform |
|
|
support |
\([0,1]\) or \([0,\infty)\), bounded, discrete |
\(\mathbb{R}\), signed, continuous |
mean–variance coupling |
yes — binomial or Poisson; variance is a function of the mean |
no — \(\operatorname{Var} \approx \sigma^2 / n_{\text{eff}}\), independent of the value |
A coordinate of \(\Phi = \sum_\sigma w_\sigma z_\sigma\) is a linear functional of a weighted
mean of fixed embedding vectors. It is signed and roughly symmetric, there is no boundary to
compress against, and its variance does not depend on its own value — exactly the condition under
which a variance-stabilising transform buys nothing. Applying one would not merely be useless:
log, logit and arcsine all require a non-negative or \([0,1]\) domain, and these
coordinates go negative. What they need instead is location–scale rescaling against the frozen
reference, which is what they get.
The exceptions prove the rule. rsig transforms exactly where the quantity stops being a
coordinate: the block norms (\(\lVert\Phi\rVert\), Rao dispersion) are non-negative,
right-skewed magnitudes on \([0,\infty)\), so they take log1p; band is a genuine closed
composition, so it takes clr.
One block breaks the pattern deliberately#
contrast — \(\Psi = \mathrm{mass}\cdot(\Phi - \mathrm{naive})\) — is flagged
magnitude=True: it is divided by one frozen scalar RMS for the whole block, and is never
centred. Per-column z-scoring would force every coordinate to unit variance, which makes a sample
sitting near zero — an immune desert, a repertoire that has barely moved from naive — look
identical to a typical one. How far a repertoire is from naive is what that block exists to
carry, so rescaling it away would delete precisely the feature. This is the opposite policy from
every other column in the matrix, and it is a property of the block, not of the sample.
How many components should you keep#
Not “enough for 90% of the variance”. In a repertoire matrix the leading variance is sequencing depth, batch and V-gene usage, so a variance-ranked criterion ranks nuisance first.
Measured on the emitted signature matrix — 14,553 samples × 1,369 columns across 182 studies,
robust median/MAD scaling with clipping (benchmark_signature_dimension.py):
criterion |
components |
what it actually measures |
|---|---|---|
90% cumulative variance |
394 |
how much of this corpus you reproduce |
Horn parallel analysis |
241 |
how many exceed a column-permuted null |
participation ratio (effective rank) |
144 |
how spread the eigenvalue mass is |
per-component \(|r| \ge 0.95\) across a study-disjoint refit |
1 |
which individual axes are identified at all |
per-component \(|r| \ge 0.90\) |
1 |
The gap between 394 and 1 is the finding, not a contradiction. Split-half correlation per component was 0.949 for PC1, 0.614 for PC2 and 0.15–0.32 from PC3 onward, while eigenvalues 2–12 sit at 73, 56, 51, 48, 44, 42, 38, 34, 32, 31, 30 — near-degenerate. Components of nearly equal eigenvalue swap order between two refits, so a per-component correlation punishes a labelling artifact rather than a stability failure. That is why the same script also reports the rotation-invariant subspace overlap \(\lVert V_a V_b^\top\rVert_F^2 / k\): the subspace can be stable where the axes are not.
So what number do you actually use? Ask the only criterion that knows what the components are for. Mean AUC over the four largest tasks (2,199–8,016 samples, 21–70 studies), study-disjoint folds, rotation refit inside every fold:
components |
8 |
16 |
64 |
256 |
512 |
all 1,369 |
|---|---|---|---|---|---|---|
mean AUC |
0.575 |
0.581 |
0.589 |
0.591 |
0.584 |
0.555 |
The curve is flat from 16 to 256 and then falls off a cliff: the full 1,369-column matrix scores
worse than 16 components. That is the curse of dimensionality, located. Per task the effect is
large — l3_covid reads 0.727 at \(k = 64\) against 0.582 on all columns; l1_infection
0.659 against 0.577.
The recommendation: 16–64 components. 16 buys 98% of the achievable AUC at a quarter of the width; 64 is the plateau; beyond 256 you are paying for noise. Keep the full matrix only when the learner is regularised for it (L1, gradient boosting) or when you are hunting a rare, sparse signal that a rotation would average away.
Practical rules that follow:
Never interpret an individual PC beyond the first as though it were a named feature. It is a coordinate of the corpus that fitted it.
Select rank by out-of-study reproducibility, not by explained variance — and prefer the subspace-overlap criterion to the per-component one, which fails on degeneracy alone.
Refit the rotation inside every cross-validation fold. Fitting it once on the whole task and cross-validating only the classifier lets the components see the test studies’ covariance.
Report a permutation null for anything chosen by looking at the labels. A maximum over 64 components reached AUC 0.84 by chance on a 26-vs-7 contrast (p = 0.20).
For a rare, discriminative signal, do not project at all — keep sparse columns and an L1 model. The SVD optimises for variance, and a motif carried by a handful of donors has none.
This is also why no corpus-fitted rotation ships in the artifact: the phiv / phij / phic
bases come from the prototype cloud, which involves zero samples and so has no corpus to be
unstable with respect to.
Holes are never zeros#
A locus that was not sequenced, a compartment with too few clonotypes to be a compartment, a
statistic the sample is too shallow to estimate — each yields nan and a mask: column. A
model that reads “absent” as “zero” reads an unsequenced chain as a biological finding. Most
learners take nan natively; those that do not should impute and keep the mask.
The signature filters for you — do not pre-filter#
signature() sanitises before it embeds, and sanitise=True is the default.
Leave it there. The reason is specific to the geometry: a stop codon does not raise in the
distance code. * is in seqtree’s alphabet, so an unfiltered frame used to return a finite,
meaningless distance and contaminate Φ silently — which is strictly worse than crashing.
Since 3.12.0 embed() refuses it instead:
junction |
default |
|
|---|---|---|
|
raises |
raises |
outside |
raises — a corrupt table |
raises |
|
raises — crashes |
raises |
|
raises |
embedded |
allow_nonstandard covers stop codons and nothing else. A guard against a crash, and a guard
against a damaged file, cannot be switched off; only the guard against a silently-wrong number has
an opt-out, and taking it has to be written down. Measured over 6,047,716 rows of real clinical
AIRR: zero corrupt characters and zero _, so the strict default costs nothing on well-formed
data.
Neither predicate filters on length — a two-residue junction and a sixty-residue one both pass. The question asked is only whether the string is a plain amino-acid string.
What pre-filtering actually changes. Exactly one column per locus,
vsig:qc:<locus>:nonstd_aa_frac, because sanitise reports the weight fraction it dropped and
a pre-filtered frame has nothing left to drop. Measured on 1,168 blood samples from a clinical AIRR cohort at
tier="standard": of 688 columns, 7 move and 681 are bit-identical — including all 528
rsig geometry columns, which do not move because rsig is handed sanitised frames either
way. Under compact, transfer or classify — every recommended preset — zero
columns move, because they drop the qc block. If you want the column honest on a pre-filtered
corpus, pass signature(..., prefiltered=True) and it reports a hole rather than a floor.
Tiers#
core ⊂ standard ⊂ full, as exact index subsets of one frozen layout — so a narrower
tier is a slice of a wider one and never a differently-computed number.
from mir.signature import columns, describe
len(columns("core")), len(columns("standard")), len(columns("full"))
describe("standard") # column, sig, block, locus, feature, tier, transform, flags
What is fitted on data, and what is not#
Two artifacts ship, and the split is the design.
the geometry artifact |
the scale artifact |
|
|---|---|---|
holds |
slot rotations, prototype-cloud location and scale, the naive reference |
per-column location and scale, the measured |
fitted on |
nothing — bundled resources only |
a reference corpus draw |
re-fit risk |
none; the rebuild is bit-identical |
low; a median and a MAD are identified at any n |
Fitting a scale and fitting a basis are different statistical problems, and only one of them is safe at the sample sizes anyone actually has. A rotation over \(p = 256\) coordinates per slot is not column-identified at a few thousand samples — measured split-half column agreement of a fitted junction basis is 0.23 — whereas a per-column median and MAD converge as \(1/\sqrt{n}\). So the rotation is taken from the prototype cloud instead: bundled receptors embedded against bundled receptors, zero samples, nothing to re-fit and nothing of any corpus in it.
Batch is the thing to check first#
A frozen reference removes the scaling difference between two cohorts. It does not remove a
batch effect, and nothing in this vector should be read as if it did — sequencing protocol, depth
and sample handling all move real columns. Use the depth: columns as covariates, and check a
batch label before believing a between-cohort contrast.
How strong is that warning? On a clonal-density read-out, the flagged fraction fell from 19.4% to 0.9% when the background was drawn from within the same study instead of across studies. Nearly all of it was batch.
Regenerating these numbers#
Every measured table on this page comes from a script in the companion 2026-mirpy-analysis repo, so it can be re-measured rather than believed:
script |
what it re-measures |
|---|---|
|
variance / Horn / effective rank / split-half rank criteria, both scaling arms |
|
how many samples a frozen median and MAD need |
|
prototype-cloud rotation vs a corpus-fitted one |
|
the within-study vs cross-study background comparison |
Measurements on this page were last taken 2026-08-13.
Feature presets — pick by intent, not by column#
The signature is over 1,400 columns. Almost nobody wants all of them, and which subset is right
depends on the question — a model that must run on another lab’s samples wants different columns
from one scoring samples inside a single study. vdjtools.signature.presets names those
choices, documents each, and ranks it:
- recommended
Use this unless you have a reason not to.
- specific
Correct for a stated purpose and wrong outside it.
avoidA control, a baseline, or a measured dead end. Named so that choosing it is deliberate.
preset |
rank |
columns |
what it is |
|---|---|---|---|
|
recommended |
152 |
The smallest vector that still describes a repertoire. Start here. |
|
recommended |
550 |
For models that must work on another lab’s samples. Drops the columns whose level moves most between studies. |
|
recommended |
615 |
The general-purpose set. Best measured task performance when train and test come from comparable cohorts. |
|
specific |
101 |
Classical repertoire statistics only. Needs no embedding, so vdjtools alone suffices. |
|
specific |
286 |
B-cell receptor work: the immunoglobulin loci with somatic hypermutation and isotype. |
|
specific |
514 |
Embedding coordinates only — no count statistics at all. |
|
specific |
1403 |
Every contract column. For feature selection, not for fitting. |
|
|
73 |
Sequencing protocol only. A control, not a feature set. |
Every preset resolves to a column list from the frozen layout alone — block names, loci, tier. No corpus, no fitted artifact and no private data is involved, so two people selecting the same preset get the same columns in the same order.
mir presets # the table above
mir presets transfer # one preset in full: features, how, use cases, caveats
mir signature *.tsv --preset transfer --describe # the exact columns it selects
from vdjtools.signature import presets
presets.get("transfer").rank # 'recommended'
cols = presets.columns("compact") # a concrete, ordered column list
presets.table() # the whole registry as a DataFrame
Where the rankings come from#
A benchmark over a public multi-study AIRR corpus — several hundred study groups, tens of thousands of samples — scored with study-disjoint folds: fit on some studies, predict on studies the fit never saw. Under that split a column that merely encodes sequencing protocol scores at chance, which is the point. Three findings shaped the presets:
A nuisance floor of depth + presence masks + call quality is a surprisingly strong predictor on many contrasts. Any feature set worth using has to beat its own floor, which is why
nuisanceships as a named control rather than being hidden.Projection did not help. Plain robust or
asinhscaling beat PCA at every rank tested, so no preset projects by default andfullis documented as a feature-selection tool rather than a model input.The two halves have opposite nuisance profiles. The embedding geometry carries several times less study-to-study variance than the count statistics and the most donor-to-donor variance, and is nearly unaffected by whether a sample is blood or tissue — but wins fewer supervised tasks outright. Hence
transferandgeometryfor robustness,classifyandstatisticsfor raw accuracy.
Anyone with a comparable SRA/AIRR corpus can reproduce this; none of it depends on a private dataset.
API#
mir.signature — the geometry half of the portable repertoire signature.
The column contract lives in vdjtools.signature, which mirpy already depends on, so
there is one implementation of the layout, the transforms and the frozen reference rescaling
rather than two that can drift apart. This package supplies the rsig blocks: features of
the prototype-sum measure Φ(S) = Σ_σ w_σ z_σ, each a linear functional, a norm, or a
mixture coefficient of it.
- mir.signature.columns(tier='standard', sig=None, blocks_=None)[source]#
The column names of a signature, in emitted order.
- Parameters:
- Returns:
Column names as
<sig>:<block>:<locus>:<feature>.- Raises:
ValueError – If
tieris unknown, orblocks_names a block that is not registered.- Return type:
- mir.signature.describe(tier='standard', sig=None)[source]#
The column dictionary — one row per column, so a collaborator can read the contract.
- mir.signature.load_reference(path=None)[source]#
Load (and cache) the frozen reference.
- Raises:
FileNotFoundError – If the artifact is missing — with the command that rebuilds it, since it is derived entirely from bundled resources and needs no data to regenerate.
- Parameters:
- Return type:
- mir.signature.rsig(sample, *, tier='standard', species='human', weight='log2p1', reference=None, chunk=50000, min_clonotypes=5)[source]#
The
rsig(geometry) half of one sample’s signature, as{column_name: value}.- Parameters:
sample –
{locus: clonotype frame}, or one frame with alocuscolumn. Frames should already be sanitised (vdjtools.signature.blocks.sanitise); malformed junctions are silently accepted by the embedder and would quietly contaminate every coordinate here.tier (str) –
"core","standard"or"full".species (str) – Prototype panel species.
weight (str) – Clone-size weight
g.reference – A
SignatureReference, orNonefor the bundled one.chunk (int) – Rows embedded at a time; bounds memory, not time.
min_clonotypes (int) – Floor for a compartment to count as present. Not a floor on the sample — a whole repertoire is never dropped for being small.
- Returns:
Every column the layout lists for
tierand"rsig", in order,nanwhere the sample could not support one.- Raises:
ValueError – If
tieris unknown.- Return type:
- mir.signature.self_test(path=None)[source]#
Check the installed reference end to end, and return what it found.
Verifies the prototype hashes, then embeds a fixed synthetic repertoire and confirms the projection is finite, the right width, and — the property that matters — that centring actually buys discrimination. Cheap enough to run on import in a notebook.
- mir.signature.signature(sample, *, tier='standard', species='human', weight='log2p1', reference=None, scale=None, standardize='reference', clip=8.0, sanitise=True, prefiltered=False, **vsig_kw)[source]#
Both halves of one sample’s signature, concatenated — the hand-off object.
With
standardize="reference"(the default) every column is rescaled against the frozen reference, so the result is dimensionless and on a common scale and a downstream model needs no scaler of its own. That is the entire point: a collaborator’s matrix and ours are directly comparable, rather than each being internally consistent and mutually meaningless.- Parameters:
sample –
{locus: clonotype frame}or one frame with alocuscolumn.tier (str) –
"core","standard"or"full".species (str) – Prototype panel species.
weight (str) – Clone-size weight
g, shared by both halves so they describe one measure.reference – Frozen geometry reference, or
Nonefor the bundled one.scale – A
ScaleReference, orNonefor the bundled one.standardize (str) –
"reference"to rescale against it,"none"for raw values. Asking for"reference"when none is installed raises rather than silently handing back raw numbers that look standardised.clip (float) – Bound in robust standard deviations when standardising.
sanitise (bool) – Drop unusable clonotypes first. Leave this on. Turning it off no longer contaminates the geometry silently —
TCREmp.embednow refuses a junction outside the 20 standard amino acids — but it does mean the call raises rather than returning a vector, which is the intended outcome and not a reason to reach forallow_nonstandard.prefiltered (bool) – Say
Truewhen non-functional rearrangements were already removed upstream.vsig:qc:*:nonstd_aa_fracthen reportsnanrather than a confident floor value, because on a pre-filtered input there is nothing left to measure. This is the ONLY part of the vector that pre-filtering affects: every other column is computed on the rows that survivesanitise, and pre-filtering does not change which rows those are. If you do not want the column at all, drop it with a preset (classify,transferandcompactall do) — that is exact, whereas filtering the repertoire is not.**vsig_kw – Forwarded to
vdjtools.signature.vsig(cstar,pgen_q05,strict…).cstarandpgen_q05default to the measured values in the scale reference.
- Returns:
{column: value}forvsigthenrsig, in layout order.- Raises:
ValueError – If
standardizeis unknown, or is"reference"with none available.- Return type:
- mir.signature.signature_cohort(samples, *, tier='standard', n_jobs=1, columns=None, **kw)[source]#
Assemble a cohort: one row per sample,
sample_idplus every signature column.- Parameters:
samples –
{sample_id: {locus: frame}}or an iterable of(sample_id, frames).tier (str) – Column tier.
n_jobs (int) – Worker processes.
1runs in-process;0uses every core. A cohort is embarrassingly parallel over samples – each is independent and the frozen artifacts are read-only – and per-sample cost runs from milliseconds on shallow blood to minutes on a deep tissue biopsy, so this is the difference between minutes and hours.columns (list[str] | None) – Explicit column subset (e.g. from a preset). Defaults to the whole tier.
- Returns:
One row per sample,
sample_idfirst, then the requested columns in layout order.- Return type:
DataFrame
mir.signature.blocks#
The geometry half of the signature — features of the prototype-sum measure.
Every column here is a linear functional, a norm, or a mixture coefficient of one object:
Φ(S) = Σ_σ w_σ z_σ z_σ = TCREmp row for clonotype σ, w_σ = g(a_σ)/Σg
That object is fit-free: z_σ is a vector of distances to a fixed, bundled prototype
panel, so no basis is estimated from anybody’s cohort and two people who never share data land
in the same coordinate system. It is also linear in the clone-weight measure, which is what
makes the compartment shares well-posed rather than heuristic: for a genuine partition
Φ(S) = Σ_c π_c Φ(c) holds exactly, so π can be measured instead of assumed.
Three structural facts the layout depends on, all verified against the library:
TCREmp.embedlays columns out interleaved per prototype as[V, J, junction], so the three slots are the exact stridesΦ[0::3],Φ[1::3],Φ[2::3]. Attribution across them is exact, not a surrogate model.Prototype
k’s three columns never move as the panel grows, so aK-prototype embedding is a bit-exact prefix of a larger one. Widening the panel is therefore a superset, never a new coordinate system.Distances to an unrecognised V or J allele silently take the germline max-distance fallback. Nothing here can detect that;
vsig:qc:*:*_fallback_fracreports it, and a vector with a high fallback fraction is not comparable to one without.
Φ must be centred against the frozen reference before it means anything. Every prototype
distance is large and positive, so every repertoire’s Φ sits in almost the same place:
measured on eight unrelated donors, the cosine between different people’s raw Φ spans
0.9989–0.9999, and the shared offset is about 55× the between-donor signal in norm. Subtract
the reference mean and the same eight span −0.81 to +0.66 — roughly a thousandfold more
discriminative. So centring is not a tidying step: without it the leading component is the
constant everyone shares and the identity block is nearly blank.
The centre is the naive reference, not the prototype-cloud mean — both are fit-free, but
they average different things, and only one is where repertoires live. See
mir.signature.reference.LocusReference.standardize() for the measurement. The same
argument is why contrast() subtracts that reference
rather than reporting Φ directly — it lives there, with the naive it needs, not here.
- mir.signature.blocks.CHUNK = 50000#
Rows embedded per chunk. The embedding itself is cheap — measured 0.01 s for 2,000 clonotypes at K=512 — so this exists to bound memory, not time: the full matrix for a 500,000-clonotype sample would be 3 GB at K=512, while the accumulators are 1536 floats.
- mir.signature.blocks.BANDS: dict[str, callable] = {'middle': <function <lambda>>, 'singleton': <function <lambda>>, 'top': <function <lambda>>}#
Abundance compartments, as row predicates over the clone-size vector. A partition — unlike
mir.repertoire.band_frames, whosetopis deliberately a subset ofexpanded. The mixture identity is only exact for a partition, and an NNLS over overlapping parts is not a composition at all: its weights need not sum to one and an individual share can exceed it. These are depth-fragile, and deliberately not corrected. An abundance compartment’s share genuinely moves with sequencing depth: the singleton fraction grows as rarer clones are sampled, and a 1% quantile selects 20 clonotypes in a 2,000-clonotype sample against 1,000 in a 100,000-clonotype one. Measured on one repertoire across a 67x depth range,band:topspans about 6.9 in log-ratio coordinates. Bounding the quantile to a clonotype count (asmir.repertoire.band_frames’top_clipdoes) was tried and merely relocated the discontinuity, so it was reverted rather than shipped as a fix.The signature’s answer to a depth-fragile column is not to correct it but to carry the covariate:
vsig:depth:*andrsig:depth:*are in every tier precisely so a downstream model can adjust.vsig:clon:*:clr_f1is documented the same way for the same reason.
- mir.signature.blocks.ISOTYPE_BANDS: dict[str, tuple[str, ...]] = {'IgA': ('IGHA1', 'IGHA2'), 'IgG': ('IGHG1', 'IGHG2', 'IGHG3', 'IGHG4'), 'IgM': ('IGHM', 'IGHD')}#
IGH isotype compartments, by constant-gene call.
IGHGPis a pseudogene andIGHCis ambiguous, so neither is called; roughly two fifths of IGH reads carry no call at all and form their own part rather than being folded into IgM.
- mir.signature.blocks.weights(counts, weight='log2p1')[source]#
Normalised clone weights
w = g(a)/Σg.- Raises:
ValueError – If
weightis unknown, or no clonotype carries any weight.- Parameters:
counts (ndarray)
weight (str)
- Return type:
ndarray
- mir.signature.blocks.prototype_sum(df, model, w, *, chunk=50000)[source]#
Φ = Σ w_σ z_σand its Rao dispersion, in one chunked pass.Both quantities are running sums over the rows, so the full
(n, 3K)matrix is never held: the accumulators areΣ w zandΣ w‖z‖², and Rao’sQtelescopes out of the pair as2(Σw‖z‖² − ‖Φ‖²)(seemir.repertoire.rao_dispersion()).
- mir.signature.blocks.slots(phi)[source]#
Split
Φinto itsV/J/junctionstrides.Exact by construction — these are literal column strides of the embedding, not an attribution model — which is what makes “how much of this distance is V?” answerable without SHAP, sampling, or a surrogate.
- mir.signature.blocks.depth_block(counts, w, mass)[source]#
Effective size and retained mass — the geometry’s own reading of depth.
n_eff = 1/Σw²is a Hill number of the weights the geometry actually uses, which is not the same quantity as the richness of the count vector: it says how many clonotypes are effectively contributing toΦ, and so predicts how noisy this sample’sΦis.massis1 − M₀, the share of the repertoire that was ever drawn.
Compartment shares of
Φ, in closed form rather than by NNLS.Because
Φis linear in the clone-weight measure and the compartments partition the clonotypes, the share ofΦowned by compartmentcis just its share of the weight:Φ(S) = Σ_c π_c Φ(c) with π_c = Σ_{σ ∈ c} w_σexactly, with no fitting. Solving a non-negative least squares for the same quantity — the obvious alternative — is both slower and worse posed: over overlapping compartments the weights need not sum to one and a share can exceed it, which then breaks any log-ratio coordinate downstream.
A compartment below
min_clonotypesis recorded absent (its share is dropped from the composition) rather than set to zero. Zero is a measurement; absent is not.Takes no embedder and no
Φ: by the identity above the shares are a property of the weights alone, so asking for either would be asking the caller to compute something this cannot use.- Returns:
{band: share}over the bands that cleared the floor, plus_residualfor whatever no compartment owned. Shares are raw, un-transformed; the caller closes them into log-ratio coordinates.- Parameters:
- Return type:
Isotype shares of
Φ(IGH), by the same mixture identity asband_shares().A share of the geometry, which is a different quantity from the read fraction the statistics half reports — the two answer different questions and the signature carries both rather than picking the flattering one.
mir.signature.reference#
The frozen reference: load it, check it is the one you think, and project through it.
A signature is only portable if everyone projects through the same basis. This module owns
that basis — the artifact built by mir/resources/signature/build_rsig.py — and the one
operation that turns a raw prototype-sum into signature coordinates:
Φ → (Φ − naive)/sd_phi → split into V/J/junction slots → rotate by R_*
The centring is not cosmetic. Every prototype distance is large and positive, so all repertoires
sit in nearly the same place: across unrelated donors the raw between-donor cosine spans about
0.001, while the shared offset is ~55× the between-donor signal. Rotate without subtracting a
centre and the leading component is the constant everyone shares, so the identity block comes out
nearly blank. Centred, the same donors span 1.48. The centre is naive rather than the
prototype cloud’s own mu_phi, which is a measured distinction — see
LocusReference.standardize().
Comparability is checked, not assumed. The artifact records the prototype hash it was built
against, and SignatureReference.verify() refuses a mismatch rather than silently producing
numbers in a different coordinate system that look perfectly reasonable. That is the same
contract RepertoireSpace and CodecBundle enforce.
- mir.signature.reference.DEFAULT_PATH = PosixPath('/home/runner/work/mirpy/mirpy/src/mir/resources/signature/rsig_v2.npz')#
Where the bundled artifact lives. Alongside the germline distance matrices and the prototype panels, since it is the same kind of object: a versioned, baked resource.
- mir.signature.reference.SLOTS: dict[str, tuple[str, int]] = {'C': ('phic', 2), 'J': ('phij', 1), 'V': ('phiv', 0)}#
Slot name in the artifact -> the layout block it feeds, and its stride in
Φ.
- class mir.signature.reference.LocusReference(mu_phi, sd_phi, naive, naive_sem, rotations, prototype_hash, gaps=None)[source]#
Bases:
objectThe frozen basis for one locus.
- Parameters:
- mu_phi: ndarray#
- sd_phi: ndarray#
- naive: ndarray#
- naive_sem: ndarray#
- gaps: dict[str, ndarray] | None = None#
slot -> (k,) relative eigenvalue gap to the next component. See
exchangeable().
- exchangeable(slot, *, tol=0.02)[source]#
Indices of components whose neighbour sits within
tolrelative eigenvalue.Such a pair spans a well-determined plane, but which of the two is the earlier coordinate is determined by nothing: rebuild the artifact against a different panel size or a different LAPACK and they exchange. Measured on the junction slot, components matched by
|cos|between a 5,000-prototype rotation and the whole-panel one drop to 0.01 exactly at the near-degenerate pairs while their neighbours stay above 0.95.The shipped artifact is frozen, so nothing exchanges in practice. It matters for what a coordinate is allowed to mean: a linear model spanning the plane is unaffected, a per-coordinate feature-importance read-out on one of the pair is not interpretable.
Returns an empty array if the artifact predates the stored gaps.
- standardize(phi)[source]#
(Φ − naive)/sd_phi— centre on an unselected repertoire, then scale.The centre is
naive, notmu_phi, and the difference is large. Both are fit-free, but they are means over different things:naiveis a sample-levelΦ(an unselected repertoire, weighted over its clonotypes), whilemu_phiaverages the prototype panel, which is a differently-constituted set. Real repertoires live near the former. Measured on two cohorts, between-donor cosine spread after centring:centre
cohort A
cohort B
none (raw)
0.0105
0.0010
mu_phi(prototype cloud)0.4008
0.0785
naive(unselected)1.2660
1.6148
own sample mean (oracle)
1.9031
1.9088
naiverecovers 67–85% of what an oracle centred on the cohort’s own mean achieves;mu_phirecovers 4–21%. The residual offset explains it:‖mu_phi − sample mean‖is 5–7× the between-donor spread,‖naive − sample mean‖well under it.mu_phiis still shipped, because it is the centre the rotation was fitted against and is needed to reproduce the artifact — it is simply not the right origin for a sample.- Parameters:
phi (ndarray)
- Return type:
ndarray
- project(phi, slot, k=None)[source]#
Standardise
Φ, take one slot’s stride, and rotate into the frozen basis.- Parameters:
- Raises:
ValueError – If
slotis unknown,phihas the wrong width, orkexceeds what the artifact stores.- Return type:
ndarray
- contrast(phi, mass)[source]#
Ψ = mass·(Φ − naive)in raw prototype coordinates.Deliberately not standardised by
sd_phi: this block carries its meaning in its magnitude, and a per-coordinate rescale would make a sample that deviates barely at all look like one that deviates a lot.- Parameters:
phi (ndarray)
mass (float)
- Return type:
ndarray
- class mir.signature.reference.SignatureReference(loci, meta, path)[source]#
Bases:
objectThe frozen bases for every locus the artifact covers.
- Parameters:
loci (dict[str, LocusReference])
meta (dict)
path (Path)
- loci: dict[str, LocusReference]#
- verify(*, species='human')[source]#
Check each locus’s basis against the prototype panel currently installed.
- Returns:
{locus: True}when every hash matches.- Raises:
ValueError – On the first mismatch. Loud, because the failure it prevents is silent — a mismatched panel still yields a full, plausible vector, in coordinates nobody else shares.
- Parameters:
species (str)
- Return type:
- mir.signature.reference.load_reference(path=None)[source]#
Load (and cache) the frozen reference.
- Raises:
FileNotFoundError – If the artifact is missing — with the command that rebuilds it, since it is derived entirely from bundled resources and needs no data to regenerate.
- Parameters:
- Return type:
- mir.signature.reference.self_test(path=None)[source]#
Check the installed reference end to end, and return what it found.
Verifies the prototype hashes, then embeds a fixed synthetic repertoire and confirms the projection is finite, the right width, and — the property that matters — that centring actually buys discrimination. Cheap enough to run on import in a notebook.
mir.signature.scale#
The corpus-fitted half of the reference: per-column location and scale.
The geometry is fit-free — a rotation and a naive reference derived from bundled resources, with no cohort involved. This module owns the one part that must come from data, because there is no other way to know it: where each column typically sits and how far it typically moves.
That is deliberately the cheap part to estimate. A rotation over p=256 coordinates is not
identified at a thousand samples, which is why none is fitted here; a per-column median and MAD
are identified at any n, converge as 1/sqrt(n), and are what the whole re-fit story rests
on. Fitting a scale is a different statistical problem from fitting a basis, and only one of them
is safe at the sample sizes anyone actually has.
Three rules the estimator follows, each because the alternative silently corrupts something:
Robust, not moment-based. Median and
1.4826·MAD. A handful of pathological repertoires in a reference corpus would otherwise set the scale for everyone.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 principal component.
Refuse a column the corpus barely saw. A location fitted on nine samples is not a reference. Below
min_n_obsthe column ships as “unscaled” and passes through, rather than carrying a confident-looking number derived from almost nothing.
It also measures the two constants the statistics half cannot pick for itself: the per-locus
coverage level cstar at which Hill numbers are compared, and the Pgen quantile below which a
clonotype counts as atypical. Both are quantiles of what the corpus actually attains, not values
chosen by taste — a textbook C* = 0.95 puts every real repertoire into extrapolation.
- mir.signature.scale.DEFAULT_PATH = PosixPath('/home/runner/work/mirpy/mirpy/src/mir/resources/signature/rsig_scale_v2.npz')#
Bundled alongside the geometry artifact.
- mir.signature.scale.MIN_N_OBS = 1000#
A column observed fewer times than this ships unscaled. A reference is a claim about a population; a hundred samples cannot support one for 716 columns.
- mir.signature.scale.MIN_N_GROUPS = 20#
…and a claim about a population is not supportable from a handful of studies either, however many samples they hold. Only checked when
group=is given. 20 is where the measured acceptance rate is still 0.16 (benchmarks/SIGNATURE_SCALE_N.md), i.e. the floor below which a reference is worse than none; the 947-group blood corpus clears it on every column with room to spare (minimum 566 groups behind any scaled column), so this costs nothing on a broad corpus and only bites on a narrow one — which is exactly the case that used to pass silently.
- mir.signature.scale.MIN_PER_GROUP = 25#
the higher floor admitted 40 of the blood reference’s 947 studies — the largest 40, which are also the most protocol-homogeneous — and missed 102 batch-loaded columns. See
_batch_ratio().- Type:
Samples a group needs before its centre counts towards
batch_ratio. 25, not 100
- mir.signature.scale.CSTAR_QUANTILE = 0.1#
real repertoires reach Good-Turing coverage 0.24-0.58, so anything near the textbook 0.95 forces every sample into extrapolation, where diversity inflates roughly tenfold.
- Type:
Coverage level, as a quantile of what the reference corpus attains. Low on purpose
- mir.signature.scale.COVERAGE_CEILING = 0.99#
Attained coverage at or above this is treated as “no singleton tail”, not as deep sequencing. Real repertoires reach 0.24-0.58; a value here means the input was truncated or pre-collapsed.
- class mir.signature.scale.ScaleReference(columns, loc, scale, n_obs, cstar, pgen_q05, meta, batch_ratio=None)[source]#
Bases:
objectPer-column location and scale, plus the constants the blocks need.
- Parameters:
- loc: ndarray#
- scale: ndarray#
- n_obs: ndarray#
- batch_ratio: ndarray | None = None#
Between-corpus spread of the column’s median over within-corpus spread, measured on the reference draw. Above 1 the column separates cohorts better than it separates donors within one. Diagnostic only — nothing here divides by it — but it is the number that says which columns a cross-cohort model should treat as nuisance.
nanwhere unmeasured.
- property scaled: ndarray#
Mask of columns with a usable scale.
- apply(values, *, clip=8.0)[source]#
Rescale one sample’s columns against the reference.
Unknown columns and columns without an established scale pass through untouched, so a caller always gets back exactly the keys it handed in. A hole stays a hole:
nanis not something to centre.
- property batch_dominated: ndarray#
Mask of columns that separate reference corpora better than donors within one.
Not a defect of the scaling — a property of the feature. Standardising cannot remove a batch effect, and a cross-cohort model should treat these as nuisance or residualise them. All
Falsewhen no grouping was supplied at fit time.
- mir.signature.scale.fit_scale(frame, *, min_n_obs=1000, min_n_groups=20, cstar=None, pgen_q05=None, group=None, weight_by_group=True, meta=None)[source]#
Fit location and scale from an assembled cohort matrix.
The estimator is the median and
1.4826·MAD, and that is a measured choice rather than a stylistic one: the columns are heavy-tailed after their block transform (excess kurtosis 0.6-207, and 0.4-3% of samples beyond five robust deviations against the 6e-7 a normal would give), so a standard deviation is set by a handful of samples and moves when they do. Against a fit on held-out studies, the moment estimator never reaches the robust one.One vote per study, not one per sample (
weight_by_group, on by default whengroupis given). Samples inside a study share a protocol, a batch and a donor pool, so an unweighted fit lets whichever studies happen to be large set the reference. Measured on 23,234 blood samples over 947 SRA study groups, six independent 70/30 study splits, scoring the fit against a fit on the held-out studies — the fraction of columns whose location lands within 0.10 scales and whose scale lands within ±10%:weighting
pass rate
sd over splits
scale swing
per sample
0.630
0.064
0.063
per study
0.792
0.089
0.044
“Scale swing” is the sd across splits of the median pool/target scale ratio: the reference is only defined up to a global multiplicative factor, and which studies you fit on moves it. One vote per study shrinks that by a third and tightens the per-column scatter four-fold (IQR of the log ratio 0.092±0.020 to 0.079±0.005).
The unit that has to converge is therefore the study, and the sample count follows from it. On the same corpus, drawing whole studies: 80 studies (~2,000 samples) is where every column clears
min_n_obs; 160 studies (~3,800) reaches 0.66, 320 (~7,000) 0.74 and 640 (~14,500) 0.85. An earlier revision of this docstring quoted “median/MAD converges at N=1000” from a benchmark that drew samples IID and scored them against the same corpus’s own fit; that measures the estimator’s own noise, not what a new corpus needs, and is withdrawn.- Parameters:
frame – A
pl.DataFramefrommir.signature.signature_cohort()— one row per sample,sample_idplus signature columns.min_n_obs (int) – Columns observed fewer times than this get no scale.
min_n_groups (int) – Columns observed in fewer
groupvalues than this get no scale, and a corpus with fewer groups than this in total is refused outright. Ignored withoutgroup. Pass 0 to fit a narrow corpus deliberately.pgen_q05 (dict | None) – Measured constants to carry alongside (see
measure_constants()).group – Optional per-sample corpus/batch label (a column name in
frame, or a sequence). Supplying it recordsbatch_ratioper column — diagnostic only, nothing divides by it — and, unlessweight_by_group=False, weights the fit itself.weight_by_group (bool) – Give each
groupone vote, spread over its samples. No effect withoutgroup. PassFalseto reproduce a pre-3.11 artifact.meta (dict | None) – Provenance recorded into the artifact.
cstar (dict | None)
pgen_q05
- Returns:
- Raises:
ValueError – If the frame has no signature columns.
- Return type:
- mir.signature.scale.measure_constants(samples, *, loci=None, group=None, cstar_quantile=0.1, n_pgen=2000, threads=0)[source]#
Measure
cstarandpgen_q05per locus from a reference draw.cstaris a low quantile of attained Good–Turing coverage, so most samples interpolate rather than extrapolate;pgen_q05is the 5th percentile oflog10 Pgenpooled over the draw, which is what “atypically improbable” is measured against.Both are quantiles over the corpus, so both inherit
fit_scale()’s problem: unweighted, a 500-sample study casts 500 votes and a 5-sample study 5, and pgen_q05 is worse — it poolsn_pgenjunctions per sample, so the same study puts a million junctions into a pool the small one contributes ten thousand to. Passgroupto give each study one vote. Measured on the 23,234-sample / 947-group blood reference, the shift incstaris −0.019 (TRA), −0.025 (TRB), −0.023 (IGH), −0.046 (IGK), −0.040 (IGL), −0.014 (TRG), +0.014 (TRD) — 11–17% relative on the T-cell and IGK/IGL loci, and downward, because the large studies are the deep ones. Lower is the safer direction: fewer samples pushed into extrapolation.- Parameters:
samples – Iterable of
(sample_id, {locus: frame}).loci – Restrict to these loci;
Nonemeasures whatever appears.group – Optional
{sample_id: label}mapping. Given, each label gets one vote, spread over its samples (and over their junctions, forpgen_q05). Samples with no entry fall into a sharedNonegroup rather than being dropped.cstar_quantile (float) – Quantile of attained coverage to freeze.
n_pgen (int) – Junctions sampled per repertoire for the Pgen pool, via the same
pgen_junctions()draw the per-sample block uses. This can be far smaller than the per-samplen_max: the pool is one percentile over the whole corpus, so 400 samples at 2,000 each is 800,000 junctions to place a single q05 that ~20,000 already pins. Lower it to make a reference fit cheap – Pgen is ~all of the cost, and IGH alone is ~80% of it at 1.5 ms/junction against 0.0-0.2 elsewhere.threads (int) – Worker threads for the Pgen batch; 0 = auto. Pgen is essentially the whole cost of this function — coverage is a one-line reduction, and the models load once — so leaving this unplumbed pins a reference fit to whatever the library defaults to regardless of the machine it was given.
vsig’spgen_blockhas always taken it.
- Returns:
(cstar, pgen_q05), each{locus: value}.- Return type:
- mir.signature.scale.save_scale(ref, path=PosixPath('/home/runner/work/mirpy/mirpy/src/mir/resources/signature/rsig_scale_v2.npz'))[source]#
Write the scale artifact (and its json sidecar).
- Parameters:
ref (ScaleReference)
- Return type:
- mir.signature.scale.load_scale(path=None)[source]#
Load the scale artifact, or
Noneif none is installed.Nonerather than an exception only for the default path: a signature without a scale reference is still a perfectly usable raw feature vector, and the caller is told which it got viastandardize=.An explicitly supplied path that does not exist RAISES. Returning
Nonethere conflates “you did not ask for a reference” with “the reference you named is missing” – so a typo in--scalewould silently produce an unstandardised matrix that looks exactly like a standardised one, and the caller has already said they want a specific artifact.- Raises:
FileNotFoundError – If
pathwas given and does not exist.- Parameters:
- Return type:
ScaleReference | None