gset 0.1.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.
gset/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .graph import build_graph
2
+ from .embeddings import (train_gcn, coexpr_embedding,
3
+ train_deepsets, expr_embedding, train_both)
4
+ from .signals import kl_vector, kl_score, ot_residual, ot_score
5
+ from .lags import lag_matrix, symmetric_score
6
+ from .programs import find_programs, program_order, core_genes, programs_from_queries
7
+ from .selection import gset_context, bridge_genes, hvg_ranking, tf_selection, random_selection
8
+ from .metrics import relay_coverage, within_between_gap
9
+ from .pipeline import transition_vectors
10
+ from .reproducible import make_reproducible
11
+ from .api import select_genes, matrices_from_anndata
gset/api.py ADDED
@@ -0,0 +1,109 @@
1
+ """select_genes: GSET in one function.
2
+
3
+ from gset import select_genes
4
+ genes = select_genes(adata, budget=30, n_programs=3)
5
+
6
+ It follows the steps of the paper:
7
+ 1. train the encoders and compute the KL and OT signals of every transition
8
+ 2. find the gene programs, and their order
9
+ 3. choose the bridge genes between consecutive programs and the core genes of the programs
10
+ """
11
+ import numpy as np
12
+
13
+ from .lags import lag_matrix
14
+ from .pipeline import transition_vectors
15
+ from .programs import concat_embedding, find_programs, mean_profiles, program_order, programs_from_queries
16
+ from .selection import gset_context
17
+
18
+
19
+ def matrices_from_anndata(adata, time_key="time"):
20
+ """The count matrices (cells x genes), one per timepoint, in the order of the time.
21
+ The counts are in adata.X and the time of every cell is in adata.obs[time_key]."""
22
+ X = adata.X.toarray() if hasattr(adata.X, "toarray") else np.asarray(adata.X)
23
+ times = np.asarray(adata.obs[time_key])
24
+ return [X[times == t].astype(float) for t in np.unique(times)]
25
+
26
+
27
+ def _runs_from(data, time_key):
28
+ """Every run is a dict {timepoint: matrix}. Here: one run, or one for every replicate."""
29
+ if hasattr(data, "obs"): # an AnnData object
30
+ data = matrices_from_anndata(data, time_key)
31
+ if isinstance(data[0], np.ndarray): # one run: a list of matrices
32
+ data = [data]
33
+ runs = [dict(enumerate(run)) for run in data]
34
+ return runs, list(range(len(runs[0])))
35
+
36
+
37
+ def _split_budget(budget, n_cores, n_bridges, n_kl, n_ot):
38
+ """How many genes are cores, bridges from the KL signal and bridges from the OT signal."""
39
+ if n_cores is None and n_bridges is None:
40
+ n_cores = budget // 3 # a third of the budget for the cores
41
+ n_bridges = budget - n_cores # two thirds for the bridges
42
+ elif n_cores is None:
43
+ n_cores = budget - n_bridges
44
+ elif n_bridges is None:
45
+ n_bridges = budget - n_cores
46
+ if n_kl is None and n_ot is None:
47
+ n_ot = int(round(0.3 * n_bridges)) # 70% of the bridges from KL, 30% from OT
48
+ if n_kl is None:
49
+ n_kl = n_bridges - n_ot
50
+ if n_ot is None:
51
+ n_ot = n_bridges - n_kl
52
+ return n_cores + n_bridges, n_kl, n_ot
53
+
54
+
55
+ def select_genes(data, budget=30, n_programs=3, n_cores=None, n_bridges=None, n_kl=None, n_ot=None,
56
+ query_genes=None, program_size=30, labels=None, order=None,
57
+ coexpr_encoder="simulation", time_key="time", seed=42, n_epochs=None):
58
+ """Select a compact set of genes from time-resolved single-cell data.
59
+
60
+ Returns the sorted list of the indices of the selected genes: the bridge genes between consecutive
61
+ programs and the core genes of the programs.
62
+
63
+ Data
64
+ data an AnnData object (raw counts in X, the time of every cell in obs[time_key]) or a list of
65
+ count matrices (cells x genes), one per timepoint in the order of the time. With several
66
+ replicates, a list of such lists. The cells of different timepoints do not have to be matched.
67
+ time_key the column of obs with the time of the cells ("time")
68
+
69
+ Number of genes
70
+ budget how many genes to select (30)
71
+ n_cores how many of them are core genes (default: a third of the budget)
72
+ n_bridges how many are bridges (default: the rest). If you give both n_cores and n_bridges, the budget
73
+ is their sum.
74
+ n_kl, n_ot how many of the bridges come from the KL signal without lag and from the OT signal with lag 1
75
+ (default: 70% and 30%)
76
+
77
+ Programs (the groups of genes that work together). Choose one of the three ways:
78
+ n_programs the number of programs, found by clustering the genes (3)
79
+ query_genes the indices of one gene for every program, in the order in which the programs become active.
80
+ The program of a query gene is the gene and its program_size - 1 nearest genes (program_size
81
+ is 30), and the other genes go to the program with the nearest centroid.
82
+ labels, order the program of every gene (0, 1, 2, ...) and the order of the programs (by default 0, 1, 2, ...)
83
+
84
+ Encoders
85
+ coexpr_encoder the encoder of the co-expression embedding (the KL signal). "simulation" (default): the
86
+ encoder of the simulations, for panels of tens to a few hundred genes. "real_data": the encoder
87
+ of the experiments on the real data, for thousands of genes (rmsexplorer/encoder.py).
88
+ seed, n_epochs the training. n_epochs: 300 for "simulation", 200 and 150 for "real_data" (default).
89
+ """
90
+ runs, times = _runs_from(data, time_key)
91
+
92
+ # 1. the signals of every transition
93
+ vectors = transition_vectors(runs, times, seed=seed, n_epochs=n_epochs, coexpr_encoder=coexpr_encoder)
94
+ kl_lag0 = lag_matrix(vectors["kl"], 0)
95
+ ot_lag1 = lag_matrix(vectors["ot"], 1)
96
+ z_concat = concat_embedding(vectors["gcn"], runs, vectors["active"], embed=vectors["embed"])
97
+
98
+ # 2. the programs and their order
99
+ if query_genes is not None:
100
+ labels, order = programs_from_queries(z_concat, query_genes, program_size)
101
+ elif labels is None:
102
+ labels = find_programs(z_concat, n_programs)
103
+ order, _ = program_order(labels, mean_profiles(runs, vectors["active"]))
104
+ elif order is None:
105
+ order = list(range(int(np.max(labels)) + 1))
106
+
107
+ # 3. the bridges and the cores
108
+ budget, n_kl, n_ot = _split_budget(budget, n_cores, n_bridges, n_kl, n_ot)
109
+ return gset_context(budget, n_kl, n_ot, np.asarray(labels), order, kl_lag0, ot_lag1, z_concat)
gset/cardamom_eval.py ADDED
@@ -0,0 +1,56 @@
1
+ """Scoring of an inferred network (from CardamomOT) against the true network.
2
+
3
+ The inferred matrix belongs to a context of genes. Its first row and column are
4
+ the stimulus, the other rows and columns follow the order of the genes.
5
+ """
6
+ import numpy as np
7
+ from sklearn.metrics import average_precision_score, roc_auc_score
8
+
9
+
10
+ def _auroc(y, score):
11
+ return float(roc_auc_score(y, score)) if 0 < y.sum() < len(y) else None
12
+
13
+
14
+ def _auprc(y, score):
15
+ return float(average_precision_score(y, score)) if y.sum() > 0 else None
16
+
17
+
18
+ def restrict(network, genes):
19
+ """The part of a (genes + 1) x (genes + 1) matrix that belongs to the given genes."""
20
+ index = [0] + [g + 1 for g in genes]
21
+ return network[np.ix_(index, index)]
22
+
23
+
24
+ def evaluate_network(inferred, true_network, genes, labels):
25
+ """Scores for all edges inside the context and for the relay edges.
26
+
27
+ all_edge: AUROC and AUPRC over all pairs of genes of the context.
28
+ relay_ab: relay edges from program a to program b. A relay edge with a gene
29
+ outside the context cannot be found and gets the score 0
30
+ ("uncovered edges count as missed").
31
+ """
32
+ genes = list(genes)
33
+ position = {g: k for k, g in enumerate(genes)}
34
+ scores = inferred[1:, 1:]
35
+ n = len(genes)
36
+
37
+ truth = restrict(true_network, genes)[1:, 1:]
38
+ off_diagonal = ~np.eye(n, dtype=bool)
39
+ y = (np.abs(truth) > 1e-6)[off_diagonal].astype(int)
40
+ s = np.abs(scores)[off_diagonal]
41
+ result = {"all_edge": {"auroc": _auroc(y, s), "auprc": _auprc(y, s),
42
+ "n_true": int(y.sum()), "base_rate": float(y.mean())}}
43
+
44
+ for a in range(int(labels.max())):
45
+ b = a + 1
46
+ pairs = [(int(i), int(j)) for i in np.where(labels == a)[0]
47
+ for j in np.where(labels == b)[0] if i != j]
48
+ is_edge = np.array([int(abs(true_network[i + 1, j + 1]) > 1e-6) for i, j in pairs])
49
+ kept = np.array([(i in position and j in position) for i, j in pairs])
50
+ score = np.array([abs(scores[position[i], position[j]]) if (i in position and j in position) else 0.0
51
+ for i, j in pairs])
52
+ result[f"relay_{a}{b}"] = {
53
+ "n_true": int(is_edge.sum()), "n_covered": int((is_edge * kept).sum()),
54
+ "auroc": _auroc(is_edge, score), "auprc": _auprc(is_edge, score),
55
+ "base_rate": float(is_edge.mean())}
56
+ return result
gset/clustering.py ADDED
@@ -0,0 +1,62 @@
1
+ """Scores for how well a distance matrix between genes recovers the programs."""
2
+ import numpy as np
3
+ from scipy.sparse import csr_matrix
4
+ from scipy.sparse.csgraph import shortest_path
5
+ from sklearn.cluster import AgglomerativeClustering
6
+ from sklearn.manifold import MDS
7
+ from sklearn.metrics import (adjusted_rand_score, normalized_mutual_info_score,
8
+ roc_auc_score)
9
+
10
+
11
+ def cosine_distance(V):
12
+ norms = np.linalg.norm(V, axis=1, keepdims=True)
13
+ norms = np.where(norms == 0, 1e-12, norms)
14
+ Vn = V / norms
15
+ return (1.0 - np.clip(Vn @ Vn.T, -1.0, 1.0)).astype(np.float32)
16
+
17
+
18
+ def max_rank_distance(W1, W2):
19
+ """Larger of the two normalised ranks of every entry."""
20
+ n = W1.shape[0]
21
+
22
+ def ranks(W):
23
+ return np.argsort(np.argsort(W.flatten())).reshape(W.shape) / (n * n)
24
+
25
+ return np.maximum(ranks(W1), ranks(W2)).astype(np.float32)
26
+
27
+
28
+ def score_distance_matrix(W, labels, n_programs, complete_with_paths=False):
29
+ """Cluster the genes and compare with the true programs.
30
+
31
+ The distances are turned into 10 coordinates by multidimensional scaling
32
+ and the genes are clustered with Ward linkage. ARI and NMI compare the
33
+ clusters with the true programs. AUROC asks if two genes of the same
34
+ program are closer than two genes of different programs.
35
+
36
+ complete_with_paths: for sparse distance matrices (zero means "no edge")
37
+ the missing distances are first filled with shortest paths.
38
+ """
39
+ if complete_with_paths:
40
+ W_full = shortest_path(csr_matrix(W), method="D", directed=False)
41
+ finite = W_full[np.isfinite(W_full)]
42
+ W_full[~np.isfinite(W_full)] = (finite.max() if len(finite) else 1.0) * 1.5
43
+ else:
44
+ W_full = W.astype(float)
45
+ W_full = (W_full + W_full.T) / 2
46
+ np.fill_diagonal(W_full, 0.0)
47
+
48
+ n_genes = len(labels)
49
+ n_mds = min(10, n_genes - 1)
50
+ mds = MDS(n_components=n_mds, dissimilarity="precomputed",
51
+ random_state=42, normalized_stress="auto")
52
+ coords = mds.fit_transform(W_full)
53
+ clusters = AgglomerativeClustering(n_clusters=min(n_programs, n_genes - 1),
54
+ linkage="ward").fit_predict(coords)
55
+
56
+ rows, cols = np.triu_indices(n_genes, k=1)
57
+ same = (labels[rows] == labels[cols]).astype(int)
58
+ return {
59
+ "ARI": float(adjusted_rand_score(labels, clusters)),
60
+ "NMI": float(normalized_mutual_info_score(labels, clusters)),
61
+ "AUROC": float(roc_auc_score(same, -W[rows, cols])),
62
+ }
gset/data.py ADDED
@@ -0,0 +1,64 @@
1
+ import json
2
+ import numpy as np
3
+ from pathlib import Path
4
+
5
+
6
+ def load_run(path, keep_stimulus=False):
7
+ """Read one HARISSA output file.
8
+
9
+ The file has one row of times, one row with the stimulus and then one row
10
+ per gene. The first column only holds labels, so it is skipped.
11
+ Returns the matrix (cells, genes) and the time of every cell.
12
+ """
13
+ raw = np.loadtxt(path, delimiter="\t")
14
+ times = raw[0, 1:].astype(int)
15
+ first_row = 1 if keep_stimulus else 2
16
+ X = raw[first_row:, 1:].T
17
+ return X, times
18
+
19
+
20
+ def load_simulation(sim_dir, keep_stimulus=False):
21
+ """Load all runs of a simulation folder.
22
+
23
+ Returns a list of runs (each a dict: time -> cells x genes matrix),
24
+ the program label of every gene and the sorted list of timepoints.
25
+ """
26
+ sim_dir = Path(sim_dir)
27
+ labels = np.load(sim_dir / "cluster_ids.npy")
28
+ runs = []
29
+ all_times = set()
30
+ for path in sorted((sim_dir / "data").glob("data_*.txt")):
31
+ X, times = load_run(path, keep_stimulus)
32
+ run = {}
33
+ for t in np.unique(times):
34
+ run[int(t)] = X[times == t]
35
+ all_times.add(int(t))
36
+ runs.append(run)
37
+ return runs, labels, sorted(all_times)
38
+
39
+
40
+ def load_all_cells(sim_dir):
41
+ """All cells of all runs in the order of the files (run after run).
42
+
43
+ Returns the matrix (cells, genes) and the time of every cell.
44
+ """
45
+ parts, times = [], []
46
+ for path in sorted((Path(sim_dir) / "data").glob("data_*.txt")):
47
+ X, t = load_run(path)
48
+ parts.append(X)
49
+ times.append(t)
50
+ return np.vstack(parts), np.concatenate(times)
51
+
52
+
53
+ def active_timepoints(runs, timepoints):
54
+ """Timepoints at which the genes are switched on (mean count above 1)."""
55
+ keep = []
56
+ for t in timepoints:
57
+ mean = np.concatenate([run[t] for run in runs]).mean()
58
+ if mean > 1.0:
59
+ keep.append(t)
60
+ return keep if len(keep) >= 2 else timepoints
61
+
62
+
63
+ def load_metadata(sim_dir):
64
+ return json.loads((Path(sim_dir) / "metadata.json").read_text())
gset/embeddings.py ADDED
@@ -0,0 +1,167 @@
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+ from torch_geometric.nn import GCNConv
6
+
7
+ from .graph import build_graph, log_normalize
8
+
9
+ BETA = 6.0 # power of the correlation in the co-expression graph (as in WGCNA)
10
+ THRESH = 0.01 # edges with a weight below this are dropped
11
+ QUANTILE_LEVELS = np.linspace(1, 99, 49) # the 49 quantiles of a gene that the DeepSets encoder has to reconstruct
12
+
13
+
14
+ # ---------------------------------------------------------------
15
+ # co-expression embedding (graph auto-encoder with a GCN)
16
+ # ---------------------------------------------------------------
17
+
18
+ class GCNEncoder(nn.Module):
19
+ """Graph auto-encoder: two graph convolutions turn every gene into a vector of 16 numbers."""
20
+
21
+ def __init__(self, n_genes, d_hid=64, d_out=16):
22
+ super().__init__()
23
+ self.conv1 = GCNConv(n_genes, d_hid, add_self_loops=False)
24
+ self.conv2 = GCNConv(d_hid, d_out, add_self_loops=False)
25
+
26
+ def forward(self, data):
27
+ x = F.elu(self.conv1(data.x, data.edge_index, data.edge_attr))
28
+ return self.conv2(x, data.edge_index, data.edge_attr)
29
+
30
+ def loss(self, data):
31
+ # the inner product of two embeddings should give the edge weight
32
+ z = self(data)
33
+ src, dst = data.edge_index
34
+ pred = (z[src] * z[dst]).sum(dim=1)
35
+ return F.mse_loss(pred, data.edge_attr.reshape(-1))
36
+
37
+
38
+ def train_gcn(matrices, n_genes, n_epochs=300, seed=42):
39
+ """Train one GCN on the graphs of all matrices (weights are shared)."""
40
+ torch.manual_seed(seed)
41
+ np.random.seed(seed)
42
+ model = GCNEncoder(n_genes)
43
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
44
+
45
+ graphs = []
46
+ for X in matrices:
47
+ data, n_edges = build_graph(X, BETA, THRESH)
48
+ if n_edges > 0:
49
+ graphs.append(data)
50
+
51
+ order = np.arange(len(graphs))
52
+ model.train()
53
+ for epoch in range(n_epochs):
54
+ np.random.shuffle(order)
55
+ for i in order:
56
+ optimizer.zero_grad()
57
+ loss = model.loss(graphs[i])
58
+ loss.backward()
59
+ optimizer.step()
60
+ model.eval()
61
+ return model
62
+
63
+
64
+ def coexpr_embedding(model, X):
65
+ """z_coexpr for one cell x gene matrix."""
66
+ data, _ = build_graph(X.astype(float), BETA, THRESH)
67
+ with torch.no_grad():
68
+ z = model(data).numpy()
69
+ return np.nan_to_num(z, nan=0.0)
70
+
71
+
72
+ # ---------------------------------------------------------------
73
+ # marginal expression embedding (DeepSets on the cells of each gene)
74
+ # ---------------------------------------------------------------
75
+
76
+ class DeepSetsEncoder(nn.Module):
77
+ """Turns the expression of one gene in all cells into a vector. The order of the cells does not matter:
78
+ every cell goes through phi, the results are pooled (mean and std over the cells) and go through rho.
79
+ The decoder rebuilds the 49 quantiles of the gene from the vector."""
80
+
81
+ def __init__(self, d_hid=32, d_out=16, d_target=49):
82
+ super().__init__()
83
+ self.phi = nn.Sequential(nn.Linear(1, d_hid), nn.ReLU(), nn.Linear(d_hid, d_hid))
84
+ self.rho = nn.Sequential(nn.Linear(2 * d_hid, d_hid), nn.ReLU(), nn.Linear(d_hid, d_out))
85
+ self.decoder = nn.Sequential(nn.Linear(d_out, d_hid), nn.ReLU(), nn.Linear(d_hid, d_target))
86
+
87
+ def encode(self, x):
88
+ # x has shape (cells, genes). Every gene is encoded on its own.
89
+ h = self.phi(x.unsqueeze(-1))
90
+ pooled = torch.cat([h.mean(0), h.std(0)], dim=-1)
91
+ return self.rho(pooled)
92
+
93
+ def forward(self, x):
94
+ z = self.encode(x)
95
+ return z, self.decoder(z)
96
+
97
+
98
+ def quantile_target(X):
99
+ # 49 quantiles of each gene, used as the reconstruction target
100
+ Xn = log_normalize(X)
101
+ q = np.percentile(Xn, QUANTILE_LEVELS, axis=0)
102
+ return q.T.astype(np.float32)
103
+
104
+
105
+ def train_deepsets(matrices, n_epochs=300, seed=42):
106
+ """Train the DeepSets encoder on the cell x gene matrices of all timepoints.
107
+
108
+ The loss has two parts: the vector of a gene must rebuild its quantiles (loss_rec), and the vectors of two
109
+ random halves of the cells of the gene must be the same (loss_con)."""
110
+ torch.manual_seed(seed)
111
+ rng = np.random.default_rng(seed)
112
+ model = DeepSetsEncoder()
113
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
114
+
115
+ # 1. log-normalise and standardise every gene with the mean and std of all timepoints together
116
+ logs = [log_normalize(X) for X in matrices]
117
+ pooled = np.vstack(logs)
118
+ ref_mean = pooled.mean(0, keepdims=True)
119
+ ref_std = pooled.std(0, keepdims=True) + 1e-8
120
+
121
+ data = []
122
+ for X, Xn in zip(matrices, logs):
123
+ x_std = (Xn - ref_mean) / ref_std
124
+ target = quantile_target(X)
125
+ target = (target - target.mean(0, keepdims=True)) / (target.std(0, keepdims=True) + 1e-8)
126
+ data.append((x_std, target))
127
+
128
+ # 2. training: one step for every matrix in every epoch
129
+ model.train()
130
+ for epoch in range(n_epochs):
131
+ for x_std, target in data:
132
+ n = x_std.shape[0]
133
+ perm = rng.permutation(n)
134
+ half1, half2 = perm[: n // 2], perm[n // 2:]
135
+ x_all = torch.tensor(x_std, dtype=torch.float32)
136
+ x1 = torch.tensor(x_std[half1], dtype=torch.float32)
137
+ x2 = torch.tensor(x_std[half2], dtype=torch.float32)
138
+ target = torch.tensor(target, dtype=torch.float32)
139
+
140
+ optimizer.zero_grad()
141
+ _, recon = model(x_all)
142
+ loss_rec = ((recon - target) ** 2).mean()
143
+ # two halves of the cells of one gene should give the same embedding
144
+ loss_con = ((model.encode(x1) - model.encode(x2)) ** 2).mean()
145
+ loss = loss_rec + loss_con
146
+ loss.backward()
147
+ optimizer.step()
148
+ model.eval()
149
+ model.ref_mean = ref_mean
150
+ model.ref_std = ref_std
151
+ return model
152
+
153
+
154
+ def expr_embedding(model, X):
155
+ """z_expr for one cell x gene matrix."""
156
+ Xn = log_normalize(X.astype(float))
157
+ x_std = (Xn - model.ref_mean) / model.ref_std
158
+ with torch.no_grad():
159
+ z = model.encode(torch.tensor(x_std, dtype=torch.float32)).numpy()
160
+ return np.nan_to_num(z, nan=0.0).astype(np.float32)
161
+
162
+
163
+ def train_both(matrices, n_genes, n_epochs=300, seed=42):
164
+ """Train the GCN and the DeepSets encoder on the same matrices."""
165
+ gcn = train_gcn(matrices, n_genes, n_epochs, seed)
166
+ deepsets = train_deepsets([X.astype(float) for X in matrices], n_epochs, seed)
167
+ return gcn, deepsets
gset/graph.py ADDED
@@ -0,0 +1,50 @@
1
+ import numpy as np
2
+ import torch
3
+ from torch_geometric.data import Data
4
+
5
+
6
+ def log_normalize(X):
7
+ # library size normalisation followed by log1p
8
+ return np.log1p(X / (X.sum(1, keepdims=True) + 1e-8) * 1e4)
9
+
10
+
11
+ def build_graph(X, beta=6.0, thresh=0.01):
12
+ """Co-expression graph of the genes (like WGCNA).
13
+
14
+ X has shape (cells, genes). The edge weight of two genes is
15
+ |correlation| ** beta. Weights below thresh are dropped.
16
+ """
17
+ Xn = log_normalize(X)
18
+ mean = Xn.mean(0)
19
+ std = Xn.std(0)
20
+ Xs = (Xn - mean) / (std + 1e-8)
21
+ Xs[:, std < 1e-6] = 0.0
22
+
23
+ n_genes = Xs.shape[1]
24
+ corr = (Xs.T @ Xs) / Xs.shape[0]
25
+ np.fill_diagonal(corr, 0)
26
+ corr = np.nan_to_num(corr, nan=0.0)
27
+ A = np.clip(np.abs(corr) ** beta, 0.0, 1.0)
28
+
29
+ rows, cols = np.where(A >= thresh)
30
+ keep = rows < cols
31
+ rows, cols = rows[keep], cols[keep]
32
+ weights = A[rows, cols].astype(np.float32)
33
+
34
+ if len(rows) == 0:
35
+ # nothing passed the threshold, so keep the strongest 1% of the pairs
36
+ iu = np.triu_indices(n_genes, k=1)
37
+ vals = A[iu]
38
+ k = max(1, int(round(0.01 * len(vals))))
39
+ top = np.argpartition(-vals, k - 1)[:k]
40
+ rows, cols = iu[0][top], iu[1][top]
41
+ weights = vals[top].astype(np.float32)
42
+
43
+ edge_index = torch.tensor(
44
+ np.stack([np.concatenate([rows, cols]), np.concatenate([cols, rows])]),
45
+ dtype=torch.long)
46
+ edge_weight = torch.tensor(np.concatenate([weights, weights]), dtype=torch.float32)
47
+ data = Data(x=torch.tensor(corr, dtype=torch.float32),
48
+ edge_index=edge_index,
49
+ edge_attr=edge_weight.unsqueeze(1))
50
+ return data, len(rows)
gset/lags.py ADDED
@@ -0,0 +1,25 @@
1
+ import numpy as np
2
+ from sklearn.preprocessing import normalize
3
+
4
+
5
+ def lag_matrix(vectors, lag):
6
+ """Lagged cosine similarity between the genes.
7
+
8
+ vectors is a list with one (genes x dim) array per transition.
9
+ Entry [i, j] compares gene i in transition t with gene j in transition
10
+ t + lag, averaged over t. With lag 0 the matrix is symmetric.
11
+ """
12
+ vecs = [normalize(v, norm="l2", axis=1) for v in vectors]
13
+ if lag == 0:
14
+ return sum(v @ v.T for v in vecs) / len(vecs)
15
+ n = len(vecs) - lag
16
+ return sum(vecs[t] @ vecs[t + lag].T for t in range(n)) / n
17
+
18
+
19
+ def symmetric_score(M, labels):
20
+ """Best partner of every gene in any other program (both directions)."""
21
+ score = np.zeros(len(labels))
22
+ for g in range(len(labels)):
23
+ other = labels != labels[g]
24
+ score[g] = max(M[g, other].max(), M[other, g].max())
25
+ return score
gset/metrics.py ADDED
@@ -0,0 +1,87 @@
1
+ import numpy as np
2
+ from sklearn.metrics import roc_auc_score, average_precision_score
3
+ from sklearn.preprocessing import normalize
4
+
5
+
6
+ def relay_edges(labels, true_network, a, b):
7
+ """True edges from program a to program b.
8
+
9
+ true_network is the interaction matrix of the simulation. Its first row
10
+ and column belong to the stimulus, so gene i has index i + 1.
11
+ """
12
+ edges = []
13
+ for i in np.where(labels == a)[0]:
14
+ for j in np.where(labels == b)[0]:
15
+ if i != j and true_network[i + 1, j + 1] != 0:
16
+ edges.append((int(i), int(j)))
17
+ return edges
18
+
19
+
20
+ def relay_coverage(genes, labels, true_network):
21
+ """For every pair of consecutive programs: how many relay edges have both
22
+ genes in the selected set."""
23
+ selected = set(genes)
24
+ result = {}
25
+ for a in range(int(labels.max())):
26
+ edges = relay_edges(labels, true_network, a, a + 1)
27
+ covered = sum(1 for i, j in edges if i in selected and j in selected)
28
+ result[f"{a}{a + 1}"] = (covered, len(edges))
29
+ return result
30
+
31
+
32
+ def within_between_gap(V, labels):
33
+ """Mean cosine similarity of gene pairs in the same program minus the mean
34
+ of pairs in different programs."""
35
+ Vn = normalize(V, norm="l2", axis=1)
36
+ S = Vn @ Vn.T
37
+ same = labels[:, None] == labels[None, :]
38
+ upper = np.triu(np.ones_like(same, dtype=bool), k=1)
39
+ return S[same & upper].mean() - S[~same & upper].mean()
40
+
41
+
42
+ def load_relay_edges(sim_dir):
43
+ """Relay edges of a simulation as {(a, b): set of (i, j)}."""
44
+ import json
45
+ from pathlib import Path
46
+ raw = json.loads((Path(sim_dir) / "relay_edges.json").read_text())
47
+ edges = {}
48
+ for key, pairs in raw.items():
49
+ a, b = (int(x) for x in key.split("_"))
50
+ edges[(a, b)] = {(int(i), int(j)) for i, j in pairs}
51
+ return edges
52
+
53
+
54
+ def candidate_pairs(labels, tf_mask, a, b, targets="any"):
55
+ """Pairs (i, j) with i a TF of program a and j a gene of program b.
56
+ targets="tf" keeps only TFs as j."""
57
+ sources = [i for i in np.where(labels == a)[0] if tf_mask[i]]
58
+ if targets == "tf":
59
+ sinks = [j for j in np.where(labels == b)[0] if tf_mask[j]]
60
+ else:
61
+ sinks = list(np.where(labels == b)[0])
62
+ return [(int(i), int(j)) for i in sources for j in sinks]
63
+
64
+
65
+ def forward_discrimination(C, edges, pairs):
66
+ """AUROC of true edges against the other candidate pairs, using the
67
+ forward reading C[i, j] (i in the earlier program)."""
68
+ y = [int((i, j) in edges) for i, j in pairs]
69
+ return roc_auc_score(y, [C[i, j] for i, j in pairs])
70
+
71
+
72
+ def direction_statistics(C, edges, pairs):
73
+ """How often does the forward reading beat the reversed reading?
74
+
75
+ Returns the fraction for true edges, the fraction for the other pairs and
76
+ the AUROC of the asymmetry C[i, j] - C[j, i] (true edges against the other
77
+ pairs). A symmetric matrix has no direction and gives None.
78
+ """
79
+ if np.abs(C - C.T).max() < 1e-9:
80
+ return None
81
+ edge_list = sorted(edges)
82
+ others = [p for p in pairs if p not in edges]
83
+ asym = lambda ps: [C[i, j] - C[j, i] for i, j in ps]
84
+ y = [1] * len(edge_list) + [0] * len(others)
85
+ return {"edges": float(np.mean([x > 0 for x in asym(edge_list)])),
86
+ "others": float(np.mean([x > 0 for x in asym(others)])),
87
+ "auroc": float(roc_auc_score(y, asym(edge_list) + asym(others)))}