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/embedder.py ADDED
@@ -0,0 +1,554 @@
1
+ """embedding methods for pathway graphs."""
2
+
3
+ import random
4
+
5
+ import networkx as nx
6
+ import numpy as np
7
+ import torch
8
+ from sklearn.decomposition import TruncatedSVD
9
+ from sklearn.manifold import SpectralEmbedding as SklearnSpectral
10
+
11
+
12
+ def _set_seed(seed):
13
+ # pin python, numpy and torch global rngs so embeddings are bit-reproducible.
14
+ random.seed(seed)
15
+ np.random.seed(seed)
16
+ torch.manual_seed(seed)
17
+
18
+
19
+ class Embedder:
20
+ """base class for all embedding methods."""
21
+
22
+ def __init__(self):
23
+ self.embeddings = {}
24
+ self.model = self.method()
25
+
26
+ @staticmethod
27
+ def method():
28
+ return None
29
+
30
+ def get_embeddings(self):
31
+ return self.embeddings
32
+
33
+ def save_model(self, path):
34
+ torch.save({"embeddings": self.embeddings}, path)
35
+
36
+ def load_model(self, path):
37
+ state = torch.load(path)
38
+ self.embeddings = state["embeddings"]
39
+
40
+
41
+ class VAEEmbedder(Embedder):
42
+ """
43
+ variational autoencoder on the ea matrix (cluster x pathway).
44
+ learns a smooth latent space of pathway activity patterns.
45
+
46
+ ea_matrix: pandas dataframe (cluster x pathway)
47
+ dimensions: latent space size
48
+ hidden_dim: hidden layer size
49
+ epochs: training epochs
50
+ lr: learning rate
51
+ beta: weight on kl divergence (beta-vae)
52
+ """
53
+
54
+ def __init__(self, ea_matrix, dimensions=512, hidden_dim=256,
55
+ epochs=50, lr=0.001, beta=1.0, seed=1234):
56
+ self.ea_matrix = ea_matrix
57
+ self.dimensions = dimensions
58
+ self.hidden_dim = hidden_dim
59
+ self.epochs = epochs
60
+ self.lr = lr
61
+ self.beta = beta
62
+ self.seed = seed
63
+ self.latent_means = {}
64
+ self.latent_vars = {}
65
+ super().__init__()
66
+
67
+ def method(self):
68
+ _set_seed(self.seed)
69
+ input_dim = self.ea_matrix.shape[1]
70
+ X = torch.FloatTensor(self.ea_matrix.values)
71
+ names = list(self.ea_matrix.index)
72
+
73
+ class VAE(torch.nn.Module):
74
+ def __init__(vae, input_dim, hidden_dim, latent_dim):
75
+ super().__init__()
76
+ vae.encoder = torch.nn.Sequential(
77
+ torch.nn.Linear(input_dim, hidden_dim),
78
+ torch.nn.ReLU(),
79
+ torch.nn.Linear(hidden_dim, hidden_dim),
80
+ torch.nn.ReLU(),
81
+ )
82
+ vae.mu = torch.nn.Linear(hidden_dim, latent_dim)
83
+ vae.logvar = torch.nn.Linear(hidden_dim, latent_dim)
84
+ vae.decoder = torch.nn.Sequential(
85
+ torch.nn.Linear(latent_dim, hidden_dim),
86
+ torch.nn.ReLU(),
87
+ torch.nn.Linear(hidden_dim, hidden_dim),
88
+ torch.nn.ReLU(),
89
+ torch.nn.Linear(hidden_dim, input_dim),
90
+ torch.nn.Sigmoid(),
91
+ )
92
+
93
+ def encode(vae, x):
94
+ h = vae.encoder(x)
95
+ return vae.mu(h), vae.logvar(h)
96
+
97
+ def reparameterize(vae, mu, logvar):
98
+ std = torch.exp(0.5 * logvar)
99
+ eps = torch.randn_like(std)
100
+ return mu + eps * std
101
+
102
+ def decode(vae, z):
103
+ return vae.decoder(z)
104
+
105
+ def forward(vae, x):
106
+ mu, logvar = vae.encode(x)
107
+ z = vae.reparameterize(mu, logvar)
108
+ return vae.decode(z), mu, logvar
109
+
110
+ model = VAE(input_dim, self.hidden_dim, self.dimensions)
111
+ optimizer = torch.optim.Adam(model.parameters(), lr=self.lr)
112
+
113
+ print(f"vae: {len(names)} clusters, {input_dim} pathways -> {self.dimensions} latent dims")
114
+
115
+ batch_size = min(256, len(names))
116
+ for epoch in range(self.epochs):
117
+ perm = torch.randperm(len(names))
118
+ total_loss = 0
119
+ n_batches = 0
120
+
121
+ for i in range(0, len(names), batch_size):
122
+ batch = X[perm[i:i + batch_size]]
123
+ recon, mu, logvar = model(batch)
124
+
125
+ recon_loss = torch.nn.functional.mse_loss(recon, batch, reduction="sum")
126
+ kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
127
+ loss = recon_loss + self.beta * kl_loss
128
+
129
+ optimizer.zero_grad()
130
+ loss.backward()
131
+ optimizer.step()
132
+ total_loss += loss.item()
133
+ n_batches += 1
134
+
135
+ if (epoch + 1) % 10 == 0 or epoch == 0:
136
+ avg = total_loss / len(names)
137
+ print(f" epoch {epoch+1}/{self.epochs}, loss: {avg:.4f}")
138
+
139
+ # extract latent means as embeddings
140
+ model.eval()
141
+ with torch.no_grad():
142
+ mu, logvar = model.encode(X)
143
+ embeddings_np = mu.numpy()
144
+ vars_np = logvar.exp().numpy()
145
+
146
+ self.embeddings = {names[i]: embeddings_np[i] for i in range(len(names))}
147
+ self.latent_means = self.embeddings
148
+ self.latent_vars = {names[i]: vars_np[i] for i in range(len(names))}
149
+ self._model = model
150
+ return model
151
+
152
+ def get_uncertainty(self):
153
+ """return per-cluster latent variance (uncertainty estimate)."""
154
+ return self.latent_vars
155
+
156
+ def save_model(self, path):
157
+ """
158
+ saves: embeddings (latent means), latent_vars (uncertainty),
159
+ and model weights (for generation via model.decode(z)).
160
+ """
161
+ torch.save({
162
+ "embeddings": self.embeddings,
163
+ "latent_vars": self.latent_vars,
164
+ "model_state": self._model.state_dict() if hasattr(self, "_model") else None,
165
+ }, path)
166
+
167
+ def load_model(self, path):
168
+ state = torch.load(path)
169
+ self.embeddings = state["embeddings"]
170
+ self.latent_vars = state.get("latent_vars", {})
171
+
172
+
173
+ class SVDEmbedder(Embedder):
174
+ """
175
+ truncated svd on the ea matrix (cluster x pathway).
176
+ baseline: no graph structure, just the enrichment signal.
177
+
178
+ ea_matrix: pandas dataframe (cluster x pathway)
179
+ dimensions: output embedding size
180
+ """
181
+
182
+ def __init__(self, ea_matrix, dimensions=512):
183
+ self.ea_matrix = ea_matrix
184
+ self.dimensions = min(dimensions, min(ea_matrix.shape) - 1)
185
+ super().__init__()
186
+
187
+ def method(self):
188
+ svd = TruncatedSVD(n_components=self.dimensions, random_state=42)
189
+ X = svd.fit_transform(self.ea_matrix.values)
190
+ self.explained_variance = svd.explained_variance_ratio_.sum()
191
+ print(f"svd: {self.ea_matrix.shape[0]} x {self.ea_matrix.shape[1]} -> "
192
+ f"{X.shape[1]} dims ({self.explained_variance:.1%} variance)")
193
+ self.embeddings = {
194
+ name: X[i] for i, name in enumerate(self.ea_matrix.index)
195
+ }
196
+ return svd
197
+
198
+
199
+ class SpectralGraphEmbedder(Embedder):
200
+ """
201
+ spectral embedding on the graph laplacian.
202
+ deterministic, no training, captures global graph structure.
203
+
204
+ graph: networkx graph
205
+ dimensions: output embedding size
206
+ """
207
+
208
+ def __init__(self, graph, dimensions=512):
209
+ self.graph = graph
210
+ self.dimensions = dimensions
211
+ super().__init__()
212
+
213
+ def method(self):
214
+ nodes = list(self.graph.nodes())
215
+ n = len(nodes)
216
+ dims = min(self.dimensions, n - 2)
217
+
218
+ A = nx.adjacency_matrix(self.graph, nodelist=nodes, weight="weight")
219
+ se = SklearnSpectral(n_components=dims, affinity="precomputed",
220
+ random_state=42)
221
+ X = se.fit_transform(A.toarray())
222
+
223
+ print(f"spectral: {n} nodes -> {dims} dims")
224
+ self.embeddings = {nodes[i]: X[i] for i in range(n)}
225
+ return se
226
+
227
+
228
+ class LINEEmbedder(Embedder):
229
+ """
230
+ large-scale information network embedding.
231
+ two objectives: first-order (direct neighbors) and
232
+ second-order (shared neighbor structure). handles edge weights.
233
+
234
+ graph: networkx graph
235
+ dimensions: output embedding size (split half/half between orders)
236
+ epochs: training epochs
237
+ lr: learning rate
238
+ neg_samples: negative samples per positive
239
+ """
240
+
241
+ def __init__(self, graph, dimensions=512, epochs=15, lr=0.005,
242
+ neg_samples=5, seed=1234):
243
+ self.graph = graph
244
+ self.dimensions = dimensions
245
+ self.epochs = epochs
246
+ self.lr = lr
247
+ self.neg_samples = neg_samples
248
+ self.seed = seed
249
+ self.vocab = {}
250
+ super().__init__()
251
+
252
+ def method(self):
253
+ _set_seed(self.seed)
254
+ nodes = list(self.graph.nodes())
255
+ node_to_ix = {n: i for i, n in enumerate(nodes)}
256
+ self.vocab = node_to_ix
257
+ n = len(nodes)
258
+ half_dim = self.dimensions // 2
259
+
260
+ # build edge list with weights
261
+ edges = []
262
+ for u, v, d in self.graph.edges(data=True):
263
+ w = d.get("weight", 1.0)
264
+ edges.append((node_to_ix[u], node_to_ix[v], w))
265
+ if not self.graph.is_directed():
266
+ edges.append((node_to_ix[v], node_to_ix[u], w))
267
+
268
+ if not edges:
269
+ print("line: no edges")
270
+ return None
271
+
272
+ # degree distribution for negative sampling
273
+ degrees = np.zeros(n)
274
+ for u, v, w in edges:
275
+ degrees[u] += w
276
+ degrees[v] += w
277
+ neg_dist = np.power(degrees, 0.75)
278
+ neg_dist /= neg_dist.sum()
279
+
280
+ print(f"line: {n} nodes, {len(edges)} edges")
281
+
282
+ # first-order proximity
283
+ emb_1 = self._train_order(edges, n, half_dim, neg_dist, order=1)
284
+ # second-order proximity
285
+ emb_2 = self._train_order(edges, n, half_dim, neg_dist, order=2)
286
+
287
+ # concatenate both orders
288
+ X = np.concatenate([emb_1, emb_2], axis=1)
289
+ self.embeddings = {nodes[i]: X[i] for i in range(n)}
290
+ return X
291
+
292
+ def _train_order(self, edges, n, dim, neg_dist, order):
293
+ emb = torch.nn.Embedding(n, dim)
294
+ ctx = torch.nn.Embedding(n, dim)
295
+ emb.weight.data.uniform_(-0.5 / dim, 0.5 / dim)
296
+ ctx.weight.data.uniform_(-0.5 / dim, 0.5 / dim)
297
+
298
+ optimizer = torch.optim.Adam(
299
+ list(emb.parameters()) + list(ctx.parameters()), lr=self.lr
300
+ )
301
+
302
+ edge_arr = np.array(edges)
303
+ weights = edge_arr[:, 2].astype(float)
304
+ weight_dist = weights / weights.sum()
305
+
306
+ batch_size = 1024
307
+ n_batches = max(1, len(edges) // batch_size)
308
+
309
+ for epoch in range(self.epochs):
310
+ total_loss = 0
311
+ # sample edges proportional to weight
312
+ sampled = np.random.choice(len(edges), size=len(edges), p=weight_dist)
313
+
314
+ for i in range(0, len(sampled), batch_size):
315
+ batch_idx = sampled[i:i + batch_size]
316
+ src = torch.LongTensor(edge_arr[batch_idx, 0].astype(int))
317
+ dst = torch.LongTensor(edge_arr[batch_idx, 1].astype(int))
318
+ neg = torch.LongTensor(
319
+ np.random.choice(n, size=(len(batch_idx), self.neg_samples),
320
+ p=neg_dist)
321
+ )
322
+
323
+ optimizer.zero_grad()
324
+
325
+ if order == 1:
326
+ # first-order: both use emb
327
+ pos_score = torch.nn.functional.logsigmoid(
328
+ torch.sum(emb(src) * emb(dst), dim=1))
329
+ neg_score = sum(
330
+ torch.nn.functional.logsigmoid(
331
+ -torch.sum(emb(src) * emb(neg[:, j]), dim=1))
332
+ for j in range(self.neg_samples)
333
+ )
334
+ else:
335
+ # second-order: src uses emb, dst/neg use ctx
336
+ pos_score = torch.nn.functional.logsigmoid(
337
+ torch.sum(emb(src) * ctx(dst), dim=1))
338
+ neg_score = sum(
339
+ torch.nn.functional.logsigmoid(
340
+ -torch.sum(emb(src) * ctx(neg[:, j]), dim=1))
341
+ for j in range(self.neg_samples)
342
+ )
343
+
344
+ loss = -torch.mean(pos_score + neg_score)
345
+ loss.backward()
346
+ optimizer.step()
347
+ total_loss += loss.item()
348
+
349
+ print(f" line order-{order} epoch {epoch+1}/{self.epochs}, "
350
+ f"loss: {total_loss / n_batches:.4f}")
351
+
352
+ with torch.no_grad():
353
+ return emb.weight.numpy()
354
+
355
+
356
+ class PathwayMetapath2vec(Embedder):
357
+ def __init__(self, graph, name, walks_per_node=10, walk_length=100,
358
+ metapaths=None, seed=1234):
359
+ self.graph = graph
360
+ self.name = name
361
+ self.walks_per_node = walks_per_node
362
+ self.walk_length = walk_length
363
+ self.seed = seed
364
+ # None -> the default 6 sig/notsig schemas (unchanged behavior).
365
+ # pass an explicit list for connectivity-typed graphs, ex.
366
+ # [["cluster","pathway","pathway"], ["pathway","pathway","pathway"]].
367
+ self.metapaths = metapaths
368
+ self.vocab = {}
369
+ super().__init__()
370
+
371
+ def method(self):
372
+ return self.metapath2vec()
373
+
374
+ def _weighted_choice(self, neighbors, current):
375
+ """sample a neighbor proportional to edge weight, uniform if no weights."""
376
+ weights = []
377
+ for n in neighbors:
378
+ e = self.graph.edges[current, n] if self.graph.has_edge(current, n) else {}
379
+ weights.append(e.get("weight", 1.0))
380
+ total = sum(weights)
381
+ if total == 0:
382
+ return random.choice(neighbors)
383
+ probs = [w / total for w in weights]
384
+ return random.choices(neighbors, weights=probs, k=1)[0]
385
+
386
+ def metapath2vec(self):
387
+ print(f"graph: {self.graph.number_of_nodes()} nodes, {self.graph.number_of_edges()} edges")
388
+ node_types = nx.get_node_attributes(self.graph, "node_type")
389
+
390
+ metapaths = self.metapaths if self.metapaths else [
391
+ ["sig", "sig"],
392
+ ["sig", "notsig", "sig"],
393
+ ["notsig", "sig", "notsig"],
394
+ ["cluster", "sig", "sig"],
395
+ ["cluster", "sig", "notsig"],
396
+ ["cluster", "notsig", "sig"],
397
+ ]
398
+
399
+ walks = []
400
+ _set_seed(self.seed)
401
+
402
+ for _ in range(self.walks_per_node):
403
+ for start_node in self.graph.nodes():
404
+ walk = [start_node]
405
+ current = start_node
406
+ mp = random.choice(metapaths)
407
+
408
+ for i in range(self.walk_length):
409
+ neighbors = list(self.graph.neighbors(current))
410
+ if not neighbors:
411
+ break
412
+
413
+ target_type = mp[i % len(mp)]
414
+ typed = [n for n in neighbors if node_types.get(n, "unknown") == target_type]
415
+
416
+ if typed:
417
+ next_node = self._weighted_choice(typed, current)
418
+ else:
419
+ next_node = self._weighted_choice(neighbors, current)
420
+
421
+ walk.append(next_node)
422
+ current = next_node
423
+
424
+ walks.append(walk)
425
+
426
+ print(f"random walks: {len(walks)}")
427
+ return walks
428
+
429
+ def train_embeddings(self, walks, dimensions=512, window_size=5, epochs=10,
430
+ lr=0.025, batch_size=1024, n_neg=5, walks_per_chunk=5000,
431
+ seed=None):
432
+ """
433
+ skip-gram with negative sampling. streams (target, context) pairs per
434
+ chunk of walks instead of materializing every pair upfront, so memory
435
+ stays O(chunk) rather than O(all pairs).
436
+
437
+ seed defaults to the value passed at construction; re-seeding here makes
438
+ training independent of how much rng was consumed during walk generation.
439
+ """
440
+ _set_seed(self.seed if seed is None else seed)
441
+ word_to_ix = {}
442
+ for walk in walks:
443
+ for word in walk:
444
+ if word not in word_to_ix:
445
+ word_to_ix[word] = len(word_to_ix)
446
+
447
+ vocab_size = len(word_to_ix)
448
+ self.vocab = word_to_ix
449
+
450
+ # encode walks once as int arrays (reused across epochs)
451
+ ix_walks = [
452
+ np.fromiter((word_to_ix[w] for w in walk), dtype=np.int64, count=len(walk))
453
+ for walk in walks
454
+ ]
455
+
456
+ # precompute context offsets ex. [-5,-4,-3,-2,-1,1,2,3,4,5]
457
+ offsets = np.arange(-window_size, window_size + 1)
458
+ offsets = offsets[offsets != 0]
459
+
460
+ class SkipGram(torch.nn.Module):
461
+ def __init__(self, vocab_size, embedding_dim):
462
+ super().__init__()
463
+ self.embeddings = torch.nn.Embedding(vocab_size, embedding_dim)
464
+ self.output = torch.nn.Embedding(vocab_size, embedding_dim)
465
+ self.embeddings.weight.data.uniform_(-0.5 / embedding_dim, 0.5 / embedding_dim)
466
+ self.output.weight.data.uniform_(-0.5 / embedding_dim, 0.5 / embedding_dim)
467
+
468
+ def forward(self, target, context):
469
+ return torch.sum(self.embeddings(target) * self.output(context), dim=1)
470
+
471
+ model = SkipGram(vocab_size, dimensions)
472
+ optimizer = torch.optim.Adam(model.parameters(), lr=lr)
473
+
474
+ def _pairs_for_walk(walk):
475
+ # vectorized window extraction, returns (targets, contexts) int arrays
476
+ L = walk.shape[0]
477
+ if L < 2:
478
+ return np.empty(0, dtype=np.int64), np.empty(0, dtype=np.int64)
479
+ pos = np.arange(L)
480
+ ctx_pos = pos[:, None] + offsets[None, :]
481
+ valid = (ctx_pos >= 0) & (ctx_pos < L)
482
+ clipped = np.clip(ctx_pos, 0, L - 1)
483
+ tgt = np.broadcast_to(walk[:, None], (L, offsets.size))[valid]
484
+ ctx = walk[clipped][valid]
485
+ return tgt, ctx
486
+
487
+ def _train_batch(tgt_np, ctx_np):
488
+ targets = torch.from_numpy(tgt_np)
489
+ contexts = torch.from_numpy(ctx_np)
490
+ neg = torch.from_numpy(
491
+ np.random.randint(0, vocab_size, size=(tgt_np.shape[0], n_neg),
492
+ dtype=np.int64)
493
+ )
494
+ optimizer.zero_grad()
495
+ pos_score = torch.nn.functional.logsigmoid(model(targets, contexts))
496
+ neg_score = sum(
497
+ torch.nn.functional.logsigmoid(-model(targets, neg[:, j]))
498
+ for j in range(n_neg)
499
+ )
500
+ loss = -torch.mean(pos_score + neg_score)
501
+ loss.backward()
502
+ optimizer.step()
503
+ return loss.item()
504
+
505
+ walk_order = np.arange(len(ix_walks))
506
+ for epoch in range(epochs):
507
+ total_loss = 0.0
508
+ n_batches = 0
509
+ np.random.shuffle(walk_order)
510
+
511
+ # process walks in chunks to amortize vectorization
512
+ for chunk_start in range(0, len(walk_order), walks_per_chunk):
513
+ chunk_idx = walk_order[chunk_start:chunk_start + walks_per_chunk]
514
+ tgts, ctxs = [], []
515
+ for wi in chunk_idx:
516
+ t, c = _pairs_for_walk(ix_walks[wi])
517
+ if t.size:
518
+ tgts.append(t)
519
+ ctxs.append(c)
520
+ if not tgts:
521
+ continue
522
+ tgt_all = np.concatenate(tgts)
523
+ ctx_all = np.concatenate(ctxs)
524
+
525
+ # shuffle pairs within chunk, then iterate batches
526
+ perm = np.random.permutation(tgt_all.shape[0])
527
+ tgt_all = tgt_all[perm]
528
+ ctx_all = ctx_all[perm]
529
+
530
+ for i in range(0, tgt_all.shape[0], batch_size):
531
+ total_loss += _train_batch(
532
+ tgt_all[i:i + batch_size],
533
+ ctx_all[i:i + batch_size],
534
+ )
535
+ n_batches += 1
536
+
537
+ print(f"epoch {epoch + 1}/{epochs}, loss: "
538
+ f"{total_loss / max(1, n_batches):.4f}, batches: {n_batches}")
539
+
540
+ self.model = model
541
+ with torch.no_grad():
542
+ self.embeddings = {
543
+ word: model.embeddings.weight[idx].numpy()
544
+ for word, idx in word_to_ix.items()
545
+ }
546
+ return model
547
+
548
+ def save_model(self, path):
549
+ torch.save({"embeddings": self.embeddings, "vocab": self.vocab}, path)
550
+
551
+ def load_model(self, path):
552
+ state = torch.load(path)
553
+ self.embeddings = state["embeddings"]
554
+ self.vocab = state["vocab"]
gpath2vec/net.py ADDED
@@ -0,0 +1,140 @@
1
+ """reactome pathway network construction."""
2
+
3
+ import pickle
4
+ from itertools import chain
5
+
6
+ import networkx as nx
7
+
8
+ from . import utils
9
+ from . import ea
10
+
11
+
12
+ def fetch_human_hierarchy():
13
+ """parent-child pathway relations for homo sapiens, local cache or http."""
14
+ text = utils.fetch("ReactomePathwaysRelation.txt",
15
+ "https://reactome.org/download/current/ReactomePathwaysRelation.txt")
16
+ if text is None:
17
+ return []
18
+ return [tuple(line.split("\t")) for line in text.splitlines() if "-HSA" in line]
19
+
20
+
21
+ class Net:
22
+ """
23
+ pathway network over the reactome homo sapiens hierarchy.
24
+
25
+ enrichment: list of dicts [{"stId": ..., "entities": {"fdr": ...}}]
26
+ level: high/mid/low/all (filters pathways by ehld/sbgn before building)
27
+ gene_filter: optional set of genes to restrict pathway universe
28
+ clusters: optional dict {cluster_name: {stId: weight}} to add
29
+ gene lists of interest (e.g. clusters) as nodes connected
30
+ to their significant pathways with ea weights
31
+ """
32
+
33
+ def __init__(self, enrichment=None, id="study", digraph=False,
34
+ induce=False, level="all", gene_filter=None,
35
+ clusters=None, node_typing="sig"):
36
+ self.enrichment = enrichment or []
37
+ self.digraph = digraph
38
+ self.induce = induce
39
+ self.id = id
40
+ self.level = level
41
+ self.gene_filter = gene_filter
42
+ # "sig" (default): pathway nodes typed sig/notsig from enrichment FDR
43
+ # (unchanged behavior). "uniform": every pathway node typed "pathway",
44
+ # no FDR dependence, for connectivity-typed (ex. AUCell) graphs.
45
+ self.node_typing = node_typing
46
+ self.pathway_relations = fetch_human_hierarchy()
47
+ self.pathway_stids = set(chain(*self.pathway_relations))
48
+ self.graph = self._build_graph()
49
+ self.fdr_values = [p.get("entities", {}).get("fdr", 1.0) for p in self.enrichment]
50
+ self.ea_stids = [p["stId"] for p in self.enrichment]
51
+ self._set_node_attr()
52
+ if clusters is not None:
53
+ self.add_clusters(clusters)
54
+
55
+ def _build_graph(self):
56
+ G = nx.DiGraph(study=self.id) if self.digraph else nx.Graph(study=self.id)
57
+ G.add_edges_from(self.pathway_relations)
58
+
59
+ if self.level != "all":
60
+ keep = ea.level_stids(self.level)
61
+ if keep is not None:
62
+ G = G.subgraph(n for n in G.nodes() if n in keep).copy()
63
+
64
+ if self.gene_filter is not None:
65
+ gm = ea.filter_pathways(level=self.level, gene_filter=self.gene_filter)
66
+ keep = set(gm.stId)
67
+ G = G.subgraph(n for n in G.nodes() if n in keep).copy()
68
+
69
+ if self.induce and self.enrichment:
70
+ sig = [p["stId"] for p in self.enrichment
71
+ if p.get("entities", {}).get("fdr", 1) < 0.05]
72
+ G = G.subgraph(sig).copy()
73
+
74
+ return G
75
+
76
+ def _set_node_attr(self):
77
+ if self.node_typing == "uniform":
78
+ # connectivity-typed graph: every node present at this point is a
79
+ # pathway node (clusters are added afterwards). no sig/notsig, no
80
+ # FDR. simplified metapaths walk on this single "pathway" type.
81
+ nx.set_node_attributes(self.graph,
82
+ utils.pathway_parent_mappings(),
83
+ "parent_pathway")
84
+ nx.set_node_attributes(self.graph,
85
+ utils.pathway_name_mappings(),
86
+ "pathway_name")
87
+ nx.set_node_attributes(self.graph, {
88
+ n: {"node_type": "pathway", "stId": n}
89
+ for n in self.graph.nodes()
90
+ })
91
+ return
92
+
93
+ fdr_vals = self.fdr_values
94
+ id_keys = self.ea_stids
95
+
96
+ attr = {
97
+ id_keys[i]: {
98
+ "het": 1 if fdr_vals[i] < 0.05 else 0,
99
+ "features": [1 - round(fdr_vals[i], 4)],
100
+ "feature": round(fdr_vals[i], 4),
101
+ "node_type": "sig" if fdr_vals[i] < 0.05 else "notsig",
102
+ "stId": id_keys[i],
103
+ }
104
+ for i in range(len(id_keys))
105
+ }
106
+
107
+ not_ea = self.pathway_stids - set(id_keys)
108
+ attr_not_ea = {
109
+ stid: {"het": -1, "features": [-1], "feature": -1,
110
+ "node_type": "notsig", "stId": stid}
111
+ for stid in not_ea
112
+ }
113
+
114
+ nx.set_node_attributes(self.graph, utils.pathway_parent_mappings(), "parent_pathway")
115
+ nx.set_node_attributes(self.graph, utils.pathway_name_mappings(), "pathway_name")
116
+ nx.set_node_attributes(self.graph, attr)
117
+ nx.set_node_attributes(self.graph, attr_not_ea)
118
+
119
+ def add_clusters(self, clusters):
120
+ """
121
+ add gene lists of interest (clusters) as nodes in the graph,
122
+ connected to their significant pathways with ea weights.
123
+
124
+ clusters: {cluster_name: {stId: weight}}
125
+ """
126
+ for cname, pathway_weights in clusters.items():
127
+ node_id = f"cluster_{cname}"
128
+ self.graph.add_node(node_id, node_type="cluster", cluster=cname)
129
+ for stid, weight in pathway_weights.items():
130
+ if stid in self.graph:
131
+ self.graph.add_edge(node_id, stid, weight=weight)
132
+
133
+ def save(self, path):
134
+ with open(path, "wb") as f:
135
+ pickle.dump(self.graph, f)
136
+
137
+ def load(self, path):
138
+ # only load graphs you trust, pickle can execute arbitrary code
139
+ with open(path, "rb") as f:
140
+ self.graph = pickle.load(f)