Skip to content

Commit 6d671e0

Browse files
committed
fix(genotype): novel-allele review round 4 — no unplaced leak, arg validation, docs reproducibility
Correctness: - effective_dataconfig injects ONLY carried novel alleles (defined-but-unplaced novels no longer leak into the aligner reference / v_call). High. - add_novel_allele tolerates missing/empty/mismatched gapped_seq (falls back to ungapped) instead of IndexError. Medium. - delete_gene / duplicate_gene validate the haplotype argument (both/0/1) instead of silently no-op'ing or negative-indexing. Medium. Docs: - 'Reproduce it' now builds the EXACT 9-gene planted genotype behind the figure, writes reads.fasta (IgDiscover/partis) + germline + truth, and states the scoring + reported precision/recall. High. - Added a method-signature table (segment/haplotype defaults) + a D/J example; a 'Supported loci and chains' note (VDJ vs VJ, BCR/TCR); 'at compile time' wording fix. Tests: unplaced-novel non-leak, haplotype validation, missing-gapped-seq fallback.
1 parent 234c341 commit 6d671e0

3 files changed

Lines changed: 158 additions & 24 deletions

File tree

site_docs/guides/genotype.md

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ a D and a J **from the same chromosome**. That linkage is exactly the signal
3535
haplotype-inference methods exploit (e.g. the IGHJ6-anchor approach), and GenAIRR
3636
reproduces it.
3737

38+
!!! note "Supported loci and chains"
39+
Genotypes work on any GenAIRR reference cartridge — BCR **and** TCR, heavy
40+
**and** light/α/β chains. On **VDJ** loci (IGH, TRB, TRD) the genotype spans
41+
V, D and J and each rearrangement draws all three from one chromosome. On
42+
**VJ** loci (IGK, IGL, TRA, TRG) there is no D segment: genotype V and J,
43+
D rows are simply not required and are ignored. The examples below use the
44+
human IGH cartridge, but the same API applies to every locus; just use that
45+
cartridge's gene/allele names.
46+
3847
## Quick start
3948

4049
```python
@@ -100,7 +109,8 @@ the J drawn later (the phased choices are evaluated together, not independently)
100109
### Strict vs permissive
101110

102111
`Genotype.from_dataconfig(cfg)` is **strict**: any gene that could be used during
103-
recombination but was never specified is an error at attach time — you must define
112+
recombination but was never specified is an error when the experiment is compiled
113+
(`compile()` / `run_records()`) — you must define
104114
the whole genotype (use `complete_from_reference` to fill the genes you don't care
105115
about). This guarantees a genuine diploid complement, which is what you want for a
106116
ground-truth benchmark.
@@ -136,6 +146,27 @@ g.with_subject("DONOR01") # provenance label
136146
g.complete_from_reference("homozygous_first_reference") # fill every unspecified gene
137147
```
138148

149+
Every editing method takes a `segment` argument (`"V"` default, or `"D"` / `"J"`),
150+
so genotype the D and J loci too — important since J anchors and D/J usage drive
151+
haplotype-inference methods:
152+
153+
| Method | Signature | Notes |
154+
|---|---|---|
155+
| `homozygous` | `(gene, allele, segment="V")` | one allele on both chromosomes |
156+
| `heterozygous` | `(gene, allele0, allele1, segment="V")` | one allele per chromosome |
157+
| `delete_gene` | `(gene, haplotype="both"\|0\|1, segment="V")` | whole-gene or one-chromosome (hemizygous) deletion |
158+
| `duplicate_gene` | `(gene, alleles=[...], haplotype=0\|1, segment="V")` | >1 copy on one chromosome |
159+
| `add_novel_allele` | `(name, *, base, mutations\|sequence, segment="V", allow_nonfunctional=False)` | define a private allele (see below) |
160+
| `chromosome_weights` | `(w0, w1)` | allelic-expression imbalance (default 0.5/0.5) |
161+
| `with_subject` | `(sid)` | provenance label stamped on every record |
162+
| `complete_from_reference` | `(policy="homozygous_first_reference"\|"heterozygous_first_two")` | fill unspecified genes |
163+
164+
```python
165+
# Genotype the J locus too — e.g. heterozygous IGHJ6 + a homozygous IGHJ4:
166+
g.heterozygous("IGHJ6", "IGHJ6*02", "IGHJ6*03", segment="J")
167+
g.homozygous("IGHJ4", "IGHJ4*02", segment="J")
168+
```
169+
139170
Notes and guard-rails:
140171

