API reference#
Every public module is documented below, grouped by subpackage. See the user guide for runnable examples.
mir#
mir#
Package root: version, get_resource_path, re-exported TCREmp / PairedTCREmp.
mirpy — ML-oriented embeddings for immune receptor repertoires (TCR/BCR).
Version 3 is a slim, embedding-focused rewrite. Heavy machinery is delegated to sibling packages rather than duplicated:
alignment →
seqtree(seqtree.gapblock),VDJ-rearrangement / Pgen model + sampling →
vdjtools.model(core dependency),VDJdb annotation / E-values →
vdjmatch(extra[annotate]),build-time germline region annotation →
arda(extra[build]).
The classical repertoire toolkit lives on the legacy-v2 branch (mirpy-lib 2.x).
- mir.get_resource_path(name=None)[source]#
Return the absolute path to a bundled resource under
mir/resources.- Parameters:
name (str | None) – Resource file or directory name. When
None, return the sorted list of top-level resource names instead of a path.- Returns:
The absolute path as a string, or the sorted list of resource names when
nameisNone.- Raises:
FileNotFoundError – If
namedoes not resolve to a bundled resource.
Clonotype embedding (mir.embedding)#
mir.embedding.prototypes#
Bundled prototype loader + manifest.
Reference prototype loading for TCREMP embeddings.
Prototype files are generated once by mir/resources/prototypes/generate_prototypes.py and
committed to the repository. They are never regenerated at build or test time. Each of the nine
bundled files holds N_PROTOTYPES = 10 000 rows of real receptors — a uniform random
sample (fixed seed=42) of germline-resolvable, unique, productive clonotypes from
arda-annotated real repertoires, de-duplicated on the (v_call, j_call, junction_aa) triple.
Real repertoires, not model draws: synthetic P_gen junctions have degenerate lengths and embed
measurably worse (see SOURCES.md).
The default set. replicate=0 — the first n rows — is the prototype set. It is what
every default, preset, bundled codec and published number uses; leave it alone unless you are
deliberately measuring prototype-draw sensitivity.
Replicates. Because the file order is itself a uniform shuffle, any disjoint block of rows is an
independent draw from the same pool. replicate=r returns block r (rows r*n … (r+1)*n),
so replicates are disjoint — independent by construction, not a with-replacement bootstrap. You
get n_replicates() = 10000 // n of them: 10 at n=1000, 5 at n=2000. Use them to
answer “how much does my result depend on which prototypes I drew?”:
from mir.embedding.tcremp import TCREmp
scores = [score(TCREmp.from_defaults("human", "TRB", 1000, replicate=r).embed(df))
for r in range(n_replicates("human", "TRB", 1000))] # spread = draw sensitivity
Every replicate is a different coordinate system: distances within one are comparable, distances
across two are not. The prototype hash covers replicate, so codecs, RepertoireSpace and
DonorCohort all refuse to mix them — compare summary statistics across replicates, never raw
embeddings.
Typical usage:
from mir.embedding.prototypes import load_prototypes, n_replicates
df = load_prototypes("human", "TRB") # all 10 000
df_small = load_prototypes("human", "TRB", n=1000) # the default 1 000
df_alt = load_prototypes("human", "TRB", n=1000, replicate=3) # an independent 1 000
- mir.embedding.prototypes.N_PROTOTYPES: int = 10000#
Maximum number of prototypes per species/locus combination.
- mir.embedding.prototypes.list_available_prototypes()[source]#
Return sorted
(species, locus)pairs with a bundled prototype file.Example
>>> ("human", "TRB") in list_available_prototypes() True
- mir.embedding.prototypes.n_replicates(species, locus, n)[source]#
How many disjoint prototype replicates of size n the bundled pool supports.
pool_size // n— 10 at the commonn=1000, 5 atn=2000. Replicate indices arerange(n_replicates(...)), with0the default set.Example
>>> n_replicates("human", "TRB", 1000) 10
- mir.embedding.prototypes.load_prototypes(species, locus, n=None, replicate=0)[source]#
Load prototype clonotypes for TCREMP distance-based embeddings.
Reads the pre-generated TSV in
mir/resources/prototypes/and returns n rows in the fixed generation order, so embeddings are reproducible across calls and machines.- Parameters:
species (str) – Species identifier; aliases (
'hsa','Homo sapiens', …) are resolved viamir.aliases.normalize_species_alias().locus (str) – Receptor locus; aliases (
'beta','T_beta', …) are resolved viamir.aliases.normalize_locus_alias().n (int | None) – Number of prototypes. Must be
<= N_PROTOTYPES. Loads the whole pool whenNone.replicate (int) – Which disjoint block of n rows to take.
0(default) is the canonical prototype set — the first n rows, what every preset and published number uses.r > 0returns rowsr*n … (r+1)*n, an independent draw from the same pool (the file order is a uniform shuffle), for measuring prototype-draw sensitivity. Requires an explicit n; seen_replicates()for the number available.
- Returns:
DataFrame with columns
v_call,j_call,junction_aain fixed generation order.- Raises:
ValueError – If
nis not in[1, N_PROTOTYPES], orreplicateis negative, orreplicateis given withoutn, or the pool has no such block.FileNotFoundError – If no prototype file exists for the species/locus.
- Return type:
DataFrame
Example
>>> df = load_prototypes("human", "TRB", n=100) >>> df.columns ['v_call', 'j_call', 'junction_aa'] >>> len(df) 100 >>> alt = load_prototypes("human", "TRB", n=100, replicate=1) >>> set(df["junction_aa"]) & set(alt["junction_aa"]) # disjoint blocks set()
mir.embedding.tcremp#
TCREmp / PairedTCREmp — the prototype (TCREMP) embedding.
TCREMP: distance-vector embeddings for TCR/BCR clonotypes.
TCREMP (T-Cell Receptor EMbedding with Prototypes) embeds each clonotype as a flat
vector of distances to a fixed set of prototype clonotypes. For clonotype i and
prototype k, three distances are emitted; the first two depend on the mode:
mode="vjcdr3"(default): V-germline, J-germline, junction.mode="cdr123": CDR1, CDR2 (both germline V-gene-determined), junction.
The embedding vector (vjcdr3 shown) is:
[v_i1, j_i1, junc_i1, v_i2, j_i2, junc_i2, ..., v_iK, j_iK, junc_iK]
for K prototypes; the output matrix is (n_clonotypes, 3*K) float32.
Junction (CDR3) distances are computed with seqtree.gapblock.score_matrix()
(best of contiguous gap-block placements at (3, 4, -4, -3), BLOSUM62 Gram
penalty). The result d is a negative-type semimetric — a squared Hilbert
distance d = ‖ψ(a)−ψ(b)‖² (symmetric, d(a,a)=0, non-negative) — not itself a
metric; the induced genuine metric is ρ = √d (see mir/distances/junction.py).
V/J and CDR1/CDR2 distances are looked up
from baked matrices (mir.distances.germline.GermlineDistances), same Gram
construction. Input is a polars DataFrame with the vdjtools.io.schema columns
v_call, j_call, junction_aa.
Coordinate-system options (all default to the published v3 space): metric="sqrt" maps
every block to the metric ρ=√d (benchmarked a wash, so "squared" is default);
matrix= a custom seqtree.SubstitutionMatrix and
alignment="sw" (paper-exact Smith-Waterman, slow) reshape the junction block — see
mir.distances.junction.junction_distance_matrix().
Typical usage:
import polars as pl
from mir.embedding.tcremp import TCREmp
model = TCREmp.from_defaults("human", "TRB", n_prototypes=1000)
df = pl.DataFrame({"v_call": ["TRBV10-3*01"], "j_call": ["TRBJ2-7*01"],
"junction_aa": ["CASSIRSSYEQYF"]})
X = model.embed(df) # shape: (1, 3000)
- class mir.embedding.tcremp.TCREmp(species, locus, prototypes, germline, mode='vjcdr3', gap_positions=(3, 4, -4, -3), threads=0, metric='squared', matrix=None, alignment='gapblock', replicate=0)[source]#
Bases:
objectPrototype distance-vector embedding for one locus.
- Parameters:
- classmethod from_defaults(species, locus, n_prototypes=None, mode='vjcdr3', replicate=0, **kwargs)[source]#
Build from the bundled prototypes and baked germline distances.
- Parameters:
species (str) – Species identifier (aliases resolved).
locus (str) – Receptor locus (aliases resolved).
n_prototypes (int | None) – Prototype count;
Noneuses the per-chain recommended preset (mir.embedding.presets.get_preset()).mode (str) –
"vjcdr3"(default) or"cdr123".replicate (int) – Which bundled prototype draw to use.
0(default) is the prototype set — every preset, bundled codec and published number uses it.r > 0is an independent disjoint draw of the same size, for measuring how much a result depends on the prototype sample (seemir.embedding.prototypes.n_replicates()). Embeddings from different replicates are not mutually comparable.
- Return type:
- classmethod from_file(prototypes_path, species, locus, mode='vjcdr3', **kwargs)[source]#
Build from a custom prototype TSV (
v_call/j_call/junction_aa).Embeddings from a custom prototype set are not comparable to those from the bundled prototypes — the coordinate system differs.
- embed(clonotypes)[source]#
Embed a clonotype frame into
(n_clonotypes, 3 * n_prototypes)float32.- Parameters:
clonotypes (DataFrame) – polars DataFrame with columns
v_call,j_call,junction_aa(vdjtools.io.schemanames).- Returns:
float32array of shape(len(clonotypes), 3 * n_prototypes), interleaved per prototype as[slot0, slot1, junction].- Return type:
ndarray
mir.embedding.pca#
PCA denoising / de-redundancy of embeddings.
PCA denoising of TCREMP embeddings.
The raw prototype-distance coordinates are highly redundant — neighbouring
prototypes and correlated V/J scores span a low-rank subspace (Theory T3). A
StandardScaler → PCA step smooths the embedding, removes that redundancy,
and yields a compact basis that is well behaved for clustering / visualization
(the paper uses n_components=50).
mir.embedding.presets#
Per-chain recommended prototype counts + PCA dims.
Recommended per-chain presets: prototype count and PCA dimensionality.
Data-driven from the bundled prototypes (measured by the benchmarks/ scripts in the companion
2026-mirpy-analysis repo):
n_prototypes — the pairwise-distance geometry saturates (Pearson r vs a 5000-prototype reference ≥ 0.996 at 1000, ≥ 0.999 at 2000). Compact chains (IGK/IGL/TRG) saturate earlier; diverse chains (IGH/TR*) benefit from 2000. The paper’s 3000 is safe but generous.
n_components — PCA dimensions retaining ~95% variance (the paper’s clustering regime).
n_components_recon — PCA dimensions retaining ~99% variance, needed to reconstruct the longer, more diverse junctions with the inverse codec (Theory T5, arda coords: IGH exact-match 0.115 at 95% → 0.356 at 99%). Compact chains barely differ; IGH/TRD/TRA need many more.
Values are rounded up to the nearest 5; treat them as starting points, not hard limits.
- class mir.embedding.presets.ChainPreset(n_prototypes, n_components, n_components_recon)[source]#
Bases:
objectRecommended settings for one (species, locus).
- mir.embedding.presets.get_preset(species, locus)[source]#
Return the
ChainPresetfor a (species, locus), aliases resolved.Falls back to a generic diverse-chain preset for unmeasured combinations.
Example
>>> get_preset("human", "IGH").n_components_recon 300 >>> get_preset("hsa", "beta").n_prototypes 2000
- Parameters:
- Return type:
Distances (mir.distances)#
mir.distances.junction#
Junction distance via seqtree.gapblock (metric / matrix / alignment knobs).
Junction (CDR3) distance matrices.
The default backend is seqtree.gapblock — a fast contiguous-gap-block approximation to
Smith-Waterman. seqtree’s blosum62() substitution matrix is already the Gram transform
s_aa + s_bb − 2·s_ab, so gapblock.score_matrix returns the squared dissimilarity d
directly (d(a,a)=0, symmetric, non-negative), selecting the best of contiguous gap-block
placements at gap_positions.
Three knobs, all defaulting to the published v3 coordinate system:
alignment—"gapblock"(default, fast, ~5·10⁸ pairs/s) or"sw"(paper-exact Smith-Waterman via BioPython; O(n·K) pairwise, so validation / small-scale only). gap-block approximates SW in the near-neighbour regime that clustering and density use: close pairs agree exactly (e.g.CASSTTGLNTEAFF/CASSLTAMNTEAFF→ 26 under both). They part company on distant pairs, because"sw"is a local alignment that simply stops extending while gap-block scores the whole junction — over all 44 850 pairs of the 300 bundled human-TRB prototypes the two rank-correlate ρ≈0.78, with equal-length outliers as far apart as 181 vs 77. The two are therefore different coordinate systems, not interchangeable scales: use"sw"to reproduce the paper’s distances,"gapblock"(default) for everything else, and never mix them in one embedding.matrix— the substitution matrix for the gap-block backend: anyseqtree.SubstitutionMatrix(blosum62()default, pluspam250(),structural(),unit(), orfrom_similarityfor a fully custom matrix). Changing it is a coordinate-system change (the baked germline blocks stay BLOSUM62, so mix scales only deliberately).metric—"squared"(default) returns the Gram dissimilarityd;"sqrt"returns the induced metricρ=√d.dis a squared Hilbert distance, so it is not itself a metric (it violates the triangle inequality);ρis. Benchmarked a wash vsd, sodis the default (the published v3 space). The gap-block placement is a monotone argmin, identical underdand√d; the sqrt is applied to the chosen value.
- mir.distances.junction.junction_distance_matrix(queries, refs, gap_positions=(3, 4, -4, -3), threads=0, *, metric='squared', matrix=None, alignment='gapblock')[source]#
Return the
(len(queries), len(refs))junction distance matrix (float32).- Parameters:
queries – Query junction (CDR3) amino-acid strings.
refs – Reference/prototype junction strings.
gap_positions (tuple[int, ...]) – Candidate contiguous gap-block placements; best is chosen (gap-block only).
threads (int) – Worker threads (
0= all cores, GIL released; gap-block only).metric (str) –
"squared"(Gram dissimilarityd, default) or"sqrt"(metricρ=√d).matrix – A
seqtree.SubstitutionMatrixfor the gap-block backend;None=blosum62().alignment (str) –
"gapblock"(default, fast) or"sw"(paper-exact Smith-Waterman, slow).
- Return type:
ndarray
mir.distances.germline#
Resource-backed V / J / CDR1 / CDR2 germline distance lookup.
Resource-backed germline distance lookup for TCREMP embeddings.
Loads the per-locus distance matrices baked by
mir/resources/germline_dist/build_germline_dist.py and gathers, for a batch
of query alleles against a fixed set of prototype alleles, the (n_query,
n_proto) distance matrix for a component (V/J/CDR1/CDR2).
Runtime is pure numpy — no BioPython, no gene library. Unknown alleles resolve via the cascade exact allele → ``*01`` → bare gene → fallback (the fallback is the maximum observed distance for that component, so an unknown gene sits maximally far from every prototype).
- class mir.distances.germline.GermlineDistances(species, locus, components)[source]#
Bases:
objectBaked germline distances for one
(species, locus).Example
>>> gd = GermlineDistances.load("human", "TRB") >>> D = gd.matrix("V", ["TRBV20-1*01"], ["TRBV20-1*01", "TRBV6-5*01"]) >>> D.shape (1, 2) >>> float(D[0, 0]) # identical gene -> 0 0.0
- mir.distances.germline.load_germline_distances(species, locus)[source]#
Cached
GermlineDistances.load(), keyed by canonical species/locus.- Parameters:
- Return type:
Density and background subtraction (mir.density)#
Graph-free continuous-density TCRNET/ALICE neighbour enrichment (Theory T6).
Continuous-density TCRNET / ALICE: enrichment in TCREMP embedding space (Theory T6).
TCRNET (Pogorelyy & Shugay, Front Immunol 2019) and ALICE (Pogorelyy et al., PLoS Biol 2019) flag antigen-driven / convergent-selected clonotypes by neighbour enrichment: count a clonotype’s near-identical (Hamming-1) neighbours and compare against a background — OLGA generation probability for ALICE, a control repertoire for TCRNET. Both are graph methods (a sequence trie).
This module reimplements the same enrichment test with neighbour counting in the TCREMP embedding space instead of on a trie — the graph-free density ratio the theory specifies (Theory T6):
E(z) = f_obs(z) / f_gen(z), f_gen = φ_# P_gen
For an observed clonotype embedded at z_i we count observed neighbours n_obs
and background neighbours n_bg within a radius r (calibrated to one CDR3
substitution, so the continuous test approximates the discrete Hamming-1 one). Under
the null “the observed repertoire is a background sample”, each of the other
N_obs − 1 observed clonotypes lands within r of z_i with probability
p_bg = n_bg / N_bg, so the expected count is (N_obs−1)·p_bg and
- p-value = poisson.sf(n_obs − 1, expected) # ALICE-style (test=”poisson”)
= binom.sf(n_obs − 1, N_obs−1, p_bg) # TCRNET-style (test=”binomial”)
with Benjamini-Hochberg q-values. The fold enrichment fold = n_obs / expected is the
density ratio E(z) itself. The background is either generated from the vdjtools
P_gen model (generate_background(), the ALICE analog) or any supplied control
repertoire (the TCRNET analog) — both just become bg_df.
Passing abundance= (clone sizes) to neighbor_enrichment() adds the clonal-depth
channel (appendix §T.6 sec:dens-abund): the distinct in-ball count becomes a
variance-stabilised weighted mass S=Σ g(a_j) with concave g (default log(1+a)) so a
hyperexpanded clone can’t dominate, tested against a compound-Poisson Gamma tail, plus an orphan
size-test P(A≥a_j) combined with breadth by Fisher. Default (abundance=None) counts each
clonotype once — the shipped g≡1 behaviour.
Observed and background embeddings are comparable only in one coordinate system (same
prototypes and same PCA rotation). fit_density_space() enforces that: it embeds
both through one TCREmp and fits one PCA on the pooled
matrix, returning a DensitySpace that projects any further frame (a control, or
the radius-calibration mutants) into the same basis.
Typical usage:
from mir.density import fit_density_space, calibrate_radius, neighbor_enrichment, enriched_mask
from mir.embedding.tcremp import TCREmp
from mir.embedding.presets import get_preset
model = TCREmp.from_defaults("human", "TRB")
nc = get_preset("human", "TRB").n_components
space, obs_emb, bg_emb = fit_density_space(model, obs_df, bg_df, n_components=nc)
r = calibrate_radius(space) # radius ≈ one CDR3 substitution
res = neighbor_enrichment(obs_emb, bg_emb, r) # E(z), p, q per observed clonotype
hits = obs_df.filter(enriched_mask(res)) # significantly enriched clonotypes
- class mir.density.DensitySpace(model, space, scaler, pca)[source]#
Bases:
objectA single fitted TCREMP → PCA coordinate system for density comparison.
Holds the embedding model plus the standardization + PCA fitted on the pooled observed+background matrix.
transform()projects any frame into this same basis so that observed, background, control and calibration points are all directly comparable — the invariant that makesE(z)meaningful.- scaler: StandardScaler#
- pca: PCA#
- class mir.density.EnrichmentResult(n_obs, n_bg, expected, fold, pvalue, qvalue, radius, score=None, pvalue_breadth=None, pvalue_size=None)[source]#
Bases:
objectPer-observed-clonotype neighbour enrichment (input order preserved).
- Parameters:
n_obs (ndarray)
n_bg (ndarray)
expected (ndarray)
fold (ndarray)
pvalue (ndarray)
qvalue (ndarray)
radius (float)
score (ndarray | None)
pvalue_breadth (ndarray | None)
pvalue_size (ndarray | None)
- n_obs: ndarray#
- n_bg: ndarray#
- expected: ndarray#
- fold: ndarray#
- pvalue: ndarray#
- qvalue: ndarray#
- mir.density.fit_density_space(model, obs_df, bg_df, *, n_components, space='full', seed=0, pca_fit_cap=None, chunk_size=None)[source]#
Embed observed + background frames into one shared PCA coordinate system.
Both frames are embedded through the same
modeland a single PCA (fit on the pooled matrix) is applied to both, guaranteeing the two point clouds live in one comparable basis — the invariant that makesE(z)meaningful.- Parameters:
model – A fitted single-chain
TCREmp.obs_df (DataFrame) – Observed clonotypes (
v_call,j_call,junction_aa).bg_df (DataFrame) – Background clonotypes (generated P_gen sample or a control repertoire).
n_components (int) – PCA dimensionality (clamped to
min(n_samples, n_features)); useget_preset(species, locus).n_components.space (str) –
"full"(whole V+J+junction vector — V/J restriction is soft/continuous) or"junction"(CDR3 sub-block only, mimicking pure-CDR3 TCRNET/ALICE and a third of the memory at whole-repertoire scale).seed (int) – RNG / PCA solver seed.
pca_fit_cap (int | None) – Fit the
StandardScaler+ PCA on at most this many randomly-sampled pooled rows (then transform all rows).Nonefits on everything. NB on its own this caps the fit, not the memory: withoutchunk_sizethe full raw matrix for both frames is materialized before the PCA is fitted at all.chunk_size (int | None) – Embed and project in batches of this many rows, so the full raw matrix is never resident (peak ≈
chunk_size × n_featuresinstead oflen(df) × n_features). Required for whole-cohort clouds: 4.2M clonotypes × 1000 prototypes is ~51 GB raw, versus ~2.4 GB atchunk_size=200_000. When set andpca_fit_capisNone, the fit is capped at 200k pooled rows — fitting on everything would defeat the purpose.None(default) keeps the original single-shot path exactly.
- Returns:
(density_space, obs_emb, bg_emb)— the fittedDensitySpaceand the two reducedfloatarrays, row-aligned toobs_df/bg_df.- Return type:
tuple[DensitySpace, ndarray, ndarray]
- mir.density.calibrate_radius(space, *, sample_df=None, sample=2000, seed=0, quantile=0.5)[source]#
Radius corresponding to one CDR3 substitution, in space’s coordinate system.
Mutates one interior residue of each sampled junction and measures the embedding drift
‖φ(seq) − φ(mutated)‖through the same fitted transform used for enrichment (reusing the Theory-T5 SHM-drift idea). The requestedquantileof that drift is the neighbourhood radius, so the continuous test approximates the discrete Hamming-1 one. V/J are held fixed, isolating the junction contribution.- Parameters:
space (DensitySpace) – A
DensitySpacefromfit_density_space().sample_df (DataFrame | None) – Frame to draw calibration sequences from; defaults to the model’s own prototype set (the coordinate anchor).
sample (int) – Number of prototypes to use when
sample_dfisNone.seed (int) – RNG seed for the substitutions.
quantile (float) – Drift quantile to return (
0.5= median).
- Returns:
The calibrated radius (a positive float).
- Return type:
- mir.density.neighbor_enrichment(obs_emb, bg_emb, radius=None, *, lambda0=3.0, test='poisson', pseudocount=1.0, calibrate='median', abundance=None, weight='log1p', orphan=True, backend='kdtree', k_max=96, seed=0)[source]#
Continuous neighbour-enrichment test in one embedding coordinate system.
Two neighbourhood-scale modes (appendix §T.6):
balloon (default,
radius=None) — adaptive bandwidth. Each observed point’s radius is the distance to itsk-th background neighbour, withkchosen so the expected background occupancy equalslambda0. The adaptive radius encodes the P_gen density shape (small where generation is dense, large where sparse); it is robust to the distance concentration that makes a single global radius fragile in high dimensions, and keeps the test well-powered everywhere (minimum detectableE ≳ 1 + c/√lambda0).fixed (
radiusgiven) — one global radius (e.g. fromcalibrate_radius()).
A real repertoire’s background is systematically denser than a generative P_gen model (gene usage, sampling structure, thymic selection) — the “water level”
πof §T.6.calibrate="median"rescales the null so the bulk of clones sits atfold ≈ 1and only clones exceeding the repertoire’s own typical local density are called. Signal (< 5 % in naive repertoires) barely moves the median, so this is robust; passcalibrate=Noneto test against the raw P_gen null instead.Clonal abundance (appendix §T.6
sec:dens-abund). By default each clonotype counts once (abundance=None), scoring only convergence breadth. Supplying per-clonotype clone sizesabundanceadds the depth channel: the in-ball count is replaced by a variance-stabilised weighted massS(z) = Σ g(a_j)over neighbours, withgnon-decreasing and concave (weight="log1p"g=log(1+a)or"anscombe"g=√(a+3/8)) so a hyperexpanded clone contributes a bounded increment, not a thousand-fold one. Under H0 the mass is compound-Poisson with dispersion indexφ=E[g²]/E[g](Prop. abund), soSis tested against a moment-matchedGamma(μ_S/φ, φ)upper tail (which collapses to the Poisson count test wheng≡1). Withorphan=Trueeach clone additionally gets a size p-valueP(A≥a_j)and the breadth/depth channels are combined by Fisher (χ²₄), recovering a hyperexpanded orphan (depth, no breadth) that the count test alone misses.- Parameters:
obs_emb (ndarray) –
(N_obs, d)observed embedding.bg_emb (ndarray) –
(N_bg, d)background embedding, same basis asobs_emb(produce both withfit_density_space()). UseM ≥ 5Nfor a stable ratio.radius (float | None) – Fixed neighbourhood radius;
Noneselects the balloon estimator.lambda0 (float) – Target expected background occupancy for the balloon estimator (
[1, 5]).test (str) –
"poisson"(ALICE analog) or"binomial"(TCRNET control analog). Applies to the distinct-count path; the abundance-weighted path always uses the Gamma tail.pseudocount (float) – Added to the background count to stabilize
p_bg.calibrate (str | None) –
"median"empirical-null water-level calibration (default), orNone.abundance (ndarray | None) – Optional
(N_obs,)clone sizes (e.g.duplicate_count), row-aligned toobs_emb; enables the weighted/orphan channels.weight (str) – Concave size transform
g—"log1p"(default),"anscombe", or"distinct"(g≡1, ignore sizes even ifabundanceis given).orphan (bool) – When abundance-aware, combine the size p-value with the breadth p-value by Fisher.
backend (str) – neighbour engine.
"kdtree"(default, scipy cKDTree — exact, multithreaded, 5–9× faster than BallTree),"exact"(BallTree — exact, single-threaded; the historical default, kept for bit-reproducing the BallTree baseline), or"ann"(approximate pynndescent kNN for whole-repertoire scale — ~30× faster at ≥40k but recall < 1 undercounts, biasing enrichment conservatively; see_ann_neighbors())."kdtree"is bit-identical to"exact"at a fixedradius; in balloon mode counts differ by at most ±1 at the ball boundary (float-epsilon in the computed k-th-neighbour distance), which is negligible — passbackend="exact"only to reproduce the BallTree baseline exactly.k_max (int) –
backend="ann"only — kNN-graph degree. A clonotype whose ball holds allk_maxneighbours is undercounted (and warned about); raise this when that warning fires.seed (int) –
backend="ann"only — pynndescentrandom_state.
- Returns:
An
EnrichmentResult;foldis the (calibrated) density ratioE(z). In balloon moderadiusis the median adaptive radius used. When abundance-aware,scoreholds the weighted massSandpvalueis the combined breadth×depth test.- Return type:
- mir.density.enriched_mask(res, *, alpha=0.05, min_fold=1.0, min_neighbors=2)[source]#
Boolean hit mask:
q < alpha&fold > min_fold&≥ min_neighbors(self + neighbours).min_neighbors=2reproduces the legacy “self plus at least one neighbour” criterion (n_obsexcludes self, so the threshold isn_obs ≥ min_neighbors − 1).For an abundance-aware result (
res.scoreset) themin_fold/min_neighborsbreadth gates are dropped: the combined breadth×depthqalready governs, so a hyperexpanded orphan (significant depth, no neighbours) is kept rather than filtered out.- Parameters:
res (EnrichmentResult)
alpha (float)
min_fold (float)
min_neighbors (int)
- Return type:
ndarray
- mir.density.exposure_score(res, *, abundance=None, alpha=0.05, min_fold=1.0, min_neighbors=2)[source]#
Aggregate one repertoire’s per-clonotype
EnrichmentResultinto 3 exposure scalars.neighbor_enrichment()answers “which clones are enriched” (a per-clonotype hit list, the TCRNET/ALICE readout); this answers “how exposed does the whole repertoire look” — a sample-level summary suitable as an explainable channel (mir.explain) alongside identity/diversity/ coverage, rather than a clonotype list. Exposure detection moves from clone-level (density.py’s core job) to repertoire-level (a scalar a cohort model can score) with no new statistics: it is a read-off of the enrichment already computed.- Parameters:
res (EnrichmentResult) – One donor’s enrichment result (
obs_emb= that donor’s repertoire,bg_embshared across the cohort so results are comparable).abundance (ndarray | None) – Optional clone sizes (
duplicate_count), row-aligned tores— enables the mass-weighted column.Nonefalls back to unweighted (distinct-count) breadth.alpha (float) – Forwarded to
enriched_mask().min_fold (float) – Forwarded to
enriched_mask().min_neighbors (int) – Forwarded to
enriched_mask().
- Returns:
breadth— fraction of distinct clonotypes flagged enriched.mass_fraction— abundance-weighted fraction of repertoire mass flagged (equalsbreadthwhenabundanceisNone).mean_log2_fold— meanlog2(fold)among flagged clones (0.0if none flagged).
- Return type:
[breadth, mass_fraction, mean_log2_fold]
- mir.density.exposure_channel(results, *, abundances=None, **kwargs)[source]#
Stack
exposure_score()across a cohort into one ready-to-fuse channel matrix.- Parameters:
results (list[EnrichmentResult]) – One
EnrichmentResultper donor (same shared background embedding).abundances (list[ndarray | None] | None) – Optional per-donor clone-size arrays, row-aligned to
results(Noneper donor falls back to unweighted breadth for that donor).**kwargs – Forwarded to
exposure_score()(alpha,min_fold,min_neighbors).
- Returns:
(n_donors, 3)array — columns[breadth, mass_fraction, mean_log2_fold]— pass directly tomir.explain.ChannelBuilder.add()(e.g.builder.add("exposure", exposure_channel(results))) or return it from amir.cohort.fit_donor_embeddings()extra_channelsclosure.- Return type:
ndarray
- mir.density.denoise_and_cluster(obs_emb, res, *, alpha=0.05, min_fold=1.0, min_neighbors=2, **cluster_kw)[source]#
Noise-filter then cluster: DBSCAN the enriched subset, non-enriched → label
-1.Background subtraction (
enriched_mask()) removes the naive-repertoire noise, thenmir.bench.metrics.cluster()groups the surviving hits into convergent motifs.- Returns:
(labels, mask)—labelsis lengthN_obs(-1for non-enriched or DBSCAN-noise points);maskis the enriched-hit boolean.- Parameters:
obs_emb (ndarray)
res (EnrichmentResult)
alpha (float)
min_fold (float)
min_neighbors (int)
- Return type:
tuple[ndarray, ndarray]
- mir.density.generate_background(locus, n, *, species='human', source='learned', seed=0, productive_only=True)[source]#
Draw
nsynthetic background clonotypes from the bundled vdjtools P_gen model.Lazy
vdjtools.modelimport (vdjtoolsis a core dependency; imported here only to defer the cost). Returns a frame withjunction_aa,v_call,j_call— ready forTCREmp.embed— sampled from the vdjtools VDJ-rearrangement model, i.e. a Monte-Carlo estimate of thef_gen = φ_# P_genpushforward (the ALICE analog background).A biological control repertoire is the better background whenever you have one (see the module docstring): a P_gen sample flags the pervasive convergence of any real repertoire.
- Parameters:
locus (str) – Bundled model locus (e.g.
"TRB").n (int) – Number of sequences to generate.
species (str) –
"human"(default) or"mouse". Mouse requiressource="arda"— it is the only bundled set with a non-human organism.source (str) – Bundled model source —
"learned"(vdjtools EM-inferred, the default),"olga"(legacy OLGA-parameter bootstrap, retained only for comparison), or"arda". Prefer"arda"when the observed data is arda-annotated: it is the only set in the arda IMGT allele namespace, the same frame as mirpy’s prototypes and baked germline distances, so generated V/J calls resolve exactly instead of taking the allele-cascade fallback.seed (int) – Sampling seed.
productive_only (bool) – Reject out-of-frame / stop-codon rearrangements.
- Raises:
ValueError – If the installed
vdjtoolspredates thearda/organismsupport thatsource="arda"and non-human species need.- Return type:
DataFrame
Sample-level (repertoire) embedding (mir.repertoire)#
One vector per repertoire: RFF kernel mean ‖ Hill diversity ‖ second moment; MMD (Theory T7).
Optionally a sub-probability measure: missing_mass (Good-Turing / bias-corrected Chao1),
naive_reference (the germline location of the unseen) and contrast_embedding
(Ψ = mass·(Φ − naive), signed and magnitude-carrying). Also the measure algebra around it:
rao_q (Rao’s quadratic entropy = 1 − ‖Φ₁‖²), depth_threshold (the κ below which Φ
is mostly sampling noise), sample_statistics / cohort_statistics, the compartment
decomposition band_frames / band_embeddings / mixture_weights, and rarefy_embedding.
Derivations: Mathematical foundations.
Sample-level (repertoire) embedding: one fixed vector per repertoire (Theory §T.7).
A repertoire is an order-invariant multiset of clonotypes with clone counts,
S = {(σ, a_σ)}. We embed it as the empirical measure ρ_S = Σ_σ w_σ δ_{z_σ} on
the clonotype embedding space (z_σ = φ(σ) from TCREmp),
with clone-size weights w_σ = g(a_σ) / Σ_τ g(a_τ) (g = log2p1 — log2(1+a) — by
default, so one hyperexpanded clone can’t dominate; "duplicate_count" weights linearly by clone
size and "distinct" ignores size entirely, g≡1). Φ(S) is a sketch of ρ_S in three blocks,
each owning one requirement (appendix §T.7):
mean
Φ₁ = Σ_σ w_σ ψ(z_σ)— the random-Fourier-feature kernel mean embedding (order-invariant + depth-robust: converges to the population mean map at raten_eff^{-1/2},n_eff = (Σ w²)⁻¹). Distance‖Φ₁(S)−Φ₁(S')‖ ≈ MMD. Codebook-free — noK, no clustering (Prop.prop:kme/prop:codebook).diversity
Φ₂ = {⁰D, ¹D, ²D}(Hill numbers), optionally coverage-standardized to a common Good–Turing coverageĈ*viavdjtools.stats.inext;n_effis itself a Hill number,n_eff ∈ [²D, ⁰D](Prop.prop:antag).second
Σ_S = Σ_σ w_σ ψ₂(z_σ) ψ₂(z_σ)ᵀ— the codebook-free Fisher vector carrying clonotype co-occurrence / HLA-linked public structure (Prop.prop:interact); computed on a small RFF so its upper triangle stays cheap.
The comparability invariant (as in mir.ml.bundle.CodecBundle and
mir.density.fit_density_space()): every sample in a cohort must be embedded through
one prototype set and one PCA + RFF basis, or the measures are incomparable.
fit_repertoire_space() fits that basis once on the pooled clonotype cloud;
RepertoireSpace serializes it and refuses a prototype-hash mismatch on load.
Sub-probability embeddings. Φ₁ above is the kernel mean of a probability measure — the
weights are normalised to sum to 1, so every sample asserts one full unit of confidence. At RNA-seq
depth that premise fails: a tissue sample holding 21 unique clonotypes has w_σ = 1/n for a
technical draw size, not a clonal frequency, and normalising forces a 5-clonotype tumour to land
somewhere arbitrary on the unit sphere. sample_embedding(missing_mass="turing"|"chao") instead
estimates the probability mass of the clonotypes that were never drawn (missing_mass()) and
records the retained mass on SampleEmbedding.mass ≤ 1 — a sub-probability measure, which
keeps everything that makes Φ useful (‖Φ_P − Φ_Q‖ = MMD, convex combinations are real pooled
repertoires) while a signed measure would not. The unseen block gets a principled location from
naive_reference() (the germline recombination model, not the corpus centroid — measured
worse), and contrast_embedding() composes the two into Ψ_S = mass·(Φ_S − naive), where
magnitude = confidence × deviation-from-naive and an immune desert lands at the origin instead of
being filtered out. See mir.bench.recovery_report() for the “is this object honest” check.
Torch-free (numpy / sklearn; vdjtools + the learned set-network in mir.ml are lazy).
Typical usage:
from mir.repertoire import fit_repertoire_space, sample_embedding, mmd_distance
from mir.embedding.tcremp import TCREmp
import polars as pl
model = TCREmp.from_defaults("human", "TRB", n_prototypes=1000)
pooled = pl.concat(samples) # all clonotypes across the cohort
space = fit_repertoire_space(model, pooled) # ONE basis for the cohort
embs = [sample_embedding(space, s) for s in samples]
d = mmd_distance(embs[0], embs[1]) # ≈ MMD between the two repertoires
- mir.repertoire.missing_mass(counts, method='chao')[source]#
Estimated probability mass
M₀of the clonotypes that were never drawn.Both estimators feed the same identity with a different
M₀; they rank samples nearly identically (measuredr= 0.93 blood TRB, 0.76 tissue IGH) but differ in level — Chao runs above Turing when singletons dominate doubletons (f₁ ≫ 2f₂) and below it when doubletons are abundant. Measured means: blood TRB 0.552 (Turing) vs 0.649 (Chao), tissue IGH 0.189 vs 0.458:none M0 = 0 the sample is treated as complete turing M0 = f1 / N Good-Turing, read-weighted chao M0 = S_u / (N + S_u) S_u = f1(f1-1) / (2(f2+1))
f₁/f₂are the singleton / doubleton clonotype counts andNthe total reads. Thechaoform rests on one modelling assumption: an unseen clonotype is rare, so had it been drawn it would carry at most one read, and unseen clonotypes do not overlap. That pins each unseen clone’s frequency at the detection boundary1/(N + S_u)and is what makes the units legal —Ncounts reads andS_ucounts clonotypes, and adding them is only defensible because each unseen clone contributes exactly one read. The observed term then collapses to exactlya_σ/(N + S_u).S_uis the bias-corrected Chao1f1(f1-1)/(2(f2+1)), never the classicalf1²/(2f2): at these depths a sample with no clonotype seen exactly twice is common, and the classical form is undefined there.- Parameters:
counts – Per-clonotype read counts (
duplicate_count); rounded to integers to count singletons and doubletons.method (str) –
"none"|"turing"|"chao".
- Returns:
M₀ ∈ [0, 1]— the missing (unobserved) mass. The retained mass is1 − M₀.- Raises:
ValueError – If
methodis not recognised.- Return type:
Example
>>> missing_mass([5000, 3000, 2000], "chao") # deep, no singletons 0.0
- mir.repertoire.sample_statistics(sample_df)[source]#
The sampling fingerprint of one repertoire — the basic statistics, from counts alone.
Two uses. (a) The targets
mir.bench.recovery_report()asks the embedding to carry: if a statistic is recoverable fromΦ, nothing has to be bolted on beside it. (b) Features in their own right — the abundance classesf1/f2/f3plusand the top-clone fraction are clonality and expansion, i.e. candidate biology, not only depth nuisance.- Parameters:
sample_df (DataFrame) – One sample’s clonotypes with
duplicate_count.- Returns:
{name: float}withn_reads,log_reads,richness,log_richness,f1,f2,f3plus,singleton_fraction,top_clone_fraction,shannon,missing_mass_turing,missing_mass_chao.- Raises:
ValueError – On an empty or all-zero-count repertoire (via
_sample_weights()).- Return type:
- mir.repertoire.cohort_statistics(frames)[source]#
Per-statistic arrays over a cohort —
sample_statistics()transposed.- Parameters:
frames – One clonotype frame per sample, in the same order as the embedding rows.
- Returns:
{name: (n_samples,) array}, ready asmir.bench.recovery_report()’sstats.- Raises:
ValueError – If
framesis empty.- Return type:
- class mir.repertoire.RandomFourierFeatures(omega, b, length_scale)[source]#
Bases:
objectRandom Fourier features for a Gaussian kernel
k(z,z')=exp(−‖z−z'‖²/2ℓ²).ψ(z) = √(2/D)·cos(Ωzᵀ + b)withΩ ~ N(0, 1/ℓ²),b ~ U[0, 2π], so thatE[ψ(z)·ψ(z')] = k(z,z')(Rahimi–Recht 2007). Bandwidthℓis set to the one-substitution embedding scaler₁so the kernel resolves ~one CDR3 mutation.- Parameters:
omega (ndarray)
b (ndarray)
length_scale (float)
- omega: ndarray#
- b: ndarray#
- class mir.repertoire.RepertoireSpace(clono, rff, rff2, meta, _naive=<factory>)[source]#
Bases:
objectOne fitted clonotype→PCA→RFF basis shared by a whole cohort.
clonoprojects a clonotype frame into the shared PCA coordinates;rfflifts those into the kernel-mean feature space (mean block);rff2is a smaller RFF for the second-moment block.metarecords the embedding-space identity (prototype hash + knobs) soload()can refuse an incomparable basis.- Parameters:
clono (DensitySpace)
rff (RandomFourierFeatures)
rff2 (RandomFourierFeatures | None)
meta (dict)
_naive (dict)
- clono: DensitySpace#
- rff2: RandomFourierFeatures | None#
- transform_clonotypes(df)[source]#
Project a clonotype frame into the shared PCA coordinate system.
- Parameters:
df (DataFrame)
- Return type:
ndarray
- sample_cloud(df, *, weight='log2p1')[source]#
A sample as
(Z, w): PCA-coord clonotypesZand normalized weightsw=g(a)/Σg.The raw material for both the fixed kernel mean (
sample_embedding()) and the learned set network (mir.ml.set_encoder) — same basis, so their embeddings are comparable.- Parameters:
df (DataFrame)
weight (str)
- save(path)[source]#
Pickle the basis (scaler + PCA + RFF + meta); the model is reconstructed on load.
- Raises:
ValueError – If the model uses a non-default
matrix/alignment. Neither is recorded inmeta, soload()would silently rebuild a different coordinate system while the prototype-hash check still passed.- Return type:
None
- mir.repertoire.fit_repertoire_space(model, cohort_df, *, n_rff=2048, n_rff_second=128, n_eigs=None, length_scale=None, n_components=None, space='full', pca_fit_cap=200000, seed=0)[source]#
Fit ONE PCA + RFF basis on the pooled clonotype cloud of a cohort.
- Parameters:
model – A fitted single-chain
TCREmp.cohort_df (DataFrame) – Pooled clonotypes across all samples (
v_call/j_call/junction_aa); counts are ignored here — only the geometry of the cloud sets the basis.n_rff (int) – Mean-block RFF dimension
D(~1–4k; §T.7tab:sample).n_rff_second (int) – Second-moment-block RFF dimension
D₂(kept small — the block storesD₂(D₂+1)/2upper-triangle entries, or its top-n_eigseigenvalues).n_eigs (int | None) – If set, the second-moment block keeps the top-``n_eigs`` eigenvalues of the weighted covariance
Σ_σ w_σ ψ₂ψ₂ᵀ(a compact, rotation-invariant spectral signature) instead of its fullD₂(D₂+1)/2upper triangle.None(default) keeps the upper triangle — unchanged behaviour. Must satisfy0 < n_eigs ≤ n_rff_second.length_scale (float | None) – Gaussian-kernel bandwidth
ℓ.None→mir.density.calibrate_radius()(the one-substitution scaler₁), so the kernel resolves ~one CDR3 mutation.n_components (int | None) – PCA dimensionality;
None→get_preset(species, locus).n_components.space (str) –
"full"(V+J+junction) or"junction"(CDR3 sub-block only).pca_fit_cap (int | None) – Fit the scaler + PCA on at most this many randomly-sampled pooled rows.
seed (int) – RNG seed (PCA solver + RFF draws).
- Returns:
A
RepertoireSpaceready forsample_embedding().- Return type:
- mir.repertoire.fit_repertoire_spaces(models, cohort_frames, *, min_clonotypes=50, **kwargs)[source]#
Fit one
RepertoireSpaceper locus — the multi-chain (digital-donor) basis.Each locus gets an independent basis (its own prototype set + PCA + RFF), so per-chain kernel means are only ever compared within their own chain, honouring the comparability contract. Loci whose pooled cloud is too small to fit a stable PCA are skipped (returned dict omits them).
- Parameters:
models (dict) –
{locus: TCREmp}— one fitted single-chain model per locus.cohort_frames (dict) –
{locus: pooled clonotype frame}(v_call/j_call/junction_aa), the pooled cloud for that locus across the whole cohort.min_clonotypes (int) – Skip a locus whose pooled frame has fewer rows (PCA would be degenerate).
**kwargs – Forwarded to
fit_repertoire_space()(n_rff,n_components,seed…).
- Returns:
{locus: RepertoireSpace}for the loci that could be fit.- Return type:
- class mir.repertoire.SampleEmbedding(mean, diversity, second, n_eff, mass=1.0)[source]#
Bases:
objectOne repertoire’s fixed-width embedding, kept block-wise so MMD uses only the mean block.
massis the retained probability mass1 − M₀:1.0(the default) means the sample is treated as a complete probability measure,< 1thatmissing_mass()estimated part of the repertoire was never drawn. The blocks themselves are unchanged bymass— the deficiency is applied where it is meaningful, incontrast_embedding().- Parameters:
- mean: ndarray#
- property vector: ndarray#
The concatenated feature vector (the multimodal-fusion tensor).
Only the blocks that were computed are concatenated —
blocks=("diversity",)is a valid (mean-less) Φ, even though MMD then is not (seemmd_distance()).
- mir.repertoire.sample_embedding(space, sample_df, *, weight='log2p1', blocks=('mean', 'diversity', 'second'), coverage=None, missing_mass='none')[source]#
Embed one repertoire into
Φ(S)(mean ‖ diversity ‖ second).- Parameters:
space (RepertoireSpace) – A
RepertoireSpacefromfit_repertoire_space().sample_df (DataFrame) – One sample’s clonotypes with
duplicate_count(counts drive the weights).weight (str) – Clone-size weight
g—"log2p1"(default,g=log2(1+a)) /"duplicate_count"(g=a, linear) /"distinct"(g≡1, presence) /"log1p"(g=ln(1+a)) /"anscombe"(g=√(a+3/8)). Frequencies arew = g(a)/Σg(scale-free).coverage (float | None) – Common Good–Turing coverage
Ĉ*for the diversity block;Noneuses the sample’s observed Hill numbers.missing_mass (str) – Estimator for the mass of the never-drawn clonotypes (
missing_mass()):"none"(default — a complete probability measure,mass=1, bit-identical to pre-3.8 output),"turing"or"chao". Only.masschanges; the blocks do not.
- Returns:
A
SampleEmbedding;.vectoris the concatenated fixed-width tensor.- Return type:
- mir.repertoire.rao_q(emb)[source]#
Rao’s quadratic entropy of the repertoire, read straight off
Φ₁as1 − ‖Φ₁‖².Rao’s
Q = Σ_{σ,τ} w_σ w_τ d(σ,τ)with the kernel dissimilarityd = 1 − kcollapses, for weights summing to 1 and a normalised kernel (k(z,z)=1), to exactly1 − ‖Φ₁‖²— so the norm of the kernel mean is a diversity statistic and no Gram matrix is needed. Verified against an explicit Gram to machine precision (max relative error ~1e-16).This is the diversity the Hill block cannot express. Every Hill number is a functional of the clone-size distribution alone, hence invariant to permuting which receptor carries which abundance — it describes the shape of the abundance distribution and is blind to composition. Rao’s
Qweights each pair by how different the receptors are, so it is a functional diversity: sequence-aware, and already carried insideΦ. Measured: this one scalar recovers R² 0.74–0.85 of classical diversity, while embedding derivatives reach R² 0.974–0.994 for Shannon and 0.985–0.9999 for richness.Two caveats. The kernel is a random-Fourier approximation, so
k(z,z)=1holds only toO(D^{-1/2})and a single-clone sample readsQof order1e-2rather than exactly 0 — widenn_rffif the absolute level matters, or read differences, which are unaffected. And it is valid only for the true, uncentredΦ₁: Centring or PCA-projectingΦ₁preserves differences (so MMD survives) but not norms, soQcannot be read off a centred/reduced identity block — check which object you hold before quoting any norm-based quantity. With a deficientmassthis is theQof the observed (renormalised) part, which is the honest reading: it is the diversity you measured.- Parameters:
emb (SampleEmbedding) – A
SampleEmbeddingwith its mean block (uncentred, as returned).- Returns:
Q ∈ [0, 1]— 0 for a single clone, rising as the receptors present grow more dissimilar.- Raises:
ValueError – If
embhas no mean block.- Return type:
- class mir.repertoire.DepthThreshold(kappa, tau2, sigma2, fraction_below, sizes)[source]#
Bases:
objectWhere sampling noise stops being smaller than between-sample signal (see
depth_threshold()).- sizes: ndarray#
- mir.repertoire.depth_threshold(embs, sizes=None)[source]#
Estimate
κ, the sample size below which a repertoire’sΦis mostly sampling noise.The damage depth does to a kernel mean is not bias — it is variance ∝ 1/n, over an
nspanning four orders of magnitude, and in a neighbour graph that heteroscedasticity is a depth axis. So regress each sample’s squared distance from the cohort centroid on1/n:E‖Φ_S − Φ̄‖² ≈ τ² + σ²/nThe intercept
τ²is between-sample (biological) spread, the slopeσ²is within-sample sampling noise, and ``κ = σ²/τ²`` is the size at which the two are equal — below it a sample’sΦcarries more noise than signal. Measured across four independent views,κcame out at 40–70 clonotypes every time, with 23–69% of samples below it.This is the estimable replacement for a hand-picked clonotype floor: report
κandfraction_belowfor the cohort in front of you rather than importing someone else’s cutoff. Note the library still does not apply a floor — in blood a low clonotype count is shallow sequencing, in a tumour it is low infiltration (the phenotype of interest). Pairκwithmissing_mass()/contrast_embedding()and weight instead of filtering.- Parameters:
embs – Per-sample
SampleEmbedding(mean block present), one cohort, one basis.sizes – Per-sample size to use as
n.None→ each embedding’sn_eff. Pass observed clonotype richness to readκin clonotypes rather than effective clonotypes.
- Returns:
A
DepthThreshold.kappaisinfwhen the fit finds no between-sample spread (τ² ≤ 0) and0.0when it finds no depth dependence (σ² ≤ 0).- Raises:
ValueError – If fewer than three samples are given, or
sizeshas the wrong length.- Return type:
- mir.repertoire.naive_reference(space, *, n=20000, seed=0, sequences=None)[source]#
Kernel mean of
nnaive V(D)J recombinations — where the unseen clonotypes live.Once a sample’s mass is deficient (
missing_mass()), something has to occupy the unseen block, and the choice decides whether the object is a shrinkage estimator toward a meaningful point. Three priors were measured:the sample’s own singletons — begs the question, adds no information;
the corpus mean — James–Stein toward the centroid, and it measurably hurt: it piles shallow samples into a dense ball that is itself depth-correlated;
the germline draw — the one that works. Filling the unseen block with it dropped
R²(PC1, depth)from 0.259 to 0.001 (blood TRB) and 0.067 to 0.006 (tissue IGH), with kNN label entropy unchanged or better and PC1’s explained variance unchanged (so PC1 is a different direction, not a collapsed one).
The draw comes from
vdjtools.model.generate()over the bundled germline model for this space’s locus (~8 s for 20,000 sequences), and is cached per(n, seed)on the space. Passsequences=to supply the reference repertoire yourself (an uncached path) if you do not want the vdjtools generative model in the loop.- Parameters:
space (RepertoireSpace) – The shared
RepertoireSpace— supplies the locus and the coordinate system, so the reference is automatically comparable to everyΦfit through it.n (int) – Number of naive recombinations to draw.
seed (int) – Generation seed; the result is deterministic in
(n, seed).sequences (DataFrame | None) – Optional pre-built clonotype frame to use instead of generating (
v_call/j_call/junction_aa). Not cached.
- Returns:
(n_rff,)unweighted kernel mean of the naive draw, inspace’s RFF coordinates.- Raises:
ValueError – If the space is non-human (only human germline models are bundled).
- Return type:
ndarray
Example
>>> ref = naive_reference(space) >>> psi = contrast_embedding(emb, ref)
- mir.repertoire.contrast_embedding(emb, reference)[source]#
Ψ_S = mass · (Φ₁(S) − reference)— the deficient measure as a signed contrast.This is the answer to “can the missing mass be a negative probability”. It cannot:
Φ’s value is that‖Φ_P − Φ_Q‖ = MMD(P,Q)and that a convex combination of twoΦ’s is theΦof a real pooled repertoire (what makesmir.twinand trajectory interpolation mean anything), and a measure allowed to go negative on a set is not a probability measure. But a signed difference of two probability measures already gives signed coordinates — negative wherever the sample is depleted relative to unselected recombination — and stays an ordinary RKHS element with‖Ψ_S‖ = MMD(S, naive)intact.Note
Ψ_Sis exactly the reference-centred sub-probability embedding: withΦ_true = mass·Φ₁ + M₀·reference,Φ_true − reference = mass·(Φ₁ − reference).Combined with a deficient mass, magnitude = confidence × deviation-from-naive. An immune desert has
M₀ → 1and lands at the origin, which is the correct place for “no infiltrate detected”; a vague shallow blood sample lands there too and says so by its norm rather than by being dropped from the cohort. Scale such a block with one global scalar — seemir.explain.ChannelBuilder.add()’spreserve_magnitude.- Parameters:
emb (SampleEmbedding) – A
SampleEmbeddingwith a mean block (embed withmissing_mass=formass < 1; at the"none"default this is just the centred kernel mean).reference (ndarray) –
(n_rff,)unseen-block location, normallynaive_reference().
- Returns:
(n_rff,)signed contrast vector.- Raises:
ValueError – If
embhas no mean block.- Return type:
ndarray
- mir.repertoire.band_frames(sample_df, *, bands=('singleton', 'expanded', 'top'), top_fraction=0.01, top_clip=(10, 500), min_clonotypes=5)[source]#
Partition one sample’s clonotypes into compartments — abundance bands or IGH isotypes.
Φ₁is a clone-size-weighted average, which is the right operation for estimating a population mean and the wrong one for detecting a minority signal. Writing the repertoire as a mixtureρ_S = (1−π)ρ_naive + π ρ_expandedgivesΦ₁(S) = (1−π)Φ₁(naive) + π Φ₁(expanded), so a difference confined to the expanded compartment reachesΦ₁attenuated toπΔwhile the noise is supplied by the naive compartment that owns most of the clonotypes. Embedding each compartment separately occupies exactly the blind spot of the Hill block, which is invariant to which receptor carries which abundance.Bands (on
duplicate_count, never on a frequency column):band
definition
compartment
singletoncount == 1naive-dominated background; also where sequencing error concentrates
expandedcount >= 2clones that divided at least once
toptop
top_fractionby count, clipped totop_clipthe dominant clonal compartment (chronic exposure history)
igmc_callin IGHM/IGHDunswitched (IGH only)
iggc_callin IGHG1–4class-switched (IGH only)
igac_callin IGHA1–2mucosal (IGH only)
topandexpandedoverlap deliberately — “what does the dominant compartment look like” and “what does everything that expanded look like” are different questions. The isotype cut partitions on an irreversible molecular event rather than an abundance threshold, so IgM/IgG/IgA are genuinely different cell populations sharing one locus. Rows with a nullc_callare excluded from every isotype band, never defaulted to IgM: an uncalled constant region is missing data, and roughly 43% of IGH reads have none at RNA-seq coverage.- Parameters:
sample_df (DataFrame) – One sample’s clonotypes with
duplicate_count(andc_callfor isotypes).bands (tuple[str, ...]) – Which bands to build. Any of
singleton/expanded/top/igm/igg/iga.top_fraction (float) – Fraction of clonotypes in the
topband before clipping.top_clip (tuple[int, int]) –
(min, max)clonotype count for thetopband.min_clonotypes (int) – A band with fewer clonotypes is returned as
None— recorded absent, never embedded. A 3-clone “repertoire” is not an estimate;Noneis the same hole conventionChannelBuilderandmir.cohort.align_loci()already understand.
- Returns:
{band: frame or None}in the requested order.- Raises:
ValueError – On an unknown band name, or an isotype band without a
c_callcolumn.- Return type:
- mir.repertoire.band_embeddings(space, sample_df, *, bands=('singleton', 'expanded', 'top'), min_clonotypes=5, **kwargs)[source]#
Embed each compartment of one sample through the same basis (see
band_frames()).A band is never a reason to refit: refitting would put each compartment in its own geometry and make band-to-band and cohort-to-cohort distances meaningless. One
space, every band.- Parameters:
space (RepertoireSpace) – The shared
RepertoireSpace.sample_df (DataFrame) – One sample’s clonotypes.
bands (tuple[str, ...]) – Which bands to embed (
band_frames()).min_clonotypes (int) – Bands smaller than this are
None(absent, not embedded).**kwargs – Forwarded to
sample_embedding()(weight,blocks,missing_mass…).
- Returns:
{band: SampleEmbedding or None}.- Return type:
Note
To compare two compartments’ directions, centre the cohort first (
X - X.mean(0), orcentroid_atypicality()for the per-group version) and cosine the residuals. Do not read a raw cosine between two TCREmp-derived vectors, and do not think subtracting each vector’s own scalar mean fixes it: on mean distance profiles, measured raw cos 0.9993, self-centred 0.9985, cohort-centred −0.14. Only cohort centring removes the shared profile. Measured compartment overlaps of order 0.01 (RFF kernel means, whose null is already ≈0) are real signal above that baseline.
- mir.repertoire.mixture_weights(whole, parts)[source]#
Non-negative mixture weights
π_cof compartments inside a whole-repertoireΦ₁.Φis linear in the clone-weight measure, soΦ(S) = Σ_c π_c Φ(c)exactly for any partition, which makes a non-negative least squares of the whole on its compartments well-posed rather than a heuristic fit. (Exact in exact arithmetic: the realised agreement is limited by the float32 clonotype embedding, whose per-row values depend slightly on the thread batching, so expect a relative residual around1e-5rather than machine epsilon.) The weights are the answer to “how much of what I am measuring does this compartment actually own” — the dilution factorπofband_frames(), measured instead of assumed.Two things it is for. Interpretation: measured on IGH, the class-switched IgG compartment — affinity-matured, T-dependent, the fraction most likely to carry antigen-specific signal — carries a median
πof only 0.070 ofΦ₁(IGH), with unswitched IgM at 0.230, IgA at 0.176 and 0.520 unexplained (the uncalled-isotype share, agreeing in ballpark with the ~0.43 counted from reads — different denominators, embedding weight vs reads). Power:πis a prior estimate of how far a subset can moveΦ, so a subset carryingπ≈ 0.001 will not be detectable by any aggregate distance onΦ, and the per-clonotype route (class_witness()) is the sensitive one. Checkπbefore spending compute on the aggregate.- Parameters:
whole – The whole repertoire’s
Φ₁— aSampleEmbeddingor a(D,)array.parts (dict) –
{name: SampleEmbedding | array | None};Noneentries are skipped (an absent band is not a zero-weight band).
- Returns:
{"weights": {name: π}, "residual": 1 − Σπ, "r2": reconstruction R²}.residualis the share of the whole no compartment accounts for (an unmeasured or excluded compartment), and can be negative if the parts overlap (top⊂expanded).- Raises:
ValueError – If no usable parts were given, or a part’s width differs from
whole’s.- Return type:
- class mir.repertoire.RarefyResult(embedding, v_rep, depth, n_replicates)[source]#
Bases:
objectA depth-standardised embedding plus the replicate dispersion (see
rarefy_embedding()).- Parameters:
embedding (SampleEmbedding)
v_rep (float)
depth (int)
n_replicates (int)
- embedding: SampleEmbedding#
- rao_gap()[source]#
v_repagain, under the name of the identity it satisfies.Rao(Φ̄) = mean_r Rao(Φ_r) + v_repholds exactly:Φ̄embeds the mixture over subsamples, which is genuinely more diverse than any single subsample, so the excess diversity of the average is the replicate variance. Averaging commutes withΦbut not with nonlinear functionals of it —mean(Rao) ≠ Rao(mean)— and this is the exact gap.- Return type:
- mir.repertoire.rarefy_embedding(space, sample_df, depth, *, n_replicates=10, weight='log2p1', seed=0)[source]#
Embed a repertoire rarefied to a common read depth, averaged over replicate draws.
The only depth correction that preserves the kernel-mean semantics exactly.
Φis linear in the clone-weight measure, so the mean ofΦover independent multinomial subsamples is itself a kernel mean embedding — of the mixture distribution over subsamples — and MMD, Rao’sQand mixture linearity all survive (verified to ~1e-15). An orthogonal projection breaks Rao; a per-coordinate location-scale rescale breaks MMD and Rao both.Not a default, and not a substitute for carrying depth explicitly: rarefying a whole cohort to the depth of its shallowest useful samples throws away the deep samples’ entire advantage, which is why
depth_threshold()+missing_mass()are the first-line tools. Reach for this when two groups must be compared at matched depth and you need the comparison to stay an MMD.Only the mean block is averaged — see
RarefyResult.rao_gap().- Parameters:
space (RepertoireSpace) – The shared
RepertoireSpace.sample_df (DataFrame) – One sample’s clonotypes with
duplicate_count.depth (int) – Reads to draw per replicate. Must not exceed the sample’s total.
n_replicates (int) – Independent multinomial draws to average (
v_repneeds≥ 2).weight (str) – Clone-size weight applied to the rarefied counts.
seed (int) – RNG seed.
- Returns:
A
RarefyResult.- Raises:
ValueError – If
depthexceeds the sample’s read total,depth < 1, orn_replicates < 1.- Return type:
- class mir.repertoire.RepertoireDescriptor(log_mass, log_neff, simpson, mean)[source]#
Bases:
objectSmooth, mass-preserving repertoire descriptor — every summary metric is a derivable coordinate.
sample_embedding()’sΦfrequency-normalises, which discards the total mass (= infiltration). The descriptor instead keeps the mass as a coordinate alongside diversity and clonality, so the whole object is:decodable —
metrics()reads infiltration / diversity / clonality off analytically;smooth —
logmass,logn_eff and Simpson λ are continuous (no integer richness⁰D), the “smoother form” of the Hill block;simulatable —
vectoris a fixed-width continuous vector you can perturb and generate: fit a density over a cohort, move along the infiltration coordinate → in-silico “hotter / colder” (decode_metrics()turns any perturbed vector back into named metrics).
meanis the normalised kernel meanμ/G(clonotype identity / composition); the scalar coordinates are the count-distribution summaries. Distances/generation live in the concatenatedvector.- mean: ndarray#
- property scalar: ndarray#
The derivable-metric coordinates
[infiltration, log n_eff, clonality].
- property vector: ndarray#
The full descriptor
[scalar ‖ mean]— the continuous object to perturb / generate.
- mir.repertoire.sample_descriptor(space, sample_df, *, weight='log2p1')[source]#
Mass-preserving smooth descriptor of one repertoire (see
RepertoireDescriptor).The scale (
log_mass= infiltration) is retained rather than normalised away, so infiltration, diversity and clonality are all smooth coordinates of the same object — the representation the in-silico-evolution / embedding-simulation workflow perturbs.- Parameters:
space (RepertoireSpace)
sample_df (DataFrame)
weight (str)
- Return type:
- mir.repertoire.decode_metrics(vector)[source]#
Read named metrics off a (possibly perturbed or generated) descriptor vector — the inverse used for in-silico evolution: perturb
RepertoireDescriptor.vector, decode the new metrics.- Parameters:
vector (ndarray)
- Return type:
- mir.repertoire.mmd_distance(a, b, *, unbiased=False)[source]#
MMD between two repertoires,
‖Φ₁(a) − Φ₁(b)‖(Eq.eq:kme).The default (
unbiased=False) is the biased V-statistic‖μ̂_a − μ̂_b‖— simple, but its self-terms carry a positive bias≈ 1/n_eff(from thek(z,z)diagonal), so a low-diversity (smalln_eff) sample gets its distances inflated by construction. When diversity is itself the variable of interest (e.g. divergence-vs-age), that bias masquerades as signal with the wrong sign (distance tracks low diversity, not high).unbiased=Trueremoves the diagonal analytically using the storedn_eff(Gretton et al. 2012, unbiased MMD²) — the estimator to trust when comparing samples of unequal depth/diversity.- Parameters:
a (SampleEmbedding)
b (SampleEmbedding)
unbiased (bool)
- Return type:
- mir.repertoire.mmd_matrix(embs, *, unbiased=False)[source]#
Symmetric sample×sample MMD matrix (feeds a regressor /
cluster_samples).unbiased=Trueuses the diagonal-removed MMD² (seemmd_distance()) — necessary whenever samples differ in depth/n_eff, else the1/n_effself-bias confounds the comparison.- Parameters:
embs (list[SampleEmbedding])
unbiased (bool)
- Return type:
ndarray
- mir.repertoire.class_witness(space, pos, neg, candidates, *, weight='log2p1', top=30, witness=None)[source]#
Rank clonotypes by how much they drive the
posvsneggroup difference (Prop.prop:witness).The MMD witness is the mean-embedding difference
w = μ_pos − μ_negin RFF feature space; a clonotypeσscoress(σ) = ⟨w, ψ(φ(σ))⟩, so the top-scoring candidates are the discriminative public clones / motifs separating the two groups — the supervised way to find motifs that the unsupervised bulk kernel mean cannot surface (it is swamped by the naive background).- Parameters:
space (RepertoireSpace) – The shared
RepertoireSpace.pos (list[DataFrame]) – Per-group lists of sample clonotype frames (with
duplicate_count).neg (list[DataFrame]) – Per-group lists of sample clonotype frames (with
duplicate_count).candidates (DataFrame) – Clonotype frame to score (e.g. all clonotypes seen in
pos).weight (str) – Clone-size weight for the per-sample kernel means.
top (int) – Number of top motifs to return.
witness (ndarray | None) – Optional precomputed witness direction
μ_pos − μ_neg((D,)). When given,pos/negare not re-embedded — reuse it to score several candidate sets cheaply.
- Returns:
candidateswith awitness_scorecolumn, sorted descending, truncated totop.- Return type:
DataFrame
- mir.repertoire.hla_stratified_mmd(embs, hla)[source]#
MMD restricted to HLA-matched sample pairs;
nanwhere two samples share no allele.Response ridges are presented only by a specific HLA type, so cross-sample overlap is informative only within an HLA-matched stratum (Prop.
prop:hla). Pairs that share no HLA allele are masked tonanrather than compared.- Parameters:
embs (list[SampleEmbedding])
- Return type:
ndarray
- mir.repertoire.centroid_atypicality(X, groups)[source]#
Per-sample cosine distance of its identity vector to its group’s centroid — a Φ-geometry op.
A sample far from its group’s mean identity has an atypical clonotype composition (selection / divergence). Grouping is caller-supplied (tumour type, cohort, …); the geometry — centroid then
1 − cos— is the library concern. Feeds anatypicalitychannel of a digital-donor embedding.- Parameters:
X (ndarray) –
(n, d)identity block (a per-sample kernel-mean or its PCA reduction).groups (ndarray) –
(n,)group label per row; centroids are computed within each group.
- Returns:
(n,)atypicality in[0, 2](0 = on the group centroid direction).- Return type:
ndarray
- mir.repertoire.correct_batch(X, batch, *, covariates=None, n_clusters=8, theta=1.0, sigma=0.1, max_iter=10, ridge=1.0, seed=0)[source]#
Harmony-like cluster-aware batch correction on a stacked sample×feature
Φmatrix.Removes a batch offset per soft cluster rather than globally, so a batch confounded with a biological cluster is corrected without erasing that biology — the failure mode of plain per-group mean subtraction (
mir.cohort.residualize()). Follows Harmony (Korsunsky et al. 2019, Nat. Methods): soft-cluster the samples with a batch-diversity penaltytheta(clusters pushed toward batch-balanced membership), then subtract each cluster’s membership-weighted batch offset (covariates retained), and iterate. Reduces exactly toresidualizeatn_clusters=1ortheta=0.- Parameters:
X (ndarray) – Stacked
(n_samples, n_features)matrix (e.g.mir.explain.stack_embeddings()).batch (ndarray) – Length-
n_samplesbatch label per row.covariates (ndarray | None) – Optional
(n_samples, k)biological covariates to retain (never removed).n_clusters (int) – Number of soft clusters (Harmony’s
K);<=1disables clustering.theta (float) – Batch-diversity penalty strength;
0disables clustering (→residualize).sigma (float) – Soft-assignment temperature.
max_iter (int) – Correction iterations (converges in a few — the batch fit vanishes once removed).
ridge (float) – L2 ridge on the per-cluster batch regression (stabilises small clusters).
seed (int) – KMeans seed for the soft-cluster initialisation.
- Returns:
The corrected
(n_samples, n_features)matrix — a new coordinate system (as withresidualize, never compare a correctedXto an uncorrected one).- Return type:
ndarray
Explainable readouts (mir.explain)#
Which named channel of Φ carries the signal, and which clonotypes drive it (Theory T7).
ChannelBuilder.add(..., preserve_magnitude=True) scales a magnitude-carrying (sub-probability)
block by one global scalar instead of per-column z-scoring.
Explainable readouts over a repertoire feature matrix: which channel carries the signal (§T.7).
vdjtools-era repertoire analysis answers “which summary statistic separates my groups?” by running a fixed menu of named statistics side by side — diversity, clonality, CDR3 length, V-usage, overlap — one test per statistic, and reading off which one moved. The menu is the explanation: every number has a name, so a result is a sentence (“the groups differ in clonality”).
The embedding replaces the menu with one vector Φ(S) (mir.repertoire), which scores better
and explains worse: Φ.vector is an anonymous concatenation, so “the classifier found something”
has no noun. This module restores the noun without giving up the vector. A channel is a named
group of Φ columns (the mean/identity block, the Hill block, the second-moment block, and any
extra blocks a study bolts on — per-chain blocks of the same kind merge under one name). A
ChannelSpec is the name→column-index map that Φ.vector alone does not carry;
ChannelBuilder assembles one alongside the matrix, so the layout is declared where the
blocks are stacked, not rediscovered later.
Given the map, “which part of Φ carries this signal” is an ablation against a user-supplied
scorer f: X_block → float (higher is better):
delta_in(g) = f(X[:, cols(g)]) − base # does channel g carry signal on its own?
delta_out(g) = f(X) − f(X[:, ¬cols(g)]) # is channel g's signal anywhere else?
The scorer is a closure over the study’s labels, so the same call works for a Cox C-index, a
cross-validated AUC, or anything else — this module never sees y and ships no scorers of its own
(model choices belong to the analysis, not the library). delta_in is the default and is
marginal: it is inflated by correlation between channels, so two redundant channels both look
important. delta_out is conditional and deflated by the same correlation. Reported together
they separate the two claims — high in / high out = irreplaceable; high in / ~zero out =
redundant, its signal is duplicated elsewhere. Significance, when asked for, is a row permutation
of the block: the scorer holds y in row order, so shuffling the block’s rows breaks the
association exactly as shuffling y would, and it is the only scorer-agnostic null available.
Leave-one-in is the default for a concrete reason: the scorers in practice reduce their input
(an in-fold PCA to n_pc components), so dropping one narrow channel out of a wide block leaves
the reduction to re-mix the rest and reconstruct nearly the same components — leave-one-out is
structurally near-blind to exactly the 1-column channels that often win. Ask for mode="both"
when the question is whether the winner is necessary rather than sufficient.
The last hop, channel → clonotypes, exists only for channels with a clonotype pre-image: a
kernel-mean block, where mir.repertoire.class_witness() reads the MMD witness
s(σ)=⟨μ_pos−μ_neg, ψ(φ(σ))⟩. A Hill number or a read-count fraction has no such pre-image —
asking which clones drive the diversity channel is a category error, and channel_drivers()
raises rather than answer it. Attributability is therefore declared per channel at build time,
never guessed from its name.
Torch-free (numpy / polars); scorer-agnostic (no sklearn / lifelines import here).
Typical usage:
from mir.explain import ChannelBuilder, channel_drivers, channel_report, stack_embeddings
embs = [sample_embedding(space, s) for s in samples]
X, spec = stack_embeddings(embs) # channels: mean ‖ diversity ‖ second
rep = channel_report(X, spec, lambda B: cv_auc(B, y)[0], base=0.5)
print(rep.frame()) # one row per channel: score, delta, rank
rep.best # -> "second" (the HLA imprint)
# multi-source assembly (per-chain blocks merge by name) + the channel -> clonotypes hop
b = ChannelBuilder()
for c in chains:
b.add("identity", ident[c], attributable=True).add("diversity", hill[c])
X, spec = b.add("coverage", log_reads).build() # median-impute + z-score
rep = channel_report(X, spec, lambda B: cv_cindex(dur, evt, base=clin, block=B, n_pc=8),
base=cv_cindex(dur, evt, base=clin), mode="both")
channel_drivers(rep, space=space, pos=pos, neg=neg, candidates=cands) # if rep.best is a KME
- class mir.explain.ChannelSpec(columns_by_name, attributable=frozenset({}))[source]#
Bases:
objectName → column-index map for a feature matrix — the label
Φ.vectordoes not carry.spec[name]is an alias forcolumns(), so a spec drops into code written against the plaindict[str, list[int]]it replaces (X[:, spec["coverage"]], orspec["a"] + spec["b"]for a union of columns).- Variables:
- Parameters:
- class mir.explain.ChannelBuilder(_blocks=<factory>, _cols=<factory>, _attr=<factory>, _mag=<factory>, _col=0)[source]#
Bases:
objectAccumulate named blocks into one matrix + its
ChannelSpec.Blocks added under the same name merge into one channel (per-chain identity blocks become a single
identitychannel), which is the granularity ablation asks about. For per-chain resolution, add under distinct names (f"identity:{chain}").- Parameters:
- add(name, mat, *, attributable=False, preserve_magnitude=False)[source]#
Append a block. Returns
selffor chaining.- Parameters:
name (str) – Channel name; repeat a name to merge blocks into one channel.
mat (ndarray) –
(n_samples,)or(n_samples, k). 1-D is treated as a single column.attributable (bool) – Mark the channel clonotype-attributable (a kernel-mean block). Sticky: one attributable block makes the whole merged channel attributable.
preserve_magnitude (bool) – Scale this channel by one global scalar (its pooled RMS over observed entries, no centring) instead of per-column z-scoring, and fill holes with
0. Required for any block whose magnitude carries information — a sub-probability / contrast block frommir.repertoire.contrast_embedding(), where a near-zero row means “no confident deviation from naive”. Per-column standardisation forces every coordinate to unit variance across samples, so a matrix where half the rows are near-zero comes out looking exactly like one where none are: it deletes the deficiency it was meant to preserve. (This already invalidated one experiment, whose deficient arm scored 0.6481 against a 0.6179 baseline purely from the rescaling.) Sticky, as withattributable.
- Returns:
self.- Raises:
ValueError – On a row-count mismatch with the blocks already added.
- Return type:
- build(*, standardize=True, impute=True)[source]#
Assemble
(X, spec).- Parameters:
impute (bool) – Replace non-finite entries with the column median (
0.0if a column is all non-finite) — chains a sample lacks leave holes.standardize (bool) – Z-score each column. Default
Truebecause the builder exists for heterogeneous assembly, where a log-read-count column and a read-fraction column differ by orders of magnitude and any distance/PCA/penalised fit is otherwise dominated by the loudest unit. PassFalseto keep raw values.
- Return type:
tuple[ndarray, ChannelSpec]
Both statistics come from the observed entries of each column, before imputation. The order matters: imputing first and then z-scoring computes
sdover a column whose holes have all been set to one constant, which deflates it in proportion to how sparse the column is — so a locus present in 30% of samples ends up with its real values scaled up ~1.8x against a fully-covered one, giving the least-observed chain the most weight in every downstream distance, PCA and penalised fit. Standardising on observed entries makes an imputed hole land at exactly 0 (the column mean, i.e. no information) in a column whose scale the hole did not set.Channels added with
preserve_magnitude=Trueare exempt from both: they get one global scalar and no centring, so their row norms survive, and their holes are filled with0.- Returns:
(X, spec).- Raises:
ValueError – If no blocks were added.
- Parameters:
- Return type:
tuple[ndarray, ChannelSpec]
- mir.explain.stack_embeddings(embs)[source]#
Stack
Φ(S)embeddings into(X, spec)with channels named after their blocks.Xis row-wise identical tonp.stack([e.vector for e in embs])— this only attaches the names, it does not transform. No z-scoring: the blocks are one homogeneousΦand rescaling them would silently change every MMD/PCA downstream.- Parameters:
embs (Sequence[SampleEmbedding]) – Per-sample embeddings from
mir.repertoire.sample_embedding(), all fitted from oneRepertoireSpace.- Returns:
(X, spec)withXshape(len(embs), Φ_dim)and channelsmean/diversity/second(whichever are present).meanis marked attributable.- Raises:
ValueError – If
embsis empty or the embeddings disagree on present blocks / widths.- Warns:
UserWarning – If any embedding carries a deficient mass (
mass < 1).Φ.vectordoes not encode it — the blocks are renormalised measures either way — so a deficient cohort stacked this way silently discards the deficiency. Build the mean block withmir.repertoire.contrast_embedding()and add it underChannelBuilder.add(..., preserve_magnitude=True)instead.- Return type:
tuple[ndarray, ChannelSpec]
- class mir.explain.ChannelReport(channels, score, delta, rank, base, full, n_samples, spec, mode, delta_out=None, pvalue=None)[source]#
Bases:
objectPer-channel ablation readout — the explainable answer to “which part of
Φcarries this”.- Variables:
score (numpy.ndarray) –
scorer(X[:, cols(g)])— the leave-one-in score of each channel alone.delta (numpy.ndarray) –
score − base.nanif nobasewas given.rank (numpy.ndarray) – 1 = best, by the primary statistic of
mode.base (float) – Reference score with no channel features (Cox clinical-only, or 0.5 for chance);
nanif not supplied.full (float) –
scorerover all reported channels — the “C+Φ” headline.n_samples (int) – Rows scored.
spec (mir.explain.ChannelSpec) – The
ChannelSpecscored (provenance; also carriesattributable).mode (str) –
"in"|"out"|"both".delta_out (numpy.ndarray | None) –
full − scorer(X[:, ¬cols(g)])— the drop caused by removing g, so larger = more uniquely necessary (same sign convention asdelta).Noneunless requested.pvalue (numpy.ndarray | None) – One-sided row-permutation p per channel, add-one smoothed (
≥ 1/(1+n_permutations), never 0).Noneunless requested.
- Parameters:
- score: ndarray#
- delta: ndarray#
- rank: ndarray#
- spec: ChannelSpec#
- mir.explain.channel_report(X, spec, scorer, *, base=None, mode='in', channels=None, n_permutations=0, seed=0)[source]#
Score every channel of
Xunderscorerand rank them (§T.7).- Parameters:
X (ndarray) –
(n_samples, spec.width)feature matrix.spec (ChannelSpec) – Channel map for
X’s columns.scorer (Callable[[ndarray], float]) –
X_block → float, higher is better, closing over the study’s labels (lambda B: cv_auc(B, y)[0],lambda B: cv_cindex(dur, evt, base=clin, block=B, n_pc=8)). Any block-width-dependent choice — in-fold PCA, penaliser — belongs inside the closure; this function only slices columns.base (float | None) – Reference score with no channel features. Cox:
cv_cindex(dur, evt, base=clin). Classification:0.5.None→deltaisnanand ranking falls back toscore(an identical ordering —deltais a constant shift ofscore).mode (str) –
"in"(default; each channel alone vsbase— the marginal question),"out"(each channel removed vsfull— the uniqueness question), or"both".channels (Sequence[str] | None) – Restrict / order the report.
None→specorder. For"out"/"both",fulland the complements are taken over the union of the reported channels.n_permutations (int) – If > 0, a one-sided permutation p per channel. Costs
n_permutations × len(channels)extra scorer calls; off by default.seed (int) – Permutation RNG seed.
- Returns:
A
ChannelReport;.bestis the winning channel,.frame()the tidy table.- Raises:
ValueError – If
X.shape[1] != spec.width,channelsnames an unknown channel, the channel list is empty, ormodeis not one of"in"/"out"/"both".- Return type:
- mir.explain.channel_drivers(report, *, space, pos, neg, candidates, channel=None, weight='log2p1', top=30)[source]#
Clonotypes driving a channel — the channel → clones hop (Prop.
prop:witness).Delegates to
mir.repertoire.class_witness()(the MMD witnesss(σ)=⟨μ_pos−μ_neg, ψ(φ(σ))⟩) and guards it: only a channel declaredattributable— a kernel-mean block — has a clonotype pre-image. A Hill number, a read-count fraction or a centroid distance is a summary; its “drivers” are the count distribution, not any clone, and asking for them is a category error this function refuses rather than answers.NB the witness is recomputed from
pos/negthroughspace’s raw kernel mean, so it attributes the channel’s source, not the exact (typically reduced) columns that were scored. That is the intended reading — the reduction is a fitting convenience, the mean is the object — but a driver list explainsμ_pos−μ_neg, not the specific components.- Parameters:
report (ChannelReport) – The readout naming the channel.
space (RepertoireSpace) – The shared
RepertoireSpacethe channel was built from.pos (list[DataFrame]) – Per-sample clonotype frames for the positive group (with
duplicate_count).neg (list[DataFrame]) – Per-sample clonotype frames for the negative group.
candidates (DataFrame) – Clonotype frame to score (e.g. all clonotypes seen in
pos).channel (str | None) – Which channel to attribute.
None→report.best.weight (str) – Clone-size weight for the per-sample kernel means.
top (int) – Number of driving clonotypes to return.
- Returns:
candidateswithwitness_score(descending, truncated totop) plus achannelcolumn, so a stacked driver frame stays self-describing.- Raises:
ValueError – If
channelis unknown, or is not clonotype-attributable.- Return type:
DataFrame
Cohort / digital donor (mir.cohort)#
Fuse per-chain repertoire embeddings into one hash-verified, serialisable donor matrix; batch residualisation (with optional James–Stein shrinkage), depth and missingness diagnostics, sample clustering, and incidence biomarkers (Theory T7).
The digital donor: one fixed, explainable descriptor per subject, fused across chains (§T.7).
Where mir.repertoire embeds one repertoire (one chain of one sample) and mir.explain
names the pieces of a feature matrix, this module assembles the cohort object the clinical
pipelines actually consume — a digital donor whose vector concatenates, per locus, the identity
(kernel mean), diversity (Hill) and coverage (log receptor load) channels into one row, ready for
survival / classification via mir.explain.channel_report().
The library owns the geometry, fusion and serialization; the study owns which extra channels
(isotype, composition, HLA, …) and which scorer. So fit_donor_embeddings() builds the
per-chain geometry and fuses it through mir.explain.ChannelBuilder, and takes an
extra_channels hook for the analysis to inject its own tissue/clinical blocks into the same
matrix — the channels then flow through channel_report indistinguishably.
Comparability bites twice here (two bases, not one): each locus carries its own prototype hash and
a cross-sample identity-PCA rotation. DonorCohort stores both; DonorCohort.load()
verifies every locus’s prototype hash, and DonorCohort.transform() is the only way to project
held-out donors into the fitted basis. A batch-residualize``d ``X is likewise incomparable to the
raw one — record the correction in provenance.
Torch-free (numpy / sklearn / polars); vdjtools is lazy (only incidence_biomarkers()).
- class mir.cohort.LocusAlignment(ids, blocks, mask, loci)[source]#
Bases:
objectPer-locus matrices aligned onto one sample axis, with holes where a locus is missing.
- Variables:
- Parameters:
- mask: ndarray#
- property n_loci: ndarray#
(n_samples,)count of loci present per sample.
- property pattern: ndarray#
(n_samples,)presence pattern as a string, e.g."1101100"— the missingness stratum.
- build(*, standardize=True, attributable=True)[source]#
Fuse to
(X, spec)throughChannelBuilder, one channel per locus.The holes are imputed and standardized by the builder, which takes both statistics from observed entries — so a locus present in a third of samples is not upweighted by its own sparsity, and a hole lands at exactly 0.
- missingness_report(labels)[source]#
Whether a grouping tracks which loci a sample has rather than what is in them.
See
missingness_report().- Return type:
- mir.cohort.align_loci(blocks, *, how='union', require=None, min_loci=1)[source]#
Align per-locus embedding matrices, keyed by sample id, onto one sample axis.
The step between “one matrix per locus, each over its own samples” and “one matrix over one sample set”. Loci are sequenced to different depths, so their sample sets differ — and an inner join across seven loci is bound by the thinnest two. Measured in a 62,293-sample tissue cohort: intersecting all seven left 19,346 samples (31%), while the union keeps every one. That is complete-case deletion wearing a join’s clothes, so
how="union"is the default.A missing locus becomes
nanrows, which is the hole conventionChannelBuilderalready understands: it imputes them to the observed centre and standardizes on observed entries, so an absent locus contributes no information and does not set any channel’s scale.Do not zero-fill before calling this. A literal 0 is a value — after a naive global z-score it becomes
-mean/std, a large shared constant that every sample missing that locus carries, and the cohort then clusters by coverage. (Measured: cluster-vs-locus-count AMI 0.741 and read-depth eta-squared 0.42 cohort-wide against 0.01 inside the fully-observed subset.)nansays “not observed”;0says “observed to be zero”.- Parameters:
blocks (dict) –
{locus: (ids, matrix)}—idsa sequence of sample ids,matrixan(len(ids), k)array. Per-locuskmay differ.how (str) –
"union"keeps every sample seen in any locus (holes where absent);"inner"keeps only samples present in all of them."inner"exists to be compared against, not as a default.require (list | None) – Loci a sample must have to be kept at all — for when one chain genuinely carries the question and the others are context.
min_loci (int) – Drop samples carrying fewer than this many of the loci.
- Returns:
- Raises:
ValueError – If
blocksis empty, a matrix’s row count disagrees with its ids,howis not recognised,requirenames an absent locus, or nothing survives the filters.- Return type:
Example
>>> al = align_loci({"TRB": (trb_ids, TRB), "IGH": (igh_ids, IGH)}) >>> X, spec = al.build() >>> al.missingness_report(clusters)["ami_vs_pattern"] < 0.1 True
- mir.cohort.missingness_report(labels, mask)[source]#
How much of a grouping is explained by which blocks each sample has.
A cohort assembled from partly-observed blocks can cluster by coverage rather than by content, and nothing in
Xsays which happened. This is the check to run before believing a clustering, an enrichment or a trajectory built on holed data.- Parameters:
labels –
(n_samples,)cluster or group assignment to test.mask –
(n_samples, n_blocks)boolean presence, e.g.LocusAlignment.mask.
- Returns:
ami_vs_pattern— adjusted mutual information betweenlabelsand the presence pattern;eta2_n_present— correlation ratio of the per-sample present-block count;n_patterns;mean_present. Near zero on the first two is what you want. High means the grouping is a missingness stratification: re-read it inside a fully-observed subset before reporting it as biology.- Return type:
Example
>>> missingness_report(clusters, al.mask)["ami_vs_pattern"] 0.041
- mir.cohort.depth_report(X, stats, *, n_pc=5, subset=None)[source]#
How much of an embedding’s leading geometry is explained by sampling depth.
The companion to
missingness_report(): that one asks whether a grouping tracks which blocks a sample has, this one whether the embedding’s dominant axes track how deeply it was sequenced. Each of the topn_pcprincipal components is regressed on the sampling fingerprint (mir.repertoire.cohort_statistics()) and reported as R².Report the leading block, not only PC1 — a correction can move depth off PC1 and leave it in PC2. Measured with the deficient measure (
mir.repertoire.contrast_embedding()), R²(PC1, depth) fell 0.259 → 0.001 and best-of-PC1–5 0.253 → 0.047, while PC1’s explained variance was unchanged, which is what distinguishes “a different direction” from “a collapsed one” — so readexplained_variancealongside the R².- Parameters:
X –
(n_samples, d)embedding matrix (uncentred is fine; PCA centres internally).stats (dict) –
{name: (n_samples,) values}sampling fingerprint. Non-finite entries are mean-filled so one missing library size cannot drop a sample.n_pc (int) – Number of leading components to test.
subset – Optional boolean mask of a trusted subset (e.g. samples above
mir.repertoire.depth_threshold()’sκ). When given, the same statistics are recomputed inside it and returned under*_subset: high overall and low within the subset means the structure was between coverage strata.
- Returns:
r2_pc1,r2_best,pc_best(1-indexed),r2_by_pc,explained_variance(per-PC ratio),n_pc,stats(names, in fit order),n_samples— plusr2_pc1_subset/r2_best_subset/n_samples_subsetwhensubsetis given.- Raises:
ValueError – If
statsis empty or a statistic’s length disagrees withX.- Return type:
- class mir.cohort.DonorCohort(X, spec, spaces, identity_pca, rows, meta=<factory>)[source]#
Bases:
objectA fitted cohort of digital donors: the fused matrix plus everything needed to reproduce it.
- Variables:
X (numpy.ndarray) –
(n_donors, width)fused, imputed, z-scored feature matrix.spec (mir.explain.ChannelSpec) –
ChannelSpec— the channel → column map forX.spaces (dict) –
{locus: RepertoireSpace}— the per-locus clonotype bases (hash-verified on load).identity_pca (dict) –
{locus: fitted StandardScaler→PCA}— the cross-sample identity reducer, stored (not thrown away) so held-out donors project comparably.rows (list | None) – Per-donor metadata, row-aligned to
X(Noneif not supplied).meta (dict) – Provenance — per-locus prototype hashes,
id_pca, block order, standardization stats.
- Parameters:
X (ndarray)
spec (ChannelSpec)
spaces (dict)
identity_pca (dict)
rows (list | None)
meta (dict)
- X: ndarray#
- spec: ChannelSpec#
- missingness_report(labels)[source]#
Whether a grouping tracks which chains each donor has, rather than what is in them.
See
missingness_report(); the presence matrix is the one recorded at fit, one column per assembled block.- Parameters:
labels –
(n_donors,)cluster or group assignment to test.- Returns:
{ami_vs_pattern, eta2_n_present, n_patterns, mean_present}.- Raises:
ValueError – If the cohort predates block-presence recording.
- Return type:
Example
>>> cohort.missingness_report(clusters)["ami_vs_pattern"] < 0.1 True
- transform(donor_frames, *, extra=None)[source]#
Project held-out donors into this fitted basis (the only comparable path for new donors).
Rebuilds the per-locus identity/diversity/coverage blocks through the stored spaces and identity PCAs, re-adds any
extrachannels the caller supplies (same names/widths as at fit — the study owns those), then applies the fit-cohort impute medians and z-scores.- Parameters:
- Returns:
(len(donor_frames), width)matrix in the fitted basis.- Return type:
ndarray
- save(path)[source]#
Pickle the cohort:
X/spec/rows/meta/identity PCAs + each locus’s basis.- Raises:
ValueError – If any locus basis uses a non-default
matrix/alignment—loadcould not rebuild that coordinate system (seemir.repertoire._check_rebuildable()).- Return type:
None
- mir.cohort.fit_donor_embeddings(spaces, donor_frames, *, rows=None, id_pca=8, min_clones=5, coverage=True, extra_channels=None, standardize=True, impute=True, seed=0)[source]#
Fuse per-chain repertoire embeddings into one digital-donor matrix (§T.7).
For each donor and each fitted locus, computes the kernel-mean identity (cross-sample PCA-reduced), Hill diversity and log receptor-load coverage; per-chain blocks of the same kind merge under one channel name. Missing chains leave holes that
ChannelBuilderimputes.- Parameters:
spaces (dict) –
{locus: RepertoireSpace}frommir.repertoire.fit_repertoire_spaces().donor_frames (list[dict]) – Per donor,
{locus: chain clonotype frame}(already canonical-filtered / grouped by the study; the library only embeds). Row-aligned torows.rows (list | None) – Optional per-donor metadata, carried onto the cohort.
id_pca (int) – Cross-sample identity-PCA dimensionality per locus.
min_clones (int) – Skip a chain for a donor with fewer clonotypes (its blocks become holes).
coverage (bool) – Include the per-chain log-receptor-load channel.
extra_channels – Optional
(rows, identity_concat) -> {name: (n, k) array}— the study’s own blocks (isotype / composition / atypicality …), fused into the same matrix. All are treated as non-attributable.identity_concatis the(n, Σ id_pca)per-chain identity, so e.g.mir.repertoire.centroid_atypicality()can be computed against it.impute (bool) – Passed through to the final assembly (z-score / median-impute).
seed (int) – RNG seed for the identity PCA.
standardize (bool)
impute
- Returns:
A
DonorCohort;.X/.specfeedmir.explain.channel_report().- Return type:
- mir.cohort.residualize(X, group, *, shrink=False)[source]#
Subtract each group’s mean vector — remove the first-order batch offset (Prop.
prop:batch).The detect→correct→verify batch cookbook’s correction step, applied to a stacked donor matrix. NB the corrected matrix is a different coordinate system: never compare a residualized
Xto an uncorrected one.⚠ The plain correction can make the batch easier to read, not harder, and this is measured rather than hypothetical: evaluated out-of-sample (leave-one-batch-out for any shared basis, plus a donor-level split inside each batch), batch-identity AUC went 0.863 raw → 0.985 after per-group centring, and 0.978 after ComBat. The mechanism is estimation error, not a bug — with a mean fitted from as few as 8–15 samples in ~1,280 dimensions,
‖µ̂ − µ‖ ≈ √(σ²d/n)was ≈16 against a true offset of norm 7–24, so subtracting it injects a batch-constant vector as large as the one it removes. In-sample this vanishes by construction, which is why only an out-of-sample evaluation sees it.shrink=True(positive-part James–Stein) recovered most of the damage in the same experiment: 0.985 → 0.889.- Parameters:
X (ndarray) –
(n_samples, n_features)stacked matrix.group (ndarray) – Length-
n_samplesbatch label per row.shrink (bool) – Scale each group’s offset by the positive-part James–Stein factor
max(0, 1 − (d−2)·(σ̂²/n_g)/‖µ̂_g‖²)— whereσ̂²is the pooled within-group per-coordinate variance — so a group whose apparent offset is no bigger than its own estimation error is left alone. Recommended whenever a group has few samples relative to the feature count;Falsekeeps the exact previous behaviour.
- Returns:
The corrected
(n_samples, n_features)matrix.- Return type:
ndarray
- mir.cohort.cluster_samples(embs, *, unbiased=True, method='dbscan', eps=None, min_samples=3, k=4, **kwargs)[source]#
Cluster repertoires by MMD — the repertoire-level analog of
mir.bench.metrics.cluster()(TME states etc.).Builds the MMD distance matrix (unbiased by default, since cohorts differ in depth) and clusters it with a precomputed-metric density estimator (
mir.bench.metrics.cluster()).- Parameters:
embs – Per-sample
SampleEmbedding(mean block present).unbiased (bool) – Use the diagonal-removed MMD² (recommended for unequal-depth cohorts).
method (str) –
"dbscan"/"hdbscan"/"optics".eps (float | None) – DBSCAN radius;
None→ median of each sample’sk-th nearest MMD (self excluded).k (int) – density / eps-estimation neighbours.
**kwargs – forwarded to the estimator.
min_samples (int)
k
- Returns:
Cluster labels (
-1= noise), lengthlen(embs).
- mir.cohort.incidence_biomarkers(cohort, phenotype, *, pheno_col, match='1mm', min_incidence=3, **kwargs)[source]#
Cohort biomarker detection: per-clonotype subject-incidence Fisher test (Emerson 2017).
A thin delegate to
vdjtools.biomarker.fisher.fisher_association()— the presence/absence biomarker that complements the geometry witness (mir.repertoire.class_witness()) and, at realistic donor counts, recovers public motifs the witness misses.match="1mm"groups single-mismatch metaclonotypes (the paper’s method; exact-match typically returns ~0 hits).- Parameters:
cohort – Pooled clonotype frame with a sample-id column (vdjtools cohort form).
phenotype – Per-sample frame carrying the binary
pheno_collabel.pheno_col (str) – Phenotype column name in
phenotype.match (str) –
"1mm"(metaclonotypes) or"exact".min_incidence (int) – Minimum subject incidence to test a feature.
**kwargs – forwarded to
fisher_association(key,scope,alternative, …).
- Returns:
incidence, odds-ratio,
p_value, BHq_value, direction.- Return type:
One row per feature
Exposure trajectory (mir.track)#
A PhenoPath-style (Campbell & Yau 2018) covariate-disentangled latent trajectory over a per-sample
channel matrix — repertoire-level exposure detection, complementing mir.density’s clone-level
TCRNET/ALICE enrichment.
Covariate-disentangled repertoire trajectory: a PhenoPath-style exposure pseudotime.
Exposure detection so far in mirpy is clone-level: mir.density.neighbor_enrichment()
answers “which clonotypes look antigen-driven” against a background null. This module answers a
different question at the repertoire level: given a cohort of samples with a known covariate
(HLA type, vaccine arm, batch — anything already recorded) but an unknown or noisy progression
axis (days since exposure, response stage), can we recover a shared latent trajectory while
explicitly separating out which channels of the embedding respond to that trajectory differently
depending on the covariate?
This is the model of PhenoPath (Campbell & Yau 2018, Nat. Commun. 9:2442, doi:10.1038/s41467-018-04696-6), adapted from genes × cells to repertoire channels × samples. For sample n and channel g:
Y[n, g] = c_g + alpha_g . x_n + (kappa_g + gamma_g . x_n) * tau_n + eps_{n,g}
x_n is the known covariate vector, tau_n the latent trajectory (pseudo-exposure/progression),
kappa_g the channel’s baseline sensitivity to the trajectory, and gamma_g the covariate ×
trajectory interaction — the object of interest: a channel with large ‖gamma_g‖ evolves along the
trajectory differently depending on the covariate (e.g. an HLA-restricted response channel that
contracts over the response only in carriers). alpha_g is the covariate’s ordinary main effect,
independent of progression.
Inference here is a simplified, closed-form alternative to PhenoPath’s coordinate-ascent
variational inference: rather than a full mean-field posterior over every parameter, this
alternates (a) per-channel ridge regression for (c, alpha, kappa, gamma) given tau, (b) a
generalized-least-squares update of tau given the channel loadings, and (c) an iteratively-
reweighted (empirical-Bayes) precision update on gamma that shrinks small interaction
coefficients toward zero — the fast/approximate limit of PhenoPath’s ARD prior, not a literal
reimplementation of its CAVI engine. Both blocks are ordinary linear-Gaussian conditionals, so every
step is closed-form; this keeps the module torch-free (numpy only), consistent with
mir.repertoire / mir.density.
Typical usage:
from mir.explain import stack_embeddings
from mir.track import fit_exposure_trajectory
embs = [sample_embedding(space, s) for s in samples] # one shared RepertoireSpace
X, spec = stack_embeddings(embs) # channels: mean | diversity | second
covariates = hla_indicator # (n_samples, n_alleles) known covariate
fit = fit_exposure_trajectory(X, covariates, channel_names=spec.names)
fit.tau # inferred exposure/progression pseudotime
fit.top_interactions(top=5) # which channels are covariate-modulated
- class mir.track.TrajectoryFit(tau, intercept, alpha, kappa, gamma, sigma2, ard_precision, channel_names, covariate_names, n_iter, converged, loglik_trace)[source]#
Bases:
objectA fitted covariate-disentangled repertoire trajectory (PhenoPath-style, see module docstring).
- Variables:
tau (numpy.ndarray) –
(n_samples,)latent trajectory, standardized (mean 0, unit variance). Sign is fixed sotaucorrelates positively with the first principal component of the (standardized) input channels, so it is not arbitrary between refits.intercept (numpy.ndarray) –
(n_channels,)per-channel interceptc_g.alpha (numpy.ndarray) –
(n_channels, n_covariates)direct covariate effect (independent oftau).kappa (numpy.ndarray) –
(n_channels,)baseline trajectory loading — the channel’s progression sensitivity shared across covariate levels.gamma (numpy.ndarray) –
(n_channels, n_covariates)covariate x trajectory interaction — the channel’s progression sensitivity changes by this much per unit covariate. The quantitytop_interactions()ranks channels by.sigma2 (numpy.ndarray) –
(n_channels,)residual variance per channel.ard_precision (numpy.ndarray) –
(n_channels, n_covariates)shrinkage precision applied togamma(large -> that interaction is forced toward 0). Only meaningful when fit withard=True.covariate_names (channel_names /) – Labels carried through for reporting, if supplied.
n_iter (int) – Iterations actually run.
converged (bool) – Whether the relative change in
(tau, gamma)fell belowtol.loglik_trace (list[float]) – Per-iteration Gaussian log-likelihood (up to the additive normalizing constant) — should increase monotonically-ish; a non-monotone trace signals a fitting problem.
- Parameters:
- tau: ndarray#
- intercept: ndarray#
- alpha: ndarray#
- kappa: ndarray#
- gamma: ndarray#
- sigma2: ndarray#
- ard_precision: ndarray#
- property interaction_score: ndarray#
(n_channels,)covariate-trajectory interaction magnitude,‖gamma_g‖over covariates.
- top_interactions(top=10)[source]#
Rank channels by covariate x trajectory interaction strength (descending).
- Parameters:
top (int) – Number of channels to return.
- Returns:
Columns
channel,interaction_score(‖gamma_g‖),kappa(the channel’s baseline trajectory sensitivity, for context), sorted byinteraction_scoredescending.- Return type:
DataFrame
- mir.track.fit_exposure_trajectory(X, covariates, *, channel_names=None, covariate_names=None, ard=True, max_iter=200, tol=0.0001, seed=0)[source]#
Fit a covariate-disentangled latent trajectory over a per-sample channel matrix.
- Parameters:
X (ndarray) –
(n_samples, n_channels)per-sample feature matrix — any stacked repertoire channel matrix (mir.explain.stack_embeddings(), aChannelBuilderbuild, or a raw embedding block). Standardized internally; the input scale doesn’t matter.covariates (ndarray) –
(n_samples,)or(n_samples, n_covariates)known covariate(s) — e.g. an HLA indicator, batch, vaccine arm. Continuous or one-hot; standardized internally.channel_names (list[str] | None) – Optional length-
n_channelslabels, carried ontoTrajectoryFitfortop_interactions().covariate_names (list[str] | None) – Optional length-
n_covariateslabels.ard (bool) – Iteratively-reweighted shrinkage on
gamma(see module docstring) — the empirical-Bayes approximation to PhenoPath’s ARD prior.Falsefits an unregularized interaction term (a fixed light ridge only, no shrinkage), useful for comparison / small cohorts where ARD’s extra sparsity isn’t wanted.max_iter (int) – Maximum alternating-update iterations.
tol (float) – Convergence tolerance on the relative change of
(tau, gamma)between iterations.seed (int) – RNG seed for a tiny initialization jitter that breaks exact PC1 ties (e.g. a rank-deficient
X); the fit is otherwise deterministic.
- Returns:
A fitted
TrajectoryFit.- Raises:
ValueError – On a row-count mismatch between
Xandcovariates, or fewer than 3 samples.- Return type:
- mir.track.fit_exposure_trajectory_from_samples(space, samples, covariates, *, weight='log2p1', blocks=('mean', 'diversity'), **kwargs)[source]#
Convenience: build the channel matrix from repertoire samples, then fit the trajectory.
- Parameters:
space – A shared
RepertoireSpace(mir.repertoire.fit_repertoire_space()).samples (list) – Per-sample clonotype frames (
duplicate_countpresent), oneSampleEmbeddingper sample viamir.repertoire.sample_embedding().covariates (ndarray) –
(len(samples),)or(len(samples), n_covariates)known covariate(s).weight (str) – Forwarded to
mir.repertoire.sample_embedding().blocks (tuple[str, ...]) – Forwarded to
mir.repertoire.sample_embedding().**kwargs – Forwarded to
fit_exposure_trajectory()(ard,max_iter,tol,seed,covariate_names).
- Returns:
A fitted
TrajectoryFit, withchannel_namesset from the embeddings’ blocks.- Return type:
Generative loop (mir.generate)#
A fitted (optionally class-conditional) density over RepertoireDescriptor vectors: sample new
synthetic donor states, or evolve one along a coordinate via the fitted covariance’s conditional
mean — the mechanical half of the generative loop (ROADMAP Phase 2).
The generative loop: a fitted density over repertoire descriptors, sample from it, evolve one.
Promotes the ad-hoc “in-silico evolution” pattern (a bare np.cov + a hardcoded conditional-mean
slope over a hand-picked metric list, prototyped analysis-side over TCGA) into a real, reusable
library object built on mir.repertoire.RepertoireDescriptor — the mass-preserving,
decodable, smooth coordinate system mir.repertoire already ships ([infiltration, log n_eff,
clonality] ‖ identity mean). DescriptorDensity is the (optionally class-conditional)
Gaussian fit over that coordinate; sample() draws brand-new synthetic donor
states from it (a digital twin generator — see mir.track for the complementary trajectory
axis), and evolve() perturbs one coordinate of a real descriptor and
propagates the coupled shift through every other coordinate via the fitted covariance’s multivariate-
normal conditional mean — the same move as “hotter ⇒ diversity ↑ / switch ↑ / T-vs-B ↓” but as a
library primitive instead of a one-off script.
This is the mechanical half of the generative loop (ROADMAP Phase 2): a linear-Gaussian density is
what the shipped in-silico-evolution numbers were fit with, so it stays the default here. The
research half — a genuinely non-linear, learned generative model over the full embedding (not just
the compact descriptor) — is mir.ml.diffusion, which this module’s sample/evolve API
deliberately mirrors so the two are drop-in alternatives.
What stays analysis-local (ROADMAP non-goals): fitting a survival/classification model on the
perturbed metrics (e.g. “does hotter predict better survival”) is the study’s scorer, not this
module’s job — mir.explain’s channel_report/the analysis’s own CoxPH fit consume the
output of evolve(), this module never sees an outcome y.
Torch-free (numpy / sklearn).
Typical usage:
from mir.repertoire import sample_descriptor
from mir.generate import fit_descriptor_density, evolve
descriptors = [sample_descriptor(space, s) for s in samples]
density = fit_descriptor_density(descriptors, labels=tumor_type) # one Gaussian per label
synthetic = density.sample(50, condition="SKCM") # 50 new synthetic donor states
hotter = evolve(density, descriptors[0], coordinate="infiltration", delta=2.0, condition="SKCM")
hotter.metrics() # named metrics after the move
- class mir.generate.DescriptorDensity(mean=<factory>, cov=<factory>, dim=0)[source]#
Bases:
objectA fitted (optionally class-conditional) Gaussian density over descriptor vectors.
- Variables:
- Parameters:
- classmethod fit(X, labels=None)[source]#
Fit a Gaussian (or, with
labels, one Gaussian per label) over descriptor vectors.- Parameters:
X (ndarray) –
(n_samples, dim)stacked descriptor vectors (e.g.np.stack([d.vector for d in descriptors])).labels (list | None) – Optional length-
n_samplesgroup labels (tumor type, batch, condition, …) — fits one mean/covariance per distinct label instead of a single pooled Gaussian.
- Returns:
A fitted
DescriptorDensity.- Raises:
ValueError – If
Xhas fewer than 2 rows.- Return type:
- sample(n=1, *, condition=None, seed=0)[source]#
Draw
nsynthetic descriptor vectors from the fitted (conditional) Gaussian.- Parameters:
- Returns:
(n, dim)array — decode withmir.repertoire.decode_metrics()per row.- Return type:
ndarray
- evolve(vector, *, coordinate, delta, condition=None)[source]#
Shift
vectorbydeltaalong one coordinate, propagating the coupled response.The multivariate-normal conditional mean: fixing coordinate
dto move bydeltashifts every other coordinatejbySigma[j,d]/Sigma[d,d] * deltain expectation — this is the “hotter -> diversity up / switch up / T-vs-B down” move, generalized from a hand-picked metric list to the full descriptor (and, viaRepertoireDescriptor.mean, the identity block too).- Parameters:
vector (ndarray) – A
(dim,)descriptor vector to perturb (e.g. one real donor’s).coordinate (int | str) – Vector index, or one of
"infiltration"/"log_neff"/"clonality"for the named scalar coordinates.delta (float) – Amount to shift
coordinateby (in the descriptor’s own units — e.g. log-mass).condition – Which fitted Gaussian’s covariance defines the coupling (see
sample()).
- Returns:
The perturbed
(dim,)vector — decode withmir.repertoire.decode_metrics().- Return type:
ndarray
- mir.generate.fit_descriptor_density(descriptors, labels=None)[source]#
Fit a
DescriptorDensitydirectly fromRepertoireDescriptorobjects.- Parameters:
descriptors (list[RepertoireDescriptor]) – One
RepertoireDescriptorper sample (mir.repertoire.sample_descriptor()).labels (list | None) – Optional per-sample group labels — see
DescriptorDensity.fit().
- Returns:
A fitted
DescriptorDensity.- Return type:
- mir.generate.evolve(density, descriptor, *, coordinate, delta, condition=None)[source]#
Perturb one
RepertoireDescriptorand rebuild the result as one.Thin wrapper around
DescriptorDensity.evolve()that unpacks/repacksRepertoireDescriptorinstead of a bare vector, so the result’s.metrics()reads off directly.- Parameters:
density (DescriptorDensity) – A fitted
DescriptorDensity(the coupling comes from its covariance).descriptor (RepertoireDescriptor) – The descriptor to perturb.
coordinate (int | str) – See
DescriptorDensity.evolve().delta (float) – See
DescriptorDensity.evolve().condition – See
DescriptorDensity.evolve().
- Returns:
The perturbed
RepertoireDescriptor.- Return type:
Digital twin (mir.twin)#
One donor’s perturbable, simulatable state — glues RepertoireDescriptor + an optional
mir.track trajectory position + a mir.generate/mir.ml.diffusion generator into one object.
The digital twin: one donor’s compact, perturbable, simulatable state.
Where DonorCohort fuses a cohort’s measured state into one comparable matrix
for scoring, this module wraps one donor’s state as an object that can be perturbed
(mir.generate.evolve(), a “what if” move) or used to seed brand-new synthetic realizations from
a fitted generator — mir.generate.DescriptorDensity (linear-Gaussian) or
mir.ml.diffusion.DiffusionModel (non-linear, needs [ml]) — both of which share the same
sample(n, *, condition=None, seed=0) call shape, so either drops in unchanged. A twin optionally
carries this donor’s position on a fitted TrajectoryFit (where along an inferred
exposure/progression axis it sits) and its known covariate, so simulate() can
resample condition-matched to the donor’s own group by default.
This closes the loop the rest of the library already opened: mir.cohort measures a donor,
mir.track locates it on a trajectory, mir.generate / mir.ml.diffusion
generate new states — DonorTwin is the one object a caller perturbs or resamples through,
instead of threading three APIs together by hand for every donor.
Torch-free itself; the diffusion generator (only if actually passed to simulate())
is imported lazily by the caller, not by this module.
Typical usage:
from mir.twin import make_twins
from mir.generate import fit_descriptor_density
density = fit_descriptor_density(descriptors, labels=tumor_type)
twins = make_twins(descriptors, conditions=tumor_type, donor_ids=sample_ids)
hotter = twins[0].perturb(density, coordinate="infiltration", delta=2.0) # this donor, "what if hotter"
synthetic = twins[0].simulate(density, n=20) # 20 new synthetic peers
- class mir.twin.DonorTwin(descriptor, tau=None, condition=None, donor_id=None)[source]#
Bases:
objectOne donor’s digital twin: measured state + optional trajectory position + covariate.
- Variables:
descriptor (mir.repertoire.RepertoireDescriptor) – The donor’s measured
RepertoireDescriptor.tau (float | None) – This donor’s position on a fitted
TrajectoryFit, if known (typicallyfit.tau[donor_index]).condition (object | None) – The donor’s known covariate/group label (tumor type, HLA, batch, …) — the default conditioning value for
perturb()/simulate().donor_id (str | None) – Optional identifier, carried through unchanged by every method here.
- Parameters:
descriptor (RepertoireDescriptor)
tau (float | None)
condition (object | None)
donor_id (str | None)
- descriptor: RepertoireDescriptor#
- perturb(density, *, coordinate, delta, condition=None)[source]#
Move this twin along one descriptor coordinate (
mir.generate.evolve()).- Parameters:
density – A fitted
mir.generate.DescriptorDensity— the coupling between coordinates comes from its covariance.coordinate – Vector index, or
"infiltration"/"log_neff"/"clonality".delta (float) – Amount to shift
coordinateby (in the descriptor’s own units).condition – Which of
density’s fitted groups defines the coupling; defaults to this twin’s owncondition.
- Returns:
A new
DonorTwinwith the perturbed descriptor —tau,conditionanddonor_idcarry over unchanged (a perturbation is a “what if”, not a re-measurement of a different donor).- Return type:
- simulate(generator, n=1, *, condition=None, seed=0, **kwargs)[source]#
Draw
nnew synthetic donor states from a fitted generator, decoded to named metrics.- Parameters:
generator – A fitted
mir.generate.DescriptorDensityormir.ml.diffusion.DiffusionModel— both share thesample(n, *, condition, seed)call shape; either drops in unchanged.n (int) – Number of synthetic states to draw.
condition – Which fitted group to sample from; defaults to this twin’s own
condition.seed (int) – RNG seed.
**kwargs – Forwarded to
generator.sample(e.g. aDiffusionModel’ssteps=/guidance_scale=; unused byDescriptorDensity).
- Returns:
ndecoded metric dicts (mir.repertoire.decode_metrics()) — synthetic “what a donor like this typically looks like” states, not perturbations of this specific twin (seeperturb()for that).- Return type:
- mir.twin.make_twins(descriptors, *, tau=None, conditions=None, donor_ids=None)[source]#
Zip a cohort’s descriptors (+ optional trajectory/condition/id) into one
DonorTwineach.- Parameters:
descriptors (list[RepertoireDescriptor]) – One
RepertoireDescriptorper donor.tau (ndarray | None) – Optional
(n_donors,)trajectory position per donor (a fittedTrajectoryFit’s.tau), row-aligned todescriptors.conditions (list | None) – Optional per-donor covariate/group labels, row-aligned.
donor_ids (list[str] | None) – Optional per-donor identifiers, row-aligned.
- Returns:
One
DonorTwinper donor, in the same order asdescriptors.- Raises:
ValueError – If a supplied
tau/conditions/donor_idslength disagrees withdescriptors.- Return type:
Benchmark harness (mir.bench)#
mir.bench.vdjdb#
VDJdb loader + antigen subsets.
Load VDJdb dumps into the AIRR polars frame TCREmp expects.
VDJdb “slim” dumps use dotted column names (cdr3, v.segm, j.segm,
antigen.epitope, gene, complex.id, mhc.class). This maps them to
the vdjtools.io.schema names plus an epitope label column.
mir.bench.metrics#
Clustering (DBSCAN/HDBSCAN/OPTICS) + F1 / retention / purity.
Antigen-cluster metrics for the TCREMP benchmark (paper Table 1 / S1).
DBSCAN clustering with a kneedle-selected eps on embedded TCRs, then per-antigen
F1 (on clustered TCRs, via cluster→majority-label assignment) and retention
(fraction of an antigen’s TCRs that land in a cluster), following the decoupling used
in the paper: F1 measures clustering quality, retention measures coverage.
- mir.bench.metrics.estimate_dbscan_eps(X, k=4)[source]#
Kneedle
eps: the knee of the sorted k-th nearest-neighbour distance curve.
- mir.bench.metrics.cluster(X, eps=None, min_samples=3, k=4, eps_factor=0.4, method='dbscan', **kwargs)[source]#
Density-cluster an embedding; noise =
-1for every method.- Parameters:
method (str) –
"dbscan"(default),"hdbscan", or"optics". All three are density estimators that emit-1noise, socluster_metrics()andmir.density.denoise_and_cluster()work unchanged across them."dbscan"is the default for reproducibility of the published Table-S1 numbers.k (int) – DBSCAN only. When
eps is Noneit iskneedle_knee × eps_factor— the paper’seps := (distance at knee) × (dataset-specific factor)(Fig 1); the raw knee over-merges in the PCA embedding, so ~0.3–0.4 recovers tight antigen clusters.min_samples (int) – core-point / density threshold, shared by all three methods.
**kwargs – passed to the underlying estimator (e.g.
min_cluster_sizefor HDBSCAN,cluster_method/xifor OPTICS).X (ndarray)
eps (float | None)
k
eps_factor (float)
- Return type:
ndarray
HDBSCAN is a variable-density estimator — the persistent cluster tree (Hartigan level sets) rather than DBSCAN’s single global
epsslice — so it is the natural choice when local density spans orders of magnitude across antigen ridges (appendixsec:dens-depth).
- class mir.bench.metrics.AntigenMetric(epitope, n, f1, precision, recall, retention)[source]#
Bases:
objectPer-antigen clustering quality: F1 of the recovered cluster plus retention.
- Variables:
epitope (str) – The antigen epitope this metric scores.
n (int) – Number of clonotypes labelled with this epitope.
f1 (float) – F1 of the best-matching predicted cluster against the epitope’s clonotypes.
precision (float) – Precision of that best-matching cluster.
recall (float) – Recall of that best-matching cluster.
retention (float) – Fraction of the epitope’s clonotypes that were clustered (not noise).
- Parameters:
- mir.bench.metrics.cluster_metrics(labels, antigens)[source]#
Per-antigen F1 (on clustered TCRs) and retention.
- Parameters:
labels (ndarray) – DBSCAN cluster labels (
-1= noise/unclustered).antigens – True antigen (epitope) label per TCR, same length as labels.
- Returns:
epitope -> AntigenMetric.- Return type:
mir.bench.theory#
Reproduced supplementary theory (S1–S3, T5–T6, codec losslessness).
Theory-validation experiments (reproduce TCREMP supplementary S1–S3).
These numerically check the properties the embedding is claimed to have, using the
actual v3 pipeline distances (seqtree.gapblock junction dissimilarity):
S2 / Theory T1 — Euclidean distance
D_ijin embedding space tracks the pairwise junction dissimilarityd_ij(s2_dissimilarity_distance_correlation(); paper Pearson R ≈ 0.56). Using the sequences as their own prototypes, the embedding of sequence i is row i of the dissimilarity matrix andD_ij = ‖d_i − d_j‖₂.S1 / Theory T4 —
d_ijfollows a Gamma law (Gaussian fails);D_ijfollows a Fréchet (GEV, shape ξ>0) law (fit_distributions()).S3 — distances from real vs model prototypes agree (
prototype_source_correlation(); paper Pearson R ≈ 0.96), i.e. the prototype source barely matters.
- mir.bench.theory.junction_dissimilarity(cdr3s, threads=0)[source]#
Symmetric
(n, n)junction dissimilarityd_ij(v3 gapblock; a negative-type semimetric / squared Hilbert distance, not a metric — ρ=√d is).- Parameters:
threads (int)
- Return type:
ndarray
- mir.bench.theory.junction_dissimilarity_sw(cdr3s)[source]#
Paper-exact
d_ijvia Smith-Waterman BLOSUM62 (supplementary S1/S2).d_ij = s_ii + s_jj − 2·s_ijfrom a local BLOSUM62 alignment (linear gap). Requires BioPython (the[build]extra). O(n²) alignments — use for the theory validation on a few thousand sequences, not at scale.- Return type:
ndarray
- class mir.bench.theory.CorrelationResult(n: 'int', n_pairs: 'int', pearson: 'float', d: 'np.ndarray', D: 'np.ndarray')[source]#
Bases:
object- d: ndarray#
- D: ndarray#
- mir.bench.theory.s2_dissimilarity_distance_correlation(cdr3s, threads=0, dissimilarity='gapblock')[source]#
Correlate junction dissimilarity
d_ijwith embedding distanceD_ij.Uses the self-prototype construction from supplementary Fig. S2: the embedding of each sequence is its row of the dissimilarity matrix, and
D_ijis the Euclidean distance between rows. Returns the flat i>j vectors and Pearson R.- Parameters:
- Return type:
- mir.bench.theory.fit_distributions(d_flat, D_flat, sample=200000, seed=0)[source]#
Fit S1 distributions: Gamma vs Normal for
d, GEV/Fréchet vs Normal forD.Returns a nested dict of
{ks, aic, params}per fit plus the GEV shapexi(xi > 0⇒ Fréchet). Large inputs are subsampled to sample points.
- mir.bench.theory.shm_embedding_drift(cdr3s, prototypes, max_mut=8, n_rep=3, seed=0, threads=0)[source]#
Embedding drift vs mutation load (Theory T5, the IGH/SHM case).
Applies
krandom interior substitutions (a somatic-hypermutation proxy) to each CDR3 and measures the junction-embedding Euclidean distance to the original,D_k = ‖φ(mutated) − φ(original)‖. Returnsk -> (mean D_k, std). The claim: the drift is bounded by the mutation load —D_kgrows ~linearly ink— so heavily hypermutated IGH sequences sit controllably far from germline in embedding space.
- mir.bench.theory.prototype_source_correlation(query_cdr3s, real_prototypes, model_prototypes, threads=0)[source]#
Correlate embedding distances built from real vs model prototypes (S3).
Embeds
query_cdr3sagainst each prototype set (junction only), takes pairwise Euclidean distances under each embedding, and returns their Pearson R (paper ≈ 0.96).
- mir.bench.theory.tcrnet_convergence(obs_cdr3s, bg_cdr3s, prototypes, *, n_components=25, scales=(0.5, 1.0, 1.5, 2.0, 3.0), seed=0, threads=0)[source]#
T6: continuous embedding enrichment converges to discrete Hamming-1 enrichment.
Embeds observed and background CDR3s against the prototype set (junction only), reduces with one shared
StandardScaler → PCA, and at radiusscale × r₁(r₁= the median one-substitution embedding drift) counts each observed clonotype’s neighbours in embedding space. Returns the Spearman correlation between those continuous counts and the discrete Hamming-1 neighbour counts. The correlation is high at small radii and fades as the radius grows past one substitution — numerically confirming that graph neighbour-enrichment (TCRNET/ALICE) is ther→0limit of the density ratioE(z).- Parameters:
obs_cdr3s – Observed junction sequences.
bg_cdr3s – Background junction sequences (P_gen sample / control).
prototypes – Prototype junctions defining the embedding.
n_components (int) – PCA dimensionality of the shared coordinate system.
scales – Radius multiples of the one-substitution scale
r₁to evaluate.seed (int) – RNG seed for the calibration mutations and PCA solver.
threads (int)
- Returns:
{radius_1sub, hamming1_mean, spearman_by_scale, spearman_at_1sub}.- Return type:
- mir.bench.theory.codec_losslessness(codes, seqs, recon=None, *, eps=1e-06)[source]#
Measurable losslessness of a codec, split into decoder-independent vs -dependent parts.
Informational ceiling (decoder-independent, from
codesalone). The compact code is injective iff distinct sequences map to distinct codes; two distinct sequences whose codes coincide can never both be recovered, soexact_ceiling = 1 − collision_rateupper-bounds any decoder’s exact-match — the information the code retains, separate from how well a decoder reads it. Encoding is deterministic (identical sequences ⇒ identical codes), so duplicate sequences are collapsed first and a collision is a near-coincident code pair between distinct sequences (nearest-code distance <eps).nn_dist_{median,min}report the code-resolution scale soepscan be read in context.Reconstructive loss (needs
recon, the decoded strings row-aligned toseqs):exact_match, mean edit distance (a graded distortion, unlike binary exact-match), and per-position token accuracy over the length-40 frame — localizing where loss falls (conserved C…[FW] anchors vs the specificity-bearing variable middle). Per-position accuracy re-encodesreconinto the canonical frame, so it measures positional token agreement.- Parameters:
codes –
(n, m)compact codes, one per sequence (e.g. whitened-PCA junction code).seqs – the
ntrue junction strings.recon – optional
ndecoded strings (e.g.InverseDecoder.decode(codes)). WhenNone, only the decoder-independent ceiling is returned.eps (float) – code-distance below which two distinct sequences count as colliding.
- Returns:
Always
{n, n_unique, collision_rate, exact_ceiling, nn_dist_median, nn_dist_min}; and whenreconis given, additionally{exact_match, mean_edit, token_acc, anchor_acc, middle_acc, pos_acc}(pos_acca length-40 array).- Return type:
mir.bench.eval#
Scorers for the explainable readout: cross-validated AUC, Cox C-index, log-rank — plus
recovery_report, the grouped-CV ridge that asks whether each basic repertoire statistic is
carried inside the embedding.
Scorers for the explainable cohort readout (mir.explain) — the closures you hand to
mir.explain.channel_report().
mir.explain is deliberately scorer-agnostic: it slices channels and never sees y, so the
model choice lives here, in the caller’s closure. This module collects the recurring choices —
cross-validated classification AUC and Cox survival C-index — as small, reusable functions so every
cohort benchmark stops re-gluing them. A scorer takes a feature block and returns a float where
higher is better:
from mir.bench.eval import cv_auc, cv_cindex
from mir.explain import stack_embeddings, channel_report
X, spec = stack_embeddings(embs)
rep = channel_report(X, spec, lambda B: cv_auc(B, y)[0], base=0.5) # classification
rep = channel_report(X, spec, lambda B: cv_cindex(dur, evt, base=C, block=B, n_pc=8),
base=cv_cindex(dur, evt, base=C, block=None), mode="both") # survival
Needs the [bench] extra (scikit-learn always; lifelines for the survival scorers; vdjtools for
the k-mer baseline). Everything is lazily imported so importing this module stays cheap.
- mir.bench.eval.held_out_auc(Xtr, ytr, Xte, yte, *, pca_cols=0)[source]#
Held-out ROC-AUC of a logistic head; the first
pca_colscolumns get in-fold PCA(0.9 var).Wide blocks (kernel mean, second moment, k-mer) overfit unless reduced inside the fold; the few diversity/coverage features pass through raw so their signal isn’t buried.
pca_cols=0= no PCA.
- mir.bench.eval.cv_auc(X, y, *, pca_cols=0, n_splits=5, n_repeats=10, seed=0)[source]#
Repeated stratified k-fold AUC as
(mean, std)— a CI, not a single-split point estimate.A single 70/30 split at small
nhas AUC SD ≈ 0.5/√n_test (~0.1 for n_test≈30), so point estimates are near-meaningless; repeated CV exposes whether two methods’ intervals separate. Passcv_auc(B, y)[0]as thechannel_reportscorer.
- mir.bench.eval.cv_cindex(durations, events, *, base=None, block=None, n_pc=0, n_splits=5, seed=0, penalizer=0.1)[source]#
5-fold CV Cox C-index of
basecovariates + an optional featureblock.The survival analog of
cv_auc(), and the scorer for survival channel reports.baseis the clinical design (e.g. age+sex+stage+log-reads — whatever the study built; this function is schema-agnostic) andblockis the feature block being scored; whenblockis wider thann_pcit is PCA-reduced ton_pccomponents inside each fold (train-only fit) so a wide kernel-mean block doesn’t overfit. Returns the mean concordance across folds (nan-robust).Use as
lambda B: cv_cindex(dur, evt, base=C, block=B, n_pc=8)with base scorecv_cindex(dur, evt, base=C, block=None).
- mir.bench.eval.km_logrank(durations, events, groups)[source]#
Multivariate log-rank p-value across
groups— do the KM survival curves differ?The test behind a Kaplan–Meier stratification (e.g. TME states from
cluster_samples). Returns the p-value.- Return type:
- mir.bench.eval.recovery_report(X, stats, groups=None, *, n_pc=20, n_splits=5, alpha=1.0, seed=0)[source]#
Grouped-CV ridge
R²from an embedding’s PCs back to each basic repertoire statistic.The honest scoring rule for a repertoire embedding is not “does
Φbeat the one-number marker” — that is a competition between an object and one of its own coordinates. It is recoverability: is the statistic carried inside the embedding, so nothing has to be bolted on beside it? A highR²says yes; a low one says the embedding threw that information away and a downstream model will need the statistic as a separate feature.This is where a sub-probability embedding earns its keep by construction. Renormalising every sample to mass 1 (
mir.repertoire.sample_embedding()’s default) deletes the magnitude, so coverage- and richness-like statistics are unrecoverable fromΦno matter how good the model — whereas the deficient measure (mir.repertoire.contrast_embedding()) keeps them. Sits besidemir.cohort.missingness_report()as the other “is this object honest” check.Report it together with the increment: the endpoint score for the embedding, for the statistic alone, and for both together (
cv_auc()/cv_cindex()).- Parameters:
X (ndarray) –
(n_samples, d)embedding matrix.stats (dict) –
{name: (n_samples,) values}— the basic statistics to recover (clonotype richness, Shannon, top-clone fraction, singleton fraction, Chao unseen fraction, library size, AIRR-per-million …). Non-finite entries are dropped per statistic.groups – Optional
(n_samples,)grouping (subject / batch / cohort) — folds are then grouped so a subject never appears in both train and test.None→ plain shuffled k-fold.n_pc (int) – PCA components fit inside each training fold;
0uses the raw columns.n_splits (int) – Number of folds.
alpha (float) – Ridge penalty.
seed (int) – Fold RNG seed.
- Returns:
{name: R²}from out-of-fold predictions (can be negative — worse than the mean).- Raises:
ValueError – If a statistic’s length disagrees with
X’s row count.- Return type:
Example
>>> recovery_report(X, {"richness": rich, "top_clone": top}, groups=subject) {'richness': 0.81, 'top_clone': 0.44}
- mir.bench.eval.kmer_matrix(frames, k=3)[source]#
Sample × k-mer frequency matrix (vdjtools
kmer_profilelong-form → wide) — the classic baseline.- Parameters:
frames – One clonotype
polars.DataFrameper sample (withjunction_aa+ counts).k (int) – k-mer length.
- Returns:
(len(frames), n_kmers)frequency matrix over the pooled vocabulary.- Return type:
ndarray
Neural codecs and learned encoders (mir.ml)#
mir.ml.tokenize#
CDR3 tokenisation for the neural codecs.
Fixed-length-40 one-hot tokenization of CDR3/junction sequences.
Mirrors the irrm-codec representation: every sequence is placed into a length-40 frame by anchoring the conserved ends and inserting a contiguous gap block in the variable middle — the N-terminal 4 residues left-align at the start, the C-terminal 3 residues right-align at the end, the middle left-aligns after position 4, and gaps fill the remainder (mirrors the TCREMP gap placement). Long IGH middles that overflow are truncated (the hard case). The alphabet is the 20 amino acids plus a gap symbol (21 tokens); unknown characters map to gap.
mir.ml.encoder#
Forward encoder: sequence → compact embedding code.
Forward codec: a CNN that maps a CDR3 sequence to its TCREMP embedding.
The GPU-friendly approximation of the prototype mapping (irrm-codec forward model).
Trained with free supervision — targets are TCREMP embeddings computed by
mir.embedding.tcremp.TCREmp, so labelled data is never the bottleneck.
mir.ml.decoder#
Inverse decoder: code → sequence.
Inverse codec: reconstruct a CDR3 sequence from its (compact) embedding.
A parallel decoder (irrm-codec style): the compact PCA code is expanded and refined by 1-D convolutions into length-40 per-position token logits in one shot (no autoregression). Trained with cross-entropy against the fixed-length-40 tokenization; the reconstructed string drops gap tokens.
mir.ml.train#
Training loops + device selection (CUDA → MPS → CPU).
Train the forward codec (CDR3 → TCREMP embedding) with free supervision.
Targets are TCREMP embeddings computed by mir.embedding.tcremp.TCREmp, so
training data is unlimited — sample sequences from vdjtools.model.generate (or a
real repertoire) and embed them. Reports test-set mean cosine similarity (irrm-codec’s
forward metric).
- mir.ml.train.pick_device(device=None)[source]#
Best available accelerator: explicit
deviceoverride, else CUDA, else Applemps, elsecpu.Set
MIR_DEVICE(e.g.cuda:1) to override without threadingdevice=through every call.- Parameters:
device (str | None)
- Return type:
torch.device
- class mir.ml.train.ForwardEncoder(model, transform, device, is_pca)[source]#
Bases:
objectA trained forward codec: CDR3 strings → embedding vectors.
transformmaps the model output back to the original embedding space — either an inverseStandardScaler(raw target) or an inverse whitenedPCA(compact 95%-variance target).codereturns the compact code.- Parameters:
model (nn.Module)
device (torch.device)
is_pca (bool)
- mir.ml.train.train_forward_encoder(cdr3s, targets, *, target_pca=0.95, epochs=40, batch=256, lr=0.001, val_frac=0.1, test_frac=0.1, seed=0, device=None, verbose=True)[source]#
Train a
SequenceEncoderto predict targets from cdr3s.- Parameters:
target_pca (float | None) – If a float in (0, 1], compact the (redundant) target embedding with a whitened PCA keeping that fraction of variance — fit on the train split only.
Nonetrains on the raw standardized target.targets (ndarray)
epochs (int)
batch (int)
lr (float)
val_frac (float)
test_frac (float)
seed (int)
device (str | None)
verbose (bool)
- Return type:
Returns the fitted
ForwardEncoderand a metrics dict (test_cosine— reconstruction cosine in the original embedding space —val_mse,n,n_components). No leakage: PCA / scaler are fit on train only.
- class mir.ml.train.InverseDecoder(model, code_scaler, device)[source]#
Bases:
objectA trained inverse codec: embedding codes → CDR3 strings.
- Parameters:
model (nn.Module)
code_scaler (StandardScaler)
device (torch.device)
- mir.ml.train.train_inverse_decoder(codes, cdr3s, *, epochs=50, batch=256, lr=0.001, val_frac=0.1, test_frac=0.1, seed=0, device=None, verbose=True)[source]#
Train a
SequenceDecoderto reconstruct cdr3s from codes.Returns the fitted
InverseDecoderand metrics (exact_match— full sequence reconstructed correctly —token_acc,n). Codes are standardized on the train split only.
- class mir.ml.train.PgenRegressor(model, scaler, device)[source]#
Bases:
objectA trained regressor: CDR3 strings → log10 Pgen.
- Parameters:
model (nn.Module)
scaler (StandardScaler)
device (torch.device)
- mir.ml.train.train_pgen_regressor(cdr3s, log_pgen, *, epochs=40, batch=256, lr=0.001, val_frac=0.1, test_frac=0.1, seed=0, device=None, verbose=True)[source]#
Train the shared sequence encoder to predict
log10 Pgenfrom CDR3.Returns the fitted
PgenRegressorand metrics (pearson,rmsein log10 units,n). Targets standardized on the train split only.
mir.ml.codec#
Unified encoder+decoder codec with a geometry-anchor term.
Unified codec: jointly train encoder + decoder, anchored to the embedding geometry.
The encoder (seq → code) and decoder (code → seq) are trained together, but a
geometry-preservation term keeps the encoder’s code close to the true (compact PCA)
embedding — so the codec round-trips sequences without the code drifting away from
the embedding space that makes distances meaningful (Theory T1). lambda_embed
controls the anchor strength: large → geometry preserved, decoder adapts to it.
- class mir.ml.codec.UnifiedCodec(encoder, decoder, device)[source]#
Bases:
objectTrained encoder+decoder:
encode(seq→code),decode(code→seq),roundtrip.- Parameters:
encoder (nn.Module)
decoder (nn.Module)
device (torch.device)
- mir.ml.codec.train_unified_codec(cdr3s, codes, *, lambda_embed=1.0, epochs=60, batch=256, lr=0.001, val_frac=0.1, test_frac=0.1, seed=0, device=None, verbose=True)[source]#
Jointly train encoder+decoder to reconstruct cdr3s while matching codes.
- Parameters:
codes (ndarray) – The true compact embedding codes (whitened PCA), one per sequence — the geometry anchor and the encoder target.
lambda_embed (float) – Weight of the encoder geometry term vs the reconstruction term.
epochs (int)
batch (int)
lr (float)
val_frac (float)
test_frac (float)
seed (int)
device (str | None)
verbose (bool)
- Return type:
Returns the
UnifiedCodecand metrics:encode_cosine(encoder code vs true code — geometry preservation),roundtrip_exact(seq→code→seq), anddecode_true_exact(decode the true code — reference upper bound).
mir.ml.bundle#
CodecBundle — prototype-hash-verified shipping of a trained codec.
Ship a trained codec together with the things that define its embedding space.
Two embeddings are only comparable if they share the same prototype set and the same
PCA rotation — different prototypes or a different PCA give an incomparable coordinate
system. A CodecBundle therefore serializes, as one artifact:
the PCA transform (the rotation/whitening — the exact coordinate system),
a prototype hash (identity of the prototype set the embedding was built on),
the model weights and the metadata needed to reproduce/verify it.
Reconstructing a usable encoder (CodecBundle.forward_encoder()) verifies the prototype
hash against the currently-bundled prototypes, so a codec can never be silently used to produce
embeddings in a different, incomparable space. CodecBundle.load() only deserializes — call
matches_current_prototypes() if you touch transform / model directly.
- mir.ml.bundle.prototype_hash(species, locus, n, replicate=0)[source]#
Stable 16-hex-char hash of the ordered prototype junction set (its identity).
replicateis part of the identity: two draws of the same size are different coordinate systems, so they must hash differently or the comparability guards would let them mix.
- class mir.ml.bundle.CodecBundle(meta, transform, model)[source]#
Bases:
objectA trained codec plus its embedding-space identity (PCA + prototype hash).
- classmethod from_forward(encoder, species, locus, n_prototypes, gap_positions=(3, 4, -4, -3), kind='forward', replicate=0)[source]#
mir.ml.set_encoder#
Learned repertoire track (Set-Transformer / DeepRC attention pooling).
Learned permutation-invariant repertoire encoder — the co-equal learned track (Theory §T.7/§T.8).
The fixed backbone (mir.repertoire) pools clonotypes by a size-weighted sum of fixed RFF
features. This module replaces that with a trained set-pooling network (Deep Sets form,
Prop. prop:sampinv): a per-clonotype MLP ψ_θ followed by pooling-by-multihead-attention
(PMA, Lee et al. 2019 Set Transformer) whose learned seed vectors act as inducing points — i.e.
trained metaclonotype / public-cluster detectors (the interaction signal of Prop. prop:interact,
the alternative to the fixed second-moment Σ_S). It reads the same PCA-coordinate clonotype
cloud as the fixed backbone (so the two are comparable) and is fit end-to-end to a label (age / CMV).
Depth-robustness is engineered in, not free (§T.7.5): clonotypes enter the attention weighted by
their frequency g(a_σ) (a log-weight bias on the attention logits), and each epoch every sample
is randomly subsampled so the network learns to read shallow repertoires — the RNA-seq regime.
Torch ([ml] extra). Shipped as a SetEncoderBundle (weights + PCA transform + prototype
hash), refusing a prototype mismatch on load, exactly like mir.ml.bundle.CodecBundle.
- class mir.ml.set_encoder.SetEncoder(*args, **kwargs)[source]#
Bases:
ModulePer-clonotype MLP + frequency-weighted PMA pooling → fixed vector / prediction.
forward(Z, w)maps one sample’s clonotype cloudZ(n×pPCA coords) and weightsw(n, summing to 1) to anout_dimoutput, permutation-invariantly. Then_seedslearned queries are the inducing points (metaclonotype detectors); frequency enters as an additivelog wbias on the attention logits so abundant clones weigh more.
- class mir.ml.set_encoder.SetEncoderModel(model, device, task)[source]#
Bases:
objectA trained
SetEncoderplus its device — turns a sample cloud into a prediction.- Parameters:
model (torch.nn.Module)
device (torch.device)
task (str)
- model: torch.nn.Module#
- device: torch.device#
- predict(clouds)#
Predict for a list of
(Z, w)clonotype clouds (probabilities if classification).- Return type:
ndarray
- mir.ml.set_encoder.train_set_encoder(clouds, y, *, task='regression', epochs=60, lr=0.001, n_seeds=16, d=128, val_frac=0.2, augment=True, seed=0, device=None, verbose=True)[source]#
Train the set encoder on
(clouds, y)(clouds = list of(Z, w)per sample).- Parameters:
clouds – per-sample
(Z: (n_i, p) float, w: (n_i,) float)fromRepertoireSpace.sample_cloud.y (ndarray) – per-sample target (float for regression, {0,1} for classification).
task (str) –
"regression"(MSE) or"classification"(BCE-with-logits).augment (bool) – subsample each sample every epoch to engineer depth-robustness (§T.7.5).
epochs (int)
lr (float)
n_seeds (int)
d (int)
val_frac (float)
seed (int)
device (str | None)
verbose (bool)
- Returns:
(SetEncoderModel, metrics)— metrics carry the held-out score (Spearman / AUC).- Return type:
mir.ml.diffusion#
Conditional diffusion generator (DDPM/DDIM + classifier-free guidance) over a compact descriptor/code
space — the research half of the generative loop, complementing mir.generate’s linear Gaussian.
Diffusion generator over a compact embedding/descriptor space (Part 2, needs [ml]).
The research half of the generative loop (ROADMAP Phase 2/3), complementing
mir.generate’s linear-Gaussian DescriptorDensity: a small conditional denoising diffusion
model (DDPM, Ho et al. 2020) trained directly on a compact coordinate system — a
RepertoireDescriptor vector, a codec’s PCA-compacted code
(mir.ml.bundle), or any other (N, dim) matrix — that can express a genuinely non-linear
generative manifold where the Gaussian baseline can only fit an ellipsoid. Sampling uses DDIM
(Song et al. 2021) rather than the full ancestral chain: deterministic given a seed and needs far
fewer steps (tens, not the training horizon T), the standard modern choice over vanilla DDPM
sampling. Classifier-free guidance (Ho & Salimans 2022) — random label dropout during training,
so one model samples both unconditionally and conditionally, with guidance_scale trading
diversity for how strongly a sample matches its condition — lets one model serve as the digital-twin
generator mir.generate’s sample/evolve API mirrors: draw a synthetic donor state
unconditionally, or conditioned on a covariate (tumor type, HLA, batch), with the same call shape.
The coordinate space here is already compact (tens to a few hundred dimensions after PCA/descriptor
reduction), so the epsilon-predictor is a small MLP (DiffusionMLP) — matching mir.ml’s
existing compact-network style (SequenceDecoder), not a U-Net or
transformer built for pixel/token grids that would be over-parameterized for this data.
DiffusionModel ships the same way CodecBundle does — meta +
weights via save()/load() — so a trained generator
carries its own provenance; pass a prototype_hash (mir.ml.bundle.prototype_hash()) in
meta when the coordinate space came from a specific prototype set, and check it with
matches_prototype_hash() before sampling.
Typical usage:
from mir.ml.diffusion import train_diffusion
model, metrics = train_diffusion(X, tumor_type, epochs=300) # X: (n, dim) descriptor/code matrix
synth = model.sample(50, condition="SKCM", steps=50, guidance_scale=2.0) # 50 synthetic SKCM-like
model.save("skcm_diffusion.pt")
- mir.ml.diffusion.cosine_beta_schedule(T, s=0.008)[source]#
Cosine noise schedule (Nichol & Dhariwal 2021) — smoother SNR decay than linear DDPM betas, the modern default over the original linear schedule.
- class mir.ml.diffusion.DiffusionMLP(*args, **kwargs)[source]#
Bases:
ModuleEpsilon-predictor: noised vector + timestep (+ optional class) -> predicted noise.
Class index
0is reserved as the “null” conditioning token (classifier-free guidance): real classes are indices1..n_classes, soy=None(or the null token during training-time label dropout) samples the unconditional model.- Parameters:
- class mir.ml.diffusion.DiffusionModel(model, betas, meta=<factory>)[source]#
Bases:
objectA trained diffusion generator: noised-vector-space -> new samples via DDIM.
- Variables:
model (torch.nn.Module) – The trained
DiffusionMLP(eval mode).betas (torch.Tensor) –
(T,)noise schedule the model was trained with.meta (dict) – Provenance dict — always carries
dim,n_classes,T, and the per-dimensionmu/sdthe training data was standardized with (sample()un-standardizes with these before returning); carriesclasses(the original label values, indexi-> class tokeni+1) when class-conditional; carries aprototype_hashiff the caller supplied one at train time.
- Parameters:
model (torch.nn.Module)
betas (torch.Tensor)
meta (dict)
- model: torch.nn.Module#
- betas: torch.Tensor#
- matches_prototype_hash(current)[source]#
Trueiffmetahas noprototype_hashrecorded, or it equalscurrent.
- save(path)[source]#
Serialize model + schedule + meta (mirrors
mir.ml.bundle.CodecBundle.save()).
- classmethod load(path, device=None)[source]#
Rebuild a saved
DiffusionModel(weights on CPU unlessdevicegiven).- Parameters:
- Return type:
- sample(n=1, *, condition=None, steps=50, guidance_scale=1.0, seed=0, device=None)#
Draw
nsynthetic vectors via deterministic DDIM (Song et al. 2021,eta=0).Same call shape as
mir.generate.DescriptorDensity.sample()(n,condition,seed) so the two generators are drop-in alternatives.- Parameters:
n (int) – Number of samples to draw.
condition – Class label to condition on (must be one of the labels
train_diffusionsaw);Nonesamples unconditionally.steps (int) – DDIM steps — far fewer than the training horizon
T(state-of-art vs. the full ancestral chain); clipped toTif larger.guidance_scale (float) – Classifier-free guidance weight.
1.0= plain conditional/ unconditional prediction;>1.0sharpens toward the condition (requiresconditionset and the model to be class-conditional).seed (int) – RNG seed.
device (str | None) – Override device (default: wherever the model currently lives).
- Returns:
(n, dim)array.- Raises:
ValueError – If
conditionis unknown, guidance is requested withoutcondition, or the model isn’t class-conditional butconditionwas given.- Return type:
ndarray
- mir.ml.diffusion.train_diffusion(X, condition=None, *, T=200, epochs=200, batch=64, lr=0.001, hidden=256, time_dim=64, class_dim=32, class_drop_prob=0.1, val_frac=0.1, seed=0, device=None, verbose=True, meta=None)[source]#
Train a conditional (or unconditional) diffusion model over
X.- Parameters:
X (ndarray) –
(n_samples, dim)data — a descriptor/code matrix.condition – Optional length-
n_samplesclass labels (any hashable value);Nonetrains unconditionally.T (int) – Diffusion horizon (training timesteps). Sampling can use far fewer via DDIM.
epochs (int) – Standard training hyperparameters.
batch (int) – Standard training hyperparameters.
lr (float) – Standard training hyperparameters.
hidden (int) –
DiffusionMLParchitecture knobs.time_dim (int) –
DiffusionMLParchitecture knobs.class_dim (int) –
DiffusionMLParchitecture knobs.class_drop_prob (float) – Per-example probability of replacing the true label with the null token during training (classifier-free guidance) — ignored if
condition is None.val_frac (float) – Held-out fraction for the reported validation loss (best-epoch checkpointing).
seed (int) – RNG seed (data split, weight init, training stochasticity).
device (str | None) – Override device (default:
mir.ml.train.pick_device()’s auto-pick).verbose (bool) – Print progress.
meta (dict | None) – Extra provenance to carry on the returned model (e.g.
{"prototype_hash": ...}).
- Returns:
(model, metrics)— the fittedDiffusionModel(best-validation-loss weights) and a metrics dict (val_loss,n,n_classes).- Raises:
ValueError – If
Xhas fewer than 4 rows (too few to hold out a validation split).- Return type:
Utilities#
mir.aliases#
Species / locus aliases.
Species and locus alias normalization for user-facing entry points.
Users, notebooks, and other tools spell species and loci many ways (beta,
T_beta, hsa, Homo sapiens). Prototype resource files and germline
lookups are keyed by the canonical IMGT locus (TRB) and canonical species
(human / mouse), so normalize once at the boundary.
mir.alleles#
Allele normalisation with default-allele cascade.
Scalar V/D/J/C allele-notation helpers used by the germline distance lookup.
For frame-level allele operations use vdjtools.io.schema.strip_allele (a polars
expression). These string helpers drive the per-clonotype resolution cascade
(exact allele → *01 → bare gene → fallback) when gathering baked germline
distances.
- mir.alleles.strip_allele(gene_name)[source]#
Return the gene base without allele suffix (
TRBV6-5*02→TRBV6-5).
Command-line interface (mir)#
pip install mirpy-lib installs a mir console script (also python -m mir.cli) with two
commands, one per embedding scale:
Both commands drop non-coding clonotypes (stop codon / legacy out-of-frame junction_aa) before
embedding by default via vdjtools.preprocess.filter_functional — pass --no-filter-functional
to disable.
mir embed clonotypes INPUTOne repertoire’s clonotype table → a per-clonotype TCREMP embedding table (columns
e0…). Flags:--species,--locus(inferred when the file has one locus),--n-prototypes,--mode {vjcdr3,cdr123},--replicate R(prototype draw),--pca K(compact the table),--filter-functional/--no-filter-functional,--threads,-o(.tsv/.parquet; default stdout TSV).mir embed repertoires INPUT...A dataset of clonotype tables → one repertoire vector
Φ(S)per sample per chain on one shared basis (columnsphi0…; sample id = filename stem). Flags:--species,--locus(restrict),--n-prototypes,--replicate R,--weight {log2p1,duplicate_count,distinct,log1p,anscombe}(defaultlog2p1),--blocks mean,diversity[,second],--n-rff,--n-rff-second,--n-components,--mmd OUT(also write the per-chain pairwise unbiased-MMD matrix),--filter-functional/--no-filter-functional,--threads,--seed,-o.
mir command-line interface — turn receptor tables into embeddings.
Two commands cover the two scales mirpy embeds at:
mir embed clonotypes SAMPLE— one repertoire’s clonotype table → a per-clonotype TCREMP embedding table (e0…), the input to clustering / ML.mir embed repertoires SAMPLE…— a dataset of clonotype tables → one repertoire vectorΦ(S)(phi0…) per sample, per chain, on one shared basis (so the rows are mutually comparable / MMD-able).
Inputs are any format vdjtools.io.read sniffs (AIRR TSV, vdjtools, MiXCR, immunoSEQ,
parquet, …). Output is TSV (default / .tsv) or Parquet (.parquet — recommended for
the wide raw embedding); -o - (or no -o) writes TSV to stdout.
Run mir embed clonotypes -h / mir embed repertoires -h for the full flag list.