nest-st 0.1.0__tar.gz

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.
nest_st-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: nest-st
3
+ Version: 0.1.0
4
+ Summary: NEST: Native Expression reconstruction for Spatial Transcriptomics
5
+ Author-email: Tianyi Liu <1694414073@qq.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/lty10086/NEST
8
+ Project-URL: Repository, https://github.com/lty10086/NEST
9
+ Project-URL: Issues, https://github.com/lty10086/NEST/issues
10
+ Keywords: spatial transcriptomics,single-cell,imputation,dropout,graph autoencoder
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy>=1.23
20
+ Requires-Dist: pandas>=1.5
21
+ Requires-Dist: scipy>=1.9
22
+ Requires-Dist: anndata>=0.9
23
+ Requires-Dist: scanpy>=1.9
24
+ Requires-Dist: scikit-learn>=1.2
25
+ Requires-Dist: matplotlib>=3.6
26
+ Requires-Dist: torch>=2.0
27
+ Requires-Dist: torch-geometric>=2.3
28
+
29
+ # NEST
30
+
31
+ NEST, Native Expression reconstruction for Spatial Transcriptomics, reconstructs biologically faithful spatial transcriptomes under native spatial constraints.
32
+
33
+ It supports two tasks with one parameter:
34
+
35
+ - `mode="missing_gene"`: imputes unmeasured genes in imaging-based spatial transcriptomics using a matched scRNA-seq reference.
36
+ - `mode="dropout"`: recovers dropout-corrupted false zeros in sequencing-based spatial transcriptomics using ST data only.
37
+
38
+ ## Installation for development
39
+
40
+ ```bash
41
+ git clone https://github.com/yourname/nest-st.git
42
+ cd nest-st
43
+ pip install -e .
44
+ ```
45
+
46
+ For CUDA environments, install PyTorch and PyTorch Geometric using the wheel index recommended for your CUDA version before installing the package.
47
+
48
+ ## Python usage
49
+
50
+ ### Missing-gene imputation
51
+
52
+ ```python
53
+ import scanpy as sc
54
+ from nest_st import NEST, NESTConfig
55
+
56
+ adata_st = sc.read_h5ad("data/st.h5ad")
57
+ adata_sc = sc.read_h5ad("data/sc.h5ad")
58
+
59
+ sc.pp.log1p(adata_st)
60
+ sc.pp.log1p(adata_sc)
61
+
62
+ config = NESTConfig(mode="missing_gene", ae_epochs=1000, gae_epochs=2000, device="cuda")
63
+ adata_imputed = NEST(config).fit_transform(adata_st=adata_st, adata_sc=adata_sc)
64
+ adata_imputed.write_h5ad("nest_missing_gene_result.h5ad")
65
+ ```
66
+
67
+ ### Dropout recovery
68
+
69
+ ```python
70
+ import scanpy as sc
71
+ from nest_st import NEST, NESTConfig
72
+
73
+ adata_st = sc.read_h5ad("data/st.h5ad")
74
+ sc.pp.log1p(adata_st)
75
+
76
+ config = NESTConfig(mode="dropout", fill_zeros_only=True, ae_epochs=1000, gae_epochs=2000, device="cuda")
77
+ adata_recovered = NEST(config).fit_transform(adata_st=adata_st)
78
+ adata_recovered.write_h5ad("nest_dropout_result.h5ad")
79
+ ```
80
+
81
+ ## Command-line usage
82
+
83
+ ```bash
84
+ python main.py --mode missing_gene --st data/st.h5ad --sc data/sc.h5ad --output result_gene.h5ad --log1p
85
+ python main.py --mode dropout --st data/st.h5ad --output result_dropout.h5ad --log1p --fill-zeros-only
86
+ ```
87
+
88
+ ## Input requirements
89
+
90
+ - Input files should be AnnData `.h5ad` files.
91
+ - `adata_st.obsm["spatial"]` must contain spatial coordinates.
92
+ - For `missing_gene` mode, `adata_sc` must contain genes to be transferred into the ST domain.
93
+ - For `dropout` mode, no scRNA-seq reference is required.
94
+
95
+ ## Upload to PyPI
96
+
97
+ ```bash
98
+ python -m pip install --upgrade build twine
99
+ python -m build
100
+ python -m twine upload dist/*
101
+ ```
102
+
103
+ For TestPyPI:
104
+
105
+ ```bash
106
+ python -m twine upload --repository testpypi dist/*
107
+ ```
108
+
109
+ Update the package name, author information and GitHub URLs in `pyproject.toml` before publishing.
@@ -0,0 +1,81 @@
1
+ # NEST
2
+
3
+ NEST, Native Expression reconstruction for Spatial Transcriptomics, reconstructs biologically faithful spatial transcriptomes under native spatial constraints.
4
+
5
+ It supports two tasks with one parameter:
6
+
7
+ - `mode="missing_gene"`: imputes unmeasured genes in imaging-based spatial transcriptomics using a matched scRNA-seq reference.
8
+ - `mode="dropout"`: recovers dropout-corrupted false zeros in sequencing-based spatial transcriptomics using ST data only.
9
+
10
+ ## Installation for development
11
+
12
+ ```bash
13
+ git clone https://github.com/yourname/nest-st.git
14
+ cd nest-st
15
+ pip install -e .
16
+ ```
17
+
18
+ For CUDA environments, install PyTorch and PyTorch Geometric using the wheel index recommended for your CUDA version before installing the package.
19
+
20
+ ## Python usage
21
+
22
+ ### Missing-gene imputation
23
+
24
+ ```python
25
+ import scanpy as sc
26
+ from nest_st import NEST, NESTConfig
27
+
28
+ adata_st = sc.read_h5ad("data/st.h5ad")
29
+ adata_sc = sc.read_h5ad("data/sc.h5ad")
30
+
31
+ sc.pp.log1p(adata_st)
32
+ sc.pp.log1p(adata_sc)
33
+
34
+ config = NESTConfig(mode="missing_gene", ae_epochs=1000, gae_epochs=2000, device="cuda")
35
+ adata_imputed = NEST(config).fit_transform(adata_st=adata_st, adata_sc=adata_sc)
36
+ adata_imputed.write_h5ad("nest_missing_gene_result.h5ad")
37
+ ```
38
+
39
+ ### Dropout recovery
40
+
41
+ ```python
42
+ import scanpy as sc
43
+ from nest_st import NEST, NESTConfig
44
+
45
+ adata_st = sc.read_h5ad("data/st.h5ad")
46
+ sc.pp.log1p(adata_st)
47
+
48
+ config = NESTConfig(mode="dropout", fill_zeros_only=True, ae_epochs=1000, gae_epochs=2000, device="cuda")
49
+ adata_recovered = NEST(config).fit_transform(adata_st=adata_st)
50
+ adata_recovered.write_h5ad("nest_dropout_result.h5ad")
51
+ ```
52
+
53
+ ## Command-line usage
54
+
55
+ ```bash
56
+ python main.py --mode missing_gene --st data/st.h5ad --sc data/sc.h5ad --output result_gene.h5ad --log1p
57
+ python main.py --mode dropout --st data/st.h5ad --output result_dropout.h5ad --log1p --fill-zeros-only
58
+ ```
59
+
60
+ ## Input requirements
61
+
62
+ - Input files should be AnnData `.h5ad` files.
63
+ - `adata_st.obsm["spatial"]` must contain spatial coordinates.
64
+ - For `missing_gene` mode, `adata_sc` must contain genes to be transferred into the ST domain.
65
+ - For `dropout` mode, no scRNA-seq reference is required.
66
+
67
+ ## Upload to PyPI
68
+
69
+ ```bash
70
+ python -m pip install --upgrade build twine
71
+ python -m build
72
+ python -m twine upload dist/*
73
+ ```
74
+
75
+ For TestPyPI:
76
+
77
+ ```bash
78
+ python -m twine upload --repository testpypi dist/*
79
+ ```
80
+
81
+ Update the package name, author information and GitHub URLs in `pyproject.toml` before publishing.
@@ -0,0 +1,13 @@
1
+ """NEST: Native Expression reconstruction for Spatial Transcriptomics."""
2
+
3
+ from .core import NEST, NESTConfig, impute_missing_genes, recover_dropout, run_nest
4
+
5
+ __all__ = [
6
+ "NEST",
7
+ "NESTConfig",
8
+ "run_nest",
9
+ "impute_missing_genes",
10
+ "recover_dropout",
11
+ ]
12
+
13
+ __version__ = "0.1.0"
@@ -0,0 +1,627 @@
1
+ """Core implementation of NEST.
2
+
3
+ NEST supports two tasks:
4
+ 1. missing_gene: impute genes absent from spatial transcriptomics data using a matched
5
+ scRNA-seq reference, followed by native-domain correction and graph-guided recovery.
6
+ 2. dropout: recover dropout-corrupted false zeros in spatial transcriptomics data without
7
+ requiring a scRNA-seq reference.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Iterable, Literal, Optional, Sequence, Tuple
14
+
15
+ import anndata as ad
16
+ import numpy as np
17
+ import scanpy as sc
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from scipy import sparse
22
+ from sklearn.cluster import KMeans
23
+ from sklearn.metrics.pairwise import cosine_similarity as sklearn_cosine_similarity
24
+ from sklearn.neighbors import LocalOutlierFactor, NearestNeighbors
25
+ from torch.nn.functional import cosine_similarity
26
+ from torch_geometric.data import Data
27
+ from torch_geometric.nn import GCNConv
28
+ from torch_geometric.utils import add_self_loops, dense_to_sparse
29
+
30
+ NESTMode = Literal["missing_gene", "dropout"]
31
+
32
+
33
+ @dataclass
34
+ class NESTConfig:
35
+ """Configuration for NEST.
36
+
37
+ Parameters
38
+ ----------
39
+ mode
40
+ ``"missing_gene"`` imputes unmeasured genes using a scRNA-seq reference.
41
+ ``"dropout"`` recovers dropout events using the ST matrix only.
42
+ k
43
+ Number of cell neighbors used for graph construction. If ``None``, NEST uses
44
+ ``max(10, min(30, n_cells // 100))``, bounded by the number of cells.
45
+ kmap
46
+ Number of nearest scRNA-seq neighbors used for cross-modal mapping.
47
+ Used only when ``mode="missing_gene"``.
48
+ kg
49
+ Number of shared gene neighbors used for native-domain correction. If ``None``,
50
+ NEST uses ``max(5, n_common_genes // 20)``.
51
+ latent_size
52
+ Latent dimension for AE and GAE. If ``None``, NEST uses one quarter of the
53
+ input dimension, with a minimum of 2.
54
+ lr
55
+ Learning rate for Adam.
56
+ ae_epochs
57
+ Number of AE training epochs.
58
+ gae_epochs
59
+ Number of GAE training epochs.
60
+ device
61
+ ``"cuda"`` or ``"cpu"``. If ``None``, CUDA is used when available.
62
+ fill_zeros_only
63
+ In dropout mode, if ``True``, observed non-zero values are kept and only zero
64
+ entries are replaced by reconstructed values. If ``False``, the full GAE
65
+ reconstruction is returned.
66
+ random_state
67
+ Random seed for K-means and Scanpy-related operations.
68
+ verbose
69
+ Whether to print progress messages.
70
+ """
71
+
72
+ mode: NESTMode = "missing_gene"
73
+ k: Optional[int] = None
74
+ kmap: int = 50
75
+ kg: Optional[int] = None
76
+ latent_size: Optional[int] = None
77
+ lr: float = 1e-3
78
+ ae_epochs: int = 1000
79
+ gae_epochs: int = 2000
80
+ device: Optional[str] = None
81
+ fill_zeros_only: bool = True
82
+ random_state: int = 42
83
+ verbose: bool = True
84
+
85
+
86
+ class AutoEncoder(nn.Module):
87
+ """Two-layer fully connected autoencoder used to learn the shared ST latent space."""
88
+
89
+ def __init__(self, input_dim: int, latent_size: int):
90
+ super().__init__()
91
+ hidden_size = max(2, input_dim // 2)
92
+ latent_size = max(2, latent_size)
93
+
94
+ self.fc1 = nn.Linear(input_dim, hidden_size)
95
+ self.layer_norm1 = nn.LayerNorm(hidden_size)
96
+ self.fc2 = nn.Linear(hidden_size, latent_size)
97
+ self.layer_norm2 = nn.LayerNorm(latent_size)
98
+ self.fc3 = nn.Linear(latent_size, hidden_size)
99
+ self.fc4 = nn.Linear(hidden_size, input_dim)
100
+
101
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
102
+ encoded = self.fc1(x)
103
+ encoded = self.layer_norm1(encoded)
104
+ latent = self.fc2(encoded)
105
+ latent = self.layer_norm2(latent)
106
+ decoded = F.relu(self.fc3(latent))
107
+ reconstructed = F.relu(self.fc4(decoded))
108
+ return latent, reconstructed
109
+
110
+
111
+ class GraphAutoEncoder(nn.Module):
112
+ """Graph autoencoder with two GCN layers and an auxiliary cluster classifier."""
113
+
114
+ def __init__(self, input_dim: int, latent_size: int, num_clusters: int):
115
+ super().__init__()
116
+ hidden_size = max(2, input_dim // 2)
117
+ latent_size = max(2, latent_size)
118
+ num_clusters = max(1, num_clusters)
119
+
120
+ self.gc1 = GCNConv(input_dim, hidden_size)
121
+ self.layer_norm1 = nn.LayerNorm(hidden_size)
122
+ self.gc2 = GCNConv(hidden_size, latent_size)
123
+ self.layer_norm2 = nn.LayerNorm(latent_size)
124
+ self.fc1 = nn.Linear(latent_size, hidden_size)
125
+ self.fc2 = nn.Linear(hidden_size, input_dim)
126
+ self.classifier = nn.Linear(latent_size, num_clusters)
127
+
128
+ def forward(
129
+ self, x: torch.Tensor, edge_index: torch.Tensor
130
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
131
+ hidden = self.gc1(x, edge_index)
132
+ hidden = self.layer_norm1(hidden)
133
+ latent = self.gc2(hidden, edge_index)
134
+ latent = self.layer_norm2(latent)
135
+ decoded = F.relu(self.fc1(latent))
136
+ reconstructed = F.relu(self.fc2(decoded))
137
+ logits = self.classifier(latent)
138
+ return latent, reconstructed, logits
139
+
140
+
141
+ def _as_numpy(x) -> np.ndarray:
142
+ if sparse.issparse(x):
143
+ return x.toarray()
144
+ return np.asarray(x)
145
+
146
+
147
+ def _copy_adata_with_numpy(adata: ad.AnnData) -> ad.AnnData:
148
+ out = adata.copy()
149
+ out.X = _as_numpy(out.X).astype(np.float32, copy=False)
150
+ return out
151
+
152
+
153
+ def _resolve_device(device: Optional[str]) -> torch.device:
154
+ if device is None:
155
+ device = "cuda" if torch.cuda.is_available() else "cpu"
156
+ return torch.device(device)
157
+
158
+
159
+ def _resolve_k(n_cells: int, k: Optional[int]) -> int:
160
+ if n_cells < 3:
161
+ raise ValueError("NEST requires at least three spatial cells/spots.")
162
+ if k is None:
163
+ k = max(10, min(30, n_cells // 100))
164
+ return int(max(2, min(k, n_cells - 1)))
165
+
166
+
167
+ def _resolve_latent_size(input_dim: int, latent_size: Optional[int]) -> int:
168
+ if input_dim < 2:
169
+ raise ValueError("NEST requires at least two input genes.")
170
+ if latent_size is None:
171
+ latent_size = input_dim // 4
172
+ return int(max(2, min(latent_size, input_dim)))
173
+
174
+
175
+ def _require_spatial(adata_st: ad.AnnData) -> np.ndarray:
176
+ if "spatial" not in adata_st.obsm:
177
+ raise ValueError("adata_st.obsm['spatial'] is required for spatial graph construction.")
178
+ return np.asarray(adata_st.obsm["spatial"])
179
+
180
+
181
+ def _log(message: str, verbose: bool = True) -> None:
182
+ if verbose:
183
+ print(message)
184
+
185
+
186
+ def _pre_clustering(
187
+ adata: ad.AnnData,
188
+ k: int,
189
+ random_state: int,
190
+ scale: bool,
191
+ verbose: bool,
192
+ ) -> Tuple[np.ndarray, np.ndarray]:
193
+ _log("Preliminary cell clustering...", verbose)
194
+ adata_pre = adata.copy()
195
+ if scale:
196
+ sc.pp.scale(adata_pre, max_value=10)
197
+ n_components = min(adata_pre.n_obs, adata_pre.n_vars) - 1
198
+ n_components = max(1, min(40, n_components))
199
+ sc.pp.pca(adata_pre, n_comps=n_components, random_state=random_state)
200
+ sc.pp.neighbors(adata_pre, n_neighbors=k, n_pcs=n_components, random_state=random_state)
201
+ sc.tl.leiden(adata_pre, resolution=1.0, random_state=random_state)
202
+ sc.tl.umap(adata_pre, random_state=random_state)
203
+ labels = adata_pre.obs["leiden"].astype("category").cat.codes.values
204
+ return np.asarray(adata_pre.obsm["X_umap"]), labels
205
+
206
+
207
+ def _create_cell_network(
208
+ adata_base: ad.AnnData,
209
+ adata_expr: ad.AnnData,
210
+ k: int,
211
+ random_state: int,
212
+ scale_base: bool,
213
+ scale_expr: bool,
214
+ verbose: bool,
215
+ ) -> Tuple[ad.AnnData, np.ndarray, np.ndarray]:
216
+ """Build the consensus cell graph used by GAE."""
217
+
218
+ spatial_coordinates = _require_spatial(adata_base)
219
+ z_umap_base, cell_cluster_labels = _pre_clustering(
220
+ adata_base, k=k, random_state=random_state, scale=scale_base, verbose=verbose
221
+ )
222
+ adata_expr.obs["leiden"] = cell_cluster_labels.astype(str)
223
+
224
+ if adata_expr is adata_base:
225
+ z_umap_expr = z_umap_base.copy()
226
+ else:
227
+ z_umap_expr, _ = _pre_clustering(
228
+ adata_expr, k=k, random_state=random_state, scale=scale_expr, verbose=verbose
229
+ )
230
+
231
+ _log("Searching for scattered cells...", verbose)
232
+ outlier_indices_all = []
233
+ for cluster in np.unique(cell_cluster_labels):
234
+ cluster_cells = np.where(cell_cluster_labels == cluster)[0]
235
+ if len(cluster_cells) < 5:
236
+ continue
237
+ cluster_data = z_umap_expr[cluster_cells]
238
+ n_neighbors = max(2, min(len(cluster_cells) - 1, len(cluster_cells) // 3))
239
+ lof = LocalOutlierFactor(n_neighbors=n_neighbors, contamination=0.3)
240
+ y_pred = lof.fit_predict(cluster_data)
241
+ local_outliers = np.where(y_pred == -1)[0]
242
+ if local_outliers.size > 0:
243
+ outlier_indices_all.append(cluster_cells[local_outliers])
244
+ if outlier_indices_all:
245
+ outlier_indices_all = np.concatenate(outlier_indices_all)
246
+ else:
247
+ outlier_indices_all = np.array([], dtype=int)
248
+
249
+ _log("Creating cell network...", verbose)
250
+ nn_expr = NearestNeighbors(n_neighbors=min(k, z_umap_base.shape[0]))
251
+ nn_expr.fit(z_umap_base)
252
+ _, expr_neighbors = nn_expr.kneighbors(z_umap_base)
253
+
254
+ g1 = np.zeros((z_umap_base.shape[0], z_umap_base.shape[0]), dtype=np.float32)
255
+ for i in range(z_umap_base.shape[0]):
256
+ for neigh in expr_neighbors[i]:
257
+ if i < neigh and cell_cluster_labels[i] == cell_cluster_labels[neigh]:
258
+ g1[i, neigh] = 1.0
259
+ g1[neigh, i] = 1.0
260
+
261
+ g2 = g1.copy()
262
+ nn_spatial = NearestNeighbors(n_neighbors=min(k, spatial_coordinates.shape[0]))
263
+ nn_spatial.fit(spatial_coordinates)
264
+ _, spatial_neighbors = nn_spatial.kneighbors(spatial_coordinates)
265
+
266
+ for i in outlier_indices_all:
267
+ neigh = spatial_neighbors[i]
268
+ neigh = neigh[neigh != i]
269
+ same_cluster = neigh[cell_cluster_labels[neigh] == cell_cluster_labels[i]]
270
+ for j in same_cluster:
271
+ g2[i, j] = 1.0
272
+ g2[j, i] = 1.0
273
+
274
+ return adata_expr, g1, g2
275
+
276
+
277
+ def _train_autoencoder_missing_gene(
278
+ adata_sc: ad.AnnData,
279
+ adata_st: ad.AnnData,
280
+ common_genes: Sequence[str],
281
+ latent_size: int,
282
+ lr: float,
283
+ kmap: int,
284
+ kg: int,
285
+ epochs: int,
286
+ device: torch.device,
287
+ verbose: bool,
288
+ ) -> ad.AnnData:
289
+ criterion = nn.MSELoss()
290
+ _log("ST autoencoder begins training for missing-gene imputation...", verbose)
291
+
292
+ sc_norm = _as_numpy(adata_sc[:, common_genes].X).astype(np.float32)
293
+ st_norm = _as_numpy(adata_st[:, common_genes].X).astype(np.float32)
294
+ x_sc = torch.tensor(sc_norm, dtype=torch.float32, device=device)
295
+ x_st = torch.tensor(st_norm, dtype=torch.float32, device=device)
296
+
297
+ model = AutoEncoder(x_st.shape[1], latent_size).to(device)
298
+ optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.999))
299
+
300
+ for epoch in range(epochs):
301
+ model.train()
302
+ optimizer.zero_grad()
303
+ _, xhat_st = model(x_st)
304
+ loss_rec = criterion(xhat_st, x_st)
305
+ loss_cos = 1 - cosine_similarity(xhat_st, x_st, dim=1).mean()
306
+ loss = loss_rec + loss_cos
307
+ loss.backward()
308
+ optimizer.step()
309
+ if verbose and (epoch + 1) % 500 == 0:
310
+ print(
311
+ f"[AE {epoch + 1}/{epochs}] loss={loss.item():.4f} "
312
+ f"mse={loss_rec.item():.4f} cos={loss_cos.item():.4f}"
313
+ )
314
+
315
+ _log("Cell-domain mapping...", verbose)
316
+ model.eval()
317
+ with torch.no_grad():
318
+ st_embedding, _ = model(x_st)
319
+ sc_embedding, _ = model(x_sc)
320
+
321
+ sc_embedding_np = sc_embedding.detach().cpu().numpy()
322
+ st_embedding_np = st_embedding.detach().cpu().numpy()
323
+ kmap = min(kmap, sc_embedding_np.shape[0])
324
+ nbrs = NearestNeighbors(n_neighbors=kmap, metric="euclidean").fit(sc_embedding_np)
325
+ distances, indices = nbrs.kneighbors(st_embedding_np)
326
+ weights = 1.0 / (distances + 1e-8)
327
+ weights /= weights.sum(axis=1, keepdims=True)
328
+
329
+ sc_all = _as_numpy(adata_sc.X).astype(np.float32)
330
+ mapped_x = (weights[..., None] * sc_all[indices]).sum(axis=1)
331
+
332
+ adata_map = ad.AnnData(X=mapped_x)
333
+ adata_map.obs_names = adata_st.obs_names.copy()
334
+ adata_map.var_names = adata_sc.var_names.copy()
335
+ adata_map.obs = adata_st.obs.copy()
336
+ adata_map.var = adata_sc.var.copy()
337
+ if "spatial" in adata_st.obsm:
338
+ adata_map.obsm["spatial"] = np.asarray(adata_st.obsm["spatial"]).copy()
339
+ adata_map.obsm["X_latent"] = st_embedding_np
340
+
341
+ _log("Native gene-domain correction...", verbose)
342
+ non_common_genes = [g for g in adata_map.var_names if g not in set(common_genes)]
343
+ if len(non_common_genes) == 0:
344
+ _log("No non-common genes found. Gene-domain correction skipped.", verbose)
345
+ return adata_map
346
+
347
+ valid_common = [g for g in common_genes if g in adata_st.var_names and g in adata_map.var_names]
348
+ if len(valid_common) == 0:
349
+ return adata_map
350
+
351
+ x_corrected = _as_numpy(adata_map.X).astype(np.float32).copy()
352
+ idx_common_map = [adata_map.var_names.get_loc(g) for g in valid_common]
353
+ idx_common_st = [adata_st.var_names.get_loc(g) for g in valid_common]
354
+ expr_map_full = _as_numpy(adata_map.X).astype(np.float32)
355
+ expr_st_common = _as_numpy(adata_st.X[:, idx_common_st]).astype(np.float32)
356
+ expr_common_in_map = expr_map_full[:, idx_common_map].T
357
+
358
+ for gene in non_common_genes:
359
+ idx_gene = adata_map.var_names.get_loc(gene)
360
+ expr_gene = expr_map_full[:, idx_gene].reshape(1, -1)
361
+ sim = sklearn_cosine_similarity(expr_gene, expr_common_in_map).flatten()
362
+ top_k = min(kg, len(sim))
363
+ neighbor_indices = np.argsort(-sim)[:top_k]
364
+ neighbor_expr = expr_st_common[:, neighbor_indices]
365
+ gene_weights = sim[neighbor_indices]
366
+ if np.allclose(gene_weights.sum(), 0):
367
+ gene_weights = np.ones_like(gene_weights) / len(gene_weights)
368
+ else:
369
+ gene_weights = gene_weights / gene_weights.sum()
370
+ x_corrected[:, idx_gene] = neighbor_expr @ gene_weights
371
+
372
+ adata_map.X = x_corrected
373
+ for gene in valid_common:
374
+ adata_map[:, gene].X = _as_numpy(adata_st[:, gene].X).copy()
375
+
376
+ return adata_map
377
+
378
+
379
+ def _train_autoencoder_dropout(
380
+ adata_st: ad.AnnData,
381
+ genes: Sequence[str],
382
+ latent_size: int,
383
+ lr: float,
384
+ epochs: int,
385
+ device: torch.device,
386
+ verbose: bool,
387
+ ) -> ad.AnnData:
388
+ criterion = nn.MSELoss()
389
+ _log("ST autoencoder begins training for dropout recovery...", verbose)
390
+ st_norm = _as_numpy(adata_st[:, genes].X).astype(np.float32)
391
+ x_st = torch.tensor(st_norm, dtype=torch.float32, device=device)
392
+
393
+ model = AutoEncoder(x_st.shape[1], latent_size).to(device)
394
+ optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.999))
395
+
396
+ for epoch in range(epochs):
397
+ model.train()
398
+ optimizer.zero_grad()
399
+ _, xhat_st = model(x_st)
400
+ mask = x_st != 0
401
+ loss_rec = criterion(xhat_st[mask], x_st[mask])
402
+ loss_cos = 1 - cosine_similarity(xhat_st * mask, x_st * mask, dim=1).mean()
403
+ loss = loss_rec + loss_cos
404
+ loss.backward()
405
+ optimizer.step()
406
+ if verbose and (epoch + 1) % 500 == 0:
407
+ print(
408
+ f"[AE {epoch + 1}/{epochs}] loss={loss.item():.4f} "
409
+ f"mse={loss_rec.item():.4f} cos={loss_cos.item():.4f}"
410
+ )
411
+
412
+ model.eval()
413
+ with torch.no_grad():
414
+ st_embedding, _ = model(x_st)
415
+ adata_out = adata_st.copy()
416
+ adata_out.obsm["X_latent"] = st_embedding.detach().cpu().numpy()
417
+ return adata_out
418
+
419
+
420
+ def _train_graph_autoencoder(
421
+ adata_expr: ad.AnnData,
422
+ graph: np.ndarray,
423
+ latent_size: int,
424
+ lr: float,
425
+ epochs: int,
426
+ device: torch.device,
427
+ observed_only_loss: bool,
428
+ random_state: int,
429
+ verbose: bool,
430
+ ) -> np.ndarray:
431
+ _log("Graph autoencoder begins training...", verbose)
432
+ edge_index, _ = dense_to_sparse(torch.tensor(graph, dtype=torch.float32))
433
+ edge_index, _ = add_self_loops(edge_index, num_nodes=graph.shape[0])
434
+ edge_index = edge_index.to(device)
435
+
436
+ x = torch.tensor(_as_numpy(adata_expr.X).astype(np.float32), dtype=torch.float32, device=device)
437
+ data = Data(x=x, edge_index=edge_index)
438
+
439
+ latent_np = np.asarray(adata_expr.obsm["X_latent"])
440
+ n_clusters = int(max(1, len(np.unique(adata_expr.obs["leiden"]))))
441
+ if n_clusters == 1:
442
+ labels = np.zeros(latent_np.shape[0], dtype=int)
443
+ else:
444
+ labels = KMeans(n_clusters=n_clusters, n_init=10, random_state=random_state).fit_predict(latent_np)
445
+ pseudo_labels = torch.tensor(labels, dtype=torch.long, device=device)
446
+
447
+ model = GraphAutoEncoder(x.size(1), latent_size, n_clusters).to(device)
448
+ optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.999))
449
+ criterion_rec = nn.MSELoss()
450
+ criterion_cls = nn.CrossEntropyLoss()
451
+
452
+ for epoch in range(epochs):
453
+ model.train()
454
+ optimizer.zero_grad()
455
+ latent, reconstructed, logits = model(data.x, data.edge_index)
456
+ if observed_only_loss:
457
+ mask = data.x != 0
458
+ loss_rec = criterion_rec(reconstructed[mask], data.x[mask])
459
+ loss_cos = 1 - cosine_similarity(reconstructed * mask, data.x * mask, dim=1).mean()
460
+ else:
461
+ loss_rec = criterion_rec(reconstructed, data.x)
462
+ loss_cos = 1 - cosine_similarity(reconstructed, data.x, dim=1).mean()
463
+ loss_cls = criterion_cls(logits, pseudo_labels)
464
+ loss = loss_rec + loss_cos + loss_cls
465
+ loss.backward()
466
+ optimizer.step()
467
+ if verbose and (epoch + 1) % 500 == 0:
468
+ print(
469
+ f"[GAE {epoch + 1}/{epochs}] loss={loss.item():.4f} "
470
+ f"mse={loss_rec.item():.4f} cos={loss_cos.item():.4f} label={loss_cls.item():.4f}"
471
+ )
472
+
473
+ model.eval()
474
+ with torch.no_grad():
475
+ _, imputed, _ = model(data.x, data.edge_index)
476
+ return imputed.detach().cpu().numpy()
477
+
478
+
479
+ class NEST:
480
+ """High-level NEST runner."""
481
+
482
+ def __init__(self, config: Optional[NESTConfig] = None):
483
+ self.config = config or NESTConfig()
484
+
485
+ def fit_transform(
486
+ self,
487
+ adata_st: ad.AnnData,
488
+ adata_sc: Optional[ad.AnnData] = None,
489
+ common_genes: Optional[Iterable[str]] = None,
490
+ ) -> ad.AnnData:
491
+ """Run NEST and return an AnnData object containing reconstructed expression."""
492
+
493
+ config = self.config
494
+ torch.manual_seed(config.random_state)
495
+ np.random.seed(config.random_state)
496
+ device = _resolve_device(config.device)
497
+
498
+ adata_st_work = _copy_adata_with_numpy(adata_st)
499
+ k = _resolve_k(adata_st_work.n_obs, config.k)
500
+
501
+ if config.mode == "missing_gene":
502
+ if adata_sc is None:
503
+ raise ValueError("adata_sc is required when mode='missing_gene'.")
504
+ adata_sc_work = _copy_adata_with_numpy(adata_sc)
505
+ if common_genes is None:
506
+ common_genes = list(adata_st_work.var_names.intersection(adata_sc_work.var_names))
507
+ else:
508
+ common_genes = list(common_genes)
509
+ if len(common_genes) < 2:
510
+ raise ValueError("At least two common genes are required for missing-gene imputation.")
511
+ kg = config.kg if config.kg is not None else max(5, len(common_genes) // 20)
512
+ latent_size = _resolve_latent_size(len(common_genes), config.latent_size)
513
+ adata_expr = _train_autoencoder_missing_gene(
514
+ adata_sc=adata_sc_work,
515
+ adata_st=adata_st_work,
516
+ common_genes=common_genes,
517
+ latent_size=latent_size,
518
+ lr=config.lr,
519
+ kmap=config.kmap,
520
+ kg=kg,
521
+ epochs=config.ae_epochs,
522
+ device=device,
523
+ verbose=config.verbose,
524
+ )
525
+ # Match the original NEST.py logic: cell clusters are learned from the
526
+ # original measured ST matrix, while outlier detection is performed on
527
+ # the mapped and gene-domain-corrected expression matrix.
528
+ adata_expr, _, graph = _create_cell_network(
529
+ adata_base=adata_st_work.copy(),
530
+ adata_expr=adata_expr,
531
+ k=k,
532
+ random_state=config.random_state,
533
+ scale_base=False,
534
+ scale_expr=False,
535
+ verbose=config.verbose,
536
+ )
537
+ latent_size_gae = _resolve_latent_size(adata_expr.n_vars, config.latent_size)
538
+ reconstructed = _train_graph_autoencoder(
539
+ adata_expr=adata_expr,
540
+ graph=graph,
541
+ latent_size=latent_size_gae,
542
+ lr=config.lr,
543
+ epochs=config.gae_epochs,
544
+ device=device,
545
+ observed_only_loss=False,
546
+ random_state=config.random_state,
547
+ verbose=config.verbose,
548
+ )
549
+ adata_out = adata_expr.copy()
550
+ adata_out.X = reconstructed.astype(np.float32)
551
+ for gene in common_genes:
552
+ if gene in adata_out.var_names and gene in adata_st_work.var_names:
553
+ adata_out[:, gene].X = _as_numpy(adata_st_work[:, gene].X).copy()
554
+ return adata_out
555
+
556
+ if config.mode == "dropout":
557
+ genes = list(adata_st_work.var_names)
558
+ latent_size = _resolve_latent_size(len(genes), config.latent_size)
559
+ adata_expr = _train_autoencoder_dropout(
560
+ adata_st=adata_st_work,
561
+ genes=genes,
562
+ latent_size=latent_size,
563
+ lr=config.lr,
564
+ epochs=config.ae_epochs,
565
+ device=device,
566
+ verbose=config.verbose,
567
+ )
568
+ adata_expr, _, graph = _create_cell_network(
569
+ adata_base=adata_expr,
570
+ adata_expr=adata_expr,
571
+ k=k,
572
+ random_state=config.random_state,
573
+ scale_base=True,
574
+ scale_expr=True,
575
+ verbose=config.verbose,
576
+ )
577
+ reconstructed = _train_graph_autoencoder(
578
+ adata_expr=adata_expr,
579
+ graph=graph,
580
+ latent_size=latent_size,
581
+ lr=config.lr,
582
+ epochs=config.gae_epochs,
583
+ device=device,
584
+ observed_only_loss=True,
585
+ random_state=config.random_state,
586
+ verbose=config.verbose,
587
+ )
588
+ original = _as_numpy(adata_st_work.X).astype(np.float32)
589
+ if config.fill_zeros_only:
590
+ out_x = original.copy()
591
+ zero_mask = original == 0
592
+ out_x[zero_mask] = reconstructed[zero_mask]
593
+ else:
594
+ out_x = reconstructed
595
+ adata_out = adata_st_work.copy()
596
+ adata_out.X = out_x.astype(np.float32)
597
+ return adata_out
598
+
599
+ raise ValueError("config.mode must be either 'missing_gene' or 'dropout'.")
600
+
601
+
602
+ def run_nest(
603
+ adata_st: ad.AnnData,
604
+ adata_sc: Optional[ad.AnnData] = None,
605
+ mode: NESTMode = "missing_gene",
606
+ **kwargs,
607
+ ) -> ad.AnnData:
608
+ """Convenience function for running NEST in one line."""
609
+ config = NESTConfig(mode=mode, **kwargs)
610
+ return NEST(config).fit_transform(adata_st=adata_st, adata_sc=adata_sc)
611
+
612
+
613
+ def impute_missing_genes(
614
+ adata_st: ad.AnnData,
615
+ adata_sc: ad.AnnData,
616
+ **kwargs,
617
+ ) -> ad.AnnData:
618
+ """Impute unmeasured genes in ST data using a matched scRNA-seq reference."""
619
+ return run_nest(adata_st=adata_st, adata_sc=adata_sc, mode="missing_gene", **kwargs)
620
+
621
+
622
+ def recover_dropout(
623
+ adata_st: ad.AnnData,
624
+ **kwargs,
625
+ ) -> ad.AnnData:
626
+ """Recover dropout-corrupted zeros in ST data without a scRNA-seq reference."""
627
+ return run_nest(adata_st=adata_st, mode="dropout", **kwargs)
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: nest-st
3
+ Version: 0.1.0
4
+ Summary: NEST: Native Expression reconstruction for Spatial Transcriptomics
5
+ Author-email: Tianyi Liu <1694414073@qq.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/lty10086/NEST
8
+ Project-URL: Repository, https://github.com/lty10086/NEST
9
+ Project-URL: Issues, https://github.com/lty10086/NEST/issues
10
+ Keywords: spatial transcriptomics,single-cell,imputation,dropout,graph autoencoder
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy>=1.23
20
+ Requires-Dist: pandas>=1.5
21
+ Requires-Dist: scipy>=1.9
22
+ Requires-Dist: anndata>=0.9
23
+ Requires-Dist: scanpy>=1.9
24
+ Requires-Dist: scikit-learn>=1.2
25
+ Requires-Dist: matplotlib>=3.6
26
+ Requires-Dist: torch>=2.0
27
+ Requires-Dist: torch-geometric>=2.3
28
+
29
+ # NEST
30
+
31
+ NEST, Native Expression reconstruction for Spatial Transcriptomics, reconstructs biologically faithful spatial transcriptomes under native spatial constraints.
32
+
33
+ It supports two tasks with one parameter:
34
+
35
+ - `mode="missing_gene"`: imputes unmeasured genes in imaging-based spatial transcriptomics using a matched scRNA-seq reference.
36
+ - `mode="dropout"`: recovers dropout-corrupted false zeros in sequencing-based spatial transcriptomics using ST data only.
37
+
38
+ ## Installation for development
39
+
40
+ ```bash
41
+ git clone https://github.com/yourname/nest-st.git
42
+ cd nest-st
43
+ pip install -e .
44
+ ```
45
+
46
+ For CUDA environments, install PyTorch and PyTorch Geometric using the wheel index recommended for your CUDA version before installing the package.
47
+
48
+ ## Python usage
49
+
50
+ ### Missing-gene imputation
51
+
52
+ ```python
53
+ import scanpy as sc
54
+ from nest_st import NEST, NESTConfig
55
+
56
+ adata_st = sc.read_h5ad("data/st.h5ad")
57
+ adata_sc = sc.read_h5ad("data/sc.h5ad")
58
+
59
+ sc.pp.log1p(adata_st)
60
+ sc.pp.log1p(adata_sc)
61
+
62
+ config = NESTConfig(mode="missing_gene", ae_epochs=1000, gae_epochs=2000, device="cuda")
63
+ adata_imputed = NEST(config).fit_transform(adata_st=adata_st, adata_sc=adata_sc)
64
+ adata_imputed.write_h5ad("nest_missing_gene_result.h5ad")
65
+ ```
66
+
67
+ ### Dropout recovery
68
+
69
+ ```python
70
+ import scanpy as sc
71
+ from nest_st import NEST, NESTConfig
72
+
73
+ adata_st = sc.read_h5ad("data/st.h5ad")
74
+ sc.pp.log1p(adata_st)
75
+
76
+ config = NESTConfig(mode="dropout", fill_zeros_only=True, ae_epochs=1000, gae_epochs=2000, device="cuda")
77
+ adata_recovered = NEST(config).fit_transform(adata_st=adata_st)
78
+ adata_recovered.write_h5ad("nest_dropout_result.h5ad")
79
+ ```
80
+
81
+ ## Command-line usage
82
+
83
+ ```bash
84
+ python main.py --mode missing_gene --st data/st.h5ad --sc data/sc.h5ad --output result_gene.h5ad --log1p
85
+ python main.py --mode dropout --st data/st.h5ad --output result_dropout.h5ad --log1p --fill-zeros-only
86
+ ```
87
+
88
+ ## Input requirements
89
+
90
+ - Input files should be AnnData `.h5ad` files.
91
+ - `adata_st.obsm["spatial"]` must contain spatial coordinates.
92
+ - For `missing_gene` mode, `adata_sc` must contain genes to be transferred into the ST domain.
93
+ - For `dropout` mode, no scRNA-seq reference is required.
94
+
95
+ ## Upload to PyPI
96
+
97
+ ```bash
98
+ python -m pip install --upgrade build twine
99
+ python -m build
100
+ python -m twine upload dist/*
101
+ ```
102
+
103
+ For TestPyPI:
104
+
105
+ ```bash
106
+ python -m twine upload --repository testpypi dist/*
107
+ ```
108
+
109
+ Update the package name, author information and GitHub URLs in `pyproject.toml` before publishing.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ nest_st/__init__.py
4
+ nest_st/core.py
5
+ nest_st.egg-info/PKG-INFO
6
+ nest_st.egg-info/SOURCES.txt
7
+ nest_st.egg-info/dependency_links.txt
8
+ nest_st.egg-info/requires.txt
9
+ nest_st.egg-info/top_level.txt
@@ -0,0 +1,9 @@
1
+ numpy>=1.23
2
+ pandas>=1.5
3
+ scipy>=1.9
4
+ anndata>=0.9
5
+ scanpy>=1.9
6
+ scikit-learn>=1.2
7
+ matplotlib>=3.6
8
+ torch>=2.0
9
+ torch-geometric>=2.3
@@ -0,0 +1 @@
1
+ nest_st
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nest-st"
7
+ version = "0.1.0"
8
+ description = "NEST: Native Expression reconstruction for Spatial Transcriptomics"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Tianyi Liu", email = "1694414073@qq.com"}
14
+ ]
15
+ keywords = ["spatial transcriptomics", "single-cell", "imputation", "dropout", "graph autoencoder"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
23
+ ]
24
+ dependencies = [
25
+ "numpy>=1.23",
26
+ "pandas>=1.5",
27
+ "scipy>=1.9",
28
+ "anndata>=0.9",
29
+ "scanpy>=1.9",
30
+ "scikit-learn>=1.2",
31
+ "matplotlib>=3.6",
32
+ "torch>=2.0",
33
+ "torch-geometric>=2.3",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/lty10086/NEST"
38
+ Repository = "https://github.com/lty10086/NEST"
39
+ Issues = "https://github.com/lty10086/NEST/issues"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["."]
43
+ include = ["nest_st*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+