141172
- **`delete_gene(..., haplotype=0|1)`** (one chromosome) requires the gene to be
@@ -359,36 +390,57 @@ deletions correct.*
359390

360391
### Reproduce it
361392

393+
This builds the **exact** genotype behind the figure — 3 heterozygous, 3
394+
homozygous, and 3 deleted study V genes, the rest filled from the reference —
395+
simulates 4,000 reads with light SHM at `seed=7`, and writes every input the two
396+
tools need plus the ground truth to score against:
397+
362398
```python
363399
import GenAIRR as ga
364400
import GenAIRR.data as gdata
365401
from GenAIRR.genotype import Genotype
366402

367403
cfg = gdata.HUMAN_IGH_OGRDB
368-
g = (
369-
Genotype.from_dataconfig(cfg)
370-
.complete_from_reference("homozygous_first_reference")
371-
.heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02")
372-
.homozygous("IGHVF2-G4", "IGHVF2-G4*01")
373-
.delete_gene("IGHVF3-G7", haplotype="both")
374-
.with_subject("DONOR01")
375-
)
404+
HET = ["IGHVF1-G1", "IGHVF1-G2", "IGHVF1-G3"] # 2 alleles each
405+
HOM = ["IGHVF2-G4", "IGHVF3-G5", "IGHVF3-G6"] # 1 allele
406+
DEL = ["IGHVF3-G7", "IGHVF3-G8", "IGHVF3-G9"] # deleted (both chromosomes)
407+
408+
g = Genotype.from_dataconfig(cfg).complete_from_reference("homozygous_first_reference")
409+
for gene in HET:
410+
a0, a1 = (a.name for a in cfg.v_alleles[gene][:2])
411+
g.heterozygous(gene, a0, a1)
412+
for gene in HOM:
413+
g.homozygous(gene, cfg.v_alleles[gene][0].name)
414+
for gene in DEL:
415+
g.delete_gene(gene, haplotype="both")
416+
g.with_subject("DONOR01")
417+
376418
res = (
377419
ga.Experiment.on(cfg).with_genotype(g).recombine()
378420
.mutate(rate=0.004) # light SHM, as in real data
379-
.run_records(n=4000, seed=7)
421+
.run_records(n=4000, seed=7, expose_provenance=True)
380422
)
423+
381424
res.to_tsv("repertoire.tsv") # AIRR table → TIgGER
382425
g.to_tsv("truth_genotype.tsv") # ground truth to score against
383426

384-
# export the cartridge V germline (names match v_call) for TIgGER's germline_db
385-
with open("germline_V.fasta", "w") as fh:
427+
with open("reads.fasta", "w") as fh: # raw reads → IgDiscover / partis
428+
for r in res:
429+
fh.write(f">{r['sequence_id']}\n{r['sequence'].upper()}\n")
430+
431+
with open("germline_V.fasta", "w") as fh: # cartridge V germline (names match v_call)
386432
for gene, alleles in cfg.v_alleles.items():
387433
for a in alleles:
388434
fh.write(f">{a.name}\n{a.ungapped_seq.upper()}\n")
389435
```
390436

391-
Then run the R snippet above and compare `geno` against `truth_genotype.tsv`.
437+
**Score it.** Run TIgGER (R snippet above) on `repertoire.tsv`, or IgDiscover on
438+
`reads.fasta` with the cartridge as its starting database
439+
(`igdiscover init --database db/ --single-reads reads.fasta project/ && cd project
440+
&& igdiscover run`). Then compare each tool's per-gene allele set against
441+
`g.to_table()` (the planted truth): allele-presence precision/recall, zygosity,
442+
and deletion calls. With the genotype above this yields TIgGER precision/recall
443+
1.00 (52/52 genes) and IgDiscover precision 1.00 / recall 0.96 — the figure.
392444

