Skip to content

Add SpatialExperiment support, fix SCE normalization, and add optional plaid backend - #181

Merged
ncborcherding merged 7 commits into
develfrom
spe-plaid-support
Aug 9, 2026
Merged

Add SpatialExperiment support, fix SCE normalization, and add optional plaid backend#181
ncborcherding merged 7 commits into
develfrom
spe-plaid-support

Conversation

@ncborcherding

Copy link
Copy Markdown
Member

Summary

Closes #180 and closes #167.

Two things here. The first is a bug fix that turned out to be broader than reported: escape.matrix(normalize = TRUE) was failing on every SummarizedExperiment-derived input, not just SpatialExperiment. The second is the optional plaid backend, plus the input.assay argument it needed.


Part 1 — Spatial / SCE normalization (#180)

The working hypothesis in the issue was close but not exact. Reproducing it surfaced three distinct defects, all confirmed on pbmc_small before any fix was written.

1. .pull.Enrich() selected the wrong altExp

R/utils.R read:

assay(altExp(sc)[[name]])

altExp(sc) with no e argument returns the first altExp, and [[name]] on the resulting SummarizedExperiment dispatches to the colData accessor, not the assay list. For a name like "escape_normalized" that returns NULL, giving the reported assay(NULL) S4 dispatch error. Now uses altExp(sc, name), which is the form .cntEval() already used a few lines away.

This was never spatial-specific. It reproduces on a plain SingleCellExperiment; Seurat and raw-matrix input were unaffected, which is why it went unnoticed. It also broke performPCA() on any SCE — not reported, but fixed by the same change.

2. Underscored gene-set names were silently dropped

performNormalization() rewrote every _ to - unconditionally to match Seurat's feature-name coercion, then subset the gene sets to names matching the enrichment columns. For matrix, SCE, or SPE input the columns keep their underscores, so every HALLMARK_*, GO_*, and REACTOME_* set matched nothing:

escape.matrix(mat, gene.sets = list(HALLMARK_BCELL = ..., Tcells = ...), normalize = TRUE)
#> Error: non-conformable arrays

Matching is now literal first, with the Seurat-mangled form as a fallback for the columns still unmatched, and per-set scale factors are aligned to colnames(enriched) explicitly with an assertion.

3. %||% was used but never defined

base::%||% only exists in R >= 4.4.0, while DESCRIPTION declares R (>= 4.1). Now defined internally.

Also in this part

  • escape.matrix(normalize = TRUE) no longer attaches an altExp only to pull it straight back off — it stays on the matrix path for every input class. That round trip was the mechanism of the original failure.
  • Missing-assay lookups now report what was requested 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 exist.
  • runEscape() calls escape.matrix() with named rather than positional arguments.
  • Removed duplicate internal definitions of .split_cols(), .match_summary_fun(), and .filter_genes().

Part 2 — plaid backend (#167)

escape.matrix(..., backend = c("native", "plaid"), backend.args = list(), input.assay = "auto")
  • backend defaults to "native"; nothing changes for existing users.
  • method gains "PLAID", "singscore", "scSE". These have no native implementation, so they route to plaid regardless of backend and say so.
  • backend.args carries method-specific tuning (alpha, tau, rowtf, aucMaxRank, rmax, nsmooth, stats, chunk, removeLog2, scoreMean), validated against the target function's formals so a typo errors rather than being swallowed.

Design notes worth reviewing

New arguments sit after .... That makes them name-only, so no positional caller can break and partial matching can't land backend.a = on backend.args.

plaid bypasses escape's chunk loop entirely. Three reasons, in severity order: several plaid scores are not column-separable (median normalization, rowtf = "ecdf", rank statistics), so chunking would make results depend on groups and break the invariance test; plaid forks internally via parallel, and nesting that inside MulticoreParam forks inside forks; and plaid's speed is the single large sparse crossprod. plaid owns the cores on that path, and a non-SerialParam BPPARAM is ignored with a message.

max.genes is the main trap and is handled explicitly. plaid defaults to max.genes = 500, which would silently drop most Hallmark, GO, and Reactome sets. escape sets a real cap above anything scoreable rather than trusting a sentinel, and independently checks the returned rows — any set requested but not returned warns by name, and all sets missing is an error. That holds even if plaid changes its filtering rules.

The two normalize arguments stay separate. plaid's (median normalization of scores, inside the calculation) is reachable only as backend.args$normalize and keeps plaid's own default. escape's (drop-out scaling, after scoring) is untouched. Both are messaged when they could interact.

⚠️ Fidelity

method agreement
ssGSEA 0.85
UCell 0.83
AUCell 0.83
GSVA 0.73

replaid.ssgsea is documented as exact at alpha = 0. Compared directly against GSVA::gsva() on identical input it correlated at 0.85, not 1. The input assay is not the explanation — native ssGSEA on counts versus logcounts correlates at exactly 1.0, so the entire gap is plaid vs GSVA. On pbmc_small (230 genes) it drops to 0.50.

The vignette, roxygen, and NEWS all state the measured numbers and frame backend = "plaid" as a fast screen rather than a drop-in replacement. The concordance test threshold is set at 0.6 with a comment that it must not be tightened into an equivalence claim. Every score matrix carries an escape.backend attribute, and runEscape() mirrors it into metadata() / Misc() so provenance travels with the object.

Speed on the real Xenium object (166k spots, UCell, three sets): 28.6 s native → 6.8 s plaid, 4.2×. Real, but not the 10–100× the paper reports for this shape of data.


Part 3 — input.assay

plaid expects log-space input, but .cntEval() could only reach the main counts assay — every other name was treated as an altExp.

escape.matrix(spe, ..., input.assay = "logcounts")

"auto" (default) reads counts for the native backend and logcounts for plaid. "counts" and "logcounts" resolve to the right place for both object families (Seurat's counts/data layers, a SummarizedExperiment's counts/logcounts assays), so the same call works across classes. Anything else is taken literally.

The .cntEval() change is one line and provably inert: all six in-package assay = "RNA" call sites pass type = "counts". performNormalization() deliberately still reads raw counts regardless of input.assay — its scale factor counts detected genes, and detection is identical in count and log space. That is documented rather than left implicit.

Errors name the fix (Seurat::NormalizeData(), scuttle::logNormCounts(), or input.assay = "counts") and tolerate Seurat v5 split layers.


Docs

  • Vignette: method × backend table with measured agreement, a runnable spatial section (builds a toy SPE, scores it, plots enrichment against coordinates), an input-assay section, and expanded escape.matrix() / performNormalization() argument notes. Renders clean.
  • Cathal King added to Authors@R as ctb.
  • CI installs plaid and SpatialExperiment in both workflows. test-coverage.yaml previously installed nothing extra, so it would have skipped every new test.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.59259% with 47 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
R/utils.R 70.11% 26 Missing ⚠️
R/plaid.R 84.46% 16 Missing ⚠️
R/runEscape.R 91.80% 5 Missing ⚠️
Files with missing lines Coverage Δ
R/performNormalization.R 92.59% <100.00%> (+17.03%) ⬆️
R/runEscape.R 84.31% <91.80%> (+28.25%) ⬆️
R/plaid.R 84.46% <84.46%> (ø)
R/utils.R 75.50% <70.11%> (+3.89%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ncborcherding
ncborcherding merged commit da55640 into devel Aug 9, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

escape.matrix(normalize = TRUE) fails on SpatialExperiment (and possibly all SCE) input — assay() called on NULL during normalization Include PLAID

2 participants