Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
^\.github$
^\.claude$
^www$
^codecov\.yml$
^.*\.Rproj$
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ jobs:

- name: Add GSVA repo
run: Rscript -e 'remotes::install_github("rcastelo/GSVA@c9fb985eb555f06a4195260b902747fe0a1ade9f")'


# plaid is Bioconductor 3.23 (devel) only, so BiocManager will not resolve
# it on the R release runner. Fall back to source so the plaid backend
# tests execute rather than skip. Note the GitHub HEAD may lag Bioconductor.
- name: Install plaid
run: Rscript -e 'if (!requireNamespace("plaid", quietly = TRUE)) tryCatch(BiocManager::install("plaid", ask = FALSE, update = FALSE), error = function(e) NULL)' -e 'if (!requireNamespace("plaid", quietly = TRUE)) remotes::install_github("bigomics/plaid", upgrade = "never")'

- name: Install SpatialExperiment
run: Rscript -e 'if (!requireNamespace("SpatialExperiment", quietly = TRUE)) BiocManager::install("SpatialExperiment", ask = FALSE, update = FALSE)'


- uses: r-lib/actions/check-r-package@v2
with:
upload-snapshots: true
14 changes: 13 additions & 1 deletion .github/workflows/test-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ jobs:

- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::covr
extra-packages: any::covr, any::remotes, any::BiocManager
needs: coverage

- name: Add GSVA repo
run: Rscript -e 'remotes::install_github("rcastelo/GSVA@c9fb985eb555f06a4195260b902747fe0a1ade9f")'

# plaid is Bioconductor 3.23 (devel) only, so BiocManager will not resolve
# it on the R release runner. Fall back to source so the plaid backend
# tests execute rather than skip. Note the GitHub HEAD may lag Bioconductor.
- name: Install plaid
run: Rscript -e 'if (!requireNamespace("plaid", quietly = TRUE)) tryCatch(BiocManager::install("plaid", ask = FALSE, update = FALSE), error = function(e) NULL)' -e 'if (!requireNamespace("plaid", quietly = TRUE)) remotes::install_github("bigomics/plaid", upgrade = "never")'

- name: Install SpatialExperiment
run: Rscript -e 'if (!requireNamespace("SpatialExperiment", quietly = TRUE)) BiocManager::install("SpatialExperiment", ask = FALSE, update = FALSE)'

# This step is corrected to use the right functions
- name: Test coverage and generate report
run: |
Expand Down
14 changes: 9 additions & 5 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
Package: escape
Title: Easy single cell analysis platform for enrichment
Version: 2.7.3
Title: Easy single-cell analysis platform for enrichment
Version: 2.9.1
Authors@R: c(
person(given = "Nick", family = "Borcherding", role = c("aut", "cre"), email = "ncborch@gmail.com"),
person(given = "Jared", family = "Andrews", role = c("aut"), email = "jared.andrews07@gmail.com"),
person(given = "Tobias", family = "Hoch", role = c("ctb"), email = "tobias@hoch.earth"),
person(given = "Alexei", family = "Martsinkovskiy", role = c("ctb"), email = "alexei.martsinkovskiy@gmail.com")
person(given = "Alexei", family = "Martsinkovskiy", role = c("ctb"), email = "alexei.martsinkovskiy@gmail.com"),
person(given = "Cathal", family = "King", role = c("ctb"))
)
Description: A bridging R package to facilitate gene set enrichment analysis (GSEA) in the context of single-cell RNA sequencing. Using raw count information, Seurat objects, or SingleCellExperiment format, users can perform and visualize ssGSEA, GSVA, AUCell, and UCell-based enrichment calculations across individual cells. Alternatively, escape supports use of rank-based GSEA, such as the use of differential gene expression via fgsea.
Description: A bridging R package to facilitate gene set enrichment analysis (GSEA) in the context of single-cell RNA sequencing. Using raw count information, Seurat objects, SingleCellExperiment or SpatialExperiment format, users can perform and visualize ssGSEA, GSVA, AUCell, and UCell-based enrichment calculations across individual cells. An optional plaid backend provides fast reimplementations of these methods plus PLAID, singscore, and scSE scoring. Alternatively, escape supports use of rank-based GSEA, such as the use of differential gene expression via fgsea.
License: MIT + file LICENSE
Encoding: UTF-8
LazyData: false
RoxygenNote: 7.3.3
biocViews: Software, SingleCell, Classification, Annotation, GeneSetEnrichment, Sequencing, GeneSignaling, Pathways
Depends: R (>= 4.1)
Imports:
Expand Down Expand Up @@ -42,16 +42,20 @@ Suggests:
knitr,
msigdb,
patchwork,
plaid,
rmarkdown,
rlang,
S4Vectors,
scran,
SeuratObject,
Seurat,
SingleCellExperiment,
SpatialExperiment,
spelling,
stringr,
testthat (>= 3.0.0),
UCell
VignetteBuilder: knitr
Language: en-US
BugReports: https://github.com/BorchLab/escape/issues
Config/roxygen2/version: 8.0.0
23 changes: 23 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# 2.9.1