393445
### Running other tools on the same data
394446

src/GenAIRR/genotype.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ def heterozygous(
109109
return self
110110

111111
def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genotype":
112+
if haplotype not in ("both", 0, 1):
113+
raise ValueError(
114+
f"haplotype must be 'both', 0, or 1, got {haplotype!r}"
115+
)
112116
# One-haplotype (hemizygous) deletion requires the gene to be
113117
# specified first, otherwise the *other* haplotype would also be
114118
# empty — silently producing a full deletion that
@@ -133,11 +137,13 @@ def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genot
133137
def duplicate_gene(
134138
self, gene: str, alleles: List[str], haplotype: int, segment: str = "V"
135139
) -> "Genotype":
140+
if haplotype not in (0, 1):
141+
raise ValueError(f"haplotype must be 0 or 1, got {haplotype!r}")
136142
for a in alleles:
137143
self._check_allele(segment, gene, a)
138144
cur = self._slots[segment].get(gene, [[], []])
139145
cur = [list(cur[0]), list(cur[1])]
140-
cur[int(haplotype)] = [(a, 1, 1.0) for a in alleles]
146+
cur[haplotype] = [(a, 1, 1.0) for a in alleles]
141147
self._slots[segment][gene] = cur
142148
return self
143149

@@ -210,9 +216,13 @@ def add_novel_allele(
210216
raise ValueError("provide exactly one of `mutations` or `sequence`")
211217

212218
base_ungapped = base_allele.ungapped_seq.upper()
213-
gapped = list(base_allele.gapped_seq)
214-
# ungapped index -> gapped index (positions of non-gap characters)
215-
ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq) if ch != "."]
219+
gapped = list(base_allele.gapped_seq or "")
220+
# ungapped index -> gapped index (positions of non-gap characters).
221+
# Some custom cartridges carry no (or inconsistent) gapped sequence;
222+
# in that case we can't project onto gaps, so fall back to an
223+
# ungapped novel sequence (no gap-derived metadata).
224+
ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq or "") if ch != "."]
225+
project_gaps = len(ung_to_gap) == len(base_ungapped)
216226
seq = list(base_ungapped)
217227
if sequence is not None:
218228
sequence = sequence.upper()
@@ -241,15 +251,20 @@ def add_novel_allele(
241251
if new_ungapped == base_ungapped:
242252
raise ValueError("novel allele is identical to its base allele")
243253
# Project the substitutions onto the gapped sequence too, so
244-
# gap-dependent metadata stays consistent.
245-
for k, b in enumerate(seq):
246-
gapped[ung_to_gap[k]] = b
254+
# gap-dependent metadata stays consistent. If the base has no
255+
# usable gapped sequence, fall back to the ungapped form.
256+
if project_gaps:
257+
for k, b in enumerate(seq):
258+
gapped[ung_to_gap[k]] = b
259+
new_gapped = "".join(gapped)
260+
else:
261+
new_gapped = new_ungapped
247262

248263
novel = _copy.deepcopy(base_allele)
249264
novel.name = name
250265
novel.gene = gene
251266
novel.ungapped_seq = new_ungapped
252-
novel.gapped_seq = "".join(gapped)
267+
novel.gapped_seq = new_gapped
253268
if hasattr(novel, "ungapped_len"):
254269
novel.ungapped_len = len(new_ungapped)
255270

@@ -296,15 +311,31 @@ def has_novel(self) -> bool:
296311
def novel_allele_names(self) -> Set[str]:
297312
return set(self._novel)
298313

314+
def _carried_allele_names(self) -> Set[str]:
315+
"""Allele names actually placed on a haplotype (across segments)."""
316+
names: Set[str] = set()
317+
for seg in _SEGMENTS:
318+
for haps in self._slots[seg].values():
319+
for hap in haps:
320+
names.update(a for (a, _c, _w) in hap)
321+
return names
322+
299323
def effective_dataconfig(self):
300324
"""Return a copy of the source ``DataConfig`` with this genotype's
301-
novel alleles appended to their genes' allele lists — the reference
302-
the engine actually runs against when novel alleles are present."""
325+
**carried** novel alleles appended to their genes' allele lists —
326+
the reference the engine actually runs against when novel alleles
327+
are present. A novel allele that was defined but never placed on a
328+
haplotype is NOT injected (it would otherwise pollute the aligner
329+
reference and could surface in ``v_call`` despite being absent from
330+
the ground truth)."""
303331
import copy as _copy
304332

305333
cfg = _copy.deepcopy(self._cfg)
306334
by_seg = {"V": cfg.v_alleles, "D": cfg.d_alleles, "J": cfg.j_alleles}
307-
for info in self._novel.values():
335+
carried = self._carried_allele_names()
336+
for name, info in self._novel.items():
337+
if name not in carried:
338+
continue
308339
d = by_seg[info["segment"]]
309340
existing = list(d.get(info["gene"], []))
310341
existing.append(_copy.deepcopy(info["allele"]))

tests/test_genotype_novel.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,57 @@ def test_to_table_and_tsv_expose_novel(tmp_path):
136136
assert "novel" in header
137137

138138

139+
def test_unplaced_novel_allele_not_injected_or_emitted():
140+
cfg = _cfg()
141+
gene = "IGHVF1-G1"
142+
# Define a novel allele but NEVER place it on a haplotype.
143+
g = (
144+
Genotype.from_dataconfig(cfg)
145+
.add_novel_allele(f"{gene}*unplaced", base=f"{gene}*01", mutations=_SAFE)
146+
.complete_from_reference()
147+
.with_subject("S1")
148+
)
149+
# Effective reference must not contain the unplaced novel allele.
150+
eff = g.effective_dataconfig()
151+
eff_names = {a.name for alleles in eff.v_alleles.values() for a in alleles}
152+
assert f"{gene}*unplaced" not in eff_names
153+
res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(
154+
n=200, seed=4, expose_provenance=True
155+
)
156+
assert all(f"{gene}*unplaced" not in r["v_call"] for r in res)
157+
assert all(f"{gene}*unplaced" not in r["truth_v_call"] for r in res)
158+
159+
160+
def test_haplotype_argument_validation():
161+
cfg = _cfg()
162+
gene = "IGHVF1-G1"
163+
a0 = cfg.v_alleles[gene][0].name
164+
g = Genotype.from_dataconfig(cfg).homozygous(gene, a0)
165+
with pytest.raises(ValueError, match="haplotype must be"):
166+
g.delete_gene(gene, haplotype=2)
167+
with pytest.raises(ValueError, match="haplotype must be"):
168+
g.delete_gene(gene, haplotype="x")
169+
with pytest.raises(ValueError, match="haplotype must be 0 or 1"):
170+
Genotype.from_dataconfig(cfg).duplicate_gene(gene, [a0], haplotype=-1)
171+
with pytest.raises(ValueError, match="haplotype must be 0 or 1"):
172+
Genotype.from_dataconfig(cfg).duplicate_gene(gene, [a0], haplotype=2)
173+
174+
175+
def test_novel_synthesis_tolerates_missing_gapped_seq():
176+
import copy
177+
178+
cfg = copy.deepcopy(_cfg())
179+
# Simulate a custom cartridge whose base allele has no gapped sequence.
180+
cfg.v_alleles["IGHVF1-G1"][0].gapped_seq = ""
181+
g = Genotype.from_dataconfig(cfg).add_novel_allele(
182+
"IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=_SAFE
183+
)
184+
nv = g._novel["IGHVF1-G1*i01"]["allele"]
185+
# falls back to ungapped form (no crash)
186+
assert nv.gapped_seq == nv.ungapped_seq
187+
assert nv.ungapped_seq[38] == "C"
188+
189+
139190
def test_genotype_without_novel_is_unaffected():
140191
cfg = _cfg()
141192
g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1")

0 commit comments

Comments
 (0)