Personalized germline: genotype and call restriction#
A germline reference is a catalogue of every allele anyone carries. No donor carries all of them, and no donor carries more than two per gene — so a call naming a third is wrong before any sequence is looked at, and a read that “could not choose” between two alleles the donor does not have was never ambiguous at all. Immcantation’s TIgGER measured the size of this on full-length BCR: restricting V calls to an inferred genotype took ambiguous assignments from 11.2 % to 1.5 %.
arda splits this into the two halves that have very different confidence:
arda resolve-ties --genotypeApply an allele set you already trust. Solid, cheap, and useful on its own.
arda genotypeInfer the allele set from mapped reads, by a likelihood ratio between diploid genotypes. Whether it can say anything depends on read length — see below.
Applying a genotype#
The simplest possible genotype file is a one-column TSV of allele names:
allele
TRBV19*01
TRBV19*03
TRBV20-1*01
arda resolve-ties -i sample.airr.tsv -o sample.genotyped.tsv --genotype genotype.tsv
This adds one column, v_call_genotyped, and leaves v_call byte-identical — the evidence the
genotype was applied to stays in the same file, so a reader can re-restrict at a different genotype
without re-running anything.
It never re-aligns and never rebuilds a reference. Given the span a read already aligned over,
the restriction is a set intersection: the germlines a read cannot rule out, intersected with the
ones its donor carries. The same reasoning as TIgGER’s reassignAlleles, plus three arda-specific
reasons a per-donor reference would be a trap — scaffold ids are positional so changing the allele
set renumbers every scaffold in the locus; arda build-db needs IgBLAST and IMGT network access
that a pip install does not have; and the mmseqs freshness contract is an mtime with no
allele-set identity recorded, so a donor-specific FASTA beside the shared one would silently
invalidate the index for every concurrent process.
v_call_genotyped has exactly three value classes, and they are deliberately distinguishable:
value |
meaning |
|---|---|
narrower than |
the usual case, and the point |
equal to |
either the tie machinery declined (span under 30 nt, allele not in the reference, tie list past the cap) or no candidate’s gene was genotyped. A refusal to answer is not a contradiction; treating the two alike would delete the call of every short read. |
empty |
every candidate belongs to a genotyped gene and none is carried — this read contradicts the genotype |
An allele survives unless its own gene was genotyped and did not name it. Tie lists routinely span genes, so a gene-blind test empties every read whose tie list merely brushes a genotyped gene.
Surviving alleles keep v_call’s own order, so a restriction that removes nothing returns a
byte-identical string. ⚠ That is not cosmetic: the tie list comes back sorted by name while
v_call carries the aligner’s order, and emitting the sorted one made a pure reordering
indistinguishable from a real narrowing — on a 100,000-read TRA amplicon the run report said
20,587 narrowed when 20,306 of those were TRAV20*02,TRAV20*01 becoming
TRAV20*01,TRAV20*02. The real number was 281.
Any source works: an OGRDB set, a library MiXCR inferred, a list typed by hand. Every named allele is validated against the reference and an unknown one raises — 884 human V alleles are functional in IMGT and 801 reach a scaffold, so naming one of the others is an easy mistake that would otherwise restrict nothing, silently.
Inferring a genotype#
arda genotype -i sample.airr.tsv -o sample.genotype.tsv --loci TRB
The junction is de facto a UMI. V(D)J junctional diversity makes a nucleotide junction
essentially unique to one rearrangement, so grouping reads by (locus, gene, J gene, junction)
groups them by molecule. Two things follow:
The clonotype, not the read, is the unit of observation — each distinct junction is one independent draw from the donor’s two chromosomes, however many reads carry it. Read depth is reported beside the clonotype count, never substituted for it.
Disagreement within a junction is error, not allele. Every read of one rearrangement carries the same V allele by construction, so a read naming a different one is hypermutation or sequencing error. That is where the error rate comes from — measured on the library rather than picked. ⚠ It is measured over all reads, not the germline-exact subset used for assignment: those were selected for carrying no mismatch, so they never disagree and the estimate collapses silently onto its floor (0 discordant of 77,345 reads on the amplicon below).
The call is then a likelihood ratio between diploid genotypes. Every single allele and every
pair is scored by its multinomial likelihood under that error rate, and the winner is called only
if it beats the runner-up by --min-log10-bf (default 1.0 — ten times more likely). Homozygous
and heterozygous are the same formula at genotype size 1 and 2, so neither is special-cased.
Note
Why a likelihood and not a coverage rule. The first version of this was TIgGER’s frequency
rule — the fewest alleles explaining 7/8 of the calls — which has no error model and so cannot
tell overwhelming evidence from none. On a real TRB amplicon it called TRBV11-2 off 754 of
757 clonotypes that singled the allele out and TRBV20-1 off 1 of 2,544, reported both
as explained = 1.0000, ok, and returned 43 of 53 genes with not one heterozygous. A ratio of
counts is not a confidence.
Ambiguous clonotypes still constrain the answer. Discarding them is not the conservative option, it
is wrong: if a donor is *01/*02 and only *02 is separable at the read length, every
unambiguous observation is *02, the likelihood says homozygous, and the restriction then
empties every *01 read — systematically, on every sample with that genotype. A genotype that
fails to intersect --fraction-to-explain of the compatible sets is rejected however well it
fits the counts.
Read length is the binding constraint#
Separating a gene’s alleles needs, measured from the 3’ end of the V germline:
multi-allele genes |
median nt needed |
separable within 100 nt of the 3’ end |
|
|---|---|---|---|
TRBV |
44 / 56 |
150 |
16 / 44 |
TRAV |
30 / 45 |
175 |
8 / 30 |
IGHV |
57 / 75 |
230 |
9 / 57 |
Measured end to end on SRR5233641 (human TRB amplicon, 151 nt paired, 99,839 mapped reads →
33,440 clonotypes / 77,345 reads, error rate 5.25 × 10⁻⁴): reads cover a median of 72 nt
of V germline — 56 nt after clipping at the Cys104 anchor — and not one reaches the ~150 nt
TRBV needs. Only 33.1 % of clonotypes can be assigned an allele at all. The result is 17 of 53
genes called, of which 11 are single_allele (one catalogued allele, carried without being
inferred) and 6 genuinely inferred, with log₁₀ Bayes factors of 223 (TRBV11-2, 754
clonotypes), 251 (TRBV5-6, 846) and 11.7 (TRBV5-8, 39). The other 36 are refused —
including TRBV10-3 with 1,037 clonotypes, none of which can separate its alleles.
That is the honest output for that library, and the refusals are the point: a full-length, 5’RACE
or arda cells library (contig N50 536 nt) has the resolution this one does not.
What it looks like when a gene IS separable. On a TRA amplicon (SRR5233635, 100,000 reads,
151 nt, 21,710 clonotypes / 45,007 reads, error rate 5.53 × 10⁻⁴ — two independent libraries,
same order of magnitude), 20 of 44 genes are called and one is heterozygous:
TRAV36/DV7 = *01 / *04, 78 clonotypes against 143, at a log₁₀ Bayes factor of 211
over the gene’s 290 clonotypes. Five more are inferred homozygous at log₁₀ BF 53–221, 14 have one
catalogued allele, and 24 are refused low_support — TRAV needs 175 nt and these reads are
151. The whole inference takes 1.53 s and 442 MB on 49,748 mapped reads.
Applying that genotype narrows 281 of 47,743 rows, contradicts 75, and leaves 47,387 unchanged. That is the honest shape of the feature at this read length and it follows directly from the table above: you cannot restrict what you could not genotype, and 14 of the 20 genes that were called have only one allele to begin with. ⚠ The narrowing a full-length library buys is the 11.2 % → 1.5 % TIgGER measured; it is not what a 151 nt amplicon can show.
The output has one row per carried allele, and a row with an empty allele for every gene that
could not be called — locus, gene, allele, clonotypes, reads, gene_clonotypes, log10_bf, note.
clonotypes and reads are that allele’s coverage at both levels, gene_clonotypes is
every clonotype of the gene including the ones no allele could be assigned to, and log10_bf is
the margin over the runner-up genotype. The parameters that produced the file, the clonotype and
read totals, the assigned fraction and the measured error rate are written as # comments at the
top, because a genotype is only meaningful against the reference and the evidence it came from.
|
meaning |
|---|---|
|
called, beating the runner-up by at least |
|
the reference has one allele for this gene; carried without being inferred |
|
fewer than |
|
the best genotype does not beat the runner-up by |
|
the best genotype fits the counts but leaves too many ambiguous clonotypes unexplained |
What this is not#
A claim about the reference, never about the repertoire. It answers which germline alleles a
donor carries — annotation, the same kind of question as which J a read uses. Clonal composition,
diversity and overlap are vdjtools’.
Not Quality control’s allele_candidate scope, which stays a shortlist of recurrent high-quality V
mutations to look at and is deliberately never a call.
Downstream, vdjtools already ingests a user germline through
model.reference.read_germline_fasta and model.extend_alleles, whose own docstring names the
use case: a newer IMGT release, a population-specific library, your own genotyped alleles.