Skip to contents

Gene Set Enrichment Methods in bixverse

This vignette shows you how to use the gene set enrichment methods implemented in bixverse.

library(bixverse)
library(data.table)
#> 
#> Attaching package: 'data.table'
#> The following object is masked from 'package:base':
#> 
#>     %notin%
library(magrittr)

Intro

Gene set enrichment analysis sits at the heart of most functional genomics workflows. Given a set of genes of interest (or a ranked list of all measured genes) you want to know which biological processes, pathways, or molecular functions are over-represented relative to what you would expect by chance. bixverse provides Rust-accelerated implementations of the two dominant paradigms for this, along with gene ontology-aware variants of each.

Over-representation analysis (ORA) via the hypergeometric test asks: given that I have drawn kk genes from a universe of NN, and KK of those belong to a particular gene set, is the overlap of k∩Kk \cap K larger than expected? This is fast and interpretable, but requires a discrete gene list — typically genes passing some fold-change and significance threshold. The choice of threshold and universe can substantially affect results, which is a known limitation of the approach.

Gene Set Enrichment Analysis (GSEA) sidesteps this by working with a continuously ranked list of all measured genes, asking whether the members of a gene set are systematically concentrated towards the top or bottom of the ranking. This avoids arbitrary thresholding and tends to be more sensitive to coordinated but modest shifts in expression, at the cost of being more computationally demanding and less straightforward to interpret.

A practical complication with both approaches is the redundancy of the Gene Ontology (GO). The GO is a directed acyclic graph: a significant hit on a specific term (e.g., “mitochondrial complex I assembly”) will typically also produce significant hits on all of its ancestor terms (e.g., “oxidative phosphorylation”, “metabolic process”), most of which add no new information. bixverse provides two strategies for dealing with this: the elimination method of Adrian et al., which propagates signal up the DAG while removing explained genes, and a post-hoc simplification approach based on Wang semantic similarity, which collapses redundant terms after running a standard enrichment.

All methods are implemented in Rust and exposed via extendr. The performance gains relative to existing R implementations range from modest (~20% faster for GSEA relative to fgsea’s Rcpp implementation) to substantial: the parallelised hypergeometric test over large gene set libraries can be orders of magnitude faster than a naive R loop.

Hypergeometric tests

bixverse implements the standard hypergeometric over-representation test. In this example we load the Hallmark gene sets from msigdbr and construct a named list, which is the expected input format throughout bixverse for gene set analyses.

h_gene_sets <- msigdbr::msigdbr(species = "human", collection = "H")
#> Downloading gene sets (first use only, may take a few minutes)...

h_gene_sets_ls <- split(h_gene_sets$ensembl_gene, h_gene_sets$gs_name)

set.seed(123L)

target_genes_1 <- c(
  sample(h_gene_sets_ls[["HALLMARK_MYC_TARGETS_V1"]], 25),
  sample(h_gene_sets_ls[["HALLMARK_MYC_TARGETS_V2"]], 25)
)

To test a single target gene set, use gse_hypergeometric. The gene universe defaults to the union of all genes represented in the gene_set_list if not provided explicitly. This is usually reasonable but worth checking if your assay covers a non-standard set of genes. FDR threshold and minimum overlap are also configurable.

results <- gse_hypergeometric(
  target_genes = target_genes_1,
  gene_set_list = h_gene_sets_ls
)

head(results)
#>              gene_set_name odds_ratios        pvals          fdr  hits
#>                     <char>       <num>        <num>        <num> <num>
#> 1: HALLMARK_MYC_TARGETS_V2   170.67009 5.919144e-41 2.959572e-39    27
#> 2: HALLMARK_MYC_TARGETS_V1    35.36813 2.110201e-27 5.275502e-26    29
#> 3:    HALLMARK_E2F_TARGETS     4.21748 1.448776e-03 2.414627e-02     8
#>    gene_set_lengths target_set_lengths
#>               <num>              <int>
#> 1:               58                 50
#> 2:              200                 50
#> 3:              200                 50

When you have many target gene sets to test simultaneously, use gse_hypergeometric_list. Under the hood this runs all tests in parallel via rayon and optimiser FxHashSets, making it very fast even at scale.