## NEW FEATURES
* **`plaid` backend**: `escape.matrix()` and `runEscape()` gain `backend = c("native", "plaid")`. Setting `backend = "plaid"` routes `ssGSEA`, `GSVA`, `UCell`, and `AUCell` to the `replaid.*` family for large speed and memory gains. `plaid` is a `Suggests`, so the native path is unaffected if it is not installed. **These are approximations, and the gap is wider than the plaid documentation suggests** — measured pooled Pearson agreement with the native scores was roughly 0.85 (ssGSEA), 0.83 (UCell, AUCell) and 0.73 (GSVA) on a 2000-gene simulation, and lower on narrower matrices. See `?escape.matrix` and the vignette before substituting one backend for the other.
* **Three new methods**: `method = "PLAID"`, `"singscore"`, and `"scSE"`. These have no native implementation and select the `plaid` backend automatically.
* **`backend.args`**: a named list carrying method-specific tuning (`alpha`, `tau`, `rowtf`, `aucMaxRank`, `rmax`, `nsmooth`, `stats`, `chunk`, `removeLog2`, `scoreMean`). Names are validated against the target function, so typos error instead of being silently swallowed. Note `backend.args$normalize` is `plaid`'s median normalization of the scores and is unrelated to escape's `normalize`.
* **`input.assay`**: choose which expression matrix to score. `"auto"` (default) keeps raw counts for the native backend and picks log-normalized values for `plaid`. `"counts"` and `"logcounts"` map to the correct layer for both Seurat and `SummarizedExperiment`-derived objects.
* **`SpatialExperiment` support** is now explicit and tested, including `spatialCoords()`, `imgData()`, and `sample_id` preservation through `runEscape()`.
* Enrichment scores carry an `escape.backend` attribute recording the engine, method, and `plaid` version; `runEscape()` stores the same record in `metadata()` (SCE) or `Misc()` (Seurat).

## BUG FIXES
* **`escape.matrix(normalize = TRUE)` failed on every `SummarizedExperiment`-derived input** with `unable to find an inherited method for function 'assay' for signature 'x = "NULL"'`. `.pull.Enrich()` used `altExp(sc)[[name]]`, which indexes the `colData` of the *first* altExp rather than selecting the named one, and returned `NULL`. Now uses `altExp(sc, name)`. This also fixes `performNormalization()` and `performPCA()` on `SingleCellExperiment` and `SpatialExperiment` objects. Reported by Cathal King, who also provided the Xenium test data.
* **Underscored gene-set names were silently dropped during normalization.** `performNormalization()` rewrote every `_` to `-` to match Seurat's feature-name coercion, which meant `HALLMARK_*`, `GO_*`, and `REACTOME_*` sets matched nothing for matrix, `SingleCellExperiment`, or `SpatialExperiment` input. Matching is now literal first with the Seurat-mangled form as a fallback, and per-set scale factors are aligned to the enrichment columns explicitly so a partial match can never divide the wrong column.
* Requesting an assay that does not exist now reports what was asked for and what is available, instead of failing with an S4 dispatch error.
* `performNormalization()` gives a supplied `enrichment.data` precedence over scores stored on the object, and warns when both are present.
* `%||%` was used but never defined; it only exists in base R >= 4.4 while the package declares R >= 4.1.
* `runEscape()` now calls `escape.matrix()` with named rather than positional arguments.

## ENHANCEMENTS
* Removed duplicate internal definitions of `.split_cols()`, `.match_summary_fun()`, and `.filter_genes()`.
* `escape.matrix()` validates `method` up front rather than failing inside the scoring switch.
* Test suite: added a toy `SpatialExperiment` fixture built at run time (no new package data), regression coverage for all three bug fixes, and `plaid` dispatch tests that run whether or not `plaid` is installed.

# 2.7.3

