API reference#
Every public subpackage is documented below: the native model engine (vdjtools.model),
IO/schema (vdjtools.io), statistics (vdjtools.stats), CDR3 features
(vdjtools.features), overlap/TCRnet (vdjtools.overlap), preprocessing
(vdjtools.preprocess), biomarker association (vdjtools.biomarker), repertoire dynamics (vdjtools.dynamics), single-cell interop
(vdjtools.sc), and the command-line interface (vdjtools.cli).
vdjtools#
vdjtools — TCR/BCR immune-repertoire analysis (v2; Python + C++).
A clean-room rewrite of the legacy Groovy/Java vdjtools, standardised on the AIRR
schema and polars DataFrames with minimal object-orientation, built on the
antigenomics ecosystem (seqtree, vdjmatch, arda).
Native hot loops — the V(D)J Pgen dynamic program, the generation sampler, and the
EM E-step — live in the compiled vdjtools._core extension, imported lazily by
vdjtools.model. Everything else is pure polars/numpy, and generic sequence
primitives (Hamming/edit distance, fuzzy search) are used straight from the
seqtree / vdjmatch / arda dependencies rather than duplicated here.
Subpackages (io, model, stats, features, overlap, preprocess,
biomarker, sc) are imported explicitly by the caller, so import vdjtools
never pays the cost of the compiled extension or heavy optional dependencies until a
feature that needs them is used.
Model engine (vdjtools.model)#
The native V(D)J recombination engine: a model is a directory of tidy polars marginal
tables plus a manifest.json declaring the recombination Bayes net. It supersedes OLGA
(generation probability, sampling) and IGoR (EM inference), adds tandem-D (D-D) support, and
exposes information-theoretic diagnostics.
Model container, schema and events#
The Model container — a manifest plus its polars marginal and germline tables.
- class vdjtools.model.model.Model(manifest, tables, genomic, training=None)[source]#
Bases:
objectA V(D)J recombination model: declared graph + tidy polars tables.
- Parameters:
manifest (Manifest) – Locus metadata and the recombination Bayes net.
tables (dict[str, DataFrame]) – Event name -> its long-format marginal
pl.DataFrame.genomic (dict[str, DataFrame]) –
"genes_v"/"genes_j"/ (VDJ)"genes_d"-> germline reference frame.training (dict | None) – Optional EM training log,
{"runs": [...]}— one entry per inference run, so a warm-start refit appends rather than overwrites. Set byinfer()/infer_native(), persisted alongside the model astraining.json, and read back as a table bytraining_frame().Nonefor a model that was never fitted here (every bundled model, and anything imported from OLGA).
- validate(*, tol=1e-05)[source]#
Assert every event table has the right columns and normalizes; returns
self.
Polars schema for a V(D)J recombination model + its manifest.
A model is a directory: one tidy (long-format) parquet per event marginal, parquet tables
for the germline references, and a manifest.json that declares the Bayes-net graph
(events) plus locus metadata. This is the clean tabular replacement
for IGoR’s model_parms.txt / model_marginals.txt grammar: every probability row is
self-describing and every M-step normalization is one group_by(key).over(...).
Conventions (match OLGA so a loaded model is bit-faithful — see the loader):
Nucleotides are integer-coded
A,C,G,T = 0,1,2,3everywhere.ndelis the biological deletion count: negative values are palindromic (P-) nt,0is a flush cut, positive values trim germline. (OLGA storesndel + max_palindromeas an array index; we store the biological value.)A dinucleotide table row
(from_nt, to_nt, p)isp = P(next = to_nt | prev = from_nt), so it normalizes withinfrom_nt(OLGA’s column-stochasticR[next, prev]).
- vdjtools.model.schema.table_columns(event)[source]#
Full column schema (name -> dtype) for an event’s marginal table.
Layout:
givenallele columns, then the event’s own realization columns, thenp.
- vdjtools.model.schema.normalization_keys(event)[source]#
Columns the table’s
pmust sum to 1 within (the M-step / validation group key).Parents (
given) always; plusfrom_ntfor dinucleotide tables (column-stochastic).
- class vdjtools.model.schema.Manifest(locus, organism, chain_type, events, palindrome_max=<factory>, model_version='2.0.0', source='', builder_version='', error_rate=None)[source]#
Bases:
objectModel metadata + the declared recombination Bayes net.
- Parameters:
locus (str) – e.g.
"TRB".organism (str) – e.g.
"human".chain_type (str) –
"VDJ"(has D) or"VJ"(no D).events (dict[str, Event]) – The recombination graph, name ->
Event.palindrome_max (dict[str, int]) – Max palindromic nt per trimmable end (e.g.
{"v_3": 4, "j_5": 4}).model_version (str) – Schema/model version tag.
source (str) – Free-text provenance (e.g.
"olga:human_T_beta").builder_version (str) – The
vdjtoolsversion whose builder produced this model (set bybuild_model();""means “written before 3.9.2, unknown”). Germline bugs live in the builder, not the schema, somodel_versioncannot answer was this built before or after the fix — the 3.9.1 J-anchor defect had to be diagnosed by comparing a shipped model’s P(J) against an unaffected reference fit. This makes that a field lookup instead.error_rate (float | None) – Optional per-nt error rate (unused by Pgen; carried for round-trip).
- vdjtools.model.schema.validate_tables(manifest, tables, *, tol=1e-05)[source]#
Check every event table has the right columns and normalizes within its key.
- Parameters:
- Raises:
ValueError – On a missing table, wrong columns, or a group whose
p≠ 1.- Return type:
None
V(D)J recombination events — the Bayes-net structure, declared as data.
A generative model’s factorization is a set of named events, each conditioned on zero or
more parent events (its given list). This mirrors IGoR’s @Edges and OLGA’s parameter
factorization, but is stored declaratively in the model manifest rather than baked into a
code path — so a locus can change its conditioning (say P(D | J) vs P(D | V, J), or
add a tandem-D slot) without touching the engine.
This module is just the vocabulary and validation. The concrete graph for a given model
lives in its Manifest (populated by the loader or by EM).
- class vdjtools.model.events.EventKind(*values)[source]#
-
The kind of a recombination event — fixes the realization columns of its table.
GENE_CHOICE— pick a germline allele (V, D, or J). realization:<seg>_alleleN_D— number of D segments in the junction (0/1/2). realization:n_dDELETION— 3’/5’ exonuclease trimming of one segment end. realization:ndel(may be <0: P-nt)DELETION_2D— joint 5’+3’ trimming of a D segment. realization:ndel5, ndel3INS_LENGTH— number of non-templated (N-region) insertions. realization:lengthDINUCLEOTIDE— Markov transition of the N-region nt sequence. realization:from_nt, to_nt
- GENE_CHOICE = 'gene_choice'#
- N_D = 'n_d'#
- DELETION = 'deletion'#
- DELETION_2D = 'deletion_2d'#
- INS_LENGTH = 'ins_length'#
- DINUCLEOTIDE = 'dinucleotide'#
- class vdjtools.model.events.Event(name, kind, given=())[source]#
Bases:
objectOne node of the recombination Bayes net.
- Parameters:
- vdjtools.model.events.validate_graph(events)[source]#
Check that an event graph is well-formed: parents exist and there are no cycles.
- Parameters:
- Raises:
ValueError – If a
givennames a missing event, or the graph has a cycle.- Return type:
None
Import, germline reference and stitching#
Model I/O: import from OLGA’s format, and save/load our native parquet directory.
from_olga uses OLGA’s own parser (an optional, import-time dependency — the [oracle]
extra) to read a bootstrap model, then tabularises its processed arrays into our long-format
polars schema. Native load_model reads the parquet directory and needs no OLGA.
Conventions reproduced exactly (so a loaded model is bit-faithful to OLGA — verified by the round-trip test):
Gene order follows OLGA’s IGoR gene index (
genV/genD/genJlist order).ndel = array_index - max_palindrome(biological deletion; negatives are palindromic P-nt).PDJ(VDJ) is factored toj_choice(marginalP(J)) ×d_genegiven J (P(D|J));PVJ(VJ) tov_choice(P(V)) ×j_choicegiven V (P(J|V)). Both reconstruct exactly.Dinucleotide row
(from_nt, to_nt, p)=R[to_nt, from_nt](OLGA’s column-stochastic Markov).
- vdjtools.model.io.from_olga(model_dir, *, locus, organism='human', derive_orf=False)[source]#
Import an OLGA default-model directory into a
Model.- Parameters:
- Returns:
A validated
Modelwhose tables reproduce OLGA’s arrays exactly.- Return type:
- vdjtools.model.io.from_germline(germline, *, locus, organism='custom', palindrome_max=None, ins_max=40, strict=True, source=None)[source]#
Build a recombination
Modelscaffold from any V(D)J germline library.This is the entry point for a custom reference: supply your own alleles (from
vdjtools.model.reference.read_germline_fasta(), from arda, or assembled by hand) and get back a model with the right gene set, germline geometry and event graph, whose marginaltablesare placeholders — uniform gene usage, small-trim / small-insert biased deletions and insertions with wide support — meant to be refit byvdjtools.model.infer.infer_native(). The placeholders’ndel/lengthsupport ranges bound what EM can learn, so they are sized to each segment’s full cut-segment length and toins_max.- Parameters:
germline (DataFrame) – Germline frame in
vdjtools.model.reference.load_germline()’s schema — requiredallele, segment, sequence, optionalgene, functional, cdr3_anchor, full_germline.sequenceis the CDR3-region germline for V/J and the full germline for D. Alleles with non-ACGT bases are dropped (the native DP cannot encode them);strictdecides whether that and the other audit findings are fatal.locus (str) – e.g.
"TRB","IGH".organism (str) – Free-text organism tag carried in the manifest.
palindrome_max (dict[str, int] | None) – Max palindromic nt per trimmable end (default matches OLGA human).
ins_max (int) – Maximum N-region insertion length in the placeholder tables.
strict (bool) – Raise if
vdjtools.model.reference.validate_germline()reports anerror. Warnings (a misplaced-looking anchor, ambiguous bases, a gene-level allele name) are never fatal — callvalidate_germlineyourself to inspect them.source (str | None) – Manifest provenance string; defaults to
"germline:<locus>".
- Returns:
A validated
Model—VDJif the frame carries any D allele, elseVJ.- Raises:
ValueError – If the germline frame is unusable (missing columns, no V or no J, duplicate or empty alleles) and
strictis set.- Return type:
- vdjtools.model.io.from_arda(locus, organism='human', *, palindrome_max=None, ins_max=40)[source]#
Build a recombination
Modelwhose gene set + germline come from arda.A thin wrapper over
from_germline()that sources the germline library from arda — the single source of germline truth — so generated sequences carry arda’s IMGT allele names and germline, and stitching gets the full-length V/J germline + anchor from arda’s scaffolds. The marginal tables are placeholders meant to be refit; seefrom_germline().- Parameters:
- Returns:
A validated
Model(VDJ if arda has D germline for the locus, else VJ).- Raises:
ImportError – If arda is not importable (it is a base dependency; a plain
pip install vdjtoolsships it).ValueError – If arda has no germline for
locus/organism.
- Return type:
- vdjtools.model.io.marginals_frame(model)[source]#
Flatten every marginal into ONE long, self-describing table.
The per-event tables are already tidy polars, but they have different schemas, so inspecting a whole model means juggling a dozen frames. This stacks them into a single frame with the union of all realization columns (null where a column does not apply to that event), which is what you want for eyeballing, diffing, spreadsheeting or shipping a model as one file.
- Parameters:
model (Model) – The model to flatten.
- Returns:
event, kind, given+_REALIZATION_COLS+p, one row per probability.- Return type:
DataFrame
Example
>>> marginals_frame(m).filter(pl.col("event") == "v_choice").head()
- vdjtools.model.io.set_marginals(model, frame, *, validate=True)[source]#
Rebuild a model’s marginals from a
marginals_frame()-shaped table.The inverse of
marginals_frame(), and the reason a hand-edited TSV is a first-class model input: edit the probabilities in whatever tool you like, read the table back, and get a model. The germline frames and the event graph come frommodel— this replaces probabilities only.- Parameters:
model (Model) – Supplies the manifest and germline; its own tables are replaced.
frame (DataFrame) – Long-format marginals with at least
eventandp, plus whichever realization columns the events need. Extra columns are ignored; rows for unknown events are an error.validate (bool) – Run
validate()on the result.
- Returns:
A new
Model; the input is not modified.- Raises:
ValueError – If an event has no rows, an event name is unknown, or a required realization column is missing for some event.
- Return type:
- vdjtools.model.io.save_model(model, path, *, fmt='parquet')[source]#
Write a model to
path/asmanifest.json+ one file per event/germline table.- Parameters:
model (Model) – The model to write.
path (str | Path) – Destination directory (created if absent).
fmt (str) –
"parquet"(default, exact and compact), or"tsv"/"csv"for a hand-editable directory. Text formats lose dtypes on the way out;load_model()restores them from the manifest, so the round-trip is still exact.
- Raises:
ValueError – On an unknown
fmt.- Return type:
None
- vdjtools.model.io.load_model(path, *, validate=False)[source]#
Load a native model directory written by
save_model().The on-disk format is detected per table, so parquet, TSV and CSV directories all load, and text-format tables are cast back to the schema’s dtypes.
training.jsonis read when present; a model written before training logs existed simply hastraining is None.- Parameters:
validate (bool) – Also run
validate(). Off by default so a deliberately-broken model can still be loaded forcheck_model()to diagnose.
- Returns:
The loaded
Model.- Return type:
Precomputed recombination models shipped with vdjtools.
Three model sets live under vdjtools/model/_bundled/:
olga— imported from OLGA’s default models (the exact-Pgen bootstrap; single-D). Seven human loci, OLGA’s germline namespace.learned— EM-inferred from real non-functional reads (out-of-frame + stop-codon, HuggingFace), tandem-D on the D-bearing loci (IGH/TRD/TRB). These carry a learnedP(n_D=2)and broader trim/insertion distributions than the synthetic OLGA models. Seven human loci, OLGA’s germline namespace.arda— the same EM inference on the arda IMGT allele namespace (from_arda()scaffold refit byinfer_native()). Nine models: the seven human loci plus mouse TRA/TRB — the only bundled set with a non-human organism. Use this one when the rest of your pipeline is arda-annotated, so generated sequences share one allele namespace with your query data instead of needing a name fallback.
arda models are keyed by {organism}_{LOCUS} on disk; the other two by {LOCUS} alone.
load_bundled() hides that — pass organism= and it picks the right key.
Each model is a directory of parquet marginal tables + manifest.json (see vdjtools.model).
Provenance and the build command are recorded in SOURCES.md.
- vdjtools.model.bundled.SOURCES = ('olga', 'learned', 'arda')#
The three shipped model sets.
- vdjtools.model.bundled.LOCI = ('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL')#
The seven human loci with bundled models (
ardaadds mouse TRA/TRB).
- vdjtools.model.bundled.load_bundled(locus, source='olga', *, organism='human', collapse=True)[source]#
Load a precomputed model shipped with the package (no OLGA/HuggingFace at runtime).
- Parameters:
locus (str) – One of
TRA TRB TRG TRD IGH IGK IGL(case-insensitive).source (str) –
"olga"(OLGA bootstrap, exact Pgen),"learned"(EM-inferred from real non-functional reads; tandem-D on IGH/TRD/TRB), or"arda"(the same EM inference on the arda IMGT allele namespace, and the only set covering mouse).organism (str) –
"human"(default) or"mouse". Only theardaset ships a non-human organism; asking the others for one is an error rather than a silent human model.collapse (bool) – If
True(default), collapse each gene to a single*01allele viacollapse_alleles()— the working gene-level resolution, in which Pgen also collapses a clonotype’s allele to*01(short-read aligners cannot resolve alleles reliably, so the suffix is noise). Passcollapse=Falsefor full allele resolution, e.g. the exact-OLGA-Pgen fidelity check.
- Returns:
The
Model.- Raises:
ValueError – If
sourceis not one ofSOURCES, or a non-humanorganismis asked of a human-only set.FileNotFoundError – If no bundled model exists for
(source, organism, locus).
- Return type:
Example
>>> m = load_bundled("TRB") # olga, human >>> m_arda = load_bundled("TRB", "arda") # arda namespace, human >>> m_mouse = load_bundled("TRB", "arda", organism="mouse") # the only mouse models
- vdjtools.model.bundled.list_bundled()[source]#
Return the available bundled models as
{source: [key, ...]}.Keys are bare loci for
olga/learnedand{organism}_{LOCUS}forarda, matching whatload_bundled()accepts for each set.
Collapse a V(D)J model to one allele per gene — the default working resolution.
IMGT alleles of one gene (TRBV12-4*01, TRBV12-4*02) differ by a handful of SNPs, usually
in framework rather than the CDR3 region, and short-read aligners cannot reliably tell them apart
(arda splits usage across *01/*03 as mapping noise). So the default working model sums each
gene’s sub-variants into a single *01 representative, and Pgen collapses a clonotype’s allele to
that representative too — the allele suffix stops carrying (spurious) information. Pass
collapse=False (or keep the un-collapsed model) when you genuinely want allele resolution, e.g.
the exact-OLGA-Pgen fidelity check.
The collapse is a proper marginalisation, not a truncation:
a choice probability (
P(V=a)) becomes the plain sum over the gene’s alleles — exact;a conditional (
P(delV | V=a),P(D | J=ja)) becomes the usage-weighted average over the gene’s alleles,P(x | gene) = Σ_a [P(a)/P(gene)] · P(x | a)— the correct allele-marginal;the germline of one representative allele is kept and relabelled
gene*01. This is the one lossy step: where alleles differ inside the CDR3 region the mixture cannot be reproduced by a single germline, so collapsed Pgen is approximate there (exact wherever the CDR3-region germline is allele-invariant, which is the common case).
The representative is ranked, in order, by CDR3-region germline length, then IMGT
functionality (F > ORF > P, read from arda’s per-allele CDR3-anchor table), then
usage, then a preference for *01, then the allele name as a last resort.
Length leads because IMGT ships some alleles with a truncated — sometimes empty — CDR3-region
germline, and a truncated allele must never define the gene’s trim range. Human IGKV3-20 is the
usage version of that trap: *02 is 11 nt against *01’s 30 and had the higher learned usage,
so the collapsed gene inherited an 11-nt germline (labelled *01, which was doubly misleading)
and stranded 25% of its own deletion distribution on trims it could no longer reach. Functionality
cannot lead for the same reason: over every bundled model the two orders differ on exactly one gene,
human TRBV23/OR9-2, where the only non-pseudogene allele (*02, ORF) has an empty CDR3
germline and *01 (P) has 21 nt — leading with functionality would install the empty one and make
Pgen through that gene exactly 0. Alleles of one gene are near-identical through the CDR3 region, so
a large length gap means an incomplete database entry, not biology.
Functionality is second because ties on length are common and the name tie-break underneath them
is meaningless — it just takes the lexicographically last allele. That is how TRBJ2-7*02, an
ORF allele whose germline templates SYEQYV instead of the conserved SYEQYF, became
the representative of TRBJ2-7 and was relabelled *01: both alleles are 19 nt, so
length did not separate them, no usage was passed for J at all, and *02 won on the name. Every
real …YEQYF junction then scored exactly zero against the collapsed model, with no error
raised, and the TRBJ2-7 marginal fell 5–6 orders of magnitude.
Having fixed the representative, the collapsed deletion conditionals are then projected onto what that germline supports and renormalized: the collapsed model is a single-germline model, so any residual mass on unreachable trims would leak straight out of Pgen.
- vdjtools.model.collapse.collapse_alleles(model)[source]#
Return a copy of
modelwith every gene reduced to a single*01allele.- Parameters:
model (Model) – A V(D)J
Model(OLGA-derived or EM-learned).- Returns:
one allele (
gene*01) per gene in every table and in the germline. Marginal usage is preserved exactly; conditionals are the correct usage-weighted allele averages; germline is the representative allele’s (ranked by length, then IMGT functionality, then usage, then*01), relabelled*01.- Return type:
A new
Modelat gene resolution
Example
>>> m = collapse_alleles(load_bundled("TRB", "learned")) >>> m.tables["v_choice"].height # one row per V gene, not per allele
Germline reference — arda is the single source of germline truth.
Every V/D/J germline sequence and CDR3 anchor used anywhere in vdjtools (annotation, scenario enumeration, contig stitching, generation, and arda-native models) resolves from arda’s germline library by allele name, so the whole pipeline speaks one coordinate frame.
arda’s anchor convention is byte-identical to OLGA’s: anchor_nt is a 0-based offset
into the full germline marking the conserved Cys104 codon (V) or [FW]118 codon (J); the
CDR3-region germline is full[anchor:] for V and full[:anchor+3] for J. So no coordinate
conversion is needed between the two — this module documents and enforces that shared frame
(reconcile_olga() catalogs any residual sequence differences).
Full-length V/J germline for contig stitching (Phase 1c) is recovered from arda’s bundled
scaffold reference by load_full_vj_germline() / arda_full_germline(): arda’s
alleles.fasta scaffolds are full_V + N-pad + full_J, sliced per allele at the
v_sequence_end / j_sequence_start boundaries from arda.annotate.reference. The
sliced full germline is anchor-consistent with load_germline() (verified across all
functional V/J alleles: full_V[anchor:] == the CDR3-region germline, and the CDR3-region J
germline is a prefix of full_J). Only functional/ORF alleles with complete markup have a
full germline in arda; pseudogenes (present in the CDR3 anchors) may be absent.
- vdjtools.model.reference.reverse_complement(seq)[source]#
Reverse complement of a nucleotide string (ACGT).
- vdjtools.model.reference.translate(seq)[source]#
Translate a nucleotide string to amino acids (standard code; trailing partial codon dropped).
- vdjtools.model.reference.cut_segment(seq, segment, max_pal)[source]#
Palindrome-extend a CDR3-region germline for the Pgen DP (mirrors OLGA’s cutR/cutL_seq).
Appends up to
max_palreverse-complement (P-nucleotide) bases on the trimmable end so a deletion index into the result directly counts nt removed:V: append at the 3’ end. - J: prepend at the 5’ end.
- vdjtools.model.reference.cut_segment_d(seq, max_pal5, max_pal3)[source]#
Palindrome-extend a D germline on both ends (5’ then 3’).
- vdjtools.model.reference.load_germline(locus, organism='human')[source]#
V/D/J germline + CDR3 anchors for a locus, from arda (the source of truth).
- Parameters:
- Returns:
One row per allele with columns
allele, gene, segment, sequence, cdr3_anchor, functionality, functional, status. For V/J,sequenceis the CDR3-region germline andcdr3_anchorthe 0-based anchor codon offset in the full germline; for D,sequenceis the full D germline,cdr3_anchor = -1.- Raises:
ImportError – If arda is not importable (it is a base dependency; a plain
pip install vdjtoolsships it).ValueError – If no germline is found for
locus/organism.
- Return type:
DataFrame
- vdjtools.model.reference.load_full_vj_germline(organism='human')[source]#
Full-length V and J germline nucleotide sequences from arda, by
(segment, allele).arda ships full V/J germline only inside deduplicated V–J scaffolds (
database/vdj/<organism>/alleles.fasta, keyed by opaque scaffold id); this slices each scaffold at thev_sequence_end/j_sequence_startboundaries fromarda.annotate.reference.load_referenceto recover the per-allele full germline (V: FR1 → 3’ end of V-REGION; J: 5’ J → end of FR4). The first scaffold carrying an allele wins (all are byte-identical for that allele’s segment).- Parameters:
organism (str) – e.g.
"human","mouse".- Returns:
{("V"|"J", allele): full_germline_nt}. Only functional/ORF alleles with complete arda markup are present; pseudogenes (in the CDR3 anchors) may be missing.- Raises:
ImportError – If arda is not importable (it is a base dependency; a plain
pip install vdjtoolsships it).- Return type:
- vdjtools.model.reference.arda_full_germline(locus, organism='human')[source]#
Stitch-ready full V/J germline + anchor for a locus, entirely from arda.
Combines
load_full_vj_germline()(full-length germline) withload_germline()(the CDR3-region germline) so the anchor is derived self-consistently by length — no reliance on a cross-source coordinate assumption:V:
anchor = len(full) - len(cdr3_region)—full[:anchor]is the framework 5’ of the conserved Cys104.J:
anchor = len(cdr3_region) - 3—full[anchor + 3:]is the framework 3’ of the conserved Phe/Trp118 codon.
This is exactly the
(full_germline, anchor)pairvdjtools.model.stitch.stitch_contig()consumes, so an arda-native model (no OLGA germline) can stitch full contigs. Alleles whose full germline is absent from arda (pseudogenes / incomplete markup) are skipped.
- vdjtools.model.reference.GERMLINE_REQUIRED = ('allele', 'segment', 'sequence')#
Columns a germline frame must carry, and the defaults filled in for the optional ones.
- vdjtools.model.reference.normalize_germline(germline)[source]#
Fill a germline frame’s optional columns with their defaults; returns a new frame.
genedefaults toallele.split("*")[0]. SeeGERMLINE_OPTIONALfor the rest.- Parameters:
germline (DataFrame)
- Return type:
DataFrame
- vdjtools.model.reference.validate_germline(germline)[source]#
Audit a germline frame destined for
vdjtools.model.io.from_germline().Catches the mistakes that otherwise produce a model that builds cleanly and scores wrongly — above all a misplaced CDR3 anchor, which shifts every deletion profile by a constant and is invisible downstream.
- Parameters:
germline (DataFrame) – A frame with at least
allele, segment, sequence(seeGERMLINE_REQUIRED); optional columns are described byGERMLINE_OPTIONAL.sequenceis the CDR3-region germline (V: Cys104 codon → 3’ end; J: 5’ end → through the [FW]118 codon), or the full germline for D.- Returns:
Tidy issue frame
severity, check, event, segment, allele, detail, value; empty when the library is clean.severity == "error"means the frame cannot build a model.- Return type:
DataFrame
- vdjtools.model.reference.forbidden_dj_pairs(d_alleles, j_alleles, locus)[source]#
(D, J) pairs that deletional V(D)J recombination cannot produce, for this locus.
Recombination excises the DNA between the two segments it joins, so a D can only be joined to a J that lies 3’ of it. In almost every locus that is vacuous — all D genes sit 5’ of all J genes — but TRB interleaves:
5’ ─ TRBV… ─ TRBD1 ─ TRBJ1 cluster ─ TRBD2 ─ TRBJ2 cluster ─ TRBC2 ─ 3’
so TRBD2 can only reach the TRBJ2 cluster; a TRBD2–TRBJ1 join is physically impossible. TRBD1, sitting 5’ of both clusters, can reach either.
This is a hard genomic fact, not a probability the data gets to inform, and EM will happily learn the impossible pair from noise if nothing forbids it: a D match is only 10–18 nt, which is weak evidence on short reads. Before this constraint existed the retrained human TRB model assigned
0.091to that pair, and OLGA’s own model assigns0.333. Ambiguousd_callvalues in the training data concentrate exactly there, so the error is systematic, not random.- Parameters:
d_alleles – D allele names, e.g.
["TRBD1*01", "TRBD2*01"].j_alleles – J allele names.
locus (str) – e.g.
"TRB". Loci with no interleaving return an empty set.
- Returns:
The set of impossible
(d_allele, j_allele)pairs.- Return type:
Example
>>> forbidden_dj_pairs(["TRBD2*01"], ["TRBJ1-1*01", "TRBJ2-1*01"], "TRB") {('TRBD2*01', 'TRBJ1-1*01')}
- vdjtools.model.reference.read_fasta(path)[source]#
Read a FASTA into
(header, sequence)pairs, transparently handling.gz.Parsing itself is delegated to arda (one FASTA parser in the stack, not two), but arda’s reader takes a path and plain
open(), so a gzipped file reaches it as mojibake and dies on the first byte. Gzipped input is therefore decompressed to a temporary file first.
- vdjtools.model.reference.read_germline_fasta(v, j, d=None, *, anchors=None)[source]#
Build a germline frame from your own FASTA files — the entry point for a custom library.
The segment comes from which argument a file was passed as, so no header convention is assumed: a header is either
>ALLELEor>ANYTHING|ALLELE(arda’s D convention), and everything after the second|is ignored.- Parameters:
v – FASTA path for the V alleles.
j – FASTA path for the J alleles.
d – Optional FASTA path for the D alleles. Supplying it makes the model
VDJ; omitting it makes itVJ.anchors – Optional CDR3-anchor CSV in OLGA’s
*_gene_CDR3_anchors.csvformat (gene,anchor_index,function). When given, the FASTAs are taken to hold full-length germline and are sliced to the CDR3 region (full[anchor:]for V,full[:anchor + 3]for J). When omitted, the V/J sequences are taken to be CDR3-region germline already —validate_germline()flags it if they are not.
- Returns:
A germline frame in
load_germline()’s schema, ready forvdjtools.model.io.from_germline().- Return type:
DataFrame
- vdjtools.model.reference.reconcile_olga(model)[source]#
Catalog how an OLGA-loaded model’s germline relates to arda’s (the shared-frame audit).
OLGA bootstrap models keep OLGA’s germline geometry for exact-Pgen fidelity; this reports, per V/J allele, whether it resolves in arda and whether the CDR3-region germline matches — so divergences (IMGT-version drift, V 3’ extent) are flagged, never silent.
- Parameters:
model – A
Modelloaded viafrom_olga.- Returns:
allele, segment, in_arda, germline_equal, olga_len, arda_len.- Return type:
Per-allele report
Reconstruct full-length nt contigs from (V, J, CDR3) and run them through arda.
OLGA generation (and OLGA-synthetic bootstrap data) emits only (V call, J call, CDR3 nt),
but arda — the aligner that drives scenario enumeration for real reads — consumes full nt
reads. stitch_contig rebuilds the read by prepending the V germline 5’ of the CDR3 anchor
and appending the J germline 3’ of it, so synthetic and real reads flow through the identical
arda.annotate_sequences → scenarios → EM path (with known ground truth on the synthetic side).
- vdjtools.model.stitch.stitch_contig(model, v, j, cdr3_nt)[source]#
Rebuild a full nt contig: V framework 5’ of Cys104 + CDR3 + J framework 3’ of [FW]118.
- Parameters:
- Returns:
The contig, or
Noneif either gene lacks a usable anchor / full germline.- Return type:
str | None
- vdjtools.model.stitch.stitch_frame(model, gen, *, cdr3_col='junction_nt')[source]#
Add a
contigcolumn to a generated frame (rows with no anchor drop to null).
- vdjtools.model.stitch.annotate(contigs, *, organism='human')[source]#
Annotate nt contigs with arda → a polars frame of the calls the scenario/EM path needs.
Returns columns
v_call, d_call, j_call, junction, junction_aa, productive(arda’s best alignment). arda is a base dependency (ships with vdjtools).
Most-likely nucleotide CDR3 for an amino-acid CDR3, and the recombination scenario behind it.
VDJdb-style records carry (V, J, CDR3aa) and no nucleotides. infer_nt() returns the
nucleotide CDR3 that maximises the model’s generation probability, together with the argmax
scenario — which is the V/D/J boundary markup (v_end, d_start, d_end, j_start).
This is the argmax counterpart of a sum that already exists. pgen.pgen_aa() marginalises
over every nucleotide sequence encoding a given amino-acid CDR3; the most likely such sequence is
the maximum of the same set, and pgen.pgen_nt()’s own loops — (V, len_v), (J, len_j),
then the D/insertion middle — already enumerate the scenarios. Nothing here re-derives the
recombination model: every probability comes from pgen.prepare()’s tables.
Two objectives are deliberately separated, because they are not the same number:
infer_nt()maximises the marginalP(nt) = pgen_nt(nt), which sums over every scenario that could produce that sequence. That is the right target for “the most likely nucleotide sequence”.best_scenario()then maximises over scenarios for that fixed sequence, which is what the boundary markup has to come from. The two maxima are over different things and taking the second as an answer to the first would be wrong.
STATUS.
best_scenario()is complete and validated. It needs no enumeration: it is a max-product walk of the(V, len_v) x (J, len_j) x middleloopspgen.pgen_nt()already sums over. Checked on 500 generated draws per locus: the reported V span is the V germline 500/500, the J span the J germline 500/500, the D span the D germline 500/500 (TRB), andscenario_p <= pgen_nt500/500 — a max cannot exceed the sum it is taken over.infer_nt()is the production path: a codon-constrained max-product DP over the same scenario enumerationpgen.pgen_aa()sums over, with exact branch-and-bound, followed by an exactpgen.pgen_nt()re-score of the top candidates.infer_nt_bruteforce()is exact but exponential, and exists as the TEST ORACLE.
Why brute force cannot be the answer, measured on the real VDJdb (79,997 records). The codon
search space — the product of synonymous-codon counts over the CDR3 — has a median of 5.3e6 for
TRA and 1.9e7 for TRB; only 8.9 % / 1.6 % of records are at or under 1e5 candidates.
Each candidate costs a full pgen.pgen_nt(), so enumeration is out by orders of magnitude.
WARNING: And it cannot be rescued by pinning the germline-templated flanks, which was tried and is
unsound: pinning a codon to the V/J germline excludes every sequence in which that germline was
trimmed, and the true maximum can be one of them. Caught against the brute-force oracle —
CAVSDMRF under TRAV21*01 gives …GTGAGTGAC… where the pinned version returns
…GTGAGCGAT…. The pin is gone; do not reintroduce it as an optimisation.
NOTE: Both functions assume an in-frame CDR3, i.e. len(nt) == 3 * len(aa). That is the VDJdb
population (real, productive receptors) but NOT every draw from generate() — an out-of-frame
rearrangement has e.g. 8 aa and 25 nt, and no in-frame nt can explain it. Test with
productive_only=True or the comparison is meaningless.
- class vdjtools.model.viterbi.Scenario(cdr3_nt, v_call, j_call, v_end, j_start, d_call=None, d_start=None, d_end=None, pgen=0.0, scenario_p=0.0, n_candidates=0, truncated=False, runner_up_pgen=0.0)[source]#
Bases:
objectA nucleotide CDR3 and the recombination that most likely produced it.
Coordinates are 0-based, half-open, in CDR3 nucleotide space (the conserved Cys through the conserved Phe/Trp, both included) — the same space as
cdr3_ntitself.d_start/d_endareNonewhen the model has no D (a VJ chain) or no D was placed.- Parameters:
- vdjtools.model.viterbi.best_scenario(model_or_prep, cdr3_nt, v=None, j=None)[source]#
The single most likely recombination for a KNOWN nucleotide CDR3.
A max-product walk of exactly the loops
pgen.pgen_nt()sums over —(V, P(V), len_v)×(J, P(J), len_j)× the middle — so the scenario space, the pruning and every probability are the shipped ones. ReturnsNonewhen no (V, J) pair can explain the sequence.NOTE: The D placement is taken from the middle by the same gapless scan the model uses;
d_callisNonefor a VJ chain.
- vdjtools.model.viterbi.infer_nt(model_or_prep, cdr3_aa, v=None, j=None, *, n_best=8, keep=0.01, top_vj=8)[source]#
The most likely nucleotide CDR3 for an amino-acid CDR3, and its recombination markup.
Two stages, and the second is what makes the first honest:
Max-product over (scenario, nucleotides) jointly. Every scenario
pgen.pgen_aa()sums over is enumerated —(V, delV) x (J, delJ) x (D, delD, position)— and for each,_aa_dp_max()picks the single best codon assignment: germline positions are pinned to their segment, and each free N-region position takes the nucleotide maximisingP(nt_1)·prod P(nt_k | nt_{k-1})under the VD / DJ / VJ dinucleotide model covering it.The surviving candidates are re-scored with the exact marginal
pgen.pgen_nt(), and the winner is the best of those. Stage 1 maximises the jointP(nt, scenario), which is not the marginalP(nt)this function’s contract asks for; the re-scoring is what turns a Viterbi path into an answer about the marginal, and it is whypgenon the result is a realpgen_ntrather than a path weight.
Both stages earn their place, measured against
infer_nt_bruteforce()on generated productive draws with V and J pinned (25 TRG, 19 TRA, 10 residues each).variant
TRG
TRA
one scenario, best codon per residue (no search)
9/25
4/19
the joint argmax alone, no re-score
21/25
15/19
argmax + marginal re-score (the defaults)
25/25
19/19
The cheap shortcut fails because a trim chosen before the codons pins a codon the true optimum would have trimmed away — the same unsoundness as pinning the germline flanks.
Two implementations. Pass a
Modeland stage 1 runs natively (vdjtools.model.native.best_aa_scenarios()) — 2.5 ms per human TRB CDR3, 0.5 ms per TRA, i.e. all 80k VDJdb records in about 3 minutes. Pass an already-pgen.prepare()-d model and it runs the pure-Python scenario search instead, which is the reference implementation the native one is validated against and is ~600x slower on TRB.keepandtop_vjapply only to that reference path; the native DP needs neither, because it marginalizes over V and J at essentially no cost (0.26 vs 0.23 ms with both free).Three input modes for the calls, because annotation tables have all three:
v="TRBV5-1*01", j="TRBJ2-3*01"— the normal mode. Exhaustive over the trims of that pair.v="TRBV6-2*01,TRBV6-3*01"(or a list) — the aligner offered several and could not choose. Every listed allele is searched and the model picks the most plausible.v=None— nothing known; the DP marginalizes over every V (or J).
- Parameters:
model_or_prep – A
Model(native, the normal call) or apgen.prepare()-d one (the pure-Python reference search).cdr3_aa (str) – The CDR3 amino-acid sequence (conserved Cys -> conserved Phe/Trp inclusive).
v – V allele, comma-separated alleles, a list, or
None— see above. Allele-keyed, never gene-level: see the_gene_idxtrap.j – J allele, comma-separated alleles, a list, or
None.n_best (int) – How many distinct candidates to re-score with the exact marginal Pgen.
keep (float) – Reference path only. Candidates within this factor of the running best survive the prune.
1.0is the pure argmax and the fastest.top_vj (int) – Reference path only. Shortlist size when a call is
None, ranked by the model’s ownP(gene)·P(del);0searches everything. It exists because the Python search costs ~23 s per unconstrained TRB CDR3. The native path ignores it.
- Returns:
A
Scenariowithpgenthe marginal of the winner andrunner_up_pgenthe marginal of the next distinct candidate — reportScenario.margin, because with a long non-templated core many sequences are near-equally likely.Nonewhen no nucleotide CDR3 encodingcdr3_aais explicable under the model.- Return type:
Scenario | None
Note
Tandem-D (
n_D = 2) scenarios are not enumerated in stage 1. They cannot add candidates — a single D trimmed to zero length already reaches every middle — so they can only reorder them, and the stage-2pgen_ntre-scoring includes the tandem-D mass in full. Raisen_bestif a D-D model’s ordering matters to you.
- vdjtools.model.viterbi.infer_nt_bruteforce(model, cdr3_aa, v=None, j=None, *, max_candidates=200000)[source]#
Exact most-likely nucleotide CDR3 by enumerating every codon assignment. Oracle only.
Maximises the marginal
pgen_ntover every nucleotide sequence encodingcdr3_aa, then reports the argmax scenario for the winner viabest_scenario(). Exact, and exponential in the number of residues.WARNING: Not usable on real data — see the module header: the median VDJdb record has 5.3e6 (TRA) to 1.9e7 (TRB) candidates. This exists so the production DP, when written, has an exact reference to be tested against on the small cases where both can run.
max_candidatesbounds the work and setstruncated; a truncated result is the best of a PARTIAL set and is not the maximum, which is why it is flagged rather than returned quietly.Returns
Nonewhen no candidate is explicable under the model — including the out-of-frame case, where no in-frame sequence of length3 * len(aa)exists.
- vdjtools.model.viterbi.codon_options(aa)[source]#
Per residue, the codons encoding it. Unknown residues get no options (an empty list).
Bootstrap the recombination model from real AIRR reads (private HF isalgo/airr_model_read).
The dataset ships raw 5’-RACE FASTQ reads per organism-group (human, human_fetal — TdT-low,
mouse) and chain, split into functional (productive) and non-functional (out-of-frame or
stop) buckets. Training / benchmarking the engine means:
fetch_fastq()— pull one{group}/{CHAIN}.{label}.fq.gzfrom the hub.annotate_reads()— map the reads with arda (arda rnaseq map) to a per-read AIRR table with V/D/J (and D2 for D-D joins) calls, junction, CIGARs, isotype, and productivity.unique_clonotypes()— collapse to the model’s clonotype identity: same V allele, J allele, and junction nucleotides. Reads differing only in alignment (CIGAR) or isotype (IGHc_call) are one clonotype; isotype is dropped from the key (isotype switching is the same clonotype) and the collapse can be restricted to naive IgM.
The non-functional clonotypes are the unbiased EM training set; the functional ones are the
selection-shaped test set. huggingface_hub (fetch) and arda[rnaseq] (annotate; needs arda +
mmseqs2 + seqtree) are lazy, tool-only imports — never runtime dependencies of the model math.
Example:
from vdjtools.model import from_olga, data
from vdjtools.model.infer import infer_native
clones = data.prepare("human", "TRB", "nonfunctional", out_dir="/tmp/arda", cap=200_000)
template = from_olga(olga_dir, locus="TRB")
fit, rep = infer_native(template, clones["junction"].to_list())
- vdjtools.model.data.MODEL_READS_REPO = 'isalgo/airr_model_read'#
Private HuggingFace dataset of real AIRR reads (owner isalgo, cc-by-nc-nd); fetched, not vendored.
- vdjtools.model.data.ORGANISM = {'human': 'human', 'human_fetal': 'human', 'mouse': 'mouse'}#
arda reference organism per group (fetal T cells use the human reference).
- vdjtools.model.data.fetch_fastq(group, chain, label, *, repo='isalgo/airr_model_read')[source]#
Download one
{group}/{CHAIN}.{label}.fq.gzfrom the dataset; return its local path.
- vdjtools.model.data.annotate_reads(fq_path, *, out_dir, prefix, organism='human', cap=None, threads=0, reconstruct=False, verbose=False)[source]#
Map a FASTQ to a per-read AIRR table with arda (
arda rnaseq map→<prefix>.airr.tsv).Returns the raw per-read annotation (V/D/J and D2 calls, junction, CIGARs, isotype, productivity); collapse it to clonotypes with
unique_clonotypes(). We deliberately stop atmap— the clonotype identity “same V/J allele + junction, up to CIGAR” is exactly that dedup, so arda’s heavier error-modelcorrectstage is not needed (and, on these single-end 5’-RACE reads, it discards reads that map but whose mate-spanned junction it cannot reassemble).- Parameters:
fq_path (str | Path) – Input FASTQ (
.fq/.fq.gz), e.g. fromfetch_fastq().prefix (str) – Output basename.
organism (str) – arda reference organism (
"human"/"mouse").cap (int | None) – If set, annotate only the first
capreads (bounded benchmark scale).threads (int) – mmseqs threads (0 = all cores).
reconstruct (bool) – Merge overlapping paired mates into one fragment before mapping (recovers longer junctions single reads don’t span; needs the FASTQ to carry both mates).
verbose (bool)
- Returns:
The per-read AIRR
polars.DataFrame. Reads that do not span a full junction have an emptyjunctionand are dropped byunique_clonotypes().- Return type:
DataFrame
Requires the
ardaCLI with thernaseqextra (arda + mmseqs2) onPATH.
- vdjtools.model.data.unique_clonotypes(clones, *, naive_igm_only=False)[source]#
Collapse an arda clonotype table to unique clonotypes =
(v_call, j_call, junction).Isotype (
c_call) is dropped from the key — isotype switching is the same clonotype. Rows are keyed to allele resolution (V/J allele) and full junction nt, so reads that differ only in alignment (CIGAR) or isotype collapse together; read support sums intocount.- Parameters:
clones (DataFrame) – An arda clonotype frame (from
annotate_reads()).naive_igm_only (bool) – Keep only IgM (naive B) clonotypes before collapsing (IGH only; no-op elsewhere) — for a naive-repertoire (pre-selection-independent) subset.
- Returns:
v_call, j_call, junction, junction_aa, locus, d_call, d2_call, count(one row per unique clonotype), sorted by descendingcount.- Return type:
Deduplicated clonotype frame
- vdjtools.model.data.prepare(group, chain, label, *, out_dir, cap=None, reconstruct=False, naive_igm_only=False, verbose=False)[source]#
Fetch → map (arda) → unique clonotypes for one
(group, chain, label)bucket.
- vdjtools.model.data.PREPARED_DIR = PosixPath('/home/runner/work/vdjtools/vdjtools/tests/python/fixtures/model_reads')#
Directory of the arda-mapped clonotype examples shipped with the source tree.
- vdjtools.model.data.write_prepared(clones, path)[source]#
Write a clonotype frame as the gzipped FASTA
load_prepared()reads.FASTA rather than a table because the payload is a sequence set — it stays greppable, readable by any bioinformatics tool, and about half the size of the equivalent TSV. The V/J/D calls EM needs for its per-read masks ride in the header, pipe-separated:
>{id}|{v_call}|{j_call}|{d_call}|{d2_call}|{count}then the junction nucleotides.Empty fields are written as empty strings and read back as nulls.
- vdjtools.model.data.load_prepared(group='human', chain='TRB', label='nonfunctional', *, path=None)[source]#
Load a small arda-mapped clonotype example — no arda, no mmseqs2, no network, seconds.
prepare()runs the full pipeline on the raw FASTQ, which needs arda + mmseqs2 and takes minutes per chain. For examples, notebooks, tests and a quick look at real junctions that is overkill, so a few already-mapped subsets ship with the source tree as gzipped FASTA (seewrite_prepared()for the header format).These live under
tests/and are not packaged into the wheel — they exist for working in a checkout. From an installed vdjtools, useprepare()or passpath=.- Parameters:
- Returns:
junction, v_call, j_call, d_call, d2_call, count— the columnsunique_clonotypes()produces, minus the ones a FASTA cannot carry.- Raises:
FileNotFoundError – If no example ships for that combination. Only a couple do; build the rest with
prepare()(or the whole corpus withbuild_all()).- Return type:
DataFrame
Example
>>> clones = load_prepared("human", "TRB") >>> model, report = infer_frame("TRB", clones, max_iter=10)
- vdjtools.model.data.BUILD_DEFAULTS = {'gene_prior': 1.0, 'iters': 15, 'nd_prior': 0.0, 'tol': 0.0001}#
P(V) = 0is an absorbing state of this EM, so without a Dirichlet pseudocount over the germline’s functional alleles one unlucky iteration deletes a real gene permanently (human TRB kept 30 of 57 V genes unregularized, having seen 54 in the data).- Type:
EM defaults for a corpus build.
gene_prioris not optional in spirit
- vdjtools.model.data.build_model(chain, *, group='human', template=None, clones=None, work_dir='/tmp/vdjtools_build', cap=None, iters=15, tol=0.0001, single_d=False, nd_prior=0.0, gene_prior=1.0, threads=0, ambiguous='A', verbose=False, checkpoint=None, checkpoint_every=1)[source]#
Fetch, annotate and EM-fit a model for one chain — the whole corpus pipeline, end to end.
- Parameters:
chain (str) – e.g.
"TRB","IGH".group (str) –
human,human_fetalormouse. Sets the arda reference organism.template – Model supplying the gene set, germline and event graph. Defaults to
from_arda()for the chain and the group’s organism.clones (DataFrame | None) – Skip fetch+annotate and fit these clonotypes instead (an
EM_DATA_DIRparquet, aload_prepared()subset, or your own reads). Must carryjunction,v_call,j_calland, for a D locus,d_call/d2_call.work_dir (str | Path) – Scratch directory for arda’s per-read output (it caches there).
cap (int | None) – Read cap for annotation.
Noneuses every read — a subsample is a silent claim that the tail does not matter, and the tail is where the rare V genes are.iters (int) – EM iteration cap. Convergence is on relative log-likelihood, so this is a safety net.
tol (float) – Stop when the relative log-likelihood improvement falls below this.
single_d (bool) – Force a strict single-D model on a D-bearing locus.
nd_prior (float) – Dirichlet pseudocount pushing
P(n_D=2)toward 0.gene_prior (float) – Dirichlet pseudocount over the germline’s functional V/J alleles — see
BUILD_DEFAULTS.threads (int) – E-step worker threads (
0= auto).ambiguous (str | None) – Substitute this base for any non-ACGT character in a junction (default
"A"), orNoneto drop those clonotypes. Seesanitize_junctions().verbose (bool)
checkpoint_every (int)
- Returns:
(model, report, stats)—statsrecordschain, group, chain_type, n_clonotypes, n_used, p_nd2, loglik_first, loglik_last, iters, seconds.
Example
>>> model, rep, stats = build_model("TRB", work_dir="/tmp/em") >>> model.save("models/TRB")
- vdjtools.model.data.build_all(chains=('IGH', 'IGK', 'IGL', 'TRA', 'TRB', 'TRG', 'TRD'), *, groups=('human',), workers=None, out_dir=None, **kw)[source]#
Build models for several chains concurrently — the full-corpus entry point.
Each
(group, chain)runs the wholebuild_model()pipeline. Concurrency is threads, not processes: arda annotation is a subprocess (so it blocks on I/O, not the GIL) and the native E-step releases the GIL, so a thread pool gets real parallelism without the memory cost of forking the germline and count arrays per chain.- Parameters:
chains – Chains to build. Defaults to all seven.
groups – Organism groups to build each chain for.
workers (int | None) – Concurrent builds.
None=min(len(jobs), cpu_count // 2), leaving cores for each build’s own E-step threads.out_dir – If given, each model is saved to
out_dir/{group}_{chain}/.**kw – Passed to
build_model()(iters,tol,cap,gene_prior, …).
- Returns:
{f"{group}_{chain}": {"model": ..., "report": ..., "stats": ...}}for the builds that succeeded, plus{"error": "..."}entries for those that did not — one failing chain never aborts the rest of a 30-minute corpus run.- Return type:
Example
>>> results = build_all(["TRB", "TRA"], workers=2, out_dir="models") >>> pl.DataFrame([r["stats"] for r in results.values() if "stats" in r])
Generation probability, sampling and inference#
Generation probability (Pgen) of a CDR3 from a Model.
This is the reference nucleotide implementation: a transparent sum over every
recombination scenario (V/J/D choice × deletions × insertion partition) that could produce
the observed CDR3 nt, reading straight from the model’s polars tables (no OLGA at runtime).
It reproduces OLGA’s compute_nt_CDR3_pgen to numerical tolerance (see the tests).
It is correctness-first, not speed-first — the fast amino-acid transfer-matrix DP is the job
of the native _core port (Phase 1f). The nt scenario sum here is exactly the quantity the
EM E-step needs (the bootstrap training data is out-of-frame nucleotide reads).
- vdjtools.model.pgen.prepare(model)[source]#
Unpack a model’s polars tables into the lookup structures the Pgen sum uses.
- Parameters:
model (Model)
- Return type:
_Prepared
- vdjtools.model.pgen.pgen_aa(prep, cdr3_aa, v=None, j=None)[source]#
Generation probability of an amino-acid CDR3, marginalizing over synonymous codons.
- Parameters:
- Returns:
Pgen as a float. Sums tandem-D (
n_D=2) scenarios for a D-D model, mixed with the single-D term byP(n_D)— consistent withpgen_nt().- Return type:
- vdjtools.model.pgen.pgen_nt(prep, cdr3_nt, v=None, j=None)[source]#
Generation probability of a nucleotide CDR3, optionally restricted to a V and/or J.
- Parameters:
prep (_Prepared) – A
prepare()-d model.cdr3_nt (str) – The CDR3 nucleotide sequence (conserved-Cys → conserved-Phe/Trp inclusive).
v (str | None) – Optional gene names to restrict the sum to (an OLGA-style usage mask of size 1).
j (str | None) – Optional gene names to restrict the sum to (an OLGA-style usage mask of size 1).
- Returns:
Pgen as a float.
- Return type:
Bridge from the polars Model to the native _core hot loops.
pack reconstructs the model’s dense arrays (gene choice, deletion, insertion, dinucleotide)
and germline cut segments into a C++ PackedModel; pgen_nt calls the native Pgen.
The result matches the pure-Python reference (and OLGA) exactly — the native path is just faster.
- vdjtools.model.native.pack(model)[source]#
Build (and cache) the native
PackedModelformodel.The native nt Pgen, aa Pgen (incl. Hamming-1 and v/j-agnostic), and the EM E-step (
infer_native()) all support tandem-D (n_D=2).The cache is keyed by
id(model)but stores the model reference and verifies identity on hit: CPython recycles object ids after GC, so a bare-id cache could return a stalePackedModelfor a different model that reused a freed id (e.g. running TRB then TRD EM in one process — the stale TRB pack has a different gene count and crashes the M-step). Keeping the ref also pins the id.- Parameters:
model (Model)
- vdjtools.model.native.pgen_nt(model, cdr3_nt, v=None, j=None)[source]#
Native nucleotide Pgen — same result as
vdjtools.model.pgen.pgen_nt(), faster.
- vdjtools.model.native.pgen_aa(model, cdr3_aa, v=None, j=None, mismatches=0)[source]#
Native amino-acid Pgen — same result as
vdjtools.model.pgen.pgen_aa(), much faster.- Parameters:
model (Model) – A recombination
Model.cdr3_aa (str) – The junction/CDR3 amino-acid sequence (Cys → Phe/Trp inclusive).
v (str | None) – V allele to condition on (e.g.
"TRBV9*01"), orNoneto marginalize over all V (V-agnostic). A gene-level name ("TRBV9") or any unknown allele raisesKeyError— it must not silently degrade to the V-agnostic value.j (str | None) – J allele to condition on (e.g.
"TRBJ2-3*01"), orNoneto marginalize (asv).mismatches (int) –
0for the exact sequence;1to also sum the Pgen of every amino-acid sequence within Hamming distance 1 (one substitution) — the total probability mass in the 1-mismatch ball, computed natively far faster than OLGA’s per-neighbour approach.
- Returns:
Pgen as a float.
- Return type:
- vdjtools.model.native.pgen_aa_degenerate(model, allowed, v=None, j=None)[source]#
Total Pgen of every CDR3 matching a motif — a per-position set of permitted residues.
The same masked transfer-matrix DP
pgen_aa()runs (that is the all-singletons case) andpgen_aa()withmismatches=1runs per position (the one-wildcard case), with the residue set at each position free. Cost is one DP pass whatever the sets contain: the sequences the motif matches are summed over, never enumerated. This is the exact Pgen of a V/J/length- pinned motif such as a VDJdb cluster PWM thresholded per position.- Parameters:
model (Model) – A recombination
Model.allowed (list[str]) – One item per amino-acid position of the junction/CDR3 (so
len(allowed)is the junction length). Each item is a string of the residues permitted there:"C"pins one,"ILVF"allows a subset, and""or a string containing"X"is a wildcard (any of the 20 amino acids).list(seq)reproducespgen_aa()exactly. A character the genetic code does not name raisesValueError— an empty codon mask would score a silent0.0.v (str | None) – V allele to condition on, or
Noneto marginalize — aspgen_aa(), including theKeyErroron a gene-level or unknown name.j (str | None) – J allele to condition on, or
Noneto marginalize (asv).
- Returns:
Pgen as a float — the summed Pgen of all matching sequences.
- Return type:
Note
Xmeans wildcard here only.pgen_aa()matches residues by exact character against the genetic code, sopgen_aa(m, "CASSLAPGATNEKLXF")is0.0, not a wildcard query. Passlist("CASSLAPGATNEKLXF")to this function to get the latter.
- vdjtools.model.native.pgen_aa_degenerate_batch(model, allowed, v=None, j=None, threads=0)[source]#
Batch
pgen_aa_degenerate()over many motifs, parallelized across queries.Mirrors
pgen_aa_batch(): the GIL is released and the queries are partitioned across worker threads, and the result is bitwise-identical to the serial per-motif computation for anythreads.- Parameters:
model (Model) – A recombination
Model.allowed (list[list[str]]) – One per-position residue-set list per motif (see
pgen_aa_degenerate()).v (list[str | None] | None) – Optional per-motif V alleles (same length as
allowed);Nonemarginalizes over all V for every motif. Individual entries may beNone.j (list[str | None] | None) – Optional per-motif J alleles (as
v).threads (int) – Worker threads;
0= auto (hardware_concurrency - 2). Batches under 64 motifs run single-threaded.
- Returns:
Per-motif Pgen in input order.
- Return type:
- vdjtools.model.native.pgen_aa_batch(model, cdr3_aas, v=None, j=None, mismatches=0, threads=0)[source]#
Batch amino-acid Pgen over many CDR3s, parallelized across sequences in native code.
Computes the same value as calling
pgen_aa()per sequence, but releases the GIL and partitions the sequences across worker threads — the clean, exact speedup for the real workload (Pgen / 1-mismatch matching over many clonotypes). The result is bitwise-identical to the serial per-sequence computation for anythreads.- Parameters:
model (Model) – A recombination
Model.v (list[str | None] | None) – Optional per-sequence V alleles to condition on (same length as
cdr3_aas);Nonemarginalises over all V for every sequence. Individual entries may beNone. An unknown or gene-level name raisesKeyError(seepgen_aa()).j (list[str | None] | None) – Optional per-sequence J alleles (as
v).mismatches (int) –
0for exact Pgen,1for the Hamming-1 ball (aspgen_aa()).threads (int) – Worker threads;
0= auto (hardware_concurrency - 2). Batches under 64 sequences run single-threaded.
- Returns:
Per-sequence Pgen in input order.
- Return type:
- vdjtools.model.native.best_aa_scenarios(model, cdr3_aa, v=None, j=None, k=8)[source]#
Top-
krecombination scenarios for an amino-acid CDR3, best first.The argmax counterpart of
pgen_aa(), over the same Pi_L*Pi_R transfer matrix:maxin place of the sums, with the winning(V, delV)and(J, delJ)carried through the state. Because the DP marginalizes over V and J at no extra cost, leaving both unspecified is barely slower than pinning them (0.26 vs 0.23 ms on human TRB).- Parameters:
model (Model) – The recombination model.
cdr3_aa (str) – CDR3 amino-acid sequence (conserved Cys -> conserved Phe/Trp inclusive).
v (str | None) – Optional allele names to condition on.
Nonemarginalizes.j (str | None) – Optional allele names to condition on.
Nonemarginalizes.k (int) – How many scenarios to return.
- Returns:
[(w, v_allele, len_v, j_allele, len_j, d_allele | None, idx5, idx3, pos)], descending byw.len_v/len_jare the nucleotides each germline contributes to the CDR3;idx5/idx3are the D’s 5’/3’ trims andposwhere its contribution starts.- Return type:
Sample recombined sequences from a Model (OLGA-style generation).
Ancestral sampling over the model’s Bayes net: pick genes, deletions, insertion lengths and non-templated nt (via the dinucleotide Markov chain), assemble the CDR3, translate. Returns a polars DataFrame. This is the reference sampler; the native port is Phase 1f.
- vdjtools.model.generate.prepare_generation(model)[source]#
Precompute cumulative distributions for fast ancestral sampling.
- Parameters:
model (Model)
- Return type:
_GenPrep
- vdjtools.model.generate.generate(model, n, *, seed=None, productive_only=False)[source]#
Sample
nrecombined CDR3s from the model.- Parameters:
- Returns:
DataFrame with
junction_nt, junction_aa, v_call, d_call, d2_call, j_call, productive.d2_callis the second D of a tandem (n_D=2) draw, else null; for a single-D model it is all-null.- Return type:
DataFrame
EM inference of model marginals from nucleotide CDR3 sequences.
Expectation–Maximization over the recombination Bayes net: the E-step enumerates every
scenario that could produce each observed nt CDR3 (the same enumeration as pgen), weights
them by the current model, and accumulates soft counts per event realization; the M-step
re-normalizes those counts in polars to get the next marginals. Trained on out-of-frame reads,
it recovers the raw generation model (no productivity conditioning, so no selection bias).
Closed-loop oracle: generate synthetic sequences from a known model, then infer must recover
that model’s marginals (see the tests). This is the reference driver; the E-step hot loop is a
Phase 1f native-port candidate.
- class vdjtools.model.infer.InferenceReport(loglik=<factory>, n_scoreable=<factory>, gene_tv=<factory>, n_iter=0, converged=False, n_sequences=0, max_iter=0, tol=0.0, init='', native=False, elapsed_s=0.0, finished_at='', template_source='')[source]#
Bases:
objectPer-iteration diagnostics from
infer()— the training log.Every field after
convergedis metadata recorded so a saved model can say what it was fitted on and how; all are defaulted, so constructing a bare report still works.- Parameters:
- to_dict()[source]#
The report as a JSON-serializable dict (one entry of a model’s
training["runs"]).- Return type:
- vdjtools.model.infer.resume(path, sequences, **kw)[source]#
Continue EM from a checkpoint written by
infer_native(checkpoint=...).A warm start from the saved marginals, so the fit picks up where it stopped rather than realigning from scratch. The checkpoint carries its own training log and this run appends to it, so the full history survives across as many interruptions as it takes — which is what makes a multi-hour fit on a time-limited queue practical.
- Parameters:
path – The checkpoint directory (or an already-loaded
Model).sequences – The same sequences the interrupted run was fitting.
**kw – Passed to
infer_native().initis forced to"template", andcheckpointdefaults topathso the continued run keeps saving to the same place — passcheckpoint=Noneto stop checkpointing.
- Returns:
(model, report)—reportcovers this run only;training_frame(model)shows every run.
Example
>>> model, rep = resume("ckpt/IGH", seqs, max_iter=10)
- vdjtools.model.infer.print_progress(stream=None, prefix='')[source]#
A ready-made
progress=callback that reports each EM iteration as it happens.EM on a large D-bearing locus runs for tens of minutes with nothing to show for it, and the training log only becomes readable once the fit returns — so a long run is indistinguishable from a hung one. This prints the log-likelihood and its relative change per iteration, which is exactly the quantity the convergence test uses, so you can watch it approach
tol.- Parameters:
stream – Where to write; defaults to
sys.stderrso it never pollutes piped output.prefix (str) – Prepended to each line, e.g. the locus being built.
- Returns:
A callable suitable for
infer(progress=...)/infer_native(progress=...).
Example
>>> infer_native(template, seqs, progress=print_progress(prefix="[TRB] ")) [TRB] iter 1 loglik -37.0067 rel inf n=122703 [TRB] iter 2 loglik -33.9738 rel 8.20e-02 n=122703
- vdjtools.model.infer.training_frame(obj)[source]#
The training log of a model (or a single report) as one tidy frame across all runs.
- Parameters:
obj – A
Modelcarrying atraininglog, or anInferenceReport.- Returns:
run, iter, loglik, n_scoreable, rel_change— empty when the model was never fitted here (every bundled model, and anything imported straight from OLGA).- Return type:
DataFrame
Example
>>> m, rep = infer_native(template, seqs, max_iter=10) >>> training_frame(m) # loglik per iteration, ready to plot
- vdjtools.model.infer.infer(template, sequences, *, max_iter=30, tol=0.001, init='align', masks=None, single_d=False, p_nd2_init=0.02, dd_allowed=None, nd_prior=0.0, progress=None, checkpoint=None, checkpoint_every=1)[source]#
Re-estimate a model’s marginals from nucleotide CDR3s by EM.
- Parameters:
template (Model) – A model supplying the gene set, germline, and event graph (its marginals are replaced). Use one built by
from_olga(or anyModel).sequences (list[str]) – Observed CDR3 nucleotide strings (typically out-of-frame reads).
max_iter (int) – Maximum EM iterations.
tol (float) – Stop when the V-usage total-variation between iterations falls below this.
init (str) –
"align"(seed gene usage from a best-match vote — the default and fastest),"uniform"(each event uniform on its support), or"template"(warm start).masks (list | None) – Optional per-sequence
(v_genes, j_genes, d_genes)name lists (e.g. fromarda_masks()) restricting each read’s scenario enumeration to its aligned genes. Strongly recommended for VDJ — without it the E-step enumerates every Cys-sharing V × the full D grid per read (tens of s/seq); with it, VDJ inference is tractable.single_d (bool) – By default a tandem-D (D-D) model is learned for the D-bearing loci (IGH/TRD/TRB): a single-D template is promoted with
to_dd()(seedingP(n_D=2)=p_nd2_init) and EM learns the trueP(n_D=2). SetTrueto keep a strict single-D model. No effect on VJ loci or an already-tandem template.p_nd2_init (float) – Initial
P(n_D=2)seed when promoting to D-D (ignored ifsingle_d).dd_allowed (list | None) – Optional per-read booleans gating the tandem (
n_D=2) E-step — a read may be tandem only wheredd_allowed[i]is true (e.g. reads arda flags with ad2_call). Anchors D-D learning to alignment-detected tandems, countering the tandem-vs-long-insertion identifiability that inflates unregularizedP(n_D=2)on real data.None= all reads.nd_prior (float) – Dirichlet/Beta pseudocount added to the single-D (
n_D=1) soft count each M-step, regularizingP(n_D=2)toward 0. Both anchors combine.progress – Optional
callable(iteration, loglik, rel_change, n_scoreable)invoked after every iteration — useprint_progress()to watch a long fit converge live.checkpoint – Directory to save the model into after each iteration, so a long fit survives being interrupted. Continue it with
resume(). The checkpoint carries the training log so far, and the resumed run appends to it.checkpoint_every (int) – Write a checkpoint every N iterations (default every one).
- Returns:
(fitted_model, report). For a tandem-D template the E-step enumeratesn_D=2scenarios and the M-step learnsP(n_D=2)along with thed2_gene/d2_del/ddevents.- Return type:
- vdjtools.model.infer.call_alleles(index, call)[source]#
All model alleles compatible with one AIRR gene call, ambiguity included.
Two kinds of ambiguity, and both must widen the mask rather than narrow it:
allele — a call of
TRBV20-1*03where the truth is*01. Expanding to every model allele of the gene keeps the right scenario reachable.comma-separated genes — AIRR writes an aligner’s tie as
IGHV3-23*01,IGHV3-23D*01, which means the aligner could not tell these apart. Splitting on*alone keeps onlyIGHV3-23and silently DROPSIGHV3-23D— a different gene on a duplicated locus. If the truth is the dropped one, its scenario is unreachable and EM misattributes the read. Measured on human IGH: 23,176 of 160,324 non-functional clonotypes (14.5%) carry an ambiguous V call; TRB 2.0%; TRA/TRD 0%.
Returns the union over every gene named, deduplicated and order-stable. Unknown genes contribute nothing; a call naming no known gene yields
[], which the E-step reads as “unrestricted” — the honest degradation, since we know nothing about that read’s gene.
- vdjtools.model.infer.gene_masks(model, v_calls, j_calls)[source]#
Build per-read
(v_genes, j_genes, d_genes)E-step masks from V/J gene calls.Each call is expanded to every model allele of every gene it names — see
call_alleles()for why both allele- and comma-ambiguity must widen the mask. D is left unrestricted (few D genes, and D calls on the short D germline are unreliable).
- vdjtools.model.infer.sanitize_junctions(df, col, *, ambiguous='A', where='infer_frame')[source]#
Make a junction column safe for the native encoder, which knows only A/C/G/T.
Real annotated reads carry the occasional ambiguous base (
N, or an IUPAC code), and an unhandled one surfaces as aKeyErrorfrom deep inside the E-step.- Parameters:
df (DataFrame) – Clonotype frame.
col (str) – Junction column.
ambiguous (str | None) – A single base to substitute for every non-ACGT character (default
"A"), orNoneto drop those clonotypes instead. Substituting is the default because it keeps the clonotype: an ambiguous base is one uncertain position in a junction that is otherwise perfectly good evidence, and on these reads it affects ~0.01% of rows, so dropping costs sample size for no gain in correctness. It is a substitution, not a marginalization — the base is treated as read, so a run with many ambiguous positions will bias the insertion model toward the substituted base and should useNone.where (str) – Caller name, used in the warning.
- Returns:
A frame with the column uppercased and cleaned; rows are dropped only when
ambiguous is None.- Return type:
DataFrame
- vdjtools.model.infer.infer_frame(template, clones, *, seq_col=None, v_col='v_call', j_col='j_call', use_calls=True, native=True, ambiguous='A', **kw)[source]#
Fit a model from a clonotype frame — the ergonomic entry point to EM.
Wraps
infer_native()with the two steps every caller otherwise repeats: find the nucleotide junction column, and turn the frame’s V/J calls into per-read E-step masks withgene_masks(). The masks matter enormously on a D-bearing locus — without them the E-step enumerates every Cys-sharing V against the full D grid for every read.- Parameters:
template – A
Modelsupplying the gene set, germline and event graph, or a locus string (e.g."TRB") to build one withfrom_arda().clones (DataFrame) – Clonotype frame. Needs a nucleotide junction column and, for
use_calls,v_call/j_call.seq_col (str | None) – Explicit junction column; auto-detected from
_JUNCTION_COLSotherwise.v_col (str) – V-call column.
j_col (str) – J-call column.
use_calls (bool) – Build per-read masks from the V/J calls. Turn off only if the frame’s calls are untrustworthy — inference then enumerates every gene and gets much slower.
native (bool) – Use
infer_native()(default).Falseruns the pure-Pythoninfer().ambiguous (str | None) – What to do with a junction holding a non-ACGT base — substitute this base (default
"A"), orNoneto drop the clonotype. Seesanitize_junctions().**kw – Passed through (
max_iter,tol,init,single_d,nd_prior,gene_prior, …).
- Returns:
(fitted_model, report)— the model carries the run in itstraininglog.- Raises:
ValueError – If no junction column is found, or it holds no usable sequences.
Example
>>> m, rep = infer_frame("TRB", clones, max_iter=10) >>> training_frame(m)
- vdjtools.model.infer.arda_masks(contigs, model, *, organism='human')[source]#
Annotate nt contigs with arda and build
(junctions, masks)for maskedinfer().The production path for real reads:
junctions, masks = arda_masks(contigs, template); infer_native(template, junctions, masks=masks). arda is a base dependency (ships with vdjtools).
- vdjtools.model.infer.augment_from_oracle(learned, oracle)[source]#
Fill functional genes the learned model left at P=0 with the ORACLE’s own usage and conditionals.
A learned model carries only the genes arda saw producibly in the training repertoire; a user’s library (different protocol/tissue) can be full of genes 5’RACE or this cohort never amplified, or that arda called but the germline can’t emit (a source mismatch, or a hard-call tie it lost). The OLGA oracle models every functional gene it knows with a real per-gene usage and deletion profile, and differs from the learned model ONLY in D/D-D handling — orthogonal to V/J gene identity — so its V/J genes transplant cleanly. For each functional (scoreable) gene present in the oracle but absent from the learned model, this copies the oracle’s choice mass AND every child table the gene parents (its deletion profile, P(J|V) on a VJ locus, P(D|J) for a J), then renormalizes — so no functional gene is silently missing. Usage is the oracle’s here;
rescale_usage()adapts it to the user’s actual library (its cross-protocol job). Idempotent.
- vdjtools.model.infer.enforce_dj_order(model)[source]#
Zero the genomically impossible
(D, J)pairs in an existing model and renormalize.The repair for a model fitted before this constraint existed — see
forbidden_dj_pairs()for why TRBD2 cannot reach the TRBJ1 cluster. New fits get it from the M-step and need no repair.- Parameters:
- Returns:
A new model with
P(D|J)(andP(D2|D)’s parent table, where applicable) masked and renormalized within each J. Unchanged for a VJ model or a locus with no interleaving.- Return type:
Example
>>> fixed = enforce_dj_order(load_bundled("TRB", "learned", collapse=False))
- vdjtools.model.infer.extend_alleles(model, germline, *, weight=1.0)[source]#
Add alleles from a larger germline library to an existing model, seeded from what it knows.
The use case is a model fitted against one reference meeting a richer one — a newer IMGT release, a population-specific library, your own genotyped alleles. Every new allele needs a germline row, a choice probability and a full set of child conditionals (its deletion profile, and
P(J|V)/P(D|J)where it is a parent), none of which the library supplies.Seeding uses the strongest evidence available for each case:
A new allele of a gene the model already has. Its choice mass is
weight ×the mean mass of that gene’s existing alleles, and its child tables are copied from a gene-mate. A new IMGT allele of a known gene is a polymorphism whose carriers use it about as often as the*01, so the gene’s own level is the right prior.A brand-new gene. Child tables come from the germline-nearest existing allele, and the choice mass is a floor of half the smallest non-zero mass in the model. Plausible shape, deliberately tiny mass — there is no evidence at all for how often it is used.
Deletion rows copied from a donor are clipped to the new allele’s own germline length, so an extension can never introduce the unreachable mass
check_model()flags.Existing alleles are never modified, including their germline: silently swapping the sequence under an allele the model was fitted on would invalidate every conditional that references it. A library that disagrees about an existing allele is reported by
check_model’sgermline_sourcecheck, not fixed here.- Parameters:
model (Model) – The model to extend.
germline (DataFrame) – A germline frame (see
from_germline()for the schema), typically a superset of the model’s own.weight (float) – Scales the seeded mass for new alleles of known genes.
1.0gives a new allele the gene’s average;0.5is a more conservative half of it.
- Returns:
A new, validated and renormalized
Model. Idempotent — extending twice with the same library changes nothing the second time.- Return type:
Note
This seeds, it does not estimate. Follow it with
infer_native(extended, seqs, init="template")to let data set the new probabilities.Example
>>> bigger = extend_alleles(m, load_germline("TRB", "human")) >>> bigger, rep = infer_native(bigger, seqs, init="template", max_iter=5)
- vdjtools.model.infer.infer_native(template, sequences, *, max_iter=30, tol=0.001, init='align', masks=None, single_d=False, p_nd2_init=0.02, dd_allowed=None, nd_prior=0.0, gene_prior=0.0, progress=None, checkpoint=None, checkpoint_every=1)[source]#
EM inference with the native C++ E-step — same result as
infer(), much faster.Requires the compiled
_coreextension. Seeinfer()for the arguments (includingsingle_d/p_nd2_init/dd_allowed/nd_prior/gene_prior).gene_prioris a Dirichlet pseudocount spread over the germline’s functional V/J alleles in each M-step.P(V)=0is an absorbing state of this EM — the E-step weights scenarios by P(V), so a zeroed allele can never be re-attributed — and on real data that silently deletes real genes for good (human TRB: 30 of 57 V genes survived unregularized). The germline says which alleles exist; the prior keeps all of them reachable and lets the data set the usage.0.0(default) is byte-identical to plain MLE. Learns tandem-D (n_D=2) by default on the D-bearing loci: the native E-step accumulates the second-D soft counts via a factorized forward/backward pass, read-parallelized across cores.
Tandem-D (D-D) extension#
Tandem-D (D-D) model extension: upgrade a single-D VDJ model to n_D ∈ {1, 2}.
OLGA (and every bootstrap model) assumes exactly one D per rearrangement. Real IGH and TRD
repertoires contain tandem D-D joins — two D segments in the junction (V-D1-D2-J) — which
no OLGA/IGoR model can represent. to_dd() adds the four events that a tandem rearrangement
needs on top of an existing single-D Model:
n_d—P(n_D)over{1, 2}(the D-count prior;2= a tandem join).d2_gene—P(D2 | D1)(the genomic-order mask lives in this table’s zeros).d2_del—P(delD2_5', delD2_3' | D2)joint trimming of the second D.dd_ins/dd_dinucl— the N-region between the two Ds.
The upgrade is an initialisation: it seeds P(n_D=2) = p_nd2 and copies the single-D
deletion/insertion profiles for the new events. EM on real tandem-D reads then learns the true
values (the enumeration in pgen_nt() already sums the n_D=2 scenarios).
With p_nd2 = 0 the model is generatively identical to its single-D input.
- vdjtools.model.dd.has_tandem(model)[source]#
True if the model places positive probability on a tandem (
n_D=2) rearrangement.Guards the paths that do not yet sum/sample the
n_D=2scenarios (amino-acid Pgen, the native_corePgen/EM, generation, EM inference) so that a tandem model raises rather than silently returning a single-D result. The predicate is positive ``P(n_D=2)`` alone — so ato_dd(..., p_nd2=0)model (generatively single-D,d2_genepresent but unused) correctly returnsFalseand flows through the single-D fast paths byte-identically.
Model diagnostics, checking and scoring#
Entropy, mutual information and model-vs-model comparison live in analyze; the consistency
audit in check; and everything that needs sequences — likelihood, BIC, Pgen distributions and
diversity estimates — in score. See Recombination model workshop for the workflow these fit into.
Information-theoretic diagnostics for a recombination Model.
Turns a model’s declared Bayes net (events) and its marginal tables into
three views used for validation and for the appendix figures:
entropy_table()— per-event Shannon entropy of that part of the rearrangement (bits): the marginal entropyH(X)of the event’s realization and, where the event is conditioned, the expected conditional entropyH(X | parents).mutual_information()— the information each declared edge carries,I(child; parent)=H(child) − H(child | parent)(bits), plusI(V; J)and the within-DI(delD5; delD3).bayes_net_dot()/render_bayes_net()— a graphviz DAG (bnlearn style) with nodes annotated byH(X)and edges byI; rendered to PDF/PNG via thedotCLI.
Everything is read straight from the polars tables, so it works identically on a legacy OLGA bootstrap model and an EM-inferred native model — the two are directly comparable.
- vdjtools.model.analyze.gene_marginal(model, seg)[source]#
Marginal
P(seg)as {allele: prob}, forward-propagated over the Bayes net.V is always a root. VDJ J is a root (
P(J)); VJ J isP(J|V)marginalized over V. D isP(D|J)marginalized over J.
- vdjtools.model.analyze.entropy_table(model)[source]#
Per-event entropy (bits): marginal
H(X)and conditionalH(X | parents).Returns a tidy frame
(event, kind, given, n_states, H_bits, H_cond_bits)— one row per event of the model’s declared graph, in graph order.- Parameters:
model (Model)
- Return type:
DataFrame
- vdjtools.model.analyze.mutual_information(model)[source]#
Mutual information (bits) carried by informative pairs of the model.
One row per declared parent→child edge (
I(child; parent) = H(child) − H(child|parent)), plusI(V; J)(0 by construction for a VDJ model — V, J are independent roots) and the within-D deletion couplingI(delD5; delD3 | D)— for the second D too on a tandem model.- Parameters:
model (Model)
- Return type:
DataFrame
- vdjtools.model.analyze.total_entropy(model)[source]#
Per-event contribution to the scenario entropy
H(recombination event), in bits.For a Bayes net the joint entropy is the sum of each node’s conditional entropy given its parents, so this frame’s
contribution_bitssums to the entropy of one whole recombination scenario — the information content of the process, and the basis of the2^Hdiversity figure invdjtools.model.score.diversity().The insertion regions need care, and this is the only place the accounting is non-obvious.
entropy_table()reports a dinucleotide event’s per-step conditional entropy (a composition summary, independent of how long the N-region is). Its contribution to the scenario is that per-step entropy times the expected number of steps, so the dinucleotide row here isE[length] · H_stepusing its paired*_insevent’s mean length. The first inserted base is treated as drawn from the chain’s stationary distribution rather than the model’s separate first-base bias — the standard decomposition, and worth a fraction of a bit at most.- Parameters:
model (Model) – The model to measure.
- Returns:
event, kind, contribution_bits— one row per event, in graph order.- Return type:
DataFrame
Example
>>> total_entropy(m)["contribution_bits"].sum() # bits per rearrangement
- vdjtools.model.analyze.bayes_net_dot(model, *, title=None)[source]#
Graphviz DOT for the model’s Bayes net: nodes labelled with
H(X), edges withI.
- vdjtools.model.analyze.render_dot(dot, path, *, fmt='pdf')[source]#
Render any DOT source to
pathvia the graphvizdotCLI; returns the output path.- Parameters:
dot (str) – DOT source, e.g. from
bayes_net_dot()orcompare_net_dot().path (str | Path) – Output path; the suffix is replaced with
fmt.fmt (str) – Any format
dotsupports (pdf,png,svg, …).
- Raises:
RuntimeError – If the
dotCLI is not onPATH.- Return type:
- vdjtools.model.analyze.render_bayes_net(model, path, *, fmt='pdf')[source]#
Render
bayes_net_dot()topathvia thedotCLI; returns the output path.
- vdjtools.model.analyze.compare_entropy(models)[source]#
Stack
entropy_table()across models into a wideevent × modelH(X) matrix.
- vdjtools.model.analyze.compare_models(a, b, *, labels=('a', 'b'), by='allele')[source]#
Per-event distance between two models — the parameter-level
compare_networks.The two models’ tables are aligned on the union of their realization keys with zero fill, so a gene one model knows and the other does not contributes to the distance instead of being dropped. For a conditioned event the distance is computed per parent group and averaged weighted by the parent’s marginal (the same weighting the conditional-entropy code uses), so a rarely-used V’s deletion profile cannot dominate the number;
tv_maxreports the worst single group, which is what finds the one broken gene an average hides.- Parameters:
a (Model) – First model.
b (Model) – Second model.
labels (tuple[str, str]) – Names for the two models (used in error messages and the DOT title).
by (str) –
"allele"(default) or"gene". Use"gene"to compare models built on different germline vintages or sources — an OLGA-namespace model against an arda-namespace one only lines up at gene level.
- Returns:
event, kind, given, status, n_groups, support_a, support_b, support_shared, support_only_a, support_only_b, tv, tv_max, jsd_bits.statusisshared,only_a,only_b, orschema_differs(the event exists in both but is factorized differently, e.g.j_choiceisP(J|V)on a VJ locus and a rootP(J)on a VDJ one). Distances are null unless the status isshared.- Return type:
One row per event of the union of both graphs
Note
Jensen-Shannon is the primary metric: it is symmetric, bounded by 1 bit, and finite when the supports differ, which is exactly the case here. KL is deliberately not reported — it is infinite whenever one model assigns zero to something the other does not.
Example
>>> compare_models(load_bundled("TRB", "olga"), load_bundled("TRB", "learned"), by="gene")
- vdjtools.model.analyze.compare_usage(a, b, seg='v', *, by='gene')[source]#
Side-by-side gene (or allele) usage of two models — the protocol-bias view.
- Parameters:
- Returns:
name, p_a, p_b, log2_ratiosorted by descendingp_a, over the union of both models’ genes.log2_ratiois null where either side is zero.- Return type:
DataFrame
- vdjtools.model.analyze.compare_net_dot(a, b, *, labels=('a', 'b'), title=None)[source]#
Graphviz DOT contrasting two models’ Bayes nets — the
compare_networkspicture.One DAG over the union of both graphs. Edges present in both are solid black; an edge only in
ais blue and dashed, only inbred and dotted. Node fill intensity scales with that event’s Jensen-Shannon divergence between the two models, and node labels carryΔH = H_a − H_bso the structural and the quantitative differences are visible at once.
Consistency audit of a Model against its own germline.
validate_tables() answers a narrow question — does every marginal
normalize? — and raises on the first offender. This module answers the wider one a model builder
actually has: is this model internally coherent, and does it agree with the germline it claims
to be built on? It returns a tidy issue frame instead of raising, so every problem in a model is
visible at once and the result is sortable, filterable and writable like any other table.
The checks exist because each one has silently produced a wrong answer at some point:
a functional gene left at
P=0makes Pgen exactly 0 for every clonotype using it, with no error;deletion mass past a germline’s length is unreachable, so its probability is quietly lost;
an allele in a marginal but not in the germline frame crashes the native packer, far from the cause;
a model whose germline drifted from arda’s scores a different sequence than the one you annotated.
Severity is the contract: error means the model is broken (it will crash, or score wrongly),
warn means it is suspicious but usable, info is a note.
- vdjtools.model.check.DELETION_ENDS = {'d2_del': ('d_5', 'd_3'), 'd_del': ('d_5', 'd_3'), 'j_5_del': ('j_5',), 'v_3_del': ('v_3',)}#
Deletion event -> the
palindrome_maxkey(s) bounding its most-negative (palindromic) ndel.
- vdjtools.model.check.max_reachable_trim(event_name, kind, cut_len, palindrome_max)[source]#
Largest trim the Pgen DP can actually reach for a segment of
cut_lennt.The single definition of deletion reachability, shared by the checker, the collapse and the allele extension so the three cannot drift apart. Taken from
pgen._v_options/pgen._d_middlerather than assumed: a trim consumes the palindrome-extendedcut_segmentandndel = len(cut) - contributed - max_palindrome, soV/J:
ndel <= cut_len - Σ palindrome_max - 1— the-1is the invariant that V and J each contribute at least one nt to the CDR3;D:
ndel5 + ndel3 <= cut_len - Σ palindrome_max— a D may legally be deleted away.
Returns
Nonefor an event that is not a deletion, or one whose ends are not declared.
- vdjtools.model.check.check_model(model, *, germline='auto', tol=1e-05, raise_on=None)[source]#
Audit a model’s marginals against its manifest, its germline frames, and a reference library.
- Parameters:
model (Model) – The model to check.
germline (str | DataFrame) – External germline to reconcile against.
"auto"(default) triesload_germline()for the model’s own locus/organism and skips silently if arda has none (a custom library, or a non-arda organism);"none"disables the reconciliation; apl.DataFrameuses that library directly.tol (float) – Absolute tolerance for the “sums to 1 (or 0)” normalization check.
raise_on (str | None) – If
"error"(or"warn"), raiseValueErrorwhen any issue at that severity or worse is found, instead of returning it.
- Returns:
Tidy issue frame
severity, check, event, segment, allele, detail, value— empty when the model is clean.severityis one oferror,warn,info.- Raises:
ValueError – Only when
raise_onis set and a matching issue is present.- Return type:
DataFrame
Example
>>> issues = check_model(load_bundled("TRB", "learned")) >>> issues.filter(pl.col("severity") == "error").height 0
Score sequences under a model: likelihood, BIC, Pgen distributions, entropy and diversity.
Everything here is data-conditioned — it needs sequences, or sequences drawn from the model —
which is what separates it from vdjtools.model.analyze (model-only information theory).
Three questions this answers:
How well does this model explain these sequences?
model_fit()— log-likelihood, AIC, BIC.Do two models score the same repertoire the same way?
compare_pgen()+pgen_summary().How much diversity does this model actually generate?
diversity()— the entropy of the generated sequence distribution and the~10^xeffective-diversity number that follows from it.
Two conventions run through the module and are worth stating once.
Likelihoods use nucleotide Pgen. Σ Pgen_nt over all nt CDR3s is 1, so log Pgen_nt is a
proper log-likelihood and BIC is meaningful. Pgen_aa sums only the in-frame, stop-free
nucleotide fiber of a translation, so Σ Pgen_aa < 1: an amino-acid log-likelihood is
unnormalized, and the missing constant differs between models. Amino-acid scoring is supported
(real clonotype tables are often aa-only) but is a relative score on one fixed sequence set, never
an absolute one.
Pgen = 0 never becomes ``-inf``. A sequence the model cannot generate gets pgen = 0.0 and a
null log-probability, and aggregates are taken over the scoreable subset — the same convention
InferenceReport already uses. Every aggregate reports n beside
n_scoreable so a flattering log-likelihood earned on 10% of the data is visible rather than
hidden.
- vdjtools.model.score.pgen_frame(model, sequences, *, v=None, j=None, kind='auto', use_calls=True, on_unknown='error', threads=0, seq_col=None, v_col=None, j_col=None)[source]#
Generation probability of each sequence under
model, as a tidy frame.- Parameters:
model (Model) – The recombination model to score with.
sequences – A list of junction/CDR3 strings, or a clonotype
pl.DataFrame(the sequence column andv_call/j_callare auto-detected).v – Optional per-sequence V calls, when
sequencesis a list.j – Optional per-sequence J calls, when
sequencesis a list.kind (str) –
"nt","aa", or"auto"(default) to detect from the sequences."auto"requires a homogeneous set.use_calls (bool) – Condition each sequence’s Pgen on its own V/J call.
Falsemarginalizes over all V/J — note this changes the quantity fromP(junction, V, J)toP(junction).on_unknown (str) – What to do with a call that does not resolve to exactly one model allele —
"error"(default) or"marginalize".threads (int) – Worker threads for amino-acid scoring (
0= auto). Nucleotide scoring is serial.seq_col (str | None) – Explicit sequence column, when
sequencesis a frame.v_col (str | None) – Explicit V-call column, when
sequencesis a frame.j_col (str | None) – Explicit J-call column, when
sequencesis a frame.
- Returns:
sequence, v_call, j_call, kind, pgen, log_pgen, log10_pgen, scoreable— one row per input sequence, in input order.log_pgenis null wherepgenis 0.- Raises:
KeyError – If a V/J call does not resolve and
on_unknown="error".ValueError – On a mixed nt/aa set under
kind="auto", or a missing sequence column.
- Return type:
DataFrame
- vdjtools.model.score.free_params(model, *, by_event=False, eps=0.0, tol=1e-09, reachable_only=True)[source]#
Number of free parameters
kin a model — the penalty term of AIC / BIC.k = Σ_events Σ_groups max(support − 1, 0), where a group is one normalization group (normalization_keys(), which already splits a dinucleotide table byfrom_nt, giving the right4 × (4 − 1) = 12), the−1is the simplex constraint, and support is the number of cells carrying probability, not the number of rows.Counting rows instead of support would be badly wrong: deletion bins past a germline’s length, alleles pinned to zero and insertion tail bins are structural zeros that parameterize nothing. On human TRB that is the difference between ~3,600 and ~700 parameters for
v_3_delalone.Two group kinds contribute nothing and are dropped:
undefined conditionals — the all-zero groups the schema explicitly permits, kept only for gene-index alignment;
unreachable conditionals — a group whose parent allele has zero marginal probability (
from_olgafillsP(D|J)uniformly even whereP(J) = 0). These sum to 1 but are not estimable, and counting them inflateskbyn_D − 1for every dead J.
Caveat, and it is a real one: a support-based count cannot tell a structural zero from a parameter EM happened to drive to exactly zero, so
kis a lower bound. BIC is therefore only comparable between models counted the same way — which is the case for any two models compared through this function.- Parameters:
model (Model) – The model to count.
by_event (bool) – Return a per-event breakdown instead of the total.
eps (float) – A cell counts toward the support when
p > eps.tol (float) – A group whose probabilities sum to at most this is treated as an undefined conditional.
reachable_only (bool) – Drop groups whose parent allele has zero marginal probability.
- Returns:
kas anint, or (withby_event) a frameevent, n_groups, k.- Return type:
int | DataFrame
- vdjtools.model.score.model_fit(model, sequences, *, weights=None, k=None, **kw)[source]#
Log-likelihood, AIC and BIC of a sequence set under a model.
- Parameters:
model (Model) – The model to score with.
sequences – Sequences or a clonotype frame — see
pgen_frame().weights – Optional per-sequence weights (e.g.
duplicate_count), or the name of a column whensequencesis a frame. The log-likelihood becomesΣ w·log pandnbecomesΣ w, i.e. exactly as if each clonotype were repeatedwtimes.k (int | None) – Override the free-parameter count; defaults to
free_params().**kw – Passed to
pgen_frame()(kind,use_calls,on_unknown,threads,seq_col,v_col,j_col,v,j).
- Returns:
kind, conditioned, n, n_scoreable, frac_scoreable, loglik_sum, loglik_mean, k, aic, bic.conditionedrecords whether V/J were conditioned on, because that changes the sample space (P(junction, V, J)vsP(junction)) and two fits are only comparable when it matches.- Return type:
A one-row frame
Example
>>> model_fit(load_bundled("TRB", "learned"), held_out_junctions)
- vdjtools.model.score.compare_pgen(a, b, sequences, *, labels=('a', 'b'), **kw)[source]#
Score one sequence set under two models and pair the results up.
- Parameters:
a (Model) – First model.
b (Model) – Second model.
sequences – Sequences or a clonotype frame — see
pgen_frame().labels (tuple[str, str]) – Names for the two models, used in the output column suffixes.
**kw – Passed to
pgen_frame()for both models, so the two are scored identically.
- Returns:
sequence, v_call_<a>, v_call_<b>, j_call_<a>, j_call_<b>, kind, pgen_<a>, pgen_<b>, log10_<a>, log10_<b>, delta_log10— one row per sequence. The two V/J columns differ when the models resolve a gene-level call to different alleles.delta_log10is null unless both models scored the sequence.- Return type:
DataFrame
Example
>>> summary = pgen_summary(compare_pgen(olga, learned, seqs, labels=("olga", "learned")))
- vdjtools.model.score.pgen_summary(cmp, *, labels=('a', 'b'))[source]#
Summarise a
compare_pgen()frame: agreement, offset, and coverage.- Parameters:
cmp (DataFrame) – The output of
compare_pgen().
- Returns:
n, n_scoreable_a, n_scoreable_b, n_scoreable_both, only_a_scoreable, only_b_scoreable, mean_log10_a, mean_log10_b, median_log10_a, median_log10_b, mean_delta, median_delta, sd_delta, q05_delta, q95_delta, pearson_log10, spearman_log10, ks_stat, ks_p.- Return type:
A one-row frame
Note
only_a_scoreable/only_b_scoreableare the headline numbers, not the correlations: one model assigning Pgen 0 to thousands of sequences the other scores fine is the finding, and a mean-delta-only report would hide it entirely. The KS statistic compares the two marginal log10 distributions; the samples are paired, so its p-value is anticonservative — lean on the statistic.
- vdjtools.model.score.pgen_spectrum(model, *, n=10000, seed=0, bins=40, productive_only=False, sequences=None, **kw)[source]#
The model’s Pgen distribution, as a histogram table ready to plot.
- Parameters:
model (Model) – The model whose Pgen spectrum to take.
n (int) – Sequences to generate when
sequencesis not given.seed (int) – Generation seed.
bins (int) – Number of equal-width log10 bins.
productive_only (bool) – Restrict generation to productive rearrangements.
sequences – Score these instead of generating (e.g. a real repertoire), so an observed spectrum can be overlaid on the model’s own.
**kw – Passed to
pgen_frame().
- Returns:
bin_left, bin_right, bin_mid, count, fracoverlog10(Pgen).- Return type:
DataFrame
- vdjtools.model.score.diversity(model, *, n=5000, seed=0, productive_only=False)[source]#
How much diversity this model generates — entropy in bits, and effective diversity.
Two independent readings of “total diversity”, reported side by side because they answer different questions and differ by orders of magnitude:
Scenario entropy
H_scenario— the information in one recombination event, summed over the Bayes net (total_entropy()). This is an upper bound on the sequence entropy, because different scenarios can produce the same junction.Sequence entropy
H_sequence— the entropy of the junction distribution itself, estimated by Monte Carlo. Sequences drawn from the model are distributed asPgen, soE[−log₂ Pgen]over generated sequences is an unbiased estimator ofHand the sample standard error comes free.
From those, two Hill numbers:
diversity_shannon = 2^H_sequence(Hillq = 1) — the classic “effective number of distinct sequences” figure, the one usually quoted as~10^xfor a locus.diversity_simpson = 1 / E[Pgen](Hillq = 2) — the inverse coincidence probability, i.e. how many sequences you would need for two independent draws to collide. Exact, becauseΣ Pgen² = E_{s∼Pgen}[Pgen], so the generated sample estimates it directly. It is always the smaller number: it weights the common sequences more heavily.
- Parameters:
model (Model) – The model to characterise.
n (int) – Sequences to generate. The Shannon estimate converges quickly;
diversity_simpsonis driven by the few highest-Pgen draws and needs more samples for a tight interval — checkpgen_mean_seagainstpgen_mean.seed (int) – Generation seed, so the estimate is reproducible.
productive_only (bool) – Estimate over productive rearrangements only. Off by default: the unrestricted distribution is the one
Pgennormalizes over, so the estimator is unbiased there and merely descriptive here.
- Returns:
n, scenario_entropy_bits, scenario_diversity, sequence_entropy_bits, sequence_entropy_se_bits, diversity_shannon, diversity_simpson, pgen_mean, pgen_mean_se, median_log10_pgen, q05_log10_pgen, q95_log10_pgen.- Return type:
A one-row frame
Example
>>> diversity(load_bundled("TRB", "olga"), n=20_000)
Input/output and schema (vdjtools.io)#
The canonical clonotype frame (AIRR Rearrangement column names + polars dtypes) and every reader that emits it: native vdjtools tables, AIRR Rearrangement TSV, and Parquet, plus format auto-detection, metadata-driven batch loading, and hive-partitioned cohort scans.
vdjtools.io.schema#
Canonical clonotype schema and coercion helpers.
Canonical clonotype-frame schema (AIRR-aligned) and coercion helpers.
The basic-analytics layer speaks a single, flat clonotype frame — one row per clonotype, AIRR Rearrangement column names, polars dtypes. Every reader emits it and every analysis function consumes it. Kept deliberately minimal (free functions, no classes) to mirror the vdjmatch / arda convention.
Columns:
v_call, d_call, j_call, c_call(Utf8, nullable) — IMGT segment calls;c_callis frequently absent in native vdjtools data.junction_aa(Utf8) — the junction amino-acid sequence (conserved anchors Cys104 … Phe/Trp118 INCLUDED), per the AIRRjunction_aaconvention (equivalently the legacy vdjtoolscdr3aa). This is two residues longer than the IMGTcdr3_aa(anchors excluded); readers prefer the junction form.junction_nt(Utf8, nullable) — the junction nucleotide sequence (anchors included), matchingjunction_aaabove. AIRR spells the nucleotide junctionjunction(no_ntsuffix); readers accept that (and legacycdr3_nt) as input aliases.duplicate_count(Int64) — read/UMI count for the clonotype.frequency(Float64) —duplicate_countnormalised within the sample.locus(Utf8, derived) — first three characters ofv_call(TRB,IGH…).
- vdjtools.io.schema.PRODUCTIVE = 'productive'#
OPTIONAL AIRR Rearrangement annotation columns. Not in SCHEMA – they are not required and most bulk pipelines do not emit them – but when a file DOES carry them they are authoritative and
vdjtools.preprocess.filter_productive()reads them in preference to re-deriving the same fact fromjunction_aa. See https://docs.airr-community.org/en/latest/datarep/rearrangements.htmlproductiveis the composite: an open reading frame, no defect in the start codon, splicing sites or regulatory elements, no internal stop codon, and an in-frame junction. The other two are components of it, useful when the composite is absent.
- vdjtools.io.schema.AIRR_FUNCTIONAL_COLUMNS: tuple[str, ...] = ('productive', 'stop_codon', 'vj_in_frame')#
The optional AIRR annotation columns, in the order they are preferred as evidence.
- vdjtools.io.schema.SCHEMA: dict[str, DataType] = {'c_call': String, 'd_call': String, 'duplicate_count': Int64, 'frequency': Float64, 'j_call': String, 'junction_aa': String, 'junction_nt': String, 'v_call': String}#
Canonical columns in canonical order, mapped to their polars dtype.
- vdjtools.io.schema.COLUMNS: list[str] = ['v_call', 'd_call', 'j_call', 'c_call', 'junction_aa', 'junction_nt', 'duplicate_count', 'frequency']#
Column names in canonical order.
- vdjtools.io.schema.column_names(df)[source]#
Column names of an eager or lazy frame.
Uses
polars.LazyFrame.collect_schema()for aLazyFrameso the check does not emit polars’ “resolving schema” performance warning; falls back to.columnsfor an eagerDataFrame.
- vdjtools.io.schema.locus_of(v_call)[source]#
Return the locus (first three characters) of an IMGT V-gene call.
- Parameters:
v_call (str | None) – An IMGT V-gene call such as
"TRBV12-3*01", orNone.- Returns:
The three-letter locus (
"TRB"), orNoneifv_callisNoneor shorter than three characters.- Return type:
str | None
Example
>>> locus_of("TRBV12-3*01") 'TRB'
- vdjtools.io.schema.add_locus(df)[source]#
Add (or overwrite) the derived
locuscolumn fromv_call.- Parameters:
df (DataFrame) – A clonotype frame carrying a
v_callcolumn.- Returns:
The frame with a
locuscolumn (null wherev_callis null).- Return type:
DataFrame
- vdjtools.io.schema.recompute_frequency(df)[source]#
Recompute
frequencyasduplicate_count / sum(duplicate_count).- Parameters:
df (DataFrame) – A clonotype frame with a
duplicate_countcolumn.- Returns:
The frame with
frequencyoverwritten. If the total count is zero the frequency is set to0.0for every row.- Return type:
DataFrame
- vdjtools.io.schema.normalize(df, *, recompute_freq=False, keep=())[source]#
Coerce an arbitrary frame to the canonical clonotype schema.
Missing canonical columns are added as nulls, present ones are cast to their declared dtype (non-strict — unparseable values become null). The result is the canonical columns in canonical order, followed by any
keepcolumns; every other non-canonical column (e.g. native vdjtools markup likeVEnd/DStart) is dropped.- Parameters:
df (DataFrame) – A frame that already uses canonical column names for whatever columns it carries.
recompute_freq (bool) – If
True, recomputefrequencyfromduplicate_countafter coercion (use when the source lacks a trustworthy frequency).keep (tuple[str, ...]) – Non-canonical columns to preserve, e.g.
("v_identity",). Dtypes are left alone — the canonical schema has nothing to say about them.
- Returns:
A frame with the canonical columns, correctly typed and ordered, plus
keep.- Return type:
DataFrame
- vdjtools.io.schema.weight_expr(weight)[source]#
Return the per-clonotype weight expression for an analysis mode.
- Parameters:
weight (str) – One of
"reads"(weight byduplicate_count),"unique"(one per clonotype), or"freq"/"frequency"(weight byfrequency).- Returns:
A polars expression yielding the per-row weight.
- Raises:
ValueError – If
weightis not a recognised mode.- Return type:
Expr
- vdjtools.io.schema.strip_allele(expr)[source]#
Reduce a segment-call expression to gene resolution, ambiguity-safe.
Strips the IMGT allele suffix from every gene an AIRR call names, not just the first. The old
\*.*$regex matched from the FIRST*to end of string, so a comma-ambiguous call likeIGHV3-23*01,IGHV3-23D*01collapsed toIGHV3-23– silently dropping IGHV3-23D, which then reported zero usage across a whole cohort despite being named in tens of thousands of rows. Genes are de-duplicated after stripping, so an allele-level tie within one gene (IGHV1-2*02,IGHV1-2*04) correctly collapses to the single unambiguous geneIGHV1-2, while a genuine cross-gene tie staysIGHV3-23,IGHV3-23D.- Parameters:
expr (Expr) – A polars string expression over segment calls.
- Returns:
Each call reduced to its distinct gene(s), sorted and comma-joined (
TRBV12-3*01→TRBV12-3;A*01,A*02→A;A*01,B*01→A,B); nulls pass through unchanged.- Return type:
Expr
- vdjtools.io.schema.resolve_gene(expr)[source]#
Reduce a segment call to exactly ONE gene: allele stripped, ambiguity resolved to the first.
The companion to
strip_allele(), and the distinction matters:strip_allele()keeps a genuine cross-gene tie asIGHV3-23,IGHV3-23D, because when you are reporting usage you must not invent certainty the aligner did not have.resolve_gene()collapses it toIGHV3-23, because when the gene is a feature axis every distinct ambiguity string otherwise becomes its own category.
That second failure is not hypothetical. Fitting a V+k-mer vocabulary on 200 HIP samples produced 1,296 V “genes”, 1,235 of them comma-strings such as
TRBV1,TRBV23-1,TRBV4-1,TRBV4-2,TRBV4-3. The cost is not the 21x wider axis: it is that the realTRBV9bucket gets drained, since every TRBV9 clone that happened to be called ambiguously was filed elsewhere. Its features then fall below any incidence floor and vanish, so a cohort with clean calls is scored against columns nobody populated.Where the ambiguity comes from matters, because it is not a parsing artifact and will not go away: that cohort is Adaptive/immunoSEQ realigned with MiXCR against IMGT from the junction plus short flanks. The realignment is the better call – MiXCR/IMGT is a sounder reference than Adaptive’s own – and the ambiguity is what honest calling looks like when V genes differ only outside the sequenced window. So first-listed is a resolution, not a correction.
Two things it cannot fix, and which belong to the assay rather than the reference: the window still bounds what is resolvable, and Adaptive’s multiplex V primers distort V usage frequencies. A V-conditioned feature axis fitted on such a cohort inherits both, which makes it a poor donor for a 5’RACE cohort whatever this function does.
First-listed rather than dropped: an ambiguous call still carries a clonotype, and the aligner lists its best call first. Note this takes the first call as written, not
strip_allele(...).list.first()–strip_allelesorts, so composing the two silently returns the alphabetically-first gene instead (TRBV5*01,TRBV19*03->TRBV19, notTRBV5).- Parameters:
expr (Expr) – A polars string expression over segment calls.
- Returns:
One gene per row (
TRBV12-3*01->TRBV12-3;A*01,B*01->A); nulls pass through unchanged.- Return type:
Expr
vdjtools.io.read#
Single-file readers (native vdjtools, AIRR, Parquet).
Readers for the native vdjtools table format and AIRR Rearrangement TSV.
Both readers return the canonical clonotype frame described in vdjtools.io.schema.
Gzip-compressed inputs (.gz) are handled transparently by polars.
- vdjtools.io.read.read_vdjtools(path, n_rows=None, *, keep=(), recompute_frequencies=True)[source]#
Read a native vdjtools clonotype table into the canonical frame.
The native header is
count freq cdr3nt cdr3aa v d j VEnd DStart DEnd JStartfollowed by optional annotation columns;.and empty strings mean missing. Ambiguous V/D/J calls ("TRBV12-4, TRBV12-3") are reduced to the first (highest-confidence) call.c_callis always null (the format has no C gene).frequencyis recomputed exactly fromduplicate_count(the nativefreqcolumn is a rounded copy of the same ratio).- Parameters:
path (str | PathLike) – Path to a
.txtor.txt.gznative vdjtools table.n_rows (int | None) – If given, read at most this many data rows (preview huge files).
keep (tuple[str, ...]) – Annotation columns to preserve past the canonical set (this format’s trailing annotation fields are otherwise dropped). Same contract as the other two readers: absent names are skipped, not raised.
recompute_frequencies (bool)
- Returns:
Canonical clonotype frame with a derived
locuscolumn.- Raises:
ValueError – If the required
countandcdr3aacolumns are absent.- Return type:
DataFrame
- vdjtools.io.read.read_parquet(path, n_rows=None, *, keep=(), recompute_frequencies=True)[source]#
Read a Parquet clonotype table into the canonical frame.
Parquet is the at-scale storage format for repertoire cohorts: typed, columnar, compressed, and — laid out as a hive-partitioned directory — scannable as one
polars.LazyFramewith predicate/projection pushdown (seevdjtools.io.cohort). Columns already using the canonical names (e.g. a file written bypolars.DataFrame.write_parquet()from a canonical frame) are kept as-is; AIRR source names (junction_aa,duplicate_count…) are mapped to canonical only to fill a canonical column that is otherwise absent. Unlike the TSV path this reads native dtypes directly (no all-Utf8 pass), so it is both faster and lower-peak-memory on large files.- Parameters:
path (str | PathLike) – Path to a
.parquet/.pqclonotype table.n_rows (int | None) – If given, read at most this many data rows (preview huge files).
keep (tuple[str, ...]) – Extra source columns to carry through unrenamed, e.g.
("v_identity",). The canonical set is deliberately small, but a feature can need a field outside it – the signature’s SHM block is a weighted mean ofv_identity, which no canonical column holds, so without this it is not merely missing but uncomputable, and ships as a permanently-nan column. Names absent from the file are skipped rather than raising:keepsays what to preserve if present, not what to require.recompute_frequencies (bool)
- Returns:
Canonical clonotype frame with a derived
locuscolumn.- Raises:
ValueError – If no CDR3 amino-acid column (
cdr3_aa/junction_aa) is present.- Return type:
DataFrame
- vdjtools.io.read.read_airr(path, *, collapse=True, n_rows=None, keep=(), recompute_frequencies=True)[source]#
Read an AIRR Rearrangement TSV into the canonical frame.
Prefers the junction columns
junction_aa/junction(conserved anchors INCLUDED — the canonical vdjtools / AIRR convention) over the IMGTcdr3_aa/cdr3columns (anchors excluded), which are used only as a fallback. Known limitation: a file that provides only the IMGTcdr3_aayields sequences two residues shorter than the junction, so downstream lengths / k-mers will differ. The count column may beduplicate_countor a vdjtools-stylecount/reads(AIRR-hybrid exports); absent, it defaults to 1. Per-read files (one row per rearrangement) collapse to unique clonotypes with summed counts; already-aggregated files pass through unchanged.frequencyis always recomputed after collapsing.Clonotype identity for collapsing is
(v_call, j_call, junction_nt, junction_aa)— matching legacyClonotypeequality (V, J, CDR3nt);d_callandc_callare not part of the identity, so a representative (first non-null) value is attached to each collapsed clonotype.- Parameters:
path (str | PathLike) – Path to a
.tsv/.tsv.gzAIRR Rearrangement file.collapse (bool) – If
True(default), sum the count over clonotypes identical on(v_call, j_call, junction_nt, junction_aa).n_rows (int | None) – If given, read at most this many data rows (preview huge files).
keep (tuple[str, ...]) – Extra source columns to carry through unrenamed, e.g.
("v_identity",)for the signature’s SHM block, which no canonical column holds. Undercollapsea numeric kept column is averaged over the clonotype’s reads and anything else takes a representative value. Names absent from the file are skipped rather than raising.recompute_frequencies (bool)
- Returns:
Canonical clonotype frame with a derived
locuscolumn.- Raises:
ValueError – If no CDR3 amino-acid column (
junction_aaorcdr3_aa) is present.- Return type:
DataFrame
vdjtools.io.batch#
Format sniffing and metadata-driven batch / streaming reads.
Format auto-detection and metadata-driven batch reading.
Sits on top of the single-file readers in vdjtools.io.read: sniffs a file’s
format, dispatches to the right reader, and loads a whole cohort described by a
metadata table into one long clonotype frame (or a per-sample dict).
- vdjtools.io.batch.sniff_format(path)[source]#
Detect a clonotype file’s format from its header.
- Parameters:
- Returns:
"parquet"(.parquet/.pqextension),"vidjil"(.vidjil/.jsonor a leading{),"imgt","migec","mitcr","rtcr","mixcr","immunoseq","trust4"(each by its signature header columns),"airr_cell"(AIRR + acell_idbarcode — single-cell, seeread()),"arda"(AIRR + arda’sd2_call),"vdjtools"(native table / MigMap —cdr3aa/count+cdr3nt), or"airr"(AIRR Rearrangement —v_call/junction_aa/junction_nt/cdr3_aa).- Return type:
The detected format string, one of
- Raises:
ValueError – If no known format signature is recognised.
- vdjtools.io.batch.read(path, fmt='auto', n_rows=None, *, keep=(), recompute_frequencies=True)[source]#
Read a clonotype table, auto-detecting the format by default.
- Parameters:
path (str | PathLike) – Path to a native vdjtools, AIRR Rearrangement, or Parquet table, or a third-party tool export (MiXcr, MiGec, MiTCR/tcR, immunoSEQ, IMGT/HighV-QUEST, Vidjil, RTCR, TRUST4, arda) — see
vdjtools.io.convert(.gzok for the text formats).recompute_frequencies (bool) – Derive
frequencyfromduplicate_count(default, and the historical behaviour). PassFalseto use the frequencies as they are in the file – which matters when the source carries a UMI-corrected or already-normalised frequency thatcount/totalwould silently discard. A file with no frequency column derives one either way; there is nothing to preserve. Only thevdjtools,airrandparquetreaders honour it – the legacy converters buildfrequencythemselves.fmt (str) –
"auto"(sniff the header / extension),"vdjtools","airr","parquet", or a legacy format string ("mixcr","migec","mitcr","immunoseq","imgt","vidjil","rtcr","trust4","arda").n_rows (int | None) – If given, read at most this many data rows (preview huge files).
keep (tuple[str, ...]) – Non-canonical columns to preserve if present, e.g.
("v_identity",)for the signature’s SHM block. Honoured by the three native readers; the legacy converters narrow to the canonical schema and ignore it.
- Returns:
Canonical clonotype frame.
- Raises:
ValueError – If
fmtis unknown, auto-detection fails, or the file is a single-cell AIRR table (acell_idcolumn) — those go throughvdjtools.sc.read_airr_cell(), since collapsing them here would drop the cell barcode. Passfmt="airr"to pool one into a bulk repertoire anyway.- Return type:
DataFrame
- vdjtools.io.batch.read_metadata(path)[source]#
Read a sample metadata TSV.
The literal string
"nan"(and empty strings) are treated as null. All columns are read as strings so metadata joins are stable. A leading#on the first column name (some metadata sheets comment out the header line, e.g.#file_name sample_id ...) is stripped.
- vdjtools.io.batch.iter_samples(metadata, base_dir, sample_col='sample_name', file_template='{sample}.tsv.gz', fmt='auto', add_metadata=True)[source]#
Yield
(sample_id, frame)one sample at a time — O(1-sample) RAM.The streaming counterpart to
read_samples(). Because it reads and yields each sample in turn (never accumulating), a caller can reduce-and-discard — per-sample diversity/usage, or sink each sample to a Parquet partition — and so process a 100k-sample cohort whose concatenation would not fit in memory. Each yielded frame carries the canonical columns +locus+ the reservedsample_id/file_name, and (ifadd_metadata) that row’s metadata.- Parameters:
metadata (DataFrame) – Metadata frame (e.g. from
read_metadata()).base_dir (str | PathLike) – Directory holding the per-sample clonotype files.
sample_col (str) – Metadata column carrying the sample name.
file_template (str) – Filename template;
{sample}is substituted with the sample name.fmt (str) – Format for the readers (
"auto"/"vdjtools"/"airr"/"parquet").add_metadata (bool) – If
True, attach the metadata columns to every clonotype row.
- Yields:
(sample_id, frame)tuples in metadata order.
- vdjtools.io.batch.read_samples(metadata, base_dir, sample_col='sample_name', file_template='{sample}.tsv.gz', fmt='auto', add_metadata=True, as_dict=False)[source]#
Read a batch of samples described by a metadata table into one frame.
Eagerly materialises the whole cohort — convenient for tens-to-hundreds of samples, but it holds every sample in RAM at once. For large cohorts (thousands+ of samples) use
iter_samples()to stream, orvdjtools.io.cohort()to persist a hive-partitioned Parquet dataset and scan it lazily.For each metadata row the file
base_dir/file_template.format(sample=<sample>)is read into the canonical schema, tagged with the reserved columnssample_id(thesample_colvalue) andfile_name, and — ifadd_metadata— joined with that row’s metadata columns.- Parameters:
metadata (DataFrame) – Metadata frame (e.g. from
read_metadata()).base_dir (str | PathLike) – Directory holding the per-sample clonotype files.
sample_col (str) – Metadata column carrying the sample name (default
"sample_name").file_template (str) – Filename template;
{sample}is substituted with the sample name (default"{sample}.tsv.gz").fmt (str) – Format for the readers (
"auto"/"vdjtools"/"airr"/"parquet").add_metadata (bool) – If
True, attach the metadata columns to every clonotype row.as_dict (bool) – If
True, return{sample_id: frame}instead of one long frame.
- Returns:
One concatenated long
pl.DataFrame(canonical columns +locus+sample_id+file_name+ metadata), or adict[str, pl.DataFrame]ifas_dict.
- vdjtools.io.batch.map_samples(fn, items, *, fmt='auto', workers=None, keep=())[source]#
Read each sample and apply
fnto it, in parallel, low peak memory.The parallel, streaming replacement for “load the whole cohort, then loop over it”: each worker reads one sample, reduces it via
fn(typically a per-sample summary statistic returning a small frame), and discards the raw clonotype frame — so peak memory isO(workers)samples, not the whole cohort. Reading (gzip TSV decode + polars parse) andfn(polarsgroup_by/ numpy) both release the GIL, so a thread pool runs them genuinely in parallel with no frame pickling.- Parameters:
fn – A callable
pl.DataFrame -> Tapplied to each sample’s canonical clonotype frame (e.g.vdjtools.stats.diversity.diversity_stats()).items – Iterable of
(sample_id, path)pairs (e.g. from a metadata sheet).fmt (str) – Reader format passed to
read()("auto"sniffs each file).workers (int | None) – Max worker threads.
Noneuses the pool default (min(32, os.cpu_count() + 4)); pass a smaller value iffnis compute-bound, to avoid oversubscribing polars’ own thread pool.keep (tuple[str, ...]) – Non-canonical columns to preserve, passed to
read(). Without it a reduction needing a field outside the canonical eight —v_identityfor the SHM block — gets a frame that never carried it, and reports a hole rather than a number.
- Returns:
A list of
(sample_id, fn(frame))in the same order asitems(input/metadata order, regardless of which sample finishes first).
vdjtools.io.cohort#
Hive-partitioned Parquet cohort writer / lazy scanner.
Cohort-scale I/O: a hive-partitioned Parquet dataset scanned as one LazyFrame.
The single-file readers and vdjtools.io.read_samples() materialise a whole
cohort in RAM — fine for tens-to-hundreds of samples, fatal for 100k. This module
provides the at-scale path:
ingest_cohort()streams every sample through the readers one at a time (never holding two at once) and writes each to its own Parquet partition<out_dir>/sample_id=<id>/part.parquet, with the sample metadata sheet stored once as<out_dir>/metadata.parquet(not broadcast into every clonotype row).scan_cohort()opens the whole cohort as a singlepolars.LazyFrame—sample_idrecovered from the partition path, metadata joined lazily — so a cohort far larger than RAM is analysed with one streaminggroup_by:>>> lf = scan_cohort("cohort/") >>> usage = (lf.group_by(["sample_id", "v_call"]) ... .agg(pl.col("duplicate_count").sum()) ... .collect(engine="streaming"))
Every analysis in the package is already group_by(...).agg(...), so the cohort
feature matrix is one streamed pass; only the final (bounded) sample×feature pivot
is materialised.
- vdjtools.io.cohort.SAMPLE_ID = 'sample_id'#
Reserved partition-key column recovered from the
sample_id=<id>path.
- vdjtools.io.cohort.ingest_cohort(metadata, base_dir, out_dir, sample_col='sample_name', file_template='{sample}.tsv.gz', fmt='auto')[source]#
Convert a sample cohort into a hive-partitioned Parquet dataset (streaming).
Reads each sample in turn via
vdjtools.io.iter_samples()(O(1-sample) RAM) and writes the canonical clonotype frame toout_dir/sample_id=<id>/part.parquet. The metadata sheet is written once toout_dir/metadata.parquetwith its sample column renamed tosample_id— it is not broadcast into the clonotype rows. Run this once; thereafter analyse the cohort withscan_cohort().- Parameters:
metadata (DataFrame) – Metadata frame (e.g. from
vdjtools.io.read_metadata()).base_dir (str | PathLike) – Directory holding the per-sample clonotype files.
out_dir (str | PathLike) – Destination directory for the partitioned dataset (created if absent).
sample_col (str) – Metadata column carrying the sample name.
file_template (str) – Filename template;
{sample}is substituted per sample.fmt (str) – Reader format (
"auto"/"vdjtools"/"airr"/"parquet").
- Returns:
The
out_dirPath(feed straight intoscan_cohort()).- Return type:
- vdjtools.io.cohort.scan_cohort(out_dir, *, join_metadata=True)[source]#
Scan a hive-partitioned cohort (from
ingest_cohort()) as one LazyFrame.Nothing is read until
.collect();group_by/filter/ column selection push down into the Parquet scan, so a cohort far larger than memory is reduced with.collect(engine="streaming").sample_idis recovered from the partition path; the metadata columns are joined lazily onsample_id.- Parameters:
out_dir (str | PathLike) – The partitioned dataset directory written by
ingest_cohort().join_metadata (bool) – If
True(default) andmetadata.parquetis present, left-join the per-sample metadata onto the clonotype rows lazily.
- Returns:
A
polars.LazyFrameover the whole cohort (canonical clonotype columns +sample_id+ metadata).- Return type:
LazyFrame
vdjtools.io.convert#
Converters for third-party repertoire formats (MiXcr, MiGec, Adaptive immunoSEQ, IMGT/HighV-QUEST, Vidjil, RTCR) to the canonical clonotype frame.
Converters for legacy repertoire input formats → the canonical clonotype frame.
Reimplements the battle-tested legacy vdjtools format parsers (their exact column mappings,
gene-name normalisation and CDR3 handling were lifted verbatim from the Groovy
com.antigenomics.vdjtools.io.parser classes) so third-party tool output can be read
straight into the canonical AIRR-junction frame of vdjtools.io.schema:
MiXcr (v1/2 and v3/4 header dialects, incl. the C-gene / isotype hit) —
read_mixcr()MiGec —
read_migec()Adaptive immunoSEQ v1 and v2 —
read_immunoseq()IMGT/HighV-QUEST —
read_imgt()Vidjil (
.vidjilJSON) —read_vidjil()RTCR —
read_rtcr()TRUST4 (
*_report.tsv) —read_trust4()arda (AIRR annotation output) —
read_arda()
Every reader returns the canonical frame: V/D/J IMGT calls, junction_nt / junction_aa
(the AIRR junction — conserved Cys104 … Phe/Trp118 anchors included, matching the legacy
vdjtools cdr3nt/cdr3aa), duplicate_count and recomputed frequency. Per-read
formats are collapsed to unique clonotypes with summed counts.
- vdjtools.io.convert.translate(seq)[source]#
Translate a (possibly out-of-frame) CDR3 nt sequence, V→J bidirectionally.
Port of the legacy
CommonUtil.translate: in-frame sequences are a plain codon walk (stop →*); an out-of-frame sequence is padded in the middle with?and translated inward from both ends, leaving the untranslatable middle codon(s) lower-cased (later collapsed to_byto_unified_cdr3aa()).
- vdjtools.io.convert.to_unified_cdr3aa(seq)[source]#
Collapse each run of non-coding markers (lower-case nt /
# ~ _ ?) to a single_.
- vdjtools.io.convert.extract_vdj(field)[source]#
First tie, allele stripped, quotes/space trimmed → an IMGT gene call (or
None).Port of
CommonUtil.extractVDJ:"TRBV12-4,TRBV12-3" → "TRBV12-4","TRBV13*00(401.5)" → "TRBV13". Empty / placeholder →None.
- vdjtools.io.convert.read_mixcr(path, n_rows=None)[source]#
Read a MiXcr
exportClonestable — legacy (v1/2) or current (v3/4) dialect.The two header dialects name every field differently, so both spellings are accepted for each column:
field
legacy (v1/2)
current (v3/4)
count
Clone countcloneCount/readCount/uniqueTagCountMoleculeV/D/J/C hits
All V hits…allVHitsWithScore…CDR3 nt / aa
N. Seq. CDR3…nSeqCDR3/aaSeqCDR3MiXcr’s current field API exposes
-readCount/-readFraction(there is no-cloneCountfield any more), while the default preset still labels the columncloneCount— so a v4 export carries one spelling or the other depending on how it was produced, and both must parse.Count precedence is read-based first (
cloneCount→readCount), with the UMI molecule count (uniqueTagCountMolecule) used only when no read count column is present. On a UMI library the molecule count is the less PCR-biased abundance, so prefer exporting it alone if that is the quantity you want.
- vdjtools.io.convert.read_mitcr(path, n_rows=None)[source]#
Read a MiTCR / tcR (R package) clonotype table — the dot-separated dialect.
Header is
Read.count Read.proportion CDR3.nucleotide.sequence CDR3.amino.acid.sequence V.gene J.gene D.gene V.end J.start D5.end D3.end VD.insertions DJ.insertions Total.insertions. Distinct from MiGEC’sCDR3 nucleotide sequence/V segments(spaces, not dots), so it needs its own picks.D.genemay carry an ambiguous call ("TRBD1, TRBD2");extract_vdj()keeps the first.- Parameters:
- Returns:
Canonical clonotype frame.
- Raises:
ValueError – If the signature columns are absent.
- Return type:
DataFrame
- vdjtools.io.convert.read_rtcr(path, n_rows=None)[source]#
Read an RTCR clonotype table (junction aa is re-translated from the nt junction).
- vdjtools.io.convert.read_imgt(path, n_rows=None)[source]#
Read an IMGT/HighV-QUEST
1_Summarytable (per-read → collapsed clonotypes).
- vdjtools.io.convert.read_immunoseq(path, n_rows=None)[source]#
Read an Adaptive immunoSEQ export (v1 or v2 header dialect, auto-detected).
The Adaptive nomenclature (
TCRBV29-01) is converted to IMGT (TRBV29-1) with the gene→family→family-ties fallback; the CDR3/junction nt is sliced out of the fullrearrangement/nucleotideread via the vIndex + cdr3Length coordinates.
- vdjtools.io.convert.read_vidjil(path, sample_id=0)[source]#
Read a Vidjil
.vidjilJSON file.Uses the anchor-inclusive
seg.junction(neverseg.cdr3, which excludes the anchors); the junction nt is sliced from the clone’s fullsequenceby the 1-basedjunction.start/junction.stopinterval.sample_idselects thereadscount for multi-sample files.
- vdjtools.io.convert.read_trust4(path, n_rows=None)[source]#
Read a TRUST4 clonotype report (
*_report.tsv).The TRUST4 report header is
#count frequency CDR3nt CDR3aa V D J C cid cid_full_length. TRUST4’s CDR3 spans the conserved Cys104 … Phe/Trp118 anchors (anchors included), i.e. it is the AIRR junction, soCDR3nt/CDR3aamap straight tojunction_nt/junction_aa(the_stop /?ambiguous-N markers are collapsed byto_unified_cdr3aa()).V/D/J/Ckeep the first allele’s gene (*→ missing); the C column is the BCR isotype (or the TCR constant gene) when the constant region was captured. Rows whose CDR3 nt is not cleanACGT(TRUST4partial/out_of_frame/ N-containing) are dropped.
- vdjtools.io.convert.read_arda(path, n_rows=None)[source]#
Read arda’s AIRR annotation output (per-sequence
*.airr.tsvorclones.tsv).arda (AIRR annotation + markup repair) writes standard AIRR Rearrangement column names, so this delegates to
read_airr()— which mapsv_call/d_call/j_call/c_callandjunction/junction_aaand collapses reads to clonotypes — then nulls the literal""arda emits for an empty gene call. arda’s extra columns (d2_call,c_class,mmseqs2_*) are ignored.
Repertoire statistics (vdjtools.stats)#
Diversity estimators (observed, Chao1/ChaoE, Shannon/Simpson, d50, Efron-Thisted), iNEXT Hill-number rarefaction/extrapolation, similarity-aware (functional) diversity, spectratype, and V/J/VJ segment usage.
vdjtools.stats.diversity#
Point-estimate diversity indices.
Diversity estimators on a clonotype count vector.
Every estimator takes the per-clonotype duplicate_count vector of a single
clonotype frame (one row per clonotype). Definitions follow legacy vdjtools
(com.antigenomics.vdjtools.diversity); each deviation is noted in its docstring.
Notation: n = total reads, Sobs = observed richness (# clonotypes),
f_i = count_i / n, F1 / F2 = number of singleton / doubleton clonotypes.
- vdjtools.stats.diversity.observed_richness(counts)[source]#
Observed species richness
Sobs— the number of clonotypes.- Parameters:
counts (ndarray) – Per-clonotype count vector.
- Returns:
Number of clonotypes.
- Return type:
- vdjtools.stats.diversity.chao1(counts)[source]#
Chao1 lower-bound richness estimate.
Chao1 = Sobs + F1*(F1-1) / (2*(F2+1))(bias-corrected form, legacy default).- Parameters:
counts (ndarray) – Per-clonotype count vector.
- Returns:
The Chao1 richness estimate.
- Return type:
- vdjtools.stats.diversity.chao_e(counts, extrapolate_to=None)[source]#
Chao extrapolated richness at a larger sampling depth.
Follows legacy
ChaoEstimator.chaoE:S = Sobs + F0 * [1 - (1 - F1/(n*F0))^(m* )]whereF0 = F1*(F1-1)/(2*(F2+1))andm* = extrapolate_to - n.- Parameters:
counts (ndarray) – Per-clonotype count vector.
extrapolate_to (int | None) – Target read depth (
>= n). Defaults to2*n— there is no cohort here to borrow “largest sample” from as legacy does, so the sample is extrapolated to double its size.
- Returns:
The extrapolated richness. Falls back to
Sobswhen extrapolation is undefined (F0 == 0, i.e. too few singletons/doubletons).- Raises:
ValueError – If
extrapolate_to < n.- Return type:
- vdjtools.stats.diversity.efron_thisted(counts, max_depth=20, cv_threshold=0.05)[source]#
Efron–Thisted total-diversity lower-bound estimate.
Direct port of legacy
ExactEstimator.getEfronThisted: increases the truncation depth (up tomax_depth) until the coefficient of variationD/Sreachescv_threshold, accumulating alternating binomial coefficients over the count-frequency spectrumf_x(number of clonotypes seen exactlyxtimes). The CV stopping rule is the only stopping rule — legacy has no “stop at the max observed count” cap.
- vdjtools.stats.diversity.shannon_wiener(counts)[source]#
Shannon–Wiener diversity
exp(H)(effective number of clonotypes).Matches legacy
shannonWienerIndex=exp(-Σ f_i ln f_i)(the Hill number of order 1), i.e. the exponential of Shannon entropy rather than the entropy itself.- Parameters:
counts (ndarray) – Per-clonotype count vector.
- Returns:
exp(H).- Return type:
- vdjtools.stats.diversity.normalized_shannon_wiener(counts)[source]#
Normalised Shannon–Wiener index
H / ln(Sobs)(Pielou evenness).Matches legacy
normalizedShannonWienerIndex. This normalises the entropyH(notexp(H)) byln(Sobs), so it lies in[0, 1].- Parameters:
counts (ndarray) – Per-clonotype count vector.
- Returns:
H / ln(Sobs);0.0whenSobs <= 1(no diversity to normalise).- Return type:
- vdjtools.stats.diversity.inverse_simpson(counts)[source]#
Inverse Simpson index
1 / Σ f_i^2(Hill number of order 2).- Parameters:
counts (ndarray) – Per-clonotype count vector.
- Returns:
The inverse Simpson index.
- Return type:
- vdjtools.stats.diversity.d50(counts, fraction=0.5)[source]#
D50 — the legacy
getDxxIndexdominance index1 - k/Sobs.Ranks clonotypes by descending count and finds the minimum number
kwhose cumulative read fraction reachesfraction; returns1 - k/Sobs— the fraction of clonotypes not needed to coverfractionof the reads. This is the exact legacyExactEstimator.getDxxIndexdefinition (1.0 - div / frequencyTable.diversity).
- vdjtools.stats.diversity.diversity_stats(df, extrapolate_to=None)[source]#
Compute all diversity estimators for a clonotype frame as a one-row frame.
- Parameters:
- Returns:
A single-row
pl.DataFramewith columnsreads, observed_diversity, chao1, chaoE, efron_thisted, shannon_wiener, normalized_shannon_wiener, inverse_simpson, d50.- Return type:
DataFrame
- vdjtools.stats.diversity.diversity_cohort(cohort, extrapolate_to=None, *, sample_col='sample_id')[source]#
Per-sample
diversity_stats()for a whole cohort, low peak memory + exact.Collapses the cohort to each sample’s count-frequency spectrum in one streamed pass —
group_by([sample_id, duplicate_count]).agg(len)— which is bounded by the number of distinct counts per sample (tiny), never the number of clonotypes. Each sample’s count vector is then reconstructed from its spectrum and fed to the exact per-sample estimators, so every value is bit-identical to runningdiversity_stats()on the full sample (including Efron–Thisted / chaoE / d50, which need the spectrum/sorted form). Peak memory isO(Σ distinct counts), so a cohort far larger than RAM is handled with onecollect().- Parameters:
cohort – A clonotype cohort with a
sample_idcolumn — a lazyvdjtools.io.scan_cohort()frame (streamed) or an eagerpl.DataFrame.extrapolate_to (int | None) – Target depth for
chaoE(seediversity_stats()).sample_col (str) – The per-sample id column (default
"sample_id").
- Returns:
sample_idfollowed by thediversity_stats()columns, in first-appearance order ofsample_id.- Return type:
One row per sample
vdjtools.stats.functional#
Leinster-Cobbold similarity-aware diversity profile + Rao’s quadratic entropy.
Similarity-aware (functional) diversity of a single repertoire.
Classical diversity treats every clonotype as equally distinct: two repertoires of 1000 clones score the same whether those clones are near-identical variants of one motif or 1000 unrelated rearrangements. That is the wrong null for TCRs, where convergent recombination and clonal expansion both manufacture near-neighbours.
Leinster & Cobbold (2012, Ecology 93(3):477, doi:10.1890/10-2402.1) fix this by folding a
similarity matrix Z into the Hill numbers. With p the clonotype abundance vector and
(Zp)_i = Σ_j Z_ij p_j the ordinariness of clonotype i (how well-represented its
neighbourhood is):
ᑫD^Z(p) = ( Σ_i p_i (Zp)_i^(q-1) )^(1/(1-q)) q ≠ 1 ¹D^Z(p) = exp( − Σ_i p_i ln (Zp)_i ) the q→1 limit Rao’s Q = Σ_ij p_i p_j (1 − Z_ij) = 1 − pᵀZp expected dissimilarity of two draws
Z = I (nothing resembles anything but itself) recovers the plain Hill numbers exactly —
richness, exp(Shannon), inverse Simpson at q = 0, 1, 2. That identity is the defining special
case and is what test_functional.py pins against vdjtools.stats.inext.
This is the single-community counterpart of vdjtools.overlap.similarity_overlap(), which
applies the same kernel as a two-sample bilinear form pᵀZq. Both take Z from seqtree,
so the kernel is defined in exactly one place.
q orders the profile by how much it cares about rare clonotypes: q=0 counts them fully
(and is the most sensitive to sequencing depth), q=2 is dominated by the expanded ones. Read
the profile, not a single number — that is the whole point of a Hill profile.
- vdjtools.stats.functional.functional_diversity(df, *, q=(0, 1, 2), key=('junction_aa',), kernel='exp', tau=None, matrix=None, max_penalty=None, gap_prior='central', gap_open=None, dense=None, weight='freq', threads=0)[source]#
Leinster-Cobbold similarity-aware diversity profile of one repertoire.
- Parameters:
df (DataFrame) – Clonotype frame (canonical schema), one row per clonotype.
q – Diversity orders to report.
q=0weights rare clonotypes fully,q=1is the Shannon-equivalent,q=2is dominated by expanded clones. Non-integerqworks.key (tuple[str, ...]) – Columns forming the clonotype identity; must include
junction_aa.kernel (str) –
"exp"(Zᵢⱼ = exp(−Pᵢⱼ/τ)),"step"(1[P ≤ max_penalty]), or"identity"(Z = I— recovers the plain Hill numbers).tau (float | None) – Kernel bandwidth for
"exp".matrix – Substitution matrix (default BLOSUM62, via seqtree).
max_penalty (int | None) – Alignment-penalty cutoff; the
"step"kernel threshold.gap_prior – Gap-placement prior passed to seqtree.
gap_open (int | None) – Gap-open penalty passed to seqtree.
dense (bool | None) – Force the dense (
True) or sparse (False) kernel path.weight (str) –
"freq"(relative abundance) or"presence"(uniform — the unweighted, incidence-style profile).threads (int) – Worker threads for the seqtree kernel build (
0= engine default).
- Returns:
One row per requested
qwith columnsq,diversity(ᑫD^Z) andrao(Rao’s quadratic entropy — constant acrossq, carried for convenience).- Raises:
ImportError – If seqtree (or, for the sparse path, scipy) is missing.
ValueError – On an unknown
kernel/weight/gap_prior, or akeywithoutjunction_aa.
- Return type:
DataFrame
Example
>>> functional_diversity(sample, q=(0, 1, 2))["diversity"].to_list() [812.4, 402.1, 233.7] >>> # Z = I is the sanity anchor: plain Hill numbers. >>> functional_diversity(sample, q=(0,), kernel="identity")["diversity"][0] 4000.0
vdjtools.stats.rarefaction#
Rarefaction / extrapolation curves.
Rarefaction / extrapolation of repertoire diversity — the iNEXT framework.
rarefaction() is the single canonical entry point for
rarefaction/extrapolation (R/E) of clonotype diversity. Its default
(q=0, base="size") is the vdjtools-original richness R/E curve — the
classic clonotype-accumulation curve, computed by the validated iNEXT q = 0
estimator. The same call also gives the full Hill-number profile (q) and
coverage-based R/E (base="coverage").
The estimators themselves live in vdjtools.stats.inext (transcribed from
the iNEXT R package and numerically validated against it). inext() and
inext_coverage() are kept as aliases of the size- and coverage-based
engines for discoverability; rarefaction() dispatches to them.
- vdjtools.stats.rarefaction.rarefaction(data, q=0, base='size', *, sizes=None, coverages=None, endpoint=None, knots=40, se=True, nboot=50, conf=0.95, seed=0)[source]#
Rarefaction/extrapolation of Hill-number diversity (canonical entry point).
Single entry point for repertoire R/E, dispatching to the size- or coverage-based iNEXT engine (Chao et al. 2014; Hsieh et al. 2016). The default
q=0, base="size"is the vdjtools-original richness rarefaction / extrapolation curve — the classic clonotype-accumulation curve, computed by the validated iNEXTq = 0estimator. Pass a tuple of orders for the full Hill-number profile, orbase="coverage"for coverage-based R/E.- Parameters:
data – A 1-D count vector (list/
np.ndarray/pl.Series) of clonotype abundances, or a clonotypepl.DataFrame(duplicate_countused).q – Hill order(s) — a scalar (default
0= richness, the vdjtools-original curve) or a tuple such as(0, 1, 2)for the richness/Shannon/Simpson profile.base –
"size"for size-based R/E (usessizes/endpoint/knots) or"coverage"for coverage-based R/E (usescoverages/knots).sizes – Explicit sampling depths for
base="size"; seeinext().coverages – Explicit target coverages for
base="coverage"; seeinext_coverage().endpoint – Maximum depth for the default size grid (
base="size").knots – Number of grid points when
sizes/coveragesareNone.se – If
True, compute bootstrap confidence intervals.nboot – Number of bootstrap replicates.
conf – Confidence level for the intervals.
seed – Seed for the bootstrap RNG.
- Returns:
columns
order_q,m,method,sample_coverage,qD,qD_lo,qD_hi. Forbase="coverage"(seeinext_coverage()): columnsorder_q,sample_coverage,m,method,qD,qD_lo,qD_hi.- Return type:
A tidy
pl.DataFrame. Forbase="size"(seeinext())- Raises:
ValueError – If
baseis not"size"or"coverage".
vdjtools.stats.inext#
iNEXT-style Hill-number rarefaction and extrapolation.
iNEXT interpolation/extrapolation of Hill-number diversity for a repertoire.
Size- and coverage-based rarefaction/extrapolation (R/E) plus asymptotic
diversity estimators for Hill numbers of order q (0 = richness, 1 = Shannon,
2 = Simpson). The estimators are transcribed from the iNEXT R package
(v3.0.2 internals TD.m.est, Chat.Ind, Diversity_profile,
EstiBootComm.Ind, invChat.Ind) and the underlying papers, and are
numerically validated against that package in tests/python/test_inext.py.
For extrapolation of orders q != 2 this uses iNEXT’s “beta” method
(qD(n+m*) = D_obs + (D_asy - D_obs) * (1 - (1-beta)^m*)), which matches the R
package rather than Eq. 10c of Chao et al. (2014). Order q = 2 uses the exact
closed form for both interpolation and extrapolation. Only non-negative integer
orders are supported (iNEXT’s default profile is q = (0, 1, 2)).
References
Chao, A., Gotelli, N. J., Hsieh, T. C., Sander, E. L., Ma, K. H., Colwell, R. K., & Ellison, A. M. (2014). Rarefaction and extrapolation with Hill numbers: a framework for sampling and estimation in species diversity studies. Ecological Monographs, 84(1), 45-67. doi:10.1890/13-0133.1
Hsieh, T. C., Ma, K. H., & Chao, A. (2016). iNEXT: an R package for rarefaction and extrapolation of species diversity (Hill numbers). Methods in Ecology and Evolution, 7(12), 1451-1456. doi:10.1111/2041-210X.12613
- vdjtools.stats.inext.inext(data, q=(0, 1, 2), *, sizes=None, endpoint=None, knots=40, se=True, nboot=50, conf=0.95, seed=0)[source]#
Size-based rarefaction/extrapolation of Hill-number diversity.
This is the size-based engine; the canonical entry point is
vdjtools.stats.rarefaction()withbase="size"—inextis kept as an alias for discoverability (people search for “iNEXT”).Interpolates (
m < n) and extrapolates (m > n) the Hill number of each order inqacross a grid of sampling depths, following Chao et al. (2014) and theiNEXTR package (Hsieh et al. 2016). Point estimates are computed on the original data; confidence bands come from an augmented-assemblage bootstrap.- Parameters:
data – A 1-D count vector (list/
np.ndarray/pl.Series) of clonotype abundances, or a clonotypepl.DataFrame(duplicate_countused).q – Hill orders (non-negative integers); default
(0, 1, 2).sizes – Explicit sampling depths. If
None,knotsdepths from 1 toendpointare used, always including the observed depthn.endpoint – Maximum depth when
sizesisNone. Defaults to2*n.knots – Number of depths when
sizesisNone.se – If
True, compute bootstrap confidence intervals.nboot – Number of bootstrap replicates.
conf – Confidence level for the intervals.
seed – Seed for the bootstrap RNG.
- Returns:
A tidy
pl.DataFramewith columnsorder_q(Int64),m(Int64),method(rarefaction|observed|extrapolation),sample_coverage,qD,qD_lo,qD_hi(the CI columns are null whense=False).- Return type:
DataFrame
- vdjtools.stats.inext.inext_coverage(data, q=(0, 1, 2), *, coverages=None, knots=40, se=True, nboot=50, conf=0.95, seed=0)[source]#
Coverage-based rarefaction/extrapolation of Hill-number diversity.
This is the coverage-based engine; the canonical entry point is
vdjtools.stats.rarefaction()withbase="coverage"—inext_coverageis kept as an alias for discoverability (people search for “iNEXT”).Each target sample coverage is inverted to a sampling depth
m(iNEXTinvChat.Ind) and the Hill number is then evaluated at that depth.- Parameters:
data – Count vector or clonotype
pl.DataFrame(seeinext()).q – Hill orders (non-negative integers); default
(0, 1, 2).coverages – Explicit target coverages in
(0, 1). IfNone, a grid of the coverages attained by the default size grid (1..``2*n``) is used.knots – Number of coverages when
coveragesisNone.se – If
True, compute bootstrap confidence intervals.nboot – Number of bootstrap replicates.
conf – Confidence level.
seed – Seed for the bootstrap RNG.
- Returns:
A tidy
pl.DataFramewith columnsorder_q,sample_coverage,m(Float64),method,qD,qD_lo,qD_hi.- Return type:
DataFrame
- vdjtools.stats.inext.asymptotic_diversity(data, q=(0, 1, 2), *, se=True, nboot=50, conf=0.95, seed=0)[source]#
Asymptotic (estimated true) Hill-number diversity of each order.
Uses Chao1 (
q = 0), the Chao-Wang-Jost Shannon Hill number (q = 1) and the MVUE Simpson Hill number (q = 2); see_asymptotic().- Parameters:
data – Count vector or clonotype
pl.DataFrame(seeinext()).q – Hill orders (non-negative integers); default
(0, 1, 2).se – If
True, compute bootstrap standard errors and intervals.nboot – Number of bootstrap replicates.
conf – Confidence level.
seed – Seed for the bootstrap RNG.
- Returns:
A
pl.DataFramewith columnsorder_q,observed(plug-in),estimator(asymptotic),se,lo,hi.- Return type:
DataFrame
- vdjtools.stats.inext.sample_coverage(data, m=None)[source]#
Estimated sample coverage
Ĉ(Chao et al. 2014).- Parameters:
data – Count vector or clonotype
pl.DataFrame(seeinext()).m – A single depth, an iterable of depths, or
None(the observed depthn).
- Returns:
Ĉ(n)(float) whenmisNone,Ĉ(m)(float) for a scalarm, or apl.DataFramewith columnsmandsample_coveragefor an iterablem.
- vdjtools.stats.inext.coverage(data, m=None)#
Clean top-level name for the sample-coverage curve Ĉ(n)/Ĉ(m).
- vdjtools.stats.inext.estimate_d(data, base='size', level=None, q=(0, 1, 2), *, se=True, nboot=50, conf=0.95, seed=0)[source]#
Diversity at a target sample size or coverage (
iNEXTestimateDanalog).- Parameters:
data – Count vector or clonotype
pl.DataFrame(seeinext()).base –
"size"to fix a sampling depth,"coverage"to fix a coverage.level – The depth (
base="size"; defaults to the observed depthn) or the coverage (base="coverage"; required, in(0, 1)).q – Hill orders (non-negative integers); default
(0, 1, 2).se – If
True, compute bootstrap confidence intervals.nboot – Number of bootstrap replicates.
conf – Confidence level.
seed – Seed for the bootstrap RNG.
- Returns:
A
pl.DataFramewith columnsorder_q,m(Float64),method,sample_coverage,qD,qD_lo,qD_hi.- Return type:
DataFrame
- vdjtools.stats.inext.inext_batch(samples, q=(0, 1, 2), *, sizes=None, endpoint=None, knots=40, se=True, nboot=50, conf=0.95, seed=0, threads=0)[source]#
Size-based R/E of Hill-number diversity for many samples at once.
Computes the point curve and (optionally) bootstrap confidence intervals for every sample, parallelizing the per-sample work across a native thread pool (
_core.inext_batch, GIL released). This is the “cohort of many repertoires, quickly” entry point; a single sample is better served byinext().- Parameters:
samples – A list of clonotype count vectors, or a clonotype
pl.DataFramewith asample_idcolumn (grouped by it, weighted byduplicate_count).q – Hill orders (non-negative integers); default
(0, 1, 2).sizes – Explicit sampling depths applied to every sample. If
None, each sample getsknotsdepths from 1 toendpoint(its own default), always including its observed depthn.endpoint – Maximum depth for the default per-sample grid. Defaults to
2*n.knots – Number of depths when
sizesisNone.se – If
True, compute bootstrap confidence intervals.nboot – Number of bootstrap replicates per sample.
conf – Confidence level for the intervals.
seed – Base RNG seed; sample
iis seededseed + i.threads – Worker threads (0 =
hardware_concurrency), capped at the number of samples.
- Returns:
columns
sample,order_q,m,method(rarefaction|observed|extrapolation),sample_coverage,qD,qD_lo,qD_hi(the CI columns are null whense=False).- Return type:
A tidy long
pl.DataFramewith one block per sample- Raises:
ValueError – If a
pl.DataFrameinput lacks asample_idcolumn.
- vdjtools.stats.inext.rarefaction_batch(samples, q=(0, 1, 2), *, sizes=None, endpoint=None, knots=40, se=True, nboot=50, conf=0.95, seed=0, threads=0)#
Alias of
inext_batch()under the canonical rarefaction name.- Return type:
DataFrame
vdjtools.stats.spectratype#
CDR3 length spectratype (nt / aa, weighted / unweighted).
CDR3-length spectratypes (long-format polars).
The spectratype is the weighted distribution of CDR3 lengths. vj_spectratype
additionally breaks the distribution down by V-J pairing (legacy SpectratypeV
is the V-only special case, obtained by ignoring the j_call column).
- vdjtools.stats.spectratype.spectratype(df, kind='aa', weight='reads', by_locus=True, by=())[source]#
CDR3-length distribution.
- Parameters:
df – A clonotype frame (eager
pl.DataFrameor a lazypl.LazyFrame— e.g. a whole cohort fromvdjtools.io.scan_cohort()); the result mirrors the input’s laziness.kind (str) –
"aa"(length ofjunction_aa) or"nt"(length ofjunction_nt).weight (str) –
"reads","unique", or"freq".by_locus (bool) – If
True, break the distribution down per locus.by – Extra column(s) to prepend to the group key (e.g.
["sample_id"]to compute the whole cohort in one grouped pass). Empty by default — output is byte-identical to the per-sample spectratype.
- Returns:
Long-format frame with
length,weight, thebycolumns and (ifby_locus)locus, sorted by the group key; apl.LazyFramewhendfis lazy.
- vdjtools.stats.spectratype.vj_spectratype(df, kind='aa', weight='reads', by_locus=True, keep_allele=False, by=())[source]#
CDR3-length distribution broken down by V-J pairing.
- Parameters:
df – A clonotype frame (eager or lazy; see
spectratype()).kind (str) –
"aa"or"nt"(seespectratype()).weight (str) –
"reads","unique", or"freq".by_locus (bool) – If
True, break the distribution down per locus.keep_allele (bool) – If
True, keep allele suffixes; otherwise collapse V/J to gene level (default).by – Extra column(s) prepended to the group key (e.g.
["sample_id"]for a one-pass cohort profile).
- Returns:
Long-format frame with
v_call,j_call,length,weight, thebycolumns and (ifby_locus)locus(lazy whendfis lazy). A V-only spectratype is obtained by summingweightoverj_call.
vdjtools.stats.usage#
V / J / VJ segment-usage vectors and matrices.
V / D / J / C segment usage and V-J pairing profiles (long-format polars).
Usage is the summed weight per segment: reads (duplicate_count) or unique
clonotypes. Allele suffixes (*01) are stripped to gene level by default.
Results are long-format and normalisable to fractions by dividing within a locus.
- vdjtools.stats.usage.segment_usage(df, segment, weight='reads', by_locus=True, keep_allele=False, by=())[source]#
Segment usage profile for one of the V, D, J, or C genes.
- Parameters:
df – A clonotype frame (eager
pl.DataFrameor a lazypl.LazyFrame— e.g. a whole cohort fromvdjtools.io.scan_cohort()); the result mirrors the input’s laziness.segment (str) – One of
"v","d","j","c".weight (str) –
"reads"(sumduplicate_count),"unique"(count clonotypes), or"freq"(sumfrequency).by_locus (bool) – If
True, break the profile down per locus.keep_allele (bool) – If
True, keep the IMGT allele suffix; otherwise collapse to gene level (default).by – Extra column(s) to prepend to the group key (e.g.
["sample_id"]to compute the whole cohort in one grouped pass over aLazyFrame). Empty by default — output is byte-identical to the per-sample profile.
- Returns:
Long-format frame with the segment call column (
v_call…), aweightcolumn, thebycolumns, and (ifby_locus) alocuscolumn; apl.LazyFramewhendfis lazy. Rows with a null segment call are dropped, so an all-null C gene yields an empty frame.- Raises:
ValueError – If
segmentis not one of v/d/j/c.
- vdjtools.stats.usage.vj_usage(df, weight='reads', by_locus=True, keep_allele=False, by=())[source]#
V-J pairing usage profile.
- Parameters:
df – A clonotype frame (eager or lazy; see
segment_usage()).weight (str) –
"reads","unique", or"freq"(seesegment_usage()).by_locus (bool) – If
True, break the profile down per locus.keep_allele (bool) – If
True, keep allele suffixes; otherwise collapse to gene level (default).by – Extra column(s) prepended to the group key (e.g.
["sample_id"]for a one-pass cohort profile).
- Returns:
Long-format frame with
v_call,j_call,weight, thebycolumns and (ifby_locus)locus(lazy whendfis lazy). Rows with a null V or J call are dropped.
CDR3 features (vdjtools.features)#
Per-clonotype and sample-level CDR3 sequence features: amino-acid physicochemical region profiles and k-mer / V+k-mer summaries.
vdjtools.features.physchem#
Physicochemical CDR3 region profiles (Kidera factors, charge, hydropathy, …).
CDR3 physicochemical-property profiles from the legacy amino-acid property table.
For each clonotype the mean of a property over the residues of a chosen CDR3 region is computed, then averaged (weighted by reads/frequency, or unweighted) within a group (e.g. per V-J pairing, or per locus).
Region definitions (over junction_aa, length L):
all— the entire CDR3.trimmed—junction_aa[3:-3](conserved-anchor-trimmed core); clonotypes withL <= 6have an empty core and are skipped for this region.center— the middle five residuesjunction_aa[L//2-2 : L//2+3]; clonotypes withL < 5are skipped for this region.
- vdjtools.features.physchem.DEFAULT_PROPERTIES = ('hydropathy', 'charge', 'polarity', 'volume', 'strength', 'kf1', 'kf2', 'kf3', 'kf4', 'kf5', 'kf6', 'kf7', 'kf8', 'kf9', 'kf10')#
Kidera-factor-free physicochemistry + the 10 Kidera factors.
- Type:
Default property subset
- vdjtools.features.physchem.load_property_table()[source]#
Load the legacy amino-acid property table (cached).
The shipped
resources/aa_property_table.txtuses classic-Mac\rline endings and a leading##reference comment; both are handled here.- Returns:
A
pl.DataFramewith anamino_acidcolumn and one column per property (all property columns cast toFloat64).- Return type:
DataFrame
- vdjtools.features.physchem.physchem_profile(df, group_by=('v_call', 'j_call'), region='all', weight='reads', properties=None, by=())[source]#
Group-wise weighted mean of CDR3 physicochemical properties.
For each clonotype the region residues are looked up in the property table and averaged per property (the per-clonotype property mean). These are then combined per group by a weighted mean
Σ w_c m_c / Σ w_c(w_c= clonotype weight).- Parameters:
df – A clonotype frame (eager
pl.DataFrameor lazypl.LazyFrame— e.g. a whole cohort fromvdjtools.io.scan_cohort()); result mirrors the input.group_by – Grouping column(s). Either the string
"locus"(derived if absent) or an iterable of column names present indf(default("v_call", "j_call")).region (str) –
"all","trimmed", or"center"(see module docstring).weight (str) –
"reads","unique", or"freq".properties (tuple[str, ...] | None) – Property names to compute. Defaults to
DEFAULT_PROPERTIES.by – Extra column(s) to prepend to the group key (e.g.
["sample_id"]to profile a whole cohort in one pass). Empty by default — byte-identical to the per-sample profile.
- Returns:
Tidy frame with the
bycolumns, the group columns,region,propertyandmean_value, sorted by group then property (lazy whendfis lazy).- Raises:
ValueError – If
regionis unknown or a requested property is missing.
vdjtools.features.kmer#
k-mer and V+k-mer occurrence summaries.
CDR3 amino-acid k-mer profiles and joint V + k-mer + C feature summaries.
K-mers are overlapping sliding windows over junction_aa. Each k-mer occurrence
carries its clonotype’s weight (reads, unique, or frequency); weights are summed.
Anchors carry no information. A junction begins with the conserved Cys104 and a few germline
V residues and ends with germline J: on a 250k human-TRB control, an N-terminal 4-mer is shared by
31.0% of clonotypes (CASS alone by 56.5%) while a central 4-mer is shared by
0.080% — ~386x more selective (measured in seqtree.seeds). So a window that starts at
index 0 spends most of its multiple-testing budget on germline. Pass flank to drop that many
residues from each end first; flank=4 matches seqtree.seeds.core_kmers.
flank is a fixed trim, so it is only an approximation of the germline boundary — the real
one is per-clonotype and locus-specific (TRBJ2-3’s germline is STDTQYF, seven residues, so
flank=4 still leaves STD in the core). Where the data carries V/J markup, prefer it.
- vdjtools.features.kmer.kmer_profile(df, k=3, weight='reads', by_locus=True, flank=0, by=())[source]#
CDR3 amino-acid k-mer spectrum.
- Parameters:
df – A clonotype frame (eager
pl.DataFrameor lazypl.LazyFrame— e.g. a whole cohort fromvdjtools.io.scan_cohort()); result mirrors the input.k (int) – K-mer length (default 3).
weight (str) –
"reads","unique", or"freq".by_locus (bool) – If
True, break the spectrum down per locus.flank (int) – Residues to drop from each end before windowing (see the module docstring);
0(default) keeps the whole junction,4gives theseqtreecore.by – Extra column(s) to prepend to the group key (e.g.
["sample_id"]for a one-pass cohort spectrum). Empty by default — byte-identical to per-sample.
- Returns:
Long-format frame with
kmer,weight, thebycolumns and (ifby_locus)locus, sorted by the group key (lazy whendfis lazy).
- vdjtools.features.kmer.v_kmer_c_profile(df, k=3, weight='reads', by_locus=True, keep_allele=False, flank=0, by=())[source]#
Joint (V gene, k-mer, C gene) profile — a tidy feature-matrix source.
Produces one aggregated row per
(v_call, kmer, c_call)combination (pluslocusif requested), suitable to pivot into a feature matrix. A nullc_call(common in native vdjtools data) is retained as its own group.- Parameters:
df – A clonotype frame (eager or lazy; see
kmer_profile()).k (int) – K-mer length (default 3).
weight (str) –
"reads","unique", or"freq".by_locus (bool) – If
True, includelocusin the grouping.keep_allele (bool) – If
True, keep V allele suffixes; otherwise collapse V to gene level (default).flank (int) – Residues to drop from each end before windowing (see the module docstring).
by – Extra column(s) prepended to the group key (e.g.
["sample_id"]for a one-pass cohort feature source).
- Returns:
Long-format frame with
v_call,kmer,c_call,weight, thebycolumns and (ifby_locus)locus(lazy whendfis lazy).
- vdjtools.features.kmer.kmer_cohort(cohort, k=4, flank=4, keep_allele=False)[source]#
Explode a cohort into a per-
(sample_id, v_call, kmer)frame for an incidence test.The bridge from k-mers to
vdjtools.biomarker.association(): the result carries akmercolumn alongsidesample_id, so a V + k-mer phenotype test is justfrom vdjtools.biomarker import association from vdjtools.features.kmer import kmer_cohort km = kmer_cohort(cohort, k=4, flank=4) res = association(km, design, key=("v_call", "kmer"), match="exact")
match="exact"is the only valid mode here —fuzzy/1mmsearch on the CDR3, and a 1-mismatch ball around a 4-mer is most of k-mer space.Pinning V matters. A short central k-mer is only selective given the germline context: on its own a 4-mer is shared by ~0.08% of a control repertoire, which over ~10⁵ features is still thousands of hits.
(v_call, kmer)is the feature the V+k-mer search is named for.- Parameters:
cohort (DataFrame | LazyFrame) – A clonotype frame with
sample_id(e.g. fromvdjtools.io.scan_cohort()).k (int) – K-mer length. Note
seqtree.seeds’ measurement that a central k-mer’s median E-value crosses 1 atk=6; below that, prune withseqtree.seeds.SeedIndexagainst a real control rather than trusting a modelled background (its residual KL grows with k — D-gene germline runs correlate — so it must be counted, not fitted).flank (int) – Residues dropped from each end (see the module docstring).
4= seqtree’s core.keep_allele (bool) – Keep V allele suffixes; default collapses to gene level.
- Returns:
Unique
(sample_id, v_call, kmer)rows.- Return type:
DataFrame
Repertoire overlap and TCRnet (vdjtools.overlap)#
Sample overlap and TCRnet built on vdjmatch / seqtree, including sequence-similarity-aware (TINA / Leinster-Cobbold) overlap, pairwise-distance matrices, clustering / MDS, and tracking.
vdjtools.overlap.metrics#
Exact-match overlap metrics (F, D, Jaccard, Morisita-Horn, …).
Exact-match pairwise repertoire-overlap metrics (pure polars + numpy).
Implements the four legacy vdjtools overlap metrics (OverlapEvaluator) on an
exact clonotype match key. This is exact-match only; fuzzy / e-value overlap and
TCRnet are delegated to vdjmatch (cluster.overlap / evalue.query_evalues).
- vdjtools.overlap.metrics.DEFAULT_KEY = ('junction_aa', 'v_call', 'j_call')#
CDR3 aa + V + J).
- Type:
Default exact match key (legacy “Strict” overlap
- vdjtools.overlap.metrics.STANDARD_AA = '^[ACDEFGHIKLMNPQRSTVWY]+$'#
CDR3s outside this alphabet (
*,_,X) are not scoreable by the similarity kernels.
- vdjtools.overlap.metrics.overlap_pair(a, b, key=('junction_aa', 'v_call', 'j_call'))[source]#
Compute the shared-clonotype table and overlap metrics for two samples.
Both frames are first collapsed to unique clonotype keys (summing counts; frequencies are recomputed within each sample). The four legacy metrics are:
D (diversity):
d12 / (d1 * d2).F (frequency):
sqrt(Σ_shared f_a · Σ_shared f_b).F2:
Σ_shared sqrt(f_a · f_b).R: Pearson correlation of the raw shared-clonotype frequencies (legacy
OverlapEvaluator:x[k] = it.getFreq(i)fed straight toPearsonsCorrelation, no log transform). Requires at least three shared clonotypes (legacy guardn > 2); with fewer, or when the correlation is undefined (a constant/degenerate vector yields NaN),RisNone. Legacy coerced both of these cases to0.0;Nonekeeps “undefined” distinct from a genuine zero correlation.
- Parameters:
- Returns:
A tuple
(shared, metrics)wheresharedis apl.DataFrameof the joined shared clonotypes (key columns pluscount_a, count_b, freq_a, freq_b) andmetricsis a dict with keysD, F, F2, R, d1, d2, d12(RisNonewhen undefined; see above).- Return type:
- vdjtools.overlap.metrics.overlap_metrics(a, b, key=('junction_aa', 'v_call', 'j_call'))[source]#
Compute the four exact-match overlap metrics (D, F, F2, R) for two samples.
- Parameters:
a (DataFrame) – First clonotype frame.
b (DataFrame) – Second clonotype frame.
key (tuple[str, ...]) – Exact match key (see
overlap_pair()).
- Returns:
Dict with keys
D, F, F2, R, d1, d2, d12.- Return type:
vdjtools.overlap.similarity#
Sequence-similarity-weighted (TINA / Leinster-Cobbold) overlap.
Sequence-similarity-weighted repertoire overlap (TINA / Leinster-Cobbold).
Where vdjtools.overlap.metrics scores overlap on an exact clonotype match and
vdjtools.overlap.fuzzy on a within-edit-scope match, this module scores it
through a continuous CDR3 similarity kernel Z (Leinster-Cobbold / Nei; Schmidt
et al. 2016 TINA). Two repertoires are relative-abundance vectors p, q over their
clonotypes and the overlap is built on the bilinear form pᵀZq = Σᵢⱼ pᵢ Zᵢⱼ qⱼ:
cosine (TINA_w):
S = pᵀZq / sqrt((pᵀZp)(qᵀZq))Morisita-Horn:
S = 2·pᵀZq / (pᵀZp + qᵀZq)distance = 1 − S.
Z is a kernel of a seqtree gap-block alignment penalty P (≥0, 0
identical), symmetric, with Zᵢᵢ = 1:
kernel="exp"—Zᵢⱼ = exp(−Pᵢⱼ/τ)(Leinster-Cobbold / Nei);τdefaults toSubstitutionMatrix.blosum62().scale()(= 14).kernel="step"—Zᵢⱼ = 1[Pᵢⱼ ≤ max_penalty]. On unit cost with indels prohibited this is exactly vdjmatch’s fuzzy edit-distance overlap.kernel="identity"—Z = I(match on the clonotype key). This recovers the exact frequency overlap: cosine collapses to the classical cosine of the shared-frequency vectors and Morisita to the classical Morisita-Horn index.
The three kernels are the same code on the same spine — identity and step are the exact
special cases (exact / fuzzy overlap) of the continuous exp form. The CDR3 kernel is
block-diagonalised by the non-cdr3 key fields (V/J/locus): exp/step never connect
clonotypes that differ outside the CDR3, so identity is the exact τ→0 limit of exp on
the same key.
The penalty is built with seqtree (a base dependency): the dense path scores
every clonotype pair via seqtree.score_matrix() (O(N²), for small N and the
tests); the sparse path uses seqtree.pairwise_batch() only to find near
candidates, then re-scores each with the same gap-block model
(seqtree.gapblock_score(), same matrix / gap_open / gap_prior as dense) and keeps
those with penalty ≤ the threshold — so dense and sparse agree on every retained pair.
It assembles a scipy.sparse Z (the practical path at scale). Both within-sample
blocks (Z_AA, Z_BB) are needed in full — a diagonal-only approximation of pᵀZp
is wrong.
- class vdjtools.overlap.similarity.SimilarityMatrices(z_ab, z_aa, z_bb, keys_a, keys_b, freq_a, freq_b, kernel, tau, sparse)[source]#
Bases:
objectThe three similarity blocks plus the aligned weight vectors for a sample pair.
- Variables:
z_ab (Any) – Cross-sample kernel, shape
(n_a, n_b)(densenp.ndarrayor ascipy.sparsematrix).z_aa (Any) – Within-
akernel, shape(n_a, n_a); diagonal 1, symmetric.z_bb (Any) – Within-
bkernel, shape(n_b, n_b).keys_a (list) – Clonotype key tuples for
ain row order.keys_b (list) – Clonotype key tuples for
bin row order.freq_a (numpy.ndarray) – Within-sample relative abundance of each
aclonotype (sums to 1).freq_b (numpy.ndarray) – Within-sample relative abundance of each
bclonotype.kernel (str) – The kernel used (
"exp","step","identity").tau (float | None) – The kernel bandwidth
τ(Nonefor non-expkernels).sparse (bool) – Whether the blocks are
scipy.sparsematrices.
- Parameters:
- freq_a: ndarray#
- freq_b: ndarray#
- vdjtools.overlap.similarity.similarity_matrix(a, b, *, key=('junction_aa',), kernel='exp', tau=None, matrix=None, max_penalty=None, gap_prior='central', gap_open=None, dense=None, threads=0)[source]#
Build the three CDR3-similarity blocks
Z_AB, Z_AA, Z_BBfor a sample pair.- Parameters:
a (DataFrame) – First clonotype frame (canonical schema).
b (DataFrame) – Second clonotype frame.
key (tuple[str, ...]) – Columns forming the clonotype identity; must include
junction_aa(the similarity match unit). Default("junction_aa",).kernel (str) –
"exp"(exp(−P/τ)),"step"(1[P ≤ max_penalty]), or"identity"(Z = Ion the key — exact overlap, no seqtree).tau (float | None) – Kernel bandwidth for
"exp"; defaults to the matrix scale (blosum62().scale() == 14).matrix – A
seqtree.SubstitutionMatrix; defaults to BLOSUM62 for"exp"and to unit cost (edit-count penalty) for"step".max_penalty (int | None) – Penalty ceiling. Required (and the step threshold) for
"step"(default1= one edit); for the sparse"exp"path it is the neighbourhood cutoff (default fromτsoexp(−P/τ) ≳ 1e-3).gap_prior – Single-indel block placement rule:
"central"(default — biases the indel to the loop centre, where CDR3 length variation sits; seqtree’s own gapblock recommendation for pairwise scoring),"frame"(left-anchored common frame),"none", or aseqtree.GapPrior.gap_open (int | None) – Block-opening cost. Defaults to the seqtree score default for
"exp"and to a gap-prohibiting value for"step"(substitution-only, matching vdjmatch’s default scope).dense (bool | None) – Force the dense (
True) or sparse (False) path;Noneauto-selects (dense whilemax(n_a, n_b) ≤ 1500).threads (int) – Worker threads for the native search (
0= all cores).
- Returns:
- Raises:
ImportError – If seqtree (or, for the sparse path, scipy) is missing.
ValueError – On an unknown
kernel/gap_prioror akeywithoutjunction_aa.
- Return type:
- vdjtools.overlap.similarity.similarity_overlap(a, b, *, key=('junction_aa',), metric='cosine', weight='freq', kernel='exp', tau=None, matrix=None, max_penalty=None, dense=None, threads=0)[source]#
Similarity-weighted overlap between two repertoires.
Builds
Zviasimilarity_matrix(), forms the weight vectorsp, q, and returns the cosine (TINA_w) or Morisita-Horn similarity onpᵀZq.- Parameters:
a (DataFrame) – First clonotype frame.
b (DataFrame) – Second clonotype frame.
key (tuple[str, ...]) – Clonotype identity key (must include
junction_aa).metric (str) –
"cosine"(pᵀZq / sqrt(pᵀZp·qᵀZq)) or"morisita"(2·pᵀZq / (pᵀZp + qᵀZq)).weight (str) –
"freq"(relative abundance) or"presence"(uniform per clonotype, TINA-unweighted).kernel (str) –
"exp","step", or"identity"(seesimilarity_matrix())."identity"recovers the classical (exact) cosine / Morisita-Horn.tau (float | None) – Kernel bandwidth for
"exp"(default14).matrix – Substitution matrix (see
similarity_matrix()).max_penalty (int | None) – Penalty ceiling / step threshold (see
similarity_matrix()).dense (bool | None) – Force the dense (
True) or sparse (False) kernel path;Noneauto-selects (seesimilarity_matrix()). Both agree on retained pairs.threads (int) – Worker threads for the native search.
- Returns:
Dict with keys
similarity, distance, pTZq, pTZp, qTZq, metric, kernel.- Raises:
ValueError – On an unknown
metric/weight/kernel.- Return type:
vdjtools.overlap.fuzzy#
Fuzzy (mismatch-tolerant) overlap via vdjmatch / seqtree.
Fuzzy (edit-distance) sample overlap — delegated to vdjmatch/seqtree.
Where vdjtools.overlap.metrics matches clonotypes exactly, this module
matches CDR3s within an edit-distance scope (substitutions/indels), so a pair of
repertoires that share only near-variants of a clonotype still register as
overlapping. The fuzzy search itself is not reimplemented here — it is delegated
to vdjmatch.cluster.overlap(), which runs on the native seqtree engine.
These functions are thin polars wrappers that (a) collapse each sample to unique
CDR3s, (b) hand the CDR3 lists to vdjmatch, and (c) join the matched pairs back to
per-clonotype counts/frequencies.
Matching is on the amino-acid CDR3 (junction_aa): vdjmatch runs on the "aa"
alphabet, so this module is aa-only. Scope syntax is vdjmatch’s
"subs,ins,dels,total" (max substitutions, insertions, deletions, total edits);
the default "1,0,0,1" is a single substitution.
- vdjtools.overlap.fuzzy.fuzzy_overlap(a, b, scope='1,0,0,1', threads=0)[source]#
Fuzzy-matched clonotype pairs between two samples (within an edit scope).
Both samples are collapsed to unique
junction_aavalues (counts summed, frequencies recomputed within each sample); the two CDR3 lists are handed tovdjmatch.cluster.overlap(), and the returned within-scope pairs are joined back to per-clonotype counts and frequencies.- Parameters:
- Returns:
A
pl.DataFrame, one row per matched pair, with columnsa_cdr3, b_cdr3, n_subs, score, count_a, freq_a, count_b, freq_b. Empty (with that schema) when nothing matches.- Raises:
ImportError – If vdjmatch is not importable (it is a base dependency).
- Return type:
DataFrame
- vdjtools.overlap.fuzzy.fuzzy_overlap_metrics(a, b, scope='1,0,0,1', threads=0)[source]#
Summary fuzzy-overlap metrics for two samples.
Computes
fuzzy_overlap()once and derives:pairs — number of within-scope matched clonotype pairs.
frac_a_matched / frac_b_matched — fraction of each sample’s unique clonotypes with at least one fuzzy match in the other.
fuzzy_F — the frequency-weighted fuzzy analogue of the exact
Fmetric,sqrt(Σ_{a matched} freq_a · Σ_{b matched} freq_b), where a clonotype’s frequency is counted once if it has any fuzzy neighbour in the other sample (so a clonotype matching several near-variants is not double-weighted).
- Parameters:
a (DataFrame) – First clonotype frame.
b (DataFrame) – Second clonotype frame.
scope (str) – vdjmatch edit-distance scope (see
fuzzy_overlap()).threads (int) – Worker threads for the native search.
- Returns:
Dict with keys
pairs, frac_a_matched, frac_b_matched, fuzzy_F.- Raises:
ImportError – If vdjmatch is not importable (it is a base dependency).
- Return type:
vdjtools.overlap.tcrnet#
TCRnet neighbourhood-enrichment degree statistics.
TCRnet neighbourhood-enrichment (convergence) test — delegated to vdjmatch/seqtree.
The classic TCRnet / CalcDegreeStats analysis asks, for every clonotype in a
sample, whether it has more close CDR3 neighbours than a background/generative
process would produce — the signature of antigen-driven convergent selection. The
legacy tool counted a clonotype’s within-sample degree and compared it to a control
sample’s degree under a grouping (V/VJ/VJL) that bounded the comparison scope.
This reimplements it on a finite-sample, control-calibrated footing by delegating
to vdjmatch.evalue.query_evalues() (the seqtree E-value engine):
target = a fuzzy
seqtree.Indexover the sample’s own unique CDR3s — so a clonotype’s neighbour count is its within-sample degree;control = a matched background repertoire (
vdjmatch.evalue.background());for each query CDR3,
E = (N/M)·n_control— whereNis the target index size andMthe control index size — is the neighbour count expected from the background, andp_enrichmentis the Poisson tail probability of seeing at least the observed within-sample degree by chance.q_valueis the Benjamini-Hochberg FDR over the clonotypes scored in one call.
Self-hit handling (this is subtle and was previously wrong). The query is a member of
the target index but is not a member of the control, so the two sides need different
treatment — and query_evalues(exclude_exact=True) punctures distance-0 hits on both.
That silently dropped genuine public-clone neighbours from the background, understating E
and inflating significance. We therefore query with exclude_exact=False and subtract the
self-hit from the target side only: verified, n_target differs by exactly 1 between the
two modes for every query (the query’s own copy), while n_control differs by 1 for the
public clonotypes whose background neighbours were being discarded.
Locus handling. A TCRnet neighbourhood is only meaningful within one locus (a TRA
sequence has no true neighbours in a TRB background). When neither control nor
locus is given, the sample is split by the locus of its v_call and each locus
is scored against its own matched background — mirroring the legacy V-grouping that
kept loci disjoint. Clonotypes whose locus cannot be resolved (null v_call) are
dropped with a warning. Passing control or locus explicitly overrides this and
scores every clonotype against that single background.
Scope note: the legacy default was s,id,t = 1,0,1 (one substitution, no indels,
one total edit), i.e. vdjmatch "1,0,0,1" — the default here.
- vdjtools.overlap.tcrnet.tcrnet(sample, control=None, scope='1,0,0,1', locus=None, species='human', exclude_exact=True, threads=0)[source]#
Per-clonotype neighbourhood-enrichment (TCRnet) test for one sample.
Collapses the sample to unique CDR3s, builds a fuzzy
seqtree.Indexover them (the within-sample neighbourhood target), and queries each CDR3 against that target and a background control viavdjmatch.evalue.query_evalues(). When neithercontrolnorlocusis given the sample is scored per locus, each against its own matched background (see the module docstring).- Parameters:
sample (DataFrame) – Clonotype frame (canonical schema).
control – A prebuilt
seqtree.Indexbackground, orNoneto load matched background(s) viavdjmatch.evalue.background(). Passing one overrides the per-locus split (every clonotype is scored against it).scope (str) – vdjmatch edit-distance scope
"subs,ins,dels,total"defining the neighbourhood ball (default one substitution).locus (str | None) – Force a single background locus (e.g.
"TRB"); overrides the per-locus split. WhenNone(andcontrolisNone) the sample is split by the locus of itsv_call.species (str) – Species for the background control (default
"human").exclude_exact (bool) – Whether a clonotype’s own copy counts toward its within-sample degree (default
True= it does not). This applies to the target side only — the control side is never punctured, because the query is not a member of the control and a distance-0 control hit is a genuine public-clone background neighbour. SetFalsefor the closed-ball convention ALICE uses.threads (int) – Worker threads for the native search (
0= all cores).
- Returns:
One row per unique clonotype (per locus) with columns
junction_aa, v_call, j_call, duplicate_count, n_neighbors(within-sample degree),n_control(background degree),E(expected neighbours),p_enrichment(Poisson tail),q_value(Benjamini-Hochberg FDR over the clonotypes scored in this call),p_any, andlocus— sorted by ascendingp_enrichment.- Raises:
ImportError – If vdjmatch is not importable (it is a base dependency).
ValueError – If
controlandlocusareNoneand no clonotype has a resolvable locus.
- Return type:
DataFrame
vdjtools.overlap.alice#
The same neighbourhood-enrichment test against a V(D)J generation model (Pgen null) rather than a control repertoire — the complement of tcrnet.
ALICE neighbourhood enrichment — the generative null, against TCRnet’s empirical one.
ALICE (Pogorelyy et al., PLoS Biol 2019) asks the same question as vdjtools.overlap.tcrnet
— does this clonotype have more close CDR3 neighbours than chance? — but answers it from a V(D)J
generation model rather than from a control repertoire. For each amino-acid CDR3 σ within
one V–J class:
λ_σ = n · Σ_{σ' : Hamming(σ,σ') ≤ 1} Q · Pgen(σ')
p = P(Poisson(λ_σ) ≥ d(σ))
where n is the number of unique nucleotide clonotypes in that V–J class, d(σ) counts
the nucleotide clonotypes whose CDR3aa lies within one mismatch of σ (σ included — different
nucleotide variants of one amino-acid sequence are genuine neighbours), and Q rescales for
thymic selection, which removes a fraction 1 − 1/Q of generated sequences.
The two nulls are complements, not rivals, and that is why both are here: ALICE’s generative null controls for the intrinsic biases of V(D)J recombination but knows nothing about selection or about which clonotypes are already common in people; TCRnet’s control repertoire absorbs thymic selection and endemic-pathogen expansions but needs a large, HLA-matched cohort to do it.
Both share a known blind spot worth stating plainly: neighbourhood enrichment cannot see a
monoclonal expansion. A single hyperexpanded clone has no near neighbours by definition, so it
scores as unremarkable no matter how dominant it is. That is precisely why this module and
vdjtools.dynamics are complementary — enrichment measures breadth, the paired test
measures magnitude.
The Hamming-1 ball sum is exact and already native: pgen_aa_batch(..., mismatches=1) computes
Σ_k Pgen(a_{k→*}) − (L−1)Pgen(a), which is the closed ball with the centre counted exactly
once — verified here against brute-force enumeration of all 19L neighbours to 2e-16.
- vdjtools.overlap.alice.DEFAULT_Q = 9.41#
ALICE’s default thymic-selection factor — the paper’s average over V–J combinations.
- vdjtools.overlap.alice.alice(sample, model=None, *, locus=None, source='olga', scope='1,0,0,1', selection_q=9.41, min_degree=3, min_count=2, threads=0)[source]#
Per-clonotype neighbourhood enrichment against a V(D)J generation model.
- Parameters:
sample (DataFrame) – Clonotype frame (canonical schema).
junction_ntis used when present so degrees are in nucleotide-clonotype units, as ALICE defines them; without it each amino-acid sequence counts once.model – A
Model.Noneloads the bundled model forlocus.locus (str | None) – Locus to score (e.g.
"TRB").Noneinfers it fromv_call; a sample spanning several loci is scored per locus.source (str) – Bundled model source. Pinned to ``”olga”`` by default and you should leave it there: the
"learned"models are EM-fit on ~2k clonotypes with no gene-usage pseudocount, so 68 of 89 bundled TRB V alleles haveP(V) = 0(vs 8 for OLGA) — those clonotypes getλ = 0and come back infinitely significant. Their ball-Pgen scale is also ~16x below OLGA’s, whichQis calibrated against.scope (str) – vdjmatch edit scope defining the neighbourhood (default one substitution).
selection_q (float) – Thymic-selection factor
Q. The default is the paper’s average over V–J combinations; it is calibrated on OLGA’s TRB Pgen scale and there is no evidence it transfers to the other loci, so the benchmark titrates it per locus.min_degree (int) – Only clonotypes with at least this many neighbours (self included) are tested; the rest are dropped. ALICE tests
d(σ) > 2.min_count (int) – Clonotypes below this count do not participate at all. Low-count variants of an abundant clonotype are usually sequencing error, and counting them as neighbours inflates every degree in their neighbourhood.
threads (int) – Worker threads (
0= all cores).
- Returns:
the key columns,
duplicate_count,n_neighbors(d(σ), self included),n_group(n, the V–J class’s nucleotide-clonotype count),pgen_ball(Σ Pgenover the closed ball),E(λ_σ),p_enrichment(Poisson tail),q_value(BH FDR over the clonotypes tested in this call) andlocus— sorted by ascendingp_enrichment. No threshold is applied: ALICE’s own papers use BH < 0.001 while the TCRnet framework paper used 0.05 for the same family of tests, and that was never reconciled — so the caller chooses.- Return type:
One row per tested clonotype
- Raises:
ValueError – If no locus can be resolved, or the sample has no usable clonotypes.
KeyError – If a V/J call is neither a model allele nor a gene of one.
vdjtools.overlap.cluster#
Pairwise-distance matrices, clustering and MDS.
Pairwise sample distances and low-dimensional clustering.
Formalises the legacy CalcPairwiseDistances + ClusterSamples workflow (and
the ad-hoc MDS the aging example notebook did inline): compute an all-pairs distance
matrix from a repertoire-overlap metric, then embed it in 2-D (MDS) or build a
hierarchy (hclust).
An overlap similarity is turned into a distance with the legacy per-metric
normalisation (OverlapMetricNormalization):
F,F2,D(frequency/diversity overlaps,(0, 1]) →-log10(x + 1e-9);R(correlation,[-1, 1]) →(1 - x) / 2;jaccard(similarity index,[0, 1]) →1 - x.
The diagonal is forced to 0 and the matrix is symmetric. When a scope is
passed the (fuzzy) vdjtools.overlap.fuzzy.fuzzy_overlap_metrics() fuzzy_F is
used instead of the exact metric.
- vdjtools.overlap.cluster.DEFAULT_KEY = ('junction_aa', 'v_call', 'j_call')#
Default clonotype match key (CDR3 aa + V + J), matching the exact-overlap default.
- vdjtools.overlap.cluster.pairwise_distances(samples, metric='F', key=('junction_aa', 'v_call', 'j_call'), scope=None, form='matrix')[source]#
All-pairs distance matrix over a collection of samples.
- Parameters:
samples – A
listof clonotype frames (named"0".."N-1") or adictmapping sample name to frame.metric (str) – Overlap similarity to base the distance on:
"F","F2","D"(→-log10),"R"(→(1-x)/2),"jaccard"(→1-x), or the sequence-similarity-weighted"similarity_cosine"/"similarity_morisita"(TINA / Leinster-Cobbold, →1-x). See the module docstring.key – Exact-match clonotype key (default CDR3 aa + V + J); ignored when
scopeis given.scope (str | None) – If set, use fuzzy overlap within this vdjmatch edit scope (
"subs,ins,dels,total") and thefuzzy_Fsimilarity instead of the exactmetric(onlymetric="F"is valid then).form (str) –
"matrix"for a wide frame (asamplecolumn plus one column per sample) or"long"for asample_a, sample_b, distanceframe.
- Returns:
A symmetric distance matrix with a zero diagonal, in the requested
form.- Return type:
DataFrame
- vdjtools.overlap.cluster.cluster_samples(dist, method='mds', n_components=2, metadata=None)[source]#
Embed / cluster samples from a precomputed distance matrix.
- Parameters:
dist (DataFrame) – A matrix-form distance frame from
pairwise_distances()(asamplecolumn plus one column per sample).method (str) –
"mds"— metric MDS (sklearn.manifold.MDSwithdissimilarity="precomputed") →n_componentscoordinate columnsmds1..mdsK; or"hclust"— average-linkage hierarchy (scipy.cluster.hierarchy) → a dendrogramleaf_orderand a flatclusterlabel (fclusterinton_componentsclusters).n_components (int) – MDS output dimensionality (
method="mds") or the number of flat clusters (method="hclust").metadata (DataFrame | None) – Optional frame carrying a
samplecolumn plus per-sample columns (e.g.age,group) to left-join onto the result for colouring.
- Returns:
A
pl.DataFramewith one row per sample and the embedding / cluster columns, plus any joinedmetadata.- Raises:
ImportError – If
method="mds"and scikit-learn is not installed.ValueError – If
methodis not"mds"or"hclust".
- Return type:
DataFrame
vdjtools.overlap.track#
Clonotype tracking across a sample series.
Track clonotype frequencies across an ordered set of samples.
Reimplements the legacy TrackClonotypes: given several samples (e.g. a time
course or an age series) it builds one row per clonotype and one frequency column per
sample, so a clonotype’s trajectory can be read across the columns. Clonotypes
present in at least one sample are kept, sorted by their summed frequency (most
persistent/abundant first), optionally truncated to the top N.
- vdjtools.overlap.track.DEFAULT_KEY = ('junction_aa', 'v_call', 'j_call')#
Default clonotype match key (CDR3 aa + V + J).
- vdjtools.overlap.track.track_clonotypes(samples, order=None, top=None, key=('junction_aa', 'v_call', 'j_call'))[source]#
Pivot per-sample clonotype frequency into one column per sample.
- Parameters:
samples – A
dictmapping sample name to clonotype frame, or alistof frames (named"0".."N-1").order – Sample names giving the left-to-right column order. Defaults to the samples’ natural order (dict insertion / list order). Names not present in
samplesare ignored.top (int | None) – If given, keep only the
topclonotypes by summed frequency.key – Clonotype match key (default CDR3 aa + V + J).
- Returns:
A
pl.DataFramewith thekeycolumns, onefreq_<sample>column per sample inorder(0.0where the clonotype is absent), and afreq_sumcolumn, sorted byfreq_sumdescending. Clonotypes present in at least one sample are included.- Return type:
DataFrame
Preprocessing (vdjtools.preprocess)#
Sample preprocessing and operations: downsampling, frequency error-correction, decontamination, segment/frequency/functional filters, VJ-usage batch-effect correction, and pooling / joining.
vdjtools.preprocess.downsample#
Read / unique / frequency downsampling (numpy multinomial).
Random down-sampling of clonotype frames (pure polars + numpy).
Reimplements the legacy vdjtools DownSampler / TopSampler family. Two
resampling regimes, matching the legacy --unweighted switch:
reads (legacy default, weighted): draw
sizereads without replacement from the multiset of reads implied byduplicate_count. The legacyDownSamplershuffles a flattened per-read array and keeps the firstsizeentries; the exact equivalent is the multivariate hypergeometric distribution (numpy.random.Generator.multivariate_hypergeometric). The task brief phrased this as “multinomial”, but sampling a sequencing library to a fixed depth is a without-replacement operation — a multinomial (with replacement) could return more reads of a clonotype than were observed — so the hypergeometric is used to stay faithful to the legacy behaviour and to the biology.clones (legacy
--unweighted): drawsizeunique clonotypes uniformly at random without replacement, keeping each one’s original count. Note the legacy clonotype-level mode is uniform, not count-weighted (weighting by count is what the read-level mode does).
- vdjtools.preprocess.downsample.downsample(df, size, by='reads', seed=0)[source]#
Randomly down-sample a clonotype frame to a target size.
- Parameters:
df (DataFrame) – A clonotype frame with a
duplicate_countcolumn.size (int) – Target size — number of reads (
by="reads") or number of unique clonotypes (by="clones").by (str) –
"reads"(default) drawssizereads without replacement, weighted byduplicate_count(multivariate hypergeometric);"clones"drawssizeunique clonotypes uniformly without replacement, keeping their original counts.seed (int) – Seed for the numpy random generator (reproducible output).
- Returns:
A new clonotype frame with
frequencyrecomputed. Clonotypes that drew zero reads (by="reads") are dropped. Ifsizeis greater than or equal to the available size the input is returned unchanged (legacy guard).- Raises:
ValueError – If
byis not"reads"or"clones", orsize< 0.- Return type:
DataFrame
- vdjtools.preprocess.downsample.select_top(df, n, renormalize=True)[source]#
Select the top
nclonotypes byduplicate_count.Reimplements the legacy
SelectTop/TopSampler(take thenlargest clonotypes). Ties are broken by the frame’s existing order (a stable sort).- Parameters:
df (DataFrame) – A clonotype frame with a
duplicate_countcolumn.n (int) – Number of top clonotypes to keep. If
nis greater than or equal to the number of clonotypes, all are returned.renormalize (bool) – If
True(legacy default), recomputefrequencywithin the selected subset so it sums to 1; ifFalse, preserve the input frequencies (legacy--save-freqs).
- Returns:
The top-
nclonotype frame, sorted by descendingduplicate_count.- Return type:
DataFrame
vdjtools.preprocess.filter#
Segment, frequency and functional (coding) filters.
Clonotype filtering (pure polars).
Reimplements the legacy vdjtools clonotype-filter family:
filter_productive()— AIRR-productive rearrangements (supersedesfilter_functional, the legacyFunctionalClonotypeFilter/isCoding).filter_frequency()—FilterByFrequency(FrequencyFilter+QuantileFilter).filter_segment()—FilterBySegment(VFilter/DFilter/JFilter).filter_by_sample()—ApplySampleAsFilter(IntersectionClonotypeFilter).
Every filter recomputes frequency within the surviving subset by default, and
filter_productive() exposes that as recompute_frequencies=False for a caller who wants
the file’s own frequencies left alone.
On the word “functional”. It is IMGT’s, and IMGT applies it to a germline gene (F / ORF / P),
not to a rearrangement. What this module filters is AIRR productivity — a property of the
rearranged sequence: no stop codon, junction in frame. The two are orthogonal; a perfectly
productive rearrangement can use a pseudogene V. filter_productive() is therefore the name,
and filter_functional() is kept as a deprecated alias.
- vdjtools.preprocess.filter.productive_mask(df)[source]#
The productivity predicate for this frame, and the evidence it rests on.
Returns
(expr, source)wheresourceis the AIRR column(s) used, or"junction_aa"when none were present and productivity had to be derived from the amino-acid string.The fallback reads a stop codon as
*and an out-of-frame junction as one of the legacy markers. It is a proxy: it cannot see a defect in a splicing site or a regulatory element, which AIRR’sproductivecan.
- vdjtools.preprocess.filter.filter_productive(df, keep='productive', *, recompute_frequencies=True)[source]#
Keep only AIRR-productive rearrangements (or only the complement).
A rearrangement is productive when it can encode a receptor chain. Where the frame carries the AIRR annotation columns (
productive, orstop_codon/vj_in_frame) those are authoritative; otherwise productivity is derived fromjunction_aa, where a stop codon is*and an out-of-frame junction carries one of the legacy markers[atgc#~_?]. A nulljunction_aais treated as non-productive.This is not IMGT functionality. That is a property of the germline gene (F / ORF / P) and is orthogonal to this one — see
filter_functional_genes().- Parameters:
df (DataFrame) – A clonotype frame.
keep (str) –
"productive"(default) or"nonproductive"for the complement.recompute_frequencies (bool) – Renormalise
frequencyover the survivors. Default ``True``, which is the legacy behaviour and what almost every caller wants. PassFalseto leave the file’s own frequencies untouched — useful when the frequencies are the quantity of interest and must stay comparable to the unfiltered file.
- Returns:
The filtered frame.
- Raises:
ValueError – If
keepis not"productive"or"nonproductive".- Return type:
DataFrame
- vdjtools.preprocess.filter.filter_functional(df, keep='coding')[source]#
Deprecated alias for
filter_productive(). Use that instead.“Functional” is IMGT’s word for a germline gene; this function filters rearrangements, which AIRR calls productive. Kept for one release so existing callers do not break silently.
- Parameters:
df (DataFrame)
keep (str)
- Return type:
DataFrame
- vdjtools.preprocess.filter.filter_functional_genes(df, *, segments=('V', 'J'), keep=('F',), organism='human', locus=None, recompute_frequencies=True)[source]#
Keep rearrangements whose germline gene calls are IMGT-functional.
This is the other axis, and the one that actually deserves the word functional. IMGT classifies a germline gene as F (functional), ORF (an open reading frame, but a defect in splicing, regulatory elements or conserved-residue hydropathy — not functional), or P (pseudogene: a defect in the ORF itself). See https://www.imgt.org/IMGTindex/functionality.php
It is orthogonal to
filter_productive(). A rearrangement can be perfectly in frame with no stop codon — AIRR-productive — while using a pseudogene V; and a functional V gene can rearrange out of frame. Filtering one says nothing about the other.A call this function cannot resolve against the germline reference is kept, not dropped: an unrecognised gene name means our reference is incomplete or the caller uses a different nomenclature, and silently discarding those rows would be a vocabulary bug reported as biology.
- Parameters:
df (DataFrame) – A clonotype frame.
segments (tuple[str, ...]) – Which calls to check — any of
"V","D","J".keep (tuple[str, ...]) – IMGT functionality codes to keep.
("F",)is strict;("F", "ORF")is the common looser choice.organism (str) – Passed to the germline reference.
locus (str | None) – Locus to load the reference for. Inferred from the V calls when omitted.
recompute_frequencies (bool) – Renormalise
frequencyover the survivors. DefaultTrue.
- Returns:
The filtered frame.
- Return type:
DataFrame
- vdjtools.preprocess.filter.MIN_JUNCTION_AA = 5#
Default junction_aa length bounds, INCLUSIVE, in amino acids.
A CDR3 shorter than 5 aa cannot span the Cys104..Phe118 anchors with any diversity between them, and a junction longer than 60 aa is beyond anything the germline can produce – both are almost always a misparse or a chimeric read rather than a receptor. Deliberately wide: this is a sanity bound, not a biological filter, and real junctions sit far inside it.
- vdjtools.preprocess.filter.MAX_JUNCTION_AA = 60#
Default junction_aa length bounds, INCLUSIVE, in amino acids.
A CDR3 shorter than 5 aa cannot span the Cys104..Phe118 anchors with any diversity between them, and a junction longer than 60 aa is beyond anything the germline can produce – both are almost always a misparse or a chimeric read rather than a receptor. Deliberately wide: this is a sanity bound, not a biological filter, and real junctions sit far inside it.
- vdjtools.preprocess.filter.filter_length(df, *, min_len=5, max_len=60, keep='within', recompute_frequencies=True)[source]#
Keep clonotypes whose
junction_aalength is within bounds, inclusive.Both bounds are inclusive:
min_len=5keeps a 5-mer,max_len=60keeps a 60-mer.This is a data-sanity filter, not a receptor-biology one. It catches misparses, truncated reads and chimeras; it does not encode a claim about what lengths are immunologically interesting. Note that neither this nor
filter_productive()filters on length by default – nothing upstream in this package has ever imposed a length bound, so switching this on will change counts on any corpus that carries junk.CDR3 vs junction. These bounds are on
junction_aa, which includes the Cys104 and Phe118 anchors and is therefore two residues longer than the IMGT CDR3. Subtract 2 if you are reasoning in CDR3 lengths.A null
junction_aais dropped bykeep="within"– an absent junction has no length.- Parameters:
df (DataFrame) – A clonotype frame.
min_len (int) – Shortest
junction_aato keep, inclusive.max_len (int) – Longest
junction_aato keep, inclusive.keep (str) –
"within"(default) or"outside"for the complement – useful for inspecting what a bound would discard before committing to it.recompute_frequencies (bool) – Renormalise
frequencyover the survivors. DefaultTrue.
- Returns:
The filtered frame.
- Raises:
ValueError – If
keepis unknown, ormin_lenexceedsmax_len.- Return type:
DataFrame
- vdjtools.preprocess.filter.filter_frequency(df, min_freq=None, top_quantile=None)[source]#
Keep abundant clonotypes by frequency threshold and/or top quantile.
Reimplements
FilterByFrequency(a composite ofFrequencyFilterandQuantileFilter). Both criteria, when given, are combined with AND:min_freq: keep clonotypes withfrequency >= min_freq.top_quantile: keep the top clonotypes (byduplicate_count) whose cumulative original frequency, including the clonotype itself, is at mosttop_quantileof the full-sample total frequency. This matches the legacyQuantileFilter: it walks the count-sorted sample accumulating frequency and drops the first clonotype that would push the running fraction above the threshold (sotop_quantile=0.25keeps roughly the top 25% of the read mass). The denominator is the full-sample frequency total (~1.0), and only clonotypes that already passedmin_freqcontribute to the cumulative (legacy filters short-circuit in the order count/freq/quantile).
- Parameters:
- Returns:
The filtered frame, sorted by descending
duplicate_count, withfrequencyrecomputed.- Return type:
DataFrame
- vdjtools.preprocess.filter.filter_segment(df, v=None, d=None, j=None, keep=True)[source]#
Keep or remove clonotypes by V/D/J segment membership.
Reimplements
FilterBySegment. A clonotype matches when its V segment is invand its D segment indand its J segment inj(only the lists that are supplied constrain; unsupplied loci always pass). Matching is a prefix match, so incomplete names act as wildcards and are allele-insensitive (TRBV12matchesTRBV12-3*01).- Parameters:
df (DataFrame) – A clonotype frame with
v_call/d_call/j_callcolumns.v (list[str] | None) – V-segment query names (prefixes).
Noneleaves V unconstrained.d (list[str] | None) – D-segment query names.
Noneleaves D unconstrained.j (list[str] | None) – J-segment query names.
Noneleaves J unconstrained.keep (bool) – If
True(default) keep matching clonotypes; ifFalseremove them (legacy--negative).
- Returns:
The filtered frame with
frequencyrecomputed.- Return type:
DataFrame
- vdjtools.preprocess.filter.filter_by_sample(df, other, keep=True, key=('junction_aa', 'v_call', 'j_call'))[source]#
Keep or remove clonotypes by exact-key presence in another sample.
Reimplements
ApplySampleAsFilter/IntersectionClonotypeFilter: build the key set ofotherand keep (or, withkeep=False, remove) the clonotypes ofdfwhose key is present in it. Matching is an exact match on thekeycolumns.- Parameters:
df (DataFrame) – The clonotype frame to filter.
other (DataFrame) – The filter sample; only its
keycolumns are used.keep (bool) – If
True(default) keep clonotypes present inother; ifFalseremove them (legacy--negative).key (tuple[str, ...]) – Columns forming the match key (default
("junction_aa", "v_call", "j_call")— legacy “strict”-style at the aa level).
- Returns:
The filtered frame with
frequencyrecomputed.- Return type:
DataFrame
vdjtools.preprocess.correct#
Frequency-based sequencing-error correction.
Frequency-based sequencing-error correction (polars; neighbour search via seqtree).
Reimplements the legacy vdjtools Corrector. Clonotypes whose junction_nt sit
within a few substitutions of a much more abundant clonotype are treated as PCR /
sequencing errors of that parent and merged into it.
The <= max_mismatches substitution neighbour search is delegated to seqtree
(the “delegate search to seqtree” convention), which builds an immutable fuzzy
index and returns, per query, every within-budget neighbour and its substitution
count. Only the abundance-ratio decision is done here.
- vdjtools.preprocess.correct.correct(df, max_mismatches=2, ratio=0.05, same_vj=False)[source]#
Merge low-frequency sequencing-error clonotypes into their parents.
Reimplements
Corrector. Clonotype pairs whosejunction_ntare withinmax_mismatchessubstitutions are compared by abundance: a smaller clonotype is merged into a larger one when its count is belowratio ** mtimes the larger’s (m= number of substitutions). All decisions use the original counts (a single, order-independent pass, as in the legacy parallel stream).- Parameters:
df (DataFrame) – A clonotype frame with
junction_ntandduplicate_countcolumns.max_mismatches (int) – Maximum substitutions for two clonotypes to be neighbours (legacy default 2); insertions/deletions are not considered.
ratio (float) – Per-mismatch parent/child count ratio (legacy default 0.05). A child is merged when
child_count < ratio ** m * parent_count.same_vj (bool) – If
True(the opt-in “match-segment” mode, legacy-a/--match-segment) only clonotypes sharing the exactv_callandj_callare compared; ifFalse(default, matching legacy fidelity — legacyCorrectoris segment-agnostic by default) all clonotypes are compared regardless of segment.
- Returns:
The corrected frame (errors dropped, parents’ counts increased), sorted by descending
duplicate_countwithfrequencyrecomputed. Rows with a nulljunction_ntpass through uncorrected.- Raises:
ImportError – If seqtree is not importable (it is a base dependency).
- Return type:
DataFrame
vdjtools.preprocess.decontaminate#
Cross-sample contamination removal.
Cross-sample contamination removal (pure polars).
Reimplements the legacy Decontaminate / RatioFilter: drop clonotypes that
occur in another sample at a much higher abundance, on the assumption that such a
clonotype leaked (index hopping / carry-over) into the current sample.
- vdjtools.preprocess.decontaminate.decontaminate(df, others, ratio=20.0, by='freq', key=('junction_nt', 'v_call', 'j_call'))[source]#
Remove clonotypes dominated by an exact match in another sample.
Reimplements
RatioFilter. A clonotype ofdfis removed when some sample inotherscarries the same clonotype (exact match onkey) at an abundance>= ratiotimes its abundance here (legacy keeps a clonotype iffmax_other < here * ratio, i.e. removes on>=). Abundance is the within-samplefrequency(by="freq") or readduplicate_count(by="reads").Note
The legacy
Decontaminate --read-basedbranch was a no-op — both CLI branches constructed the same frequency-basedRatioFilter(a documented TODO).by="reads"here implements the intended read-count comparison.- Parameters:
df (DataFrame) – The clonotype frame to decontaminate.
others (list[DataFrame]) – The other samples that may be contamination sources.
ratio (float) – Parent-to-child abundance ratio (legacy default
20).by (str) –
"freq"(default) compares within-sample frequencies;"reads"compares raw read counts.key (tuple[str, ...]) – Exact match key (legacy default is the strict key
("junction_nt", "v_call", "j_call")).
- Returns:
The decontaminated frame with
frequencyrecomputed.- Raises:
ValueError – If
byis not"freq"or"reads".- Return type:
DataFrame
vdjtools.preprocess.pool#
Pool samples into a joint clonotype table.
Pool clonotypes across samples (pure polars).
Reimplements the legacy PoolSamples / SampleAggregator / PooledSample:
collapse clonotypes across a set of samples by a chosen match key, summing counts
and recomputing frequency, and annotate each pooled clonotype with its incidence,
occurrence count and convergence.
- vdjtools.preprocess.pool.KEY_SETS: dict[str, tuple[str, ...]] = {'aa': ('junction_aa',), 'aaV': ('junction_aa', 'v_call'), 'aaVJ': ('junction_aa', 'v_call', 'j_call'), 'nt': ('junction_nt',), 'ntV': ('junction_nt', 'v_call'), 'ntVJ': ('junction_nt', 'v_call', 'j_call'), 'strict': ('junction_nt', 'v_call', 'j_call')}#
Legacy
OverlapTypematch keys -> the clonotype columns they compare on.
- vdjtools.preprocess.pool.resolve_key(key)[source]#
Resolve a match-key name (or explicit column tuple) to key columns.
- Parameters:
key (str | tuple[str, ...]) – One of the legacy overlap-type names (
"strict","nt","ntV","ntVJ","aa","aaV","aaVJ") or an explicit tuple/list of column names.- Returns:
The list of clonotype columns forming the key.
- Raises:
ValueError – If
keyis an unknown name.- Return type:
- vdjtools.preprocess.pool.pool_samples(samples, key='aa', sample_col=None)[source]#
Pool clonotypes across samples, summing counts and recomputing frequency.
Reimplements
PooledSample(legacy default match key"aa"). For each distinct match key across all samples the pooled clonotype carries:duplicate_count— summed read count across all samples.frequency— pooled count over the pool’s total reads.incidence— number of distinct samples the clonotype occurs in.occurrences— total number of clonotype rows aggregated (a clonotype can appear once per sample, and, for amino-acid keys, via several nucleotide variants within one sample).convergence— number of distinct nucleotide variants (junction_nt+v_call+j_call, the legacy strict key) collapsed into the pooled clonotype. This is 1 for nucleotide-level keys and counts convergent recombination for amino-acid-level keys.
The representative non-key fields (e.g. the
junction_ntof an amino-acid pool) are taken from the most abundant contributing row (legacyMaxClonotypeAggregator).- Parameters:
samples (list[DataFrame]) – A list of per-sample clonotype frames, or a single long frame (pass
[df]) split bysample_col.key (str | tuple[str, ...]) – Match key name or explicit column tuple (see
resolve_key()).sample_col (str | None) – If the input is a single long frame carrying a sample-id column, its name; used only to count
incidencecorrectly.
- Returns:
A pooled clonotype frame in the canonical schema plus
incidence,occurrencesandconvergence, sorted by descendingduplicate_count.- Return type:
DataFrame
vdjtools.preprocess.join#
Join samples on shared clonotypes.
Join clonotypes across samples into a joint table (pure polars).
Reimplements the legacy JoinSamples / JointSample / JointClonotype:
build the table of clonotypes present in at least min_samples samples, keep
each member’s per-sample frequency, and summarise each joint clonotype by the
geometric mean of its member frequencies.
- vdjtools.preprocess.join.JITTER = 1e-09#
Legacy
MathUtil.JITTER— the epsilon added to each per-sample frequency so that a geometric mean over samples where the clonotype is absent stays finite.
- vdjtools.preprocess.join.join_samples(samples, key='aa', min_samples=2, names=None)[source]#
Join clonotypes across samples, keyed by an overlap-type match key.
Reimplements
JointSample(legacy default key"aa",times-detected2). A joint clonotype is kept when it is present in at leastmin_samplessamples (legacyOccurrenceJoinFilter). Its joint frequency is the geometric mean of the per-sample frequencies (absent samples contributeJITTER),base = (∏_i (freq_i + JITTER))**(1/n_samples)then normalised so passing joint frequencies sum to 1 (legacy
calcFreq = base / Σ base). The joint count isfloor(base / min base)so that the smallest joint clonotype has count 1 (legacycalcCount = base / min base).- Parameters:
samples (list[DataFrame]) – A list of per-sample clonotype frames.
key (str | tuple[str, ...]) – Match key name or explicit column tuple (see
vdjtools.preprocess.pool.resolve_key()).min_samples (int) – Minimum number of samples a clonotype must occur in to be kept.
names (list[str] | None) – Optional per-sample names for the
freq_*/count_*columns; defaults to the sample indices0..n-1.
- Returns:
the key columns, one
freq_<name>andcount_<name>column per sample,incidence(number of samples present),frequency(normalised geometric-mean joint frequency) andduplicate_count(normalised joint count, smallest = 1), sorted by descendingfrequency.- Return type:
A joint clonotype frame
- Raises:
ValueError – If
namesis given but its length differs fromsamples.
vdjtools.preprocess.batch#
VJ-usage batch-effect correction.
VJ-usage batch-effect correction + clonotype-table application (pure polars/numpy).
Different sequencing batches carry systematic V/J gene-usage biases (primer mixes, amplification, extraction). This module removes that batch-specific offset so that per-sample VJ usage becomes comparable across batches, and can then push the corrected usage back onto a sample’s clonotype table (rescale + resample).
Two stages#
correct_vj_usage()— batch-correct per-sample V-J gene usage. Two transforms:transform="location"(default) — the classic location adjustment (the location term of ComBat; Johnson, Li & Rabinovic, Biostatistics 2007) on gene-usage log-probabilities. Per(locus, gene, batch)the batch meanmu_batchoflog pis replaced by the grand meanmu_grand:log_corrected = log_p - mu_batch + mu_grand, thenexpand renormalise. Location only — no scale term.transform="sigmoid"— the σ-standardised, grand-mean-preserving correction of Vlasova, Nekrasova, Komkov, … Britanova, Shugay, Genome Medicine 2026;18:20 (DOI 10.1186/s13073-025-01589-4). Per(locus, gene, batch)compute a z-scoreZ = (log p - mu_batch) / sigma_batch(capped at±z_cap), then map it back to a probability with a sigmoid that preserves the pooled grand-mean usageP_avg(gene):P_final = 2 * P_avg / (1 + exp(-Z))(Z=0→P_avg), renormalised per(sample, locus). The paper uses the plain mean/σ of the log-normal (Shapiro–Wilk-validated), i.e.winsor_q=None(default). Winsorization (winsor_q=0.025) is an optional robustness knob for the noisy usage-as-features regime (many shallow / RNA-seq repertoires), not for deep-repertoire correction; legacy mirpy v2mir.basic.gene_usage.compute_batch_corrected_gene_usagewinsorized by default (and usedp*exp(Z)for its own map — the2*P_avg*sigmoid(Z)map here is the paper’s Methods formula).
apply_vj_correction()— apply a sample’s corrected usage back to its clonotype table: reweight each clonotype byP_final(G) / P(G)for its gene, then (by default) roulette-wheel resample to a new integer-count table with the corrected usage. Port of legacy mirpy v2mir.common.sampling.resample_to_gene_usage.
Alleles are stripped for the usage key (mirpy convention); clonotypes keep their original allele calls.
- vdjtools.preprocess.batch.correct_vj_usage(samples_or_df, batch_col, sample_col='sample_id', weighted=True, pseudocount=1.0, transform='location', z_cap=6.0, winsor_q=None)[source]#
Batch-correct per-sample V-J gene usage.
- Parameters:
samples_or_df (DataFrame | list[DataFrame]) – A single long clonotype frame carrying
sample_colandbatch_colcolumns, or a list of such frames (concatenated).batch_col (str) – Column naming each sample’s batch.
sample_col (str) – Column naming the sample (default
"sample_id").weighted (bool) – If
True(default) usage counts reads (duplicate_count); ifFalseit counts clonotypes.pseudocount (float) – Laplace smoothing constant added per gene (default
1.0).transform (str) –
"location"(default) for the ComBat location adjustment (log_p - mu_batch + mu_grand), or"sigmoid"for the σ-standardised, grand-mean-preserving z-score mapP_final = 2*P_avg/(1+exp(-Z))(Vlasova et al. 2026). See the module docstring.z_cap (float) – For
transform="sigmoid", clip the z-score to±z_cap(default6.0); ignored for"location".winsor_q (float | None) – If set (e.g.
0.025), winsorize each(gene, batch)group’slog pto[winsor_q, 1-winsor_q]before taking the batch mean/σ. The defaultNoneuses the plain mean/σ of the log-normal — the paper’s method (Vlasova et al. 2026, Shapiro–Wilk-validated). Winsorization is a robustness knob for the noisy usage-as-features regime (many shallow / RNA-seq repertoires used for UMAP etc.), not for deep-repertoire correction.
- Returns:
A long frame with one row per
(sample, locus, v_call, j_call)over the union of genes per locus, with columns:sample_id,batch,locus,v_call,j_call,count,p(smoothed raw usage probability) andp_corrected(batch-corrected, renormalised usage probability), sorted bysample_id, locus, v_call, j_call. Feed this straight intoapply_vj_correction().- Raises:
ValueError – If
sample_col/batch_colis missing ortransformis unknown.- Return type:
DataFrame
- vdjtools.preprocess.batch.apply_vj_correction(sample_df, corrected_usage, *, scope='vj', weighted=True, resample=True, sample_id=None, seed=0)[source]#
Apply batch-corrected V/J usage back to a sample’s clonotype table.
Each clonotype is reweighted by its gene’s correction factor
P_corrected(G) / P(G)(G= the clonotype’s V/J gene atscope), then either resampled or rescaled to a new clonotype table whose V/J usage matches the corrected usage. Port of legacy mirpy v2resample_to_gene_usage.- Parameters:
sample_df (DataFrame) – One sample’s canonical clonotype frame (
v_call,j_call,duplicate_count). Original allele calls are preserved on output.corrected_usage (DataFrame) – Output of
correct_vj_usage(). If it covers more than one sample, passsample_id(or slice it) to select this sample’s rows.scope (str) – Gene scope for the correction key:
"vj"(default, per V-J pair),"v", or"j"(the VJ usage is marginalised for"v"/"j").weighted (bool) – If
True(default) the resampling weight isduplicate_count * factor(roulette-wheel over reads); ifFalseit isfactor(over clonotypes).resample (bool) – If
True(default), multinomial roulette-wheel resample to a new integer-count table at the sample’s original total read count (Vlasova et al. 2026). IfFalse, deterministically rescale to the expected counts.sample_id (str | None) – The sample to select from
corrected_usagewhen it holds several.seed (int) – Seed for the multinomial resample (
resample=True).
- Returns:
A canonical clonotype frame (same columns as
sample_df) with correctedduplicate_countand recomputedfrequency; zero-count clonotypes dropped.- Raises:
ValueError – If
corrected_usagecovers multiple samples and none is selected, ifscopeis unknown, or if no clonotype has a positive corrected weight.- Return type:
DataFrame
Repertoire dynamics (vdjtools.dynamics)#
Longitudinal within-donor clonotype testing across timepoints. Three complementary tools: a per-clonotype paired test (Ayestaran 2024) with per-pair effective sample size, deterministic downscale, two-tailed Fisher, and a five-way classification (emergent / expanded / persistent / contracted / vanishing); the same test on 1-Hamming / 1-Levenshtein CDR3 metaclonotype groups; and the VDJtrack size-bucket recapture model with an edgeR NB-exact caller (Pavlova, Zvyagin & Shugay 2024). The sibling of vdjtools.biomarker — that tests incidence across subjects, this tests frequency across timepoints.
vdjtools.dynamics.paired#
Per-pair N_eff estimation and the paired clonotype test.
Paired within-donor clonotype test: per-pair N_eff, downscale, two-tailed Fisher.
The method is Ayestaran (PhD, Cambridge 2024), Ch. 2. Sequencing is a two-step sampling
process — a true frequency f is sampled into N_S1 molecules, which are sampled into
N_seq reads — so the sample size driving the noise is the harmonic sum (Eq. 2.5–2.6):
1 / N_eff = 1 / N_S1 + 1 / N_seq
and is dominated by the smaller step. Where N_S1 << N_seq (the usual case: ~1e6 PBMCs
into ~2e7 reads) the library is heavily oversampled, the observed counts are far noisier than
N_seq implies, and a Fisher test that assumes N_seq is the sample size calls enormous
numbers of clones changed when nothing changed (thesis Fig. 2.13).
N_eff is a property of the pair, not of a sample or a cohort: it is read off the
mean–variance relationship between two samples, so a global N_eff is not merely a bad
idea, it is undefined. That is why _downscale() is private — a public “rescale to N”
would invite exactly the cohort-wide normalisation this method exists to avoid.
Estimation fits the thesis’s Eq. 2.3, log σ² = log f + log(1/N), with the slope fixed at
1 — which is just a weighted mean of log σ² − log f, so it needs numpy, not a regression
library. It differs from the thesis in which variance it fits, and that choice is
load-bearing enough to record here.
The thesis (p. 19) bins clones by their frequency in sample B and takes the mean and
variance of the A frequencies in each bin, then multiplies by 2: binning on the noisy
f_B instead of the true f mixes a range of true frequencies into each bin, roughly
doubling the apparent variance, so the fitted value is half the real N_eff.
We instead bin on the mean f̄ = (f_A + f_B)/2 and fit var(f_A − f_B) = 2f/N. The
difference cancels the true frequency, so there is no mis-binning to correct and no
correction factor to mis-set — arguably closer to Eq. 2.3’s own derivation, which assumed
f known.
Why, measured on simulated two-step data against a planted N_eff (see
tests/python/test_dynamics_paired.py): the ×2 is exact only when the clone-size
distribution is flat, because only then is the bin contamination equal to the sampling
variance. Real repertoires are heavy-tailed, where the contamination is smaller and a fixed
×2 overshoots by ~25%. That is not cosmetic — it inflates the false-positive rate ~1.27×
and fails the thesis’s own p-value-uniformity acceptance test (Fig. 2.13) in 3 of 4 regimes,
including precisely the oversampled ones (N_S1 << N_seq) the method exists to handle:
regime (N_S1/N_seq) planted thesis x2 gate difference gate
200k / 2M 181,818 1.25x FAIL 1.07x pass
50k / 2M 48,780 1.22x FAIL 1.02x pass
200k / 1M 166,667 1.23x FAIL 0.99x pass
1M / 300k 230,769 0.97x pass 0.95x pass
The test itself is not at fault: pinned to the planted N_eff it is perfectly calibrated
(conservative, as an exact test on discrete counts should be). The bias was entirely the
estimator’s.
The test itself is Eq. 2.4: conditioning on the total r1 + r2 cancels the unknown true
frequency exactly, leaving a hypergeometric — i.e. a two-tailed Fisher exact test on
[[c_a, c_b], [R_a - c_a, R_b - c_b]]. It uses the minimum-likelihood two-sided
convention (R / scipy), because rounding leaves the two library sizes near-equal rather than
equal, and on near-equal margins the doubling convention differs by up to 2x.
- vdjtools.dynamics.paired.DEFAULT_KEY = ('junction_aa', 'v_call', 'j_call')#
Default clonotype match key (CDR3 aa + V + J), as in
vdjtools.overlap.track.
- vdjtools.dynamics.paired.CLASSES = ('emergent', 'expanded', 'persistent', 'contracted', 'vanishing', 'untested')#
The five dynamics classes, plus
untestedfor clones below the testability floor.
- vdjtools.dynamics.paired.estimate_neff(a, b, *, key=('junction_aa', 'v_call', 'j_call'), min_count=2, bins=25, min_bin=10, ref_n=None)[source]#
Estimate the pair’s effective sample size from its own mean–variance scaling.
- Parameters:
a (DataFrame) – First sample of the pair (canonical clonotype frame).
b (DataFrame) – Second sample of the pair.
key – Clonotype match key (default CDR3 aa + V + J).
min_count (int) – Discreteness floor for the fit — clonotypes whose mean frequency across the pair is below this many counts are excluded from the mean–variance fit, because at 1–2 counts the frequency is too discrete to estimate a variance from. This is not the testability floor used by
test_pair()(see itsmin_total); it is the most sensitive knob here, so the benchmark sweeps it rather than trusting a default.bins (int) – Number of log-spaced frequency bins.
min_bin (int) – Minimum clonotypes in a bin for it to contribute to the fit.
ref_n (float | None) – Reference sample size for the outlier pre-filter.
None(default) derives it from the data with an unfiltered first pass — the thesis hard-codes 200,000, which is a property of its cohort (2x one of our datasets’ whole libraries and 33x another’s), not a constant. Pass a number to pin it.
- Returns:
The estimated
N_eff.- Raises:
ValueError – If fewer than two frequency bins are usable — a pair too shallow, or with too few shared clonotypes, to fit. Never returns a silent fallback: a wrong
N_effsilently mis-scales every downstream p-value.- Return type:
- vdjtools.dynamics.paired.test_pair(a, b, *, key=('junction_aa', 'v_call', 'j_call'), neff='auto', min_total=6, alpha=0.01, **neff_kw)[source]#
Test every clonotype for a within-donor frequency change between two samples.
Downscales both samples to the pair’s
N_effand applies a two-tailed Fisher exact test to[[c_a, c_b], [R_a - c_a, R_b - c_b]]. Conditioning onc_a + c_bcancels the unknown true frequency exactly (thesis Eq. 2.4), so the test needs no estimate of it.- Parameters:
a (DataFrame) – The earlier sample (e.g. pre-vaccination).
b (DataFrame) – The later sample (e.g. post-vaccination). Direction is reported b-relative-to-a.
key – Clonotype match key.
neff (float | str) –
"auto"estimates it from the pair viaestimate_neff(); a float pins it;Noneskips the downscale entirely — correct only when the counts are already molecule/UMI counts rather than reads (thesis p. 86), since then there is no oversampling to undo.min_total (int) – Testability floor — clonotypes with a combined downscaled count below this are classed
untestedrather than tested and called non-significant. Below ~6 the discrete hypergeometric cannot reach a small p at all, so testing them only costs multiple-testing burden. Distinct fromestimate_neff’smin_count.alpha (float) – FDR threshold (Benjamini–Hochberg q) for calling a change significant.
**neff_kw – Forwarded to
estimate_neff()whenneff="auto".
- Returns:
One row per clonotype with the
keycolumns,count_a/count_b(downscaled),f_a/f_b(original within-sample frequencies),p_value,q_valueanddynamics— one ofemergent(absent froma),expanded,persistent(no evidence of change),contracted,vanishing(absent fromb), oruntested. The classes partition the frame.- Raises:
ValueError – If
neff="auto"and the pair cannot be fit (seeestimate_neff()).- Return type:
DataFrame
vdjtools.dynamics.groups#
Metaclonotype (1-Hamming / 1-Levenshtein CDR3 ball) grouping before the paired test.
Metaclonotype-grouped dynamics — cluster near-variant clonotypes, then test the group.
A vaccine- or antigen-driven response is often convergent: many T-cell clones with slightly
different CDR3s recognise the same epitope, so the expansion signal is spread thin across a
family of near-variants and a per-exact-clonotype test (vdjtools.dynamics.test_pair()) sees
each one only weakly. Collapsing a CDR3-neighbourhood into one metaclonotype first, then
running the same paired test on the group, concentrates that signal.
The grouping is delegated to vdjtools.biomarker.metaclonotypes() (native seqtree /
vdjmatch fuzzy search, single-linkage components) — scope="1,0,0,1" is a 1-substitution
(Hamming) ball, "1,1,1,1" a 1-edit (Levenshtein) ball. This is the VDJtrack 1-mismatch
annotation-expansion (example.Rmd / vaccination.Rmd) generalised into the test itself.
- vdjtools.dynamics.groups.test_metaclonotypes(a, b, *, scope='1,0,0,1', match_v=True, match_j=True, threads=0, **test_kw)[source]#
Group CDR3-neighbour clonotypes into metaclonotypes, then
test_pair()the groups.The union of both samples’ clonotypes is clustered once into metaclonotypes; each sample’s clonotypes are relabelled by
meta_id; and the paired within-donor test runs on the per-meta_idsummed counts — so a convergent expansion counts as a single feature.- Parameters:
a (DataFrame) – The earlier sample (canonical clonotype frame).
b (DataFrame) – The later sample.
scope (str) – vdjmatch edit scope
"subs,ins,dels,total"—"1,0,0,1"(default) is a 1-substitution (Hamming) ball,"1,1,1,1"a 1-edit (Levenshtein) ball,"0,0,0,0"reduces to exact grouping.match_v (bool) – Require the same
v_callto group two clonotypes.match_j (bool) – Require the same
j_callto group.threads (int) – Worker threads for the native search (
0= all cores).**test_kw – Forwarded to
test_pair()(e.g.neff,min_total,alpha).
- Returns:
test_pair()’s output keyed bymeta_id, plus a representativejunction_aa(andv_call/j_callwhen matched) andn_variantsper group, sorted byq_value.- Raises:
ImportError – If vdjmatch is not importable (it is a base dependency).
- Return type:
DataFrame
vdjtools.dynamics.capture#
The VDJtrack recapture model: size buckets, Poisson capture, Beta credible intervals, log-linear group test.
Clonotype recapture model — the VDJtrack size-bucket capture test.
Where vdjtools.dynamics.test_pair() calls each clonotype expanded/contracted, the
capture model asks a group question: does an annotated set of clonotypes (e.g. antigen-
specific, or “emerging”) persist / recapture across a time course more than the rest,
after controlling for what drives recapture on its own — clonotype size and repertoire
diversity?
Under Poisson sampling a clonotype of population frequency f is recaptured in a sample of
depth R with probability P = 1 − exp(−f·R) (poisson_capture()), so recapture rate
rises monotonically with pre-sample size — singleton < doubleton < tripleton < large. The
model bins clonotypes into those four size classes, measures the recapture fraction per
(donor, group, size class), puts a Beta(captured, missing) posterior on it for uncertainty,
and tests the group effect with a log-linear model log(recapture) ~ size + group +
log(div_ratio) (and a per-bucket paired test across donors).
Port of the group’s VDJtrack R pipeline (github.com/antigenomics/vdjtrack); the method is
Pavlova, Zvyagin & Shugay, Front Immunol 2024, DOI 10.3389/fimmu.2024.1321603. The two-step
sampling correction and the per-clonotype test live in vdjtools.dynamics.paired.
- vdjtools.dynamics.capture.SIZE_CLASSES = ('singleton', 'doubleton', 'tripleton', 'large')#
Pre-sample size classes, ordered (singleton=1, doubleton=2, tripleton=3, large=4+).
- vdjtools.dynamics.capture.size_class(count='duplicate_count')[source]#
Polars expression classifying a clonotype count into a
SIZE_CLASSESbucket.- Parameters:
count (str | Expr)
- Return type:
Expr
- vdjtools.dynamics.capture.poisson_capture(freq, depth)[source]#
Poisson recapture probability
P = 1 − exp(−f·R).The probability that a clonotype of population frequency
freqyields at least one molecule in a sample ofdepth(total reads/UMIs) — the generative model the recapture curve is read against (Pavlova 2024; aging_capture_model.Rmd).- Parameters:
freq – Clonotype population frequency (scalar or array).
depth – Sampling depth
R(scalar or array).
- Returns:
The capture probability, same shape as the broadcast of
freqanddepth.- Return type:
ndarray
- vdjtools.dynamics.capture.capture_rates(pre, post, *, key=('junction_aa', 'v_call', 'j_call'), group_col=None, donor=None)[source]#
Recapture fraction per (size class[, group]) between one
preandpostsample.Each
preclonotype is binned by its pre-sample size (SIZE_CLASSES) and marked captured if itskeyreappears inpost. Rows are then counted per size class (andgroup_collevel, if given):n_captured= α,n_total= α+β, and the recapture rate gets aBeta(α+1, β+1)posterior (Laplace) with a 95% credible interval.- Parameters:
pre (DataFrame) – The earlier sample (canonical clonotype frame).
post (DataFrame) – The later sample.
key – Clonotype match key (default CDR3 aa + V + J).
group_col (str | None) – A column of
precarrying a group label (e.g. antigen specificity). IfNone, all clonotypes are one group"all".donor (str | None) – If given, added as a constant
donorcolumn (forcapture_test()over a cohort assembled by concatenating per-donor calls).
- Returns:
One row per (
donor,)group,size_classwithn_captured,n_total,capture_rate(posterior mean),ci_lo,ci_hi.- Return type:
DataFrame
- vdjtools.dynamics.capture.capture_test(rates, *, group_col='group', div_ratio=None)[source]#
Test the group effect on recapture with a log-linear model.
Fits
log(capture_rate) ~ size_class + group [+ log(div_ratio)]by OLS (the VDJtracklm; example.Rmd), so thegroupcoefficient’s p-value asks whether the annotated group recaptures differently than baseline after accounting for clonotype size and (if supplied) the per-donor diversity ratio. Size classes and group are dummy-coded (first level = reference); a numericlog(div_ratio)is added per donor whendiv_ratiois given.- Parameters:
rates (DataFrame) – Stacked
capture_rates()output (one row per donor × group × size class).group_col (str) – The group column (default
"group"); coded against its first level.div_ratio (dict | None) – Optional
{donor: div_after/div_before}— addslog(div_ratio)as a covariate (needs adonorcolumn inrates).
- Returns:
A coefficient table (
term,estimate,std_error,p_value); thegrouprow is the effect of interest.- Return type:
DataFrame
- vdjtools.dynamics.capture.capture_paired_test(rates, *, group_col='group', donor_col='donor')[source]#
Per-size-class paired t-test of log recapture, group vs baseline across donors.
The VDJtrack per-bucket test (vaccination.Rmd): within each size class, pair donors and compare the two group levels’
log(capture_rate)with a paired t-test — robust to the donor-to-donor baseline that the log-linear model absorbs into an intercept. Requires exactly two group levels.- Parameters:
rates (DataFrame) – Stacked
capture_rates()output.group_col (str) – The two-level group column.
donor_col (str) – The donor column to pair on.
- Returns:
size_class,n_pairs,t,p_value(null where fewer than two complete donor pairs exist).- Return type:
One row per size class
vdjtools.dynamics.expansion#
The edgeR-style negative-binomial exact expansion caller (TMM + qCML + beta-binomial exact test).
edgeR-style negative-binomial exact test for clonotype expansion between two timepoints.
The complementary per-clone caller of Pavlova, Zvyagin & Shugay 2024 (§2.5): the classic
edgeR pipeline — TMM library normalization, a common dispersion by quantile-adjusted
conditional maximum likelihood (qCML), and the negative-binomial exact test — used to call
vaccine-associated clonotypes (|log2 FC| >= 5 & p <= 0.01 in the paper).
For two libraries with one replicate each, the NB exact test collapses to a closed form: after
equalizing library sizes, each count is NB(mean=λ, size=r=1/φ) and the conditional law of
y_a given the total t = y_a + y_b is Beta-Binomial(t, r, r) — λ cancels — so both the
qCML dispersion fit and the two-sided (“minimum-likelihood”) exact p-value are beta-binomial
computations (scipy.stats.betabinom). This is the same reduction edgeR’s exactTest uses.
The per-clonotype vdjtools.dynamics.test_pair() (Ayestaran N_eff + Fisher) is the default
caller; this one models over-dispersion explicitly instead of correcting the sample size.
- vdjtools.dynamics.expansion.expansion_test(a, b, *, key=('junction_aa', 'v_call', 'j_call'), dispersion='auto', min_total=6, log2fc=1.0, alpha=0.05)[source]#
Call clonotypes expanded/contracted between two samples by the edgeR NB exact test.
- Parameters:
a (DataFrame) – The earlier sample (canonical clonotype frame).
b (DataFrame) – The later sample (direction is b relative to a).
key – Clonotype match key (default CDR3 aa + V + J).
dispersion (float | str) –
"auto"fits the common φ by qCML; a float pins it.min_total (int) – Clonotypes with a combined equalized count below this are left
untested(the discrete tail cannot reach a small p).log2fc (float) – A clonotype is called only if
|log2 FC| >= log2fcandq < alpha. The paper used5for vaccine-association;1(a doubling) is a gentler default.alpha (float) – BH-FDR threshold.
- Returns:
the
keycolumns,count_a/count_b(raw),q_a/q_b(equalized),log2fc,p_value,q_value,dispersion, andcall— one ofexpanded/contracted/unchanged/untested. Sorted byq_value.- Return type:
One row per clonotype
Biomarker association (vdjtools.biomarker)#
Incidence-based clonotype-association testing across a cohort of repertoires (Emerson 2017, Howie 2015, De Witt 2018, Vlasova 2026): feature-vs-condition association (Fisher / χ² / Bayesian / permutation; binary, category, or Cochran–Mantel–Haenszel stratified conditions) and feature-vs-feature co-occurrence (α-β pairing, same-chain co-specificity), with exact or 1-mismatch matching and metaclonotype grouping.
vdjtools.biomarker.association#
General incidence association and candidate selection.
General incidence-based biomarker association across a cohort of repertoires.
Generalises the Emerson-2017 Fisher test (vdjtools.biomarker.fisher_association()) along
four axes, all sharing the same streamed subject-incidence table:
statistical test — Fisher, χ², a Bayesian log-odds posterior, a Beta-Binomial Bayes factor, or a label permutation (
vdjtools.biomarker.stats);condition type — binary, a category expanded one-vs-rest per level (HLA allele, zygosity), or a paired condition combined by Cochran–Mantel–Haenszel (built with
vdjtools.biomarker.condition);match scope — exact
cdr3aa/+v/+v+j(thekey), afuzzy1-mismatch search (each candidate keeps its identity and gains incidence), or1mmmetaclonotypes (candidates are merged into groups);candidate set — all public features (
min_incidencecount ormin_incidence_fracfraction of subjects), or an explicitcandidateslist.
The heavy step (the incidence table) is one streamed group_by over a
vdjtools.io.scan_cohort() LazyFrame; every test is then vectorised numpy over the whole
feature table. For match="fuzzy", that step is a cohort-wide search independent of the
phenotype design — prepare_fuzzy_features() builds it once for reuse across many designs
(association(..., features=...)) instead of paying its cost on every call.
- class vdjtools.biomarker.association.FeatureFrame(feat_lf, idcols, rep)[source]#
Bases:
objectA precomputed
association()feature frame, reusable across many designs.Built once by
prepare_fuzzy_features(); pass toassociation()asfeatures=to skip re-running the fuzzy search when testing many phenotype designs against the same cohort/key/candidates/scope.- feat_lf: LazyFrame#
- vdjtools.biomarker.association.prepare_fuzzy_features(cohort, key=('junction_aa', 'v_call', 'j_call'), *, candidates=None, scope='1,0,0,1', productive_only=True, strip_allele=True, threads=0)[source]#
Precompute a
match="fuzzy"feature frame once, for reuse across many designs.The fuzzy search (collecting the cohort’s full per-locus CDR3 universe, then
vdjmatch.cluster.overlap()) depends only on(cohort, key, candidates, scope)— never on the phenotype design. A caller testing many designs against the same cohort (e.g. one HLA gene per call, same candidates every time) should build this once and pass it to everyassociation()call viafeatures=, instead of paying the full search cost on every call.Example
>>> feats = prepare_fuzzy_features(cohort, key=("junction_aa", "v_call"), ... candidates=candidates) >>> for design in designs: ... association(cohort, design, features=feats)
- vdjtools.biomarker.association.select_candidates(cohort, *, key=('junction_aa', 'v_call', 'j_call'), match='exact', min_incidence=2, min_incidence_frac=None, productive_only=True, strip_allele=True, scope='1,0,0,1', threads=0)[source]#
Public features whose subject incidence clears a count and/or fraction threshold.
min_incidence_frac(e.g.0.05= 5% of subjects) is resolved against the cohort’s distinctsample_idcount and combined withmin_incidencebymax. Returns the feature key columns (ormeta_id+ representative key formatch="1mm") plusincidence, sorted by descending incidence.
- vdjtools.biomarker.association.association(cohort, phenotype, *, pheno_col=None, level_col=None, stratum_col=None, test='fisher', key=('junction_aa', 'v_call', 'j_call'), match='exact', min_incidence=2, min_incidence_frac=None, candidates=None, alternative='greater', productive_only=True, strip_allele=True, scope='1,0,0,1', n_perm=1000, seed=0, threads=0, features=None)[source]#
Test each clonotype feature’s subject incidence against a condition.
- Parameters:
cohort (LazyFrame | DataFrame) – Clonotype cohort — a streamed
vdjtools.io.scan_cohort()LazyFrame or apolars.DataFramewithsample_id+ thekeycolumns.phenotype (DataFrame | LazyFrame) – A design frame — one row per subject (or subject × level) with
sample_idand either the reserved_pos/_level/_stratumcolumns (fromvdjtools.biomarker.condition) or a plain binarypheno_col.pheno_col (str | None) – Binary phenotype column (if
phenotypehas no_pos).level_col (str | None) – Category-level column → one test per level (adds a
levelcolumn). Cost is multiplicative, not additive, in the number of levels: the feature-join at the heart of association() duplicates every matched feature row once per level the design carries for that sample (a legitimate consequence of each level needing its own incidence table), so a design with many levels (e.g. one row per HLA allele) can dominate memory even though the feature frame itself costs the same regardless of how many levels the design has.stratum_col (str | None) – Stratum column → the tests are combined by Cochran–Mantel–Haenszel.
test (str | list[str]) – One test or a list of
{"fisher", "chi2", "bayes_logodds", "bayes_bf", "permutation"}(long output, one row per feature×level×test). Ignored whenstratum_colis set (CMH is used).key (tuple[str, ...]) – Feature key (subset of
(junction_aa, v_call, j_call)) — the V/J match scope.match (str) –
"exact","fuzzy"or"1mm"."exact"— thekeyitself."fuzzy"— a 1-mismatch search: a candidate’s incidence counts every subject carrying anything withinscopeof it, but the candidate keeps its own identity. Non-junction_aakey columns still have to match exactly, sokey=("junction_aa", "v_call")pins the germline half while the CDR3 varies. This is the discovery mode."1mm"— metaclonotypes: candidates withinscopeare merged and the group is tested (meta_idreplaces the key). A downstream step, not discovery — merging dilutes a strong member into its neighbourhood.
min_incidence (int) – Minimum subjects a feature must appear in.
min_incidence_frac (float | None) – Alternative/added fraction-of-subjects threshold (e.g.
0.05).candidates (DataFrame | None) – Restrict testing to these feature keys (a frame with the
keycolumns).alternative (str) –
"greater"/"less"/"two-sided"(Fisher/permutation).n_perm (int) – Permutation settings.
seed (int) – Permutation settings.
features (FeatureFrame | None) – A precomputed
FeatureFramefromprepare_fuzzy_features(), reused across designs that share the same cohort/key/candidates/scope. When set,key,match,candidates,scope,productive_onlyandstrip_alleleare ignored — they are already baked intofeatures.productive_only (bool)
strip_allele (bool)
scope (str)
threads (int)
- Returns:
feature key (or
meta_id+ representative key +n_members), optionallevel, thenincidence, n_pos_present, n_neg_present, n_pos, n_neg, odds_ratio, log2_or, direction, testand the per-test statisticsp_value, q_value(Fisher/χ²/ permutation),logor, logor_ci_lo, logor_ci_hi, p_or_gt1(Bayesian log-odds), orlog_bf(Beta-Binomial). CMH addsor_mh. Sorted byp_valuewhere present.- Return type:
Long frame
vdjtools.biomarker.cooccurrence#
Feature-vs-feature co-occurrence (in-silico α-β pairing, same-chain co-specificity).
Feature-vs-feature co-occurrence across a cohort — in-silico α-β pairing & co-specificity.
Two clonotype features that co-occur across subjects more than expected under independence are
candidate pairs: a TRA and a TRB from the same clone (Howie 2015 pairSEQ, with subjects
playing the role of wells; Vlasova 2026 in-silico α-β pairing), or two same-chain clonotypes
that recognise the same antigen (De Witt 2018 co-occurrence patterns). This is the same
subject-incidence machinery as vdjtools.biomarker.association(), applied to a pair of
features: per pair a 2×2 incidence table over the n subjects profiled for both chains,
has A
n_AB…
no A
…
…
is scored by the lift θ = n·n_AB/(n_A·n_B) (Vlasova; observed / expected co-occurrence),
Fisher’s exact / χ², and Benjamini-Hochberg FDR. evalue=True adds the expected count and a
Poisson upper-tail E-value (the classic control-calibrated co-occurrence significance). The
candidate features per chain are bounded by an incidence threshold (and max_features); the
returned pairs are those with at least min_cooccurrence co-occurrences.
Repertoire depth is a common cause and is corrected by default. A deep repertoire is more
likely to contain any clonotype, so two entirely independent clonotypes co-occur across
subjects purely because deep subjects tend to carry both. The induced lift is 1 + CV²(N)
for rare clonotypes — independent of biology (0.899 → 1.81 on the FMBA covid19 cohort) — and
a pooled Fisher test is badly miscalibrated by it: on simulated independent pairs at the
~11%-incidence regime max_features steers callers into, pooled Fisher declares 45–49% of
them significant at p<0.05. depth_strata (default 10) therefore scores each pair by
Cochran–Mantel–Haenszel across equal-count per-subject depth strata, which restores
calibration (measured false-positive rate 0.023–0.057) at equal power. depth_strata=0
restores the pooled test. Note this corrects depth only — shared HLA, ancestry, batch and
exposure also induce cross-subject co-occurrence, and none of them is fixed here; see the
warning in docs/usage.rst.
- vdjtools.biomarker.cooccurrence.cooccurrence(cohort, *, chain_a='TRA', chain_b='TRB', key=('junction_aa', 'v_call', 'j_call'), match='exact', test='fisher', min_incidence=2, min_incidence_frac=None, min_cooccurrence=2, candidates_a=None, candidates_b=None, evalue=False, alternative='greater', depth_strata=10, max_features=2000, productive_only=True, strip_allele=True, scope='1,0,0,1', threads=0)[source]#
Score co-occurrence of feature pairs across the subjects profiled for both chains.
- Parameters:
cohort (LazyFrame | DataFrame) – Clonotype cohort with a
sample_idcolumn (both chains present per subject for α-β pairing).chain_a (str) – Loci to pair (e.g.
"TRA"/"TRB").chain_b=None(or equal tochain_a) does same-chain pairs (upper triangle, self-pairs excluded).chain_b (str | None) – Loci to pair (e.g.
"TRA"/"TRB").chain_b=None(or equal tochain_a) does same-chain pairs (upper triangle, self-pairs excluded).key (tuple[str, ...]) – Feature definition and exact/1mm scope (as in
association()).match (str) – Feature definition and exact/1mm scope (as in
association()).test (str) –
"fisher"or"chi2"for the per-pair 2×2 p-value. Ignored when depth conditioning is active (CMH is used), mirroringassociation()’s stratified branch. It is used when the guards below make conditioning fall back to pooled.alternative (str) –
"greater"(default; co-occurrence — the usual question),"less"(mutual exclusion) or"two-sided". Shapes the Fisher p (and, under CMH, the halving of the two-sided χ² towardor_mh). It does not shape a plaintest="chi2"p on the pooled branch: Pearson χ² is inherently two-sided, so a depth-conditioning fallback withtest="chi2"reports the same two-sided p regardless ofalternative— readdirection/odds_ratiofor the sign there.min_incidence (int) – Candidate-feature incidence threshold per chain.
min_incidence_frac (float | None) – Candidate-feature incidence threshold per chain.
min_cooccurrence (int) – Keep only pairs co-occurring in ≥ this many subjects.
candidates_a (DataFrame | None) – Restrict each chain’s features to these keys.
candidates_b (DataFrame | None) – Restrict each chain’s features to these keys.
evalue (bool) – Also report
expected(=n_A·n_B/n) and a Poisson upper-taile_value.depth_strata (int) – Number of equal-count per-subject depth strata to condition on (Cochran–Mantel–Haenszel). Depth is a common cause of co-occurrence, so the pooled test is anticonservative; the default corrects it.
0→ pooledtest(uncorrected; kept as the oracle and the De Witt-comparable path). Subject depth is the number of distinctkeyfeatures that subject contributes to the analysed chains — derived fromcohort, never supplied, so it cannot disagree with the data. Conditioning needs both a reason and enough data, so it falls back to the pooled test (with a warning) when depth barely varies (CV < 0.45, i.e. an induced lift under ~1.2) or the cohort cannot fill ≥20 subjects per stratum (<40 subjects). When that happensor_mh/chi2are absent andtestapplies.max_features (int) – Cap candidate features per chain (top by incidence); logs if it truncates.
productive_only (bool)
strip_allele (bool)
scope (str)
threads (int)
- Returns:
a_<key>/b_<key>columns,n(subjects with both chains),n_a,n_b,n_ab,theta(lift),odds_ratio,log2_or,p_value,q_value(BH), and — withevalue—expected,e_value. Withdepth_strata > 0alsoor_mhandchi2(the CMH estimate thep_valuecomes from). Sorted byp_value.theta/odds_ratioremain the pooled, depth-uncorrected lift — compare them againstor_mh, which is conditioned on depth.- Return type:
One row per surviving pair
vdjtools.biomarker.condition#
Phenotype-design builders (binary, categorical, HLA alleles, zygosity, CMH strata).
Turn subject metadata into the design frame the association engine consumes.
A design frame has one row per subject (or per subject × level) with the reserved columns
_pos— boolean, the condition-positive indicator for that (subject, level);_level— optional, the category level (HLA allele, homo/hetero, …); absent ⇒ a single binary test;_stratum— optional, the Cochran–Mantel–Haenszel stratum; absent ⇒ unstratified.
vdjtools.biomarker.association() reads exactly these columns, so any of the builders
below (or a hand-built frame with the same columns) can drive it. All keep the subject key
column sample_id.
- vdjtools.biomarker.condition.binary(meta, col, *, sample_col='sample_id')[source]#
Design frame for a binary condition (e.g. CMV
+/−); unknown labels dropped.
- vdjtools.biomarker.condition.categorical(meta, col, *, min_level_size=1, sample_col='sample_id')[source]#
One-vs-rest design for a single-label category (each subject in exactly one level).
Emits the full subject × level cross with
_pos = (col == level). Levels carried by fewer thanmin_level_sizesubjects are dropped. Null category values are excluded.
- vdjtools.biomarker.condition.hla_alleles(meta, cols, *, resolution=None, min_level_size=1, sample_col='sample_id')[source]#
Multi-label design over HLA alleles: per allele, carriers vs non-carriers.
colsare the allele columns for a locus (e.g.["HLA-A.1", "HLA-A.2"]or the 4-digit["sample.HLA-A.1", "sample.HLA-A.2"]). A subject is_posfor every allele it carries.resolutiontrims the allele to that many colon-separated fields (resolution=1→A*02);Nonekeeps the field as written. Alleles carried by fewer thanmin_level_sizesubjects are dropped.HLA-untyped subjects are dropped, not counted as non-carriers — consistent with
binary()/zygosity(), which drop unknown phenotypes. Treating an untyped subject as a non-carrier of every allele silently inflates the negative arm and biases the odds ratio anticonservatively.
- vdjtools.biomarker.condition.zygosity(meta, locus_cols, *, sample_col='sample_id')[source]#
Binary homozygous(
_pos=True)/heterozygous design for a locus’s two allele columns.
vdjtools.biomarker.stats#
Vectorised 2×2 test kernels (Fisher, χ², Bayesian, CMH, permutation, FDR).
Vectorised 2×2 contingency-test kernels for biomarker association / co-occurrence.
Every kernel takes the four cells of the subject-incidence table as numpy int arrays
(one entry per feature / feature-pair) and returns numpy arrays — there is no per-feature
Python loop, so millions of features score in a handful of scipy calls. The cell
convention throughout is
feature present
a
bfeature absent
c
d
so a = subjects that are condition-positive and carry the feature, n = a+b+c+d,
n_pos = a+c, n_neg = b+d, present = a+b. Kernels: Fisher (hypergeometric tail),
Pearson χ² (Yates), Haldane odds ratio, a normal-approximation Bayesian posterior over the
log odds-ratio, a Beta-Binomial Bayes factor, Cochran–Mantel–Haenszel (stratified), a
label-permutation null, and Benjamini-Hochberg FDR.
- vdjtools.biomarker.stats.fisher_p(a, b, c, d, alternative='greater')[source]#
Fisher’s exact p-value per feature, as the hypergeometric tail (vectorised).
"greater"tests enrichment in condition+ (one-tailed, the CMV setting),"less"depletion,"two-sided"either (doubled smaller tail, capped at 1) — identical to the convention invdjtools.biomarker.fisher_association()."two-sided-minlike"is the minimum-likelihood two-sided convention used by R’sfisher.testandscipy.stats.fisher_exact(): the sum of the likelihood of every table at most as likely as the observed one. It differs from"two-sided"whenever the margins are not exactly symmetric — measured on paired-dynamics-shaped tables, exactly equal margins agree 300/300, but margins differing by rounding alone disagree 171/300 with"two-sided"up to exactly 2× larger. Use it when comparing against R or scipy, or when the margins are only near-equal;"two-sided"is kept as the default two-sided convention because the association/co-occurrence results were computed with it.- Parameters:
alternative (str)
- Return type:
ndarray
- vdjtools.biomarker.stats.chi2_p(a, b, c, d, yates=True)[source]#
Pearson χ² p-value per feature for the 2×2 table (Yates-corrected by default).
Matches
scipy.stats.chi2_contingency([[a,b],[c,d]], correction=yates). Any table with an empty margin yields χ²=0, p=1.- Parameters:
yates (bool)
- Return type:
ndarray
- vdjtools.biomarker.stats.odds_ratio(a, b, c, d)[source]#
Haldane–Anscombe (
+0.5) odds ratio per feature — never 0/∞.- Return type:
ndarray
- vdjtools.biomarker.stats.direction(a, b, c, d)[source]#
"enriched"if the feature is over-represented in condition+, else"depleted".- Return type:
ndarray
- vdjtools.biomarker.stats.bayes_logodds(a, b, c, d, alpha=0.5)[source]#
Normal-approximation Bayesian posterior over the log odds-ratio (Woolf).
With a flat prior on
log ORthe posterior isNormal(logor, se²)wherelogoris the Haldane-corrected log odds-ratio andse² = Σ 1/(cell+alpha)(Woolf standard error;alpha=0.5= Haldane). Returnslogor,se, a 95% credible interval (ci_lo/ci_hi), andp_or_gt1 = P(OR>1) = Φ(logor/se).
- vdjtools.biomarker.stats.bayes_bf(a, b, c, d, alpha=1.0, beta=1.0)[source]#
log Bayes factor BF₁₀ for association, Beta-Binomial (analytic, vectorised).
Compares H₁ (the feature’s presence rate differs between conditions: independent
Beta(alpha,beta)priors on the two rates) against H₀ (a single shared rate). The binomial coefficients cancel, leavinglog BF₁₀ = betaln(a+α, c+β) + betaln(b+α, d+β) − betaln(α,β) − betaln(a+b+α, c+d+β)
(positive ⇒ evidence for association).
alpha=beta=1is a uniform prior.
- vdjtools.biomarker.stats.cmh(a, b, c, d)[source]#
Cochran–Mantel–Haenszel stratified test — combined OR + χ² over strata.
a,b,c,dare 2-D arrays of shape(n_features, n_strata)(the same 2×2 table per feature, one column per stratum). Returns the Mantel-Haenszel odds ratioor_mh, the continuity-correctedchi2statistic, and its 1-dfp_value. Strata with fewer than 2 subjects (n_k < 2) contribute nothing (their variance is undefined).
- vdjtools.biomarker.stats.permutation_p(present, labels, *, n_perm=1000, seed=0, alternative='greater')[source]#
Label-permutation p-value per feature from the subject-incidence matrix.
presentis a boolean(n_subjects, n_features)incidence matrix,labelsa boolean(n_subjects,)condition vector. Permutinglabelsholds each feature’s incidence fixed, so the null is exactly the hypergeometric one — this converges tofisher_p()asn_perm → ∞and is a robust check for sparse tables. Uses the add-one estimator(1 + #exceed)/(1 + n_perm). Seeded → reproducible.A permutation’s counts are
L @ presentwithLthe 0/1 permuted-label matrix, so a chunk of permutations is one BLAS call instead ofn_permfancy-index copies (~15x). The chunk still drawsrng.permutation(n_sub)once per permutation in the original order, so p-values are unchanged for a given seed; and summing 0/1 to an integer <= n_sub is exact in float64 whatever order BLAS accumulates in.
vdjtools.biomarker.fisher#
Fisher-exact incidence association (V/J-match, exact / 1mm) — the Emerson-2017 shortcut.
Incidence-based Fisher association — the Emerson-2017 method (back-compat entry point).
This is now a thin wrapper over vdjtools.biomarker.association() with test="fisher";
it exists so the original column schema and call signature keep working. New code that wants
other tests (χ², Bayesian, permutation), category / stratified conditions, candidate
selection, or co-occurrence should call vdjtools.biomarker.association() /
vdjtools.biomarker.cooccurrence() directly.
For each clonotype feature, a 2×2 subject-incidence table (present/absent × phenotype±) is tested with Fisher’s exact test (Emerson et al., Nat Genet 2017, doi:10.1038/ng.3822).
- vdjtools.biomarker.fisher.fisher_association(cohort, phenotype, *, pheno_col, key=('junction_aa', 'v_call', 'j_call'), match='exact', min_incidence=2, alternative='greater', productive_only=True, strip_allele=True, scope='1,0,0,1', threads=0)[source]#
Test each clonotype feature’s subject incidence against a binary phenotype (Fisher).
See
vdjtools.biomarker.association()for the arguments (this fixestest="fisher").- Returns:
One row per tested feature — the key columns (or
meta_id+ a representative key andn_membersformatch="1mm") followed byincidence, n_pos_present, n_neg_present, n_pos, n_neg, odds_ratio, log2_or, p_value, q_value(Benjamini-Hochberg) anddirection, sorted byp_value.- Parameters:
- Return type:
DataFrame
vdjtools.biomarker.metaclonotype#
Metaclonotype grouping (fuzzy CDR3 + V/J).
Metaclonotype grouping — collapse edit-scope-neighbour clonotypes into one feature.
Emerson-style incidence association tests each exact public TCRβ. A metaclonotype
instead groups clonotypes whose CDR3s are within an edit scope (default one substitution)
— optionally requiring the same V and/or J — so a family of near-variants counts as one
biomarker feature. The fuzzy search is not reimplemented: it is delegated to
vdjmatch.cluster.overlap() (native seqtree engine), exactly as
vdjtools.overlap.fuzzy does. Connected components of the within-scope neighbour
graph (single-linkage, union-find) become the metaclonotypes.
Scale: the unique clonotype keys are clustered once (not per subject). Partitioning by V and/or J means the all-pairs search only ever runs within one gene group, and the native call releases the GIL — so ~1M unique CDR3s are grouped in a handful of multi-threaded passes.
- vdjtools.biomarker.metaclonotype.metaclonotypes(clonotypes, *, scope='1,0,0,1', match_v=True, match_j=True, threads=0)[source]#
Group unique clonotype keys into metaclonotypes by CDR3 edit-scope neighbourhood.
Two keys share a
meta_idiff theirjunction_aaare withinscopeand they share a V call (whenmatch_v) and a J call (whenmatch_j). Grouping is single-linkage (connected components of the neighbour graph).- Parameters:
clonotypes (DataFrame) – A clonotype frame; must carry
junction_aaand, when the correspondingmatch_*flag is set,v_call/j_call.scope (str) – vdjmatch edit-distance scope
"subs,ins,dels,total"(default one substitution, length-preserving)."0,0,0,0"reduces to exact grouping.match_v (bool) – Require the same
v_callfor two keys to be grouped.match_j (bool) – Require the same
j_callfor two keys to be grouped.threads (int) – Worker threads for the native search (
0= all cores).
- Returns:
The distinct grouping keys (
junction_aaplusv_call/j_callas applicable) with an addedmeta_idcolumn (compact 0-based integer, singletons included).- Raises:
ImportError – If vdjmatch is not importable (it is a base dependency).
- Return type:
DataFrame
Single-cell interop (vdjtools.sc)#
Single-cell AIRR Cell / 10x paired-chain interop: contig ingestion, chain resolution and pairing, doublet / mispairing QC, cluster evaluation, and bridges to scirpy, dandelion and scRepertoire. See Single-cell for the narrative guide.
vdjtools.sc.read#
10x / AIRR-Cell / arda ingestion and AIRR Data File export.
10x / AIRR-Cell ingestion → a long Rearrangement frame keyed on cell_id.
The load-bearing single-cell representation here is deliberately flat: one row
per productive contig, canonical AIRR Rearrangement columns, plus a cell_id (the
cell barcode) tying a cell’s chains together and umi_count / clone_id carried
alongside. Everything downstream (pairing, QC, clustering) consumes that frame; the
AIRR Data File export (write_airr_cell()) is a secondary interchange layer.
10x CellRanger all_contig_annotations.csv names the junction column cdr3 (with
the conserved Cys/Phe-Trp anchors included) — content-identical to our junction_aa /
AIRR junction_aa convention (see io.schema) — and cdr3_nt for the
nucleotide junction; the reader maps those straight onto junction_aa / junction_nt.
- vdjtools.sc.read.VALID_LOCI: tuple[str, ...] = ('TRA', 'TRB', 'TRG', 'TRD', 'IGH', 'IGK', 'IGL')#
Receptor loci 10x can call;
Multi/None/ anything else is dropped.
- vdjtools.sc.read.SC_COLUMNS: list[str] = ['cell_id', 'sequence_id', 'locus', 'v_call', 'd_call', 'j_call', 'c_call', 'junction_aa', 'junction_nt', 'duplicate_count', 'umi_count', 'clone_id', 'productive']#
Canonical single-cell long-frame columns, in order.
productiveis a mandatory AIRR Rearrangement field and is carried sovdjtools.sc.to_airr()can emit a schema-valid table; the readers here keep only productive contigs, so it isTruewherever the source stated productivity at all.
- vdjtools.sc.read.read_10x(all_contig, consensus=None, *, require_cell=True, require_high_conf=True)[source]#
Read a 10x contig-annotation CSV into the canonical sc long frame.
Accepts both
all_contig_annotations.csv[.gz]andfiltered_contig_annotations.csv[.gz]— the two share one writer in CellRanger and so one column layout;filtered_is simply pre-restricted tois_cell && high_confidence, which this reader applies anyway.Keeps only productive, cell-associated, high-confidence contigs on a real receptor locus (
VALID_LOCI) with a resolved consensus id, one row per contig. When aconsensus_annotationsfile is supplied, the per-cell contig’s V/D/J calls are replaced by the matched consensus calls (joined on(raw_clonotype_id, raw_consensus_id) == (clonotype_id, consensus_id)).Column drift across CellRanger versions is tolerated: the
fwr*/cdr1/cdr2region columns exist only from CR6,exact_subclonotype_idfrom CR4+, andsampleonly incellranger multioutput — none is required here.raw_consensus_idis used when present and skipped when absent.NOTE: For CellRanger 4.0+, prefer
read_airr_cell()onairr_rearrangement.tsv— it is the same file the downstream tools read, so ingestion and interop agree.- Parameters:
all_contig (str | Path) – Path to
all_contig_annotations.csvorfiltered_contig_annotations.csv(.gzaccepted). Columns follow CellRanger VDJ (barcode, is_cell, contig_id, high_confidence, chain, v_gene, d_gene, j_gene, c_gene, productive, cdr3, cdr3_nt, reads, umis, raw_clonotype_id, raw_consensus_id).*_callspellings are also accepted in place of*_gene.consensus (str | Path | None) – Optional
consensus_annotations.csv[.gz]to source consensus V/D/J calls from; ifNonethe contig’s own calls are used.require_cell (bool) – Drop contigs whose
is_cellis not truthy (defaultTrue).require_high_conf (bool) – Drop contigs whose
high_confidenceis not truthy (defaultTrue).
- Returns:
A
pl.DataFramein the canonical sc long-frame layout (SC_COLUMNS) — one row per surviving productive contig.- Raises:
ValueError – If a required column (
barcode,contig_id,chain,cdr3) is missing fromall_contig.- Return type:
DataFrame
- vdjtools.sc.read.read_airr_cell(path)[source]#
Read an AIRR Rearrangement TSV carrying a
cell_idcolumn into the sc frame.This is also the reader for CellRanger’s ``airr_rearrangement.tsv`` (emitted since Cell Ranger 4.0, and under
per_sample_outs/<sample>/vdj_t/forcellranger multi) and for arda’s barcoded output — both are plain AIRR Rearrangement tables with acell_idcolumn. Prefer it overread_10x(): it is the same file scirpy, dandelion and scRepertoire read, so the ingestion and interop paths agree by construction.- Parameters:
path (str | Path) – Path to an AIRR Rearrangement TSV (
.gzaccepted) with at least acell_idcolumn plus the usual AIRR fields.- Returns:
A
pl.DataFramein the canonical sc long-frame layout (SC_COLUMNS); columns absent from the file are filled with nulls.junction_aa/junctionare accepted as sources forjunction_aa/junction_nt.- Raises:
ValueError – If the file has no
cell_idcolumn.- Return type:
DataFrame
- vdjtools.sc.read.ARDA_STATUS = 'arda_status'#
Cell-level QC columns lifted from arda’s
.chains.tsv, namespaced so they cannot be confused withvdjtools.sc.resolve_chains()’ own verdict.
- vdjtools.sc.read.read_arda_cells(prefix, *, chains=True)[source]#
Read the output of arda’s single-cell pipeline (
arda cells).arda cellswrites<prefix>.contigs.airr.tsv— an AIRR Rearrangement table withcell_id,moleculesandreads— plus a cell-level<prefix>.chains.tsvcarrying arda’s own per-chain verdict (statusis one ofprimary,secondary,doublet_candidate,extra).NOTE: arda’s
statusandresolve_chains()’ verdict are independent calls on the same question. This reader surfaces arda’s asarda_statusrather than acting on it, so runningresolve_chainsafterwards does not silently discard the upstream judgement – compare them, don’t assume they agree.Reading goes through arda’s own
read_airr, not a plain CSV read: arda has written two TSV dialects and that reader is what normalises them. It matters becausejunction_qualityis Phred+33 and character 34 is a double quote.- Parameters:
- Returns:
A
pl.DataFramein the canonical layout (SC_COLUMNS), plusarda_status/arda_moleculeswhen the chains table was joined.- Raises:
FileNotFoundError – If no contigs AIRR table is found for
prefix.- Return type:
DataFrame
- vdjtools.sc.read.write_airr_cell(rearr, cells_out, *, receptors=True, repertoire_id='')[source]#
Emit an AIRR Data File (YAML) with a
Cellarray (and optionalReceptor).Builds one
Cellpercell_id(linking itssequence_idreceptors) and, whenreceptorsis set, oneReceptorper paired heavy/light chain within a cell. Thereceptor_hashissha256of the two upper-cased domain sequences.Note
The AIRR spec’s
receptor_variable_domain_{1,2}_aais the full mature V-domain amino-acid sequence. 10x contigs only expose the junction, so this field is populated with the junction (junction_aa) and that limitation is recorded in the file’sInfoblock. Downstream code should treat these as junction-level, not full-domain, sequences.- Parameters:
rearr (DataFrame) – A single-cell long frame (
SC_COLUMNS), typically the paired output — onecell_idmay carry several chains.cells_out (str | Path) – Destination path for the AIRR Data File (
.yaml/.json).receptors (bool) – Emit the
Receptorlist pairing heavy (β/heavy) and light (α/light) chains per cell (defaultTrue).repertoire_id (str) – Value stamped into each
Cell.repertoire_id(default empty).
- Returns:
The
Pathwritten.- Raises:
ImportError – If PyYAML is not installed (see the
scextra).- Return type:
vdjtools.sc.airr#
The AIRR Rearrangement interchange layer every downstream bridge is built on, plus the scRepertoire export.
The single-cell interchange layer: a flat AIRR Rearrangement table.
scirpy, dandelion and scRepertoire all read the same thing — one AIRR Rearrangement
row per contig, carrying sequence_id and cell_id. None of them consumes AIRR
Cell objects; cell-level state lives in adata.obs / Dandelion.metadata / Seurat
meta.data. So the interop surface here is one emitter (to_airr()) and one
inverse (from_airr()); every bridge in vdjtools.sc is a thin adapter on top,
and the AIRR Cell Data File (vdjtools.sc.write_airr_cell()) stays a separate,
spec-faithful export rather than an interop path.
Two spellings are reconciled here, in exactly one place:
vdjtools calls the nucleotide junction
junction_nt; AIRR — and therefore every downstream tool — calls itjunction.consensus_countis what scRepertoire’s AIRR parser reads as the read count, while scirpy and dandelion preferumi_count. Both are emitted, so one file feeds all three.
- vdjtools.sc.airr.JUNCTION = 'junction'#
AIRR spelling of the nucleotide junction (vdjtools stores it as
junction_nt).
- vdjtools.sc.airr.CONSENSUS_COUNT = 'consensus_count'#
Read count backing the contig consensus; scRepertoire’s AIRR parser reads this one.
- vdjtools.sc.airr.AIRR_COLUMNS: list[str] = ['sequence_id', 'cell_id', 'locus', 'v_call', 'd_call', 'j_call', 'c_call', 'junction', 'junction_aa', 'productive', 'duplicate_count', 'umi_count', 'consensus_count', 'clone_id']#
Columns of the emitted AIRR Rearrangement table, in order.
- vdjtools.sc.airr.to_airr(cells)[source]#
Convert the canonical sc long frame to a flat AIRR Rearrangement table.
This is the contract every downstream bridge is built on.
sequence_idis synthesised as<cell_id>_contig_<n>when the frame does not carry one — dandelion derivescell_idback out of exactly that pattern when a file omits it, so the round-trip closes either way.consensus_countis set fromduplicate_count(the read count): for a 10x contig the reads backing the consensus are the duplicate observations, and emitting both spellings is what lets a single file satisfy scRepertoire (which readsconsensus_count) and scirpy/dandelion (which preferumi_count) at once.- Parameters:
cells (DataFrame) – Single-cell long frame —
vdjtools.sc.read.SC_COLUMNS, as returned byread_10x()orread_airr_cell(). Missing optional columns are emitted as nulls.- Returns:
A
pl.DataFramewith columnsAIRR_COLUMNS, one row per contig.- Raises:
ValueError – If
cellshas nocell_idcolumn.- Return type:
DataFrame
- vdjtools.sc.airr.from_airr(airr)[source]#
Convert a flat AIRR Rearrangement table back to the canonical sc long frame.
The inverse of
to_airr(), and the shared tail of everyfrom_*bridge (from_scirpy(),from_dandelion()) — they each reduce their container to an AIRR frame and hand it here.- Parameters:
airr (DataFrame) – AIRR Rearrangement frame with at least
cell_id. Both the AIRRjunctionand the vdjtoolsjunction_ntspellings are accepted, as areconsensus_count/duplicate_countfor the read count.- Returns:
A
pl.DataFramein the canonical layout (vdjtools.sc.read.SC_COLUMNS).- Raises:
ValueError – If
airrhas nocell_idcolumn.- Return type:
DataFrame
- vdjtools.sc.airr.write_airr(cells, path)[source]#
Write the sc frame as an AIRR Rearrangement TSV.
The result is readable by
read_airr_cell(), by scirpy (ir.io.read_airr), by dandelion (ddl.read_airr) and by scRepertoire (loadContigs(format="AIRR")).- Parameters:
cells (DataFrame) – Single-cell long frame (
vdjtools.sc.read.SC_COLUMNS).
- Returns:
The path written.
- Return type:
- vdjtools.sc.airr.write_screpertoire(cells, path, *, format='airr')[source]#
Write a file R’s scRepertoire can load, for
combineTCR/combineBCR.Export only — no R code ships with vdjtools. Load the result with
scRepertoire::loadContigs(path, format = "AIRR")(or"10X").NOTE: two footguns on the R side, neither of which this function can prevent.
.parseAIRRreads the read count from ``consensus_count``, notumi_count— which is whyto_airr()emits both. AndcombineTCR(samples=, ID=)rewrites barcodes tosample_ID_barcode, the usual cause of a silent barcode-join failure against a Seuratmeta.data; pass the samesamples/IDthere and tocombineExpression, or omit both.- Parameters:
cells (DataFrame) – Single-cell long frame (
vdjtools.sc.read.SC_COLUMNS).path (str | Path) – Destination —
.tsvforformat="airr",.csvforformat="10x".format (str) –
"airr"writes anairr_rearrangement.tsv-shaped file (the columns.parseAIRRrequires);"10x"writes afiltered_contig_annotations.csv-shaped file.
- Returns:
The path written.
- Raises:
ValueError – If
formatis not"airr"or"10x".- Return type:
vdjtools.sc.dandelion#
dandelion bridge, including a dandelion-free .h5ddl reader.
Bridge the single-cell frame to and from dandelion.
A Dandelion object is two tables: .data, the contig-level AIRR frame indexed by
sequence_id, and .metadata, one row per cell. The first is exactly what
to_airr() emits, so the bridge is a format hand-off rather than a
translation — dandelion’s own scirpy converter round-trips through the same flat AIRR
table.
read_h5ddl() is the useful part: dandelion persists to .h5ddl, which is plain
HDF5 (h5py structured arrays, with a sibling Zarr store for distances), so a dandelion
result is readable without installing dandelion.
NOTE: dandelion also ships a polars backend (ddl.set_backend("polars"),
DandelionPolars) whose .data/.metadata take polars frames directly. If you are
on that backend, hand to_airr() output straight over; the pandas
conversion here is only for the default backend.
- vdjtools.sc.dandelion.to_dandelion(cells)[source]#
Wrap the sc long frame as a
dandelion.Dandelion.sequence_idandumi_countare both required by dandelion’s loader;to_airr()guarantees the first (synthesising<cell>_contig_<n>when absent) and carries the second.- Parameters:
cells (DataFrame) – Single-cell long frame (
vdjtools.sc.read.SC_COLUMNS).- Returns:
A
Dandelionwhose.datais the contig-level AIRR table;.metadatais built by dandelion’s ownupdate_metadata().- Raises:
ImportError – If dandelion is not installed (
pip install sc-dandelion).
- vdjtools.sc.dandelion.from_dandelion(vdj)[source]#
Convert a
dandelion.Dandelionback to the canonical sc long frame.- Parameters:
vdj – A
Dandelionobject (or anything exposing a contig-level.dataframe).- Returns:
A
pl.DataFramein the canonical layout (vdjtools.sc.read.SC_COLUMNS).- Raises:
AttributeError – If
vdjhas no.data.- Return type:
DataFrame
- vdjtools.sc.dandelion.read_h5ddl(path)[source]#
Read the contig table out of a dandelion
.h5ddlfile, without dandelion..h5ddlis plain HDF5:datais a h5py structured array of the contig-level AIRR table (metadataholds the cell-level one, and distances may live in a sibling Zarr store). Onlyh5pyis needed to read it.- Parameters:
- Returns:
A
pl.DataFramein the canonical layout (vdjtools.sc.read.SC_COLUMNS).- Raises:
ImportError – If
h5pyis not installed.KeyError – If the file has no
datadataset.
- Return type:
DataFrame
vdjtools.sc.pair#
Chain resolution, alpha/beta pairing and mispairing flags.
Chain resolution, paired-receptor assembly, and doublet / mispairing QC.
A 10x cell’s contigs are noisy: barcodes collide (doublets), ambient mRNA leaks a
spurious light chain, and a genuine α/β cell can legitimately carry two productive α.
These functions clean that up on the flat single-cell frame from vdjtools.sc.read.
Locus roles (heavy / light) follow the standard receptor families:
heavy = {TRB, TRD, IGH} # one per cell
light = {TRA, TRG} # one, sometimes two (dual-α)
B-light = {IGK, IGL} # one, sometimes two
The per-cell chain ranking key is (-duplicate_count, -umi_count, sequence_id)
everywhere: most reads first, then most UMIs, then a stable id tie-break. The
thresholds encode the mirpy-derived rule one heavy but possibly two light, and are
reimplemented here on polars (no mirpy dependency).
- vdjtools.sc.pair.LOCUS_PAIR_TO_LOCI: dict[str, tuple[str, str]] = {'IGH_IGK': ('IGK', 'IGH'), 'IGH_IGL': ('IGL', 'IGH'), 'TRA_TRB': ('TRA', 'TRB'), 'TRG_TRD': ('TRG', 'TRD')}#
locus-pair family -> (light/chain1 locus, heavy/chain2 locus).
- vdjtools.sc.pair.resolve_chains(rearr, *, secondary_ratio=0.1, secondary_min_umi=2, secondary_min_dup=5)[source]#
Reduce over-expanded per-cell chains to one heavy and one (or two) light.
Per
cell_id:keep exactly the top-1 heavy chain (
TRB/TRD/IGH);keep the top-1 light chain, and a second light chain only when all of
second_dup/first_dup > secondary_ratio,second_umi/first_umi > secondary_ratio,second_umi >= secondary_min_umiandsecond_dup >= secondary_min_duphold (the dual-α allowance);the same secondary rule applies jointly across
IGK+IGLfor B-cells.
- Parameters:
rearr (DataFrame) – Single-cell long frame (
vdjtools.sc.read.SC_COLUMNS).secondary_ratio (float) – Minimum second/first ratio (on both reads and UMIs) to admit a second light chain.
secondary_min_umi (int) – Minimum absolute UMI count for a second light chain.
secondary_min_dup (int) – Minimum absolute read count for a second light chain.
- Returns:
The cleaned per-cell contigs (same columns as the input), ordered by cell then rank. Contigs on loci outside the receptor roles are dropped.
- Return type:
DataFrame
- vdjtools.sc.pair.pair_chains(rearr, *, locus_pair='TRA_TRB', resolve=True)[source]#
Assemble paired receptors as the Cartesian product of a cell’s light × heavy.
After (optionally)
resolve_chains(), each cell forms one paired receptor per (light, heavy) combination of its chains in the requested family — so a cell with two α and one β yields two pairs (<cell>_1,<cell>_2). Cells missing either side of the family are counted but not emitted (seechain_multiplicity()).- Parameters:
rearr (DataFrame) – Single-cell long frame.
locus_pair (str) – Family to pair — one of
"TRA_TRB","TRG_TRD","IGH_IGK","IGH_IGL". The first locus is the α/light side (alpha_*columns), the second the β/heavy side (beta_*columns).resolve (bool) – Run
resolve_chains()first (defaultTrue).
- Returns:
One row per paired receptor with
cell_id, pair_id, alpha_v_call, alpha_j_call, alpha_junction_aa, alpha_umi_count, alpha_duplicate_countand the matchingbeta_*columns.- Raises:
ValueError – If
locus_pairis not a recognised family.- Return type:
DataFrame
- vdjtools.sc.pair.chain_multiplicity(rearr, *, locus_pair='TRA_TRB')[source]#
Presence-quadrant histogram
(n_light, n_heavy) -> cell_countfor a family.Counts, over cells, how many carry each
(n_light, n_heavy)combination of chain multiplicities inlocus_pair— the α/β quadrant table used to diagnose doublets and dropout. Cells with neither chain in the family contribute a(0, 0)row.- Parameters:
rearr (DataFrame) – Single-cell long frame.
locus_pair (str) – Family to tabulate (see
pair_chains()).
- Returns:
A
pl.DataFramewith columnsn_light, n_heavy, cell_count, sorted byn_lightthenn_heavy.- Raises:
ValueError – If
locus_pairis not a recognised family.- Return type:
DataFrame
- vdjtools.sc.pair.flag_mispairing(paired, *, max_slaves_per_master=None, drop=False)[source]#
Flag suspected mispaired / ambient α chains against a master(β) → slave(α) graph.
Builds, across all cells, how often each master (β) heavy chain co-occurs with each slave (α) light chain. For every master its canonical slave is the one with the most co-occurrences (ties broken by summed read+UMI support). Any pairing whose α is not the master’s canonical slave is flagged as suspected mispairing / contamination. If a master pairs with more than
max_slaves_per_masterdistinct α across the dataset, the master itself is flagged as ambient (a β smeared across too many barcodes).Chains are keyed on
(v_call, j_call, junction_aa)per side, so identical clonotypes across cells are recognised as the same master / slave.- Parameters:
paired (DataFrame) – Output of
pair_chains()(alpha_*/beta_*columns).max_slaves_per_master (int | None) – Distinct-α ceiling above which a master is called ambient;
Nonedisables the ambient check.drop (bool) – If
True, remove flagged rows instead of annotating them.
- Returns:
The paired frame plus
mispairing_flag(bool) andmispairing_reason("ok"/"noncanonical_alpha"/"ambient_master"). Whendropis set, flagged rows are removed and the two columns omitted.- Raises:
ValueError – If
max_slaves_per_masteris not a positive integer.- Return type:
DataFrame
vdjtools.sc.pgen#
Paired-chain generation probability (Pgen(α)·Pgen(β)) via the native model.
Paired-chain generation probability for single-cell repertoires.
Under chain independence the paired generation probability of a cell is
Pgen(α) · Pgen(β) — the product of each chain’s junction Pgen under the native
vdjtools.model engine (bundled per-locus models). This is the single-cell
paired-Pgen residual from Phase 7; it is computed entirely from the native model
(no vdjmatch dependency).
The paired frame is the vdjtools.sc.pair.pair_chains() layout — alpha_v_call,
alpha_j_call, alpha_junction_aa and the beta_* counterparts (α/light and
β/heavy). Each chain’s locus is inferred from its V-call prefix (TRA/TRB, or
IGK/IGL + IGH for BCR) unless given explicitly.
Conditioning on V/J requires the call to match a model allele (e.g. TRBV20-1*01);
a gene-level or unmatched call marginalises over all V/J for that chain (still a valid,
if less specific, Pgen). Pass condition_vj=False to marginalise unconditionally.
- vdjtools.sc.pgen.paired_pgen(paired, *, source='olga', condition_vj=True, resolve_genes=True, alpha_locus=None, beta_locus=None)[source]#
Add
pgen_alpha,pgen_betaandpgen_pairedto a paired single-cell frame.- Parameters:
paired (DataFrame) – A paired-chain frame (
vdjtools.sc.pair.pair_chains()layout).source (str) – Bundled model set —
"olga"(OLGA-derived) or"learned"(native EM).condition_vj (bool) – Condition each chain’s Pgen on its V/J call.
Falsemarginalises over all V/J unconditionally.resolve_genes (bool) – Resolve a gene-level call (
TRBV10-3) to a representative model allele (TRBV10-3*01) before scoring – see_gene_to_allele(). DefaultTrue, because CellRanger reports genes and without this every 10x row scoresNone. SetFalseto score only exact allele matches.alpha_locus (str | None) – Locus of the α/light chain (e.g.
"TRA","IGK"); inferred from thealpha_v_callprefix ifNone.beta_locus (str | None) – Locus of the β/heavy chain (e.g.
"TRB","IGH"); inferred from thebeta_v_callprefix ifNone.
- Returns:
pairedwith three added Float64 columns.pgen_alpha/pgen_betaare null for a cell missing that chain’s junction, or carrying a V/J call the model does not know;pgen_pairedis null unless both are set.- Warns:
UserWarning – If every chain of a locus scored null – the usual cause is a V/J naming the model does not recognise, which would otherwise be an entire column of silent nulls.
- Return type:
DataFrame
vdjtools.sc.cluster_eval#
Clustering-quality evaluation against antigen labels.
Clustering-evaluation metrics (purity / homogeneity / parsimony / q-measure).
Given a ground-truth labelling (e.g. the antigen/epitope a clonotype binds) and a
predicted clustering (cluster ids), these functions score how well the clustering
recovers the truth. They are the clustereval family of information-theoretic and
set-overlap metrics, reimplemented here on a contingency matrix so they can grade any
clonotype clustering — TCRnet components, metaclonotype groups, GLIPH motifs, …
The contingency matrix n[i, j] counts items with true class i and predicted
cluster j (rows = true classes, columns = clusters), built with
sklearn.metrics.cluster.contingency_matrix(). All entropies use natural logs.
Singleton convention. An unclustered item must get its own unique cluster id
rather than being dropped or lumped into a shared “noise” cluster — otherwise purity
and homogeneity are silently inflated. assign_singleton_ids() maps a sentinel
(None / -1) onto distinct negative ids for exactly this.
- vdjtools.sc.cluster_eval.assign_singleton_ids(pred, *, sentinel=None)[source]#
Give every unclustered item (
sentinel) its own unique negative cluster id.- Parameters:
pred – Iterable of predicted cluster ids;
sentinelmarks unclustered items.sentinel – The value flagging “no cluster” (default
None;-1is common).
- Returns:
A
listof cluster ids with each sentinel replaced by a distinct negative integer (-1, -2, …), so no two unclustered items share a cluster.
Example
>>> assign_singleton_ids([5, None, 5, None]) [5, -1, 5, -2]
- vdjtools.sc.cluster_eval.purity(labels_true, labels_pred)[source]#
Cluster purity: mean over clusters of the dominant true class fraction.
purity = (1/N) · Σ_j max_i n[i, j]— 1.0 when every cluster is class-pure.- Return type:
- vdjtools.sc.cluster_eval.normalized_purity(labels_true, labels_pred)[source]#
Purity rescaled against its one-cluster floor
pmin = max_i n_i. / N.(purity - pmin) / (1 - pmin); returns 1.0 whenpmin == 1(a single true class). This maps the trivial “everything in one cluster” purity to 0 and the perfect clustering to 1.- Return type:
- vdjtools.sc.cluster_eval.inverse_purity(labels_true, labels_pred)[source]#
Inverse purity: mean over true classes of the dominant cluster fraction.
(1/N) · Σ_i max_j n[i, j]— the completeness-flavoured dual of purity.- Return type:
- vdjtools.sc.cluster_eval.normalized_inverse_purity(labels_true, labels_pred)[source]#
Inverse purity rescaled against its all-singletons floor.
(inv - imin) / (1 - imin)withimin = |unique(true)| / N(the inverse purity you get when every item is its own cluster); returns 1.0 whenimin == 1.- Return type:
- vdjtools.sc.cluster_eval.homogeneity(labels_true, labels_pred)[source]#
Homogeneity:
1 - H(C|K) / H(C)— do clusters contain a single true class?Returns 1.0 when
H(C) == 0(a single true class, trivially homogeneous).H(C|K) = max(0, H(C,K) - H(K))with all entropies in natural logs.- Return type:
- vdjtools.sc.cluster_eval.parsimony(labels_true, labels_pred)[source]#
Parsimony:
1 - H(K|C) / (ln N - H(C))— penalises fragmenting a class.Returns 1.0 when the denominator
ln N - H(C)is 0.H(K|C) = max(0, H(C,K) - H(C)). Falls to 0 when every item is its own cluster (maximal fragmentation).- Return type:
- vdjtools.sc.cluster_eval.q_measure(labels_true, labels_pred, beta=1.0)[source]#
Weighted harmonic mean of homogeneity
hand parsimonyp.(1 + beta) · h · p / (beta · h + p); 0 when eitherhorpis≤ 0(there is nothing to balance).betaweights homogeneity relative to parsimony.
- vdjtools.sc.cluster_eval.cluster_eval(labels_true, labels_pred, *, beta=1.0)[source]#
Compute the full clustering-evaluation metric suite in one pass.
- Parameters:
labels_true – Ground-truth class label per item (e.g. bound epitope).
labels_pred – Predicted cluster id per item (same length as
labels_true). Unclustered items should already carry unique ids — seeassign_singleton_ids().beta (float) – Homogeneity weight for
q_measure().
- Returns:
Dict with keys
purity, normalized_purity, inverse_purity, normalized_inverse_purity, homogeneity, parsimony, q_measure.- Raises:
ImportError – If scikit-learn is not installed (see the
scextra).ValueError – If the label arrays are empty or unequal length.
- Return type:
vdjtools.sc.anndata#
scverse bridge: the scirpy-native obsm["airr"] round-trip, the flat paired-receptor AnnData, and push_obs for augmenting an existing container.
Bridge single-cell receptors into an AnnData / MuData container.
Single-cell is the shape where AnnData fits: obs is one row per observation, so the
whole scverse ecosystem (scirpy, muon) becomes available and a gene-expression matrix
aligns naturally on cell_id.
Two containers, for two different jobs:
to_scirpy()— the scverse-native layout. Chains live as an awkward array underadata.obsm["airr"](cell → variable-length chain list → AIRR record), which is what scirpy ≥0.13 reads. Use this to hand data to scirpy, andfrom_scirpy()to get it back. Nothing is lost: it carries the full per-contig AIRR record.to_anndata()— a flat container, oneobsrow per receptor pair (alpha_*/beta_*columns). Convenient for a quick paired-chain table or for attaching an expression matrix, but it is not what scirpy consumes and it keeps only the paired-chain fields.
Writing delegates to scirpy (ir.io.read_airr) because scirpy’s reader is the source of
truth for its own on-disk layout and reimplementing it here would drift with their schema.
Reading is ours and needs only awkward, so consuming a scirpy object costs no scirpy
install.
This is the opposite of the bulk-cohort path: a cohort of many repertoires (per-
sample clonotype tables) must NOT go in AnnData — obs=clonotype yields an
~1e9 × 100k almost-empty sparse X — use vdjtools.io.scan_cohort() (a hive-
partitioned Parquet dataset scanned as one LazyFrame) for that. Rule of thumb:
single-cell (obs=cell) → AnnData; bulk cohort (per-sample tables) → parquet.
- vdjtools.sc.anndata.to_anndata(paired, X=None, *, index='pair_id')[source]#
Wrap
vdjtools.sc.pair_chains()output as ananndata.AnnData.obsis one row per receptor pair, indexed bypair_id(unique even when a cell yields two α/β pairs), withcell_idkept as a column so an expression matrix can be joined on it. With noXthe result is a pure VDJ container (ann_obs × 0matrix); pass a cells × genes matrix aligned toobsto attach gene expression. For a formally multimodal object combine this with a GEX AnnData undermudata.MuData({"gex": gex, "airr": to_anndata(paired)})(scirpy-ready).- Parameters:
paired (pl.DataFrame) – Paired-receptor frame from
vdjtools.sc.pair_chains()(cell_id, pair_id, alpha_*, beta_*columns).X – Optional feature matrix with one row per
obs(e.g. gene expression); defaults to an emptyn_obs × 0sparse matrix.index (str) – Column to use as the unique
obsindex (default"pair_id").
- Returns:
An
anndata.AnnDatawhoseobsholds the paired-chain annotation.- Raises:
ImportError – If
anndata(the[sc]extra) is not installed.ValueError – If
indexis not a column ofpairedor is not unique.
- Return type:
ad.AnnData
- vdjtools.sc.anndata.to_scirpy(cells, gex=None, *, index_chains=True)[source]#
Convert the sc long frame to a scirpy-native AnnData (or MuData with
gex).Delegates to
scirpy.io.read_airron the AIRR table fromto_airr(), so the result carries scirpy’s ownobsm["airr"]awkward layout exactly as their version defines it. The whole per-contig AIRR record survives, unlike the flatto_anndata()view.- Parameters:
cells (DataFrame) – Single-cell long frame (
vdjtools.sc.read.SC_COLUMNS).gex – Optional gene-expression
anndata.AnnData. When given, the result is amudata.MuData({"gex": gex, "airr": ...})— the multimodal object scirpy reads withairr_mod="airr".index_chains (bool) – Run
scirpy.pp.index_chainson the result (defaultTrue), which populatesobsm["chain_indices"]. Every scirpy tool needs it, so doing it here is what makes the handoff seamless; passFalseto index yourself.
- Returns:
An
anndata.AnnData, or amudata.MuDatawhengexis given.- Raises:
ImportError – If
scirpy(ormudata, whengexis given) is not installed.
- vdjtools.sc.anndata.from_scirpy(adata)[source]#
Convert a scirpy AnnData / MuData back to the canonical sc long frame.
Reads
obsm["airr"]withawkwarddirectly — scirpy itself is not needed, so a scirpy object handed to you is readable from a plainvdjtools[sc]install. Theobsindex is authoritative forcell_id(that is what AnnData keys on), so a stalecell_idfield inside the chain records is ignored.- Parameters:
adata – An
anndata.AnnDatawith anobsm["airr"]awkward array, or amudata.MuDatacarrying one in its"airr"modality.- Returns:
A
pl.DataFramein the canonical layout (vdjtools.sc.read.SC_COLUMNS), one row per contig.- Raises:
ImportError – If
awkward(the[sc]extra) is not installed.KeyError – If the object has no
obsm["airr"].
- Return type:
DataFrame
- vdjtools.sc.anndata.push_obs(target, df, columns=None, *, on='cell_id')[source]#
Push vdjtools-computed per-cell columns into a downstream container, in place.
The “augment” direction: take something vdjtools computed (
pgen_paired,mispairing_flag, a clustering) and attach it to an object the rest of an analysis is already built around. Works on anything with anobs(AnnData, MuData) or ametadata(dandelionDandelion) table; rows are matched by index, and cells the frame does not mention get nulls.- Parameters:
target – An
anndata.AnnData/mudata.MuData/Dandelion.df (DataFrame) – Per-cell frame carrying
onplus the columns to attach.columns – Which columns to push (default: everything except
on).on (str) – Key column in
dfmatched against the target’s index (defaultcell_id).
- Returns:
target, mutated in place (returned for chaining).- Raises:
ValueError – If
onis missing fromdf,onis not unique (one row per cell is required – aggregate a multi-pair frame first), or a requested column is absent.TypeError – If
targethas neitherobsnormetadata.
Command-line interface (vdjtools.cli)#
The unified vdjtools typer CLI (pgen, generate, diversity, spectratype, segment-usage, overlap, models).
vdjtools.cli#
The vdjtools command-line application.
vdjtools command-line interface (typer) — one vdjtools entry point.
Command families:
Model engine (OLGA/IGoR-style, on the native recombination core + built-in models):
pgen(generation probability),generate(sample sequences),models(list built-ins).Data (format conversion + preprocessing):
convert(any format → canonical TSV/Parquet),downsample,filter(coding / frequency / segment),pool(pool or incidence-join samples).Repertoire analytics (vanilla-vdjtools-style, over sample files or a metadata table):
diversity,spectratype,segment-usage,overlap.Longitudinal & enrichment:
dynamics(paired within-donor expansion test),tcrnet/alice(neighbourhood enrichment vs a control cohort / a generation model).
Analytics commands take either a list of sample files or -m/--metadata <table> (+ --base-dir),
mirroring the metadata-driven workflow of the legacy tool, and run in parallel over samples
(-t/--threads) or in one streamed pass over a pre-ingested Parquet cohort (--cohort). Every
command writes to -o — TSV, or Parquet when the path ends in .parquet / .pq — or, by
default, to stdout (progress/errors go to stderr), so commands pipe cleanly.
- vdjtools.cli.models()[source]#
List the precomputed recombination models shipped with the package.
- Return type:
None
- vdjtools.cli.pgen(input=<typer.models.ArgumentInfo object>, model=<typer.models.OptionInfo object>, source=<typer.models.OptionInfo object>, model_path=<typer.models.OptionInfo object>, column=<typer.models.OptionInfo object>, v_col=<typer.models.OptionInfo object>, j_col=<typer.models.OptionInfo object>, seq_type=<typer.models.OptionInfo object>, mismatches=<typer.models.OptionInfo object>, no_header=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Compute generation probability (Pgen) for CDR3 sequences — like
olga-compute_pgen.Appends a
pgencolumn. V/J are marginalized unless--v-col/--j-colare given. Nucleotide vs amino-acid is auto-detected per sequence; amino-acid input can also sum the Hamming-distance-1 ball with--mismatches 1(fast, native).
- vdjtools.cli.generate(model=<typer.models.OptionInfo object>, source=<typer.models.OptionInfo object>, model_path=<typer.models.OptionInfo object>, n=<typer.models.OptionInfo object>, seed=<typer.models.OptionInfo object>, productive=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Sample recombined sequences from a model — like
olga-generate_sequences.Emits
junction_nt, junction_aa, v_call, d_call, d2_call, j_call, productive(d2_callis the tandem D on the learned D-bearing loci; null otherwise).
- vdjtools.cli.convert(input=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Read any supported format and write the canonical AIRR-junction table.
Auto-detects native vdjtools / AIRR / Parquet and the third-party exports (MiXcr, MiGec, MiTCR, immunoSEQ, IMGT/HighV-QUEST, Vidjil, RTCR, TRUST4, arda). Output is TSV, or Parquet when
-oends in.parquet/.pq— the typed, columnar, at-scale store.
- vdjtools.cli.downsample(input=<typer.models.ArgumentInfo object>, size=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, clones=<typer.models.OptionInfo object>, seed=<typer.models.OptionInfo object>)[source]#
Randomly down-sample a repertoire to a common depth (reads, or unique clonotypes).
- vdjtools.cli.filter_(input=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, productive=<typer.models.OptionInfo object>, nonproductive=<typer.models.OptionInfo object>, functional_genes=<typer.models.OptionInfo object>, keep_orf=<typer.models.OptionInfo object>, recompute_frequencies=<typer.models.OptionInfo object>, coding=<typer.models.OptionInfo object>, noncoding=<typer.models.OptionInfo object>, min_len=<typer.models.OptionInfo object>, max_len=<typer.models.OptionInfo object>, min_freq=<typer.models.OptionInfo object>, v=<typer.models.OptionInfo object>, j=<typer.models.OptionInfo object>, remove=<typer.models.OptionInfo object>)[source]#
Filter clonotypes: productive / non-productive, IMGT-functional genes, frequency, V/J segment.
Two DIFFERENT axes, deliberately separate flags. –productive is a property of the REARRANGEMENT (AIRR: in frame, no stop codon); –functional-genes is a property of the GERMLINE GENE it uses (IMGT: F / ORF / P). A productive rearrangement can use a pseudogene V.
- Parameters:
- Return type:
None
- vdjtools.cli.pool(samples=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, key=<typer.models.OptionInfo object>, join=<typer.models.OptionInfo object>, min_samples=<typer.models.OptionInfo object>)[source]#
Pool (sum counts) or join (incidence) clonotypes across several samples.
- vdjtools.cli.diversity(samples=<typer.models.ArgumentInfo object>, metadata=<typer.models.OptionInfo object>, base_dir=<typer.models.OptionInfo object>, sample_col=<typer.models.OptionInfo object>, file_template=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>, cohort=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Per-sample diversity (observed richness, Chao, Efron-Thisted, Shannon, Simpson, d50).
- vdjtools.cli.spectratype(samples=<typer.models.ArgumentInfo object>, metadata=<typer.models.OptionInfo object>, base_dir=<typer.models.OptionInfo object>, sample_col=<typer.models.OptionInfo object>, file_template=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, kind=<typer.models.OptionInfo object>, weight=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>, cohort=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Per-sample CDR3 length spectratype.
- vdjtools.cli.segment_usage(samples=<typer.models.ArgumentInfo object>, metadata=<typer.models.OptionInfo object>, base_dir=<typer.models.OptionInfo object>, sample_col=<typer.models.OptionInfo object>, file_template=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, segment=<typer.models.OptionInfo object>, weight=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>, cohort=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Per-sample V/D/J/C segment usage.
- vdjtools.cli.overlap(samples=<typer.models.ArgumentInfo object>, metadata=<typer.models.OptionInfo object>, base_dir=<typer.models.OptionInfo object>, sample_col=<typer.models.OptionInfo object>, file_template=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Exact-match pairwise repertoire overlap (D, F, F2, R) for every sample pair.
- vdjtools.cli.dynamics(pre=<typer.models.ArgumentInfo object>, post=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, neff=<typer.models.OptionInfo object>, umi=<typer.models.OptionInfo object>, min_total=<typer.models.OptionInfo object>, alpha=<typer.models.OptionInfo object>)[source]#
Paired within-donor test: which clonotypes changed between two timepoints.
Classifies every clonotype as emergent / expanded / persistent / contracted / vanishing (or untested). Depth is handled PER PAIR via the effective sample size — never by normalising a cohort to a common depth, which is not a defined operation here.
- vdjtools.cli.tcrnet(sample=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, locus=<typer.models.OptionInfo object>, species=<typer.models.OptionInfo object>, scope=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>)[source]#
Neighbourhood enrichment against a CONTROL REPERTOIRE (TCRnet).
The control absorbs thymic selection and endemic-pathogen expansions, which a generation model cannot — at the cost of needing a large, HLA-matched cohort. See alice for the generative null. Neither can see a monoclonal expansion: enrichment measures breadth.
- vdjtools.cli.alice(sample=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, locus=<typer.models.OptionInfo object>, source=<typer.models.OptionInfo object>, scope=<typer.models.OptionInfo object>, selection_q=<typer.models.OptionInfo object>, min_degree=<typer.models.OptionInfo object>, min_count=<typer.models.OptionInfo object>, threads=<typer.models.OptionInfo object>)[source]#
Neighbourhood enrichment against a V(D)J GENERATION MODEL (ALICE).
Controls for the intrinsic biases of recombination, but knows nothing about selection or about which clonotypes are already common in people — the complement of tcrnet. Returns q_value and picks no threshold: the published ones differ 100-fold and were never reconciled.
- vdjtools.cli.model_list()[source]#
List the recombination models shipped with the package (same as
vdjtools models).- Return type:
None
- vdjtools.cli.model_check(spec=<typer.models.ArgumentInfo object>, germline=<typer.models.OptionInfo object>, organism=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Audit a model against its manifest, its germline, and a reference library.
Writes a tidy issue frame (severity, check, event, segment, allele, detail, value) and exits 1 if any issue has severity “error”, so it works as a gate in a build script.
- vdjtools.cli.model_template(locus=<typer.models.OptionInfo object>, organism=<typer.models.OptionInfo object>, germline_v=<typer.models.OptionInfo object>, germline_j=<typer.models.OptionInfo object>, germline_d=<typer.models.OptionInfo object>, anchors=<typer.models.OptionInfo object>, ins_max=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Build a model scaffold from a germline library — your own FASTA, or arda’s.
The marginals are placeholders meant to be refit with
model learn; their support ranges bound what EM can then learn, which is why--ins-maxis here.
- vdjtools.cli.model_learn(input=<typer.models.ArgumentInfo object>, template=<typer.models.OptionInfo object>, locus=<typer.models.OptionInfo object>, organism=<typer.models.OptionInfo object>, column=<typer.models.OptionInfo object>, max_iter=<typer.models.OptionInfo object>, tol=<typer.models.OptionInfo object>, init=<typer.models.OptionInfo object>, gene_prior=<typer.models.OptionInfo object>, nd_prior=<typer.models.OptionInfo object>, single_d=<typer.models.OptionInfo object>, no_calls=<typer.models.OptionInfo object>, verbose=<typer.models.OptionInfo object>, checkpoint=<typer.models.OptionInfo object>, checkpoint_every=<typer.models.OptionInfo object>, resume_from=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Fit a model’s marginals from your own sequences by EM, writing the training log alongside.
--init templatewarm-starts from the template instead of realigning, which is how you fine-tune an existing model on a new sample rather than fitting from scratch.-vprints the log-likelihood and its relative change per iteration, so a long fit is visibly converging rather than merely running.For a fit that will not finish in one sitting,
--checkpoint DIRsaves the model after every iteration and--resume DIRpicks it back up — resuming reaches the same log-likelihood as an uninterrupted run, and the training log spans every attempt.- Parameters:
input (Path)
template (str | None)
locus (str | None)
organism (str)
column (str | None)
max_iter (int)
tol (float)
init (str)
gene_prior (float)
nd_prior (float)
single_d (bool)
no_calls (bool)
verbose (bool)
checkpoint (Path | None)
checkpoint_every (int)
resume_from (Path | None)
out (Path | None)
- Return type:
None
- vdjtools.cli.model_build(chains=<typer.models.OptionInfo object>, groups=<typer.models.OptionInfo object>, workers=<typer.models.OptionInfo object>, work_dir=<typer.models.OptionInfo object>, cap=<typer.models.OptionInfo object>, max_iter=<typer.models.OptionInfo object>, verbose=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Build models from the full AIRR read corpus: fetch, arda-map, then EM — several chains at once.
This is the real training path (raw FASTQ from the
isalgo/airr_model_readdataset), so it needs HuggingFace access and arda’s mmseqs2. Mapping is minutes per chain and EM on a D-bearing locus can be far longer, so use ``-v`` — without it the whole run is silent until a chain finishes, and a slow fit is indistinguishable from a stuck one.
- vdjtools.cli.model_extend(spec=<typer.models.ArgumentInfo object>, locus=<typer.models.OptionInfo object>, organism=<typer.models.OptionInfo object>, germline_v=<typer.models.OptionInfo object>, germline_j=<typer.models.OptionInfo object>, germline_d=<typer.models.OptionInfo object>, anchors=<typer.models.OptionInfo object>, weight=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Add alleles from a larger germline library, seeded from what the model already knows.
Each pre-existing gene keeps its total usage — a richer library splits a gene’s mass more finely rather than multiplying it. This seeds; follow with
model learn --init template.
- vdjtools.cli.model_rescale(spec=<typer.models.ArgumentInfo object>, samples=<typer.models.ArgumentInfo object>, fmt=<typer.models.OptionInfo object>, no_v=<typer.models.OptionInfo object>, no_j=<typer.models.OptionInfo object>, aggregate=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Replace a model’s V/J usage with your own sample’s, keeping its junction model.
V/J usage is protocol-dependent (5’RACE and DNA-multiplex amplify different V genes at very different rates); the recombination machinery underneath is not. Pass the repertoire you are actually going to score.
- vdjtools.cli.model_export(spec=<typer.models.ArgumentInfo object>, long=<typer.models.OptionInfo object>, format=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Export a model’s probabilities as tables — a hand-editable directory, or one long frame.
Both round-trip: a TSV model directory loads straight back with
--model-path, and the long frame goes back throughvdjtools.model.set_marginals.
- vdjtools.cli.model_net(spec=<typer.models.ArgumentInfo object>, format=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Render the model’s recombination Bayes net, nodes annotated with entropy and edges with MI.
- vdjtools.cli.model_entropy(spec=<typer.models.ArgumentInfo object>, table=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Information content per recombination event: entropy, mutual information, or the total.
- vdjtools.cli.model_diversity(spec=<typer.models.ArgumentInfo object>, n=<typer.models.OptionInfo object>, seed=<typer.models.OptionInfo object>, productive=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Estimate total diversity: scenario entropy, sequence entropy, and effective diversity.
Reports both Hill numbers —
2^H(the usual “~10^x distinct sequences” figure) and1/E[Pgen](how many draws before two coincide). Monte Carlo, so give it a seed.
- vdjtools.cli.model_compare(a=<typer.models.ArgumentInfo object>, b=<typer.models.ArgumentInfo object>, by=<typer.models.OptionInfo object>, usage=<typer.models.OptionInfo object>, dot=<typer.models.OptionInfo object>, dot_format=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Compare two models parameter by parameter: per-event divergence and support differences.
Jensen-Shannon is the headline (symmetric, bounded, finite when the supports differ);
tv_maxfinds the one broken gene an average hides.
- vdjtools.cli.model_compare_pgen(a=<typer.models.ArgumentInfo object>, b=<typer.models.ArgumentInfo object>, input=<typer.models.ArgumentInfo object>, column=<typer.models.OptionInfo object>, v_col=<typer.models.OptionInfo object>, j_col=<typer.models.OptionInfo object>, seq_type=<typer.models.OptionInfo object>, summary=<typer.models.OptionInfo object>, no_header=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
Score one sequence set under two models and compare the Pgen distributions.
--summarygives correlations, the KS statistic, and — the number that usually matters — how many sequences each model can score that the other assigns Pgen 0.
- vdjtools.cli.model_loglik(input=<typer.models.ArgumentInfo object>, spec=<typer.models.ArgumentInfo object>, column=<typer.models.OptionInfo object>, v_col=<typer.models.OptionInfo object>, j_col=<typer.models.OptionInfo object>, weights_col=<typer.models.OptionInfo object>, seq_type=<typer.models.OptionInfo object>, per_sequence=<typer.models.OptionInfo object>, no_header=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>)[source]#
How well a model explains a sequence set: log-likelihood, free parameters, AIC and BIC.
Nucleotide input gives a properly normalized likelihood, so BIC is meaningful; amino-acid input is a relative score on one fixed sequence set only. Sequences the model cannot generate are counted in
n_scoreable, never turned into -inf.
- vdjtools.cli.model_log(spec=<typer.models.ArgumentInfo object>, out=<typer.models.OptionInfo object>)[source]#
Show a model’s EM training log — log-likelihood per iteration, one block per run.
- vdjtools.cli.sc_convert(contigs=<typer.models.ArgumentInfo object>, out=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, airr=<typer.models.OptionInfo object>, require_cell=<typer.models.OptionInfo object>, require_high_conf=<typer.models.OptionInfo object>, consensus=<typer.models.OptionInfo object>)[source]#
Read a single-cell contig table into the canonical (or AIRR) long frame.
- vdjtools.cli.sc_pair(contigs=<typer.models.ArgumentInfo object>, out=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, locus_pair=<typer.models.OptionInfo object>, resolve=<typer.models.OptionInfo object>, flag_mispairing=<typer.models.OptionInfo object>, max_slaves_per_master=<typer.models.OptionInfo object>, drop_mispaired=<typer.models.OptionInfo object>, require_cell=<typer.models.OptionInfo object>, require_high_conf=<typer.models.OptionInfo object>)[source]#
Resolve each cell’s chains and emit one row per paired receptor.
- vdjtools.cli.sc_qc(contigs=<typer.models.ArgumentInfo object>, out=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, locus_pair=<typer.models.OptionInfo object>, require_cell=<typer.models.OptionInfo object>, require_high_conf=<typer.models.OptionInfo object>)[source]#
Chain-multiplicity quadrants: how many cells carry n light x n heavy chains.
- vdjtools.cli.sc_pgen(contigs=<typer.models.ArgumentInfo object>, out=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, locus_pair=<typer.models.OptionInfo object>, source=<typer.models.OptionInfo object>, condition_vj=<typer.models.OptionInfo object>, resolve_genes=<typer.models.OptionInfo object>, alpha_locus=<typer.models.OptionInfo object>, beta_locus=<typer.models.OptionInfo object>)[source]#
Paired generation probability per cell: Pgen(alpha) * Pgen(beta).
- vdjtools.cli.sc_export(contigs=<typer.models.ArgumentInfo object>, to=<typer.models.OptionInfo object>, out=<typer.models.OptionInfo object>, fmt=<typer.models.OptionInfo object>, index_chains=<typer.models.OptionInfo object>, repertoire_id=<typer.models.OptionInfo object>)[source]#
Export for a downstream tool (scirpy / dandelion / scRepertoire / AIRR).
Note
signature and presets are excluded above on purpose. Their help text is written for the
terminal — worked examples in indented blocks, which are not valid reStructuredText — and it is
the primary documentation for those two commands. Read it with vdjtools signature --help /
vdjtools presets --help, or see Repertoire signature — the statistics half.