target_genes_2 <- c(
  sample(h_gene_sets_ls[["HALLMARK_TNFA_SIGNALING_VIA_NFKB"]], 20),
  sample(h_gene_sets_ls[["HALLMARK_IL6_JAK_STAT3_SIGNALING"]], 25)
)

target_list <- list(
  set_1 = target_genes_1,
  set_2 = target_genes_2,
  set_3 = c("random gene 1", "random gene 2", "random gene 3")
)

results_multiple <- gse_hypergeometric_list(
  target_genes_list = target_list,
  gene_set_list = h_gene_sets_ls
)

head(results_multiple)
#>    target_set_name odds_ratios        pvals          fdr  hits gene_set_lengths
#>             <char>       <num>        <num>        <num> <num>            <num>
#> 1:           set_2  165.038462 3.137168e-43 1.568584e-41    30               90
#> 2:           set_1  170.788856 5.811282e-41 2.905641e-39    27               58
#> 3:           set_1   35.393567 2.069809e-27 5.174524e-26    29              200
#> 4:           set_2   29.964115 5.979167e-22 1.494792e-20    24              200
#> 5:           set_2   11.995968 2.338661e-10 3.897768e-09    15              201
#> 6:           set_2    8.571723 2.515351e-07 3.144189e-06    12              200
#>                         gene_set_name target_set_lengths
#>                                <char>              <int>
#> 1:   HALLMARK_IL6_JAK_STAT3_SIGNALING                 45
#> 2:            HALLMARK_MYC_TARGETS_V2                 50
#> 3:            HALLMARK_MYC_TARGETS_V1                 50
#> 4:   HALLMARK_TNFA_SIGNALING_VIA_NFKB                 45
#> 5:     HALLMARK_INFLAMMATORY_RESPONSE                 45
#> 6: HALLMARK_INTERFERON_GAMMA_RESPONSE                 45

To give a sense of the scale this handles comfortably, the following runs 250 target gene sets against 5,000 gene sets drawn from a universe of 20,000 genes — 1.25 million hypergeometric tests in total. On most systems this completes in a few seconds.

seed <- 10101L

set.seed(seed)

universe <- sprintf("gene_%i", 1:20000)
gene_sets_no <- 5000
target_gene_sets_no <- 250

gene_sets <- purrr::map(
  1:gene_sets_no,
  ~ {
    set.seed(seed + .x + 1)
    size <- sample(20:100, 1)
    sample(universe, size, replace = FALSE)
  }
)

names(gene_sets) <- purrr::map_chr(
  1:gene_sets_no,
  ~ {
    set.seed(seed + .x + 1)
    paste(sample(LETTERS, 3), collapse = "")
  }
)

target_gene_sets <- purrr::map(
  1:target_gene_sets_no,
  ~ {
    set.seed(.x * seed)
    size <- sample(50:100, 1)
    sample(universe, size, replace = FALSE)
  }
)

names(target_gene_sets) <- purrr::map_chr(
  1:target_gene_sets_no,
  ~ {
    set.seed(seed + .x + 1)
    paste(sample(letters, 3), collapse = "")
  }
)

tictoc::tic()
rs_results_example <- gse_hypergeometric_list(
  target_genes_list = target_gene_sets,
  gene_set_list = gene_sets
)
tictoc::toc()
#> 0.97 sec elapsed

Gene Ontology-aware enrichment: the elimination method

The naive approach of running a hypergeometric test independently for each GO term produces heavily redundant results, because significant child terms mechanically inflate their ancestors. The elimination method from Adrian et al. addresses this by traversing the ontology from the most specific terms (leaves) upwards. When a term reaches the significance threshold, the genes annotated to that term are removed from all of its ancestor terms before their tests are computed. This means the ancestor terms are tested on the residual signal not already explained by their more specific descendants, yielding results that are both less redundant and more interpretable.

bixverse ships with the human GO data and exposes it via a dedicated S7 class that transfers the ontology structure into Rust for efficient traversal.

go_data_dt <- get_go_data_human()
#> Loading the data from the package.
#> Processing data for the gene_ontology class.

