diff --git a/R/GenotypeHandle.R b/R/GenotypeHandle.R index 43baff5f..5f94f4d6 100644 --- a/R/GenotypeHandle.R +++ b/R/GenotypeHandle.R @@ -130,6 +130,16 @@ setMethod("show", "GenotypeHandle", function(object) { #' \code{#chr,path} meta file or a named character vector #' (names = chromosomes, values = payload paths/prefixes). Optionally pass #' \code{format} via \code{...} to force a single backend for every shard. +#' @param chroms Optional character vector of chromosomes. With \code{genoMeta} +#' (a sharded, one-file-per-chromosome panel) only the shards whose +#' chromosome is listed are read, skipping the rest -- an I/O optimisation +#' when the panel is genome-wide but only a few chromosomes are needed. +#' Chromosome labels are compared canonically (\code{"chr1"}/\code{"1"} match, +#' \code{23}/\code{X} etc.). If none of the requested chromosomes are present +#' in the panel, every shard is read (so the caller's own absence check can +#' report the mismatch). Only meaningful with \code{genoMeta}; supplying it +#' with any other source is an error (a single-file panel has no per-chromosome +#' shards to skip). #' @param ... Additional arguments forwarded to the format-specific reader. #' @return A \code{GenotypeHandle} object. #' @export @@ -138,7 +148,7 @@ GenotypeHandle <- function(path = NULL, bed = NULL, bim = NULL, fam = NULL, pgen = NULL, pvar = NULL, psam = NULL, ldMeta = NULL, region = NULL, - genoMeta = NULL, + genoMeta = NULL, chroms = NULL, ...) { bedTrioGiven <- !is.null(bed) || !is.null(bim) || !is.null(fam) bedTrioComplete <- !is.null(bed) && !is.null(bim) && !is.null(fam) @@ -173,6 +183,10 @@ GenotypeHandle <- function(path = NULL, "bed/bim/fam triplet, the pgen/pvar/psam triplet, `ldMeta`, or ", "`genoMeta` must be specified (got ", nSources, ").") } + if (!is.null(chroms) && !sources[["genoMeta"]]) { + stop("`chroms` restricts which per-chromosome shards are read and is only ", + "supported with `genoMeta` (a single-file panel has no shards to skip).") + } if (sources[["path"]]) { return(readGenotypes(path, ...)) @@ -193,7 +207,7 @@ GenotypeHandle <- function(path = NULL, return(.genotypeHandleFromLdMeta(ldMeta, region, ...)) } if (sources[["genoMeta"]]) { - return(.genotypeHandleFromChromMeta(genoMeta, ...)) + return(.genotypeHandleFromChromMeta(genoMeta, chroms = chroms, ...)) } } @@ -393,14 +407,28 @@ GenotypeHandle <- function(path = NULL, # metadata via the existing single-file readers, validates a single shared # format and identical sample IDs (same order, required for cross-shard # cbind), and row-binds the per-shard snpInfo into one global index space. +# `chroms` (optional) restricts the read to the shards for those chromosomes: +# the other per-chromosome files are never opened, which is the I/O win when a +# genome-wide panel backs summary statistics on only a few chromosomes. #' @keywords internal -.genotypeHandleFromChromMeta <- function(genoMeta, ...) { +.genotypeHandleFromChromMeta <- function(genoMeta, chroms = NULL, ...) { dots <- list(...) format <- dots$format parsed <- .parseChromMeta(genoMeta) if (nrow(parsed) == 0L) stop("GenotypeHandle(genoMeta): no chromosomes found in the meta input.") + # Keep only the shards whose (meta-declared) chromosome was requested, so the + # rest are never read. If nothing matches -- the requested chromosomes are + # absent from the panel -- fall back to reading every shard, leaving the + # caller's own containment/absence check to produce the usual diagnostic + # instead of a confusing empty-handle error here. + if (!is.null(chroms)) { + keep <- canonChrom(as.character(parsed$chrom)) %in% + canonChrom(as.character(chroms)) + if (any(keep)) parsed <- parsed[keep, , drop = FALSE] + } + shards <- lapply(parsed$path, .resolveGenotypeShard, format = format) formats <- vapply(shards, function(h) h@format, character(1)) diff --git a/R/colocboostPipeline.R b/R/colocboostPipeline.R index fb34a93b..4b2f0c2e 100644 --- a/R/colocboostPipeline.R +++ b/R/colocboostPipeline.R @@ -81,6 +81,14 @@ #' skipped. \code{0} (default) disables it; a negative value uses #' \code{3 / n_variants}. (Summary-statistic skipping is handled upstream by #' \code{\link{summaryStatsQc}}'s own \code{pipCutoffToSkip}.) +#' @param absZCutoffToSkip,bfCutoffToSkip,logBfCutoffToSkip Alternative +#' individual-level pre-filter metrics used in place of +#' \code{pipCutoffToSkip}: drop an outcome unless its maximum marginal +#' \code{|z|} (\code{absZCutoffToSkip}), or its maximum per-variant +#' single-effect Bayes factor (\code{bfCutoffToSkip}) / log Bayes factor +#' (\code{logBfCutoffToSkip}) from the \code{L = 1} fit, exceeds the cutoff. +#' Scalars, each defaulting to 0 (off). Exactly one screening metric may be +#' enabled: setting any of these requires \code{pipCutoffToSkip = 0}. #' @param alleleFlip Logical, default \code{TRUE}. When TRUE, harmonize variants #' across the individual X, sumstats, and LD by (chrom, pos) with ref/alt #' swaps recognized (flipping z / residualized dosage / LD to a shared @@ -160,10 +168,13 @@ setGeneric("colocboostPipeline", invisible(NULL) } -# Resolve the per-context pipCutoffToSkip from either a scalar (applies to -# every context) or a named vector keyed by context. Default 0 (no skip). +# Resolve the per-context screen spec. The `pipCutoffToSkip` channel carries +# EITHER a resolved screen object (list(metric, cutoff) for absZ/bf/logBf/pip -- +# applies uniformly to every context) OR the legacy PIP cutoff as a scalar (all +# contexts) or a named vector keyed by context. Default 0 (no screen). .cbResolveCutoff <- function(pipCutoffToSkip, ctx) { if (is.null(pipCutoffToSkip) || length(pipCutoffToSkip) == 0L) return(0) + if (is.list(pipCutoffToSkip)) return(pipCutoffToSkip) # uniform screen object if (!is.null(names(pipCutoffToSkip))) { if (ctx %in% names(pipCutoffToSkip)) return(pipCutoffToSkip[[ctx]]) return(0) @@ -171,18 +182,35 @@ setGeneric("colocboostPipeline", pipCutoffToSkip[[1L]] } +# Combine the four colocboost screen cutoffs into a single spec to thread +# through the pipCutoffToSkip channel: a resolved screen object when a new +# metric (absZ / bf / logBf) is set, otherwise the (possibly context-named) +# legacy pipCutoffToSkip. Enforces one screening metric at a time. +.cbScreenSpec <- function(pipCutoffToSkip, absZCutoffToSkip, + bfCutoffToSkip, logBfCutoffToSkip) { + newScreen <- .resolveScreenMetric(0, absZCutoffToSkip, + bfCutoffToSkip, logBfCutoffToSkip) + pipOn <- !is.null(pipCutoffToSkip) && length(pipCutoffToSkip) > 0L && + any(as.numeric(pipCutoffToSkip) != 0, na.rm = TRUE) + if (!is.null(newScreen) && pipOn) + stop("colocboostPipeline: only one signal screen may be enabled at a time; ", + "unset pipCutoffToSkip to use absZCutoffToSkip / bfCutoffToSkip / ", + "logBfCutoffToSkip.") + if (!is.null(newScreen)) newScreen else pipCutoffToSkip +} + # Per-outcome single-trait skip (ports the legacy qc_individual_data # pip_cutoff_to_skip): for each outcome column of Y, fit a single-effect # SuSiE (L = 1, max_iter = 100) on (X, Y[, j]) and keep the outcome only if # any variant's PIP exceeds the cutoff. A cutoff < 0 means 3 / n_variants. # Returns the retained Y (NULL when no outcome clears the threshold). -.cbPipSkipOutcomes <- function(X, Y, cutoff) { - if (is.null(cutoff) || is.na(cutoff) || cutoff == 0) return(Y) +.cbPipSkipOutcomes <- function(X, Y, spec) { + if (is.null(.asScreen(spec))) return(Y) # Single-effect screen per outcome, sharing the L = 1 SuSiE pre-screen # (.fmSerScreen) with the fine-mapping pipeline. fallback = FALSE: an outcome # that cannot be screened (too few samples / fit failure) is dropped. keep <- vapply(seq_len(ncol(Y)), - function(j) .fmSerScreen(X, Y[, j], cutoff, fallback = FALSE), + function(j) .fmSerScreen(X, Y[, j], spec, fallback = FALSE), logical(1L)) if (!any(keep)) return(NULL) Y[, keep, drop = FALSE] @@ -251,11 +279,11 @@ setGeneric("colocboostPipeline", X <- X[common, , drop = FALSE] Y <- Y[common, , drop = FALSE] cutoffCtx <- .cbResolveCutoff(pipCutoffToSkip, ctx) - if (!is.null(cutoffCtx) && !is.na(cutoffCtx) && cutoffCtx != 0) { + if (!is.null(.asScreen(cutoffCtx))) { Y <- .cbPipSkipOutcomes(X, Y, cutoffCtx) if (is.null(Y) || ncol(Y) == 0L) { message("colocboostPipeline: skipping context '", ctx, - "' (no outcome cleared pipCutoffToSkip = ", cutoffCtx, ").") + "' (no outcome cleared the signal screen).") next } } @@ -699,9 +727,14 @@ setMethod("colocboostPipeline", "QtlDataset", separateGwas = FALSE, samples = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, alleleFlip = TRUE, ...) { dotArgs <- list(...) + screenSpec <- .cbScreenSpec(pipCutoffToSkip, absZCutoffToSkip, + bfCutoffToSkip, logBfCutoffToSkip) indBundle <- .cbIndividualBundle( qtlData, contexts = contexts, @@ -709,7 +742,7 @@ setMethod("colocboostPipeline", "QtlDataset", region = region, cisWindow = cisWindow, samples = samples, - pipCutoffToSkip = pipCutoffToSkip) + pipCutoffToSkip = screenSpec) .cbDriver(indBundle, qtlPairs = list(), gwasSumStats, xqtlColoc, jointGwas, separateGwas, focalTrait, dotArgs, alleleFlip = alleleFlip) @@ -755,9 +788,14 @@ setMethod("colocboostPipeline", "MultiStudyQtlDataset", separateGwas = FALSE, samples = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, alleleFlip = TRUE, ...) { dotArgs <- list(...) + screenSpec <- .cbScreenSpec(pipCutoffToSkip, absZCutoffToSkip, + bfCutoffToSkip, logBfCutoffToSkip) # Aggregate the individual-level bundles across all QtlDataset # members. Per-study trait names are prefixed with "{study}:" so @@ -777,7 +815,7 @@ setMethod("colocboostPipeline", "MultiStudyQtlDataset", region = region, cisWindow = cisWindow, samples = samples, - pipCutoffToSkip = pipCutoffToSkip) + pipCutoffToSkip = screenSpec) if (is.null(sub)) next xOffset <- length(combinedX) yOffset <- length(combinedY) diff --git a/R/ctwasPipeline.R b/R/ctwasPipeline.R index 75ebc7be..e1bfdc51 100644 --- a/R/ctwasPipeline.R +++ b/R/ctwasPipeline.R @@ -747,8 +747,26 @@ mergeCtwasBoundaryRegions <- function(finemapResult, groupPriorVarStructure, thin, ncore, extra = list()) { fitEm <- getFromNamespace("fit_EM", "ctwas") + # Mirror ctwas::est_param's degenerate-region skip before fitting. est_param + # drops regions with fewer than `min_var` total variables or fewer than + # `min_gene` genes -- whose per-region `sid` is unset -- and runs its prefit + # fit_EM only on the survivors. This prefit-only fallback bypasses est_param's + # p(single effect) selection gate, but it must apply the SAME filter, or + # ctwas::fit_EM errors inside extract_region_data ("regiondata$sid ... target + # is NULL") on a skipped region. Honor min_var / min_gene forwarded via `...`. + minVar <- if (!is.null(extra$min_var)) as.integer(extra$min_var) else 2L + minGene <- if (!is.null(extra$min_gene)) as.integer(extra$min_gene) else 1L + allRegionIds <- names(region_data) + nGid <- vapply(region_data, function(x) length(x$gid), integer(1L)) + nSid <- vapply(region_data, function(x) length(x$sid), integer(1L)) + keep <- rep(TRUE, length(region_data)) + if (minVar > 0L) keep <- keep & (nSid + nGid) >= minVar + if (minGene > 0L) keep <- keep & nGid >= minGene + fitRegionData <- region_data[keep] + if (length(fitRegionData) == 0L) + stop("No regions selected!") fitArgs <- list( - region_data = region_data, + region_data = fitRegionData, niter = as.integer(niterPrefit), group_prior_var_structure = groupPriorVarStructure, ncore = as.integer(ncore)) @@ -771,7 +789,7 @@ mergeCtwasBoundaryRegions <- function(finemapResult, group_prior_var_structure = groupPriorVarStructure, group_size = groupSize, p_single_effect = data.frame( - region_id = names(region_data), + region_id = allRegionIds, p_single_effect = NA_real_, stringsAsFactors = FALSE)) } diff --git a/R/fineMappingPipeline.R b/R/fineMappingPipeline.R index 471c3aef..6f2bce1f 100644 --- a/R/fineMappingPipeline.R +++ b/R/fineMappingPipeline.R @@ -197,6 +197,14 @@ #' summary-statistics analog lives in \code{summaryStatsQc()}. \code{0} #' (default) disables the screen; a negative value uses the adaptive #' \code{3 / nVariants} threshold. +#' @param absZCutoffToSkip,bfCutoffToSkip,logBfCutoffToSkip Numeric (length 1). +#' Alternative individual-level pre-screen metrics, in place of +#' \code{pipCutoffToSkip}: skip a block unless its maximum marginal +#' \code{|z|} (\code{absZCutoffToSkip}), or its maximum per-variant +#' single-effect Bayes factor (\code{bfCutoffToSkip}) / log Bayes factor +#' (\code{logBfCutoffToSkip}) from the \code{L = 1} fit, exceeds the cutoff. +#' Each defaults to 0 (off). Exactly one of the four \code{*CutoffToSkip} +#' arguments may be non-zero (one screening metric at a time). #' @param usePCA Logical (length 1). \code{QtlDataset} only. When #' \code{TRUE} (default \code{FALSE}), each multi-trait context's #' PCA-reduced phenotype is fine-mapped with univariate SuSiE on its @@ -1002,14 +1010,25 @@ combineFineMappingResults <- function(..., ldSketch = NULL) { scores } -# Single-effect (SER) pre-screen, individual-level. Fits susie with L = 1 on a -# residualized (X, y) block and reports whether any PIP clears `cutoff` -- i.e. -# whether the block shows any potentially significant variant worth a full fit. -# Ports the deleted multivariate_pipeline.R `skipConditions` / susie_twas -# `pip_cutoff_to_skip` logic (the individual-level analog of the sumstat-path -# `.applyPipScreen`): -# * `cutoff == 0` (or NULL/non-scalar) disables the screen -> always keep. -# * `cutoff < 0` uses the adaptive 3 / nVariants threshold. +# Per-column marginal-association z-scores of y on each column of X (univariate +# regression z = betahat / sebetahat), used by the individual-level absZ screen. +# @noRd +.marginalZ <- function(X, y) { + ur <- susieR::univariate_regression(X, y) + ur$betahat / ur$sebetahat +} + +# Single-effect (SER) pre-screen, individual-level. Reports whether a +# residualized (X, y) block shows a strong enough signal (by the chosen metric) +# to be worth a full fit. `screen` is a screen spec (see .asScreen): a legacy +# PIP cutoff (numeric scalar, 0 = off) OR a resolved list(metric, cutoff) for +# one of pip / absZ / bf / logBf. Ports the deleted multivariate_pipeline.R +# `skipConditions` / susie_twas `pip_cutoff_to_skip` logic (the individual-level +# analog of the sumstat-path `.applyEntryScreen`): +# * no screen (NULL / 0 / non-scalar numeric) -> always keep. +# * pip cutoff < 0 uses the adaptive 3 / nVariants threshold. +# * absZ needs no susie fit; pip/bf/logBf fit susie L = 1 once (its +# $lbf_variable gives the per-variant logBF for bf/logBf, $pip for pip). # * NA entries of `y` are dropped before fitting. # The screen is advisory: too few samples/variants or a fit failure returns # `fallback` -- TRUE (default) keeps the block rather than discard a potentially @@ -1017,26 +1036,39 @@ combineFineMappingResults <- function(..., ldSketch = NULL) { # outcome it cannot screen. This is the single L = 1 SuSiE pre-screen shared by # .fmSerScreenColumns (joint) and .cbPipSkipOutcomes (colocboost). # @noRd -.fmSerScreen <- function(X, y, cutoff, fallback = TRUE) { - if (is.null(cutoff) || length(cutoff) != 1L || is.na(cutoff) || cutoff == 0) - return(TRUE) +.fmSerScreen <- function(X, y, screen, fallback = TRUE) { + scr <- .asScreen(screen) + if (is.null(scr)) return(TRUE) ok <- !is.na(y) if (sum(ok) < 2L || ncol(X) < 1L) return(fallback) Xs <- X[ok, , drop = FALSE] if (!is.double(Xs)) storage.mode(Xs) <- "double" # susieR needs double X - thr <- if (cutoff < 0) 3 / ncol(Xs) else cutoff - pip <- tryCatch(suppressMessages(susieR::susie(Xs, y[ok], L = 1L))$pip, + ys <- y[ok] + metric <- scr$metric; cutoff <- scr$cutoff + if (metric == "absZ") { + z <- tryCatch(.marginalZ(Xs, ys), error = function(e) NULL) + if (is.null(z)) return(fallback) + return(any(abs(z) > cutoff, na.rm = TRUE)) + } + fit <- tryCatch(suppressMessages(susieR::susie(Xs, ys, L = 1L)), error = function(e) NULL) - if (is.null(pip)) return(fallback) - any(pip > thr, na.rm = TRUE) + if (is.null(fit)) return(fallback) + if (metric == "pip") { + thr <- if (cutoff < 0) 3 / ncol(Xs) else cutoff + return(any(fit$pip > thr, na.rm = TRUE)) + } + maxLbf <- suppressWarnings(max(as.numeric(fit$lbf_variable), na.rm = TRUE)) + if (!is.finite(maxLbf)) return(fallback) + # bf: cutoff on the raw BF scale -> compare in log space; logBf: log scale. + maxLbf > (if (metric == "bf") log(cutoff) else cutoff) } -# Is the SER pre-screen enabled? Only a finite, non-zero scalar activates it; -# this gates the extra screening extraction so the default (cutoff 0) costs -# nothing. +# Is a signal screen enabled? Any spec that .asScreen resolves to a screen +# object (a non-zero pip cutoff or a resolved metric) activates it; this gates +# the extra screening extraction so the default (no screen) costs nothing. # @noRd -.fmScreenActive <- function(cutoff) { - !is.null(cutoff) && length(cutoff) == 1L && !is.na(cutoff) && cutoff != 0 +.fmScreenActive <- function(screen) { + !is.null(.asScreen(screen)) } # Per-condition SER pre-screen for a joint (multi-context / multi-trait) fit: @@ -1045,9 +1077,9 @@ combineFineMappingResults <- function(..., ldSketch = NULL) { # and a port of the deleted `skipConditions`: callers drop the FALSE columns # (null contexts / traits) before the joint mvSuSiE fit. # @noRd -.fmSerScreenColumns <- function(X, Y, cutoff) { +.fmSerScreenColumns <- function(X, Y, screen) { vapply(seq_len(ncol(Y)), - function(j) .fmSerScreen(X, Y[, j], cutoff), + function(j) .fmSerScreen(X, Y[, j], screen), logical(1L)) } @@ -1374,6 +1406,9 @@ setMethod("fineMappingPipeline", "QtlDataset", cvThreads = 1, samplePartition = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, usePCA = FALSE, nPCs = 10L, seed = NULL, @@ -1392,6 +1427,10 @@ setMethod("fineMappingPipeline", "QtlDataset", ...) { naAction <- match.arg(naAction) if (!is.null(seed)) set.seed(as.integer(seed)) + # Resolve the single active signal screen (pip / absZ / bf / logBf) once and + # thread the resolved spec through the existing pipCutoffToSkip channels. + screen <- .resolveScreenMetric(pipCutoffToSkip, absZCutoffToSkip, + bfCutoffToSkip, logBfCutoffToSkip) # Apply any per-call filter overrides to a validated copy of the dataset # (replaces the construct-time slot values for this call only). data <- .qtlApplyFilterOverrides(data, mafCutoff, macCutoff, xvarCutoff, @@ -1426,7 +1465,7 @@ setMethod("fineMappingPipeline", "QtlDataset", twasWeights = twasWeights, dataDrivenPriorWeightsCutoff = dataDrivenPriorWeightsCutoff, cvFolds = cvFolds, cvThreads = cvThreads, samplePartition = samplePartition, - pipCutoffToSkip = pipCutoffToSkip, + pipCutoffToSkip = screen, fineMappingResult = fineMappingResult, fullFit = fullFit, fullFitAlphaOnly = fullFitAlphaOnly, includeAllCs = includeAllCs) @@ -1547,12 +1586,12 @@ setMethod("fineMappingPipeline", "QtlDataset", X <- X[common, , drop = FALSE] y <- Y[common, , drop = FALSE] if (ncol(y) > 1L) y <- y[, 1L, drop = TRUE] else y <- drop(y) - # SER pre-screen: skip this block when a single-effect fit finds no - # PIP above pipCutoffToSkip (no potentially significant variant). - if (!.fmSerScreen(X, y, pipCutoffToSkip)) { + # SER pre-screen: skip this block when the chosen signal screen + # finds no strong-enough variant (no potentially significant signal). + if (!.fmSerScreen(X, y, screen)) { if (verbose >= 1) message(sprintf( - "Skipping (context='%s', trait='%s'): SER pre-screen found no PIP above pipCutoffToSkip.", + "Skipping (context='%s', trait='%s'): SER pre-screen found no signal above the cutoff.", ctx, tid)) return(list()) } @@ -1607,7 +1646,7 @@ setMethod("fineMappingPipeline", "QtlDataset", common <- intersect(rownames(X), names(pcY)) if (length(common) < 2L) return(list()) Xb <- X[common, , drop = FALSE] - if (!.fmSerScreen(Xb, pcY[common], pipCutoffToSkip)) return(list()) + if (!.fmSerScreen(Xb, pcY[common], screen)) return(list()) afVec <- .fmAfForX(data, Xb, traitId = traits, region = rg, cisWindow = cisWindow) .fmFitXBlock(Xb, pcY[common], "susie", FALSE, coverage, @@ -1643,7 +1682,7 @@ setMethod("fineMappingPipeline", "QtlDataset", twasWeights = twasWeights, dataDrivenPriorWeightsCutoff = dataDrivenPriorWeightsCutoff, cvFolds = cvFolds, cvThreads = cvThreads, samplePartition = samplePartition, - pipCutoffToSkip = pipCutoffToSkip, + pipCutoffToSkip = screen, fineMappingResult = fineMappingResult, fullFit = fullFit, fullFitAlphaOnly = fullFitAlphaOnly, includeAllCs = includeAllCs) @@ -1698,6 +1737,8 @@ setMethod("fineMappingPipeline", "QtlDataset", minAbsCorr = cfg$minAbsCorr, fineMappingResult = cfg$fineMappingResult, cvFolds = cfg$cvFolds, cvThreads = cfg$cvThreads, samplePartition = cfg$samplePartition, pipCutoffToSkip = cfg$pipCutoffToSkip, + absZCutoffToSkip = cfg$absZCutoffToSkip, bfCutoffToSkip = cfg$bfCutoffToSkip, + logBfCutoffToSkip = cfg$logBfCutoffToSkip, seed = cfg$seed, naAction = cfg$naAction, verbose = cfg$verbose), cfg$dotArgs)) } @@ -1739,6 +1780,9 @@ setMethod("fineMappingPipeline", "MultiStudyQtlDataset", cvThreads = 1, samplePartition = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, seed = NULL, naAction = c("drop", "impute"), verbose = 1, @@ -1800,6 +1844,8 @@ setMethod("fineMappingPipeline", "MultiStudyQtlDataset", minAbsCorr = minAbsCorr, fineMappingResult = fineMappingResult, cvFolds = cvFolds, cvThreads = cvThreads, samplePartition = samplePartition, pipCutoffToSkip = pipCutoffToSkip, + absZCutoffToSkip = absZCutoffToSkip, bfCutoffToSkip = bfCutoffToSkip, + logBfCutoffToSkip = logBfCutoffToSkip, seed = seed, naAction = naAction, verbose = verbose, dotArgs = dotArgs) .multiStudyPipelineDriver( data, jointResult, .fmPerStudy, .fmSumStats, cfg, diff --git a/R/manifestLoaders.R b/R/manifestLoaders.R index 8d1f55d4..c7ca8648 100644 --- a/R/manifestLoaders.R +++ b/R/manifestLoaders.R @@ -142,11 +142,14 @@ NULL # Turn an ldSketch specification into a GenotypeHandle. Accepts a prebuilt # handle, a named chrom -> path vector (genoMeta), or a single string that is # either a genotype file/prefix or a chrom-sharded genoMeta meta file. -.resolveLdSketch <- function(spec) { +# `chroms` (optional) restricts a chrom-sharded panel to those chromosomes so +# the other per-chromosome shards are never read; it is ignored for a single +# genotype file (which has no shards to skip). +.resolveLdSketch <- function(spec, chroms = NULL) { if (is.null(spec)) return(NULL) if (methods::is(spec, "GenotypeHandle")) return(spec) if (is.character(spec) && !is.null(names(spec))) { - return(GenotypeHandle(genoMeta = spec)) + return(GenotypeHandle(genoMeta = spec, chroms = chroms)) } if (is.character(spec) && length(spec) == 1L) { lower <- tolower(spec) @@ -156,13 +159,18 @@ NULL file.exists(paste0(spec, ".bed")) || file.exists(paste0(spec, ".pgen")) if (looksLikeGenotype) return(.detectGenotypeFormat(spec)) # Otherwise treat it as a chrom-sharded genoMeta meta file. - return(GenotypeHandle(genoMeta = spec)) + return(GenotypeHandle(genoMeta = spec, chroms = chroms)) } stop("`ldSketch` must be a GenotypeHandle, a genotype path/prefix, or a ", "genoMeta spec (named chrom->path vector or meta-file path).") } -# Resolve the LD sketch from the (argument, ldSketchPath column) pair. +# Resolve the LD sketch SPEC from the (argument, ldSketchPath column) pair, +# without reading any genotype metadata yet. Returns a prebuilt GenotypeHandle +# (when the caller passed one), or the reconciled spec (a path/prefix or a +# genoMeta path/named-vector) for later materialisation. Deferring the read to +# `.materializeLdSketch` lets the loader first learn which chromosomes the +# summary statistics cover and skip the panel's other per-chromosome shards. .resolveLdSketchInput <- function(df, ldSketch, base) { if (methods::is(ldSketch, "GenotypeHandle")) return(ldSketch) colSpec <- NULL @@ -181,7 +189,28 @@ NULL if (is.null(spec)) { stop("`ldSketch` must be provided as an argument or an `ldSketchPath` column.") } - .resolveLdSketch(spec) + spec +} + +# Materialise a resolved ldSketch spec into a GenotypeHandle, reading only the +# shards for `chroms` when the spec is a chrom-sharded panel. A spec that is +# already a GenotypeHandle (the caller passed a prebuilt handle) is returned +# unchanged: its snpInfo is already in memory, so there is no read to restrict. +.materializeLdSketch <- function(spec, chroms) { + if (is.null(spec)) return(NULL) + if (methods::is(spec, "GenotypeHandle")) return(spec) + .resolveLdSketch(spec, chroms = chroms) +} + +# Canonical chromosomes present across a list of sumstats entry GRanges. NULL / +# empty entries contribute nothing; NA seqnames are dropped. Always returns a +# character vector (character(0) when nothing is present, never NULL). +.entriesChroms <- function(entries) { + ch <- as.character(unlist(lapply(entries, function(gr) { + if (is.null(gr) || length(gr) == 0L) return(character(0)) + canonChrom(as.character(GenomicRanges::seqnames(gr))) + }), use.names = FALSE)) + unique(ch[!is.na(ch)]) } # ============================================================================= @@ -907,7 +936,7 @@ loadGwasSumStatsFromManifest <- function(manifest, genome = NULL, stop("GwasSumStats manifest `study` values must be unique.") } genome <- .reconcileScalar(df$genome, genome, "genome") - ldSketch <- .resolveLdSketchInput(df, ldSketch, base) + ldSketchSpec <- .resolveLdSketchInput(df, ldSketch, base) # Study-level sample-size scalars (from the manifest). When a row carries a # usable scalar (n_case + n_control, or n_sample), the sumstats file need not @@ -925,13 +954,21 @@ loadGwasSumStatsFromManifest <- function(manifest, genome = NULL, } else { columnMapping } - gr <- .loadSumStatsEntry(.resolveRel(as.character(df$sumStatsPath[[i]]), base), - region, mapping, sampleSelect, formatMapping, label, - allowNoN = .manifestHasStudyScalar(i, nCaseCol, nControlCol, nSampleCol)) - .checkLdContainment(ldSketch, gr, minLdOverlapWarn, label) - gr + .loadSumStatsEntry(.resolveRel(as.character(df$sumStatsPath[[i]]), base), + region, mapping, sampleSelect, formatMapping, label, + allowNoN = .manifestHasStudyScalar(i, nCaseCol, nControlCol, nSampleCol)) }) + # Materialise the LD sketch reading only the chromosomes the summary stats + # cover -- for a chrom-sharded panel this skips the shards for every other + # chromosome. Then run the per-study containment checks (deferred until the + # sketch exists). + ldSketch <- .materializeLdSketch(ldSketchSpec, .entriesChroms(entries)) + for (i in seq_len(nrow(df))) { + .checkLdContainment(ldSketch, entries[[i]], minLdOverlapWarn, + paste0("GwasSumStats[study=", df$study[[i]], "]")) + } + # Trim a genome-wide LD sketch to the summary stats' per-chromosome position # span so the object doesn't carry a full-genome snpInfo. ldSketch <- .subsetSketchToRange(ldSketch, entries) @@ -945,12 +982,13 @@ loadGwasSumStatsFromManifest <- function(manifest, genome = NULL, do.call(GwasSumStats, args) } -# Build the entry list + tuple vectors for a QtlSumStats manifest. `allowNoN` -# is an optional per-row logical vector: when TRUE for a row, its sumstats file -# need not carry a per-variant N (a study-level nSample scalar fills it later in -# summaryStatsQc). NULL (default) requires a per-variant N on every row. -.loadQtlSumStatsEntries <- function(df, base, region, ldSketch, - minLdOverlapWarn, columnMapping, +# Build the entry list for a QtlSumStats manifest. `allowNoN` is an optional +# per-row logical vector: when TRUE for a row, its sumstats file need not carry +# a per-variant N (a study-level nSample scalar fills it later in +# summaryStatsQc). NULL (default) requires a per-variant N on every row. The LD +# containment check is deferred to the caller so the sketch can first be read +# restricted to the chromosomes these entries cover. +.loadQtlSumStatsEntries <- function(df, base, region, columnMapping, sampleSelect, formatMapping, allowNoN = NULL) { lapply(seq_len(nrow(df)), function(i) { @@ -963,13 +1001,9 @@ loadGwasSumStatsFromManifest <- function(manifest, genome = NULL, } else { columnMapping } - gr <- .loadSumStatsEntry(.resolveRel(as.character(df$sumStatsPath[[i]]), base), - region, mapping, sampleSelect, formatMapping, label, - allowNoN = !is.null(allowNoN) && isTRUE(allowNoN[[i]])) - if (!is.null(ldSketch)) { - .checkLdContainment(ldSketch, gr, minLdOverlapWarn, label) - } - gr + .loadSumStatsEntry(.resolveRel(as.character(df$sumStatsPath[[i]]), base), + region, mapping, sampleSelect, formatMapping, label, + allowNoN = !is.null(allowNoN) && isTRUE(allowNoN[[i]])) }) } @@ -1008,7 +1042,7 @@ loadQtlSumStatsFromManifest <- function(manifest, genome = NULL, "sumStatsPath"), label = "QtlSumStats") genome <- .reconcileScalar(df$genome, genome, "genome") - ldSketch <- .resolveLdSketchInput(df, ldSketch, base) + ldSketchSpec <- .resolveLdSketchInput(df, ldSketch, base) # Tuple-level total-N scalar (from the manifest). When a row carries a usable # nSample the sumstats file need not supply a per-variant N: summaryStatsQc @@ -1016,10 +1050,21 @@ loadQtlSumStatsFromManifest <- function(manifest, genome = NULL, nSampleCol <- if ("nSample" %in% names(df)) as.numeric(df$nSample) else NULL allowNoN <- if (!is.null(nSampleCol)) is.finite(nSampleCol) else NULL - entries <- .loadQtlSumStatsEntries(df, base, region, ldSketch, - minLdOverlapWarn, columnMapping, + entries <- .loadQtlSumStatsEntries(df, base, region, columnMapping, sampleSelect, formatMapping, allowNoN = allowNoN) + + # Materialise the LD sketch reading only the chromosomes the summary stats + # cover (skips other shards of a chrom-sharded panel), then check containment. + ldSketch <- .materializeLdSketch(ldSketchSpec, .entriesChroms(entries)) + if (!is.null(ldSketch)) { + for (i in seq_len(nrow(df))) { + .checkLdContainment(ldSketch, entries[[i]], minLdOverlapWarn, + paste0("QtlSumStats[", df$study[[i]], "/", + df$context[[i]], "/", df$trait[[i]], "]")) + } + } + # Trim a genome-wide LD sketch to the summary stats' per-chromosome position # span so the object doesn't carry a full-genome snpInfo. ldSketch <- .subsetSketchToRange(ldSketch, entries) diff --git a/R/sumstatsQc.R b/R/sumstatsQc.R index c379042d..7c9b180b 100644 --- a/R/sumstatsQc.R +++ b/R/sumstatsQc.R @@ -2614,19 +2614,105 @@ krigingOutlierQc <- function(zScore, R, n, variantIds = NULL, diagnostics = diagnostics) } -# Per-entry SER-based pip-screen (skip if no signal above the cutoff). -.applyPipScreen <- function(df, n, cutoff) { - if (cutoff <= 0) return(list(df = df, skipped = FALSE)) - effectiveCutoff <- if (cutoff < 0) 3 / nrow(df) else cutoff - pip <- susieR::susie_ser(z = df$Z, n = n, coverage = NULL)$pip - if (!any(pip > effectiveCutoff)) { +# ----------------------------------------------------------------------------- +# Signal screen: skip an entry/block with no strong signal by a chosen metric. +# ----------------------------------------------------------------------------- +# The screen is driven by ONE metric at a time (enforced by .resolveScreenMetric): +# pip : max single-effect PIP (susie_ser $pip); cutoff<0 => 3/nVar +# absZ : max |Z| (no model fit) +# logBf : max per-variant single-effect logBF (susie_ser $lbf_variable) +# bf : same evidence on the raw BF scale (compare maxlogBF > log(cutoff)) +# A "screen spec" flowing through the pipelines is EITHER a legacy PIP cutoff +# (numeric scalar, 0 = off -- the historical `pipCutoffToSkip`) OR a resolved +# screen object `list(metric, cutoff)`. .asScreen() canonicalizes either into +# `list(metric, cutoff)` or NULL (no screen); the pipeline channels stay +# untyped so only the screen primitives need to interpret the spec. + +# Canonicalize a screen spec into list(metric, cutoff) or NULL (no screen). +.asScreen <- function(spec) { + if (is.null(spec)) return(NULL) + if (is.list(spec)) { # already a screen object + if (is.null(spec$metric) || is.null(spec$cutoff) || + is.na(spec$cutoff) || spec$cutoff == 0) return(NULL) + return(spec) + } + # Legacy numeric: a PIP cutoff. Only a non-zero scalar activates it (a + # non-scalar / NA / 0 means no screen), matching the historical behaviour. + if (length(spec) != 1L || is.na(spec) || spec == 0) return(NULL) + list(metric = "pip", cutoff = as.numeric(spec)) +} + +# Turn the four public cutoff arguments into a single screen object (or NULL). +# Enforces one-metric-at-a-time and rejects meaningless negative cutoffs. The +# pip metric keeps its `< 0 => 3 / nVariants` adaptive convention (resolved at +# screen time); absZ / bf must be > 0; logBf may be any non-zero value. +.resolveScreenMetric <- function(pipCutoffToSkip = 0, absZCutoffToSkip = 0, + bfCutoffToSkip = 0, logBfCutoffToSkip = 0) { + scalar <- function(x) if (is.null(x) || length(x) != 1L || is.na(x)) 0 else as.numeric(x) + cuts <- c(pip = scalar(pipCutoffToSkip), absZ = scalar(absZCutoffToSkip), + bf = scalar(bfCutoffToSkip), logBf = scalar(logBfCutoffToSkip)) + on <- cuts[cuts != 0] + if (length(on) == 0L) return(NULL) + if (length(on) > 1L) + stop("Only one signal screen may be enabled at a time, but these are ", + "non-zero: ", paste(sprintf("%s=%g", names(on), on), collapse = ", "), + ". Set all but one of pipCutoffToSkip / absZCutoffToSkip / ", + "bfCutoffToSkip / logBfCutoffToSkip to 0.") + metric <- names(on); cutoff <- unname(on[[1L]]) + if (metric == "absZ" && cutoff < 0) + stop("absZCutoffToSkip must be > 0 (it screens on max|Z|).") + if (metric == "bf" && cutoff < 0) + stop("bfCutoffToSkip must be > 0 (Bayes factors are positive).") + list(metric = metric, cutoff = cutoff) +} + +# Decide whether the z-scores of one entry clear the chosen screen. Returns +# list(ok = logical, reason = character). susie_ser is fit at most once, and +# only for the metrics that need it (absZ stays model-free). +.entryScreenPass <- function(z, n, nVar, scr) { + metric <- scr$metric; cutoff <- scr$cutoff + if (metric == "absZ") { + m <- suppressWarnings(max(abs(as.numeric(z)), na.rm = TRUE)) + return(list(ok = is.finite(m) && m > cutoff, + reason = sprintf("no variant with |Z| above %g (max |Z| = %g)", + cutoff, m))) + } + ser <- susieR::susie_ser(z = z, n = n, coverage = NULL) + if (metric == "pip") { + eff <- if (cutoff < 0) 3 / nVar else cutoff + return(list(ok = any(ser$pip > eff), + reason = sprintf("no signals above PIP threshold %g", eff))) + } + maxLbf <- suppressWarnings(max(as.numeric(ser$lbf_variable), na.rm = TRUE)) + if (metric == "logBf") + return(list(ok = is.finite(maxLbf) && maxLbf > cutoff, + reason = sprintf("no variant with logBF above %g (max logBF = %g)", + cutoff, maxLbf))) + # metric == "bf": compare in log space to avoid overflow of exp(maxLbf). + list(ok = is.finite(maxLbf) && maxLbf > log(cutoff), + reason = sprintf("no variant with BF above %g (max BF = %g)", + cutoff, exp(maxLbf))) +} + +# Per-entry signal screen. `screen` is a screen spec (see .asScreen). Skips +# (empties) the entry when the chosen metric shows no signal above its cutoff. +.applyEntryScreen <- function(df, n, screen) { + scr <- .asScreen(screen) + if (is.null(scr)) return(list(df = df, skipped = FALSE)) + res <- .entryScreenPass(df$Z, n = n, nVar = nrow(df), scr = scr) + if (!res$ok) { return(list(df = df[FALSE, , drop = FALSE], skipped = TRUE, - reason = sprintf("no signals above PIP threshold %g", - effectiveCutoff))) + reason = res$reason)) } list(df = df, skipped = FALSE) } +# Back-compat thin wrapper for the original PIP-only screen (a bare numeric +# cutoff, 0 = off). Delegates to the generalized screen via .asScreen. +.applyPipScreen <- function(df, n, cutoff) { + .applyEntryScreen(df, n = n, screen = cutoff) +} + # Prefix QC-track log lines with the entry label `lbl` (as `[lbl] ...`), or emit # them bare when `lbl` is NA. # @noRd @@ -2839,12 +2925,13 @@ krigingOutlierQc <- function(zScore, R, n, variantIds = NULL, " variant(s).") } - # 5. Optional PIP screen (after harmonization, on panel-aligned variants). - if (opts$pipCutoffToSkip != 0) { - pip <- .applyPipScreen(df, n = opts$nForPip, cutoff = opts$pipCutoffToSkip) - df <- pip$df - entryAudit$pipScreenSkipped <- isTRUE(pip$skipped) - if (isTRUE(pip$skipped)) entryAudit$pipScreenReason <- pip$reason + # 5. Optional signal screen (after harmonization, on panel-aligned variants). + # One metric at a time: PIP / max|Z| / max BF / max logBF (opts$screen). + if (!is.null(opts$screen)) { + scr <- .applyEntryScreen(df, n = opts$nForPip, screen = opts$screen) + df <- scr$df + entryAudit$pipScreenSkipped <- isTRUE(scr$skipped) + if (isTRUE(scr$skipped)) entryAudit$pipScreenReason <- scr$reason } # 6. Optional kriging allele-flip QC. Uses susieR's allele-switch rule @@ -3140,6 +3227,20 @@ krigingOutlierQc <- function(zScore, R, n, variantIds = NULL, #' LD-independent single-effect SER screen and skip the entry if no #' PIP exceeds the cutoff. \code{< 0} resolves to \code{3 / nVariants}. #' Default 0 (no screen). +#' @param absZCutoffToSkip Numeric (length 1). Alternative signal screen: +#' skip the entry when \code{max(abs(Z))} does not exceed the cutoff. No +#' model fit. Default 0 (off). +#' @param bfCutoffToSkip Numeric (length 1). Alternative signal screen: skip +#' the entry when the largest per-variant single-effect Bayes factor (from +#' the same \code{susie_ser} fit as the PIP screen) does not exceed the +#' cutoff. Compared in log space (\code{maxlogBF > log(cutoff)}). Must be +#' \code{> 0}. Default 0 (off). +#' @param logBfCutoffToSkip Numeric (length 1). As \code{bfCutoffToSkip} but +#' the cutoff is on the log Bayes factor scale (\code{maxlogBF > cutoff}). +#' Default 0 (off). +#' Exactly one of \code{pipCutoffToSkip} / \code{absZCutoffToSkip} / +#' \code{bfCutoffToSkip} / \code{logBfCutoffToSkip} may be non-zero (one +#' screening metric at a time); enabling more than one is an error. #' @param zMismatchQc One of \code{"none"} (default), \code{"slalom"}, #' \code{"dentist"}. #' @param alleleFlipKriging Logical (length 1). Opt-in kriging @@ -3205,6 +3306,9 @@ summaryStatsQc <- function(sumstats, keepVariants = NULL, skipRegion = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, zMismatchQc = c("none", "slalom", "dentist"), alleleFlipKriging = FALSE, @@ -3250,7 +3354,10 @@ summaryStatsQc <- function(sumstats, nCutoff = nCutoff, keepVariants = as.character(keepVariants), skipRegion = skipRegion, - pipCutoffToSkip = pipCutoffToSkip, + screen = .resolveScreenMetric(pipCutoffToSkip, + absZCutoffToSkip, + bfCutoffToSkip, + logBfCutoffToSkip), zMismatchQc = zMismatchQc, alleleFlipKriging = alleleFlipKriging, effectiveN = effectiveN, @@ -3312,6 +3419,10 @@ summaryStatsQc <- function(sumstats, mafCutoff = mafCutoff, infoCutoff = infoCutoff, nCutoff = nCutoff, + pipCutoffToSkip = pipCutoffToSkip, + absZCutoffToSkip = absZCutoffToSkip, + bfCutoffToSkip = bfCutoffToSkip, + logBfCutoffToSkip = logBfCutoffToSkip, zMismatchQc = zMismatchQc, alleleFlipKriging = alleleFlipKriging, effectiveN = effectiveN, diff --git a/man/GenotypeHandle.Rd b/man/GenotypeHandle.Rd index c6fe0d8f..30f47cab 100644 --- a/man/GenotypeHandle.Rd +++ b/man/GenotypeHandle.Rd @@ -17,6 +17,7 @@ GenotypeHandle( ldMeta = NULL, region = NULL, genoMeta = NULL, + chroms = NULL, ... ) } @@ -51,6 +52,17 @@ supported here — use \code{\link{loadLdMatrix}} for that case.} (names = chromosomes, values = payload paths/prefixes). Optionally pass \code{format} via \code{...} to force a single backend for every shard.} +\item{chroms}{Optional character vector of chromosomes. With \code{genoMeta} +(a sharded, one-file-per-chromosome panel) only the shards whose +chromosome is listed are read, skipping the rest -- an I/O optimisation +when the panel is genome-wide but only a few chromosomes are needed. +Chromosome labels are compared canonically (\code{"chr1"}/\code{"1"} match, +\code{23}/\code{X} etc.). If none of the requested chromosomes are present +in the panel, every shard is read (so the caller's own absence check can +report the mismatch). Only meaningful with \code{genoMeta}; supplying it +with any other source is an error (a single-file panel has no per-chromosome +shards to skip).} + \item{...}{Additional arguments forwarded to the format-specific reader.} } \value{ diff --git a/man/colocboostPipeline.Rd b/man/colocboostPipeline.Rd index b8ede09a..51ae8374 100644 --- a/man/colocboostPipeline.Rd +++ b/man/colocboostPipeline.Rd @@ -23,6 +23,9 @@ colocboostPipeline(qtlData, gwasSumStats = NULL, ...) separateGwas = FALSE, samples = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, alleleFlip = TRUE, ... ) @@ -55,6 +58,9 @@ colocboostPipeline(qtlData, gwasSumStats = NULL, ...) separateGwas = FALSE, samples = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, alleleFlip = TRUE, ... ) @@ -106,6 +112,15 @@ skipped. \code{0} (default) disables it; a negative value uses \code{3 / n_variants}. (Summary-statistic skipping is handled upstream by \code{\link{summaryStatsQc}}'s own \code{pipCutoffToSkip}.)} +\item{absZCutoffToSkip, bfCutoffToSkip, logBfCutoffToSkip}{Alternative +individual-level pre-filter metrics used in place of +\code{pipCutoffToSkip}: drop an outcome unless its maximum marginal +\code{|z|} (\code{absZCutoffToSkip}), or its maximum per-variant +single-effect Bayes factor (\code{bfCutoffToSkip}) / log Bayes factor +(\code{logBfCutoffToSkip}) from the \code{L = 1} fit, exceeds the cutoff. +Scalars, each defaulting to 0 (off). Exactly one screening metric may be +enabled: setting any of these requires \code{pipCutoffToSkip = 0}.} + \item{alleleFlip}{Logical, default \code{TRUE}. When TRUE, harmonize variants across the individual X, sumstats, and LD by (chrom, pos) with ref/alt swaps recognized (flipping z / residualized dosage / LD to a shared diff --git a/man/fineMappingPipeline.Rd b/man/fineMappingPipeline.Rd index a65185f7..02db1a6c 100644 --- a/man/fineMappingPipeline.Rd +++ b/man/fineMappingPipeline.Rd @@ -40,6 +40,9 @@ fineMappingPipeline(data, ...) cvThreads = 1, samplePartition = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, usePCA = FALSE, nPCs = 10L, seed = NULL, @@ -80,6 +83,9 @@ fineMappingPipeline(data, ...) cvThreads = 1, samplePartition = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, seed = NULL, naAction = c("drop", "impute"), verbose = 1, @@ -261,6 +267,15 @@ summary-statistics analog lives in \code{summaryStatsQc()}. \code{0} (default) disables the screen; a negative value uses the adaptive \code{3 / nVariants} threshold.} +\item{absZCutoffToSkip, bfCutoffToSkip, logBfCutoffToSkip}{Numeric (length 1). +Alternative individual-level pre-screen metrics, in place of +\code{pipCutoffToSkip}: skip a block unless its maximum marginal +\code{|z|} (\code{absZCutoffToSkip}), or its maximum per-variant +single-effect Bayes factor (\code{bfCutoffToSkip}) / log Bayes factor +(\code{logBfCutoffToSkip}) from the \code{L = 1} fit, exceeds the cutoff. +Each defaults to 0 (off). Exactly one of the four \code{*CutoffToSkip} +arguments may be non-zero (one screening metric at a time).} + \item{usePCA}{Logical (length 1). \code{QtlDataset} only. When \code{TRUE} (default \code{FALSE}), each multi-trait context's PCA-reduced phenotype is fine-mapped with univariate SuSiE on its diff --git a/man/summaryStatsQc.Rd b/man/summaryStatsQc.Rd index baf8a069..0a562106 100644 --- a/man/summaryStatsQc.Rd +++ b/man/summaryStatsQc.Rd @@ -14,6 +14,9 @@ summaryStatsQc( keepVariants = NULL, skipRegion = NULL, pipCutoffToSkip = 0, + absZCutoffToSkip = 0, + bfCutoffToSkip = 0, + logBfCutoffToSkip = 0, zMismatchQc = c("none", "slalom", "dentist"), alleleFlipKriging = FALSE, effectiveN = TRUE, @@ -64,6 +67,23 @@ LD-independent single-effect SER screen and skip the entry if no PIP exceeds the cutoff. \code{< 0} resolves to \code{3 / nVariants}. Default 0 (no screen).} +\item{absZCutoffToSkip}{Numeric (length 1). Alternative signal screen: +skip the entry when \code{max(abs(Z))} does not exceed the cutoff. No +model fit. Default 0 (off).} + +\item{bfCutoffToSkip}{Numeric (length 1). Alternative signal screen: skip +the entry when the largest per-variant single-effect Bayes factor (from +the same \code{susie_ser} fit as the PIP screen) does not exceed the +cutoff. Compared in log space (\code{maxlogBF > log(cutoff)}). Must be +\code{> 0}. Default 0 (off).} + +\item{logBfCutoffToSkip}{Numeric (length 1). As \code{bfCutoffToSkip} but +the cutoff is on the log Bayes factor scale (\code{maxlogBF > cutoff}). +Default 0 (off). +Exactly one of \code{pipCutoffToSkip} / \code{absZCutoffToSkip} / +\code{bfCutoffToSkip} / \code{logBfCutoffToSkip} may be non-zero (one +screening metric at a time); enabling more than one is an error.} + \item{zMismatchQc}{One of \code{"none"} (default), \code{"slalom"}, \code{"dentist"}.} diff --git a/tests/testthat/test_colocboostPipeline.R b/tests/testthat/test_colocboostPipeline.R index 40408dde..63c31123 100644 --- a/tests/testthat/test_colocboostPipeline.R +++ b/tests/testthat/test_colocboostPipeline.R @@ -220,6 +220,32 @@ test_that(".cbBuildLdArgs: empty list returns empty list", { expect_equal(pecotmr:::.cbBuildLdArgs(list()), list()) }) +test_that(".cbScreenSpec enforces one metric and threads the right spec", { + expect_error(pecotmr:::.cbScreenSpec(0.5, 5, 0, 0), "one signal screen") + expect_equal(pecotmr:::.cbScreenSpec(0, 5, 0, 0), list(metric = "absZ", cutoff = 5)) + expect_equal(pecotmr:::.cbScreenSpec(0, 0, 100, 0), list(metric = "bf", cutoff = 100)) + expect_equal(pecotmr:::.cbScreenSpec(0.3, 0, 0, 0), 0.3) # legacy pip scalar + expect_equal(pecotmr:::.cbScreenSpec(c(brain = 0.3), 0, 0, 0), + c(brain = 0.3)) # context-named pass-through +}) + +test_that(".cbResolveCutoff passes a screen object through uniformly", { + sc <- list(metric = "absZ", cutoff = 5) + expect_identical(pecotmr:::.cbResolveCutoff(sc, "brain"), sc) + expect_identical(pecotmr:::.cbResolveCutoff(sc, "blood"), sc) + expect_equal(pecotmr:::.cbResolveCutoff(c(brain = 0.3), "brain"), 0.3) + expect_equal(pecotmr:::.cbResolveCutoff(c(brain = 0.3), "blood"), 0) # unlisted ctx + expect_equal(pecotmr:::.cbResolveCutoff(0.5, "any"), 0.5) +}) + +test_that("colocboostPipeline(QtlDataset): enabling two screen metrics errors", { + qd <- .cbp_makeQtlDataset(contexts = "brain", traits = "ENSG_A") + # .cbScreenSpec runs before the bundle/engine, so this fails fast. + expect_error( + colocboostPipeline(qd, pipCutoffToSkip = 0.5, bfCutoffToSkip = 100), + "one signal screen") +}) + test_that(".cbRequireSumStatsQc: un-QCd input errors", { ss <- .cbp_makeQtlSumStats(qc = FALSE) expect_error( @@ -564,7 +590,7 @@ test_that(".cbPipSkipOutcomes: an outcome with < 2 observations is skipped", { set.seed(4) X <- matrix(rnorm(60), 30, 2, dimnames = list(paste0("s", 1:30), c("v1", "v2"))) Y <- cbind(a = c(1, rep(NA, 29)), b = rnorm(30)) # col a: 1 obs (< 2) - res <- pecotmr:::.cbPipSkipOutcomes(X, Y, cutoff = 0.5) + res <- pecotmr:::.cbPipSkipOutcomes(X, Y, 0.5) expect_true(is.null(res) || is.matrix(res)) # col a -> next (180) }) diff --git a/tests/testthat/test_ctwasPipeline.R b/tests/testthat/test_ctwasPipeline.R index 4d71a90b..6ebacacb 100644 --- a/tests/testthat/test_ctwasPipeline.R +++ b/tests/testthat/test_ctwasPipeline.R @@ -1267,7 +1267,8 @@ test_that("estCtwasParam: fallbackToPrefit recovers from accurate-EM NaN diverge # produce a stub prefit result. Verify estCtwasParam catches the # NaN error AND that the returned param is the prefit estimate. local_mocked_bindings( - assemble_region_data = function(...) list(block1 = list(stub = TRUE)), + assemble_region_data = function(...) list(block1 = list( + gid = "t1", sid = c("s1", "s2"), stub = TRUE)), get_boundary_genes = function(...) data.frame(id = "t1", n_regions = 2L), compute_gene_z = function(...) data.frame(id = "t1", z = 1.0), est_param = function(...) stop("Estimated group_prior_var contains NAs!"), @@ -1295,6 +1296,61 @@ test_that("estCtwasParam: fallbackToPrefit recovers from accurate-EM NaN diverge expect_equal(unname(est$param$group_prior_var), c(4.0, 5.0)) }) +test_that("estCtwasParam fallback drops degenerate regions before fit_EM", { + # Regression for the ctwas >= 0.6.0 breakage: the prefit fallback used to hand + # ALL regions to ctwas::fit_EM, so a degenerate region (empty gid/sid, whose + # `sid` ctwas::extract_region_data now requires) crashed with + # "regiondata$sid ... target is NULL". The fallback must mirror est_param's + # min_var / min_gene skip and fit only the qualifying regions. + skip_if_not_installed("ctwas") + inp <- .ctp_makeMultiBlockInputs() + seen <- NULL + local_mocked_bindings( + assemble_region_data = function(...) list( + good = list(gid = "t1", sid = c("s1", "s2")), # 1 gene + 2 SNPs -> kept + degenerate = list(gid = character(0), sid = NULL)), # no variables -> dropped + get_boundary_genes = function(...) data.frame(id = "t1", n_regions = 2L), + compute_gene_z = function(...) data.frame(id = "t1", z = 1.0), + est_param = function(...) stop("No regions selected!"), + fit_EM = function(region_data, ...) { + seen <<- names(region_data) + list(group_prior = c(g = 0.05, SNP = 1e-4), + group_prior_var = c(g = 4.0, SNP = 5.0), + group_size = c(g = 1, SNP = 100)) + }, + .package = "ctwas") + local_mocked_bindings(extractBlockGenotypes = .ctp_mockExtractor(), + .package = "pecotmr") + est <- estCtwasParam( + assembleCtwasInputs(inp$gwasSumStats, inp$twasWeights), + fallbackToPrefit = TRUE) + # only the qualifying region reached fit_EM; the degenerate region was filtered + expect_equal(seen, "good") + # ...but every region is still accounted for in the returned p_single_effect + expect_setequal(est$param$p_single_effect$region_id, c("good", "degenerate")) +}) + +test_that("(real ctwas) prefit fallback skips a degenerate region fit_EM would reject", { + # No-mock guard for the ctwas >= 0.6.0 contract. Runs the REAL ctwas::fit_EM + # (via .ctwasFitPrefitEm) on a genuine assemble_region_data fixture with one + # valid region (1 gene, 108 SNPs) and one degenerate region (0 genes/SNPs, + # unset `sid`). Handing the degenerate region to fit_EM crashes ctwas >= 0.6.0 + # in extract_region_data ("regiondata$sid ... target is NULL"); the fallback + # must filter it. Unlike the mocked tests above, this exercises the real engine, + # so it would catch a FUTURE ctwas contract change (which the mocks cannot). + skip_if_not_installed("ctwas") + region_data <- readRDS(test_path("test_data", "ctwas_region_data_degenerate.rds")) + expect_length(region_data, 2L) + res <- .ctwasFitPrefitEm( + region_data, niterPrefit = 3L, + groupPriorVarStructure = "shared_all", thin = 1, ncore = 1L) + # the prefit EM ran on the valid region only and returns finite real group priors + expect_true("SNP" %in% names(res$group_prior)) + expect_true(all(is.finite(res$group_prior))) + # every region (valid + degenerate) is still listed in p_single_effect + expect_setequal(res$p_single_effect$region_id, names(region_data)) +}) + test_that("estCtwasParam / screenCtwasRegions / finemapCtwasRegions can be called independently", { skip_if_not_installed("ctwas") inp <- .ctp_makeMultiBlockInputs() diff --git a/tests/testthat/test_data/ctwas_region_data_degenerate.rds b/tests/testthat/test_data/ctwas_region_data_degenerate.rds new file mode 100644 index 00000000..bd5bd2c6 Binary files /dev/null and b/tests/testthat/test_data/ctwas_region_data_degenerate.rds differ diff --git a/tests/testthat/test_fineMappingPipeline.R b/tests/testthat/test_fineMappingPipeline.R index 2504c735..bead0c56 100644 --- a/tests/testthat/test_fineMappingPipeline.R +++ b/tests/testthat/test_fineMappingPipeline.R @@ -846,6 +846,38 @@ test_that(".fmBuildMvsusiePriorCv: mode C reuses full-fit w0/V with per-fold U", expect_equal(captured[[2]]$mixture_prior$matrices$Z, diag(2) * 3) # fold 2's U }) +test_that(".fmSerScreen supports absZ / bf / logBf metrics and the legacy pip scalar", { + skip_if_not_installed("susieR") + set.seed(11) + n <- 200L; p <- 6L + X <- matrix(stats::rnorm(n * p), n, p) + yStrong <- X[, 2] * 0.6 + stats::rnorm(n) # column 2 strongly associated + yNull <- stats::rnorm(n) # no association + + # absZ: max marginal |z| (no susie fit). + expect_true(pecotmr:::.fmSerScreen(X, yStrong, list(metric = "absZ", cutoff = 3))) + expect_false(pecotmr:::.fmSerScreen(X, yNull, list(metric = "absZ", cutoff = 3))) + # bf / logBf from the L = 1 susie lbf_variable. + expect_true(pecotmr:::.fmSerScreen(X, yStrong, list(metric = "logBf", cutoff = 2))) + expect_false(pecotmr:::.fmSerScreen(X, yNull, list(metric = "logBf", cutoff = 5))) + expect_true(pecotmr:::.fmSerScreen(X, yStrong, list(metric = "bf", cutoff = 10))) + # Legacy scalar spec still screens on PIP; 0 disables (always keep). + expect_true(pecotmr:::.fmSerScreen(X, yStrong, 0.5)) + expect_true(pecotmr:::.fmSerScreen(X, yNull, 0)) + # Too few samples -> advisory fallback (keep by default). + expect_true(pecotmr:::.fmSerScreen(X[1, , drop = FALSE], yStrong[1], + list(metric = "absZ", cutoff = 3))) +}) + +test_that("fineMappingPipeline(QtlDataset): enabling two screen metrics errors", { + qd <- .fmp_makeQtlDataset(contexts = "brain", traits = "ENSG_A") + # The resolver runs before any fitting, so this fails fast on the public call. + expect_error( + fineMappingPipeline(qd, methods = "susie", cisWindow = 1000L, + pipCutoffToSkip = 0.5, absZCutoffToSkip = 5), + "one signal screen") +}) + test_that("fineMappingPipeline(QtlDataset): pipCutoffToSkip skips no-signal univariate traits", { qd <- .fmp_makeQtlDataset(contexts = "brain", traits = c("ENSG_A", "ENSG_B")) # Stateful screen: reject the first block (ENSG_A), keep the rest (ENSG_B). @@ -2659,7 +2691,7 @@ test_that(".fmSerScreen / .fmScreenActive / .fmSerScreenColumns", { set.seed(2) X <- matrix(rnorm(40), 20, 2, dimnames = list(paste0("s", 1:20), c("v1", "v2"))) y <- rnorm(20) - expect_true(pecotmr:::.fmSerScreen(X, y, cutoff = 0)) # disabled + expect_true(pecotmr:::.fmSerScreen(X, y, 0)) # disabled expect_true(pecotmr:::.fmSerScreen(X, c(1, rep(NA, 19)), 0.5)) # < 2 obs (880) expect_type(pecotmr:::.fmSerScreen(X, y, 0.5), "logical") # real susie fit local_mocked_bindings(susie = function(...) stop("boom"), .package = "susieR") diff --git a/tests/testthat/test_genotypeHandle.R b/tests/testthat/test_genotypeHandle.R index 1482f048..3a56852f 100644 --- a/tests/testthat/test_genotypeHandle.R +++ b/tests/testthat/test_genotypeHandle.R @@ -428,6 +428,62 @@ test_that("genoMeta sharded handle show() reports the layout", { expect_match(out, "per-chromosome files") }) +# =========================================================================== +# genoMeta `chroms`: read only the requested per-chromosome shards (I/O win). +# =========================================================================== +test_that("genoMeta chroms reads only the requested shard", { + skip_if_not_installed("snpStats") + meta <- c("21" = file.path(test_data_dir, "test_variants"), + "22" = file.path(test_data_dir, "test_variants_chr22")) + full <- GenotypeHandle(genoMeta = meta) + only21 <- GenotypeHandle(genoMeta = meta, chroms = "21") + expect_equal(names(only21@chromPaths), "21") + expect_equal(nrow(only21@snpInfo), 349L) + # chr21 is the first shard, so the kept rows/fileIdx match the full handle's. + expect_equal(only21@snpInfo$SNP, full@snpInfo$SNP[1:349]) + expect_equal(only21@snpInfo$fileIdx, full@snpInfo$fileIdx[1:349]) +}) + +test_that("genoMeta chroms canonicalises chromosome labels", { + skip_if_not_installed("snpStats") + # "chr21" canonicalises to the panel's declared "21" shard. + h <- GenotypeHandle( + genoMeta = c("21" = file.path(test_data_dir, "test_variants"), + "22" = file.path(test_data_dir, "test_variants_chr22")), + chroms = "chr21") + expect_equal(names(h@chromPaths), "21") +}) + +test_that("genoMeta chroms falls back to all shards when none match", { + skip_if_not_installed("snpStats") + # chr9 is absent from the panel: rather than build an empty handle, read all + # so the caller's own absence check can report the mismatch. + h <- GenotypeHandle( + genoMeta = c("21" = file.path(test_data_dir, "test_variants"), + "22" = file.path(test_data_dir, "test_variants_chr22")), + chroms = "9") + expect_equal(sort(names(h@chromPaths)), c("21", "22")) +}) + +test_that("genoMeta chroms skips a shard whose file does not exist", { + skip_if_not_installed("snpStats") + # A bogus chr21 payload proves the skip: restricting to chr22 must never open + # it, while requesting chr21 too fails on the missing files. + meta <- c("22" = file.path(test_data_dir, "test_variants_chr22"), + "21" = "/no/such/chr21/prefix") + h <- GenotypeHandle(genoMeta = meta, chroms = "22") + expect_equal(names(h@chromPaths), "22") + expect_error(GenotypeHandle(genoMeta = meta, chroms = c("22", "21"))) +}) + +test_that("chroms is rejected for a non-genoMeta source", { + skip_if_not_installed("snpStats") + expect_error( + GenotypeHandle(plink1Prefix = file.path(test_data_dir, "test_variants"), + chroms = "21"), + "only supported with .genoMeta") +}) + # =========================================================================== # .genotypeHandleFromLdMeta: row-resolution error branches. # The real getRegionalLdMeta errors earlier for genuinely-uncovered regions diff --git a/tests/testthat/test_manifestLoaders.R b/tests/testthat/test_manifestLoaders.R index 98abd9e3..108e1e8c 100644 --- a/tests/testthat/test_manifestLoaders.R +++ b/tests/testthat/test_manifestLoaders.R @@ -545,6 +545,43 @@ test_that(".resolveLdSketch accepts a genoMeta vector, a path, and rejects bad i expect_error(pecotmr:::.resolveLdSketch(42L), "must be a GenotypeHandle") }) +test_that(".entriesChroms collects canonical chromosomes across entries", { + a <- pecotmr:::.dfToEntryGranges(.toyGwasDf(3)) # chr22 + b <- .toyGwasDf(2); b$chrom <- "1" + b <- pecotmr:::.dfToEntryGranges(b) + expect_setequal(pecotmr:::.entriesChroms(list(a, b)), c("22", "1")) + expect_equal(pecotmr:::.entriesChroms(list()), character(0)) + expect_equal(pecotmr:::.entriesChroms(list(NULL)), character(0)) +}) + +test_that(".materializeLdSketch passes a handle through and restricts spec shards", { + h <- .toyLdSketch() + expect_identical(pecotmr:::.materializeLdSketch(h, "22"), h) # already in memory + expect_null(pecotmr:::.materializeLdSketch(NULL, "22")) + # A chrom-sharded spec with a bogus chr21 payload: restricting to chr22 must + # skip (never open) the chr21 shard; requesting chr21 too fails on it. + spec <- c("22" = .toyRefPrefix(), "21" = "/no/such/chr21/prefix") + restricted <- pecotmr:::.materializeLdSketch(spec, "22") + expect_s4_class(restricted, "GenotypeHandle") + expect_equal(names(restricted@chromPaths), "22") + expect_error(pecotmr:::.materializeLdSketch(spec, c("22", "21"))) +}) + +test_that("loadGwasSumStatsFromManifest reads only the sumstats chromosomes' shards", { + skip_if_not_installed("snpStats") + tmp <- withr::local_tempdir() + ssPath <- .writeSumstatsTsv(.toyGwasDf(5), file.path(tmp, "study1.tsv")) + manifest <- data.frame(study = "study1", sumStatsPath = ssPath, + stringsAsFactors = FALSE) + # Sharded ldSketch: chr22 real (toy_ref), chr21 a bogus path. The sumstats are + # all chr22, so the loader must skip the chr21 shard -- opening it would fail. + obj <- loadGwasSumStatsFromManifest( + manifest, genome = "hg38", + ldSketch = c("22" = .toyRefPrefix(), "21" = "/no/such/chr21/prefix")) + expect_s4_class(obj, "GwasSumStats") + expect_true(methods::validObject(obj)) +}) + test_that("ldSketch is resolved from an ldSketchPath column and conflicts error", { tmp <- withr::local_tempdir() ssPath <- .writeSumstatsTsv(.toyGwasDf(5), file.path(tmp, "s.tsv")) diff --git a/tests/testthat/test_sumstatsQc.R b/tests/testthat/test_sumstatsQc.R index 93dc2c4d..4b9d37d2 100644 --- a/tests/testthat/test_sumstatsQc.R +++ b/tests/testthat/test_sumstatsQc.R @@ -3601,6 +3601,100 @@ test_that(".applyPipScreen: retains entry when signal clears the cutoff", { expect_identical(out$df, df) }) +# =========================================================================== +# Signal screen: metric resolver + absZ / bf / logBf metrics +# =========================================================================== + +test_that(".resolveScreenMetric enforces one metric at a time and sane cutoffs", { + expect_null(pecotmr:::.resolveScreenMetric()) # all 0 -> off + expect_equal(pecotmr:::.resolveScreenMetric(pipCutoffToSkip = 0.5), + list(metric = "pip", cutoff = 0.5)) + expect_equal(pecotmr:::.resolveScreenMetric(absZCutoffToSkip = 5), + list(metric = "absZ", cutoff = 5)) + expect_equal(pecotmr:::.resolveScreenMetric(bfCutoffToSkip = 100), + list(metric = "bf", cutoff = 100)) + expect_equal(pecotmr:::.resolveScreenMetric(logBfCutoffToSkip = 3), + list(metric = "logBf", cutoff = 3)) + expect_error(pecotmr:::.resolveScreenMetric(pipCutoffToSkip = 0.5, + absZCutoffToSkip = 5), + "one signal screen") + expect_error(pecotmr:::.resolveScreenMetric(absZCutoffToSkip = -1), "must be > 0") + expect_error(pecotmr:::.resolveScreenMetric(bfCutoffToSkip = -1), "must be > 0") +}) + +test_that(".asScreen canonicalizes screen specs", { + expect_null(pecotmr:::.asScreen(NULL)) + expect_null(pecotmr:::.asScreen(0)) + expect_null(pecotmr:::.asScreen(c(0.1, 0.2))) # non-scalar numeric -> off + expect_equal(pecotmr:::.asScreen(0.9), list(metric = "pip", cutoff = 0.9)) + sc <- list(metric = "logBf", cutoff = 3) + expect_identical(pecotmr:::.asScreen(sc), sc) + expect_null(pecotmr:::.asScreen(list(metric = "bf", cutoff = 0))) # explicit off +}) + +test_that(".applyEntryScreen: absZ screen skips / retains on max|Z| (no model fit)", { + weak <- data.frame(Z = c(0.2, 0.3, 0.1), stringsAsFactors = FALSE) + out <- pecotmr:::.applyEntryScreen(weak, n = 1000, + screen = list(metric = "absZ", cutoff = 5)) + expect_true(out$skipped) + expect_match(out$reason, "|Z| above 5", fixed = TRUE) + expect_equal(nrow(out$df), 0L) + + strong <- data.frame(Z = c(0.2, 6, 0.1), stringsAsFactors = FALSE) + out2 <- pecotmr:::.applyEntryScreen(strong, n = 1000, + screen = list(metric = "absZ", cutoff = 5)) + expect_false(out2$skipped) + expect_identical(out2$df, strong) +}) + +test_that(".applyEntryScreen: bf / logBf screens use the SER lbf_variable", { + weak <- data.frame(Z = rep(0.1, 10), stringsAsFactors = FALSE) + strong <- data.frame(Z = c(10, 0.1, 0.1, 0.1, 0.1), stringsAsFactors = FALSE) + expect_true(pecotmr:::.applyEntryScreen(weak, 1000, + list(metric = "logBf", cutoff = 3))$skipped) + expect_false(pecotmr:::.applyEntryScreen(strong, 1000, + list(metric = "logBf", cutoff = 3))$skipped) + expect_true(pecotmr:::.applyEntryScreen(weak, 1000, + list(metric = "bf", cutoff = 100))$skipped) + expect_false(pecotmr:::.applyEntryScreen(strong, 1000, + list(metric = "bf", cutoff = 100))$skipped) +}) + +test_that("summaryStatsQc: absZ / bf / logBf screens skip a no-signal entry", { + mk <- function() { + gr <- .ssQ_makeEntryGr() + S4Vectors::mcols(gr)$Z <- rep(0.1, length(gr)) + GwasSumStats(study = "g1", entry = list(gr), genome = "hg19", + ldSketch = .ssQ_makeHandle()) + } + for (arg in list(list(absZCutoffToSkip = 5), + list(bfCutoffToSkip = 100), + list(logBfCutoffToSkip = 5))) { + res <- do.call(summaryStatsQc, c(list(mk()), arg, list(nCutoff = 0))) + ea <- getQcInfo(res)$entryAudit[[1L]] + expect_true(isTRUE(ea$pipScreenSkipped)) + expect_equal(length(res$entry[[1L]]), 0L) + } +}) + +test_that("summaryStatsQc: absZ screen retains an entry with a strong marginal Z", { + gr <- .ssQ_makeEntryGr() + z <- rep(0.1, length(gr)); z[1] <- 8 + S4Vectors::mcols(gr)$Z <- z + ss <- GwasSumStats(study = "g1", entry = list(gr), genome = "hg19", + ldSketch = .ssQ_makeHandle()) + res <- summaryStatsQc(ss, absZCutoffToSkip = 5, nCutoff = 0) + ea <- getQcInfo(res)$entryAudit[[1L]] + expect_false(isTRUE(ea$pipScreenSkipped)) + expect_gt(length(res$entry[[1L]]), 0L) +}) + +test_that("summaryStatsQc: enabling two screens at once errors", { + ss <- .ssQ_makeGwasSumStats() + expect_error(summaryStatsQc(ss, pipCutoffToSkip = 0.5, absZCutoffToSkip = 5), + "one signal screen") +}) + context("dentist_qc") library(MASS)