---
title: "05 - Methylation calling"
output: html_document
---
Extract CpG methylation calls from the Bismark alignments, merge the two strands of each CpG,
and summarize conversion, M-bias and coverage for each sample.
Everything runs twice, on the filtered BAMs with and without deduplication, so the two can be compared.
MBD enrichment plus single-end reads means some "duplicates" may be real independent fragments.
- Input: `data/bismark/SRR*.nonCG_filtered.bam` and `SRR*.nonCG_filtered.deduplicated.bam` (from `04-bismark-align`)
- Output methylation calls: `data/methylation/{nodedup,dedup}/` (gitignored)
- Output reports and summaries: `output/05-methylation-calling/`
Uses Bismark from the existing `myflow` env and data.table/ggplot2 from the lab R container.
```{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)
library(data.table)
library(ggplot2)
```
# Link the BAMs
The extractor names its outputs after the input file, so link each BAM under a plain `SRR*.bam` name:
`data/methylation/nodedup/SRR*.bam` and `data/methylation/dedup/SRR*.bam`.
```{bash link}
set -euo pipefail
for version in nodedup dedup; do
dir=../data/methylation/${version}
mkdir -p "${dir}"
for bam in ../data/bismark/SRR*.nonCG_filtered.bam; do
run=$(basename "${bam}" .nonCG_filtered.bam)
if [[ "${version}" == "dedup" ]]; then src=${run}.nonCG_filtered.deduplicated.bam; else src=${run}.nonCG_filtered.bam; fi
[[ -s "../data/bismark/${src}" ]] || { echo "Missing ../data/bismark/${src}"; exit 1; }
ln -sfn "../../bismark/${src}" "${dir}/${run}.bam"
done
done
ls -l ../data/methylation/*/
```
# Extract methylation calls
Runs `bismark_methylation_extractor` on each BAM, one at a time.
`--parallel 8` uses about 24 cores (the extractor uses roughly 3 per `--parallel` step).
- `--comprehensive --merge_non_CpG`: one CpG and one non-CpG call file per sample instead of one per strand.
- `--bedGraph`: writes the coverage file `SRR*.bismark.cov.gz` (chromosome, start, end, % methylated, count methylated, count unmethylated).
- `ignore_5` / `ignore_3`: skip this many bases at the 5' and 3' end of every read when calling methylation.
They are 0 for now. Set them from the M-bias plots below and re-run from here.
This is quicker than clipping in `02-qc-trim`, which would mean re-aligning.
The per-read call files (`CpG_context_*`, `Non_CpG_context_*`) are tens of GB and nothing downstream uses them,
so they are deleted unless `keep_context=true`. They can be regenerated from the BAMs.
```{bash extract}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
ignore_5=0
ignore_3=0
keep_context=false
ignore_args=()
if (( ignore_5 > 0 )); then ignore_args+=(--ignore "${ignore_5}"); fi
if (( ignore_3 > 0 )); then ignore_args+=(--ignore_3prime "${ignore_3}"); fi
for version in nodedup dedup; do
dir=../data/methylation/${version}
report_dir=../output/05-methylation-calling/reports/${version}
mkdir -p "${report_dir}"
for bam in "${dir}"/SRR*.bam; do
run=$(basename "${bam}" .bam)
# -nt follows the symlink, so this compares against the real BAM
if [[ -s "${dir}/${run}.bismark.cov.gz" && "${dir}/${run}.bismark.cov.gz" -nt "${bam}" ]]; then
echo "${version} ${run}: already extracted, skipping"
continue
fi
echo "${version} ${run}: extracting"
bismark_methylation_extractor \
--single-end \
--gzip \
--comprehensive \
--merge_non_CpG \
--bedGraph \
--buffer_size 20G \
--parallel 8 \
"${ignore_args[@]}" \
--output_dir "${dir}" \
"${bam}" \
> "${report_dir}/${run}.extractor.log" 2>&1
mv "${dir}/${run}_splitting_report.txt" "${dir}/${run}.M-bias.txt" "${report_dir}/"
mv "${dir}/${run}".M-bias_R1.png "${report_dir}/" 2>/dev/null || true
if [[ "${keep_context}" != "true" ]]; then
rm -f "${dir}/CpG_context_${run}.txt.gz" "${dir}/Non_CpG_context_${run}.txt.gz"
fi
done
done
ls -lh ../data/methylation/*/
```
# Merge strands
Each CpG is on both strands, and the extractor reports the two cytosines separately.
`coverage2cytosine --merge_CpG` adds their counts together, giving one row per CpG in `SRR*.CpG_report.merged_CpG_evidence.cov.gz`.
This is the file steps 6 and 7 use. It also writes a genome-wide `SRR*.CpG_report.txt.gz` with every CpG, covered or not.
`coverage2cytosine` is single-threaded, so all 12 (6 samples × 2 versions) run at once.
Each loads the genome (about 1 GB of memory).
```{bash merge-cpg}
set -euo pipefail
export PATH=/mmfs1/gscratch/srlab/sr320/miniforge3/envs/myflow/bin:$PATH
genome=$(realpath ../data/genome/bismark)
merge_one() {
local dir=$1 run=$2 log=$3
coverage2cytosine \
--merge_CpG \
--gzip \
--genome_folder "${genome}" \
--dir "${dir}" \
-o "${run}" \
"${dir}/${run}.bismark.cov.gz" \
> "${log}" 2>&1
}
pids=(); jobs_desc=()
for version in nodedup dedup; do
dir=../data/methylation/${version}
report_dir=../output/05-methylation-calling/reports/${version}
for cov in "${dir}"/SRR*.bismark.cov.gz; do
run=$(basename "${cov}" .bismark.cov.gz)
out=${dir}/${run}.CpG_report.merged_CpG_evidence.cov.gz
if [[ -s "${out}" && "${out}" -nt "${cov}" ]]; then
echo "${version} ${run}: already merged, skipping"
continue
fi
echo "${version} ${run}: merging"
merge_one "${dir}" "${run}" "${report_dir}/${run}.coverage2cytosine.log" &
pids+=($!); jobs_desc+=("${version} ${run}")
done
done
failed=0
for i in "${!pids[@]}"; do
if wait "${pids[$i]}"; then
echo "${jobs_desc[$i]}: done"
else
echo "${jobs_desc[$i]}: FAILED, see its coverage2cytosine.log"
failed=1
fi
done
exit "${failed}"
```
# Conversion check
With unconverted reads removed in step 4, methylation outside CpG context should now be close to zero.
`--merge_non_CpG` pools CHG and CHH into one non-CpG figure in the splitting report, so
conversion efficiency ≈ 100 − non-CpG%. It should be above 99%. The M-bias table below still has CHG and CHH separately.
```{r conversion}
sample_sheet <- fread("../data/sample_sheet.csv")
report_root <- "../output/05-methylation-calling/reports"
read_field <- function(lines, pattern) {
hit <- grep(pattern, lines, value = TRUE)
if (length(hit) == 0) return(NA_real_)
as.numeric(sub("^[^\t]*\t\\s*([0-9.]+).*", "\\1", hit[1]))
}
conversion <- rbindlist(lapply(c("nodedup", "dedup"), function(version) {
files <- list.files(file.path(report_root, version), "_splitting_report\\.txt$", full.names = TRUE)
rbindlist(lapply(files, function(f) {
lines <- readLines(f)
data.table(
run = sub("_splitting_report\\.txt$", "", basename(f)),
version = version,
CpG_meth_pct = read_field(lines, "^C methylated in CpG context:"),
nonCpG_meth_pct = read_field(lines, "^C methylated in non-CpG context:")
)
}))
}))
conversion[, conversion_pct := 100 - nonCpG_meth_pct]
conversion <- merge(sample_sheet[, .(run, sample, treatment)], conversion, by = "run")
setorder(conversion, version, treatment, sample)
dir.create("../output/05-methylation-calling", showWarnings = FALSE)
fwrite(conversion, "../output/05-methylation-calling/conversion.csv")
conversion
```
# M-bias
Methylation by position in the read. It should be flat. A rise or dip over the first or last few bases
is a library-prep artifact (common in PBAT-style and random-primed libraries); set `ignore_5` / `ignore_3`
in the extract chunk to skip those bases, then re-run from there.
Shown for the deduplicated BAMs; the non-deduplicated ones should look the same.
```{r mbias, fig.width = 10, fig.height = 6}
read_mbias <- function(file) {
lines <- readLines(file)
# Single-end headings are "CpG context", "CHG context", "CHH context" (paired-end adds " (R1)")
starts <- grep("^(CpG|CHG|CHH) context", lines)
rbindlist(lapply(starts, function(s) {
context <- sub(" context.*", "", lines[s])
body <- lines[(s + 3):length(lines)]
end <- which(body == "")[1]
if (!is.na(end)) body <- body[seq_len(end - 1)]
tbl <- fread(text = paste(body, collapse = "\n"), header = FALSE,
col.names = c("position", "methylated", "unmethylated", "pct_meth", "coverage"))
tbl[, context := context]
}))
}
mbias_files <- list.files(file.path(report_root, "dedup"), "\\.M-bias\\.txt$", full.names = TRUE)
mbias <- rbindlist(lapply(mbias_files, function(f) read_mbias(f)[, run := sub("\\.M-bias\\.txt$", "", basename(f))]))
mbias <- merge(mbias, sample_sheet[, .(run, sample, treatment)], by = "run")
fwrite(mbias, "../output/05-methylation-calling/m-bias.csv")
p <- ggplot(mbias[coverage >= 1000], aes(position, pct_meth, colour = sample)) +
geom_line() +
facet_wrap(~ context, scales = "free_y") +
labs(x = "Position in read (bp)", y = "% methylated", colour = "Sample",
title = "M-bias, deduplicated reads (positions with at least 1,000 calls)") +
theme_bw()
ggsave("../output/05-methylation-calling/m-bias.png", p, width = 10, height = 6, dpi = 150)
p
```
# CpG coverage
Reads each sample's merged CpG file and counts CpGs at 1×, 5× and 10× coverage.
The plan's differential methylation (step 6) needs a CpG covered in *every* sample,
so the key numbers are how many CpGs are shared across samples, with and without HB30.
```{r coverage}
read_merged <- function(version, run) {
f <- file.path("../data/methylation", version, paste0(run, ".CpG_report.merged_CpG_evidence.cov.gz"))
cov <- fread(f, header = FALSE, select = c(1, 2, 5, 6),
col.names = c("chr", "pos", "methylated", "unmethylated"))
cov[, coverage := methylated + unmethylated]
cov[coverage > 0]
}
merged <- lapply(setNames(nm = c("nodedup", "dedup")), function(version) {
lapply(setNames(nm = sample_sheet$run), function(run) read_merged(version, run))
})
coverage_summary <- rbindlist(lapply(names(merged), function(version) {
rbindlist(lapply(names(merged[[version]]), function(run) {
cov <- merged[[version]][[run]]
data.table(
run = run,
version = version,
CpGs_1x = nrow(cov),
CpGs_5x = cov[coverage >= 5, .N],
CpGs_10x = cov[coverage >= 10, .N],
median_cov = as.numeric(median(cov$coverage)),
mean_meth_5x = round(cov[coverage >= 5, 100 * mean(methylated / coverage)], 1)
)
}))
}))
coverage_summary <- merge(sample_sheet[, .(run, sample, treatment)], coverage_summary, by = "run")
setorder(coverage_summary, version, treatment, sample)
fwrite(coverage_summary, "../output/05-methylation-calling/coverage-summary.csv")
coverage_summary
```
```{r shared-cpgs}
shared_count <- function(version, runs, min_cov) {
keys <- lapply(runs, function(r) merged[[version]][[r]][coverage >= min_cov, paste(chr, pos)])
length(Reduce(intersect, keys))
}
hb30 <- sample_sheet[sample == "HB30", run]
shared <- CJ(version = c("nodedup", "dedup"), min_cov = c(5, 10))
shared[, all_6 := mapply(shared_count, version, MoreArgs = list(runs = sample_sheet$run), min_cov = min_cov)]
shared[, without_HB30 := mapply(shared_count, version, MoreArgs = list(runs = setdiff(sample_sheet$run, hb30)), min_cov = min_cov)]
shared[, fold_gain_dropping_HB30 := round(without_HB30 / all_6, 1)]
fwrite(shared, "../output/05-methylation-calling/shared-cpgs.csv")
shared
```
**Deciding on HB30.** Keeping it means every tested CpG must reach the coverage cutoff in HB30 too.
If `fold_gain_dropping_HB30` is large, HB30 is throwing away most of the testable CpGs and step 6 should
run as 3 control vs. 2 oil. Its conversion rate and M-bias above should also look like the other samples'.
# Deduplicated vs. not
For CpGs at 10× or more in both versions, how similar are the per-CpG methylation levels?
If they agree closely, deduplication mainly costs coverage; if they differ, duplicates are skewing calls.
```{r dedup-compare}
dedup_compare <- rbindlist(lapply(sample_sheet$run, function(run) {
a <- merged$nodedup[[run]][coverage >= 10, .(chr, pos, meth_nodedup = methylated / coverage)]
b <- merged$dedup[[run]][coverage >= 10, .(chr, pos, meth_dedup = methylated / coverage)]
both <- merge(a, b, by = c("chr", "pos"))
data.table(
run = run,
CpGs_10x_nodedup = nrow(a),
CpGs_10x_dedup = nrow(b),
CpGs_10x_both = nrow(both),
correlation = round(cor(both$meth_nodedup, both$meth_dedup), 3),
mean_abs_diff_pct = round(100 * mean(abs(both$meth_nodedup - both$meth_dedup)), 2)
)
}))
dedup_compare <- merge(sample_sheet[, .(run, sample, treatment)], dedup_compare, by = "run")
setorder(dedup_compare, treatment, sample)
fwrite(dedup_compare, "../output/05-methylation-calling/dedup-compare.csv")
dedup_compare
```