go_data_s7 <- GeneOntologyElim(go_data_dt, min_genes = 3L)

The interface mirrors the generic hypergeometric functions: a single target set or a list, with Rust’s ownership model ensuring the ontology structure is shared across all tests without copying, keeping memory overhead low.

go_aware_res <- gse_go_elim_method(
  object = go_data_s7,
  target_genes = target_genes_1
)

head(go_aware_res)
#>         go_id                                     go_name odds_ratios
#>        <char>                                      <char>       <num>
#> 1: GO:0003723                                 RNA binding   10.897512
#> 2: GO:0032040                    small-subunit processome   44.202960
#> 3: GO:0000055 ribosomal large subunit export from nucleus  455.891304
#> 4: GO:0031428    box C/D methylation guide snoRNP complex  455.891304
#> 5: GO:0000056 ribosomal small subunit export from nucleus  341.902174
#> 6: GO:0005730                                   nucleolus    5.524423
#>           pvals          fdr  hits gene_set_lengths
#>           <num>        <num> <num>            <num>
#> 1: 4.901798e-12 5.397860e-08    19             1171
#> 2: 1.623943e-08 8.941431e-05     6               72
#> 3: 2.368116e-07 6.519425e-04     3                6
#> 4: 2.368116e-07 6.519425e-04     3                6
#> 5: 4.137403e-07 8.639563e-04     3                7
#> 6: 4.707354e-07 8.639563e-04    17             1857

And the version for lists:

go_aware_res_2 <- gse_go_elim_method_list(
  object = go_data_s7,
  target_gene_list = target_list
)

head(go_aware_res_2)
#>    target_set_name                                              go_name
#>             <char>                                               <char>
#> 1:           set_2 cell surface receptor signaling pathway via JAK-STAT
#> 2:           set_2                                    cytokine activity
#> 3:           set_2                                inflammatory response
#> 4:           set_1                                          RNA binding
#> 5:           set_2                                   chemokine activity
#> 6:           set_2                     external side of plasma membrane
#>         go_id odds_ratios        pvals          fdr  hits gene_set_lengths
#>        <char>       <num>        <num>        <num> <num>            <num>
#> 1: GO:0007259    97.16563 8.704402e-15 9.576583e-11     9               66
#> 2: GO:0005125    31.85212 1.130962e-12 6.221421e-09    11              235
#> 3: GO:0006954    21.05918 2.285499e-12 8.381688e-09    13              436
#> 4: GO:0003723    10.89751 4.901798e-12 5.397860e-08    19             1171
#> 5: GO:0008009    99.30420 6.201736e-12 1.705787e-08     7               48
#> 6: GO:0009897    21.74176 9.192276e-12 2.022668e-08    12              379

The DAG traversal is inherently sequential per gene set (each level depends on the one below), but tests within a level are parallelised via rayon. Running 100 target gene sets against the full human GO completes in a matter of seconds:

go_gene_universe <- unique(unlist(go_data_dt$ensembl_id))

go_target_sets_no <- 100L
seed <- 246L

go_target_gene_sets <- purrr::map(
  1:go_target_sets_no,
  ~ {
    set.seed(.x * seed)
    size <- sample(50:100, 1)
    sample(go_gene_universe, size, replace = FALSE)
  }
)

names(go_target_gene_sets) <- purrr::map_chr(
  1:go_target_sets_no,
  ~ {
    set.seed(seed + .x + 1)
    paste(sample(letters, 3), collapse = "")
  }
)

tictoc::tic()
rs_results_example <- gse_go_elim_method_list(
  object = go_data_s7,
  target_gene_list = go_target_gene_sets
)
tictoc::toc()
#> 1.492 sec elapsed

Alternative: post-hoc simplification of GO results

An alternative strategy is to run an unconstrained hypergeometric test over all GO terms and then collapse redundant results afterwards using semantic similarity. This approach is more flexible — you can apply it to any enrichment results, not just those produced by bixverse — but it does not adjust the test statistics themselves the way the elimination method does. Whether you prefer pre-hoc elimination or post-hoc simplification depends on your use case; for exploratory analyses the simplification approach can be more convenient, while for rigorous GO reporting the elimination method is generally preferable.

