gpath2vec 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gpath2vec/ea.py ADDED
@@ -0,0 +1,201 @@
1
+ """pathway enrichment via fisher's exact test with fdr correction."""
2
+
3
+ import io
4
+ import tarfile
5
+ import zipfile
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import scipy.stats as stats
10
+ import statsmodels.stats.multitest as multi
11
+
12
+ from . import utils
13
+
14
+ np.random.seed(1234)
15
+
16
+
17
+ def gene_mappings():
18
+ """stId -> name + gene list from ReactomePathways.gmt.zip"""
19
+ raw = utils.fetch("ReactomePathways.gmt.zip",
20
+ "https://reactome.org/download/current/ReactomePathways.gmt.zip",
21
+ binary=True)
22
+ if raw is None:
23
+ return []
24
+ with zipfile.ZipFile(io.BytesIO(raw)) as zf:
25
+ lines = zf.open(zf.infolist()[0]).readlines()
26
+ return [
27
+ dict(name=(p := line.decode("utf8").strip().split("\t"))[0],
28
+ stId=p[1], genes=p[2:])
29
+ for line in lines
30
+ ]
31
+
32
+
33
+ def ehld_stids():
34
+ """high-level pathway stIds (ehld)."""
35
+ text = utils.fetch("svgsummary.txt",
36
+ "https://reactome.org/download/current/ehld/svgsummary.txt")
37
+ return [s for s in (text or "").splitlines() if "R-" in s]
38
+
39
+
40
+ def sbgn_stids():
41
+ """low-level pathway stIds (sbgn minus ehld)."""
42
+ raw = utils.fetch("homo_sapiens.sbgn.tar.gz",
43
+ "https://reactome.org/download/current/homo_sapiens.sbgn.tar.gz",
44
+ binary=True)
45
+ if raw is None:
46
+ return []
47
+ names = tarfile.open(fileobj=io.BytesIO(raw)).getnames()
48
+ ehlds = set(ehld_stids())
49
+ return [n.replace(".sbgn", "") for n in names if n.replace(".sbgn", "") not in ehlds]
50
+
51
+
52
+ def level_stids(level):
53
+ """return set of stIds for a given level: high, mid, low, or all."""
54
+ if level == "all":
55
+ return None
56
+ if level == "high":
57
+ return set(ehld_stids())
58
+ if level == "low":
59
+ return set(sbgn_stids())
60
+ if level == "mid":
61
+ all_stids = set(r["stId"] for r in gene_mappings())
62
+ return all_stids - set(ehld_stids()) - set(sbgn_stids())
63
+ raise ValueError(f"unknown level: {level}")
64
+
65
+
66
+ def filter_pathways(level="all", gene_filter=None, min_genes=3):
67
+ """
68
+ filter pathway universe before EA.
69
+
70
+ level: high/mid/low/all
71
+ gene_filter: optional set of genes to restrict which pathways are included
72
+ min_genes: minimum pathway size
73
+ returns: filtered gene_mappings dataframe
74
+ """
75
+ mappings = gene_mappings()
76
+ if not mappings:
77
+ return pd.DataFrame(columns=["name", "stId", "genes"])
78
+ gm = pd.json_normalize(mappings)
79
+
80
+ stids = level_stids(level)
81
+ if stids is not None:
82
+ gm = gm[gm.stId.isin(stids)]
83
+
84
+ if gene_filter is not None:
85
+ gf = set(gene_filter)
86
+ gm = gm[gm.genes.apply(lambda g: bool(set(g) & gf))]
87
+
88
+ gm = gm[gm.genes.apply(len) > min_genes].copy()
89
+ return gm
90
+
91
+
92
+ def _enrich_chunk(items, u_set, pw_genes, n_universe, n_pw):
93
+ """fisher + per-cluster BH-FDR for a chunk of (cname, cgenes). pure;
94
+ output order == input order, so parallel == serial bit-identically."""
95
+ out = []
96
+ for cname, cgenes in items:
97
+ cset = set(cgenes) & u_set
98
+ c_in = len(cset)
99
+ c_out = n_universe - c_in
100
+
101
+ rows = []
102
+ for stid, pset in pw_genes.items():
103
+ a = len(cset & pset)
104
+ b = len(pset) - a
105
+ c = c_in - a
106
+ d = c_out - b
107
+ oddsratio, pvalue = stats.fisher_exact([[a, b], [c, d]], alternative="greater")
108
+ rows.append(dict(cluster=str(cname), stId=stid,
109
+ oddsratio=oddsratio, pvalue=pvalue))
110
+
111
+ edf = pd.DataFrame(rows)
112
+ corrected = multi.multipletests(edf.pvalue, alpha=0.05, method="fdr_bh",
113
+ is_sorted=False, returnsorted=False)
114
+ edf["fdr_bh"] = corrected[1]
115
+ edf["sig_pathway"] = corrected[0]
116
+ edf["pathway_adjpvalue"] = -np.log10(edf.fdr_bh) / n_pw
117
+ edf.fillna(value={"pathway_adjpvalue": 1}, inplace=True)
118
+ out.append(edf)
119
+ return out
120
+
121
+
122
+ def enrich(gene_sets, level="low", gene_filter=None, min_genes=3, n_jobs=1):
123
+ """
124
+ fisher's exact test per gene-set/pathway pair with fdr correction.
125
+
126
+ gene_sets: {name: [genes]}
127
+ level: high/mid/low/all
128
+ gene_filter: optional set of genes to restrict pathway universe
129
+ min_genes: minimum pathway size to include
130
+ n_jobs: 1 = serial (default, unchanged behavior); >1 or -1 parallelizes
131
+ the per-cluster loop. order-preserving, so the result is
132
+ bit-identical to the serial path.
133
+ returns: dataframe with cluster, stId, name, oddsratio, pvalue, fdr_bh
134
+ """
135
+ gm = filter_pathways(level=level, gene_filter=gene_filter, min_genes=min_genes)
136
+ if gm.empty:
137
+ return pd.DataFrame()
138
+
139
+ universe = sorted(set(g for genes in gm.genes for g in genes))
140
+ u_set = set(universe)
141
+ pw_genes = {row.stId: set(row.genes) & u_set for _, row in gm.iterrows()}
142
+ n_pw = len(pw_genes)
143
+ n_universe = len(universe)
144
+
145
+ items = list(gene_sets.items())
146
+ if n_jobs == 1 or len(items) <= 1:
147
+ results = _enrich_chunk(items, u_set, pw_genes, n_universe, n_pw)
148
+ else:
149
+ from joblib import Parallel, delayed, cpu_count
150
+ nj = cpu_count() if n_jobs == -1 else n_jobs
151
+ k = max(1, -(-len(items) // (nj * 4))) # ~4 chunks/worker
152
+ chunks = [items[i:i + k] for i in range(0, len(items), k)]
153
+ nested = Parallel(n_jobs=n_jobs)(
154
+ delayed(_enrich_chunk)(ch, u_set, pw_genes, n_universe, n_pw)
155
+ for ch in chunks)
156
+ results = [edf for sub in nested for edf in sub]
157
+
158
+ ea_df = pd.concat(results, ignore_index=True)
159
+ ea_df["name"] = ea_df.stId.map(gm.set_index("stId")["name"])
160
+ return ea_df
161
+
162
+
163
+ def aggregate_min_fdr(ea_records):
164
+ """min-FDR per pathway across all niches; the correct sig/notsig basis.
165
+
166
+ accepts records in either shape:
167
+ - {"stId": ..., "fdr_bh": ...} (long-form ea_df rows)
168
+ - {"stId": ..., "entities": {"fdr": ...}} (Net.enrichment format)
169
+ pandas Series rows (from ea_df.iterrows()) also work via .get().
170
+ returns records in Net.enrichment format.
171
+ """
172
+ best = {}
173
+ for r in ea_records:
174
+ stid = r["stId"]
175
+ fdr = r.get("fdr_bh", r.get("entities", {}).get("fdr", 1.0))
176
+ if stid not in best or fdr < best[stid]:
177
+ best[stid] = fdr
178
+ return [{"stId": s, "entities": {"fdr": f}} for s, f in best.items()]
179
+
180
+
181
+ def ea_matrix(ea_df, weight="fdr", sig_only=True):
182
+ """
183
+ pivot enrichment results into cluster x pathway matrix.
184
+
185
+ weight: "fdr" (uses 1 - fdr_bh) or "oddsratio"
186
+ sig_only: if true, zero out non-significant entries
187
+ returns: dataframe with clusters as rows, pathway stIds as columns
188
+ """
189
+ df = ea_df.copy()
190
+ if weight == "fdr":
191
+ df["value"] = 1 - df.fdr_bh
192
+ elif weight == "oddsratio":
193
+ df["value"] = df.oddsratio
194
+ else:
195
+ raise ValueError(f"unknown weight: {weight}, expected fdr/oddsratio")
196
+
197
+ if sig_only:
198
+ df.loc[~df.sig_pathway, "value"] = 0.0
199
+
200
+ matrix = df.pivot_table(index="cluster", columns="stId", values="value", fill_value=0.0)
201
+ return matrix