Repository navigation
mpileup: use 64-bit accumulators in the Mann-Whitney bias Z scores - #2595
cindykrafft wants to merge 1 commit into
Conversation
calc_mwu_biasZ() summed the tie adjustment (p*p-1)*p, the pair counts and the group sizes in `int`. The tie term is cubic in the number of reads sharing one quality or position bin and exceeds INT_MAX once a bin holds 1291 reads. That happens at a single deep site (-d raised for amplicon, mitochondrial or viral data) and, under the default -d 250, across the samples of a multi-sample call: the histograms are pooled over all samples, so ~45 samples at 30x with MAPQ 60 are enough. The wrapped tie term inflated the variance and shrank INFO/MQBZ, BQBZ, RPBZ, SCBZ, MQSBZ and NMBZ towards zero (for example MQBZ -7.9 where the tie-corrected Z is -36.6 for 1300 MAPQ-60 reference reads and 40 MAPQ-30 alternate reads). Accumulate in int64_t. Adds a regression test with 1340 20-bp reads where the MAPQ-60 bin holds 1300 reads, and a NEWS entry. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Cindy Krafft <cynthiacondra@gmail.com>
6b97709 to
83b7888
Compare
|
Could you paste in the mcve_mpileup_mqbz_overflow.sh script referred to in the issue please? I tried reproducing it and I can get overflows, but much later than 1291. I'll experiment some more. I'm not sure MQ is the first to overflow either, but it's an easy one to target as it's a single value. (In theory MQUAL can go up to 254, although in practice it's normally capped.) The overflow comes from the cube root of 2^31. For 2^63 that's about 2.1 million. Hence still theoretically possible with 64-bit, but extreme and not something to be overly concerned with I feel. However I also asked Claude if it could rearrange the formulae to avoid accumulating cubed values which is promptly did and gave me an alternative that may also solve some other issues: "Here's a refactor that removes the overflow and fixes a few related ones in the same function. The current develop code also overflows e, l, nanb and (na+nb)(na+nb-1) as int. The last of these is reachable in big multi-sample runs once N passes about 46k. This is the bug reported in #2594. The idea: don't compute sum(p³) at all. The tie-corrected variance is var = na·nb/12 · ((N+1) − Σ(p³−p) / (N(N−1))) Because Σp = N, this simplifies to var = na·nb · (N³ − Σp³) / (12·N·(N−1)) You can build N³ − Σp³ one bin at a time using only non-negative terms. Adding a bin of size p to a running total S contributes (S+p)³ − S³ − p³ = 3·S·p·(S+p). That gives three benefits: Nothing gets cubed in integer arithmetic, so there's no overflow. I'm still evaluating whether this is a better alternative. For reference, the code it gave me was this: |
|
FYI my reproduction is: So I can't see a big change in MQ, but I'm unsure how this is used now without deeper digging. I can however see a significant change in base quality which occurs between depth 1292 and 1293. At d=2097152 the BQBZ score with this PR is 1448.15. By d=2097160 it's dropped to 1.73206 while the refactored code above is unchanged. That makes sense it's scaling with (I think) approx p^2 rather than p^3. |
|
Thanks for digging into this. Here's the script. It takes a bcftools binary and the number of reference reads: 1300 shows the problem, 1290 doesn't. #!/bin/sh
# Minimal reproduction: bcftools mpileup INFO/MQBZ once one MAPQ bin holds >= 1291 reads.
# Usage: sh mcve_mpileup_mqbz_overflow.sh /path/to/bcftools [NREF] (NREF defaults to 1300; 1290 is correct)
set -e
BCF=${1:-bcftools}; NREF=${2:-1300}; NALT=40
D=$(mktemp -d); cd "$D"
# 300-bp reference "ACGTACGT..."; the site is 1-based 150 (REF C); alt reads carry T there.
awk 'BEGIN{s=""; for(i=0;i<300;i++) s=s substr("ACGT",i%4+1,1); print ">ref"; print s}' > ref.fa
printf 'ref\t300\t5\t300\t301\n' > ref.fa.fai
awk -v nref=$NREF -v nalt=$NALT 'BEGIN{
for(i=0;i<300;i++) ref=ref substr("ACGT",i%4+1,1);
q=""; for(i=0;i<50;i++) q=q "I";
print "@HD\tVN:1.6\tSO:coordinate"; print "@SQ\tSN:ref\tLN:300";
n=nref+nalt; step=int(n/nalt); a=0;
for(i=0;i<n;i++){
start=101+int(i*49/n); seq=substr(ref,start,50); # sorted starts 101..149; every read covers 150
mq=60; if(i%step==0 && a<nalt){ a++; mq=30; k=150-start+1; seq=substr(seq,1,k-1) "T" substr(seq,k+1) }
printf "r%d\t%d\tref\t%d\t%d\t50M\t*\t0\t0\t%s\t%s\n", i, (i%2)*16, start, mq, seq, q
}}' > reads.sam
"$BCF" mpileup -f ref.fa -B -d 100000 reads.sam 2>mpileup.err | awk '$2==150' | cut -f 2,4,5,8 | tr ';' '\n' | grep -E '^150|^DP=|MQBZ' | tr '\n' ' '; echo
# Expected: the tie-corrected Mann-Whitney U Z-score of the MAPQ bins (ref reads: bin 59, alt reads: bin 30).
awk -v na=$NREF -v nb=$NALT 'BEGIN{ N=na+nb; U=0; m=na*nb/2; T=(na^3-na)+(nb^3-nb);
v=na*nb/12*((N+1)-T/(N*(N-1))); printf "expected MQBZ=%.4f (U=%d, mean=%d, tie-corrected var=%.2f)\n", (U-m)/sqrt(v), U, m, v }'
"$BCF" --version | head -1
cd /; rm -rf "$D"I think your reproduction hits the same condition, just in BQ instead of MQ. In the perl one-liner You're right that MQ isn't special. Any of the Z annotations overflows once one of its bins holds 1291 or more reads. I used MQ in the example because, with most reads at MAPQ 60, the whole reference depth tends to sit in one bin, so on real data it's the easiest one to hit. I built your refactor on develop and checked it:
So I think yours is better: it removes the cube instead of moving the limit. I'm happy to replace the change in this PR with your version and keep the regression test and the NEWS entry, or to close this PR if you'd rather commit it yourself. If I update the PR, how would you like your part attributed? Generated by Claude Code |
|
Yep I realised I had a bug in my script. I was trying to create REF/ALT SNP and it basically gave ALT only bar 1 or 2 initial reads. However correcting it gave less impactful results, so no matter the buggy one will do for validation purposes! I think the refactored code is probably better (although harder to analyse and see what actually changed, unlike the original type change). I need to figure out if it's worth doing therefore as we don't really want to commit AI generated code without first understanding the impact of each line. A human needs to sign off on those changes basically, in order to fulfil our policies. |
I decided in the end to just create a new PR which contains this one with the commit intact, and a follow up one. That keeps the authorship and provenance intact, simply as a way to document the progression more than anything else. Thanks for raising the issue and providing a fix. Over to you @pd3. |
|
Thank you for this. This was merged via #2598 |
Fixes #2594.
calc_mwu_biasZ()summed the tie adjustment(p*p-1)*p, the pair counts and thegroup sizes in
int. The tie term is cubic in the number of reads sharing one qualityor position bin and exceeds
INT_MAXonce a bin holds 1291 reads, which happens at asingle deep site (
-draised for amplicon, mitochondrial or viral data) and, under thedefault
-d 250, across the samples of a multi-sample call, since the histograms arepooled over all samples. The wrapped term inflated the variance and shrank
INFO/MQBZ,BQBZ,RPBZ,SCBZ,MQSBZandNMBZtowards zero: 1300 MAPQ-60reference reads and 40 MAPQ-30 alternate reads give
MQBZ=-7.88where thetie-corrected Z is −36.59 (1290 reference reads give the right −36.46).
This changes the accumulators to
int64_t(no change in output where nothingoverflowed:
make testotherwise unchanged) and adds a regression test,test/mpileup/mwu-biasZ.1.bam(1,340 20-bp reads, 4.9 kB) with its reference andexpected output, run through
test/test.plas-B -d 100000 -a -AD -r ref:150; thetest fails on unmodified
develop(MQBZ=-7.88325) and passes with the change.NEWS entry included under the unreleased heading.
Assisted-by: Claude:claude-fable-5-1
Found in Mytochondria, a volunteer project that checks the numerical core of research software and verifies every finding by execution (methods and harnesses: https://github.com/cindykrafft/mytochondria/tree/main/audits/bcftools)
Generated by Claude Code