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.
@@ -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