spiGSEA() scores a gene set by averaging its member
genes’ statistics: each per-gene \(t\)
is converted to a \(z\), the set’s
\(m\) z-scores are averaged, and the
average is standardised by
\[ \sqrt{\frac{1 + \rho\,(m - 1)}{m}}, \]
the variance of a mean of \(m\) equicorrelated unit variates — \(\rho\) is the average inter-gene correlation, estimated from the counts (or reused from the fit). Without the \(\rho\) term the divisor is \(1/\sqrt m\), which overstates the precision of the mean for any positively correlated set and makes every set-level p-value too small.
The function offers two null hypotheses, selected by
test:
limma::camera construction)?They answer different questions, and this study measures which is safe to default to. The headline is stark enough to state up front: on a fully null benchmark at realistic inter-gene correlation, the self-contained test calls about 20 of 208 sets per replicate — all false — while the competitive test essentially never calls; and the failure is only partly the correlation term’s fault, which is why no tuning of \(\rho\) rescues it.
Design. The gene-set layer has its own generative grid, separate from the gene-level simulation study: 1,500 genes (150 markers per cell type, 450 housekeeping), \(S = 30\) samples of 300 cells, 500 replicates per design point. Candidate sets number 208 in total: at each size \(m \in \{10, 25, 50, 100\}\), 25 null sets (genes drawn at random), 25 high sets (drawn from the top expression quintile), and 2 plant sets (which carry signal only in the power arm).
Correlation levels. The base count model draws genes independently, so inter-gene correlation is injected via a latent factor loading and then measured (next section). Three levels are contrasted: none (loading 0, no library-size variation — genuinely independent genes), libonly (loading 0, library-size variation on — which alone leaves \(\hat\rho \approx 0.0036\)), and real (the calibrated loading, giving the \(\hat\rho \approx 0.011\) observed on real spatial data).
Arms. null plants nothing anywhere;
power plants an effect in the designated plant sets only, over
effect sizes \(\beta \in \{0.3, 0.6, 1.2,
2\}\); global shifts a broad share of
all genes with no set-specific signal — the scenario
that discriminates the two nulls, since a competitive test should ignore
it and a self-contained test should not. Every arm runs both tests, and
each is also re-scored with the correlation term switched off
(rho_mode = "zero") to separate the \(\rho\) term’s contribution from everything
else.
Scoring. A set’s p-value is the unweighted Cauchy
combination over its triplets — level 1 of spiGSEA()’s own
cascade — with BH across sets; type-I is read from the per-set p at
\(\alpha\), call rates from BH at the
stated FDR. On the null arm every call is false by construction.
The set statistic’s variance term is driven entirely by \(\rho\), so before the study can claim
“realistic correlation” the loading has to be mapped to the correlation
it actually produces, with the package’s own estimator
(spiDE:::.interGeneCor()) doing the measuring.
The mapping is smooth and monotone; the calibrated loading (0.30) realises \(\hat\rho = 0.011\), matching the real-data target, and the loading-0 floor of \(\hat\rho = 0.0036\) shows that per-cell library-size variation is itself a correlation source — which is why the “independent genes” contrast in this study switches both the loading and the library-size variation off, not just the loading.
The dashed line is the nominal 5 %. The competitive test sits at zero; the self-contained test calls 2.7 % of random sets and 17.5 % of high-expression sets — about 20.6 of the 208 sets per replicate, all false, so its realised false-discovery proportion is 1.00 against a reported 0.05 (the competitive test’s mean is 0.05 calls per replicate). The same ordering holds before any FDR correction: per-set type-I at \(\alpha = 0.05\) is 0.082 / 0.36 (self-contained, random / high sets) against 0.013 / 0.067 (competitive).
It is worth being precise about the mechanism, because the obvious suspect — the correlation term — is only half the story.
rho_mode = "zero") raises the self-contained
false-call rate on random sets from 2.7 % to 11.8 % — roughly a 4.4-fold
increase. Estimating \(\rho\) and
inflating the variance is necessary.A coordinated shift across most genes — a global response with no set-specific biology — is exactly where the two null hypotheses part ways: a competitive test asks “is this set different from the rest?” (no), a self-contained test asks “is this set’s mean nonzero?” (yes, like everything else’s).
At the larger shift the self-contained test calls 96.2 % of random sets against 0.7 % for competitive. On real tissue, where broad transcriptional shifts between conditions are the rule rather than the exception, this failure mode is not hypothetical.
Because the per-gene statistics track expression level, the self-contained test’s false calls are not spread evenly: under the null its called sets sit at the 86th expression percentile against a 69th-percentile average over all candidate sets — the same “your top pathways are just the abundant ones” pattern seen on real data, here reproduced with known ground truth.
Strict error control is only worth having if the test still detects real set-level signal. On the power arm the effect lives in the plant sets only.
Competitive power is usable from around 25 genes per set at moderate effects (detection 0.8 at \(m = 25\), \(\beta = 1.2\); 0.3 at \(m = 50\), \(\beta = 0.6\)) and saturates by \(m = 50\) at the larger effects. The self-contained curves sit above it everywhere, but that advantage is priced in false discoveries: a test that calls 10 % of null sets buys its extra “power” from the same broken null that produced the calibration figures above.
The gene-set layer is computationally free at this scale: on top of a 261-second model fit, estimating \(\rho\) costs about 1.2 s and scoring all 208 sets about 1 s.
spiGSEA() therefore defaults to
test = "competitive". The self-contained form is retained
only to reproduce the original flat script’s fry-based
results, and should not be used for inference.The variance-inflation formula and the estimator of \(\rho\) are documented in
?spiGSEA; the gene-level benchmark this layer sits on is in
Simulating realistic spatial data and benchmarking spiDE.
#> R version 4.5.2 (2025-10-31)
#> Platform: x86_64-conda-linux-gnu
#> Running under: Red Hat Enterprise Linux 9.8 (Plow)
#>
#> Matrix products: default
#> BLAS/LAPACK: /home/uqdbhuva/miniconda3/envs/latest-r/lib/libopenblasp-r0.3.30.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Australia/Brisbane
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] patchwork_1.3.2 ggplot2_4.0.1
#> [3] SpatialExperiment_1.20.0 SingleCellExperiment_1.32.0
#> [5] SummarizedExperiment_1.40.0 Biobase_2.70.0
#> [7] GenomicRanges_1.62.0 Seqinfo_1.0.0
#> [9] IRanges_2.44.0 S4Vectors_0.48.0
#> [11] BiocGenerics_0.56.0 generics_0.1.4
#> [13] MatrixGenerics_1.22.0 matrixStats_1.5.0
#> [15] spiDE_0.99.18
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 SparseArray_1.10.1 lattice_0.22-9
#> [4] digest_0.6.38 magrittr_2.0.4 evaluate_1.0.5
#> [7] grid_4.5.2 RColorBrewer_1.1-3 fastmap_1.2.0
#> [10] jsonlite_2.0.0 Matrix_1.7-4 limma_3.66.0
#> [13] viridisLite_0.4.2 scales_1.4.0 codetools_0.2-20
#> [16] jquerylib_0.1.4 abind_1.4-8 cli_3.6.5
#> [19] rlang_1.1.6 XVector_0.50.0 withr_3.0.2
#> [22] cachem_1.1.0 DelayedArray_0.36.0 yaml_2.3.10
#> [25] poolr_1.2-0 S4Arrays_1.10.0 tools_4.5.2
#> [28] parallel_4.5.2 BiocParallel_1.44.0 dplyr_1.1.4
#> [31] mathjaxr_1.8-0 vctrs_0.6.5 R6_2.6.1
#> [34] lifecycle_1.0.4 magick_2.9.0 fs_1.6.6
#> [37] pkgconfig_2.0.3 pillar_1.11.1 bslib_0.9.0
#> [40] gtable_0.3.6 glue_1.8.0 Rcpp_1.1.2
#> [43] statmod_1.5.1 tidyselect_1.2.1 tibble_3.3.0
#> [46] xfun_0.54 knitr_1.50 farver_2.1.2
#> [49] rjson_0.2.23 htmltools_0.5.8.1 labeling_0.4.3
#> [52] rmarkdown_2.30 compiler_4.5.2 S7_0.2.1