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 +11 -0
- gset/api.py +109 -0
- gset/cardamom_eval.py +56 -0
- gset/clustering.py +62 -0
- gset/data.py +64 -0
- gset/embeddings.py +167 -0
- gset/graph.py +50 -0
- gset/lags.py +25 -0
- gset/metrics.py +87 -0
- gset/ot_similarity.py +73 -0
- gset/pipeline.py +56 -0
- gset/programs.py +107 -0
- gset/reproducible.py +16 -0
- gset/selection.py +97 -0
- gset/signals.py +61 -0
- gset-0.1.0.dist-info/METADATA +255 -0
- gset-0.1.0.dist-info/RECORD +21 -0
- gset-0.1.0.dist-info/WHEEL +5 -0
- gset-0.1.0.dist-info/top_level.txt +2 -0
- rmsexplorer/__init__.py +3 -0
- rmsexplorer/encoder.py +115 -0
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)))}
|