Skip to content

Warn when read bases contradict the aligner's strand tag - #98

Merged
semenko merged 1 commit into
mainfrom
strand-tag-check
Nov 5, 2026
Merged

semenko merged 1 commit into
mainfrom
strand-tag-check

Conversation

@semenko

@semenko semenko commented Nov 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds a per-BAM check that catches wrongly strand-tagged reads, the situation found in #97: a non-directional library aligned with bwa-meth, where most complementary-strand pairs get a YD tag that contradicts their bases and every CpG in them is then called methylated. Until now that was silent.

How it works

A read reports on one strand, so only one base of each CpG can show a conversion. The other base never does: the G for a top-strand read, the C for a bottom-strand read. Wherever a read covers that other base, the extractor now looks at it and counts the CpGs where it shows the other strand's conversion product (A at the G, T at the C).

  • Warning. If more than 2% of at least 1,000 checked CpGs contradict their read's tag, a RuntimeWarning is raised. The CLI prints it in that BAM's report. The output file is still written.
  • Verbose. With --verbose the counts are printed for every BAM, warning or not.
  • Scope. Applies to reads decoded from their bases: Biscuit, bwameth, gem3. Bismark reads are not checked, because Bismark resolves the strand itself.

Does it separate the cases?

Data CpGs checked Contradictions
Real bwa-meth output, directional pairs (bwameth.bam) 1,617 0.00%
Real bwa-meth output, complementary-strand pairs (bwameth_nondirectional.bam, new) 1,542 15.9%
Upstream nf-core file as a whole 24,325 6.6%
Real directional EM-seq cfDNA (tests/test.bam), sequencing errors only 18,406 0.05%

The 2% limit sits well above the error rate and well below the mis-tagged case.

Limits

  • It warns, it does not repair. Recovering the right calls would need the reference sequence in memory, to infer each read's strand from its non-CpG bases.
  • The SNP contribution is not measured. CpG SNPs also produce contradictions, and the real test BAM has no matching reference to measure them against. I expect well under 1%. Running once with --verbose on a real BAM with its reference will show the actual figure.
  • The signal scales with unmethylated CpGs. A very heavily methylated sample would sit closer to the limit.
  • Cost. About 6% more time on the base-decoding path, measured on 240k simulated reads. The output is unchanged.

Tests

378 pass. nox: all 12 sessions pass locally.

  • Hand-built reads: exactly what is and is not counted, for both strands and both mates, and each threshold condition.
  • Simulated reads with 40% of fragments deliberately mis-tagged: the warning's counts are checked against an independent count, in serial and parallel runs. Correctly tagged simulated reads, which include a realistic trickle of contradicting sequencing errors, raise nothing.
  • Real aligner output: the directional fixtures pass, and the new fixture of 634 real mis-tagged pairs is flagged. That fixture is another subset of the same MIT-licensed nf-core data, about 86 KB, produced by the same documented script.

Biscuit, bwameth and gem3 reads are decoded on the strength of the
aligner's strand tag. bwa-meth assumes a directional library: for a
non-directional one it gives most complementary-strand pairs a tag that
contradicts their bases, and every CpG in such a read is then called
methylated whatever its true state. Until now that was silent.

The check: a read reports on one strand, so only one base of each CpG can
show a conversion. The other base (G for a top-strand read, C for a
bottom-strand read) is never converted. Wherever a read covers that other
base, the extractor now looks at it and counts the CpGs where it shows the
other strand's conversion product (A at the G, T at the C).

- A RuntimeWarning is raised for a BAM when more than 2% of at least 1,000
  checked CpGs contradict their read's tag. The CLI prints it in that BAM's
  report; the output file is still written.
- With verbose=True / --verbose the counts are printed for every BAM.
- Bismark reads are not checked: Bismark resolves the strand itself.
- It warns only. Repairing the calls would need the reference sequence to
  infer each read's strand.

On real data: 0 of 1,617 CpGs contradict in the bundled directional
bwa-meth fixture, and 245 of 1,542 (15.9%) in real bwa-meth alignments of
complementary-strand pairs, now bundled as bwameth_nondirectional.bam.
Sequencing errors in real directional EM-seq data account for about 0.05%.

Cost: about 6% more time on the base-decoding path; output is unchanged.

Tests: hand-built cases for what is and is not counted and for the
thresholds; simulated reads with deliberately wrong tags, with the counts
checked against an independent count in serial and parallel runs; the real
mis-tagged fixture; the CLI report.
@semenko semenko added the enhancement New feature or request label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  src/bam2tensor
  functions.py 988
Project Total  

This report was generated by python-coverage-comment-action

@semenko
semenko merged commit 45607fe into main Nov 5, 2026
8 checks passed
@semenko
semenko deleted the strand-tag-check branch November 5, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant