---
title: "04 - Bismark alignment"
output: html_document
---
Align the trimmed single-end MBD-BS reads to C_virginica-3.0 with Bismark (Bowtie2),
remove reads that were never bisulfite-converted, then deduplicate.
Both the deduplicated and non-deduplicated BAMs are kept so step 5 can compare them.
Before the full run, small tests on the first 500,000 reads of each sample
decide whether the library is directional and which `score_min` (mismatch tolerance) to use.
- Input: `data/trimmed-reads/SRR*_trimmed.fq.gz` (from `02-qc-trim`), `data/genome/bismark/` (from `03-genome-prep`)
- Output BAMs: `data/bismark/` (gitignored)
- Output reports and summaries: `output/04-bismark-align/`
Uses Bismark, Bowtie2 and samtools from the existing `myflow` env.
Alignment takes a few hours per sample, so run this on a compute node with 20+ cores.
Every step skips a sample only if its output is newer than that sample's trimmed reads,
so re-trimming in `02-qc-trim` automatically makes these chunks redo the work.
```{r setup, include=FALSE}
# Paths are relative to code/, so chunks work the same run one at a time or knitted
knitr::opts_chunk$set(echo = TRUE, eval = TRUE)
```
# Unconverted reads
A sizeable share of reads in every sample were never bisulfite-converted: both C and G stay at normal levels,
where a converted read loses nearly all of one of them. Bismark calls every C in such a read methylated,
which inflates methylation in all contexts, including CpG.
Estimated from base content in the first 500,000 trimmed reads (2026-09-25, before the adapter fix in `02`):
| Sample | Group | Converted-looking | Unconverted-looking | CHH methylation (Bismark, default `score_min`) |
|---|---|---|---|---|
| HB16 | oil | 53% | 17% | 23% |
| NB6 | control | 61% | 24% | 22% |
| NB11 | control | 48% | 24% | 33% |
| HB2 | oil | 50% | 27% | 36% |
| NB3 | control | 34% | 31% | 43% |
| HB30 | oil | 14% | 63% | 73% |
CHH methylation in oyster should be under 1%, so these reads have to go.
Bismark's `filter_non_conversion` removes any read with 3 or more methylated non-CpG calls,
which a genuinely converted oyster read almost never has. It runs on every BAM below, tests included.
# Alignment tests
Aligns the first 500,000 reads of each sample twice, with two `score_min` settings,
in `--non_directional` mode (all four strands searched). Each test BAM is filtered for unconverted reads,
then the strand of every remaining alignment is counted. Only the reports are kept.
- `L,0,-0.2`: Bismark's default, about 1 mismatch per 50 bp.
- `L,0,-0.6`: looser, about 1 mismatch per 17 bp. *C. virginica* is highly polymorphic, so this may map more reads.
This takes about 10 minutes per sample and setting with 16 cores.
```{bash tests}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
genome=../data/genome/bismark
for score_min in "L,0,-0.2" "L,0,-0.6"; do
out=../output/04-bismark-align/tests/${score_min//,/_}
mkdir -p "${out}"
for fq in ../data/trimmed-reads/SRR*_trimmed.fq.gz; do
run=$(basename "${fq}" _trimmed.fq.gz)
if [[ -s "${out}/${run}.strands.tsv" && "${out}/${run}.strands.tsv" -nt "${fq}" ]]; then
echo "${run} ${score_min}: test already done, skipping"
continue
fi
bismark \
--genome "${genome}" \
--non_directional \
--score_min "${score_min}" \
--upto 500000 \
-p 10 \
--output_dir "${out}" \
--temp_dir "${out}" \
"${fq}"
mv "${out}/${run}_trimmed_bismark_bt2.bam" "${out}/${run}.bam"
mv "${out}/${run}_trimmed_bismark_bt2_SE_report.txt" "${out}/${run}_SE_report.txt"
filter_non_conversion --single --threshold 3 "${out}/${run}.bam"
# Strand of each converted alignment, from Bismark's XR (read) and XG (genome) conversion tags
samtools view "${out}/${run}.nonCG_filtered.bam" \
| awk '{ for (i = 12; i <= NF; i++) { if ($i ~ /^XR:Z:/) xr = substr($i, 6); if ($i ~ /^XG:Z:/) xg = substr($i, 6) }
n[xr "/" xg]++ }
END { printf "OT\t%d\nOB\t%d\nCTOT\t%d\nCTOB\t%d\n", n["CT/CT"], n["CT/GA"], n["GA/CT"], n["GA/GA"] }' \
> "${out}/${run}.strands.tsv"
rm -f "${out}/${run}".*bam
done
done
```
```{r tests-summary}
sample_sheet <- read.csv("../data/sample_sheet.csv")
read_field <- function(lines, pattern) {
hit <- grep(pattern, lines, value = TRUE)
if (length(hit) == 0) return(NA)
as.numeric(sub("^[^\t]*\t\\s*([0-9.]+).*", "\\1", hit[1]))
}
# "Sequences removed because of apparent non-bisulfite conversion (...):\t1234 (5.67%)"
read_removed <- function(file) {
hit <- grep("^Sequences removed because of apparent non-bisulfite conversion", readLines(file), value = TRUE)
as.numeric(sub(".*\t([0-9]+) \\(.*", "\\1", hit[1]))
}
test_stats <- function(dir) {
runs <- sub("\\.strands\\.tsv$", "", list.files(dir, "\\.strands\\.tsv$"))
do.call(rbind, lapply(runs, function(run) {
align <- readLines(file.path(dir, paste0(run, "_SE_report.txt")))
strands <- read.delim(file.path(dir, paste0(run, ".strands.tsv")), header = FALSE, col.names = c("strand", "n"))
n <- setNames(strands$n, strands$strand)
reads <- read_field(align, "^Sequences analysed in total:")
aligned <- read_field(align, "^Number of alignments with a unique best hit")
removed <- read_removed(file.path(dir, paste0(run, ".non-conversion_filtering.txt")))
data.frame(
run = run,
score_min = gsub("_", ",", basename(dir)),
mapping_pct = read_field(align, "^Mapping efficiency:"),
CHH_meth_pct_unfiltered = read_field(align, "^C methylated in CHH context:"),
unconverted_pct = round(100 * removed / aligned, 1),
converted_mapping_pct = round(100 * (aligned - removed) / reads, 1),
OT = n[["OT"]], OB = n[["OB"]], CTOT = n[["CTOT"]], CTOB = n[["CTOB"]],
pct_complementary = round(100 * (n[["CTOT"]] + n[["CTOB"]]) / sum(n), 1)
)
}))
}
tests <- do.call(rbind, lapply(list.dirs("../output/04-bismark-align/tests", recursive = FALSE), test_stats))
tests <- merge(sample_sheet[, c("run", "sample", "treatment")], tests, by = "run")
tests <- tests[order(tests$treatment, tests$sample, tests$score_min), ]
rownames(tests) <- NULL
write.csv(tests, "../output/04-bismark-align/tests/test-summary.csv", row.names = FALSE, quote = FALSE)
tests
```
Columns:
- **mapping_pct** and **CHH_meth_pct_unfiltered**: straight from Bismark, before removing unconverted reads.
- **unconverted_pct**: share of aligned reads that `filter_non_conversion` removed.
- **converted_mapping_pct**: aligned, converted reads as a share of the 500,000 tested. This is the number that matters for choosing `score_min`.
- **OT/OB/CTOT/CTOB** and **pct_complementary**: strands of the converted alignments only.
## Is the library directional?
Bisulfite reads can come from four strands: the original top and bottom strands (OT, OB)
and their complements (CTOT, CTOB).
- **Directional** libraries: reads come only from OT/OB.
- **PBAT-style** libraries (adapters added after bisulfite conversion): read 1 comes mostly from CTOT/CTOB.
- **Fully non-directional** libraries: reads come from all four strands in roughly equal numbers.
**First result (2026-09-25, before the adapter fix and without filtering unconverted reads):**
81–96% of alignments were on CTOT/CTOB in every sample. That points to a PBAT-style library rather than
a fully non-directional one (~50%). `--pbat` would search only CTOT/CTOB, but stray OT/OB hits in a
pure PBAT library would be about 1–2%, and these were 4–19%, so they looked like real reads.
Unconverted reads may have caused some of those OT/OB hits; the filtered strand counts above settle it.
The call below uses the default `score_min` and a 10% cutoff on the filtered strand counts:
under 10% on CTOT/CTOB is `directional`; anything above is `non_directional`, which searches all four strands.
It never picks `--pbat` on its own, because that drops every OT/OB read. Choose it by hand
(write `pbat` to `library-type.txt`) only if OT/OB drop to about 1–2% after filtering.
**Check the table above before trusting it.** Every sample should give the same answer.
```{r directional-call}
default <- tests[tests$score_min == "L,0,-0.2", ]
calls <- ifelse(default$pct_complementary < 10, "directional", "non_directional")
print(setNames(calls, default$sample))
stopifnot("Samples disagree on library type - check the table" = length(unique(calls)) == 1)
library_type <- unique(calls)
writeLines(library_type, "../output/04-bismark-align/library-type.txt")
cat("Library type:", library_type, "\n")
```
The kit is still unknown. PBAT-style and random-primed libraries often show a methylation bias over
the first several bases of each read. Check the M-bias plots in step 5 before setting `clip_5`/`clip_3`
in `02-qc-trim` and re-running from there.
## Which `score_min`?
```{r score-min-compare}
cols <- c("run", "sample", "treatment", "converted_mapping_pct", "unconverted_pct")
wide <- merge(tests[tests$score_min == "L,0,-0.2", cols],
tests[tests$score_min == "L,0,-0.6", cols],
by = c("run", "sample", "treatment"), suffixes = c(" (-0.2)", " (-0.6)"))
wide$converted_mapping_gain <- wide$`converted_mapping_pct (-0.6)` - wide$`converted_mapping_pct (-0.2)`
wide
```
The looser setting is worth it only if converted mapping goes up clearly in every sample.
If the unconverted share also rises noticeably at `-0.6`, the extra alignments are probably low-quality.
Set `score_min` in the alignment chunk below to whichever setting wins.
# Align
Reads the library type from the test above. Settings:
- `--parallel 6` is sized for 28 cores. In non-directional mode each Bismark instance runs 4 Bowtie2 processes
(one per strand), each single-threaded because `-p` is left out (Bismark rejects `-p 1`; it only accepts 2 or more).
That is 6 × 4 = 24 Bowtie2 threads plus 6 single-threaded Perl processes.
Bismark's docs recommend more instances over more `-p` threads, since each instance's Perl step is the bottleneck.
Memory is about 40 GB (each Bowtie2 process loads a 1.6 GB index).
- `score_min`: set this from the comparison above. It is Bismark's default (`L,0,-0.2`) until then.
```{bash align}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
genome=../data/genome/bismark
bam_dir=../data/bismark
report_dir=../output/04-bismark-align/reports
score_min="L,0,-0.6"
mkdir -p "${bam_dir}" "${report_dir}"
library_type=$(cat ../output/04-bismark-align/library-type.txt)
mode_args=()
if [[ "${library_type}" == "non_directional" ]]; then mode_args+=(--non_directional); fi
if [[ "${library_type}" == "pbat" ]]; then mode_args+=(--pbat); fi
echo "Library type: ${library_type}"
for fq in ../data/trimmed-reads/SRR*_trimmed.fq.gz; do
run=$(basename "${fq}" _trimmed.fq.gz)
if [[ -s "${bam_dir}/${run}.bam" && "${bam_dir}/${run}.bam" -nt "${fq}" ]]; then
echo "${run}: already aligned, skipping"
continue
fi
bismark \
--genome "${genome}" \
"${mode_args[@]}" \
--score_min "${score_min}" \
--parallel 6 \
--output_dir "${bam_dir}" \
--temp_dir "${bam_dir}" \
"${fq}"
# --basename can't be combined with --parallel, so rename Bismark's default output names
mv "${bam_dir}/${run}_trimmed_bismark_bt2.bam" "${bam_dir}/${run}.bam"
mv "${bam_dir}/${run}_trimmed_bismark_bt2_SE_report.txt" "${report_dir}/${run}_SE_report.txt"
done
ls -lh "${bam_dir}"
```
# Remove unconverted reads
Writes `SRR*.nonCG_filtered.bam` (reads kept) and `SRR*.nonCG_removed_seqs.bam` (reads removed, kept for inspection).
`filter_non_conversion` is single-threaded, so all six samples run at once (about 2–3 cores each,
counting its samtools read and write processes). Each sample's output goes to
`output/04-bismark-align/reports/SRR*.filter.log`; the chunk fails if any sample fails.
```{bash filter-non-conversion}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
bam_dir=../data/bismark
report_dir=../output/04-bismark-align/reports
filter_one() {
local run=$1
local bam=${bam_dir}/${run}.bam
filter_non_conversion --single --threshold 3 "${bam}" > "${report_dir}/${run}.filter.log" 2>&1
mv "${bam_dir}/${run}.non-conversion_filtering.txt" "${report_dir}/"
}
pids=(); runs=()
for fq in ../data/trimmed-reads/SRR*_trimmed.fq.gz; do
run=$(basename "${fq}" _trimmed.fq.gz)
out=${bam_dir}/${run}.nonCG_filtered.bam
if [[ -s "${out}" && "${out}" -nt "${bam_dir}/${run}.bam" ]]; then
echo "${run}: already filtered, skipping"
continue
fi
echo "${run}: filtering"
filter_one "${run}" &
pids+=($!); runs+=("${run}")
done
failed=0
for i in "${!pids[@]}"; do
if wait "${pids[$i]}"; then
echo "${runs[$i]}: done"
else
echo "${runs[$i]}: FAILED, see ${report_dir}/${runs[$i]}.filter.log"
failed=1
fi
done
exit "${failed}"
```
# Deduplicate
Removes reads that align to the same position and strand.
MBD enrichment plus single-end reads means some of these "duplicates" may be real independent fragments,
so the filtered BAMs are kept alongside the deduplicated ones.
`deduplicate_bismark` is also single-threaded, so all six samples run at once.
Each sample's output goes to `output/04-bismark-align/reports/SRR*.dedup.log`.
```{bash dedup}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
bam_dir=../data/bismark
report_dir=../output/04-bismark-align/reports
dedup_one() {
local run=$1
deduplicate_bismark \
--single \
--bam \
--output_dir "${bam_dir}" \
"${bam_dir}/${run}.nonCG_filtered.bam" \
> "${report_dir}/${run}.dedup.log" 2>&1
mv "${bam_dir}/${run}.nonCG_filtered.deduplication_report.txt" "${report_dir}/"
}
pids=(); runs=()
for fq in ../data/trimmed-reads/SRR*_trimmed.fq.gz; do
run=$(basename "${fq}" _trimmed.fq.gz)
out=${bam_dir}/${run}.nonCG_filtered.deduplicated.bam
if [[ -s "${out}" && "${out}" -nt "${bam_dir}/${run}.nonCG_filtered.bam" ]]; then
echo "${run}: already deduplicated, skipping"
continue
fi
echo "${run}: deduplicating"
dedup_one "${run}" &
pids+=($!); runs+=("${run}")
done
failed=0
for i in "${!pids[@]}"; do
if wait "${pids[$i]}"; then
echo "${runs[$i]}: done"
else
echo "${runs[$i]}: FAILED, see ${report_dir}/${runs[$i]}.dedup.log"
failed=1
fi
done
ls -lh "${bam_dir}"
exit "${failed}"
```
# Alignment summary
```{r align-summary}
report_dir <- "../output/04-bismark-align/reports"
summarize_run <- function(run) {
align <- readLines(file.path(report_dir, paste0(run, "_SE_report.txt")))
dedup <- readLines(file.path(report_dir, paste0(run, ".nonCG_filtered.deduplication_report.txt")))
aligned <- read_field(align, "^Number of alignments with a unique best hit")
removed <- read_removed(file.path(report_dir, paste0(run, ".non-conversion_filtering.txt")))
dup_pct <- as.numeric(sub(".*\\(([0-9.]+)%\\).*", "\\1",
grep("^Total number duplicated alignments removed", dedup, value = TRUE)))
data.frame(
run = run,
reads = read_field(align, "^Sequences analysed in total:"),
unique_aligned = aligned,
mapping_pct = read_field(align, "^Mapping efficiency:"),
CHH_meth_pct_unfiltered = read_field(align, "^C methylated in CHH context:"),
unconverted_removed = removed,
unconverted_pct = round(100 * removed / aligned, 1),
converted_aligned = aligned - removed,
dup_pct = dup_pct,
final_reads = round((aligned - removed) * (1 - dup_pct / 100))
)
}
runs <- sub("_SE_report.txt$", "", list.files(report_dir, "_SE_report.txt$"))
summary_tbl <- do.call(rbind, lapply(runs, summarize_run))
summary_tbl <- merge(sample_sheet[, c("run", "sample", "treatment")], summary_tbl, by = "run")
summary_tbl <- summary_tbl[order(summary_tbl$treatment, summary_tbl$sample), ]
rownames(summary_tbl) <- NULL
write.csv(summary_tbl, "../output/04-bismark-align/alignment-summary.csv", row.names = FALSE, quote = FALSE)
summary_tbl
```
Things to check in the table:
- **mapping_pct**: 40–60% is typical for bisulfite reads on oyster. The first 500,000-read tests gave 22–32%
with the default `score_min`, before the adapter fix and before removing unconverted reads.
- **unconverted_pct**: share of aligned reads removed as unconverted. Expect roughly 20–30% in most samples and most of HB30.
- **final_reads**: reads left after filtering and deduplication, which is what step 5 works with.
HB30 in particular — this decides whether it can stay in the analysis.
Bisulfite conversion of the *remaining* reads is checked in step 5, from the methylation extractor's CHH calls
(it should be above 99%). The CHH figure in Bismark's own report still includes the unconverted reads.