Three implementations of the nearest-neighbor model are compared on
identical input: TmCalculator,
rmelting (an R interface to the MELTING 5 Java engine,
whose JAR files are supplied by melting5jars, so the two
packages share one computational engine), and
Bio.SeqUtils.MeltingTemp from
Biopython.
Two things are deliberately kept apart.
Output consistency asks whether the three tools, given the same parameter set and the same reaction conditions, return the same melting temperature. This part is independent of language, hardware and intended use, and it is the part worth trusting most.
Computational cost asks how long each takes and how
much memory it holds. Here the comparison is of three tools as
distributed, not of three languages: TmCalculator’s
nearest-neighbor loop is compiled C++, Biopython’s Tm_NN is
pure Python, and MELTING 5 is Java. That difference is an implementation
choice by each project, and it is what a user experiences, but it should
not be read as a statement about R, Python or Java.
The comparison is restricted to a plain list of oligonucleotides, which is the one task all three were designed to perform. Benchmarking MELTING 5 or Biopython on millions of genomic windows would compare tools built for different purposes. The coordinate model that only TmCalculator provides is a capability difference and appears below as a feature table, not as a timing.
A speed comparison between tools computing different models is meaningless, so the model is pinned in all three.
| TmCalculator | Biopython | MELTING 5 | |
|---|---|---|---|
| Nearest-neighbor set | nn_table = "DNA_NN_SantaLucia_2004" |
nn_table = mt.DNA_NN4 |
method.nn = "san04" |
| Salt correction | salt_method = "SantaLucia1996" |
saltcorr = 3 |
correction.ion = "san96" |
| Sodium | Na = 50 (mM) |
Na = 50 (mM) |
Na.conc = 0.05 (M) |
| Strand concentration | dnac_high = 25, dnac_low = 25 (nM) |
dnac1 = 25, dnac2 = 25 (nM) |
nucleic.acid.conc (M) |
Two points about that table are worth stating explicitly, because both were found by testing rather than by reading documentation.
The salt correction is SantaLucia et al. (1996),
12.5 * log10[Na+], rather than the
11.7 * log10[Na+] form used elsewhere in this package. It
is the only plain additive logarithmic correction available in all three
tools; MELTING 5’s correction.ion has no entry for the 11.7
form, so that form cannot serve as common ground however natural it is
otherwise.
MELTING 5 defaults to method.nn = "all97" (Allawi &
SantaLucia 1997) and correction.ion = "ahs01" (von Ahsen
2001). Neither is what the other two tools are running. Left unset, they
produced a systematic offset of about 2.4 °C that had nothing to do with
the implementations.
The nucleic.acid.conc convention is the one parameter
the R interface does not document, so
inst/scripts/bench_crosstool.R --grid sweeps the plausible
conventions against the candidate ion corrections and reports which pair
agrees. Choosing the combination with the smallest deviation is
not the right criterion, and the script prints enough to see
why: switching MELTING’s ion correction from san96 to
schlif shifts the mean deviation by exactly
(16.6 - 12.5) * log10(0.05) = -5.334 °C at every
concentration, which is how one confirms that san96 really
is the same formula rather than merely the best-scoring one.
These chunks are not evaluated when the vignette is built: they need
a Java runtime, rmelting, and a Python interpreter with
Biopython, none of which is a dependency of this package.
## 1. Confirm the three tools agree before timing anything.
## If they do not, the benchmark measures default-value differences.
system("Rscript inst/scripts/bench_crosstool.R --calibrate --outdir bench200")
## 2. rmelting accepts one sequence per call, so it is given its own,
## smaller set of sizes; results merge into the same CSV.
system(paste("Rscript inst/scripts/bench_crosstool.R",
"--tools rmelting --sizes 100,1000 --reps 3 --outdir bench200"))
system(paste("Rscript inst/scripts/bench_crosstool.R",
"--tools TmCalculator,Biopython",
"--sizes 1000,10000,100000 --reps 3 --outdir bench200"))
## 3. Summary table and figure.
system("Rscript inst/scripts/plot_crosstool.R --indir bench200")Each tool runs in its own process under GNU time. That
is the only way to attribute peak resident set size to a tool rather
than to the accumulated state of one long session, and it means a tool
that hangs does not take the others with it. Sequences are 200 bp
windows drawn from the E. coli K-12 MG1655 chromosome under a
fixed seed, matching the window size used in the genome-wide case
study.
## The measurements are shipped with the package rather than recomputed here,
## since reproducing them needs a Java runtime, rmelting and a Python
## interpreter with Biopython, none of which is a dependency. If the file is
## absent -- a source tree in which the benchmark has not been run -- the
## remaining sections are skipped rather than failing the build.
f <- system.file("extdata", "crosstool_bench.csv", package = "TmCalculator")
has_data <- nzchar(f) && file.exists(f) &&
length(readLines(f, n = 1L, warn = FALSE)) > 0LS <- utils::read.csv(f, stringsAsFactors = FALSE)
S <- S[S$ok & !is.na(S$compute_s), , drop = FALSE]
## The benchmark file accumulates across invocations, so it can hold rows
## taken before and after a change to the package. Averaging those together
## gives a table that describes no build at all, so refuse instead.
if ("pkg_version" %in% names(S)) {
vs <- sort(unique(stats::na.omit(S$pkg_version)))
if (length(vs) > 1L)
stop("crosstool_bench.csv mixes TmCalculator versions (",
paste(vs, collapse = ", "), "); re-run the benchmark with --fresh.")
} else {
stop("crosstool_bench.csv predates the split of start-up from file I/O. ",
"Re-run inst/scripts/bench_crosstool.R and copy the result into ",
"inst/extdata/, then reinstall.")
}
## Median and range over repetitions. With three repetitions a standard
## deviation is not a meaningful estimate, and repeated runs of the same
## configuration on a laptop have differed by nearly 30%, so the spread is
## reported as the range actually observed.
agg <- do.call(rbind, lapply(split(S, list(S$tool, S$n), drop = TRUE), function(d) {
data.frame(tool = d$tool[1], n = d$n[1], reps = nrow(d),
compute_med = stats::median(d$compute_s),
compute_lo = min(d$compute_s),
compute_hi = max(d$compute_s),
seqs_per_s = d$n[1] / stats::median(d$compute_s),
rss_med = stats::median(d$rss_gb),
rss_lo = min(d$rss_gb), rss_hi = max(d$rss_gb),
startup_med = stats::median(d$startup_s),
## Reading the input and writing the results exist only because
## each tool runs as a separate process, so that peak memory can
## be attributed to it. Reported, never added to start-up, never
## drawn: a user passes data in memory.
io_med = stats::median(d$io_s),
stringsAsFactors = FALSE)
}))
agg <- agg[order(agg$tool, agg$n), ]
## Start-up cannot depend on the input. If it does, something that scales
## with n is being counted as start-up.
for (t in unique(agg$tool)) {
d <- agg[agg$tool == t, ]
if (nrow(d) > 1L && max(d$startup_med) / min(d$startup_med) > 1.5)
warning(t, " start-up varies ",
sprintf("%.1fx", max(d$startup_med) / min(d$startup_med)),
" across input sizes; it should be constant.", call. = FALSE)
}
knitr::kable(agg, digits = 4, row.names = FALSE,
caption = "Median and range over repetitions at each input size.")| tool | n | reps | compute_med | compute_lo | compute_hi | seqs_per_s | rss_med | rss_lo | rss_hi | startup_med | io_med |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Biopython | 1e+02 | 3 | 0.0210 | 0.0205 | 0.0213 | 4761.9048 | 0.0103 | 0.0103 | 0.0104 | 0.0941 | 0.0006 |
| Biopython | 3e+02 | 3 | 0.0625 | 0.0618 | 0.0628 | 4800.0000 | 0.0104 | 0.0104 | 0.0104 | 0.0958 | 0.0007 |
| Biopython | 1e+03 | 3 | 0.2136 | 0.2047 | 0.2137 | 4681.6479 | 0.0107 | 0.0106 | 0.0107 | 0.0960 | 0.0014 |
| Biopython | 3e+03 | 3 | 0.6207 | 0.6145 | 0.6228 | 4833.2528 | 0.0115 | 0.0115 | 0.0118 | 0.0990 | 0.0031 |
| Biopython | 1e+04 | 3 | 2.0701 | 2.0672 | 2.0781 | 4830.6845 | 0.0145 | 0.0145 | 0.0146 | 0.0995 | 0.0094 |
| Biopython | 3e+04 | 3 | 6.6377 | 6.2713 | 7.1749 | 4519.6378 | 0.0243 | 0.0241 | 0.0244 | 0.1529 | 0.0285 |
| Biopython | 1e+05 | 3 | 21.2312 | 20.6597 | 21.2901 | 4710.0494 | 0.0553 | 0.0549 | 0.0558 | 0.1322 | 0.0920 |
| Biopython | 3e+05 | 3 | 62.8029 | 61.6619 | 63.2765 | 4776.8495 | 0.1500 | 0.1499 | 0.1500 | 0.1683 | 0.2708 |
| TmCalculator | 1e+02 | 3 | 0.0960 | 0.0960 | 0.1230 | 1041.6667 | 0.3392 | 0.3392 | 0.3401 | 2.5820 | 0.0030 |
| TmCalculator | 3e+02 | 3 | 0.1050 | 0.1040 | 0.1330 | 2857.1429 | 0.3404 | 0.3401 | 0.3405 | 2.5240 | 0.0030 |
| TmCalculator | 1e+03 | 3 | 0.1330 | 0.1320 | 0.1400 | 7518.7970 | 0.3430 | 0.3426 | 0.3439 | 2.4360 | 0.0050 |
| TmCalculator | 3e+03 | 3 | 0.2140 | 0.2130 | 0.2150 | 14018.6916 | 0.3436 | 0.3434 | 0.3470 | 2.4240 | 0.0100 |
| TmCalculator | 1e+04 | 3 | 0.4940 | 0.4930 | 0.5040 | 20242.9150 | 0.3509 | 0.3509 | 0.3525 | 2.4310 | 0.0280 |
| TmCalculator | 3e+04 | 3 | 1.3260 | 1.3240 | 1.3290 | 22624.4344 | 0.3522 | 0.3518 | 0.3558 | 2.4170 | 0.1020 |
| TmCalculator | 1e+05 | 3 | 4.5290 | 4.5160 | 4.5470 | 22079.9293 | 0.3891 | 0.3850 | 0.3896 | 2.4680 | 0.2750 |
| TmCalculator | 3e+05 | 3 | 13.6470 | 13.3390 | 13.8150 | 21982.8534 | 0.5793 | 0.5785 | 0.5804 | 2.3420 | 1.3740 |
| rmelting | 1e+02 | 3 | 5.3290 | 5.3010 | 5.3730 | 18.7652 | 0.1810 | 0.1802 | 0.1822 | 0.5500 | 0.0020 |
| rmelting | 3e+02 | 3 | 28.4790 | 28.3620 | 28.5170 | 10.5341 | 0.2027 | 0.2020 | 0.2033 | 0.5380 | 0.0030 |
| rmelting | 1e+03 | 3 | 263.2020 | 254.5180 | 267.8610 | 3.7994 | 0.2824 | 0.2796 | 0.3149 | 0.7270 | 0.0060 |
fc <- system.file("extdata", "crosstool_consistency.csv", package = "TmCalculator")
if (nzchar(fc)) {
knitr::kable(utils::read.csv(fc), digits = 10, row.names = FALSE,
caption = "Deviation in Tm relative to TmCalculator on identical input.")
}| tool | n | mean_dTm | max_abs_dTm | pearson_r |
|---|---|---|---|---|
| rmelting | 100 | 0.4787223867 | 1.350259800 | 0.9975431 |
| Biopython | 100 | 0.0000000001 | 0.000000005 | 1.0000000 |
Under an identical nearest-neighbor set and salt correction,
TmCalculator and Biopython return the same value to within
floating-point representation. MELTING 5 differs by a fraction of a
degree in a sequence-dependent way. Recovering enthalpy and entropy from
Tm measured at two strand concentrations
(bench_crosstool.R --thermo) localises that difference:
MELTING’s enthalpies are offset by a near-constant 0.30 kcal/mol, which
is a difference in the duplex initiation term rather than in the
stacking sum, while the entropies differ by an amount that is not
constant. Nominally identical parameter sets are therefore not
implemented identically across tools.
This comparison is also how an error in TmCalculator itself was
found: four of the six reverse-complement rows added to every
nearest-neighbor table had been transposed. Before that fix the
deviation from Biopython was sequence-dependent and up to 0.09 °C; after
it, the two agree exactly. See NEWS.md.
tools <- sort(unique(agg$tool))
pal <- c("#1B5E9C", "#C0392B", "#5C6B73")[seq_along(tools)]; names(pal) <- tools
pch <- c(16, 17, 15)[seq_along(tools)]; names(pch) <- tools
draw_range <- function(x, lo, hi, col) {
v <- is.finite(lo) & is.finite(hi) & hi / pmax(lo, 1e-12) > 1.02
if (any(v)) arrows(x[v], lo[v], x[v], hi[v], code = 3, angle = 90,
length = 0.03, col = col)
}
op <- par(mfrow = c(1, 2), mar = c(4.3, 4.4, 2.2, 0.8), las = 1, cex = 0.85)
xr <- range(agg$n)
## A tool measured at only the smallest sizes is left off the time axis: its
## cost is orders of magnitude larger, which would flatten the others onto the
## axis, and drawing two points as a line invites interpolation through sizes
## at which it was never run.
tt <- sort(unique(agg$tool[agg$n == max(agg$n)]))
aggt <- agg[agg$tool %in% tt, ]
plot(NA, xlim = c(0, max(aggt$n)), ylim = c(0, max(aggt$compute_hi) * 1.05),
bty = "n", xlab = "Sequences", ylab = "Compute time (s)", xaxt = "n")
axis(1, at = pretty(c(0, max(aggt$n))),
labels = format(pretty(c(0, max(aggt$n))), big.mark = ",",
scientific = FALSE, trim = TRUE))
mtext("A", side = 3, adj = 0, font = 2, line = 0.8, cex = 1.1)
for (t in tt) {
d <- aggt[aggt$tool == t, ]; d <- d[order(d$n), ]
lines(d$n, d$compute_med, col = pal[t], lwd = 2)
draw_range(d$n, d$compute_lo, d$compute_hi, pal[t])
points(d$n, d$compute_med, col = pal[t], pch = pch[t], cex = 1.05)
}
legend("topleft", bty = "n", legend = tt, col = pal[tt],
pch = pch[tt], lwd = 2, cex = 0.9)
## Expand to whole decades: range() alone puts the extreme points on the frame
yr2 <- range(c(agg$rss_lo, agg$rss_hi))
yr2 <- c(10^floor(log10(yr2[1])), 10^ceiling(log10(yr2[2])))
plot(NA, xlim = xr, ylim = yr2, log = "xy", bty = "n", yaxt = "n",
xlab = "Sequences", ylab = "Peak resident set size (GB)")
ticks <- 10^seq(log10(yr2[1]), log10(yr2[2]))
axis(2, at = ticks, labels = format(ticks, scientific = FALSE, drop0trailing = TRUE))
axis(2, at = as.numeric(outer(2:9, ticks)), labels = FALSE, tcl = -0.2)
mtext("B", side = 3, adj = 0, font = 2, line = 0.8, cex = 1.1)
for (t in tools) {
d <- agg[agg$tool == t, ]; d <- d[order(d$n), ]
if (nrow(d) > 1) lines(d$n, d$rss_med, col = pal[t], lwd = 2)
draw_range(d$n, d$rss_lo, d$rss_hi, pal[t])
points(d$n, d$rss_med, col = pal[t], pch = pch[t], cex = 1.1)
}Compute time (A) and peak resident set size (B) against input size. Panel A is linear on both axes: above a few thousand sequences each tool is proportional to its input, so each is a straight line and the ratio of the slopes is the ratio of the throughputs, read directly off the picture. On logarithmic axes that ratio would carry the same visual weight as every other ratio in the plot, including the reversed one at the smallest input. Panel A omits any tool not measured across the whole range; panel B keeps all of them, the spread there being small enough to show.
## Two points at the top of the range separate the constant part of a call
## from the part that scales with the input.
fit <- do.call(rbind, lapply(split(agg, agg$tool), function(d) {
d <- d[order(d$n), ]
if (nrow(d) < 2L) return(NULL)
k <- nrow(d)
slope <- (d$compute_med[k] - d$compute_med[k - 1L]) / (d$n[k] - d$n[k - 1L])
data.frame(tool = d$tool[1],
fixed_cost_s = d$compute_med[k] - slope * d$n[k],
marginal_seqs_per_s = 1 / slope, stringsAsFactors = FALSE)
}))
knitr::kable(fit, digits = 3, row.names = FALSE,
caption = "Fixed cost per call and marginal throughput.")| tool | fixed_cost_s | marginal_seqs_per_s |
|---|---|---|
| Biopython | 0.445 | 4810.965 |
| TmCalculator | -0.030 | 21934.635 |
| rmelting | -72.117 | 2.982 |
## The size at which two tools take equal time follows from those two
## numbers, and is the one figure that keeps either end of panel A from
## being over-read.
if (nrow(fit) >= 2L) {
o <- order(fit$marginal_seqs_per_s, decreasing = TRUE)
a <- fit[o[1], ]; b <- fit[o[2], ]
crossover <- (a$fixed_cost_s - b$fixed_cost_s) /
(1 / b$marginal_seqs_per_s - 1 / a$marginal_seqs_per_s)
cat(sprintf("%s overtakes %s at about %s sequences.\n",
a$tool, b$tool, signif(crossover, 2)))
}## TmCalculator overtakes Biopython at about -2900 sequences.
Panel A shows why a single input size is not enough. TmCalculator
pays a fixed cost per call – constructing the GRanges,
loading the parameter tables, assembling the result – that is unchanged
whether one sequence is submitted or two hundred thousand. Below the
crossover printed above, that cost dominates, and a benchmark run only
at that scale reports TmCalculator as the slower tool. Above it the two
lines separate in the other direction and settle at the marginal
throughputs in the table, which is what “how fast is the calculation”
actually means.
That ratio, however, is not what a user invoking a tool once from the command line experiences, because it excludes the cost of starting the process.
wall <- aggt[order(aggt$n, aggt$tool), ]
m <- rbind(compute = wall$compute_med, `start-up` = wall$startup_med)
colnames(m) <- paste(wall$tool, wall$n)
grp <- cumsum(c(1, diff(wall$n) != 0))
space <- ifelse(c(TRUE, diff(grp) != 0), 1.1, 0.18)
fill <- c("#1B5E9C", "#BFD3E6")
op <- par(mar = c(5.6, 4.6, 2.4, 0.8), las = 1, cex = 0.85)
bp <- barplot(m, space = space, col = fill, border = NA, ylab = "",
names.arg = rep("", ncol(m)), ylim = c(0, max(colSums(m)) * 1.18))
u <- par("usr")
text(bp, u[3] - 0.02 * (u[4] - u[3]), labels = wall$tool, srt = 40, adj = 1,
xpd = TRUE, cex = 0.72)
mtext(format(unique(wall$n), big.mark = ","), side = 1, line = 3.6,
at = tapply(bp, grp, mean), cex = 0.85)
mtext("Sequences", side = 1, line = 4.7, cex = 0.85)
mtext("Elapsed time (s)", side = 2, line = 3.2, las = 0, cex = 0.85)
legend("topleft", bty = "n", fill = fill, border = NA,
legend = c("compute", "start-up"), cex = 0.9)Elapsed time for one invocation, separated into start-up and compute. Start-up is the constant part: an R session with the Bioconductor packages attached costs about two seconds whatever the input, which is more than an entire Biopython run at the smaller sizes. Only tools measured across the whole range are shown.
## Where the TOTAL, not the compute time, crosses over.
if (nrow(fit) >= 2L) {
o <- order(fit$marginal_seqs_per_s, decreasing = TRUE)
a <- fit[o[1], ]; b <- fit[o[2], ]
su <- vapply(list(a, b), function(z)
stats::median(agg$startup_med[agg$tool == z$tool]), numeric(1))
n_eq <- ((a$fixed_cost_s + su[1]) - (b$fixed_cost_s + su[2])) /
(1 / b$marginal_seqs_per_s - 1 / a$marginal_seqs_per_s)
cat(sprintf("Including start-up, %s overtakes %s at about %s sequences.\n",
a$tool, b$tool, format(signif(n_eq, 2), big.mark = ",")))
}## Including start-up, TmCalculator overtakes Biopython at about 11,000 sequences.
The two crossovers differ by more than an order of magnitude, and a reader who takes one for the other will reach the wrong conclusion about which tool to use. Comparing only the calculation, TmCalculator overtakes Biopython at a few hundred sequences; comparing what one command actually takes, it does not overtake until roughly ten thousand, because about two seconds are spent attaching R and the Bioconductor packages before any sequence is read. Both numbers are correct, and which one applies depends on whether the process is started once and then works, as in a genome-wide run, or started afresh for every small job.
Panel B is the half of the comparison that does not favour a Bioconductor package. TmCalculator’s memory is essentially a constant floor: an R session with Biostrings and GenomicRanges attached starts near 0.8 GB whatever the input, and grows barely at all across three orders of magnitude. Biopython holds only the sequences and grows from almost nothing. At the sizes measured here Biopython is far lighter; the two would meet only at a much larger input. The same applies to start-up, which is about two seconds for R with the Bioconductor stack against a fraction of a second for a Python interpreter. Both are one-time costs, invisible in a genome-scale run and material in a loop over short sequences.
rmelting::melting() accepts one sequence per call:
rmelting::melting(sequence = c("ACGTACGTACGTACGTACGTACGT",
"GGCCGGCCGGCCGGCCGGCCGGCC"),
nucleic.acid.conc = 1.25e-8, hybridisation.type = "dnadna",
Na.conc = 0.05, method.nn = "san04", correction.ion = "san96")
#> Error: 'sequence' should be a character vector of length 1.Processing n sequences therefore requires n separate invocations of the Java engine. The gap between MELTING 5 and the other two tools reflects interface design at least as much as the cost of the arithmetic.
It also does not scale linearly. Between one hundred and one thousand sequences the input grew tenfold and the time grew about fiftyfold, so the rate fell from roughly nineteen sequences per second to four. Extrapolating from a small run therefore understates the cost of a large one, and the tool is omitted from the time figures for two reasons rather than one: at the sizes the other tools were measured at, it is orders of magnitude slower, and two points that do not lie on a straight line cannot honestly be drawn through sizes at which nothing was measured. Its measured values remain in the table above.
The capability differences behind that observation are not timings and belong in a table of their own.
| Genomic coordinate model | Batch input | Parallel interface | GRanges output |
|
|---|---|---|---|---|
| TmCalculator | yes | yes | region-level, any backend | yes |
| rmelting (MELTING 5) | no | no | no | no |
Bio.SeqUtils.MeltingTemp |
no | yes | no | no |
All measurements come from a single machine and are not a portability claim. Repeated runs of the same configuration have differed by nearly 30% on this hardware, which is why the range over repetitions is shown rather than a standard deviation, and why conclusions are drawn from ratios spanning orders of magnitude rather than from small differences.
Peak resident set size is measured for the whole process. R, Java and Python reserve memory differently, so these values indicate the practical footprint of each workflow rather than the memory the calculation itself requires.
The consistency comparison covers perfectly matched DNA duplexes under one parameter set and one salt correction. It does not cover mismatches, dangling ends, RNA or RNA/DNA hybrids, for which the three tools do not offer the same parameter sets.
## R version 4.4.1 (2024-06-14)
## Platform: x86_64-apple-darwin20
## Running under: macOS Sonoma 14.6
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.0
##
## locale:
## [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: America/New_York
## tzcode source: internal
##
## attached base packages:
## [1] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] BSgenome.Ecoli.NCBI.ASM584v2_1.0.0 BSgenome_1.72.0
## [3] rtracklayer_1.64.0 BiocIO_1.14.0
## [5] Biostrings_2.72.1 XVector_0.44.0
## [7] GenomicRanges_1.56.2 GenomeInfoDb_1.40.1
## [9] IRanges_2.38.1 S4Vectors_0.42.1
## [11] BiocGenerics_0.50.0 TmCalculator_1.1.0
##
## loaded via a namespace (and not attached):
## [1] DBI_1.2.3 bitops_1.0-9
## [3] gridExtra_2.3 rlang_1.1.7
## [5] magrittr_2.0.4 biovizBase_1.52.0
## [7] otel_0.2.0 matrixStats_1.5.0
## [9] compiler_4.4.1 RSQLite_2.4.0
## [11] GenomicFeatures_1.56.0 png_0.1-8
## [13] vctrs_0.7.1 ProtGenerics_1.36.0
## [15] stringr_1.6.0 pkgconfig_2.0.3
## [17] crayon_1.5.3 fastmap_1.2.0
## [19] backports_1.5.0 Rsamtools_2.20.0
## [21] rmarkdown_2.30 UCSC.utils_1.0.0
## [23] bit_4.6.0 xfun_0.58
## [25] zlibbioc_1.50.0 cachem_1.1.0
## [27] jsonlite_2.0.0 blob_1.2.4
## [29] DelayedArray_0.30.1 BiocParallel_1.38.0
## [31] parallel_4.4.1 cluster_2.1.6
## [33] R6_2.6.1 VariantAnnotation_1.50.0
## [35] stringi_1.8.7 bslib_0.10.0
## [37] RColorBrewer_1.1-3 bezier_1.1.2
## [39] rpart_4.1.23 jquerylib_0.1.4
## [41] Rcpp_1.1.2 SummarizedExperiment_1.34.0
## [43] knitr_1.51 base64enc_0.1-6
## [45] Matrix_1.7-0 nnet_7.3-19
## [47] tidyselect_1.2.1 rstudioapi_0.18.0
## [49] dichromat_2.0-0.1 abind_1.4-8
## [51] yaml_2.3.12 codetools_0.2-20
## [53] curl_6.2.3 lattice_0.22-6
## [55] tibble_3.2.1 regioneR_1.36.0
## [57] Biobase_2.64.0 KEGGREST_1.44.1
## [59] evaluate_1.0.5 foreign_0.8-87
## [61] karyoploteR_1.30.0 pillar_1.10.2
## [63] MatrixGenerics_1.16.0 checkmate_2.3.2
## [65] generics_0.1.4 RCurl_1.98-1.17
## [67] ensembldb_2.28.1 ggplot2_3.5.2
## [69] scales_1.4.0 glue_1.8.0
## [71] lazyeval_0.2.2 Hmisc_5.2-3
## [73] tools_4.4.1 data.table_1.17.4
## [75] GenomicAlignments_1.40.0 XML_3.99-0.18
## [77] grid_4.4.1 colorspace_2.1-1
## [79] AnnotationDbi_1.66.0 GenomeInfoDbData_1.2.12
## [81] htmlTable_2.4.3 restfulr_0.0.15
## [83] Formula_1.2-5 cli_3.6.5
## [85] S4Arrays_1.4.1 dplyr_1.1.4
## [87] AnnotationFilter_1.28.0 gtable_0.3.6
## [89] sass_0.4.10 digest_0.6.39
## [91] SparseArray_1.4.8 rjson_0.2.23
## [93] htmlwidgets_1.6.4 farver_2.1.2
## [95] memoise_2.0.1 htmltools_0.5.9
## [97] lifecycle_1.0.5 httr_1.4.7
## [99] bit64_4.6.0-1 bamsignals_1.36.0