R/olink_pathway_enrichment.R

Defines functions ora_pathwayenrichment gsea_pathwayenrichment results_to_genelist select_ont test_prep data_prep helper_non_overlap_assays check_pe_inputs check_test_mode olink_pathway_enrichment

Documented in olink_pathway_enrichment

#' Performs pathway enrichment using over-representation analysis (ORA) or
#' gene set enrichment analysis (GSEA)
#'
#' @author
#'   Kathleen Nevola
#'   Klev Diamanti
#'
#' @description
#' This function performs enrichment analysis based on statistical test results
#' and full data using `clusterProfiler`'s functions `gsea` and `enrich` for
#' MSigDB.
#'
#' @details
#' MSigDB is subset if the ontology argument is "KEGG", "GO", or "Reactome". The
#' argument `test_results` must contain estimates for all assays, otherwise an
#' error will be thrown. Results from a post-hoc statistical test can be used as
#' argument for `test_results`, but the user needs to select and filter one
#' contrast to improve interpretability of the results. Alternative statistical
#' results can be used as input as long as they include the columns "OlinkID",
#' "Assay", and "estimate". A column named "Adjusted_pval" is also required for
#' ORA. Any statistical result that contains exactly one estimate per protein
#' will work as long as the estimates are comparable to each other.
#'
#' The R library `clusterProfiler` is originally developed by Guangchuang Yu at
#' the School of Basic Medical Sciences at Southern Medical University.
#'
#' \strong{NB:} We strongly recommend to set a seed prior to running this
#' function to ensure reproducibility of the results.
#'
#' \strong{An important note regarding Pathway Enrichment with Olink Data}
#'
#' It is important to note that sometimes the proteins that are assayed in Olink
#' Panels are related to specific biological areas and therefore do not
#' represent an unbiased overview of the proteome as a whole, which is an
#' assumption for pathway enrichment. Pathways can only interpreted based on the
#' background/context they came from. For this reason, an estimate for all
#' assays measured must be provided. Furthermore, certain pathways cannot come
#' up based on Olink's coverage in this area. Additionally, if only the
#' Inflammation panel was run, then the available pathways would be given based
#' on a background of proteins related to inflammation. Both ORA and GSEA can
#' provide mechanistic and disease related insight and are best to use when
#' trying to uncover pathways/annotations of interest. It is recommended to only
#' use pathway enrichment for hypothesis generating data, which is better suited
#' for data originating from Olink's NGS platforms
#' `r ansi_collapse_quot(get_olink_platforms(broad_platform = "NGS"))` or on
#' multiple Target 96 panels from Olink's qPCR platform. For smaller lists of
#' proteins it may be more informative to use biological annotation in directed
#' research, to discover which significant assays are related to keywords of
#' interest.
#'
#' @references
#' Wu, T. et al. (2021). *clusterProfiler 4.0: A universal enrichment tool for*
#' *interpreting omics data*. The Innovation, 2(3):100141.
#' doi: 10.1016/j.xinn.2021.100141.
#'
#' @param df NPX data frame in long format with at least protein name ("Assay"),
#' "OlinkID", "UniProt", "SampleID", QC warning ("QC_Warning" or "SampleQC"),
#' quantification column ("NPX", "Ct" or "Quantified_value"), and one or more
#' columns representing limit of detection ("LOD", "PlateLOD" or "MaxLOD").
#' @param test_results a data frame of statistical test results including the
#' columns "Adjusted_pval" and "estimate".
#' @param check_log A named list returned by [`check_npx()`]. If `NULL`,
#' [`check_npx()`] will be run internally using `df`.
#' @param method One of "GSEA" (default) or "ORA".
#' @param ontology One of "MSigDb" (default), "MSigDb_com", "KEGG", "GO", and
#' "Reactome". "MSigDb" contains "C2" and "C5" gene sets which encompass "KEGG",
#' "GO", and "Reactome". "MSigDb_com" consists of "C2" and "C5" gene sets
#' without "KEGG", as the latter not permitted for commercial use.
#' @param organism One of "human" (default) or "mouse".
#' @param pvalue_cutoff (numeric) maximum adjusted p-value cutoff for ORA
#' filtering of foreground set (default = 0.05). This argument is not used for
#' GSEA.
#' @param estimate_cutoff (numeric) minimum estimate cutoff for ORA filtering
#' of foreground set (default = 0). This argument is not used for GSEA.
#'
#' @return A data frame of enrichment results.
#'
#' Columns for ORA include:
#' \itemize{
#'  \item{ID:} Pathway ID from MSigDB.
#'  \item{Description:} Description of Pathway from MSigDB.
#'  \item{GeneRatio:} Ratio of input proteins that are annotated in a term.
#'  \item{BgRatio:} Ratio of all genes that are annotated in this term.
#'  \item{pvalue:} P-value of enrichment.
#'  \item{p.adjust:} Benjamini-Hochberg adjusted p-value.
#'  \item{qvalue:} False discovery rate (FDR), the estimated probability that
#'  the normalized enrichment score represents a false positive finding.
#'  \item{geneID:} List of input proteins (Gene Symbols) annotated in a term,
#'  delimited by "/".
#'  \item{Count:} Number of input proteins that are annotated in a term.
#' }
#'
#' Columns for GSEA:
#' \itemize{
#'  \item{ID:} Pathway ID from MSigDB.
#'  \item{Description:} Description of Pathway from MSigDB.
#'  \item{setSize:} Ratio of input proteins that are annotated in a term.
#'  \item{enrichmentScore:} Enrichment score (ES), degree to which a gene set is
#'  over-represented at the top or bottom of the ranked list of genes.
#'  \item{NES:} Normalized Enrichment Score (NES), normalized to account for
#'  differences in gene set size and in correlations between gene sets and
#'  expression data sets. NES can be used to compare analysis results across
#'  gene sets.
#'  \item{pvalue:} P-value of enrichment.
#'  \item{p.adjust:} Benjamini-Hochberg adjusted p-value.
#'  \item{qvalue:} False discovery rate (FDR), the estimated probability that
#'  the normalized enrichment score represents a false positive finding.
#'  \item{rank:} The position in the ranked list where the maximum enrichment
#'  score occurred.
#'  \item{leading_edge:} Contains tags, list, and signal. Tags provide an
#'  indication of the percentage of genes contributing to the ES. List gives an
#'  indication of where in the list the ES is obtained. Signal represents the
#'  enrichment signal strength and combines the tag and list.
#'  \item{core_enrichment:} List of input proteins (Gene Symbols) annotated in a
#'  term, delimited by "/".
#' }
#'
#' @export
#'
#' @examples
#' \donttest{
#' if (rlang::is_installed(pkg = c("msigdbr", "clusterProfiler"))) {
#'   npx_df <- npx_data1 |>
#'     dplyr::filter(
#'       !grepl(
#'         pattern = "control",
#'         x = .data[["SampleID"]],
#'         ignore.case = TRUE
#'       )
#'     )
#'
#'   check_log <- check_npx(df = npx_df)
#'
#'   ttest_results <- OlinkAnalyze::olink_ttest(
#'     df = npx_df,
#'     variable = "Treatment",
#'     alternative = "two.sided",
#'     check_log = check_log
#'   )
#'
#'   # GSEA
#'   gsea_results <- OlinkAnalyze::olink_pathway_enrichment(
#'     df = npx_df,
#'     test_results = ttest_results,
#'     check_log = check_log
#'   )
#'
#'   # ORA
#'   ora_results <- OlinkAnalyze::olink_pathway_enrichment(
#'     df = npx_df,
#'     test_results = ttest_results,
#'     check_log = check_log,
#'     method = "ORA"
#'   )
#' }
#' }
#'
olink_pathway_enrichment <- function(df,
                                     test_results,
                                     check_log = NULL,
                                     method = "GSEA",
                                     ontology = "MSigDb",
                                     organism = "human",
                                     pvalue_cutoff = 0.05,
                                     estimate_cutoff = 0) {
  # check input ----

  # Is Package installed
  rlang::check_installed(pkg = c("msigdbr"),
                         version = "24.1.0",
                         call = rlang::caller_env())

  # custom check for "clusterProfiler" as it is a bioconductor package and not
  # on CRAN, which is not supported by rlang::check_installed()
  check_library_installed(x = "clusterProfiler",
                          error = TRUE)

  if (missing(df) || missing(test_results)) {
    cli::cli_abort("Arguments {.arg df} and {.arg test_results} are required!")
  }

  # check if test mode is activated and update arguments accordingly
  test_mode_results <- check_test_mode(method = method,
                                       ontology = ontology,
                                       organism = organism)
  method <- test_mode_results$method
  ontology <- test_mode_results$ontology
  organism <- test_mode_results$organism
  test_mode <- test_mode_results$test_mode

  check_log <- check_pe_inputs(df = df,
                               check_log = check_log,
                               test_results = test_results,
                               method = method,
                               ontology = ontology,
                               organism = organism)

  # prepare data ----

  df <- data_prep(df = df,
                  check_log = check_log,
                  test_results = test_results)


  test_results <- test_prep(df = df,
                            test_results = test_results,
                            check_log = check_log)

  msig_df <- select_ont(ontology = ontology,
                        organism = organism,
                        test_mode = test_mode)

  # perform pathway enrichment ----

  if (method == "ORA") {
    results <- ora_pathwayenrichment(
      test_results = test_results,
      msig_df = msig_df,
      pvalue_cutoff = pvalue_cutoff,
      estimate_cutoff = estimate_cutoff
    )
    cli::cli_inform("Over-representation analysis performed")
  } else {
    results <- gsea_pathwayenrichment(
      gene_list = results_to_genelist(
        test_results = test_results
      ),
      msig_df = msig_df
    )
    cli::cli_inform("Gene set enrichment analysis used by default.")
  }

  return(results)
}

