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/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)
|