How low can you go: exome, ctDNA and MRD#
Three applications, one question. What is the lowest allele frequency this library can detect? It is answered by two numbers, and the variant caller is neither of them:
N molecules covering the site -- what `migec assemble` counts
p per-MOLECULE error floor -- the RT/first-cycle floor, `docs/quality_floor.rst`
N sets how low you could go; p sets how low you can. Sequencing deeper raises reads per
molecule, not N. Only more input DNA, or more tracked sites, raises the evidence.
python scripts/detection_limit.py --input-ng 20 --sites 5 # a ctDNA panel
python scripts/detection_limit.py --input-ng 50 --sites 30 --rt-error duplex # MRD
python scripts/detection_limit.py --from-json asm/assemble.json --sites 5
The three regimes#
Everything below is one of three situations, and knowing which you are in tells you what to buy. The third is the one that surprises people, because the usual lever makes it worse.
molecule-limited |
floor-limited |
artifact-limited |
|
|---|---|---|---|
what binds |
too few molecules cover the site |
the chemistry errs at random, per molecule |
the chemistry errs systematically, at this base |
symptom |
the variant is absent from the library |
the variant is as common as background |
a reproducible false call at a fixed position |
fix |
more input DNA, or more tracked sites |
a lower floor: proofreading enzyme, or duplex |
a per-position background model from known negatives |
what does not help |
deeper sequencing, a better caller |
deeper sequencing, more input, a better caller |
more molecules – see below |
The molecule/floor crossover is at VAF = p/3, the frequency at which a true variant molecule is
as rare as the chemistry’s own false ones. At the default RT floor of 1e-4 that is 3.3e-5, and
no amount of input DNA reaches below it.
Never: an assay designed past its floor spends money on sequencing that cannot work. A 50 ng, 30-site MRD panel has enough molecules for 6.9e-6 – but on a single-strand protocol the floor sits at 3.3e-5, five times higher. The molecules promise something the chemistry cannot deliver.
Never: more molecules makes an existing artifact easier to call, not harder – but only where the artifact is there to begin with. Two factors decide it, and they are separable. Splitting 72 runs by preparation and by molecule count:
preparation |
molecules per site |
calls per sample |
n |
|---|---|---|---|
diluted, fewer molecules |
2,579 |
1.0 |
24 |
diluted, more molecules |
7,354 |
7.1 |
24 |
undiluted, fewer molecules |
3,686 |
1.1 |
12 |
undiluted, more molecules |
13,611 |
1.4 |
12 |
Within the diluted material, 2.9x the molecules gives 7.1x the calls. Within undiluted material at more molecules than that, 3.7x the molecules gives 1.3x. So the two factors do different jobs:
Preparation decides whether the artifact exists. The dilution series was made by mixing, and the extra handling is what the artifact tracks; the raw material barely shows it.
Molecule count decides whether you can see it. A systematic error does not average out, so the evidence that makes a real variant significant makes a present artifact significant too.
That is why the usual lever backfires here. At 20 ng and 10x the 0.125% arm read 0.17% while the true negative read 0.66% – the negative outscoring the positive. Below the artifact level the ranking carries no information without a background model.
ctDNA#
Molecule-limited almost always, because the input is a blood draw and cell-free DNA is scarce: 5-30 ng from 10 mL of plasma is typical, and 20 ng is only ~6,000 haploid genomes.
Note: ctDNA and cfDNA are not synonyms and this page uses both deliberately. Cell-free DNA is the input, all of it, from a blood draw. Circulating tumour DNA is the tumour-derived fraction of that input, and the VAF is what measures the fraction. An assay is run on cfDNA for ctDNA.
Variant calling: which caller, and what it can possibly see has the measurement over 100 runs of cfDNA reference material at certified frequencies, and it is a two-part answer. Above ~1% VAF the input mass decides the outcome and the caller does not – 1% was called in 11 of 12 runs at an accurate 0.93%. Below 1% neither does, because the assay becomes artifact-limited: 0.125% was called in 1 of 12 runs while the 0%-certified arm was called in 3 of 12.
Two things a total molecule count hides, both measured on that panel by aligning to GRCh38:
Coverage is not uniform. Across 72 runs the weakest target held 0.09-0.64x of the panel mean (median 0.36), so an average overstates the thinnest target by up to elevenfold.
Off-target product is invisible without a reference, and it grows as input falls. One locus outside any coding sequence took a share that tracked the DNA input almost perfectly:
input
off-target share
on-target molecules
80 ng
5-7%
110,000-122,000
20 ng
24%
45,000-57,000
5 ng
47-58%
11,500-12,700
The absolute count barely moves (~15,000 molecules at both 5 and 20 ng at the same depth) while on-target scales with input, so at 5 ng more than half the library is not evidence about anything.
Never: it is adapter read-through, not a PCR product and not poly-G reads. The sequences there are 85-95 bases soft-clipped with ~45 aligned (
87S52M,94S45M,95S44M) at MAPQ 4-16, against MAPQ 60 for 1,844 of 1,907 molecules on real TP53, and they carry the TruSeq adapterGATCGGAAGAGCACACGTCTGAACTCCAGTCAC. Short inserts let the read run past the fragment into the adapter; the leftover mismaps into a low-complexity locus which is 97% G with an 81 bp pure-G run, against 19-33% G for every real amplicon.So it is removed by things that cost nothing:
fix
where
effect
-q 20MAPQ filteralignment
removes the mismapped alignments – MAPQ 4-16 against 60 – and does not reduce the call burden. Measured below: it raises it
adapter trimming
before
checkoutremoves the cause. Diagnosed, still not measured
--min-reads 3assemblea different artifact class, and the one that works; see below
Note: the G-rich locus is a red herring worth naming, because it is the sort of thing that invites a poly-G explanation. The barcodes of those molecules have ordinary G content (mode 3 of 12, same as a real amplicon) and under 5% of consensus records are even 50% G. The reference is G-rich; the reads are not.
So for ctDNA: count molecules per target, quote the weakest target rather than the mean, and never read a library total as on-target depth. Precisely when input is scarce – the case that matters – the total is most misleading.
How much plasma DNA do you need?#
Combining the measured per-target molecule counts with the arithmetic above answers the question people actually ask, for this panel, at 95% detection and three supporting molecules:
input |
molecules at the weakest target |
limit of detection |
detects 0.125%? |
|---|---|---|---|
5 ng |
796 |
0.79% |
no – six times too high |
20 ng |
3,529 |
0.18% |
no – marginally too high |
80 ng |
~24,000 |
0.026% |
yes, with room to spare |
Never: quoting the panel average would have said 20 ng was sufficient. It is not, for a variant that happens to sit on the weakest amplicon – and which amplicon a patient’s variant sits on is not something you get to choose. The weakest target holds as little as 0.09x the panel mean, so an average can overstate it elevenfold.
Never: that table is the molecule-limited answer only, and it is optimistic. It says 80 ng reaches 0.026% and therefore calls 0.125% comfortably. Scoring actual calls against the certified frequencies says otherwise – 0.125% was detected in 1 of 12 runs, and the 0%-certified arm was called in 3 of 12. The molecules are there; what is missing is a background model, because below 1% this panel is artifact-limited rather than molecule-limited. Use the table to rule inputs out, never to rule one in.
What input actually buys: precision, not accuracy#
Running the full chain – assemble consensus, minimap2 -y, LoFreq on the inferred panel –
over the undiluted arm recovers PIK3CA H1047R (3:179234297 A>G), and the certified 1%
dilution comes back at 0.92% across three replicates. Across a 16x range of DNA input the point
estimate does not move; only its scatter does:
input |
n |
mean VAF |
SD |
CV observed |
CV if Poisson |
excess |
|---|---|---|---|---|---|---|
5 ng |
8 |
3.72% |
0.73 pp |
0.196 |
0.119 |
1.6x |
20 ng |
9 |
3.54% |
0.38 pp |
0.108 |
0.055 |
2.0x |
80 ng |
6 |
3.60% |
0.27 pp |
0.074 |
0.033 |
2.2x |
The assay is unbiased – 3.5-3.7% at every input – and precision improves roughly as
1/sqrt(N). That is the practical meaning of a molecule count: more DNA does not give you a
different answer, it gives you a more certain one.
Never: the observed scatter is consistently about twice the Poisson prediction. Molecule
sampling explains only half of it; the rest is library preparation and PCR efficiency. So the limit
of detection computed by scripts/detection_limit.py is a floor, not a field estimate – a
real assay will do worse, and validating against a dilution series is the only way to know by how
much.
The true negative is not empty#
The arm that matters most is the one certified at 0% mutant, and running the same consensus-then-LoFreq chain over it does not return nothing. It returns 9-11 calls per sample at 0.4-1.4% VAF, and they are not noise:
94% of them are
-> G(14 A>G, 9 T>G, 6 C>G, against one C>A and one T>A).Eight positions recur in 3 of 3 replicates, with VAF reproducible to the third decimal –
3:179234288 A>Gat 0.0137 / 0.0133 / 0.0140,4:54733163 A>Gat 0.0117 / 0.0127 / 0.0140.
A -> G bias is the signature of 2-colour chemistry, where G is the base call for no
signal: any position that loses fluorescence reads as G. These runs are MiniSeq, which is
2-colour. Consensus does not remove it, because it is not a random sequencing error – it is a
systematic, position-specific, sequence-context-driven bias that most reads of a molecule share.
Never: the artifact lands on the hotspot too. 3:179234297 A>G is PIK3CA H1047R, and the
true-negative arm calls it at 0.58-0.79% while the certified 1% arm reads 0.92%.
arm |
measured VAF at H1047R |
truth |
|---|---|---|
undiluted |
3.6% |
positive |
certified 1% (5 ng, 3.3x) |
0.92% |
1% |
certified 0% (20 ng, 10x) |
0.66% |
0% |
Note: those two arms differ in input and depth as well as in truth, so this is not a matched comparison – but the artifact is at the same base, is reproducible across replicates, and is 72% the size of the certified signal. A pipeline that reports 0.92% as a detection must explain why 0.66% is not one.
What follows for the caller#
A standard caller on consensus reads is not sufficient on its own. The molecule count is right, the consensus is right, and the caller still reports systematic false positives, because nothing in that chain knows that this base on this strand in this context reads high.
What fixes it is a per-position background model built from samples known not to carry the variant – and that, rather than UMI handling as such, is what the UMI-aware and panel-of-normals callers actually contribute:
approach |
what it models |
|---|---|
|
per-site artifact rate across a normal cohort |
|
per-position beta-binomial / learned error model over a panel of normals |
|
per-position beta-binomial background |
|
per-position Poisson background |
|
the base qualities only – which the artifact does not violate |
So the recommendation in Variant calling: which caller, and what it can possibly see stands for which caller, and gains a condition: run it against a background model. On this data a panel of normals is not optional, and the WT arm of a reference material series is exactly the cohort to build one from.
migec assemble rf/S1.fq.gz -o as/
minimap2 -ax sr -y ref.fa as/S1.consensus.fq.gz | samtools sort -o S1.bam
samtools index S1.bam
# molecules at one target: one consensus record is one molecule, so count distinct MI
samtools view S1.bam 17:7673727-7673832 | grep -o 'MI:Z:[^\t]*' | sort -u | wc -l
Exome#
Also molecule-limited, but for a different reason: an exome spreads its molecules over ~200,000 targets, so the mean is meaningless and the distribution is everything. A “100x mean” exome routinely has thousands of targets in the single digits, and a variant in one of those is undetectable no matter how good the caller is.
migec’s contribution here is that its depth is a molecule count, so the per-target number you
compute is the real evidence rather than a duplicate-inflated read count. Capture panels also make
coordinate deduplication actively wrong – probes pile reads on identical start positions whether
or not they came from one molecule, which is what notebooks/exome_capture.py demonstrates.
Target definitions do not need a vendor login: AstraZeneca-NGS/reference_data ships hg38 BEDs
for Agilent V2-V6, IDT V1, MedExome, NGv3 and a canonical CDS set (SOURCES.md).
Never: those BEDs are chr1-style. An Ensembl reference is 1, and intersecting the two
returns zero rather than an error – a silent clean-looking negative. Strip the prefix first.
MRD#
The case migec was built for. Minimal residual disease means tracking a known variant set – originally the leukaemic clone’s IGH rearrangement, which is why the original MIGEC paper is a repertoire paper – at frequencies far below anything a discovery assay reaches.
Two things make that possible, and both are arithmetic rather than software:
You know where to look. No multiple-testing burden across the genome, so a single supporting molecule can be meaningful where a discovery assay would need many.
You track many sites at once. Evidence pools. Thirty patient-specific variants is thirty times the molecules that can carry a signal, and thirty times lower a reachable frequency. This is why an MRD panel follows tens of mutations rather than one:
50 ng input, single site LOD 2.1e-04
50 ng input, 30 sites pooled LOD 6.9e-06 <- 30x lower, same blood draw
And then the floor stops you. At the RT floor of 1e-4 the crossover is 3.3e-5, so the pooled 6.9e-6 above is unreachable on a single-strand protocol: 30 background false molecules against the 3 you are trying to call. Duplex sequencing – requiring both strands of the original duplex to agree – moves the floor by orders of magnitude and makes the molecule count binding again.
protocol |
floor |
crossover |
lowest useful VAF |
|---|---|---|---|
RT / cDNA ( |
1e-4 |
3.3e-5 |
~1e-4; below this, duplex or nothing |
ordinary polymerase ( |
1e-5 |
3.3e-6 |
~1e-5 |
proofreading, no RT ( |
1e-6 |
3.3e-7 |
~1e-6 |
duplex (both strands agree) |
~1e-9 |
~3e-10 |
molecule-limited, not floor-limited |
Never: migec v2 extracts duplex tags but emits single-strand consensuses. It does not yet build
duplex consensus (ROADMAP.md), so no error-suppression claim here rests on duplex data. For IGH
MRD the clonotype half of the problem is arda’s: migec
gives it one record per molecule, and its AIRR duplicate_count is then a molecule count, which
is the number a residual-disease burden should be computed from.
--min-reads, and the artifact it removes#
Which floor applies to your chemistry, and what a consensus is worth there, is the other axis:
Assays: what a consensus is worth has the per-assay recipe and the RNA/DNA split of the pre-amplification floor. What
follows is the measurement that sets ctdna’s --min-reads 3.
Never: ``–min-reads`` defaults to 1, which is right for counting molecules and wrong for calling variants. A consensus over one read is that read – no error correction at all, just counting. Measured on certified cfDNA reference material at 20 ng and 10x:
variant |
|
|
|
|---|---|---|---|
|
0.0118 |
0.0118 |
0.0125 |
|
0.0139 |
0.0137 |
0.0138 |
|
0.0041 |
gone |
gone |
|
0.0072 |
gone |
gone |
|
0.0067 |
gone |
gone |
|
0.0077 |
gone |
gone |
|
0.0068 |
gone |
gone |
Scored against truth across three replicates per arm at 20 ng / 10x, the effect is categorical:
arm |
|
calls/sample |
|
H1047R called |
mean VAF |
|---|---|---|---|---|---|
0%, truth: absent |
1 |
10.0 |
29 |
3/3 wrong |
0.0066 |
0% |
3 |
2.0 |
0 |
0/3 correct |
– |
0% |
5 |
1.0 |
0 |
0/3 correct |
– |
1%, truth: present |
1 |
9.0 |
17 |
3/3 correct |
0.0101 |
1% |
3 |
6.3 |
3 |
3/3 correct |
0.0102 |
1% |
5 |
5.0 |
3 |
3/3 correct |
0.0104 |
Specificity goes from 0% to 100% with no loss of sensitivity, and the measured frequency does not
move (1.01% -> 1.02% against a certified 1%). Every -> G artifact in the true negative is
gone. Requiring three reads discards the molecules that
were never error-corrected, which is exactly the population the dark-G bias rides on.
It is not free, and the trade is worth stating in full. Measured on the 20 ng / 10x arm:
|
|
change |
|
|---|---|---|---|
molecules at the site |
12,471 |
7,393 |
-41% |
molecule-limited LOD |
5.1e-4 |
8.5e-4 |
1.7x worse |
artifact floor (measured) |
6.6e-3 |
none observed |
removed |
what actually binds |
6.6e-3 (the artifact) |
8.5e-4 (the molecules) |
8x better |
So the filter throws away 41% of molecules and makes the molecule-limited limit 1.7x worse – while
removing a floor that was 13x higher than the molecule limit to begin with. Never: judge
--min-reads on molecules retained and it looks like a loss. Judge it on what binds and it is an
eightfold gain, because at --min-reads 1 the molecules were never the constraint.
Note: retention across the arm was 63% at --min-reads 3 and 54% at 5. Going to 5 costs another
9% of molecules and removed nothing further here, so 3 is where the curve flattens on this
chemistry. On a library with more reads per molecule the same threshold costs less; on a shallower
one it costs more, which is why it is a recommendation per assay rather than a new default.
The intermediate arms, and what the artifact was adding#
The two arms between the extremes are where the artifact’s additivity shows, because there the
true frequency and the artifact floor are the same order of magnitude. Three replicates per arm,
LoFreq on the same consensus BAM, assets/ctdna_callers.tsv:
arm |
certified |
|
|
|
H1047R seen |
|---|---|---|---|---|---|
0.125% |
0.00125 |
0.0052 |
0.0014 |
0.0012 |
1 of 3 |
0.25% |
0.0025 |
0.0079 |
0.0022 |
0.0023 |
3 of 3 |
1% |
0.01 |
0.0101 |
0.0102 |
0.0104 |
3 of 3 |
At --min-reads 1 the measured frequency is the certified one plus a floor of 0.4-0.6%: the
0.125% arm reads 4.2x its truth and the 0.25% arm 3.2x, while the 1% arm – where the floor is a
twentieth of the signal – reads correctly. At --min-reads 3 all three track the certified
value to within 12%. That is the same additivity the 0%-certified arm shows directly, seen from the
other side.
Never: the 0.125% arm is detected in 1 replicate of 3 at every threshold. That is a molecule limit, not a filter effect, and no threshold moves it – see the plasma-DNA table above.
The MAPQ floor is not the fix#
The adapter read-through above is real and a -q 20 filter does remove those alignments. It
does not remove the calls. Applied to the same consensus BAMs before calling, three replicates per
arm:
arm |
|
calls/sample, MAPQ 0 |
calls/sample, MAPQ >= 20 |
change |
|---|---|---|---|---|
0%, truth: absent |
1 |
10.0 |
25.7 |
2.6x worse |
0% |
3 |
2.0 |
2.0 |
none |
0% |
5 |
1.0 |
1.0 |
none |
0.25% |
1 |
10.3 |
26.3 |
2.5x worse |
1% |
1 |
9.0 |
10.0 |
+1 |
The call set at MAPQ >= 20 is a strict superset of the one at MAPQ 0 on the 0% arm at
--min-reads 1: 13 positions shared, 18 added, none removed. The 18 additions sit at 0.22-0.38%
VAF and 16 of the 18 are -> G – the same dark-G artifact, previously just under the
threshold. Depth at those loci barely moved (6,434 against 6,473), so the filter did not take
evidence away; it moved where the caller’s threshold fell.
Never: a filter that removes the diagnosed cause is not the same thing as a filter that removes
the calls. The mismapping is real, the MAPQ floor removes it, and the false-positive burden goes
up. At --min-reads 3 and 5 the MAPQ floor changes nothing at all, because the artifact it
would have to remove is already gone. Adapter trimming – removing the cause before checkout
rather than its symptom after alignment – is still diagnosed and still unmeasured.
What you can do blind#
That table is also a test you can run without a panel of normals, and it is the answer to “what if I have no matched controls”. migec knows something a caller does not: how many reads built each molecule.
A real variant at VAF
fsits in ~``f`` of molecules regardless of how many reads built them, so its frequency does not move as the threshold rises.A context artifact is carried disproportionately by molecules made from few reads, because a singleton consensus is one raw read carrying the raw per-base error rate.
So call the same sample at several thresholds and keep what holds still. Note: assemble does
not need rerunning – every consensus record already carries cD:i:N, its true molecule depth,
and minimap2 -y carries it into the BAM. One assemble, one alignment, then filter on the tag:
migec assemble rf/S1.fq.gz -o as/
minimap2 -ax sr -y ref.fa as/S1.consensus.fq.gz | samtools sort -o S1.bam
samtools index S1.bam
for mr in 1 3 5; do
samtools view -e "[cD]>=$mr" -b S1.bam > S1.mr$mr.bam && samtools index S1.mr$mr.bam
lofreq call -f ref.fa -l panel.bed -o S1.mr$mr.vcf S1.mr$mr.bam
done
python scripts/blind_artifact_filter.py \
--vcf 1=S1.mr1.vcf 3=S1.mr3.vcf 5=S1.mr5.vcf
That is a third of the work, and the subsets are guaranteed nested because they are the same
records. --min-reads on assemble is still right once you have chosen a threshold; this is
for when you want the trend, which is the thing that discriminates.
On the sample above it returns 4 real and 5 artifact, and all five artifacts are -> G –
which the script says out loud, because a spectrum that lopsided is a platform signature rather
than biology.
Note: this is weaker than a real per-position background model, and it is not a substitute for one
where normals exist. What it does is convert “I have no controls” from nothing into one
orthogonal axis of evidence – and it costs two extra assemble runs, which are the cheapest
stage in the pipeline.
What to report#
For any of the three, the honest read-out is three numbers, and migec prints all of them:
number |
where |
why it matters |
|---|---|---|
molecules per target |
the consensus BAM, distinct |
the evidence; never the library total, never a read count |
barcode error, as a Phred |
|
whether grouping is trustworthy at this depth (Barcode error against depth) |
the emitted quality cap |
|
the floor; anything below |
Never: do not quote a limit of detection without saying which of the two regimes produced it. “We detect 0.1%” means one thing when 12,000 molecules cover the site and another when the chemistry floor is 1e-3, and only the first is improved by a bigger blood draw.