spaceexpress 0.1.5__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.
- SpaceExpress/__init__.py +4 -0
- SpaceExpress/preprocessing.py +109 -0
- SpaceExpress/spaceexpress.py +300 -0
- SpaceExpress/spaceexpress_dse.py +718 -0
- SpaceExpress/utils.py +369 -0
- spaceexpress-0.1.5.dist-info/METADATA +81 -0
- spaceexpress-0.1.5.dist-info/RECORD +10 -0
- spaceexpress-0.1.5.dist-info/WHEEL +5 -0
- spaceexpress-0.1.5.dist-info/licenses/LICENSE +21 -0
- spaceexpress-0.1.5.dist-info/top_level.txt +1 -0
SpaceExpress/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
from .utils import shortest_path, plot_embedding, jaccard_similarity, choose_k, plot_DSE
|
|
2
|
+
from .preprocessing import select_hvg_after_outlier
|
|
3
|
+
from .spaceexpress import train_SpaceExpress, train_SpaceExpress_multi
|
|
4
|
+
from .spaceexpress_dse import SpaceExpress_DSE, summary_DSE
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Preprocessing helpers for sparse spatial-expression inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import anndata as ad
|
|
6
|
+
import numpy as np
|
|
7
|
+
import scanpy as sc
|
|
8
|
+
import scipy.sparse as sp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def select_hvg_after_outlier(
|
|
12
|
+
adata_list,
|
|
13
|
+
n_top_genes=200,
|
|
14
|
+
z_threshold=4.0,
|
|
15
|
+
):
|
|
16
|
+
"""Remove pooled high-expression outliers before batch-aware HVG selection.
|
|
17
|
+
|
|
18
|
+
The input AnnData objects must already be normalized and log-transformed.
|
|
19
|
+
For every common gene, values greater than or equal to pooled
|
|
20
|
+
``mean + z_threshold * sample_sd`` are set to zero. Seurat HVGs are then
|
|
21
|
+
selected from the cleaned matrices with each AnnData treated as a batch.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
cleaned_hvg : list[AnnData]
|
|
26
|
+
Copies restricted to the same sorted HVGs and containing cleaned values.
|
|
27
|
+
hvg : list[str]
|
|
28
|
+
Sorted selected gene names.
|
|
29
|
+
diagnostics : dict
|
|
30
|
+
Counts and nonzero-prevalence summaries for reproducibility.
|
|
31
|
+
"""
|
|
32
|
+
if len(adata_list) < 2:
|
|
33
|
+
raise ValueError("At least two AnnData objects are required.")
|
|
34
|
+
if n_top_genes < 1:
|
|
35
|
+
raise ValueError("n_top_genes must be positive.")
|
|
36
|
+
if z_threshold <= 0:
|
|
37
|
+
raise ValueError("z_threshold must be positive.")
|
|
38
|
+
|
|
39
|
+
common = sorted(set.intersection(*(set(item.var_names) for item in adata_list)))
|
|
40
|
+
if len(common) < n_top_genes:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Need at least {n_top_genes} common genes, found {len(common)}."
|
|
43
|
+
)
|
|
44
|
+
cleaned = [item[:, common].copy() for item in adata_list]
|
|
45
|
+
matrices = [
|
|
46
|
+
item.X.tocsr().astype(np.float64, copy=True)
|
|
47
|
+
if sp.issparse(item.X)
|
|
48
|
+
else sp.csr_matrix(np.asarray(item.X, dtype=np.float64))
|
|
49
|
+
for item in cleaned
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
pooled = sp.vstack(matrices, format="csr")
|
|
53
|
+
n_obs = pooled.shape[0]
|
|
54
|
+
means = np.asarray(pooled.sum(axis=0)).ravel() / n_obs
|
|
55
|
+
sums_of_squares = np.asarray(pooled.power(2).sum(axis=0)).ravel()
|
|
56
|
+
variances = (sums_of_squares - n_obs * means**2) / max(n_obs - 1, 1)
|
|
57
|
+
thresholds = means + z_threshold * np.sqrt(np.maximum(variances, 0.0))
|
|
58
|
+
|
|
59
|
+
removed_entries = []
|
|
60
|
+
for item, matrix in zip(cleaned, matrices):
|
|
61
|
+
remove = matrix.data >= thresholds[matrix.indices]
|
|
62
|
+
removed_entries.append(int(remove.sum()))
|
|
63
|
+
matrix.data[remove] = 0.0
|
|
64
|
+
matrix.eliminate_zeros()
|
|
65
|
+
item.X = matrix.astype(np.float32)
|
|
66
|
+
|
|
67
|
+
combined = ad.concat(
|
|
68
|
+
cleaned,
|
|
69
|
+
label="_hvg_batch",
|
|
70
|
+
keys=[f"sample{i}" for i in range(len(cleaned))],
|
|
71
|
+
index_unique="-",
|
|
72
|
+
)
|
|
73
|
+
sc.pp.highly_variable_genes(
|
|
74
|
+
combined,
|
|
75
|
+
n_top_genes=n_top_genes,
|
|
76
|
+
flavor="seurat",
|
|
77
|
+
batch_key="_hvg_batch",
|
|
78
|
+
)
|
|
79
|
+
hvg = sorted(combined.var_names[combined.var["highly_variable"]].tolist())
|
|
80
|
+
if len(hvg) != n_top_genes:
|
|
81
|
+
raise RuntimeError(f"Expected {n_top_genes} HVGs, found {len(hvg)}.")
|
|
82
|
+
|
|
83
|
+
result = [item[:, hvg].copy() for item in cleaned]
|
|
84
|
+
prevalence = []
|
|
85
|
+
for item in result:
|
|
86
|
+
counts = np.asarray((item.X > 0).sum(axis=0)).ravel()
|
|
87
|
+
prevalence.append(
|
|
88
|
+
{
|
|
89
|
+
"min": int(counts.min()),
|
|
90
|
+
"q25": float(np.quantile(counts, 0.25)),
|
|
91
|
+
"median": float(np.median(counts)),
|
|
92
|
+
"q75": float(np.quantile(counts, 0.75)),
|
|
93
|
+
"max": int(counts.max()),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
diagnostics = {
|
|
97
|
+
"method": "pooled_mean_plus_sample_sd",
|
|
98
|
+
"z_threshold": float(z_threshold),
|
|
99
|
+
"common_genes_before_hvg": len(common),
|
|
100
|
+
"removed_expression_entries": removed_entries,
|
|
101
|
+
"selected_hvg_nonzero_spots": prevalence,
|
|
102
|
+
}
|
|
103
|
+
for item in result:
|
|
104
|
+
item.uns["spaceexpress_preprocessing"] = {
|
|
105
|
+
"mean_sd_outliers_removed": True,
|
|
106
|
+
"z_threshold": float(z_threshold),
|
|
107
|
+
"stage": "before_hvg",
|
|
108
|
+
}
|
|
109
|
+
return result, hvg, diagnostics
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import pickle, random
|
|
2
|
+
import numpy as np
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
import torch.nn.functional as F
|
|
6
|
+
import torch.optim as optim
|
|
7
|
+
from torch.utils.data import Dataset, DataLoader
|
|
8
|
+
from sklearn.neighbors import kneighbors_graph
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
import copy
|
|
11
|
+
import scipy
|
|
12
|
+
import scanpy as sc
|
|
13
|
+
|
|
14
|
+
def get_index (data):
|
|
15
|
+
n_cells = data.shape[0]
|
|
16
|
+
mask = torch.triu(torch.ones((n_cells, n_cells)), diagonal=1)
|
|
17
|
+
index = torch.nonzero(mask, as_tuple=False)
|
|
18
|
+
return index
|
|
19
|
+
|
|
20
|
+
def loaded_paths(file_path):
|
|
21
|
+
loaded_paths = {}
|
|
22
|
+
|
|
23
|
+
with open(file_path, 'rb') as handle:
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
data = pickle.load(handle)
|
|
27
|
+
loaded_paths.update(data)
|
|
28
|
+
except EOFError:
|
|
29
|
+
break
|
|
30
|
+
shortest_path = [loaded_paths[i][0] for i in range(len(loaded_paths))]
|
|
31
|
+
shortest_path = np.array(shortest_path)
|
|
32
|
+
return shortest_path
|
|
33
|
+
|
|
34
|
+
class SpaceExpress(nn.Module):
|
|
35
|
+
def __init__(self, input_dim, hidden_dim, latent_dim):
|
|
36
|
+
super(SpaceExpress, self).__init__()
|
|
37
|
+
|
|
38
|
+
self.encoder = nn.Sequential(
|
|
39
|
+
nn.Linear(input_dim, hidden_dim),
|
|
40
|
+
nn.BatchNorm1d(hidden_dim),
|
|
41
|
+
nn.ReLU(),
|
|
42
|
+
nn.Linear(hidden_dim, latent_dim),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def forward(self, x):
|
|
46
|
+
encoded = self.encoder(x)
|
|
47
|
+
return encoded
|
|
48
|
+
|
|
49
|
+
class KKLoss:
|
|
50
|
+
def __init__(self, device, shortest_path):
|
|
51
|
+
self.device = device
|
|
52
|
+
self.shortest_path = torch.tensor(shortest_path, dtype=torch.float32).to(device)
|
|
53
|
+
|
|
54
|
+
def __call__(self, p, idx):
|
|
55
|
+
d = self.shortest_path
|
|
56
|
+
mask = d != 0
|
|
57
|
+
d_inv = torch.where(mask, 1 / d**2, torch.zeros_like(d))
|
|
58
|
+
p = p.unsqueeze(1) - p.unsqueeze(0)
|
|
59
|
+
p = torch.sum(torch.abs(p),dim=-1)
|
|
60
|
+
diff = p - d
|
|
61
|
+
out = d_inv * mask * diff.pow(2)
|
|
62
|
+
out = out[idx[:, 0], idx[:, 1]]
|
|
63
|
+
out = torch.sum(out)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def get_avg_neighbor(pos, count, k):
|
|
67
|
+
A = kneighbors_graph(pos, k, mode='connectivity', include_self=False)
|
|
68
|
+
neighbor = A.dot(count) / k
|
|
69
|
+
neighbor_dense = neighbor.toarray() if hasattr(neighbor, "toarray") else neighbor
|
|
70
|
+
count_dense = count.toarray() if hasattr(count, "toarray") else count
|
|
71
|
+
out = np.concatenate((count_dense, neighbor_dense), axis=1)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def train_SpaceExpress(adata, shortest_file_path, device = None, epochs = 10000, lr = 0.01, hid_dim = 32, emb_dim = 8,
|
|
75
|
+
patience = 100, random_seed = 42, batch_size = 256, num_hvg = 1000, save_model = False):
|
|
76
|
+
"""
|
|
77
|
+
Train SpaceExpress model.
|
|
78
|
+
|
|
79
|
+
Parameters:
|
|
80
|
+
adata (AnnData): Anndata object
|
|
81
|
+
shortest_path_PATH (str): Path to the shortest path file
|
|
82
|
+
device (str): Device to use
|
|
83
|
+
lr (float): Learning rate
|
|
84
|
+
hid_dim (int): Hidden dimension
|
|
85
|
+
emb_dim (int): Embedding dimension
|
|
86
|
+
patience (int): Patience for early stopping
|
|
87
|
+
random_seed (int): Random seed
|
|
88
|
+
batch_size (int): Batch size
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
AnnData: Anndata object with SpaceExpress embedding
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
# Set random seed
|
|
95
|
+
torch.manual_seed(random_seed)
|
|
96
|
+
torch.cuda.manual_seed(random_seed)
|
|
97
|
+
torch.cuda.manual_seed_all(random_seed)
|
|
98
|
+
torch.backends.cudnn.deterministic = True
|
|
99
|
+
torch.backends.cudnn.benchmark = False
|
|
100
|
+
np.random.seed(random_seed)
|
|
101
|
+
random.seed(random_seed)
|
|
102
|
+
|
|
103
|
+
# Set device
|
|
104
|
+
if device == 'mps':
|
|
105
|
+
device = torch.device("mps")
|
|
106
|
+
else:
|
|
107
|
+
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
|
|
108
|
+
|
|
109
|
+
# Preprocessing
|
|
110
|
+
percentile_95 = [np.percentile(adata.X[:,i], 95) for i in range(adata.shape[1])]
|
|
111
|
+
adata.X = np.array([np.clip(adata.X[:,i], a_min=None, a_max=percentile_95[i]) for i in range(adata.shape[1])]).T
|
|
112
|
+
|
|
113
|
+
is_count_data = np.all(np.equal(np.mod(adata.X, 1), 0))
|
|
114
|
+
if adata.shape[1] > num_hvg:
|
|
115
|
+
if is_count_data == True:
|
|
116
|
+
sc.pp.highly_variable_genes(adata, n_top_genes=num_hvg, flavor='seurat_v3', subset = True)
|
|
117
|
+
else:
|
|
118
|
+
sc.pp.highly_variable_genes(adata, n_top_genes=num_hvg, subset = True)
|
|
119
|
+
sc.pp.scale(adata)
|
|
120
|
+
|
|
121
|
+
# Get data
|
|
122
|
+
data = adata.X
|
|
123
|
+
pos = adata.obsm['spatial']
|
|
124
|
+
shortest_path = loaded_paths(shortest_file_path)
|
|
125
|
+
|
|
126
|
+
data = torch.tensor(data, dtype=torch.float).to(device)
|
|
127
|
+
model = SpaceExpress(data.shape[1], hid_dim, emb_dim).to(device)
|
|
128
|
+
optimizer = optim.Adam(model.parameters(), lr=lr)
|
|
129
|
+
index = get_index(data)
|
|
130
|
+
kkloss = KKLoss(device, shortest_path)
|
|
131
|
+
|
|
132
|
+
best_train_loss = float('inf')
|
|
133
|
+
patience_counter = 0
|
|
134
|
+
|
|
135
|
+
# Training loop
|
|
136
|
+
print('Start training...')
|
|
137
|
+
with tqdm(total=epochs) as pbar:
|
|
138
|
+
for epoch in tqdm(range(epochs)):
|
|
139
|
+
total_loss = 0
|
|
140
|
+
|
|
141
|
+
idx = index[torch.randperm(index.size(0))[:batch_size**2]]
|
|
142
|
+
optimizer.zero_grad()
|
|
143
|
+
embeddings = model(data.to(device))
|
|
144
|
+
loss = kkloss(embeddings, idx)
|
|
145
|
+
loss.backward()
|
|
146
|
+
optimizer.step()
|
|
147
|
+
total_loss += loss.item()
|
|
148
|
+
|
|
149
|
+
pbar.set_description(f"Epoch {epoch+1}/{epochs}")
|
|
150
|
+
pbar.set_postfix(loss=f"{total_loss:.4f}")
|
|
151
|
+
pbar.update(1)
|
|
152
|
+
|
|
153
|
+
if total_loss < best_train_loss:
|
|
154
|
+
best_train_loss = total_loss
|
|
155
|
+
patience_counter = 0
|
|
156
|
+
best_model = copy.deepcopy(model)
|
|
157
|
+
else:
|
|
158
|
+
patience_counter += 1
|
|
159
|
+
|
|
160
|
+
if patience_counter >= patience:
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
# Switch to evaluation mode
|
|
164
|
+
best_model.eval()
|
|
165
|
+
emb = best_model(data)
|
|
166
|
+
emb = emb.cpu().detach().numpy()
|
|
167
|
+
|
|
168
|
+
if save_model == True:
|
|
169
|
+
return emb, best_model
|
|
170
|
+
|
|
171
|
+
return emb
|
|
172
|
+
|
|
173
|
+
def train_SpaceExpress_multi(adata_list_input, shortest_file_path_list, device = None, epochs = 10000, lr = 0.01, hid_dim = 32,
|
|
174
|
+
emb_dim = 4, patience = 100, random_seed = 42, batch_size = 256, num_hvg = 1000, save_model = False):
|
|
175
|
+
"""
|
|
176
|
+
Train SpaceExpress model.
|
|
177
|
+
|
|
178
|
+
Parameters:
|
|
179
|
+
adata_list (list): List of AnnData objects
|
|
180
|
+
shortest_path_PATH (str): Path to the shortest path file
|
|
181
|
+
device (str): Device to use
|
|
182
|
+
lr (float): Learning rate
|
|
183
|
+
hid_dim (int): Hidden dimension
|
|
184
|
+
emb_dim (int): Embedding dimension
|
|
185
|
+
patience (int): Patience for early stopping
|
|
186
|
+
random_seed (int): Random seed
|
|
187
|
+
batch_size (int): Batch size
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
adata_list (list): List of AnnData objects with SpaceExpress embeddings
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
# Set random seed
|
|
194
|
+
torch.manual_seed(random_seed)
|
|
195
|
+
torch.cuda.manual_seed(random_seed)
|
|
196
|
+
torch.cuda.manual_seed_all(random_seed)
|
|
197
|
+
torch.backends.cudnn.deterministic = True
|
|
198
|
+
torch.backends.cudnn.benchmark = False
|
|
199
|
+
np.random.seed(random_seed)
|
|
200
|
+
random.seed(random_seed)
|
|
201
|
+
|
|
202
|
+
adata_list = [i.copy() for i in adata_list_input]
|
|
203
|
+
|
|
204
|
+
# Set device
|
|
205
|
+
if device == 'mps':
|
|
206
|
+
device = torch.device("mps")
|
|
207
|
+
else:
|
|
208
|
+
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
|
|
209
|
+
|
|
210
|
+
# Preprocessing
|
|
211
|
+
for i, adata in enumerate(adata_list):
|
|
212
|
+
# Check if adata.X is sparse and convert if necessary
|
|
213
|
+
if isinstance(adata.X, (scipy.sparse.csr_matrix, scipy.sparse.csc_matrix)):
|
|
214
|
+
adata.X = adata.X.toarray()
|
|
215
|
+
|
|
216
|
+
percentile_95 = [np.percentile(adata.X[:,i], 95) for i in range(adata.shape[1])]
|
|
217
|
+
adata.X = np.array([np.clip(adata.X[:,i], a_min=None, a_max=percentile_95[i]) for i in range(adata.shape[1])]).T
|
|
218
|
+
is_count_data = np.all(np.equal(np.mod(adata.X, 1), 0))
|
|
219
|
+
if adata.shape[1] > num_hvg:
|
|
220
|
+
if is_count_data == True:
|
|
221
|
+
sc.pp.highly_variable_genes(adata, n_top_genes=num_hvg, flavor='seurat_v3', subset = True)
|
|
222
|
+
else:
|
|
223
|
+
sc.pp.highly_variable_genes(adata, n_top_genes=num_hvg, subset = True)
|
|
224
|
+
sc.pp.scale(adata)
|
|
225
|
+
adata_list[i] = adata
|
|
226
|
+
|
|
227
|
+
intersecting_genes = set.intersection(*(set(adata.var_names) for adata in adata_list))
|
|
228
|
+
adata_list = [adata[:, list(intersecting_genes)] for adata in adata_list]
|
|
229
|
+
print(f'Size of the input data: {[adata_list[i].shape for i in range(len(adata_list))]}')
|
|
230
|
+
|
|
231
|
+
# Get data
|
|
232
|
+
num_data = len(adata_list)
|
|
233
|
+
data_list = [adata.X for adata in adata_list]
|
|
234
|
+
pos_list = [adata.obsm['spatial'] for adata in adata_list]
|
|
235
|
+
shortest_path_list = [loaded_paths(i) for i in shortest_file_path_list]
|
|
236
|
+
|
|
237
|
+
num_gene = data_list[0].shape[1]
|
|
238
|
+
print('number of genes:', num_gene)
|
|
239
|
+
for i in data_list:
|
|
240
|
+
assert num_gene == i.shape[1], "The number of genes should be the same across all datasets"
|
|
241
|
+
|
|
242
|
+
data_list = [torch.tensor(data, dtype=torch.float).to(device) for data in data_list]
|
|
243
|
+
model = SpaceExpress(num_gene, hid_dim, emb_dim).to(device)
|
|
244
|
+
optimizer = optim.Adam(model.parameters(), lr=lr)
|
|
245
|
+
|
|
246
|
+
index_list = [get_index(data) for data in data_list]
|
|
247
|
+
kkloss_list = [KKLoss(device, shortest_path) for shortest_path in shortest_path_list]
|
|
248
|
+
|
|
249
|
+
best_train_loss = float('inf')
|
|
250
|
+
patience_counter = 0
|
|
251
|
+
|
|
252
|
+
# Training loop
|
|
253
|
+
print('Start training...')
|
|
254
|
+
with tqdm(total=epochs) as pbar:
|
|
255
|
+
for epoch in tqdm(range(epochs)):
|
|
256
|
+
total_loss = 0
|
|
257
|
+
reg_loss = 0
|
|
258
|
+
|
|
259
|
+
loss = 0
|
|
260
|
+
for i in range(num_data):
|
|
261
|
+
data = data_list[i]
|
|
262
|
+
index = index_list[i]
|
|
263
|
+
kkloss = kkloss_list[i]
|
|
264
|
+
|
|
265
|
+
optimizer.zero_grad()
|
|
266
|
+
embeddings = model(data.to(device))
|
|
267
|
+
idx = index[torch.randperm(index.size(0))[:batch_size**2]]
|
|
268
|
+
loss += kkloss(embeddings, idx)
|
|
269
|
+
|
|
270
|
+
loss.backward()
|
|
271
|
+
optimizer.step()
|
|
272
|
+
total_loss += loss.item()
|
|
273
|
+
|
|
274
|
+
pbar.set_description(f"Epoch {epoch+1}/{epochs}")
|
|
275
|
+
pbar.set_postfix(loss=f"{total_loss:.4f}")
|
|
276
|
+
pbar.update(1) # Update the progress bar once per epoch
|
|
277
|
+
|
|
278
|
+
if total_loss < best_train_loss:
|
|
279
|
+
best_train_loss = total_loss
|
|
280
|
+
patience_counter = 0
|
|
281
|
+
best_model = copy.deepcopy(model)
|
|
282
|
+
else:
|
|
283
|
+
patience_counter += 1
|
|
284
|
+
|
|
285
|
+
if patience_counter >= patience:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# Switch to evaluation mode
|
|
289
|
+
best_model.eval()
|
|
290
|
+
out = []
|
|
291
|
+
for i in range(num_data):
|
|
292
|
+
data = data_list[i]
|
|
293
|
+
emb = best_model(data)
|
|
294
|
+
emb = emb.cpu().detach().numpy()
|
|
295
|
+
out.append(emb)
|
|
296
|
+
|
|
297
|
+
if save_model == True:
|
|
298
|
+
return out, best_model
|
|
299
|
+
|
|
300
|
+
return out
|