## BUG FIXES
Expand Down
68 changes: 49 additions & 19 deletions R/performNormalization.R
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
#'
#' @param input.data A raw-counts matrix (genes x cells), a
#' \link[SeuratObject]{Seurat} object, or a
#' \link[SingleCellExperiment]{SingleCellExperiment}. Gene identifiers must
#' \link[SingleCellExperiment]{SingleCellExperiment} (including a
#' \link[SpatialExperiment]{SpatialExperiment}). Gene identifiers must
#' match those in \code{gene.sets}.
#' @param enrichment.data Matrix. Output of \code{\link{escape.matrix}} or
#' \code{NULL} if enrichment scores are already stored in \code{input.data}.
#' When supplied it takes precedence over anything stored in
#' \code{input.data}.
#' @param assay Character. Name of the assay holding enrichment scores when
#' \code{input.data} is a single-cell object. Default is \code{"escape"}.
#' Ignored otherwise.
#' Ignored when \code{input.data} is a matrix. Set to \code{NULL} to return
#' the normalized matrix rather than attaching it to the object.
#' @param gene.sets A named list of character vectors, the result of
#' \code{\link{getGeneSets}}, or the built-in data object
#' \code{\link{escape.gene.sets}}. List names must match column names in the
Expand All @@ -39,8 +43,16 @@
#' assay = "escape",
#' gene.sets = gs)
#'
#' @return If `input.data` is an object, the same object with a new assay
#' "<assay>_normalized". Otherwise a matrix of normalized scores.
#' @section Which expression values are used:
#' The per-cell scale factor is the number of genes from each set with a
#' \strong{non-zero raw count}, so this function always reads the \code{counts}
#' assay regardless of any \code{input.assay} used when the scores were
#' computed. Detection is identical in count and log space, so this is
#' deliberate rather than an oversight.
#'
#' @return If `input.data` is an object and `assay` is not `NULL`, the same
#' object with a new assay "<assay>_normalized". Otherwise a matrix of
#' normalized scores.
#' @export

performNormalization <- function(input.data,
Expand All @@ -62,28 +74,40 @@ performNormalization <- function(input.data,
}
} else if (.is_sce(input.data)) {
if (requireNamespace("SingleCellExperiment", quietly = TRUE)) {
assay.present <- assay %in% names(SingleCellExperiment::altExps(input.data))
assay.present <- assay %in% SingleCellExperiment::altExpNames(input.data)
} else {
warning("SingleCellExperiment package is required but not installed.")
}
}
}

enriched <- if (assay.present) .pull.Enrich(input.data, assay) else enrichment.data
if (is.null(enriched)) {

## Supplied scores always win - never re-pull something the caller handed in.
if (!is.null(enrichment.data)) {
if (assay.present)
warning("Both `enrichment.data` and assay '", assay, "' are available; ",
"using `enrichment.data`.", call. = FALSE)
enriched <- enrichment.data
} else if (assay.present) {
enriched <- .pull.Enrich(input.data, assay)
} else {
## nothing to work with - say what was asked for and what exists
if (!is.null(assay) && .is_seurat_or_sce(input.data)) {
avail <- if (.is_seurat(input.data)) SeuratObject::Assays(input.data)
else SingleCellExperiment::altExpNames(input.data)
.stop_missing_assay(assay, avail, "enrichment assay",
"`input.data`, and `enrichment.data` was not supplied")
}
stop("Could not obtain enrichment matrix, please set `assay` or supply `enrichment.data`.")
}

## 2. Validate / derive scale factors ----------------------------------
if (!is.null(scale.factor) && length(scale.factor) != nrow(enriched))
stop("Length of 'scale.factor' must match number of cells.")

if (is.null(scale.factor)) {
egc <- .GS.check(gene.sets)
names(egc) <- gsub("_", "-", names(egc), fixed = TRUE)
egc <- egc[names(egc) %in% colnames(enriched)]
if (!length(egc)) stop("None of the supplied gene sets match enrichment columns.")

## one gene set per enrichment column, in column order
egc <- .match_sets_to_cols(.GS.check(gene.sets), colnames(enriched))

## counts matrix (genes x cells) - drop after use to save RAM
cnts <- .cntEval(input.data, assay = "RNA", type = "counts")
message("Computing expressed-gene counts per cell...")
Expand All @@ -92,7 +116,12 @@ performNormalization <- function(input.data,
vec[vec == 0] <- 1L # avoid /0
vec
}))
colnames(scale.mat) <- names(egc)
rm(cnts)

## alignment is guaranteed by .match_sets_to_cols(); assert it anyway so a
## future refactor cannot silently divide the wrong column
stopifnot(identical(colnames(scale.mat), colnames(enriched)))
## optionally split large matrices to spare memory
chunksize <- if (is.null(groups)) nrow(enriched) else min(groups, nrow(enriched))
sf.split <- .split_rows(scale.mat, chunk.size = chunksize)
Expand Down Expand Up @@ -120,9 +149,10 @@ performNormalization <- function(input.data,
}

## 6. Return ------------------------------------------------------------
if (.is_seurat_or_sce(input.data)) {
input.data <- .adding.Enrich(input.data, normalized, paste0(assay %||% "escape", "_normalized"))
} else {
normalized
## `assay = NULL` means "hand the matrix back" - that is how escape.matrix()
## calls this, and it avoids attaching an altExp only to pull it straight off.
if (.is_seurat_or_sce(input.data) && !is.null(assay)) {
return(.adding.Enrich(input.data, normalized, paste0(assay, "_normalized")))
}
normalized
}
Loading
Loading