bamCompare (4.0.0 Rust backend): --operation first/second/add/mean write the log2 ratio, and reciprocal_ratio is inverted
What happens. calc_ratio in src/calc.rs (lines 111–169) has match arms for log2, ratio, reciprocal_ratio and subtract and a catch-all _ arm that computes the log2 ratio ("No operation is never allowed (on the py arg level, so just default to log2)"). bamCompare2.py still offers all eight operations (choices=['log2', 'ratio', 'subtract', 'add', 'mean', 'reciprocal_ratio', 'first', 'second'], line 118), so --operation first, second, add and mean silently write the same track as --operation log2 — with the pseudocount added, so the "scaled signal of the first file" the help promises is not obtainable from the new backend at all. In the same function the reciprocal_ratio arm returns den / num when num / den >= 1 and -num / den otherwise (lines 137–148), the inverse of the documented rule ("the negative of the inverse of the ratio if the ratio is less than 0", i.e. a/b if a/b ≥ 1 else −b/a, which getRatio.py implements and doctests in 3.5.x): 3/2 comes out as 0.67 instead of 1.5 and 2/3 as −0.33 instead of −3.
Minimal script (two single-end BAMs on a 300-bp chromosome; A has 3, 1, 2 reads and B 2, 3, 2 reads in the bins at 0, 100 and 200 bp; every operation compared with its definition):
import os, subprocess, sys, tempfile
import numpy as np, pysam
BIN = os.path.dirname(sys.executable)
d = tempfile.mkdtemp()
A_counts = np.array([3, 0, 1, 0, 2, 0], dtype=float) # reads per 50-bp bin
B_counts = np.array([2, 0, 3, 0, 2, 0], dtype=float)
def write_bam(path, counts):
with pysam.AlignmentFile(path, "wb", reference_names=["chr1"], reference_lengths=[300]) as fh:
n = 0
for b, c in enumerate(counts):
for _ in range(int(c)):
a = pysam.AlignedSegment()
a.query_name = "r%d" % n; n += 1
a.query_sequence = "A" * 50
a.flag = 0; a.reference_id = 0; a.reference_start = 50 * b
a.mapping_quality = 30; a.cigar = ((0, 50),)
a.query_qualities = pysam.qualitystring_to_array("I" * 50)
fh.write(a)
pysam.index(path)
write_bam(os.path.join(d, "A.bam"), A_counts)
write_bam(os.path.join(d, "B.bam"), B_counts)
with np.errstate(divide="ignore", invalid="ignore"):
ratio = A_counts / B_counts
expected = {"log2": np.log2(ratio), "ratio": ratio,
"reciprocal_ratio": np.where(ratio >= 1, ratio, -1.0 / ratio), # a/b if a/b >= 1 else -b/a
"subtract": A_counts - B_counts, "first": A_counts, "second": B_counts,
"add": A_counts + B_counts, "mean": (A_counts + B_counts) / 2.0}
wrong = []
for op in ["log2", "ratio", "subtract", "reciprocal_ratio", "first", "second", "add", "mean"]:
out = os.path.join(d, op + ".bg")
subprocess.run([os.path.join(BIN, "bamCompare"), "-b1", os.path.join(d, "A.bam"), "-b2", os.path.join(d, "B.bam"),
"-o", out, "-of", "bedgraph", "--scaleFactors", "1:1", "--pseudocount", "0", "--binSize", "50",
"--no_collapse", "--operation", op, "-p", "1"], check=True, capture_output=True)
got = np.array([float(l.split()[3]) for l in open(out)])
exp = expected[op]
ok = np.allclose(np.nan_to_num(got, nan=0, posinf=1e9, neginf=-1e9), np.nan_to_num(exp, nan=0, posinf=1e9, neginf=-1e9), atol=0.011)
print("--operation %-16s got %-40s expected %s%s" % (op, np.round(got, 2), np.round(exp, 2), "" if ok else " <- WRONG"))
if not ok:
wrong.append(op)
print("operations with wrong output:", wrong)
assert not wrong
Output (4.0.0 @ 4db9d816):
bamCompare 4.0.0
--operation log2 got [ 0.58 nan -1.58 nan 0. nan] expected [ 0.58 nan -1.58 nan 0. nan]
--operation ratio got [1.5 nan 0.33 nan 1. nan] expected [1.5 nan 0.33 nan 1. nan]
--operation subtract got [ 1. 0. -2. 0. 0. 0.] expected [ 1. 0. -2. 0. 0. 0.]
--operation reciprocal_ratio got [ 0.67 nan -0.33 nan 1. nan] expected [ 1.5 nan -3. nan 1. nan] <- WRONG
--operation first got [ 0.58 nan -1.58 nan 0. nan] expected [3. 0. 1. 0. 2. 0.] <- WRONG
--operation second got [ 0.58 nan -1.58 nan 0. nan] expected [2. 0. 3. 0. 2. 0.] <- WRONG
--operation add got [ 0.58 nan -1.58 nan 0. nan] expected [5. 0. 4. 0. 4. 0.] <- WRONG
--operation mean got [ 0.58 nan -1.58 nan 0. nan] expected [2.5 0. 2. 0. 2. 0. ] <- WRONG
operations with wrong output: ['reciprocal_ratio', 'first', 'second', 'add', 'mean']
AssertionError
The same happens on the shipped testA.bam/testB.bam with the default pseudocount: first, second, add and mean are byte-identical to the log2 output (3R 0 50 0 | 3R 50 100 -1 | 3R 100 150 0 | 3R 150 200 -0.58), and reciprocal_ratio gives 1, -0.5, 1, -0.67 where 3.5.6 gives 1, -2, 1, -1.5. bamCompare_old is right in every case.
Fix. Four explicit arms for first, second, add and mean returning the scaled signal(s) without a pseudocount (as 3.5.x did, and as the --pseudocount help says: "Only useful together with --operation log2 or --operation ratio"), the reciprocal_ratio branches swapped, and the catch-all made a panic so an unknown operation can no longer produce a plausible-looking track. The existing test_calc_ratio pins the inverted value (-0.27 for 6/22; the documented rule gives −22/6 = −3.67), so that expectation changes with the fix. A PR with the fix, two cargo tests and a pytest over all five operations on testA/testB follows.
One thing I left alone but want to flag: the subtract arm also adds the pseudocounts before subtracting (num - den with both pseudocounts), which is invisible at the default --pseudocount 1 but shifts every value by p1 − p2 with two different pseudocounts; 3.5.x subtracted the scaled signals only.
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/deeptools)
Generated by Claude Code
bamCompare (4.0.0 Rust backend): --operation first/second/add/mean write the log2 ratio, and reciprocal_ratio is inverted
master@4db9d816("4.0.0 cleanup (4.0.0 cleanup #1450)"), built withmaturin develop --release(cargo 1.94.1) into Python 3.12.3; not present in 3.5.6 (bamCompare_oldon the same tree is correct)bamCompare -b1 A.bam -b2 B.bam -o out.bg -of bedgraph --scaleFactors 1:1 --pseudocount 0 --binSize 50 --no_collapse --operation first(andsecond,add,mean,reciprocal_ratio); script below builds the BAMsWhat happens.
calc_ratioinsrc/calc.rs(lines 111–169) has match arms forlog2,ratio,reciprocal_ratioandsubtractand a catch-all_arm that computes the log2 ratio ("No operation is never allowed (on the py arg level, so just default to log2)").bamCompare2.pystill offers all eight operations (choices=['log2', 'ratio', 'subtract', 'add', 'mean', 'reciprocal_ratio', 'first', 'second'], line 118), so--operation first,second,addandmeansilently write the same track as--operation log2— with the pseudocount added, so the "scaled signal of the first file" the help promises is not obtainable from the new backend at all. In the same function thereciprocal_ratioarm returnsden / numwhennum / den >= 1and-num / denotherwise (lines 137–148), the inverse of the documented rule ("the negative of the inverse of the ratio if the ratio is less than 0", i.e. a/b if a/b ≥ 1 else −b/a, whichgetRatio.pyimplements and doctests in 3.5.x): 3/2 comes out as 0.67 instead of 1.5 and 2/3 as −0.33 instead of −3.Minimal script (two single-end BAMs on a 300-bp chromosome; A has 3, 1, 2 reads and B 2, 3, 2 reads in the bins at 0, 100 and 200 bp; every operation compared with its definition):
Output (4.0.0 @
4db9d816):The same happens on the shipped
testA.bam/testB.bamwith the default pseudocount:first,second,addandmeanare byte-identical to thelog2output (3R 0 50 0 | 3R 50 100 -1 | 3R 100 150 0 | 3R 150 200 -0.58), andreciprocal_ratiogives1, -0.5, 1, -0.67where 3.5.6 gives1, -2, 1, -1.5.bamCompare_oldis right in every case.Fix. Four explicit arms for
first,second,addandmeanreturning the scaled signal(s) without a pseudocount (as 3.5.x did, and as the--pseudocounthelp says: "Only useful together with --operation log2 or --operation ratio"), thereciprocal_ratiobranches swapped, and the catch-all made a panic so an unknown operation can no longer produce a plausible-looking track. The existingtest_calc_ratiopins the inverted value (-0.27for 6/22; the documented rule gives −22/6 = −3.67), so that expectation changes with the fix. A PR with the fix, two cargo tests and a pytest over all five operations ontestA/testBfollows.One thing I left alone but want to flag: the
subtractarm also adds the pseudocounts before subtracting (num - denwith both pseudocounts), which is invisible at the default--pseudocount 1but shifts every value byp1 − p2with two different pseudocounts; 3.5.x subtracted the scaled signals only.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/deeptools)
Generated by Claude Code