go_data <- load_go_human_data()

min_genes <- 3L

go_genes <- go_data$go_to_genes
go_genes_ls <- split(go_genes$ensembl_id, go_genes$go_id)
go_genes_ls <- purrr::keep(go_genes_ls, \(x) length(x) > min_genes)

go_results_unfiltered <- gse_hypergeometric(
  target_genes = target_genes_1,
  gene_set_list = go_genes_ls
)

head(go_results_unfiltered)
#>    gene_set_name odds_ratios        pvals          fdr  hits gene_set_lengths
#>           <char>       <num>        <num>        <num> <num>            <num>
#> 1:    GO:0003723   22.120609 1.723343e-23 1.579099e-19    31             1546
#> 2:    GO:0006364   35.837205 2.936032e-12 1.345143e-08    10              159
#> 3:    GO:0005730    8.860092 6.601460e-12 1.581347e-08    23             1927
#> 4:    GO:0042254   43.069954 6.903186e-12 1.581347e-08     9              118
#> 5:    GO:0005634    8.314215 9.730968e-12 1.783297e-08    39             6736
#> 6:    GO:0005654    6.752334 9.279447e-11 1.417126e-07    30             4005
#>    target_set_lengths
#>                 <int>
#> 1:                 50
#> 2:                 50
#> 3:                 50
#> 4:                 50
#> 5:                 50
#> 6:                 50

The simplification uses Wang semantic similarity to group related terms, then retains the term with the best test statistic within each cluster. Ties on FDR are broken by ontology depth, preferring the more specific term.

go_parent_child_dt <- go_data$gene_ontology[
  relationship %in% c("is_a", "part_of")
] %>%
  setnames(
    old = c("from", "to", "relationship"),
    new = c("parent", "child", "type")
  )

go_results_simplified <- simplify_hypergeom_res(
  res = go_results_unfiltered,
  parent_child_dt = go_parent_child_dt,
  weights = setNames(c(0.8, 0.6), c("is_a", "part_of"))
)

head(go_results_simplified)
#>    gene_set_name odds_ratios        pvals          fdr  hits gene_set_lengths
#>           <char>       <num>        <num>        <num> <num>            <num>
#> 1:    GO:0003723   22.120609 1.723343e-23 1.579099e-19    31             1546
#> 2:    GO:0006364   35.837205 2.936032e-12 1.345143e-08    10              159
#> 3:    GO:0005730    8.860092 6.601460e-12 1.581347e-08    23             1927
#> 4:    GO:0042254   43.069954 6.903186e-12 1.581347e-08     9              118
#> 5:    GO:0032040   44.202960 1.623943e-08 2.023038e-05     6               72
#> 6:    GO:0000055  455.891304 2.368116e-07 2.169905e-04     3                6
#>    target_set_lengths
#>                 <int>
#> 1:                 50
#> 2:                 50
#> 3:                 50
#> 4:                 50
#> 5:                 50
#> 6:                 50

GSEA

bixverse implements GSEA following Subramanian et al. for testing against a continuously ranked gene list. The package also wraps the fgsea multilevel method from Korotkevich et al., which uses an adaptive permutation scheme to estimate precise p-values efficiently. Both are available for comparison.

library("fgsea")

data(examplePathways)
data(exampleRanks)

set.seed(42L)

fgsea_res <- fgsea(
  pathways = examplePathways,
  stats = exampleRanks,
  minSize = 15,
  maxSize = 500
) %>%
  setorder(pathway)

