--- title: "Consensus regions across replicated experiments" author: - name: "Sebastian Gregoricchio, Ph.D." affiliation: - >- Netherlands Cancer Institute, Oncogenomics division, Amsterdam - Netherlands email: 's.gregoricchio@nki.nl' date: "Last update: `r format(Sys.time(), '%dth %B, %Y')`" output: BiocStyle::html_document: toc: true toc_float: true toc_depth: 3 number_sections: true fig_caption: true vignette: > %\VignetteIndexEntry{Consensus regions across replicated experiments} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = ">", warning = FALSE, message = FALSE, fig.align = "center", dev = "png", dpi = 96, fig.width = 7, fig.height = 4.5) ``` ---------------------------------------------------------- # **Introduction** {#intro} Replicates disagree. A region can be convincing in two samples and just below threshold in the third, and the usual response, taking the intersection of the peak sets, throws it away. Taking the union instead keeps it along with every piece of noise the worst replicate produced. `consensusRegions` does neither. Each peak is examined together with the peaks that overlap it in the other replicates, and their p-values are combined into one. Repeated weak evidence then adds up to something that survives, while a strong peak nobody else saw does not. The idea comes from [MSPC](https://genometric.github.io/MSPC/docs/) ([Jalili *et al.*, 2015](https://doi.org/10.1093/bioinformatics/btv293)); what is added here is the ability to weight replicates, to calibrate the decision threshold rather than fix it, and to work with peak files that have lost their statistics. ```{r load} library(consensusRegions) ```
# **Reading peaks** {#read_peaks} The example data is three replicates over two chromosomes, with a deliberately shallow third sample. ```{r read} peakFiles <- system.file("extdata", c("rep1.narrowPeak", "rep2.narrowPeak", "rep3.narrowPeak"), package = "consensusRegions") peaks <- readPeakSets(peakFiles, sampleNames = c("rep1", "rep2", "rep3")) peaks ``` `readPeakSets()` inspects the files and reports which statistic it can use. narrowPeak keeps -log10 p-values in its eighth column, so those go straight in. A BED with only a score gets rank-transformed. A bare BED3 has nothing at all, and the analysis will fall back to counting replicates instead of combining evidence.
## Chromosome naming Peak files rarely agree on whether a chromosome is called `chr1` or `1`. Two replicates that name the same chromosome differently share no overlap at all, so every peak is discarded for lack of support and the result looks like a biological finding rather than a clerical one. `readPeakSets()` therefore harmonises the names on the way in, defaulting to UCSC style, in the same way as `RegionSetDE::loadRegions()`: ```{r seqlevels} withChr <- GenomicRanges::GRanges( "chr1", IRanges::IRanges(start = c(1000, 20000), width = 500)) withoutChr <- GenomicRanges::GRanges( "1", IRanges::IRanges(start = c(1100, 20100), width = 500)) mixed <- readPeakSets(list(a = withChr, b = withoutChr), seqlevelsStyle = "UCSC") GenomeInfoDb::seqlevels(mixed[["b"]]) ``` `"Ensembl"` and `"NCBI"` go the other way, stripping the prefix. Setting `seqlevelsStyle = NULL` leaves the names untouched and stops if the replicates disagree, which is the safe choice when you would rather see the mismatch than have it quietly repaired.
## Call peaks permissively This is a critical issue that can substantially affect the validity of the entire analysis. If you call peaks at `q < 0.05` and run the consensus on what survives, there is nothing left to rescue and you have written an intersection with extra steps. Call at something like `p < 0.001` and let `buildConsensus()` do the thresholding. The two-tier design assumes exactly this.
# **Quick start** {#quick_start} ```{r basic} result <- buildConsensus(peaks, verbose = FALSE) result ``` The per-replicate summary is where to look first. ```{r stats} consensusStats(result) ``` `nRescued` counts peaks that were only weak on their own and were kept because the others agreed. That number is the reason to use this method, and also the thing to be suspicious of. If it dwarfs everything else, `weakThreshold` is too permissive and the combination is confirming noise. ```{r plotRescue} plotRescue(result) ``` The consensus regions carry the number of contributing replicates and a significance recomputed from their member peaks. ```{r consensus} head(consensusRanges(result), 3) ```
# **Weighting the replicates** {#weighting_reps} Fisher's method treats every replicate as equally informative. Yours probably are not: one is shallower, or has a worse antibody, or was prepared on a bad day. The choices are usually to drop it, which wastes data, or to keep it and let it drag the combined statistic around. Weighting is the third option. When the BAM files are available, FRiP and library size can be measured directly: ```{r weightsBam, eval = FALSE} weights <- computeReplicateWeights( peaks, method = "frip", bamFiles = c("rep1.bam", "rep2.bam", "rep3.bam")) ``` More often the alignments are long gone and only the numbers survive in an old QC report. Pass them in: ```{r weightsFrip} weights <- computeReplicateWeights(peaks, method = "frip", frip = c(0.21, 0.19, 0.06), verbose = FALSE) weights ``` And when there is nothing but the peaks themselves, each replicate can be scored by how well it agrees with the others: ```{r weightsIntrinsic} computeReplicateWeights(peaks, method = "intrinsic", verbose = FALSE) ``` That last measure is circular, since agreement is also what the consensus is testing. Use it when there is no alternative, not in preference to FRiP. Weights only reach the combination through Stouffer's Z or Lancaster's method. Fisher and the rank product ignore them. ```{r weighted} weighted <- buildConsensus(peaks, weights = weights, combinationMethod = "stouffer", verbose = FALSE) consensusStats(weighted) ``` ```{r jaccard} plotJaccard(weighted) ```
## Which weighting method to use The five settings differ in what they score a replicate on, and in what they cost you to obtain. | `method` | Scored on | Needs | Weight is | |-----------------|-----------|--------------------------|----------------| | `"equal"` | nothing | nothing | `1` throughout | | `"frip"` | signal | `bamFiles` or `frip` | the fraction | | `"librarySize"` | depth | `bamFiles`/`librarySize` | sqrt of depth | | `"intrinsic"` | agreement | the peak sets alone | mean Jaccard | | `"custom"` | your call | a numeric vector | what you pass | `"frip"` takes the fraction straight from an old QC report when the alignments are gone, and `"intrinsic"` scores a replicate on its mean pairwise Jaccard index with the others. Whichever you pick, the weights are rescaled to average one, so the combined statistics stay on the scale they would have had unweighted and a threshold carries across runs. A few extra details: * `"equal"` is the default and the reproducible choice. Nothing about the data steers the result, which is what you want unless you have a reason to think one replicate deserves less say than another. * `"frip"` is the one to reach for when the alignments, or the numbers someone once computed from them, still exist. It measures the thing you care about: how much of the sequencing landed in peaks. * `"librarySize"` counts depth twice to some extent, because peak-caller p-values already respond to it. Prefer `"frip"` when both are at hand. * `"intrinsic"` is circular. It scores a replicate on the agreement that the consensus then goes on to test, so strong agreement earns a large weight which in turn makes the agreement look stronger. It is there for the case where all that survives is a folder of BED files, not as an alternative to FRiP. * There is no `"drop"`. Removing a bad replicate is something you do before `readPeakSets()` ever sees it, and weighting exists so that you usually do not have to.
# **Choosing the combination method** {#choosing_combination} | Method | Weights | Use it when | |---------------|---------|-------------------------------------------------| | `fisher` | no | Reproducing MSPC, or replicates are comparable | | `stouffer` | yes | Default. Replicate quality varies | | `lancaster` | yes | Weights, with Fisher's pull from one strong peak | | `rankProduct` | no | Only scores, or p-values you do not trust | One caveat about Fisher that matters more than it usually gets credit for: the combined statistic keeps accumulating as replicates are added. With three samples this is fine. With twenty donors, a region detected faintly in all of them starts to look overwhelmingly significant, which is not what anyone means by reproducible. Stouffer and the rank product degrade more gracefully at that scale. The threshold does not carry across methods. Fisher, Stouffer and Lancaster all sit on roughly the same scale, so a cut chosen for one is a reasonable starting point for the others. The rank product does not. It works on relative ranks, so the best a peak can possibly do is come first in every replicate, and that bound depends on how many peaks each replicate holds. With the few hundred peaks in this example the smallest attainable combined p-value is around `1e-4`, and asking for `1e-8` would return nothing at all. `buildConsensus()` checks for this and refuses rather than handing back an empty result: ```{r rankProductScale, error = TRUE} buildConsensus(peaks, combinationMethod = "rankProduct", verbose = FALSE) ``` Give it a threshold on its own scale, or let the calibration below pick one: ```{r rankProductWorking} ranked <- buildConsensus(peaks, combinationMethod = "rankProduct", combinedThreshold = 0.05, verbose = FALSE) length(ranked) ``` Nothing survives, and the reason is the size of the example rather than any weakness in the method. With only a few hundred peaks per replicate the finest relative rank available is about 1/430, which is not a small number, so no peak accumulates much evidence however well it ranks. What little it does accumulate is then spent on the multiple testing correction described in the next section. Relax that correction to the one MSPC uses and the peaks reappear: ```{r rankProductRelaxed} relaxed <- buildConsensus(peaks, combinationMethod = "rankProduct", combinedThreshold = 0.05, adjustmentFamily = "confirmed", verbose = FALSE) length(relaxed) ``` On a real peak set of 10^5 calls the ranks are three orders of magnitude finer and the rank product survives the stricter correction comfortably. Judge it on your own data, not here.
# **How many replicates have to agree** {#how_many_replicates} The requirement can be stated either way round, because both readings are natural and mixing them up is easy. `minSupport` counts the *other* replicates that must hold an overlapping peak, which is what the confirmation step actually works with. `minReplicates` counts all of them including the one the peak came from, which is how the requirement is usually spoken about, and it matches MSPC's `-c`. Give one or the other, never both. ```{r minReplicates} c(byTotal = length(buildConsensus(peaks, minReplicates = 3, verbose = FALSE)), bySupport = length(buildConsensus(peaks, minSupport = 2, verbose = FALSE))) ``` `minReplicates` also takes a proportion or a percentage, rounded up, so the requirement can be written the way it is usually described: ```{r minReplicatesPercent} length(buildConsensus(peaks, minReplicates = "60%", verbose = FALSE)) ``` | Requirement | MSPC | `minReplicates` | `minSupport` | |-------------|----------|-------------------|--------------| | 2 of 3 | `-c 2` | `2` | `1` | | 3 of 3 | `-c 3` | `3` or `"100%"` | `2` | | 5 of 8 | `-c 5` | `5` | `4` | | 75% of 8 | `-c 75%` | `"75%"` or `0.75` | `5` | If you are coming from MSPC, use `minReplicates` and pass the same value you passed to `-c`. Setting `minSupport` to that value instead asks for one replicate more than you meant, which is a quiet way to lose peaks. Note that `replicateType = "technical"` overrides both and demands every replicate, on the grounds that technical replicates have no reason to disagree.
# **Multiple testing** {#multiple_testing} Peaks that clear the combined threshold are corrected within each replicate by the Benjamini-Hochberg procedure. The question is which peaks make up the family being corrected, and it matters more than it first appears. MSPC corrects across the peaks that already cleared the threshold. That family was chosen for being significant, so the usual Benjamini-Hochberg guarantee does not carry over to it, and the resulting values can be optimistic. `adjustmentFamily = "tested"`, the default here, instead corrects across every peak that entered the analysis. ```{r adjustmentFamily} tested <- buildConsensus(peaks, verbose = FALSE) mspcStyle <- buildConsensus(peaks, adjustmentFamily = "confirmed", verbose = FALSE) c(tested = length(tested), confirmed = length(mspcStyle)) ``` On this data the two agree, and that is the normal case: when the combined evidence is strong the correction is a rounding error against it. The choice only bites where the evidence was marginal, which is where you would want it to bite. Use `"confirmed"` when you are deliberately reproducing MSPC. Neither setting delivers exact false discovery rate control, and the package does not claim it does. Two things stand in the way. The supporting peak for each replicate is chosen as the most significant of those overlapping, and a maximum over a selected set is not distributed the way an unselected p-value would be. Overlapping peaks also share supporters, so their combined p-values are not independent. If you need a defensible error rate rather than a ranking, use the empirical route in the next section.
# **Calibrating the threshold** {#calibrating_threshold} The cut on the combined p-value is conventionally `1e-8`. That number is a guess, and the right one depends on the number of replicates, how the peaks were called, and how densely they sit. `calibrateThreshold()` estimates it. Peak positions are shuffled within each chromosome, keeping widths, counts and statistics, so any overlap between the shuffled replicates arises by chance. Running the combination on those gives a null, and the threshold is read off where the expected number of null peaks falls to a chosen fraction of the observed count. ```{r calibrate} calibration <- calibrateThreshold(peaks, nPermutations = 10, targetFDR = 0.05, seed = 42, verbose = FALSE) calibration$threshold ``` ```{r plotCalibration} plotCalibration(calibration) ``` ```{r calibrated} calibrated <- buildConsensus(peaks, combinedThreshold = calibration$threshold, verbose = FALSE) length(calibrated) ``` Ten permutations is enough for a demonstration; fifty is a reasonable working number. The positions are drawn at random, and `seed` is what brings the same threshold back. The draws happen inside `BiocParallel`, which keeps its random numbers apart from those of the session, so a `set.seed()` before the call does not reach them. The seed is kept with the result, in `calibration$seed`, and the random numbers of the session are left as they were. The permutations are independent of one another and are where nearly all the time goes, so they are handed to `BiocParallel`. On a genome-scale peak set a single round takes tens of seconds, which makes fifty of them most of an afternoon in series: ```{r parallel, eval = FALSE} calibration <- calibrateThreshold(peaks, nPermutations = 50, BPPARAM = 8, seed = 42) ``` `BPPARAM` takes the number of cores directly, which is how the question usually gets asked, and the same seed returns the same threshold on one core or on eight. A `BiocParallelParam` object is accepted too, for finer control of the workers. It is used as it is, so the seed then travels with the backend, as its `RNGseed`, and `seed` is ignored: ```{r parallelSeed, eval = FALSE} calibration <- calibrateThreshold( peaks, nPermutations = 50, BPPARAM = BiocParallel::MulticoreParam(workers = 8, RNGseed = 42)) ``` Passing a blacklist through `excludeRegions` makes the null more honest, because shuffled peaks otherwise land in artefact regions where real peaks cluster too.
# **Input without statistics** {#input_no_stats} Sometimes all that survives of an old analysis is a folder of BED3 files. There is no p-value to combine, so the package says so and switches to a weighted presence rule: a region is kept when the weights of the replicates supporting it sum to at least `minSupport`. With equal weights that is the familiar k-of-n rule. ```{r bed3} minimalFile <- system.file("extdata", "rep1_minimal.bed", package = "consensusRegions") minimal <- readPeakSets(rep(minimalFile, 3), sampleNames = c("a", "b", "c")) minimalResult <- buildConsensus(minimal, verbose = FALSE) analysisParameters(minimalResult)$presenceOnly ``` This is a fallback, not an equivalent. Without per-peak statistics nothing can be rescued, and the result is reproducibility filtering rather than combined evidence.
# **Merging, and relative issues** {#merging} Peak A overlaps B, B overlaps C, and A never touches C. Transitive merging puts all three in one region, and on permissive input this occasionally produces regions tens of kilobases long that mean nothing. Two defences. `maxConsensusWidth` catches oversized regions and rebuilds only those. `mergeMethod = "iterative"` avoids the problem entirely by seeding on the most significant peak, claiming what it overlaps, and repeating, which is the approach the ATAC-seq peak atlases use. ```{r merge} seeded <- buildConsensus(peaks, mergeMethod = "iterative", verbose = FALSE) summary(GenomicRanges::width(consensusRanges(result))) summary(GenomicRanges::width(consensusRanges(seeded))) ```
# **Fixed-width regions** {#fixed_width} Consensus regions inherit their edges from whichever peaks were merged, so their widths vary. That variation biases everything downstream: a wider region collects more reads and more motif matches for reasons that have nothing to do with biology. For transcription factors and ATAC data, recentre on the summit and use a common width. ```{r recentre} fixed <- recentrePeaks(result, width = 400, summitSource = "best") head(fixed, 3) ``` Do not do this to broad histone domains. Collapsing a domain to a point discards the thing being measured.
# **Handing over to a differential analysis** If the consensus is a step on the way to differential binding rather than the endpoint, convert it and count reads over the row ranges with csaw, DiffBind or edgeR. ```{r se} se <- asSummarizedExperiment(result) se ``` The assay holds per-replicate significance, not counts. It is useful for spotting a replicate that disagrees with the others; it is not a substitute for counting reads. And a word on where to spend your effort. If the consensus is an intermediate, do not over-engineer it. Aggressive filtering upfront mostly discards peaks that a proper count-based model would have handled correctly anyway. The stringency of this package earns its keep when the peak set is the deliverable.
# **Blacklisting** Filter after the consensus, not before. Removing regions upfront distorts the rank distributions the combination relies on, which is why `excludeRegions` is applied at the end of `buildConsensus()`. ```{r blacklist, eval = FALSE} result <- buildConsensus(peaks, excludeRegions = blacklistGRanges) ```
# **Checking the result** Nothing in this package can tell you whether your consensus set is any good. Validate it against something orthogonal: central motif enrichment for a transcription factor, TSS enrichment and cCRE overlap for ATAC, correlation with expression for activating marks. If the rescued peaks are motif-poor and TSS-depleted, the settings are too permissive regardless of what the statistics say.
---------------------------------------------------- # **Everything in one call** {#wrapper} The steps above almost always run in the same order, so `runConsensus()` chains them: read, weight, optionally calibrate, build, optionally recentre, optionally write out. ```{r wrapper} result <- runConsensus(peakFiles, sampleNames = c("rep1", "rep2", "rep3"), minReplicates = 2, verbose = FALSE) length(result) ``` Anything it does not name itself is passed through to `buildConsensus()`, so `combinationMethod`, `mergeMethod`, `adjustmentFamily` and the rest all work here too: ```{r wrapperOptions, eval = FALSE} result <- runConsensus(peakFiles, sampleNames = c("rep1", "rep2", "rep3"), weightMethod = "frip", bamFiles = c("rep1.bam", "rep2.bam", "rep3.bam"), calibrate = TRUE, nPermutations = 50, combinationMethod = "stouffer", minReplicates = "75%", recentre = TRUE, width = 400, excludeRegions = blacklist, outputFile = "consensus.bed", BPPARAM = 8) ``` Use the individual functions when a step needs looking at before the next one runs. Calibration in particular deserves a glance at `plotCalibration()` rather than blind trust, and the wrapper gives you no chance to take it.
---------------------------------------------------- # **Session info** ```{r sessionInfo, echo=FALSE} sessionInfo() ```