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 +109 -0
- nest_st-0.1.0/README.md +81 -0
- nest_st-0.1.0/nest_st/__init__.py +13 -0
- nest_st-0.1.0/nest_st/core.py +627 -0
- nest_st-0.1.0/nest_st.egg-info/PKG-INFO +109 -0
- nest_st-0.1.0/nest_st.egg-info/SOURCES.txt +9 -0
- nest_st-0.1.0/nest_st.egg-info/dependency_links.txt +1 -0
- nest_st-0.1.0/nest_st.egg-info/requires.txt +9 -0
- nest_st-0.1.0/nest_st.egg-info/top_level.txt +1 -0
- nest_st-0.1.0/pyproject.toml +43 -0
- nest_st-0.1.0/setup.cfg +4 -0
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.
|
nest_st-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|
nest_st-0.1.0/setup.cfg
ADDED