head(fgsea_res)
#>                                                                                    pathway
#>                                                                                     <char>
#> 1:                                                                1221633_Meiotic_Synapsis
#> 2:                                   1445146_Translocation_of_Glut4_to_the_Plasma_Membrane
#> 3: 442533_Transcriptional_Regulation_of_Adipocyte_Differentiation_in_3T3-L1_Pre-adipocytes
#> 4:                                                                  508751_Circadian_Clock
#> 5:                                               5334727_Mus_musculus_biological_processes
#> 6:                                        573389_NoRC_negatively_regulates_rRNA_expression
#>         pval      padj    log2err         ES        NES  size
#>        <num>     <num>      <num>      <num>      <num> <int>
#> 1: 0.5490534 0.7262873 0.06674261  0.2885755  0.9399888    27
#> 2: 0.6952862 0.8366277 0.05445560  0.2387284  0.8366858    39
#> 3: 0.1122449 0.2610139 0.21392786 -0.3640705 -1.3460567    31
#> 4: 0.7826888 0.8799951 0.05312981  0.2516326  0.7287094    17
#> 5: 0.3580060 0.5579562 0.08197788  0.2469065  1.0498922   106
#> 6: 0.4198895 0.6197865 0.08407456  0.3607409  1.0446789    17
#>                                     leadingEdge
#>                                          <list>
#> 1:                            15270,12189,71846
#> 2:  17918,19341,20336,22628,22627,20619,...[10]
#> 3: 76199,19014,26896,229003,17977,17978,...[12]
#> 4:                                  20893,59027
#> 5:  60406,19361,15270,20893,12189,68240,...[12]
#> 6:                                  60406,20018

How does bixverse look in comparison?

bixverse_fgsea <- calc_fgsea(
  stats = exampleRanks,
  pathways = examplePathways,
  gsea_params = params_gsea(min_size = 15L)
) %>%
  setorder(pathway_name)

head(bixverse_fgsea)
#>                                                                               pathway_name
#>                                                                                     <char>
#> 1:                                                                1221633_Meiotic_Synapsis
#> 2:                                   1445146_Translocation_of_Glut4_to_the_Plasma_Membrane
#> 3: 442533_Transcriptional_Regulation_of_Adipocyte_Differentiation_in_3T3-L1_Pre-adipocytes
#> 4:                                                                  508751_Circadian_Clock
#> 5:                                               5334727_Mus_musculus_biological_processes
#> 6:                                        573389_NoRC_negatively_regulates_rRNA_expression
#>            es        nes     pvals       fdr
#>         <num>      <num>     <num>     <num>
#> 1:  0.2885754  0.9321969 0.5603306 0.7378735
#> 2:  0.2387284  0.8369307 0.7098284 0.8577867
#> 3: -0.3640706 -1.3124920 0.1328125 0.2999415
#> 4:  0.2516324  0.7204316 0.8102837 0.9100339
#> 5:  0.2469065  1.0514797 0.3806078 0.5884860
#> 6:  0.3607407  1.0328122 0.4202128 0.6202637
#>                                    leading_edge n_more_extreme    log2err
#>                                          <list>          <num>      <num>
#> 1:                            15270,12189,71846            338 0.06378454
#> 2:  17918,19341,20336,22628,22627,20619,...[10]            454 0.04999139
#> 3: 76199,19014,26896,229003,17977,17978,...[12]             50 0.19782202
#> 4:                                  20893,59027            456 0.04979032
#> 5:  60406,19361,15270,20893,12189,68240,...[12]            262 0.07647671
#> 6:                                  60406,20018            236 0.08197788

The p-values from both implementations are in close agreement, as expected:

plot(
  x = -log10(fgsea_res$pval),
  y = -log10(bixverse_fgsea$pvals),
  xlab = "-log10(pval) fgsea",
  ylab = "-log10(pval) bixverse",
  main = "fgsea and bixverse"
)

The Rust implementation is consistently faster than fgsea’s Rcpp backend — not by an enormous margin on small data (roughly 20% in typical benchmarks), but the gap widens with the number of pathways and permutations:

microbenchmark::microbenchmark(
  fgsea = fgsea(
    pathways = examplePathways,
    stats = exampleRanks,
    minSize = 15,
    maxSize = 500
  ),
  rust = calc_fgsea(
    stats = exampleRanks,
    pathways = examplePathways,
    gsea_params = params_gsea(min_size = 15L)
  ),
  times = 5L
)
#> Unit: seconds
#>   expr      min       lq     mean   median       uq      max neval
#>  fgsea 2.012041 2.050371 2.212077 2.225927 2.251344 2.520701     5
#>   rust 1.315839 1.318514 1.343393 1.339177 1.348982 1.394455     5

blitzGSEA

