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/__init__.py +10 -0
- gpath2vec/aucell.py +274 -0
- gpath2vec/cli.py +602 -0
- gpath2vec/compare.py +86 -0
- gpath2vec/ea.py +201 -0
- gpath2vec/embedder.py +554 -0
- gpath2vec/net.py +140 -0
- gpath2vec/utils.py +124 -0
- gpath2vec-3.0.0.dist-info/METADATA +342 -0
- gpath2vec-3.0.0.dist-info/RECORD +14 -0
- gpath2vec-3.0.0.dist-info/WHEEL +5 -0
- gpath2vec-3.0.0.dist-info/entry_points.txt +2 -0
- gpath2vec-3.0.0.dist-info/licenses/LICENSE +21 -0
- gpath2vec-3.0.0.dist-info/top_level.txt +1 -0
gpath2vec/cli.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""cli for gpath2vec: enrichment, network, embeddings."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import pickle
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import scipy.sparse as sp
|
|
13
|
+
|
|
14
|
+
from gpath2vec.ea import enrich, ea_matrix, aggregate_min_fdr
|
|
15
|
+
from gpath2vec.net import Net
|
|
16
|
+
from gpath2vec.embedder import (
|
|
17
|
+
PathwayMetapath2vec, SVDEmbedder, SpectralGraphEmbedder,
|
|
18
|
+
LINEEmbedder, VAEEmbedder
|
|
19
|
+
)
|
|
20
|
+
from gpath2vec.aucell import compute_aucell, topk_per_niche
|
|
21
|
+
|
|
22
|
+
METHODS = ["metapath2vec", "svd", "spectral", "line", "vae"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_embedder(method, graph, ea_mat, study_id, dimensions, epochs, lr,
|
|
26
|
+
window, seed=1234):
|
|
27
|
+
"""run the selected embedding method, return embeddings dict."""
|
|
28
|
+
if method == "metapath2vec":
|
|
29
|
+
embedder = PathwayMetapath2vec(graph=graph, name=study_id, seed=seed)
|
|
30
|
+
walks = embedder.model
|
|
31
|
+
click.echo(f"{len(walks)} random walks")
|
|
32
|
+
embedder.train_embeddings(walks=walks, dimensions=dimensions,
|
|
33
|
+
window_size=window, epochs=epochs, lr=lr,
|
|
34
|
+
seed=seed)
|
|
35
|
+
elif method == "svd":
|
|
36
|
+
if ea_mat is None:
|
|
37
|
+
raise click.UsageError("svd requires an ea matrix (run enrichment first)")
|
|
38
|
+
embedder = SVDEmbedder(ea_mat, dimensions=dimensions)
|
|
39
|
+
elif method == "spectral":
|
|
40
|
+
embedder = SpectralGraphEmbedder(graph, dimensions=dimensions)
|
|
41
|
+
elif method == "line":
|
|
42
|
+
embedder = LINEEmbedder(graph, dimensions=dimensions, epochs=epochs,
|
|
43
|
+
lr=lr, seed=seed)
|
|
44
|
+
elif method == "vae":
|
|
45
|
+
if ea_mat is None:
|
|
46
|
+
raise click.UsageError("vae requires an ea matrix (run enrichment first)")
|
|
47
|
+
embedder = VAEEmbedder(ea_mat, dimensions=dimensions, epochs=epochs,
|
|
48
|
+
lr=lr, seed=seed)
|
|
49
|
+
else:
|
|
50
|
+
raise click.UsageError(f"unknown method: {method}")
|
|
51
|
+
|
|
52
|
+
return embedder
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_genes(genes_str):
|
|
56
|
+
"""parse gene list from comma-separated string or file path."""
|
|
57
|
+
if Path(genes_str).is_file():
|
|
58
|
+
with open(genes_str) as f:
|
|
59
|
+
return [line.strip() for line in f if line.strip()]
|
|
60
|
+
return [g.strip() for g in genes_str.split(",") if g.strip()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_gene_sets(gene_sets_str):
|
|
64
|
+
"""parse gene sets from json file or json string. returns {name: [genes]}."""
|
|
65
|
+
p = Path(gene_sets_str)
|
|
66
|
+
if p.is_file():
|
|
67
|
+
with open(p) as f:
|
|
68
|
+
return json.load(f)
|
|
69
|
+
return json.loads(gene_sets_str)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _make_id(study_id):
|
|
73
|
+
return study_id or f"study_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _outdir(path):
|
|
77
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.group()
|
|
81
|
+
def cli():
|
|
82
|
+
"""convert biological pathways to embeddings with enrichment analysis"""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command("enrichment")
|
|
87
|
+
@click.option("--genes", required=True,
|
|
88
|
+
help="comma-separated genes or path to file (one per line)")
|
|
89
|
+
@click.option("--gene-sets", required=False,
|
|
90
|
+
help="json file or string with {name: [genes]} for multiple gene lists")
|
|
91
|
+
@click.option("--level", default="low", show_default=True,
|
|
92
|
+
type=click.Choice(["high", "mid", "low", "all"]))
|
|
93
|
+
@click.option("--gene-filter", required=False,
|
|
94
|
+
help="comma-separated genes to filter pathway universe (e.g. TF genes)")
|
|
95
|
+
@click.option("--weight", default="fdr", show_default=True,
|
|
96
|
+
type=click.Choice(["fdr", "oddsratio"]),
|
|
97
|
+
help="weight type for ea matrix")
|
|
98
|
+
@click.option("--min-genes", default=3, show_default=True)
|
|
99
|
+
@click.option("--out-path", required=True, help="output path (json)")
|
|
100
|
+
def perform_enrichment(genes, gene_sets, level, gene_filter, weight, min_genes, out_path):
|
|
101
|
+
"""run pathway enrichment analysis via fisher's exact test"""
|
|
102
|
+
if gene_sets:
|
|
103
|
+
gs = _parse_gene_sets(gene_sets)
|
|
104
|
+
else:
|
|
105
|
+
gene_list = _parse_genes(genes)
|
|
106
|
+
gs = {"study": gene_list}
|
|
107
|
+
|
|
108
|
+
gf = _load_gene_filter(gene_filter) if gene_filter else None
|
|
109
|
+
|
|
110
|
+
click.echo(f"enrichment: {len(gs)} gene sets, level={level}")
|
|
111
|
+
ea_df = enrich(gs, level=level, gene_filter=gf, min_genes=min_genes)
|
|
112
|
+
|
|
113
|
+
if ea_df.empty:
|
|
114
|
+
click.echo("no results")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
sig = ea_df.sig_pathway.sum()
|
|
118
|
+
click.echo(f"{len(ea_df)} tests, {sig} significant")
|
|
119
|
+
|
|
120
|
+
# save ea dataframe as json
|
|
121
|
+
_outdir(out_path)
|
|
122
|
+
records = ea_df.to_dict(orient="records")
|
|
123
|
+
with open(out_path, "w") as f:
|
|
124
|
+
json.dump(records, f, indent=2)
|
|
125
|
+
|
|
126
|
+
# save ea matrix alongside
|
|
127
|
+
matrix = ea_matrix(ea_df, weight=weight)
|
|
128
|
+
matrix_path = out_path.replace(".json", "_matrix.csv")
|
|
129
|
+
matrix.to_csv(matrix_path)
|
|
130
|
+
click.echo(f"saved to {out_path}")
|
|
131
|
+
click.echo(f"ea matrix ({matrix.shape[0]} x {matrix.shape[1]}) saved to {matrix_path}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@cli.command("network")
|
|
135
|
+
@click.option("--enrichment-path", required=True, help="enrichment results json")
|
|
136
|
+
@click.option("--study-id", required=False)
|
|
137
|
+
@click.option("--level", default="all", show_default=True,
|
|
138
|
+
type=click.Choice(["high", "mid", "low", "all"]))
|
|
139
|
+
@click.option("--gene-filter", required=False,
|
|
140
|
+
help="comma-separated genes to filter pathway universe")
|
|
141
|
+
@click.option("--weight", default="fdr", show_default=True,
|
|
142
|
+
type=click.Choice(["fdr", "oddsratio"]),
|
|
143
|
+
help="weight type for cluster edges")
|
|
144
|
+
@click.option("--digraph", is_flag=True, default=True, show_default=True)
|
|
145
|
+
@click.option("--induce", is_flag=True, default=False, show_default=True)
|
|
146
|
+
@click.option("--out-path", required=True, help="output path (pickle)")
|
|
147
|
+
def create_network(enrichment_path, study_id, level, gene_filter, weight, digraph, induce, out_path):
|
|
148
|
+
"""create a pathway network from enrichment results"""
|
|
149
|
+
assert Path(enrichment_path).is_file(), f"{enrichment_path} not found"
|
|
150
|
+
study_id = _make_id(study_id)
|
|
151
|
+
gf = _load_gene_filter(gene_filter) if gene_filter else None
|
|
152
|
+
|
|
153
|
+
with open(enrichment_path) as f:
|
|
154
|
+
ea_records = json.load(f)
|
|
155
|
+
|
|
156
|
+
# min-fdr per pathway across all niches
|
|
157
|
+
enrichment = aggregate_min_fdr(ea_records)
|
|
158
|
+
|
|
159
|
+
# build cluster dict from ea records
|
|
160
|
+
clusters = {}
|
|
161
|
+
for r in ea_records:
|
|
162
|
+
cname = r.get("cluster", "study")
|
|
163
|
+
if not r.get("sig_pathway", False):
|
|
164
|
+
continue
|
|
165
|
+
if cname not in clusters:
|
|
166
|
+
clusters[cname] = {}
|
|
167
|
+
val = (1 - r["fdr_bh"]) if weight == "fdr" else r.get("oddsratio", 1.0)
|
|
168
|
+
clusters[cname][r["stId"]] = val
|
|
169
|
+
|
|
170
|
+
net = Net(enrichment=enrichment, id=study_id, digraph=digraph,
|
|
171
|
+
induce=induce, level=level, gene_filter=gf,
|
|
172
|
+
clusters=clusters if clusters else None)
|
|
173
|
+
|
|
174
|
+
g = net.graph
|
|
175
|
+
sig = sum(1 for _, a in g.nodes(data=True) if a.get("node_type") == "sig")
|
|
176
|
+
n_clusters = sum(1 for _, a in g.nodes(data=True) if a.get("node_type") == "cluster")
|
|
177
|
+
click.echo(f"network: {g.number_of_nodes()} nodes, {g.number_of_edges()} edges, "
|
|
178
|
+
f"{sig} significant, {n_clusters} clusters")
|
|
179
|
+
|
|
180
|
+
_outdir(out_path)
|
|
181
|
+
net.save(out_path)
|
|
182
|
+
click.echo(f"saved to {out_path}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cli.command("embeddings")
|
|
186
|
+
@click.option("--network-path", required=True, help="network pickle file")
|
|
187
|
+
@click.option("--ea-matrix-path", required=False, help="ea matrix csv (required for svd)")
|
|
188
|
+
@click.option("--method", default="metapath2vec", show_default=True,
|
|
189
|
+
type=click.Choice(METHODS))
|
|
190
|
+
@click.option("--study-id", required=False)
|
|
191
|
+
@click.option("--dimensions", default=512, show_default=True)
|
|
192
|
+
@click.option("--window", default=5, show_default=True)
|
|
193
|
+
@click.option("--epochs", default=10, show_default=True)
|
|
194
|
+
@click.option("--lr", default=0.005, show_default=True)
|
|
195
|
+
@click.option("--seed", default=1234, show_default=True, type=int,
|
|
196
|
+
help="rng seed for reproducible embeddings")
|
|
197
|
+
@click.option("--out-path", required=True, help="output path (pickle)")
|
|
198
|
+
@click.option("--save-model", required=False, help="optional model save path")
|
|
199
|
+
def generate_embeddings(network_path, ea_matrix_path, method, study_id,
|
|
200
|
+
dimensions, window, epochs, lr, seed, out_path,
|
|
201
|
+
save_model):
|
|
202
|
+
"""generate embeddings from network (metapath2vec, svd, spectral, line)"""
|
|
203
|
+
import pandas as pd
|
|
204
|
+
|
|
205
|
+
assert Path(network_path).is_file(), f"{network_path} not found"
|
|
206
|
+
study_id = _make_id(study_id)
|
|
207
|
+
|
|
208
|
+
net = Net(id=study_id)
|
|
209
|
+
net.load(network_path)
|
|
210
|
+
|
|
211
|
+
ea_mat = None
|
|
212
|
+
if ea_matrix_path and Path(ea_matrix_path).is_file():
|
|
213
|
+
ea_mat = pd.read_csv(ea_matrix_path, index_col=0)
|
|
214
|
+
|
|
215
|
+
embedder = _run_embedder(method, net.graph, ea_mat, study_id,
|
|
216
|
+
dimensions, epochs, lr, window, seed)
|
|
217
|
+
embeddings = embedder.get_embeddings()
|
|
218
|
+
click.echo(f"embeddings for {len(embeddings)} nodes")
|
|
219
|
+
|
|
220
|
+
_outdir(out_path)
|
|
221
|
+
with open(out_path, "wb") as f:
|
|
222
|
+
pickle.dump(embeddings, f)
|
|
223
|
+
click.echo(f"saved to {out_path}")
|
|
224
|
+
|
|
225
|
+
if save_model:
|
|
226
|
+
embedder.save_model(save_model)
|
|
227
|
+
click.echo(f"model saved to {save_model}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@cli.command("end2end")
|
|
231
|
+
@click.option("--genes", required=False,
|
|
232
|
+
help="comma-separated genes or file path (single gene list)")
|
|
233
|
+
@click.option("--gene-sets", required=False,
|
|
234
|
+
help="json file or string with {name: [genes]}")
|
|
235
|
+
@click.option("--output-dir", required=True)
|
|
236
|
+
@click.option("--study-id", required=False)
|
|
237
|
+
@click.option("--level", default="low", show_default=True,
|
|
238
|
+
type=click.Choice(["high", "mid", "low", "all"]))
|
|
239
|
+
@click.option("--gene-filter", required=False,
|
|
240
|
+
help="comma-separated genes to filter pathway universe")
|
|
241
|
+
@click.option("--weight", default="fdr", show_default=True,
|
|
242
|
+
type=click.Choice(["fdr", "oddsratio"]))
|
|
243
|
+
@click.option("--method", default="metapath2vec", show_default=True,
|
|
244
|
+
type=click.Choice(METHODS))
|
|
245
|
+
@click.option("--dimensions", default=512, show_default=True)
|
|
246
|
+
@click.option("--window", default=5, show_default=True)
|
|
247
|
+
@click.option("--epochs", default=10, show_default=True)
|
|
248
|
+
@click.option("--lr", default=0.005, show_default=True)
|
|
249
|
+
@click.option("--seed", default=1234, show_default=True, type=int,
|
|
250
|
+
help="rng seed for reproducible embeddings")
|
|
251
|
+
@click.option("--n-jobs", default=1, show_default=True, type=int,
|
|
252
|
+
help="parallel enrichment workers (order-preserving)")
|
|
253
|
+
@click.option("--reactome-dir", default=None,
|
|
254
|
+
help="reactome cache dir (sets GPATH2VEC_REACTOME_DIR)")
|
|
255
|
+
@click.option("--meta", "meta_path", default=None,
|
|
256
|
+
help="optional parquet to join into cluster embeddings; "
|
|
257
|
+
"all non-key columns are joined")
|
|
258
|
+
@click.option("--meta-key", default="cluster", show_default=True,
|
|
259
|
+
help="key column in --meta matching cluster names")
|
|
260
|
+
def run_pipeline(genes, gene_sets, output_dir, study_id, level, gene_filter,
|
|
261
|
+
weight, method, dimensions, window, epochs, lr, seed,
|
|
262
|
+
n_jobs, reactome_dir, meta_path, meta_key):
|
|
263
|
+
"""run the full pipeline: enrichment -> network -> embeddings"""
|
|
264
|
+
if reactome_dir:
|
|
265
|
+
os.environ["GPATH2VEC_REACTOME_DIR"] = os.path.abspath(reactome_dir)
|
|
266
|
+
study_id = _make_id(study_id)
|
|
267
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
enrichment_path = os.path.join(output_dir, f"{study_id}_enrichment.parquet")
|
|
270
|
+
matrix_path = os.path.join(output_dir, f"{study_id}_ea_matrix.csv")
|
|
271
|
+
network_path = os.path.join(output_dir, f"{study_id}_network.pkl")
|
|
272
|
+
embeddings_path = os.path.join(output_dir, f"{study_id}_embeddings.pkl")
|
|
273
|
+
model_path = os.path.join(output_dir, f"{study_id}_model.pt")
|
|
274
|
+
cluster_emb_path = os.path.join(
|
|
275
|
+
output_dir, f"{study_id}_cluster_embeddings.parquet")
|
|
276
|
+
provenance_path = os.path.join(
|
|
277
|
+
output_dir, f"{study_id}_run_provenance.json")
|
|
278
|
+
|
|
279
|
+
if gene_sets:
|
|
280
|
+
gs = _parse_gene_sets(gene_sets)
|
|
281
|
+
elif genes:
|
|
282
|
+
gs = {"study": _parse_genes(genes)}
|
|
283
|
+
else:
|
|
284
|
+
raise click.UsageError("provide --genes or --gene-sets")
|
|
285
|
+
|
|
286
|
+
gf = _load_gene_filter(gene_filter) if gene_filter else None
|
|
287
|
+
|
|
288
|
+
# enrichment
|
|
289
|
+
click.echo("step 1: enrichment")
|
|
290
|
+
ea_df = enrich(gs, level=level, gene_filter=gf, n_jobs=n_jobs)
|
|
291
|
+
ea_df.to_parquet(enrichment_path)
|
|
292
|
+
matrix = ea_matrix(ea_df, weight=weight)
|
|
293
|
+
matrix.to_csv(matrix_path)
|
|
294
|
+
|
|
295
|
+
# network
|
|
296
|
+
click.echo("step 2: network")
|
|
297
|
+
# vectorized; avoids materializing ea_df as a python list of dicts
|
|
298
|
+
sig_df = ea_df[ea_df["sig_pathway"].astype(bool)]
|
|
299
|
+
if weight == "fdr":
|
|
300
|
+
sig_df = sig_df.assign(_w=1 - sig_df["fdr_bh"])
|
|
301
|
+
else:
|
|
302
|
+
sig_df = sig_df.assign(_w=sig_df["oddsratio"])
|
|
303
|
+
clusters = {}
|
|
304
|
+
for r in sig_df[["cluster", "stId", "_w"]].itertuples(index=False):
|
|
305
|
+
clusters.setdefault(str(r.cluster), {})[str(r.stId)] = float(r._w)
|
|
306
|
+
|
|
307
|
+
# same contract as aggregate_min_fdr: one entry per pathway with min FDR
|
|
308
|
+
pw_min = ea_df.groupby("stId", as_index=False)["fdr_bh"].min()
|
|
309
|
+
enrichment = [{"stId": str(r.stId), "entities": {"fdr": float(r.fdr_bh)}}
|
|
310
|
+
for r in pw_min.itertuples(index=False)]
|
|
311
|
+
|
|
312
|
+
net = Net(enrichment=enrichment, id=study_id, digraph=True,
|
|
313
|
+
induce=False, level=level, gene_filter=gf,
|
|
314
|
+
clusters=clusters if clusters else None)
|
|
315
|
+
net.save(network_path)
|
|
316
|
+
|
|
317
|
+
# embeddings
|
|
318
|
+
click.echo(f"step 3: embeddings ({method})")
|
|
319
|
+
embedder = _run_embedder(method, net.graph, matrix, study_id,
|
|
320
|
+
dimensions, epochs, lr, window, seed)
|
|
321
|
+
embeddings = embedder.get_embeddings()
|
|
322
|
+
with open(embeddings_path, "wb") as f:
|
|
323
|
+
pickle.dump(embeddings, f)
|
|
324
|
+
embedder.save_model(model_path)
|
|
325
|
+
|
|
326
|
+
cluster_emb = {k: v for k, v in embeddings.items()
|
|
327
|
+
if isinstance(k, str) and k.startswith("cluster_")}
|
|
328
|
+
if cluster_emb:
|
|
329
|
+
emb_df = pd.DataFrame(cluster_emb).T
|
|
330
|
+
emb_df.index = [i.replace("cluster_", "", 1) for i in emb_df.index]
|
|
331
|
+
if meta_path:
|
|
332
|
+
emb_df = _join_meta(emb_df, pd.read_parquet(meta_path), meta_key)
|
|
333
|
+
emb_df.to_parquet(cluster_emb_path)
|
|
334
|
+
|
|
335
|
+
prov = {
|
|
336
|
+
"study_id": study_id,
|
|
337
|
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
338
|
+
"command": "end2end",
|
|
339
|
+
"level": level, "weight": weight, "method": method,
|
|
340
|
+
"dimensions": dimensions, "window": window, "epochs": epochs,
|
|
341
|
+
"lr": lr, "seed": seed, "n_jobs": n_jobs,
|
|
342
|
+
"gene_filter_applied": gf is not None,
|
|
343
|
+
"meta_applied": meta_path is not None,
|
|
344
|
+
"meta_key": meta_key if meta_path else None,
|
|
345
|
+
"n_gene_sets": len(gs),
|
|
346
|
+
}
|
|
347
|
+
Path(provenance_path).write_text(json.dumps(prov, indent=2))
|
|
348
|
+
|
|
349
|
+
click.echo(f"done. output in {output_dir}")
|
|
350
|
+
click.echo(f" enrichment: {enrichment_path}")
|
|
351
|
+
click.echo(f" ea matrix: {matrix_path}")
|
|
352
|
+
click.echo(f" network: {network_path}")
|
|
353
|
+
click.echo(f" embeddings: {embeddings_path}")
|
|
354
|
+
if cluster_emb:
|
|
355
|
+
click.echo(f" cluster embeddings: {cluster_emb_path}")
|
|
356
|
+
click.echo(f" model: {model_path}")
|
|
357
|
+
click.echo(f" provenance: {provenance_path}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _load_gene_filter(path):
|
|
362
|
+
"""robust gene-filter loader. accepts:
|
|
363
|
+
- JSON list of gene symbols
|
|
364
|
+
- JSON dict with a 'genes'/'tf_genes' key, or one list-valued key
|
|
365
|
+
- plain text, one gene symbol per line (comments after '#' ignored)
|
|
366
|
+
- single comma-separated string in a file
|
|
367
|
+
returns set[str].
|
|
368
|
+
"""
|
|
369
|
+
text = Path(path).read_text()
|
|
370
|
+
try:
|
|
371
|
+
obj = json.loads(text)
|
|
372
|
+
if isinstance(obj, list):
|
|
373
|
+
return set(map(str, obj))
|
|
374
|
+
if isinstance(obj, dict):
|
|
375
|
+
for k in ("genes", "tf_genes"):
|
|
376
|
+
if isinstance(obj.get(k), list):
|
|
377
|
+
return set(map(str, obj[k]))
|
|
378
|
+
lists = [v for v in obj.values() if isinstance(v, list)]
|
|
379
|
+
if len(lists) == 1:
|
|
380
|
+
return set(map(str, lists[0]))
|
|
381
|
+
except json.JSONDecodeError:
|
|
382
|
+
pass
|
|
383
|
+
# fall back: plain text (line per gene or comma-separated)
|
|
384
|
+
lines = [ln.split("#", 1)[0].strip() for ln in text.splitlines()]
|
|
385
|
+
genes = []
|
|
386
|
+
for ln in lines:
|
|
387
|
+
if not ln:
|
|
388
|
+
continue
|
|
389
|
+
genes.extend(g.strip() for g in ln.split(",") if g.strip())
|
|
390
|
+
if genes:
|
|
391
|
+
return set(genes)
|
|
392
|
+
raise click.ClickException(
|
|
393
|
+
f"--gene-filter {path}: could not parse as JSON list/dict or as "
|
|
394
|
+
f"plain-text gene symbols")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _niche_row(X, i):
|
|
398
|
+
"""dense 1-D expression vector for niche row i (sparse or dense X)."""
|
|
399
|
+
r = X[i]
|
|
400
|
+
if sp.issparse(r):
|
|
401
|
+
return np.asarray(r.todense()).ravel()
|
|
402
|
+
return np.asarray(r).ravel()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _join_meta(emb_df, meta, key):
|
|
406
|
+
"""join all non-key columns of `meta` into `emb_df` on its string index.
|
|
407
|
+
no column names are hardcoded; whatever columns the user supplies in
|
|
408
|
+
`meta` (besides `key`) end up as columns of `emb_df`. silent no-op if
|
|
409
|
+
`meta` is None."""
|
|
410
|
+
if meta is None:
|
|
411
|
+
return emb_df
|
|
412
|
+
if key not in meta.columns:
|
|
413
|
+
raise click.ClickException(
|
|
414
|
+
f"meta key {key!r} not in meta columns: {list(meta.columns)}")
|
|
415
|
+
m = meta.copy()
|
|
416
|
+
m[key] = m[key].astype(str)
|
|
417
|
+
m = m.drop_duplicates(subset=[key]).set_index(key)
|
|
418
|
+
for col in m.columns:
|
|
419
|
+
emb_df[col] = emb_df.index.map(m[col].to_dict())
|
|
420
|
+
return emb_df
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@cli.command("niche-pipeline")
|
|
424
|
+
@click.option("--niche-matrix", required=True,
|
|
425
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
426
|
+
help="niche x gene matrix: .npz (scipy sparse) or .npy (dense)")
|
|
427
|
+
@click.option("--genes", "genes_path", required=True,
|
|
428
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
429
|
+
help=".npy of gene symbols aligned to matrix columns")
|
|
430
|
+
@click.option("--niche-meta", required=True,
|
|
431
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
432
|
+
help="parquet with a key column (default 'niche_id', set with "
|
|
433
|
+
"--niche-meta-key); any other columns are joined into the "
|
|
434
|
+
"cluster embeddings parquet")
|
|
435
|
+
@click.option("--out-dir", required=True, type=click.Path(file_okay=False))
|
|
436
|
+
@click.option("--reactome-dir", default=None,
|
|
437
|
+
type=click.Path(exists=True, file_okay=False),
|
|
438
|
+
help="reactome cache dir. REQUIRED on offline/HPC nodes; else "
|
|
439
|
+
"GPATH2VEC_REACTOME_DIR env or ~/.gpath2vec/cache")
|
|
440
|
+
@click.option("--enrichment", "enrichment_method", default="fisher",
|
|
441
|
+
show_default=True, type=click.Choice(["fisher", "aucell"]))
|
|
442
|
+
@click.option("--reactome-level", default="low", show_default=True,
|
|
443
|
+
type=click.Choice(["low", "mid", "high", "all"]))
|
|
444
|
+
@click.option("--gene-filter", "gene_filter_file", default=None,
|
|
445
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
446
|
+
help="optional JSON gene-symbol list restricting the pathway "
|
|
447
|
+
"universe (off = full universe)")
|
|
448
|
+
@click.option("--min-genes", default=3, show_default=True, type=int)
|
|
449
|
+
@click.option("--max-genes", default=500, show_default=True, type=int,
|
|
450
|
+
help="aucell: pathway gene-set size upper band")
|
|
451
|
+
@click.option("--top-genes", default=100, show_default=True, type=int,
|
|
452
|
+
help="fisher: per-niche top-N expressed marker genes")
|
|
453
|
+
@click.option("--n-jobs", default=-1, show_default=True, type=int,
|
|
454
|
+
help="fisher: parallel enrichment workers (-1 = all cores)")
|
|
455
|
+
@click.option("--topk", default=50, show_default=True, type=int,
|
|
456
|
+
help="aucell: per-niche pathways kept (ablate 20/50/100)")
|
|
457
|
+
@click.option("--aucell-standardize", default="zscore", show_default=True,
|
|
458
|
+
type=click.Choice(["none", "zscore"]),
|
|
459
|
+
help="aucell: top-k selection criterion. 'zscore' ranks pathways "
|
|
460
|
+
"by cross-niche relative elevation (removes the shared "
|
|
461
|
+
"housekeeping floor); 'none' ranks by absolute score")
|
|
462
|
+
@click.option("--pre-normalized/--normalize", default=False,
|
|
463
|
+
show_default=True,
|
|
464
|
+
help="aucell: --normalize (default) applies "
|
|
465
|
+
"normalize_total(1e4)+log1p; --pre-normalized skips it")
|
|
466
|
+
@click.option("--dimensions", default=512, show_default=True, type=int)
|
|
467
|
+
@click.option("--epochs", default=5, show_default=True, type=int)
|
|
468
|
+
@click.option("--lr", default=0.005, show_default=True, type=float)
|
|
469
|
+
@click.option("--seed", default=1234, show_default=True, type=int,
|
|
470
|
+
help="rng seed for reproducible walks + embeddings")
|
|
471
|
+
@click.option("--niche-meta-key", default="niche_id", show_default=True,
|
|
472
|
+
help="key column in --niche-meta matching cluster names")
|
|
473
|
+
@click.option("--study-id", default=None)
|
|
474
|
+
def niche_pipeline(niche_matrix, genes_path, niche_meta, out_dir,
|
|
475
|
+
reactome_dir, enrichment_method, reactome_level,
|
|
476
|
+
gene_filter_file, min_genes, max_genes, top_genes,
|
|
477
|
+
n_jobs, topk, aucell_standardize, pre_normalized,
|
|
478
|
+
dimensions, epochs, lr,
|
|
479
|
+
seed, niche_meta_key, study_id):
|
|
480
|
+
"""niche expression -> enrichment (fisher|aucell) -> graph -> embeddings.
|
|
481
|
+
|
|
482
|
+
fisher: per-niche top-N expressed genes -> Fisher's exact vs Reactome,
|
|
483
|
+
sig/notsig metapaths. aucell: full-ranking AUCell per niche -> per-niche
|
|
484
|
+
top-k pathways as connectivity-typed edges, simplified metapaths. all
|
|
485
|
+
paths are arguments; a provenance JSON is written for reproducibility.
|
|
486
|
+
"""
|
|
487
|
+
if reactome_dir:
|
|
488
|
+
os.environ["GPATH2VEC_REACTOME_DIR"] = os.path.abspath(reactome_dir)
|
|
489
|
+
study_id = _make_id(study_id)
|
|
490
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
491
|
+
|
|
492
|
+
mp = str(niche_matrix)
|
|
493
|
+
if mp.endswith(".npz"):
|
|
494
|
+
X = sp.load_npz(mp)
|
|
495
|
+
elif mp.endswith(".npy"):
|
|
496
|
+
X = np.load(mp, allow_pickle=False)
|
|
497
|
+
else:
|
|
498
|
+
raise click.ClickException(
|
|
499
|
+
"--niche-matrix must be .npz (scipy sparse) or .npy (dense)")
|
|
500
|
+
genes = np.load(genes_path, allow_pickle=True)
|
|
501
|
+
meta = pd.read_parquet(niche_meta)
|
|
502
|
+
if niche_meta_key not in meta.columns:
|
|
503
|
+
raise click.ClickException(
|
|
504
|
+
f"--niche-meta must have a {niche_meta_key!r} column "
|
|
505
|
+
f"(set with --niche-meta-key)")
|
|
506
|
+
niche_ids = meta[niche_meta_key].astype(str).tolist()
|
|
507
|
+
if X.shape != (len(niche_ids), len(genes)):
|
|
508
|
+
raise click.ClickException(
|
|
509
|
+
f"shape mismatch: matrix {X.shape} vs "
|
|
510
|
+
f"(n_niches={len(niche_ids)}, n_genes={len(genes)})")
|
|
511
|
+
gene_filter = _load_gene_filter(gene_filter_file) if gene_filter_file else None
|
|
512
|
+
click.echo(f"niche-pipeline: {len(niche_ids)} niches, {len(genes)} genes, "
|
|
513
|
+
f"enrichment={enrichment_method}, level={reactome_level}")
|
|
514
|
+
|
|
515
|
+
if enrichment_method == "fisher":
|
|
516
|
+
gene_sets = {}
|
|
517
|
+
for i, nid in enumerate(niche_ids):
|
|
518
|
+
row = _niche_row(X, i)
|
|
519
|
+
if (row > 0).sum() == 0:
|
|
520
|
+
continue
|
|
521
|
+
top = np.argsort(row)[-top_genes:]
|
|
522
|
+
top = top[row[top] > 0]
|
|
523
|
+
gene_sets[str(nid)] = list(map(str, genes[top]))
|
|
524
|
+
click.echo(f" {len(gene_sets)} niches with genes -> enrichment")
|
|
525
|
+
ea_df = enrich(gene_sets, level=reactome_level,
|
|
526
|
+
gene_filter=gene_filter, min_genes=min_genes,
|
|
527
|
+
n_jobs=n_jobs)
|
|
528
|
+
ea_df.to_parquet(os.path.join(out_dir, "enrichment.parquet"))
|
|
529
|
+
clusters = {}
|
|
530
|
+
for _, r in ea_df[ea_df.sig_pathway].iterrows():
|
|
531
|
+
clusters.setdefault(str(r["cluster"]), {})[r["stId"]] = \
|
|
532
|
+
1 - r["fdr_bh"]
|
|
533
|
+
enrichment = aggregate_min_fdr(ea_df.to_dict("records"))
|
|
534
|
+
net = Net(enrichment=enrichment, id=study_id, digraph=True,
|
|
535
|
+
level=reactome_level, gene_filter=gene_filter,
|
|
536
|
+
clusters=clusters if clusters else None)
|
|
537
|
+
embedder = PathwayMetapath2vec(graph=net.graph, name=study_id,
|
|
538
|
+
walks_per_node=10, walk_length=100,
|
|
539
|
+
seed=seed)
|
|
540
|
+
else: # aucell
|
|
541
|
+
scores = compute_aucell(
|
|
542
|
+
X, genes, niche_ids, level=reactome_level,
|
|
543
|
+
gene_filter=gene_filter, min_genes=min_genes,
|
|
544
|
+
max_genes=max_genes, normalize=not pre_normalized,
|
|
545
|
+
provenance_path=os.path.join(out_dir, "aucell_params.json"))
|
|
546
|
+
scores.to_parquet(os.path.join(out_dir, "aucell_scores.parquet"))
|
|
547
|
+
clusters = topk_per_niche(scores, topk, standardize=aucell_standardize)
|
|
548
|
+
net = Net(enrichment=[], id=study_id, digraph=True,
|
|
549
|
+
level=reactome_level, gene_filter=gene_filter,
|
|
550
|
+
clusters=clusters if clusters else None,
|
|
551
|
+
node_typing="uniform")
|
|
552
|
+
embedder = PathwayMetapath2vec(
|
|
553
|
+
graph=net.graph, name=study_id, walks_per_node=10,
|
|
554
|
+
walk_length=100, seed=seed,
|
|
555
|
+
metapaths=[["cluster", "pathway", "pathway"],
|
|
556
|
+
["pathway", "pathway", "pathway"]])
|
|
557
|
+
|
|
558
|
+
g = net.graph
|
|
559
|
+
n_clust = sum(1 for _, a in g.nodes(data=True)
|
|
560
|
+
if a.get("node_type") == "cluster")
|
|
561
|
+
click.echo(f" network: {g.number_of_nodes()} nodes, "
|
|
562
|
+
f"{g.number_of_edges()} edges, {n_clust} niches")
|
|
563
|
+
net.save(os.path.join(out_dir, "network.pkl"))
|
|
564
|
+
|
|
565
|
+
embedder.train_embeddings(walks=embedder.model, dimensions=dimensions,
|
|
566
|
+
window_size=5, epochs=epochs, lr=lr, seed=seed)
|
|
567
|
+
embeddings = embedder.get_embeddings()
|
|
568
|
+
with open(os.path.join(out_dir, "embeddings.pkl"), "wb") as f:
|
|
569
|
+
pickle.dump(embeddings, f)
|
|
570
|
+
embedder.save_model(os.path.join(out_dir, "model.pt"))
|
|
571
|
+
|
|
572
|
+
cluster_emb = {k: v for k, v in embeddings.items()
|
|
573
|
+
if isinstance(k, str) and k.startswith("cluster_")}
|
|
574
|
+
if cluster_emb:
|
|
575
|
+
emb_df = pd.DataFrame(cluster_emb).T
|
|
576
|
+
emb_df.index = [i.replace("cluster_", "", 1) for i in emb_df.index]
|
|
577
|
+
emb_df = _join_meta(emb_df, meta, niche_meta_key)
|
|
578
|
+
emb_df.to_parquet(
|
|
579
|
+
os.path.join(out_dir, "cluster_embeddings.parquet"))
|
|
580
|
+
|
|
581
|
+
prov = {
|
|
582
|
+
"study_id": study_id,
|
|
583
|
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
584
|
+
"enrichment_method": enrichment_method,
|
|
585
|
+
"reactome_level": reactome_level,
|
|
586
|
+
"gene_filter_applied": gene_filter is not None,
|
|
587
|
+
"min_genes": min_genes, "max_genes": max_genes,
|
|
588
|
+
"top_genes_fisher": top_genes, "topk_aucell": topk,
|
|
589
|
+
"aucell_standardize": aucell_standardize,
|
|
590
|
+
"pre_normalized": pre_normalized,
|
|
591
|
+
"dimensions": dimensions, "epochs": epochs, "lr": lr,
|
|
592
|
+
"seed": seed,
|
|
593
|
+
"niche_meta_key": niche_meta_key,
|
|
594
|
+
"n_niches": len(niche_ids), "n_genes": len(genes),
|
|
595
|
+
}
|
|
596
|
+
Path(os.path.join(out_dir, "run_provenance.json")).write_text(
|
|
597
|
+
json.dumps(prov, indent=2))
|
|
598
|
+
click.echo(f"done. output in {out_dir}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def main():
|
|
602
|
+
return cli()
|
gpath2vec/compare.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""cross-study comparison via canonical correlation analysis."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from sklearn.cross_decomposition import CCA
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cca_compare(emb_a, emb_b, n_components=10, seed=42):
|
|
9
|
+
"""
|
|
10
|
+
canonical correlation analysis between two embedding matrices.
|
|
11
|
+
|
|
12
|
+
emb_a, emb_b: numpy arrays (n_samples_a x dim), (n_samples_b x dim)
|
|
13
|
+
n_components: number of canonical components
|
|
14
|
+
seed: rng seed for the subsample; local rng, does not touch global state
|
|
15
|
+
|
|
16
|
+
returns: dict with correlations, transformed components, and summary
|
|
17
|
+
"""
|
|
18
|
+
n = min(len(emb_a), len(emb_b))
|
|
19
|
+
n_components = min(n_components, n, emb_a.shape[1])
|
|
20
|
+
|
|
21
|
+
rng = np.random.default_rng(seed)
|
|
22
|
+
idx_a = rng.choice(len(emb_a), n, replace=False)
|
|
23
|
+
idx_b = rng.choice(len(emb_b), n, replace=False)
|
|
24
|
+
Xa = emb_a[idx_a]
|
|
25
|
+
Xb = emb_b[idx_b]
|
|
26
|
+
|
|
27
|
+
cca = CCA(n_components=n_components, max_iter=1000)
|
|
28
|
+
Xa_c, Xb_c = cca.fit_transform(Xa, Xb)
|
|
29
|
+
|
|
30
|
+
correlations = np.array([
|
|
31
|
+
np.corrcoef(Xa_c[:, i], Xb_c[:, i])[0, 1]
|
|
32
|
+
for i in range(n_components)
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"correlations": correlations,
|
|
37
|
+
"mean_correlation": correlations.mean(),
|
|
38
|
+
"components_a": Xa_c,
|
|
39
|
+
"components_b": Xb_c,
|
|
40
|
+
"n_samples": n,
|
|
41
|
+
"n_components": n_components,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pairwise_cca(embeddings_dict, n_components=10, seed=42):
|
|
46
|
+
"""
|
|
47
|
+
run cca between all pairs of groups.
|
|
48
|
+
|
|
49
|
+
embeddings_dict: {group_name: numpy array (n x dim)}
|
|
50
|
+
seed: rng seed threaded into each cca_compare call for reproducibility
|
|
51
|
+
returns: dataframe with group_a, group_b, mean_corr, per-component correlations
|
|
52
|
+
"""
|
|
53
|
+
names = sorted(embeddings_dict.keys())
|
|
54
|
+
results = []
|
|
55
|
+
|
|
56
|
+
for i in range(len(names)):
|
|
57
|
+
for j in range(i + 1, len(names)):
|
|
58
|
+
a, b = names[i], names[j]
|
|
59
|
+
res = cca_compare(embeddings_dict[a], embeddings_dict[b],
|
|
60
|
+
n_components=n_components, seed=seed)
|
|
61
|
+
row = {
|
|
62
|
+
"group_a": a,
|
|
63
|
+
"group_b": b,
|
|
64
|
+
"n_samples": res["n_samples"],
|
|
65
|
+
"mean_correlation": res["mean_correlation"],
|
|
66
|
+
}
|
|
67
|
+
for k, c in enumerate(res["correlations"]):
|
|
68
|
+
row[f"cc_{k+1}"] = c
|
|
69
|
+
results.append(row)
|
|
70
|
+
|
|
71
|
+
return pd.DataFrame(results)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cosine_similarity_matrix(embeddings_dict):
|
|
75
|
+
"""
|
|
76
|
+
cosine similarity between group mean embeddings.
|
|
77
|
+
|
|
78
|
+
embeddings_dict: {group_name: numpy array (n x dim)}
|
|
79
|
+
returns: dataframe (n_groups x n_groups) of cosine similarities
|
|
80
|
+
"""
|
|
81
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
82
|
+
|
|
83
|
+
names = sorted(embeddings_dict.keys())
|
|
84
|
+
means = np.array([embeddings_dict[n].mean(axis=0) for n in names])
|
|
85
|
+
sim = cosine_similarity(means)
|
|
86
|
+
return pd.DataFrame(sim, index=names, columns=names)
|