check_test_mode <- function(method,
                            ontology,
                            organism) {
  # set test_mode to FALSE by default
  test_mode <- FALSE

  # only if all of the arguments method, ontology, and organism start with
  # "TEST#", then test_mode will be set to TRUE and the "TEST#" prefix will be
  # removed from the arguments. This is used for internal testing purposes and
  # is not intended for user use.
  if (all(
    stringr::str_starts(string = c(method, ontology, organism),
                        pattern = "TEST#")
  ) == TRUE) {
    test_mode <- TRUE
    method <- stringr::str_remove(string = method, pattern = "TEST#")
    ontology <- stringr::str_remove(string = ontology, pattern = "TEST#")
    organism <- stringr::str_remove(string = organism, pattern = "TEST#")
  }

  # return the arguments as a list, including test_mode
  return(
    list(
      method = method,
      ontology = ontology,
      organism = organism,
      test_mode = test_mode
    )
  )
}

check_pe_inputs <- function(df,
                            check_log,
                            test_results,
                            method,
                            ontology,
                            organism) {

  check_log <- run_check_npx(df = df, check_log = check_log)

  # check required columns in test_results ----

  check_columns(df = test_results,
                col_list = list("OlinkID", "estimate", "Assay"))

  # check that the assays in test_results match those in df ----

  non_overlap_assays <- helper_non_overlap_assays(
    df = df,
    test_results = test_results,
    check_log = check_log,
    which = "both"
  )

  if (length(non_overlap_assays) != 0L) {
    cli::cli_warn(
      c(
        "The sets of assays in {.arg df} and {.arg test_results} do not
        match!",
        "i" = "The sets are expected to be identical for pathway enrichment."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  # check for contrasts ----

  # if contrasts is present in the test_results, check that there is only one
  # contrast. If there are multiple contrasts, throw an error and ask the user
  # to filter for the desired contrast. This is because the results can be
  # difficult, or impossible, to interpret if there are multiple contrasts.
  if ("contrast" %in% colnames(test_results) &&
        length(unique(test_results[["contrast"]])) > 1L) {
    cli::cli_abort(
      c(
        "x" = "{.val {length(unique(test_results[['contrast']]))}} contrast{?s}
        present in {.arg test_results}!",
        "i" = "Filter {.arg test_results} for desired contrast prior to running
        pathway enrichment."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  # check enrichment method ----

  expected_methods <- c("GSEA", "ORA")

  if (!(method %in% expected_methods)) {
    cli::cli_abort(
      c(
        "x" = "{.val {method}} is not a valid method for pathway enrichment!",
        "i" = "Expected one of {.val {expected_methods}}."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  # check ontology  ----

  expected_ontologies <- c("MSigDb", "MSigDb_com", "Reactome", "KEGG", "GO")

  if (!(ontology %in% expected_ontologies)) {
    cli::cli_abort(
      c(
        "x" = "{.val {ontology}} is not a valid ontology for pathway
        enrichment!",
        "i" = "Expected one of {.val {expected_ontologies}}."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  # check organism  ----

  expected_organisms <- c("human", "mouse")

  if (!(organism %in% expected_organisms)) {
    cli::cli_abort(
      c(
        "x" = "{.val {organism}} is not a valid organism for pathway
        enrichment!",
        "i" = "Expected one of {.val {expected_organisms}}."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  return(check_log)
}

helper_non_overlap_assays <- function(df,
                                      test_results,
                                      check_log,
                                      which = "both") {
  no_overlap_in_df <- setdiff(
    x = unique(df[[check_log$col_names$olink_id]]),
    y = unique(test_results[["OlinkID"]])
  )

  no_overlap_in_res <- setdiff(
    x = unique(test_results[["OlinkID"]]),
    y = unique(df[[check_log$col_names$olink_id]])
  )

  if (which == "both") {
    no_overlap_assays <- c(no_overlap_in_df,
                           no_overlap_in_res)
  } else if (which == "df") {
    no_overlap_assays <- no_overlap_in_df
  } else if (which == "res") {
    no_overlap_assays <- no_overlap_in_res
  }

  return(no_overlap_assays)
}

data_prep <- function(df,
                      test_results,
                      check_log) {
  # clean up data from invalid entries ----

  df <- run_clean_npx(df = df,
                      check_log = check_log,
                      remove_assay_na = TRUE,
                      remove_invalid_oid = TRUE,
                      remove_dup_sample_id = FALSE,
                      remove_control_assay = TRUE,
                      remove_control_sample = FALSE,
                      remove_qc_warning = FALSE,
                      remove_assay_warning = FALSE,
                      convert_df_cols = TRUE,
                      convert_nonunique_uniprot = FALSE,
                      verbose = FALSE)

  # remove non-overlapping assays between df and test_results ----

  no_overlap_assays <- helper_non_overlap_assays(
    df = df,
    test_results = test_results,
    check_log = check_log,
    which = "df"
  )

  if (length(no_overlap_assays) != 0L) {
    cli::cli_inform(
      "{.val {length(no_overlap_assays)}} assay{?s} in {.arg df} {?is/are} not
      represented in {.arg test_results} and will be removed from {.arg df}:
      {.val {no_overlap_assays}}"
    )

    df <- df |>
      dplyr::filter(
        !(.data[[check_log$col_names$olink_id]] %in%
            .env[["no_overlap_assays"]])
      )
  }

  # check for duplicated assays in df ----

  # this is used because all pathway annotations work with assay names, and not
  # olink assay identifiers.

  duplicated_assays <- df |>
    dplyr::select(
      dplyr::all_of(
        c(check_log$col_names$sample_id,
          check_log$col_names$assay)
      )
    )

  if (any(duplicated(duplicated_assays))) {
    duplicated_assays <- duplicated_assays |>
      dplyr::filter(
        duplicated(.env[["duplicated_assays"]])
      ) |>
      dplyr::pull(
        check_log$col_names$assay
      ) |>
      unique()

    cli::cli_abort(
      c(
        "x" = "{cli::qty(duplicated_assays)} Detected
        {.val {length(duplicated_assays)}} duplicated assay{?s} in {.arg df}:
        {.val {duplicated_assays}}!",
        "i" = "{cli::qty(duplicated_assays)} Filter {?it/them} out from
        {.arg df} and {.arg test_results}."
      ),
      call = rlang::caller_env(),
      wrap = TRUE
    )
  }

  return(df)
}

test_prep <- function(df,
                      test_results,
                      check_log) {

  # remove non-overlapping assays between test_results and df ----

  no_overlap_assays <- helper_non_overlap_assays(
    df = df,
    test_results = test_results,
    check_log = check_log,
    which = "res"
  )

  if (length(no_overlap_assays) != 0L) {
    cli::cli_inform(
      "{.val {length(no_overlap_assays)}} assay{?s} in {.arg test_results}
      {?is/are} not represented in {.arg df} and will be removed from
      {.arg test_results}: {.val {no_overlap_assays}}"
    )

    test_results <- test_results |>
      dplyr::filter(
        !(.data[["OlinkID"]] %in% .env[["no_overlap_assays"]])
      )
  }

  return(test_results)

}

select_ont <- function(ontology,
                       organism,
                       test_mode = FALSE,
                       only_relevant = TRUE) {
  # Is Package installed
  rlang::check_installed(pkg = c("msigdbr"),
                         version = "24.1.0",
                         call = rlang::caller_env())

  # select MSigDB collection based on organism ----

  if (organism == "human") {
    if (test_mode == TRUE) {
      cli::cli_inform(
        "Test mode activated: using fixed version of MSigDB for human..."
      )

      msig_fixture_path <- system.file(
        "tests",
        "testthat",
        "data",
        "msidbr_v26.1.0_hs.parquet",
        package = "OlinkAnalyze"
      )

      if (identical(msig_fixture_path, "") || !file.exists(msig_fixture_path)) {
        cli::cli_abort(
          c(
            "x" = "Fixed MSigDB test file not found for {.arg test_mode} =
            {.val {test_mode}}.",
            "i" = "Expected file
            {.val {\"tests/testthat/data/msidbr_v26.1.0_hs.parquet\"}} to be
            present."
          )
        )
      }

      msig_df <- arrow::open_dataset(
        sources = msig_fixture_path
      ) |>
        dplyr::collect()

    } else {
      msig_df <- msigdbr::msigdbr(
        species = "Homo sapiens",
        collection = "C2"
      ) |>
        dplyr::bind_rows(
          msigdbr::msigdbr(
            species = "Homo sapiens",
            collection = "C5"
          )
        )
    }
  } else if (organism == "mouse") {
    if (test_mode == TRUE) {
      cli::cli_inform(
        "Test mode activated: using fixed version of MSigDB for mouse..."
      )

      msig_fixture_path <- system.file(
        "tests",
        "testthat",
        "data",
        "msidbr_v26.1.0_mm.parquet",
        package = "OlinkAnalyze"
      )

      if (identical(msig_fixture_path, "") || !file.exists(msig_fixture_path)) {
        cli::cli_abort(
          c(
            "x" = "Fixed MSigDB test file not found for {.arg test_mode} =
            {.val {test_mode}}.",
            "i" = "Expected file
            {.val {\"tests/testthat/data/msidbr_v26.1.0_mm.parquet\"}} to be
            present."
          )
        )
      }

      msig_df <- arrow::open_dataset(
        sources = msig_fixture_path
      ) |>
        dplyr::collect()
    } else {
      msig_df <- msigdbr::msigdbr(
        species = "Mus musculus",
        collection = "C2"
      ) |>
        dplyr::bind_rows(
          msigdbr::msigdbr(
            species = "Mus musculus",
            collection = "C5"
          )
        )
    }
  }

  # select annotation based on ontology ----

  ontology_msg <- ""

  if (ontology == "Reactome") {
    msig_df <- msig_df |>
      dplyr::filter(
        .data[["gs_subcollection"]] == "CP:REACTOME"
      )
    ontology_msg <- "Extracting Reactome Database from MSigDB..."
  } else if (ontology == "KEGG") {
    msig_df <- msig_df |>
      dplyr::filter(
        .data[["gs_subcollection"]] == "CP:KEGG_MEDICUS"
      )
    ontology_msg <- "Extracting KEGG Database from MSigDB..."
  } else if (ontology == "GO") {
    msig_df <- msig_df |>
      dplyr::filter(
        .data[["gs_subcollection"]] %in% c("GO:BP", "GO:CC", "GO:MF")
      )
    ontology_msg <- "Extracting GO Database from MSigDB..."
  } else if (ontology == "MSigDb_com") {
    msig_df <- msig_df |>
      dplyr::filter(
        stringr::str_detect(
          string = .data[["gs_subcollection"]],
          pattern = "KEGG",
          negate = TRUE
        )
      )
    ontology_msg <- "Using MSigDB without KEGG subcollections..."
  } else {
    ontology_msg <- "Using MSigDB..."
  }

  cli::cli_inform(ontology_msg)
  if (ontology == "KEGG") {
    cli::cli_alert_warning("KEGG is not approved for commercial use!")
  }

  # final cleanup ----

  if (only_relevant == TRUE) {
    msig_df <- msig_df |>
      dplyr::select(
        dplyr::any_of(
          c("gs_name", "gene_symbol")
        )
      )
  }

  return(msig_df)
}

results_to_genelist <- function(test_results) {
  estimate <- test_results[["estimate"]]
  names(estimate) <- test_results[["Assay"]]
  gene_list <- sort(estimate, decreasing = TRUE)

  return(gene_list)
}

gsea_pathwayenrichment <- function(gene_list,
                                   msig_df) {

  non_ovelapping_assays <- setdiff(
    x = names(gene_list),
    y = msig_df[["gene_symbol"]]
  )

  if (length(non_ovelapping_assays) != 0L) {
    cli::cli_inform(
      "{.val {length(non_ovelapping_assays)}} assays are not found in the
      database. Please check the names for the following assays in
      {.arg test_results} and {.arg df}: {.val {non_ovelapping_assays}}."
    )
  }

  gsea <- clusterProfiler::GSEA(geneList = gene_list,
                                TERM2GENE = msig_df,
                                pvalueCutoff = 1,
                                verbose = FALSE) |>
    suppressPackageStartupMessages() |>
    suppressMessages() |>
    suppressWarnings()

  if (is.null(gsea)) {
    cli::cli_warn(
      "No remaining pathways within the range 10-500 proteins!"
    )
    return(NULL)
  } else {
    return(dplyr::filter(gsea@result, !is.na(.data[["ID"]])))
  }
}

ora_pathwayenrichment <- function(test_results,
                                  msig_df,
                                  pvalue_cutoff,
                                  estimate_cutoff) {
  sig_genes <- test_results |>
    dplyr::filter(
      .data[["Adjusted_pval"]] < .env[["pvalue_cutoff"]]
    ) |>
    dplyr::filter(
      abs(.data[["estimate"]]) > .env[["estimate_cutoff"]]
    ) |>
    dplyr::distinct(
      .data[["Assay"]]
    ) |>
    dplyr::pull(
      .data[["Assay"]]
    )

  universe <- test_results |>
    dplyr::distinct(
      .data[["Assay"]]
    ) |>
    dplyr::pull(
      .data[["Assay"]]
    )

  non_ovelapping_assays <- setdiff(
    x = universe,
    y = msig_df[["gene_symbol"]]
  )

  if (length(non_ovelapping_assays) != 0L) {
    cli::cli_inform(
      "{.val {length(non_ovelapping_assays)}} assays are not found in the
      database. Please check the names for the following assays in
      {.arg test_results} and {.arg df}: {.val {non_ovelapping_assays}}."
    )
  }

  ora <- clusterProfiler::enricher(gene = sig_genes,
                                   universe = universe,
                                   TERM2GENE = msig_df,
                                   pvalueCutoff = 1) |>
    suppressPackageStartupMessages() |>
    suppressMessages()

  if (is.null(ora)) {
    cli::cli_warn(
      "No remaining pathways within the range 10-500 proteins!"
    )
    return(NULL)
  } else {
    return(ora@result)
  }
}

Try the OlinkAnalyze package in your browser

Any scripts or data that you put into this service are public.

OlinkAnalyze documentation built on June 24, 2026, 1:06 a.m.