Permutation-based GSEA pays per pathway. blitzGSEA does not. It draws random gene sets once across a log-spaced grid of set sizes, fits gamma tails to the resulting null enrichment scores, and then reads every pathway’s p-value straight off the fitted tail, see Lachmann et al.. One gamma evaluation per pathway, no matter how big the library.

blitz_params <- params_blitzgsea(min_size = 15L)

bixverse_blitz <- calc_blitzgsea(
  stats = exampleRanks,
  pathways = examplePathways,
  blitz_params = blitz_params
)

head(bixverse_blitz)
#>                                       pathway_name        es      nes
#>                                             <char>     <num>    <num>
#> 1:                              5990980_Cell_Cycle 0.5373426 7.977693
#> 2:                     5990979_Cell_Cycle,_Mitotic 0.5579853 7.860540
#> 3:                                 5991454_M_Phase 0.5558732 5.985000
#> 4:                    5991851_Mitotic_Prometaphase 0.7243143 5.964933
#> 5: 5992217_Resolution_of_Sister_Chromatid_Cohesion 0.7337666 5.803762
#> 6:          5991502_Mitotic_Metaphase_and_Anaphase 0.6038215 5.697086
#>           pvals          fdr        sidak  size
#>           <num>        <num>        <num> <num>
#> 1: 1.490940e-15 8.736906e-13 8.736906e-13   369
#> 2: 3.824815e-15 1.120671e-12 2.241341e-12   317
#> 3: 2.163902e-09 3.585367e-07 1.268046e-06   173
#> 4: 2.447350e-09 3.585367e-07 1.434146e-06    82
#> 5: 6.484348e-09 7.599656e-07 3.799821e-06    74
#> 6: 1.218724e-08 1.033659e-06 7.141697e-06   123
#>                                     leading_edge
#>                                           <list>
#> 1: 66336,66977,12442,107995,66442,19361,...[155]
#> 2: 66336,66977,12442,107995,66442,12571,...[141]
#> 3:  66336,66977,12442,107995,66442,52276,...[67]
#> 4:  66336,66977,12442,107995,66442,52276,...[44]
#> 5:  66336,66977,12442,107995,66442,52276,...[40]
#> 6:  66336,66977,107995,66442,52276,67629,...[51]

Does the approximation hold up against the permutations + multi-level approach from fgsea?

blitz_vs_fgsea <- merge(
  bixverse_blitz[, .(pathway_name, pvals_blitz = pvals)],
  bixverse_fgsea[, .(pathway_name, pvals_fgsea = pvals)],
  by = "pathway_name"
)

plot(
  x = -log10(blitz_vs_fgsea$pvals_fgsea),
  y = -log10(blitz_vs_fgsea$pvals_blitz),
  xlab = "-log10(pval) bixverse fgsea",
  ylab = "-log10(pval) bixverse blitzGSEA",
  main = "fgsea and blitzGSEA"
)

Overall good correlation between the two. Pathways called significant in one are likely to be called significant in the other.

Reusing the null model

Nothing about a gene set library enters the calibration. It depends only on the signature, so one calibration serves every library you score against that ranking. blitzgsea_calibrate() hands it back as a plain list you can hold onto or saveRDS():

null_model <- blitzgsea_calibrate(
  stats = exampleRanks,
  blitz_params = blitz_params
)

null_model
#> BlitzGseaNull (calibrated blitzGSEA null model)
#>   Signature:        12000 genes
#>   Anchors:          37 (sizes 1 to 6000)
#>   Centred:          TRUE
#>   KS p-value:       0.42 positive tail, 0.491 negative tail

The KS p-values are a goodness-of-fit diagnostic on the gamma tails. Low values mean the fit is poor and the p-values are optimistic; calc_blitzgsea() warns when that happens. More permutations is the usual fix.

Hand the null back and the scoring is all that is left:

microbenchmark::microbenchmark(
  fgsea = calc_fgsea(
    stats = exampleRanks,
    pathways = examplePathways,
    gsea_params = params_gsea(min_size = 15L)
  ),
  blitz_cold = calc_blitzgsea(
    stats = exampleRanks,
    pathways = examplePathways,
    blitz_params = blitz_params
  ),
  blitz_warm = calc_blitzgsea(
    stats = exampleRanks,
    pathways = examplePathways,
    blitz_params = blitz_params,
    null_model = null_model
  ),
  times = 5L
)
#> Unit: milliseconds
#>        expr       min         lq       mean     median        uq        max
#>       fgsea 1315.0794 1355.55552 1361.91132 1363.05908 1365.4577 1410.40491
#>  blitz_cold  548.2042  554.03971  562.55291  562.91384  570.4685  577.13830
#>  blitz_warm    9.6499   10.27899   10.44763   10.29146   10.8152   11.20263
#>  neval
#>      5
#>      5
#>      5

On this data (586 pathways, ~12k genes) the cold run lands around 3x faster than the fgsea multilevel method and the warm one around 90x. Score five libraries against one ranking and you pay the calibration once.

The catch: the null is specific to the signature it was drawn from, both its length and its values. calc_blitzgsea() refuses a null whose gene count does not match rather than returning plausible-looking rubbish, but it cannot catch two different signatures of the same length. Recalibrate when the ranking changes.

GO-aware GSEA: the elimination method

bixverse combines the elimination method with GSEA, using the same GeneOntologyElim object as the hypergeometric variant. The ontology is traversed leaf-first; for each term a permutation-based p-value is computed and if it falls below the elimination threshold, genes from that term are removed from all ancestors before they are tested. Once the full traversal is complete, the fgsea multilevel method is applied to terms that reached nominal significance to obtain more precise p-values than pure permutation alone can provide.

gene_universe_go <- unique(
  unlist(go_data_dt[, "ensembl_id"], use.names = FALSE)
)

set.seed(42L)

random_stats <- rnorm(length(gene_universe_go))
names(random_stats) <- gene_universe_go

go_gsea_res <- fgsea_go_elim(
  object = go_data_s7,
  stats = random_stats
)

head(go_gsea_res)
#>         go_id         es       nes  size        pvals n_more_extreme
#>        <char>      <num>     <num> <num>        <num>          <num>
#> 1: GO:0031465 -0.9025944 -1.925630     6 0.0001687356              0
#> 2: GO:0006066 -0.6719029 -2.024665    18 0.0003035037              0
#> 3: GO:0050877  0.5115945  1.948953    44 0.0003059293              2
#> 4: GO:0035036  0.6951830  2.011786    15 0.0003466275              0
#> 5: GO:0060213  0.7161168  1.993104    13 0.0004616471              0
#> 6: GO:0072675 -0.8744512 -1.865588     6 0.0005371770              0
#>                                                                                               leading_edge
#>                                                                                                     <list>
#> 1:                                                         ENSG00000158290,ENSG00000167986,ENSG00000143476
#> 2:  ENSG00000111012,ENSG00000140459,ENSG00000160882,ENSG00000186684,ENSG00000006534,ENSG00000122787,...[7]
#> 3: ENSG00000205495,ENSG00000004139,ENSG00000183269,ENSG00000007372,ENSG00000084093,ENSG00000105993,...[19]
#> 4:  ENSG00000182310,ENSG00000163357,ENSG00000183560,ENSG00000165457,ENSG00000110195,ENSG00000174529,...[9]
#> 5:  ENSG00000090905,ENSG00000123908,ENSG00000159388,ENSG00000070756,ENSG00000125107,ENSG00000128016,...[8]
#> 6:                                                                                         ENSG00000110651
#>      log2err       fdr
#>        <num>     <num>
#> 1: 0.5188481 0.6767902
#> 2: 0.4984931 0.6767902
#> 3: 0.4984931 0.6767902
#> 4: 0.4984931 0.6767902
#> 5: 0.4984931 0.6992254
#> 6: 0.4772708 0.6992254
#>                                                                    go_name
#>                                                                     <char>
#> 1:                                  Cul4B-RING E3 ubiquitin ligase complex
#> 2:                                               alcohol metabolic process
#> 3:                                                  nervous system process
#> 4:                                                   sperm-egg recognition
#> 5: positive regulation of nuclear-transcribed mRNA poly(A) tail shortening
#> 6:                                                       osteoclast fusion