knitr::opts_chunk$set( echo = TRUE, message = FALSE, warning = FALSE, fig.align = "center", # html_vignette inherits fig.retina = 2 from rmarkdown, which renders every # figure at twice the nominal resolution and then scales it down in the # browser. The pixel count, and therefore the size of the base64 blob # embedded in the self-contained HTML, is four times larger for no visible # gain in a vignette. Setting it to 1 is what keeps the installed size of # doc/ within the limit R CMD check reports on. fig.retina = 1, dpi = 72, fig.width = 7, fig.height = 6 )
Any window-based genomic profile depends on the window size, which acts as a smoothing bandwidth: too large and local signal is averaged away, too small and single-window noise dominates. This vignette quantifies that dependence for TmCalculator by recomputing the Escherichia coli K-12 MG1655 Tm/GC profile at 50, 100, 200 and 500 bp and asking two separate questions.
The companion vignette
vignette("genome_wide_tm_ecoli", package = "TmCalculator") develops the
full case study at the 200 bp resolution used by Hasenauer et al. (2025),
including the MutL-associated region (MutL-AR) comparison that is re-tested
here at every window size.
We reproduce the minimum needed from the main case study: the genome, the MutL-AR peak coordinates, and the reference labels used for plotting.
library(TmCalculator) library(GenomicRanges) library(IRanges) library(GenomeInfoDb)
The E. coli genome is supplied by the pre-forged
BSgenome.Ecoli.NCBI.ASM584v2 package. The chunk below installs it if it is
not already available, exactly as in the main case-study vignette; see that
vignette for how the package is forged from the NCBI assembly accession.
ecoli_pkg <- "BSgenome.Ecoli.NCBI.ASM584v2" genome_obj <- "Ecoli" # BSgenomeObjname in DESCRIPTION; not the package name .ecoli_genome_ready <- function() { if (!requireNamespace(ecoli_pkg, quietly = TRUE)) return(FALSE) exists(genome_obj, envir = asNamespace(ecoli_pkg), inherits = FALSE) } if (!.ecoli_genome_ready()) { if (!requireNamespace("remotes", quietly = TRUE)) { utils::install.packages("remotes", repos = "https://cloud.r-project.org") } remotes::install_github( "JunhuiLi1017/BSgenome.Ecoli.NCBI.ASM584v2", upgrade = "never", quiet = TRUE ) } if (!.ecoli_genome_ready()) { stop( "Could not load genome object '", genome_obj, "' from package '", ecoli_pkg, "'.\n", "See vignette(\"genome_wide_tm_ecoli\") for how to forge it locally.", call. = FALSE ) }
suppressPackageStartupMessages(library(ecoli_pkg, character.only = TRUE)) genome <- base::get(genome_obj, envir = asNamespace(ecoli_pkg)) genome_name <- ecoli_pkg chr_name <- "U00096.3" chr_length <- GenomeInfoDb::seqlengths(genome)[[chr_name]] data(ecoli_rep_hotspots) ## MutL-AR peaks, as in the main case study mutH_peaks <- GRanges( seqnames = ecoli_rep_hotspots$all_peaks_IP_mutH$chr, ranges = IRanges(start = ecoli_rep_hotspots$all_peaks_IP_mutH$start, end = ecoli_rep_hotspots$all_peaks_IP_mutH$end) ) seqlevels(mutH_peaks) <- chr_name mutH_peaks$peak_id <- paste0("mutH_", seq_along(mutH_peaks)) ## Reference labels: replication origin (ori) and terminus (dif) label <- data.frame( seqnames = genome_name, start = c(3925804, 1590777), end = c(3925804, 1590777), label = c("ori", "dif") )
For each window size we retile the chromosome, recompute Tm and GC, and
re-run the MutL-AR comparison, keeping every other setting fixed. Nothing
downstream needs to change: the annotation layers keep their own native
resolutions (1 kb bins for the microsatellite, cruciform and GATC density
tracks; variable-width intervals for MutL-AR peaks and ssDNA regions), and
both plot_genome_track() and the overlap-based statistics work across
mixed resolutions.
window_sizes <- c(50L, 100L, 200L, 500L) sens <- lapply(window_sizes, function(w) { bins_w <- make_genomiccoord( bsgenome = genome_name, chromosomes = chr_name, window = w, slide = w, start = 1, end = chr_length, strand = "+", verbose = FALSE ) gr_w <- to_genomic_ranges_fast(list(pkg_name = genome_name, seq = bins_w)) tm_w <- tm_calculate(gr_w, method = "tm_nn", nn_table = "DNA_NN_Breslauer_1986", Na = 50)$gr ann <- integrate_granges(gr_tm = tm_w, gr_features = mutH_peaks, strategy = "overlap", feature_cols = "peak_id", keep_unmatched = TRUE) ann$in_mutH <- ifelse(is.na(ann$peak_id), "non_peak", "peak") cg <- compare_groups(gr = ann, target = c("Tm", "GC"), method = "wilcoxon", group = "in_mutH", alternative = "greater", posthoc = FALSE) list(gr = tm_w, ann = ann, test = cg) }) names(sens) <- paste0("w", window_sizes)
dist_tbl <- do.call(rbind, lapply(seq_along(window_sizes), function(i) { g <- sens[[i]]$gr data.frame( window_bp = window_sizes[i], n_windows = length(g), Tm_mean = mean(g$Tm, na.rm = TRUE), Tm_sd = stats::sd(g$Tm, na.rm = TRUE), Tm_IQR = stats::IQR(g$Tm, na.rm = TRUE), GC_mean = mean(g$GC, na.rm = TRUE) ) })) knitr::kable(dist_tbl, digits = 3, caption = "Tm/GC distribution by window size.")
The same two effects are easier to read as distributions than as summary statistics: the curve moves to the right as the window lengthens, and it narrows at the same time.
## Palettes are defined here because they are used both by this panel and by ## the multi-scale track figures further down. ## darkest = finest window (50 bp), lightest = coarsest (500 bp) scale_cols_gc <- c("#1A5276", "#2E86C1", "#5DADE2", "#AED6F1") scale_cols_tm <- c("#7B241C", "#CB4335", "#EC7063", "#F5B7B1") ## Densities are normalised, so the ~93,000 windows at 50 bp and the ~9,300 ## at 500 bp can be compared directly on one pair of axes. tm_by_size <- lapply(sens, function(x) x$gr$Tm[is.finite(x$gr$Tm)]) dens <- lapply(tm_by_size, stats::density) xlim <- range(vapply(dens, function(z) range(z$x), numeric(2))) ylim <- c(0, max(vapply(dens, function(z) max(z$y), numeric(1)))) op <- par(mar = c(4.2, 4.4, 0.8, 0.8), las = 1) plot(NA, xlim = xlim, ylim = ylim, bty = "n", xlab = expression(italic(T)[m] ~ "(" * degree * "C)"), ylab = "Density") for (i in seq_along(dens)) { lines(dens[[i]], col = scale_cols_tm[i], lwd = 2) abline(v = dist_tbl$Tm_mean[i], col = scale_cols_tm[i], lwd = 1, lty = 3) } legend("topleft", bty = "n", lwd = 2, seg.len = 1.6, col = scale_cols_tm[seq_along(window_sizes)], legend = sprintf("%d bp: mean %.1f, SD %.2f", dist_tbl$window_bp, dist_tbl$Tm_mean, dist_tbl$Tm_sd)) par(op) ## Spread of the finest window relative to the coarsest. This is the ratio ## quoted in the manuscript, so both come from the same computation. sd_ratio <- dist_tbl$Tm_sd[1] / dist_tbl$Tm_sd[nrow(dist_tbl)]
Two systematic effects are visible, both expected. First, mean Tm rises
with window length (r sprintf("%.1f", dist_tbl$Tm_mean[1]) to
r sprintf("%.1f", dist_tbl$Tm_mean[nrow(dist_tbl)]) degrees C from
r dist_tbl$window_bp[1] to r dist_tbl$window_bp[nrow(dist_tbl)] bp):
duplex stability increases with length in the nearest-neighbor model, so
absolute Tm values are only comparable within one window size. Second,
shorter windows widen the distribution (SD
r sprintf("%.2f", dist_tbl$Tm_sd[1]) at r dist_tbl$window_bp[1] bp vs
r sprintf("%.2f", dist_tbl$Tm_sd[nrow(dist_tbl)]) at
r dist_tbl$window_bp[nrow(dist_tbl)] bp, a ratio of
r sprintf("%.2f", sd_ratio)) -- window size acts as a
smoothing bandwidth. The mean GC is invariant
(r sprintf("%.3f", mean(dist_tbl$GC_mean)) throughout), confirming that
the composition landscape itself does not depend on the tiling.
Every profile is aggregated onto one common grid (mean Tm per bin) and the resulting series are correlated.
The grid is 1 kb rather than 500 bp, and the choice matters. Correlating against the 500 bp profile directly makes 200 bp the odd one out: 50 and 100 divide 500 exactly, into ten and five windows per bin, but 200 does not, so each bin receives two or three windows that straddle its boundaries and the aggregation is itself inexact. That alone drove the 200 bp correlation down to 0.976 while the two finer profiles reached 0.997 and 0.999, an ordering that would invite the reader to conclude that the closest resolution agrees worst. All four window sizes divide 1 kb (into 20, 10, 5 and 2), so on that grid every profile is aggregated exactly and the comparison is between profiles rather than between remainders. The 500 bp profile is aggregated too, and serves as the reference.
grid_bp <- 1000L stopifnot(all(grid_bp %% window_sizes == 0L)) # exact aggregation for each gr_ref <- sens[["w500"]]$gr ref_agg <- tapply(gr_ref$Tm, (start(gr_ref) - 1L) %/% grid_bp, mean, na.rm = TRUE) cor_tbl <- vapply(c("w50", "w100", "w200"), function(k) { g <- sens[[k]]$gr agg <- tapply(g$Tm, (start(g) - 1L) %/% grid_bp, mean, na.rm = TRUE) common <- intersect(names(agg), names(ref_agg)) stats::cor(agg[common], ref_agg[common], use = "complete.obs") }, numeric(1)) round(cor_tbl, 3)
Aggregated onto the common 1 kb grid, the profiles are nearly
interchangeable (r =
r paste(sprintf("%.3f", cor_tbl), collapse = ", ") for 50, 100 and
200 bp respectively). Agreement now rises monotonically as the window
approaches the grid, which is what averaging fewer windows per bin should
do, and is the behaviour the 500 bp grid obscured. Window size rescales and
smooths the profile but preserves the spatial landscape.
Finally, the vignette's biological conclusion -- that Tm and GC differ between MutL-AR peak windows and the genomic background -- is re-tested at every window size:
## Per-window-size test statistics test_results <- do.call(rbind, lapply(seq_along(window_sizes), function(i) { data.frame(window_bp = window_sizes[i], sens[[i]]$test$results) })) test_results ## Per-window-size group summaries, plus the median Tm effect size test_summary <- do.call(rbind, lapply(seq_along(window_sizes), function(i) { ann <- sens[[i]]$ann med <- tapply(ann$Tm, ann$in_mutH, stats::median, na.rm = TRUE) data.frame(window_bp = window_sizes[i], n_peak = sum(ann$in_mutH == "peak"), Tm_med_diff = unname(med["peak"] - med["non_peak"]), sens[[i]]$test$summary) })) test_summary
Both questions are answered by the same four rows, so they are assembled into a single table: the first four columns say what window size does to the landscape, and the last three say what it does to the conclusion drawn from it.
sens_tbl <- do.call(rbind, lapply(seq_along(window_sizes), function(i) { g <- sens[[i]]$gr ann <- sens[[i]]$ann med <- tapply(ann$Tm, ann$in_mutH, stats::median, na.rm = TRUE) res <- sens[[i]]$test$results w <- window_sizes[i] data.frame( `Window (bp)` = w, Windows = length(g), `Tm mean (C)` = mean(g$Tm, na.rm = TRUE), `Tm SD` = stats::sd(g$Tm, na.rm = TRUE), `GC mean (%)` = mean(g$GC, na.rm = TRUE), ## The reference profile correlates with itself by construction. `r vs 1 kb grid` = if (w == max(window_sizes)) NA_real_ else unname(cor_tbl[[paste0("w", w)]]), `MutL-AR windows` = sum(ann$in_mutH == "peak"), `Tm peak - bg (C)` = unname(med["peak"] - med["non_peak"]), ## Formatted as character: these p values span sixty-five orders of ## magnitude, and rounding them to a fixed number of decimals prints ## every one of them as zero. `p (Tm)` = format(res$p.value[res$target == "Tm"], digits = 2, scientific = TRUE), check.names = FALSE, stringsAsFactors = FALSE) })) knitr::kable(sens_tbl, digits = c(0, 0, 2, 2, 2, 3, 0, 3, 0), caption = paste("Window-size sensitivity. GC mean is invariant;", "the Tm distribution shifts and narrows; the", "MutL-AR effect size varies within a few tenths", "of a degree while the p value tracks the number", "of windows."))
The last two columns are the point of the section. The p value falls by sixty-five orders of magnitude between the coarsest and the finest tiling, which is a statement about how many windows were tested rather than about how far apart the two groups are. The difference in medians, which is that statement, stays between -1.7 and -2.0 degrees C. The finest tiling gives the largest separation, as expected: a 500 bp window overlapping a MutL-AR peak also contains flanking sequence that does not, and averaging over it dilutes the contrast, whereas a 50 bp window resolves the peak more sharply. The three coarser tilings agree within 0.1 degrees C.
Because every track keeps its native resolution, the four Tm/GC profiles can be drawn as concentric layers of one integrated figure, together with the annotation data:
## scale_cols_gc / scale_cols_tm are defined in the density-panel chunk above sens_dfs <- lapply(sens, function(x) as.data.frame(x$gr[, c("Tm", "GC")])) tracks_scale <- c( list(list(type = "rect", data = ecoli_rep_hotspots$all_peaks_IP_mutH, col = "#2C3E50", bg.col = "grey", name = "MutL-AR", legend_font_col = "#2C3E50", ideogram = TRUE, height = 0.5)), lapply(seq_along(window_sizes), function(i) list(type = "line", data = sens_dfs[[i]], value_col = "GC", name = paste0("GC ", window_sizes[i], " bp"), col = scale_cols_gc[i], legend_font_col = scale_cols_gc[i])), lapply(seq_along(window_sizes), function(i) list(type = "line", data = sens_dfs[[i]], value_col = "Tm", name = paste0("Tm ", window_sizes[i], " bp"), col = scale_cols_tm[i], legend_font_col = scale_cols_tm[i])), list(list(type = "line", data = ecoli_rep_hotspots$bins_rep, value_col = "count", name = "Microsatellites", col = "#2ECC71", legend_font_col = "#2ECC71"), list(type = "highlight", data = ecoli_rep_hotspots$all_peaks_IP_mutH, col = "#F1C40F", alpha = 0.18)) ) plot_genome_track( genome_name = genome_name, genome_size = chr_length, track_list = tracks_scale, circular = TRUE, label = label )
At whole-genome scale the four bandwidths look nearly identical -- the landscape is invariant. The difference window size makes is local smoothing, best seen in a zoomed view of the same interval used by the main case study:
## Only the four Tm profiles are drawn. Ten line tracks in one linear panel ## leave each too little vertical space for its own axis labels, which then ## collide, and everything except Tm is answering a different question: the ## GC layers duplicate what the summary table already gives numerically, and ## the microsatellite density is the subject of the main case study's figure, ## not of this one. Removing both leaves five tracks with room to be read. ## The full ten-track version is the circular figure above. ## ## The interval is the one used in the main case study's zoomed panel, so the ## two figures can be read against each other rather than against different ## parts of the chromosome. tracks_zoom <- c( list(list(type = "rect", data = ecoli_rep_hotspots$all_peaks_IP_mutH, col = "#2C3E50", bg.col = "grey", name = "MutL-AR", legend_font_col = "#2C3E50", ideogram = TRUE, height = 0.6)), lapply(seq_along(window_sizes), function(i) list(type = "line", data = sens_dfs[[i]], value_col = "Tm", name = paste0("Tm ", window_sizes[i], " bp"), col = scale_cols_tm[i], legend_font_col = scale_cols_tm[i], height = 1.2)), list(list(type = "highlight", data = ecoli_rep_hotspots$all_peaks_IP_mutH, col = "#F1C40F", alpha = 0.18)) ) plot_genome_track( genome_name = genome_name, genome_size = chr_length, track_list = tracks_zoom, zoom = "U00096.3:100000-300000", track.gap = 0.03, axis.cex = 0.55 )
eff <- unique(test_summary[, c("window_bp", "Tm_med_diff")]) gc_rows <- test_summary[test_summary$target == "GC", ]
The association is reproduced at every scale. MutL-AR windows are lower
in median Tm than the background at all four window sizes
(r paste(sprintf("%+.2f", eff$Tm_med_diff), collapse = ", ") degrees C
at r paste(eff$window_bp, collapse = ", ") bp) and lower in GC
(r sprintf("%.3f", mean(gc_rows$mean[gc_rows$group == "peak"])) in peaks
vs r sprintf("%.3f", mean(gc_rows$mean[gc_rows$group == "non_peak"]))
in the background), and every Wilcoxon test is significant
(largest p = r format(max(test_results$p.value), digits = 2, scientific = TRUE)).
As expected, p values shrink with the number of windows while the effect
size stays essentially constant, so the biological conclusion does not
depend on the window choice. The primary analysis uses 200 bp to match
Hasenauer et al. (2025), whose Methods compute GC content and melting
temperature in 200 bp bins (using TmCalculator) and bin the ChIP-seq
coverage at the same 200 bp resolution.
sessionInfo()
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.