Source code for acore.enrichment_analysis

"""Enrichment Analysis Module. Contains different functions to perform enrichment
analysis.

Most things in this module are covered in https://www.youtube.com/watch?v=2NC1QOXmc5o
by Lars Juhl Jensen.
"""

from __future__ import annotations

import logging
import os
import re
import uuid

import gseapy as gp
import pandas as pd
from pandera.typing.pandas import DataFrame

from acore.enrichment_analysis.annotate import annotate_features
from acore.enrichment_analysis.statistical_tests.fisher import run_fisher
from acore.enrichment_analysis.statistical_tests.kolmogorov_smirnov import (
    run_kolmogorov_smirnov,
)
from acore.multiple_testing import apply_pvalue_correction
from acore.types.enrichment_analysis import EnrichmentAnalysisSchema

logger = logging.getLogger(__name__)

TYPE_COLS_MSG = """
columns: 'terms', 'identifiers', 'foreground',
    'background', foreground_pop, background_pop, 'pvalue', 'padj' and 'rejected'.
"""

__all__ = [
    "run_site_regulation_enrichment",
    "run_up_down_regulation_enrichment",
    "run_fisher",
    "run_kolmogorov_smirnov",
]


# ! undocumented for now (find usage example)
[docs] def run_site_regulation_enrichment( regulation_data: pd.DataFrame, annotation: pd.DataFrame, identifier: str = "identifier", groups: list[str] = ("group1", "group2"), annotation_col: str = "annotation", rejected_col: str = "rejected", group_col: str = "group", method: str = "fisher", regex: str = "(\\w+~.+)_\\w\\d+\\-\\w+", correction: str = "fdr_bh", remove_duplicates: bool = False, ) -> DataFrame[EnrichmentAnalysisSchema]: r""" This function runs a simple enrichment analysis for significantly regulated protein sites in a dataset. :param regulation_data: pandas.DataFrame resulting from differential regulation analysis. :param annotation: pandas.DataFrame with annotations for features (columns: 'annotation', 'identifier' (feature identifiers), and 'source'). :param str identifier: name of the column from annotation containing feature identifiers. :param list groups: column names from regulation_data containing group identifiers. :param str annotation_col: name of the column from annotation containing annotation terms. :param str rejected_col: name of the column from regulation_data containing boolean for rejected null hypothesis. :param str group_col: column name for new column in annotation dataframe determining if feature belongs to foreground or background. :param str method: method used to compute enrichment (only 'fisher' is supported currently). :param str regex: how to extract the annotated identifier from the site identifier :return: pandas.DataFrame with columns: 'terms', 'identifiers', 'foreground', 'background', foreground_pop, background_pop, 'pvalue', 'padj' and 'rejected'. :raises ValueError: if regulation_data is `None` or empty. Example:: result = run_site_regulation_enrichment(regulation_data, annotation, identifier='identifier', groups=['group1', 'group2'], annotation_col='annotation', rejected_col='rejected', group_col='group', method='fisher', match="(\\w+~.+)_\\w\\d+\\-\\w+" ) """ result = pd.DataFrame() if regulation_data is None or regulation_data.empty: raise ValueError("regulation_data is empty") new_ids = [] # find any identifiers with a PTM and save only prot+gene identifer for ident in regulation_data[identifier].tolist(): match = re.search(regex, ident) if match is not None: new_ids.append( match.group(1) ) # removes the PTM extension of the identifier of CKG else: new_ids.append(ident) # so this is normalizing the identifiers to ignore the PTM extension regulation_data[identifier] = new_ids # matches are used as identifiers if remove_duplicates: regulation_data = regulation_data.drop_duplicates(subset=[identifier]) result = run_regulation_enrichment( regulation_data=regulation_data, annotation=annotation, identifier=identifier, group_col=groups, annotation_col=annotation_col, rejected_col=rejected_col, method=method, correction=correction, ) return result
[docs] def run_up_down_regulation_enrichment( regulation_data: pd.DataFrame, annotation: pd.DataFrame, identifier: str = "identifier", groups: list[str] = ("group1", "group2"), annotation_col: str = "annotation", # rejected_col: str = "rejected", # could be passed pval_col: str = "pval", group_col: str = "group", log2fc_col: str = "log2FC", method: str = "fisher", min_detected_in_set: int = 2, correction: str = "fdr_bh", correction_alpha: float = 0.05, lfc_cutoff: float = 1, ) -> DataFrame[EnrichmentAnalysisSchema]: """ This function runs a simple enrichment analysis for significantly regulated proteins distinguishing between up- and down-regulated. :param pandas.DataFrame regulation_data: pandas.DataFrame resulting from differential regulation analysis (CKG's regulation table). :param pandas.DataFrame annotation: pandas.DataFrame with annotations for features (columns: 'annotation', 'identifier' (feature identifiers), and 'source'). :param str identifier: name of the column from annotation containing feature identifiers. :param list[str] groups: column names from regulation_data containing group identifiers. See `pandas.DataFrame.groupby`_ for more information. .. _pandas.DataFrame.groupby: https://pandas.pydata.org/pandas-docs/stable/\ reference/api/pandas.DataFrame.groupby.html :param str annotation_col: name of the column from annotation containing annotation terms. :param str rejected_col: name of the column from regulation_data containing boolean for rejected null hypothesis. :param str group_col: column name for new column in annotation dataframe determining if feature belongs to foreground or background. :param str method: method used to compute enrichment (only 'fisher' is supported currently). :param str correction: method to be used for multiple-testing correction :param float alpha: adjusted p-value cutoff to define significance :param float lfc_cutoff: log fold-change cutoff to define practical significance :return: DataFrame adhering to EnrichmentAnalysisSchema :rtype: DataFrame[EnrichmentAnalysisSchema] Example:: result = run_up_down_regulation_enrichment( regulation_data, annotation, identifier='identifier', groups=['group1', 'group2'], annotation_col='annotation', rejected_col='rejected', group_col='group', method='fisher', correction='fdr_bh', alpha=0.05, lfc_cutoff=1, ) """ if isinstance(groups, str): groups = [groups] if isinstance(groups, tuple): groups = list(groups) if len(groups) != 2: raise ValueError("groups should contains exactly two columns.") ret = list() # In case of multiple comparisons this is used to get all possible combinations for g1, g2 in regulation_data.groupby(groups).groups: df = regulation_data.groupby(groups).get_group((g1, g2)) df["up_pairwise_regulation"] = (df[pval_col] <= correction_alpha) & ( df[log2fc_col] >= lfc_cutoff ) df["down_pairwise_regulation"] = (df[pval_col] <= correction_alpha) & ( df[log2fc_col] <= -lfc_cutoff ) comparison_tag = str(g1) + "~" + str(g2) if not regulation_data[identifier].is_unique: logger.warning( "Column '%s' in regulation_data contains duplicated values for comparison %s.", identifier, comparison_tag, ) for rej_col, direction in zip( ("up_pairwise_regulation", "down_pairwise_regulation"), ("upregulated", "downregulated"), ): _enrichment = run_regulation_enrichment( df, annotation, identifier=identifier, annotation_col=annotation_col, rejected_col=rej_col, group_col=group_col, method=method, min_detected_in_set=min_detected_in_set, correction=correction, correction_alpha=correction_alpha, ) _enrichment["direction"] = direction _enrichment["comparison"] = comparison_tag ret.append(_enrichment) ret = pd.concat(ret) if not ret.empty: ret["rejected"] = ret["rejected"].astype(bool) ret = EnrichmentAnalysisSchema.validate(ret) return ret
def run_regulation_enrichment( regulation_data: pd.DataFrame, annotation: pd.DataFrame, identifier: str = "identifier", annotation_col: str = "annotation", rejected_col: str = "rejected", group_col: str = "group", method: str = "fisher", min_detected_in_set: int = 2, correction: str = "fdr_bh", correction_alpha: float = 0.05, ) -> pd.DataFrame: """ This function runs a simple enrichment analysis for significantly regulated features in a dataset. :param regulation_data: pandas.DataFrame resulting from differential regulation analysis. :param annotation: pandas.DataFrame with annotations for features (columns: 'annotation', 'identifier' (feature identifiers), and 'source'). :param str identifier: name of the column from annotation containing feature identifiers. It should be present both in `regulation_data` and `annotation`. In `regulation_data` it should be unique, while in `annotation` it can contain duplicates as one identifier can be part of multiple pathways. :param str annotation_col: name of the column from annotation containing annotation terms. :param str rejected_col: name of the column from `regulation_data` containing boolean for rejected null hypothesis. :param str group_col: column name for new column in annotation dataframe determining if feature belongs to foreground or background. :param str method: method used to compute enrichment (only 'fisher' is supported currently). :param str correction: method to be used for multiple-testing correction :return: pandas.DataFrame with columns: 'terms', 'identifiers', 'foreground', 'background', 'foreground_pop', 'background_pop', 'pvalue', 'padj' and 'rejected'. Example:: result = run_regulation_enrichment( regulation_data, annotation, identifier='identifier', annotation_col='annotation', rejected_col='rejected', group_col='group', method='fisher', min_detected_in_set=2, correction='fdr_bh', correction_alpha=0.05, ) """ # ? can we remove NA features in that column? if regulation_data[rejected_col].isna().any(): raise ValueError(f"Rejected column '{rejected_col}' contains missing values.") mask_rejected = regulation_data[rejected_col].astype(bool) if not regulation_data[identifier].is_unique: raise ValueError(f"Column '{identifier}' in regulation_data has to be unique.") foreground_list = regulation_data.loc[mask_rejected, identifier] background_list = regulation_data.loc[~mask_rejected, identifier] foreground_pop = len(foreground_list) background_pop = len(regulation_data[identifier]) # needs to allow for missing annotations # ! this step needs unique identifiers in the regulation_data # group_col contains either 'foreground', 'background' or NA annotation[group_col] = annotate_features( features=annotation[identifier], in_foreground=foreground_list, in_background=background_list, ) annotation = annotation.dropna(subset=[group_col]) result = run_enrichment( annotation, foreground_id="foreground", background_id="background", foreground_pop=foreground_pop, background_pop=background_pop, annotation_col=annotation_col, group_col=group_col, identifier_col=identifier, method=method, correction=correction, min_detected_in_set=min_detected_in_set, correction_alpha=correction_alpha, ).convert_dtypes(convert_boolean=False) return result def run_enrichment( data: pd.DataFrame, foreground_id: str, background_id: str, foreground_pop: int, background_pop: int, min_detected_in_set: int = 2, annotation_col: str = "annotation", group_col: str = "group", identifier_col: str = "identifier", method: str = "fisher", correction: str = "fdr_bh", correction_alpha: float = 0.05, ) -> pd.DataFrame: """ Computes enrichment of the foreground relative to a given backgroung, using Fisher's exact test, and corrects for multiple hypothesis testing. :param data: pandas.DataFrame with annotations for dataset features (columns: 'annotation', 'identifier', 'group'). :param str foreground_id: group identifier of features that belong to the foreground. :param str background_id: group identifier of features that belong to the background. :param int foreground_pop: number of features in the foreground. :param int background_pop: number of features in the background. :param int min_detected_in_set: minimum number of features in the foreground :param str annotation_col: name of the column containing annotation terms. :param str group_col: name of column containing the group identifiers, e.g. specifying belonging to 'foreground' or 'background'. :param str identifier_col: name of column containing dependent variables identifiers. :param str method: method used to compute enrichment (only 'fisher' is supported currently). :param str correction: method to be used for multiple-testing correction. :param float correction_alpha: adjusted p-value cutoff to define significance. :return: pandas.DataFrame with columns: annotation terms, features, number of foreground/background features in each term, p-values and corrected p-values Columns are: 'terms', 'identifiers', 'foreground', 'background', 'foreground_pop', 'background_pop', 'pvalue', 'padj' and 'rejected'. Example:: result = run_enrichment( data, foreground='foreground', background='background', foreground_pop=len(foreground_list), background_pop=len(background_list), annotation_col='annotation', group_col='group', identifier_col='identifier', method='fisher', ) """ if method != "fisher": raise ValueError("Only Fisher's exact test is supported at the moment.") terms = [] ids = [] pvalues = [] fnum = [] bnum = [] countsdf = ( data.groupby([annotation_col, group_col]) .agg(["count"])[(identifier_col, "count")] .reset_index() ) countsdf.columns = [annotation_col, group_col, "count"] mask_in_foreground = countsdf[group_col] == foreground_id terms_in_foreground = countsdf.loc[mask_in_foreground, annotation_col].unique() for annotation in terms_in_foreground: counts = countsdf[countsdf[annotation_col] == annotation] num_foreground = int( counts.loc[counts[group_col] == foreground_id, "count"].squeeze() ) num_background = 0 # initialize to 0 in case all features are foreground num_background = counts.loc[ counts[group_col] == background_id, "count" ].squeeze() if isinstance(num_background, pd.Series) and num_background.empty: # if no value is found in counts, an empty series is returned num_background = 0 if num_foreground >= min_detected_in_set: _, pvalue = run_fisher( [num_foreground, foreground_pop - num_foreground], [num_background, background_pop - foreground_pop - num_background], ) fnum.append(num_foreground) bnum.append(num_background) terms.append(annotation) pvalues.append(pvalue) ids.append( ",".join( data.loc[ (data[annotation_col] == annotation) & (data[group_col] == foreground_id), identifier_col, ] ) ) if len(pvalues) >= 1: rejected, padj = apply_pvalue_correction( pvalues, alpha=correction_alpha, method=correction, ) result = pd.DataFrame( { "terms": terms, "identifiers": ids, "foreground": fnum, "background": bnum, "foreground_pop": foreground_pop, # no. of foreground features, constant "background_pop": background_pop, # no. of included features, constant "pvalue": pvalues, "padj": padj, "rejected": rejected.astype(bool), } ) result = result.sort_values(by="padj", ascending=True) else: logger.warning( "No significant enrichment found with the given parameters. " "Returning an empty DataFrame." ) # ToDo: Should we return an empty DataFrame with the expected columns? result = pd.DataFrame() return result def run_ssgsea( data: pd.DataFrame, annotation: str, set_index: list[str] = None, annotation_col: str = "annotation", identifier_col: str = "identifier", outdir: str = "tmp", min_size: int = 15, max_size: int = 500, scale: bool = False, permutations: int = 0, ) -> pd.DataFrame: """ Project each sample within a data set onto a space of gene set enrichment scores using the single sample gene set enrichment analysis (ssGSEA) projection methodology described in Barbie et al., 2009: https://www.nature.com/articles/nature08460#Sec3 (search "Single Sample" GSEA). :param pd.DataFrame data: pandas.DataFrame with the quantified features (i.e. subject x proteins) :param str annotation: pandas.DataFrame with the annotation to be used in the enrichment (i.e. CKG pathway annotation file) :param list[str] set_index: column/s to be used as index. Enrichment will be calculated for these values (i.e ["subject"] will return subjects x pathways matrix of enrichment scores) :param str annotation_col: name of the column containing annotation terms. :param str identifier_col: name of column containing dependent variables identifiers. :param str out_dir: directory path where results will be stored (default None, tmp folder is used) :param int min_size: minimum number of features (i.e. proteins) in enriched terms (i.e. pathways) :param int max_size: maximum number of features (i.e. proteins) in enriched terms (i.e. pathways) :param bool scale: whether or not to scale the data :param int permutations: number of permutations used in the ssgsea analysis :return: pandas.DataFrame containing unnormalized enrichment scores (`ES`) for each sample, and normalized enrichment scores (`NES`) with the enriched `Term` and sample `Name`. :rtype: pandas.DataFrame Example:: stproject = "P0000008" p = project.Project( stproject, datasets={}, knowledge=None, report={}, configuration_files=None, ) p.build_project(False) p.generate_report() proteomics_dataset = p.get_dataset("proteomics") annotations = proteomics_dataset.get_dataframe("pathway annotation") processed = proteomics_dataset.get_dataframe('processed') result = run_ssgsea( processed, annotations, annotation_col='annotation', identifier_col='identifier', set_index=['group', 'sample', 'subject'], outdir=None, min_size=10, scale=False, permutations=0 ) """ df = data.copy() if not os.path.exists(outdir): os.makedirs(outdir) # Comine columns to create a unique name for each set (?) name = [] if set_index is None: index = data.index.to_frame() set_index = index.columns.tolist() else: index = data[set_index] df = df.drop(set_index, axis=1) for _, row in index.iterrows(): name.append( "_".join(row[set_index].tolist()) ) # this assumes strings as identifiers df["Name"] = name index.index = name df = df.set_index("Name").transpose() if annotation_col not in annotation: raise ValueError( f"Missing Annotation Column: {annotation_col}" " as specified by `annotation_col`" ) if identifier_col not in annotation: raise ValueError( f"Missing Identifier Column: {identifier_col}" " as specified by `identifier_col`" ) grouped_annotations = ( annotation.groupby(annotation_col)[identifier_col].apply(list).reset_index() ) fid = uuid.uuid4() file_path = os.path.join(outdir, str(fid) + ".gmt") with open(file_path, "w", encoding="utf8") as out: for _, row in grouped_annotations.iterrows(): out.write( row[annotation_col] + "\t" + "\t".join(list(filter(None, row[identifier_col]))) + "\n" ) enrichment = gp.ssgsea( data=df, gene_sets=str(file_path), outdir=outdir, min_size=min_size, max_size=max_size, scale=scale, permutation_num=permutations, no_plot=True, processes=1, seed=10, format="png", ) result = pd.DataFrame(enrichment.res2d).set_index("Name") # potentially return wide format in separate format # result = {"es": enrichment_es, "nes": enrichment_nes} return result