spatialrna 0.1.2__tar.gz → 0.2.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.
- spatialrna-0.2.0/LICENSE.txt +21 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/PKG-INFO +17 -3
- {spatialrna-0.1.2 → spatialrna-0.2.0}/README.md +13 -1
- {spatialrna-0.1.2 → spatialrna-0.2.0}/pyproject.toml +2 -2
- spatialrna-0.2.0/spatialrna/run_kmeans.py +113 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna/spatialrna.py +57 -40
- spatialrna-0.2.0/spatialrna/spatialrna_ondisk.py +74 -0
- spatialrna-0.2.0/spatialrna/train_val.py +387 -0
- spatialrna-0.2.0/spatialrna/viz.py +188 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna.egg-info/PKG-INFO +17 -3
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna.egg-info/SOURCES.txt +5 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/setup.cfg +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna/__init__.py +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna.egg-info/dependency_links.txt +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna.egg-info/requires.txt +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/spatialrna.egg-info/top_level.txt +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/tests/test_data.py +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/tests/test_reload.py +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/tests/test_subgraph.py +0 -0
- {spatialrna-0.1.2 → spatialrna-0.2.0}/tests/test_subgraph_mode.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ruqian Lyu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: spatialrna
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A package for spatial RNA graph data processing and analysis.
|
|
5
5
|
Author-email: Ruqian Lyu <rlyu@svi.edu.au>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: repository, https://
|
|
7
|
+
Project-URL: repository, https://github.com/ruqianl/spatialrna.git
|
|
8
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.10
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE.txt
|
|
13
14
|
Requires-Dist: torch
|
|
14
15
|
Requires-Dist: torch_geometric
|
|
15
16
|
Requires-Dist: pandas
|
|
@@ -18,6 +19,7 @@ Provides-Extra: dev
|
|
|
18
19
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
19
20
|
Requires-Dist: twine>=4.0.2; extra == "dev"
|
|
20
21
|
Requires-Dist: build; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
21
23
|
|
|
22
24
|
## A package that provides easier inferface to GNN models for single-molecule ST data
|
|
23
25
|
|
|
@@ -43,6 +45,14 @@ pip install torch_geometric==2.6.1
|
|
|
43
45
|
|
|
44
46
|
pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.4.0+cu124.html
|
|
45
47
|
|
|
48
|
+
## clustering and plotting requires
|
|
49
|
+
|
|
50
|
+
pip install tqdm \
|
|
51
|
+
scipy \
|
|
52
|
+
scikit-learn \
|
|
53
|
+
pandas \
|
|
54
|
+
matplotlib \
|
|
55
|
+
opencv-python
|
|
46
56
|
```
|
|
47
57
|
|
|
48
58
|
## Install spatialrna
|
|
@@ -50,3 +60,7 @@ pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -
|
|
|
50
60
|
```
|
|
51
61
|
pip install spatialrna
|
|
52
62
|
```
|
|
63
|
+
|
|
64
|
+
## Documentations
|
|
65
|
+
|
|
66
|
+
https://ruqianl.github.io/spatialrna_docs/
|
|
@@ -22,10 +22,22 @@ pip install torch_geometric==2.6.1
|
|
|
22
22
|
|
|
23
23
|
pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.4.0+cu124.html
|
|
24
24
|
|
|
25
|
+
## clustering and plotting requires
|
|
26
|
+
|
|
27
|
+
pip install tqdm \
|
|
28
|
+
scipy \
|
|
29
|
+
scikit-learn \
|
|
30
|
+
pandas \
|
|
31
|
+
matplotlib \
|
|
32
|
+
opencv-python
|
|
25
33
|
```
|
|
26
34
|
|
|
27
35
|
## Install spatialrna
|
|
28
36
|
|
|
29
37
|
```
|
|
30
38
|
pip install spatialrna
|
|
31
|
-
```
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Documentations
|
|
42
|
+
|
|
43
|
+
https://ruqianl.github.io/spatialrna_docs/
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spatialrna"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description="A package for spatial RNA graph data processing and analysis."
|
|
9
9
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
10
|
authors = [
|
|
@@ -38,7 +38,7 @@ include = ["spatialrna"]
|
|
|
38
38
|
# https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#automatic-discovery
|
|
39
39
|
|
|
40
40
|
[project.urls]
|
|
41
|
-
repository = "https://
|
|
41
|
+
repository = "https://github.com/ruqianl/spatialrna.git"
|
|
42
42
|
|
|
43
43
|
[project.optional-dependencies]
|
|
44
44
|
dev = [
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import os.path as osp
|
|
3
|
+
import glob
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.cluster import KMeans
|
|
7
|
+
import joblib
|
|
8
|
+
|
|
9
|
+
def run_kmeans(
|
|
10
|
+
sample_name_list=None,
|
|
11
|
+
root="../data/",
|
|
12
|
+
downsample_to=None,
|
|
13
|
+
downsample_seed=1024,
|
|
14
|
+
verbose=True,
|
|
15
|
+
split_file_per_sample=True,
|
|
16
|
+
**kwargs
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Cluster embeddings from multiple samples using KMeans.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
sample_name_list (list[str], optional): List of sample names to cluster. Defaults to None (auto-detect).
|
|
23
|
+
root (str): Path to data directory containing samples.
|
|
24
|
+
downsample_to (int, optional): Maximum number of rows per sample. Defaults to None (no downsampling).
|
|
25
|
+
downsample_seed (int): Random seed for downsampling.
|
|
26
|
+
verbose (bool, optional): Print progress. Defaults to True.
|
|
27
|
+
split_file_per_sample (bool, optional): Save per-sample CSVs if True; else save one CSV.
|
|
28
|
+
**kwargs: Additional arguments for sklearn KMeans (e.g., n_clusters).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
pd.DataFrame: DataFrame with columns ['sample_name','tx_ids','cluster_labels']
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
if sample_name_list is None:
|
|
35
|
+
# Detect sample names with 'embedding' subdir
|
|
36
|
+
sample_name_list = [
|
|
37
|
+
d for d in os.listdir(root)
|
|
38
|
+
if osp.isdir(osp.join(root, d)) and osp.isdir(osp.join(root, d, "embedding"))
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if verbose:
|
|
42
|
+
print(f"Clustering samples: {sample_name_list}")
|
|
43
|
+
|
|
44
|
+
all_embeddings = []
|
|
45
|
+
all_tx_meta = []
|
|
46
|
+
|
|
47
|
+
np.random.seed(downsample_seed)
|
|
48
|
+
|
|
49
|
+
for sample_name in sample_name_list:
|
|
50
|
+
data_path = osp.join(root, sample_name, "embedding")
|
|
51
|
+
|
|
52
|
+
# Load embeddings
|
|
53
|
+
npy_list = glob.glob(osp.join(data_path, "*.npy"))
|
|
54
|
+
#print(npy_list)
|
|
55
|
+
if not npy_list:
|
|
56
|
+
raise FileNotFoundError(f"No .npy files found for sample {sample_name}")
|
|
57
|
+
x_list = [np.load(f) for f in npy_list]
|
|
58
|
+
x_sample = np.concatenate(x_list, axis=0)
|
|
59
|
+
|
|
60
|
+
# Load tx_id files, make sure the same order as npy files.
|
|
61
|
+
tx_id_list = [x.replace(".npy","input_tx_id.csv") for x in npy_list]
|
|
62
|
+
#print(tx_id_list)
|
|
63
|
+
if not tx_id_list:
|
|
64
|
+
raise FileNotFoundError(f"No *input_tx_id.csv files found for sample {sample_name}")
|
|
65
|
+
tx_ids_emb = pd.concat([pd.read_csv(f) for f in tx_id_list], axis=0)
|
|
66
|
+
tx_ids_emb['sample_name'] = sample_name
|
|
67
|
+
|
|
68
|
+
# Optional downsampling
|
|
69
|
+
if downsample_to is not None and tx_ids_emb.shape[0] > downsample_to:
|
|
70
|
+
tx_ids_emb = tx_ids_emb.sample(n=downsample_to, random_state=downsample_seed).reset_index(drop=True)
|
|
71
|
+
idx = np.random.choice(x_sample.shape[0], downsample_to, replace=False)
|
|
72
|
+
x_sample = x_sample[idx, :]
|
|
73
|
+
|
|
74
|
+
all_embeddings.append(x_sample)
|
|
75
|
+
all_tx_meta.append(tx_ids_emb)
|
|
76
|
+
|
|
77
|
+
if verbose:
|
|
78
|
+
print(f"{sample_name}: embeddings {x_sample.shape}, tx_ids {tx_ids_emb.shape}")
|
|
79
|
+
|
|
80
|
+
# Concatenate all samples
|
|
81
|
+
X_all = np.concatenate(all_embeddings, axis=0)
|
|
82
|
+
tx_meta_all = pd.concat(all_tx_meta, axis=0).reset_index(drop=True)
|
|
83
|
+
|
|
84
|
+
# KMeans parameters, default 10
|
|
85
|
+
n_clusters = kwargs.pop('n_clusters', 10)
|
|
86
|
+
km = KMeans(n_clusters=n_clusters, **kwargs)
|
|
87
|
+
km.fit(X_all)
|
|
88
|
+
|
|
89
|
+
# Build result DataFrame
|
|
90
|
+
res_all = pd.DataFrame({
|
|
91
|
+
'sample_name': tx_meta_all['sample_name'],
|
|
92
|
+
'tx_ids': tx_meta_all['tx_id'],
|
|
93
|
+
'cluster_labels': km.labels_.astype(str)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
# Save results
|
|
97
|
+
if split_file_per_sample:
|
|
98
|
+
for sample_name in tx_meta_all['sample_name'].unique():
|
|
99
|
+
out_dir = osp.join(root, sample_name, "clusters")
|
|
100
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
101
|
+
res_all[res_all['sample_name'] == sample_name].to_csv(
|
|
102
|
+
osp.join(out_dir, f"{sample_name}.{n_clusters}clusters.csv"),
|
|
103
|
+
index=False
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
out_file = osp.join(root, f'kmeans.{n_clusters}clusters.csv')
|
|
107
|
+
res_all.to_csv(out_file, index=False)
|
|
108
|
+
|
|
109
|
+
if verbose:
|
|
110
|
+
print("Clustering completed.")
|
|
111
|
+
joblib.dump(km, osp.join(root, f'kmeans.{n_clusters}clusters.models.joblib'))
|
|
112
|
+
|
|
113
|
+
None
|
|
@@ -12,7 +12,7 @@ from torch_geometric.loader import LinkNeighborLoader
|
|
|
12
12
|
|
|
13
13
|
## feature matrix. Reduce mem and I/O by mapping gene label to int IDs. Delay mapping to 1-D tensors when training.
|
|
14
14
|
## TODO: warnings about different num_tiles, radius_r arguments if only loading not processing
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
def files_exist(files: List[str]) -> bool:
|
|
17
17
|
# NOTE: We return `False` in case `files` is empty, leading to a
|
|
18
18
|
# re-processing of files on every instantiation.
|
|
@@ -38,17 +38,9 @@ class SpatialRNA(InMemoryDataset):
|
|
|
38
38
|
process_tile_ids (List[int]): Select tiles to process including generating tile graph and subgraph.
|
|
39
39
|
one_hot_encoding (dict): Dictionary that mapps gene names to integer (or one-hot-encoding tensors).
|
|
40
40
|
It is essential to apply the same dictionary for all samples that are analyzed together.
|
|
41
|
-
|
|
42
|
-
:obj:`torch_geometric.data.Data` object and returns a transformed
|
|
43
|
-
version. The data object will be transformed before every access.
|
|
44
|
-
(default: :obj:`None`)
|
|
45
|
-
pre_transform (callable, optional): A function/transform that takes in
|
|
46
|
-
an :obj:`torch_geometric.data.Data` object and returns a
|
|
47
|
-
transformed version. The data object will be transformed before
|
|
48
|
-
being saved to disk. (default: :obj:`None`)
|
|
49
|
-
force_reload (bool, optional): Whether to re-process the dataset.
|
|
41
|
+
force_reload (bool, optional): Whether to re-generate and overwrite the existing tile data.
|
|
50
42
|
(default: :obj:`False`)
|
|
51
|
-
force_resample (bool, optional): Whether to re-
|
|
43
|
+
force_resample (bool, optional): Whether to re-generate and overwrite the existing subgraph data.
|
|
52
44
|
(default: :obj:`False`)
|
|
53
45
|
subgraph_mode (str): The mode of subgraph generation can be either "link_based" or "node_based".
|
|
54
46
|
(default: :obj:`link_based`)
|
|
@@ -57,7 +49,9 @@ class SpatialRNA(InMemoryDataset):
|
|
|
57
49
|
(default: :obj:`5000`)
|
|
58
50
|
num_walks:(int, optional): Number of walks for generating subgraphs.
|
|
59
51
|
(default: :obj:`5`)
|
|
60
|
-
|
|
52
|
+
pred_label_col: (str): The column name that stores the classification label i.e., cell type. (default: None)
|
|
53
|
+
pred_label_map: (dict): A label id to int id categories mapping dictionary. For example, map 10 cell types to 0 - 9 (default: None)
|
|
54
|
+
pred_label_cell_id_col: (str): The column name that stores the segmentation cell id. It is used for making transcript graphs isolated by segmentation boundaries. (default: None)
|
|
61
55
|
log (bool, optional): Whether to print any console output while
|
|
62
56
|
downloading and processing the dataset. (default: :obj:`True`)
|
|
63
57
|
**kwargs (optional): Additional arguments of
|
|
@@ -88,6 +82,9 @@ class SpatialRNA(InMemoryDataset):
|
|
|
88
82
|
pre_transform = None,
|
|
89
83
|
force_reload: bool = False,
|
|
90
84
|
force_resample: bool = False,
|
|
85
|
+
pred_label_col: str = None,
|
|
86
|
+
pred_label_map: dict = None,
|
|
87
|
+
pred_label_cell_id_col: str = None,
|
|
91
88
|
log: bool= True,
|
|
92
89
|
seed: int = 100,
|
|
93
90
|
**kwargs,
|
|
@@ -115,9 +112,8 @@ class SpatialRNA(InMemoryDataset):
|
|
|
115
112
|
self.process_mode = process_mode
|
|
116
113
|
self.num_seed_nodes = num_seed_nodes
|
|
117
114
|
self.num_walks = num_walks
|
|
118
|
-
|
|
119
115
|
assert tile_by_dim in [dim_x,dim_y]
|
|
120
|
-
|
|
116
|
+
|
|
121
117
|
|
|
122
118
|
self.dim_x = dim_x
|
|
123
119
|
self.dim_y = dim_y
|
|
@@ -130,16 +126,22 @@ class SpatialRNA(InMemoryDataset):
|
|
|
130
126
|
self.process_tile_ids = [load_tile_id]
|
|
131
127
|
else:
|
|
132
128
|
self.process_tile_ids = process_tile_ids
|
|
129
|
+
self.pred_label_col = pred_label_col
|
|
130
|
+
if self.pred_label_col is not None:
|
|
131
|
+
assert pred_label_map is not None, "Running with prediction model, pred_label_map is required."
|
|
132
|
+
self.pred_label_map = pred_label_map
|
|
133
|
+
self.pred_label_cell_id_col = pred_label_cell_id_col
|
|
134
|
+
|
|
133
135
|
# super init, process tiles, if tiles.pt not exist, and regenerate if force_reload=True.
|
|
134
136
|
super().__init__(root, transform, pre_transform,force_reload=force_reload, log=log)
|
|
135
137
|
# subsequently generates the subgraphs if requested
|
|
136
138
|
if process_mode == "subgraph":
|
|
137
139
|
self.generate_subgraph(**kwargs)
|
|
140
|
+
else:
|
|
141
|
+
assert one_hot_encoding is not None
|
|
138
142
|
|
|
139
143
|
if load_type == 'subgraph':
|
|
140
144
|
load_file_name = osp.join(self.subgraph_dir,f'{self.sample_name}_subgraph_data_tile{self.load_tile_id}.pt')
|
|
141
|
-
# if self.force_resample or not files_exist([load_file_name]):
|
|
142
|
-
# self.generate_subgraph(**kwargs)
|
|
143
145
|
elif load_type == 'tile':
|
|
144
146
|
load_file_name = osp.join(self.processed_dir,f'{self.sample_name}_data_tile{self.load_tile_id}.pt')
|
|
145
147
|
else:
|
|
@@ -153,8 +155,7 @@ class SpatialRNA(InMemoryDataset):
|
|
|
153
155
|
else:
|
|
154
156
|
print(load_file_name," not exist, nothing loaded")
|
|
155
157
|
None
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
|
|
158
159
|
@property
|
|
159
160
|
def subgraph_dir(self) -> str:
|
|
160
161
|
return osp.join(self.root,"subgraph")
|
|
@@ -227,10 +228,10 @@ class SpatialRNA(InMemoryDataset):
|
|
|
227
228
|
print("Loading processed data tile for subgraphing ", osp.join(self.processed_dir,f'{self.sample_name}_data_tile{s_id}.pt'))
|
|
228
229
|
|
|
229
230
|
tile_data = torch.load(osp.join(self.processed_dir,f'{self.sample_name}_data_tile{s_id}.pt'),weights_only=True)
|
|
230
|
-
|
|
231
|
+
if self.log:
|
|
232
|
+
print("load tile data from ", osp.join(self.processed_dir,f'{self.sample_name}_data_tile{s_id}.pt'))
|
|
231
233
|
tile_data = Data(**tile_data[0])
|
|
232
234
|
print(tile_data)
|
|
233
|
-
|
|
234
235
|
if self.subgraph_mode == "link_based":
|
|
235
236
|
if self.log:
|
|
236
237
|
print("Generating subgraphs for tile ", s_id, "Sampling " ,self.num_sampled_edges, " edges")
|
|
@@ -241,11 +242,7 @@ class SpatialRNA(InMemoryDataset):
|
|
|
241
242
|
edge_label_index = sampled_edge_index,
|
|
242
243
|
**kwargs
|
|
243
244
|
)
|
|
244
|
-
|
|
245
|
-
# ## Get one batch as the sampled subgraph from the tissue tile
|
|
246
|
-
# for batch in subgraph_loader:
|
|
247
|
-
# subgraph_g = subgraph_g + [batch]
|
|
248
|
-
# break
|
|
245
|
+
|
|
249
246
|
elif self.subgraph_mode == "node_based":
|
|
250
247
|
if self.log:
|
|
251
248
|
print("Generating subgraphs for tile ", s_id, "Sampling ", self.num_seed_nodes, " nodes with ", self.num_walks, " random walks.")
|
|
@@ -265,7 +262,7 @@ class SpatialRNA(InMemoryDataset):
|
|
|
265
262
|
if self.log:
|
|
266
263
|
print("batch size ", sampled_edges_by_random_walk.squeeze().size()[0])
|
|
267
264
|
print("start node ", sample_nodes_bywalks, "size " , sample_nodes_bywalks.size())
|
|
268
|
-
print("
|
|
265
|
+
print("sampled_edges_by_random_walk ", sampled_edges_by_random_walk)
|
|
269
266
|
|
|
270
267
|
subgraph_loader = LinkNeighborLoader(
|
|
271
268
|
data = tile_data,
|
|
@@ -275,14 +272,15 @@ class SpatialRNA(InMemoryDataset):
|
|
|
275
272
|
),
|
|
276
273
|
**kwargs
|
|
277
274
|
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
275
|
+
## When use supply NegativeSampling, there is no edge_label_index returned. but neg_src and neg_pos_index
|
|
276
|
+
# Get one batch as the sampled subgraph from the tissue tile
|
|
277
|
+
batch = next(iter(subgraph_loader))
|
|
278
|
+
self.save(
|
|
279
|
+
[batch],
|
|
280
|
+
osp.join(self.subgraph_dir, f"{self.sample_name}_subgraph_data_tile{s_id}.pt")
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
286
284
|
def process(self)-> None:
|
|
287
285
|
# Load raw data
|
|
288
286
|
import numpy as np
|
|
@@ -317,19 +315,38 @@ class SpatialRNA(InMemoryDataset):
|
|
|
317
315
|
tile_df = pd.concat([tile_df,padding_df])
|
|
318
316
|
if self.log:
|
|
319
317
|
print("core area plus paddings, shape ", tile_df.shape)
|
|
320
|
-
|
|
318
|
+
# Sort dataframe by the cell id column, which is used to form batch_id for radius_graph function
|
|
319
|
+
if self.pred_label_cell_id_col is not None:
|
|
320
|
+
tile_df = tile_df.sort_values(by=self.pred_label_cell_id_col).reset_index(drop=True)
|
|
321
321
|
if isinstance(next(iter(self.one_hot_encoding.items()))[1],int):
|
|
322
322
|
feature_x = torch.tensor([self.one_hot_encoding[x] for x in tile_df[self.feature_col]])
|
|
323
323
|
else:
|
|
324
324
|
feature_x = torch.stack([self.one_hot_encoding[x] for x in tile_df[self.feature_col]])
|
|
325
|
-
xy_pos = torch.tensor(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
xy_pos = torch.tensor(
|
|
326
|
+
tile_df[[self.dim_x, self.dim_y]].values,
|
|
327
|
+
dtype=torch.float
|
|
328
|
+
)
|
|
329
|
+
if self.pred_label_cell_id_col is not None:
|
|
330
|
+
## self.pred_label_cell_id_col needs to be sorted. [0,1,0,1] won't work
|
|
331
|
+
codes, uniques = pd.factorize(tile_df[self.pred_label_cell_id_col])
|
|
332
|
+
batch = torch.tensor(codes, dtype=torch.long)
|
|
333
|
+
edge_index = radius_graph(
|
|
334
|
+
xy_pos,
|
|
335
|
+
r=self.radius_r,
|
|
336
|
+
max_num_neighbors=self.max_num_neighbors,
|
|
337
|
+
batch=batch
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
edge_index = radius_graph(
|
|
341
|
+
xy_pos,
|
|
342
|
+
r=self.radius_r,
|
|
343
|
+
max_num_neighbors=self.max_num_neighbors
|
|
344
|
+
)
|
|
329
345
|
data_1 = Data(
|
|
330
346
|
x = feature_x,
|
|
331
347
|
edge_index = edge_index,
|
|
332
348
|
trans_id = torch.tensor(list(tile_df.trans_id)),
|
|
333
349
|
core_mask = torch.tensor(list(tile_df.core_mask)))
|
|
334
|
-
|
|
350
|
+
if self.pred_label_col is not None:
|
|
351
|
+
data_1.y = torch.tensor(tile_df[self.pred_label_col].map(self.pred_label_map).values)
|
|
335
352
|
self.save([data_1], osp.join(self.processed_dir,f'{self.sample_name}_data_tile{tile}.pt'))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import torch
|
|
4
|
+
from torch_geometric.data import Database, OnDiskDataset, Data
|
|
5
|
+
|
|
6
|
+
class SpatialRNAOnDiskDatasetSQL(OnDiskDataset):
|
|
7
|
+
def __init__(self, root, pt_dir="subgraph", use_sqlite=False,
|
|
8
|
+
transform=None, pre_transform=None):
|
|
9
|
+
self.pt_dir = pt_dir
|
|
10
|
+
|
|
11
|
+
# If SQLite backend is requested, point it to a database path
|
|
12
|
+
super().__init__(root, transform, pre_transform, backend="sqlite")
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def processed_file_names(self):
|
|
16
|
+
#os.path.join(root, "sqlite.db")
|
|
17
|
+
#files = glob.glob(os.path.join(self.root, "*", self.pt_dir, "*.pt"))
|
|
18
|
+
#return os.path.join(self.root, "sqlite.db")
|
|
19
|
+
return "sqlite.db"
|
|
20
|
+
|
|
21
|
+
def pt_file_names(self):
|
|
22
|
+
# All .pt files under root/**/pt_dir/
|
|
23
|
+
files = glob.glob(os.path.join(self.root, "*", self.pt_dir, "*.pt"))
|
|
24
|
+
return sorted(files)
|
|
25
|
+
# @property
|
|
26
|
+
# def processed_dir(self) -> str:
|
|
27
|
+
# return os.path.join(self.root, '/')
|
|
28
|
+
|
|
29
|
+
def process(self):
|
|
30
|
+
all_pt_files = self.pt_file_names()
|
|
31
|
+
print(self.processed_paths)
|
|
32
|
+
for pt_file in all_pt_files:
|
|
33
|
+
data = Data(**torch.load(pt_file)[0],weights_only=True)
|
|
34
|
+
self.append(self.serialize(data))
|
|
35
|
+
|
|
36
|
+
# def len(self):
|
|
37
|
+
# return len(self.processed_file_names)
|
|
38
|
+
|
|
39
|
+
# def get(self, idx):
|
|
40
|
+
# path = self.processed_file_names[idx]
|
|
41
|
+
# return torch.load(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SpatialRNAOnDiskDataset(OnDiskDataset):
|
|
45
|
+
def __init__(self, root, pt_dir="subgraph", transform=None, pre_transform=None):
|
|
46
|
+
self._pt_dir = pt_dir # use a private var
|
|
47
|
+
super().__init__(root, transform, pre_transform)
|
|
48
|
+
None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def processed_file_names(self):
|
|
52
|
+
# Find all .pt files under root/**/processed/
|
|
53
|
+
files = glob.glob(os.path.join(self.root, "*", self._pt_dir, "*_data_tile*.pt"))
|
|
54
|
+
return sorted(files)
|
|
55
|
+
|
|
56
|
+
def process(self):
|
|
57
|
+
# Already done – nothing to process.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def len(self):
|
|
61
|
+
return len(self.processed_file_names)
|
|
62
|
+
|
|
63
|
+
def get(self, idx):
|
|
64
|
+
path = self.processed_file_names[idx]
|
|
65
|
+
data = torch.load(path,weights_only=True)
|
|
66
|
+
return Data(**data[0])
|
|
67
|
+
def multi_get(self, idx_list):
|
|
68
|
+
"""Load multiple graphs at once, similar to SQLite-backed OnDiskDataset."""
|
|
69
|
+
data_list = []
|
|
70
|
+
for idx in idx_list:
|
|
71
|
+
path = self.processed_file_names[idx]
|
|
72
|
+
data = torch.load(path, weights_only=True)
|
|
73
|
+
data_list.append(Data(**data[0]))
|
|
74
|
+
return data_list
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch.nn.functional as F
|
|
3
|
+
import numpy as np
|
|
4
|
+
import random as rn
|
|
5
|
+
from torch_geometric.loader import LinkNeighborLoader,NeighborLoader
|
|
6
|
+
from torch_geometric.sampler import NegativeSampling
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
import os.path as osp
|
|
9
|
+
import os
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from torch_geometric.data import Data
|
|
12
|
+
|
|
13
|
+
@torch.no_grad()
|
|
14
|
+
def validate(
|
|
15
|
+
model,
|
|
16
|
+
val_loader,
|
|
17
|
+
device,
|
|
18
|
+
num_classes: int,
|
|
19
|
+
batch_size: int = 2048,
|
|
20
|
+
num_neighbors: list[int] = [20, 10],
|
|
21
|
+
verbose: bool = True,
|
|
22
|
+
num_train_edges: int = 5000,
|
|
23
|
+
seed = 42
|
|
24
|
+
) -> float:
|
|
25
|
+
"""
|
|
26
|
+
Run validation for link prediction task.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
model (torch.nn.Module): The GNN model to evaluate.
|
|
30
|
+
val_loader (torch.utils.data.DataLoader): Loader with validation graphs.
|
|
31
|
+
device (torch.device): Device to run evaluation on.
|
|
32
|
+
num_classes (int): Number of node feature classes (for one-hot encoding).
|
|
33
|
+
batch_size (int, optional): Batch size for LinkNeighborLoader. Defaults to 2048.
|
|
34
|
+
num_neighbors (list[int], optional): Neighbor sampling sizes. Defaults to [20, 10].
|
|
35
|
+
verbose (bool, optional): Whether to print progress. Defaults to True.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
float: Mean validation accuracy.
|
|
39
|
+
"""
|
|
40
|
+
rn.seed(seed)
|
|
41
|
+
model.eval()
|
|
42
|
+
all_acc = []
|
|
43
|
+
|
|
44
|
+
for d_i, d_batch in enumerate(val_loader):
|
|
45
|
+
if verbose:
|
|
46
|
+
print(f"Processing validation on batch graph {d_i}...")
|
|
47
|
+
sampled_edge_index = getattr(d_batch, "edge_label_index", None)
|
|
48
|
+
if sampled_edge_index is None:
|
|
49
|
+
sampled_edge_index = d_batch.edge_index[:,rn.sample(range(d_batch.edge_index.shape[1]),num_train_edges)]
|
|
50
|
+
val_mini_loader = LinkNeighborLoader(
|
|
51
|
+
d_batch,
|
|
52
|
+
batch_size=batch_size,
|
|
53
|
+
shuffle=False,
|
|
54
|
+
edge_label_index=sampled_edge_index,
|
|
55
|
+
neg_sampling=NegativeSampling(mode="triplet", amount=1),
|
|
56
|
+
num_neighbors=num_neighbors,
|
|
57
|
+
disjoint=False,
|
|
58
|
+
subgraph_type="bidirectional",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
batch_acc = []
|
|
62
|
+
for batch in tqdm(val_mini_loader, desc=f"Val minibatching {d_i}", disable=not verbose):
|
|
63
|
+
batch = batch.to(device)
|
|
64
|
+
# Build edge labels
|
|
65
|
+
pos_edges = torch.stack([batch.src_index, batch.dst_pos_index], dim=0)
|
|
66
|
+
neg_edges = torch.stack([batch.src_index, batch.dst_neg_index], dim=0)
|
|
67
|
+
b_edge_label_index = torch.cat([pos_edges, neg_edges], dim=1)
|
|
68
|
+
|
|
69
|
+
b_edge_label = torch.cat([
|
|
70
|
+
torch.ones(batch.src_index.size(0), device=device),
|
|
71
|
+
torch.zeros(batch.src_index.size(0), device=device)
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
# One-hot encoding of features
|
|
75
|
+
batch.x = F.one_hot(batch.x, num_classes=num_classes).float().squeeze(1)
|
|
76
|
+
|
|
77
|
+
# Forward pass
|
|
78
|
+
h = model(batch.x, batch.edge_index)
|
|
79
|
+
h_src, h_dst = h[b_edge_label_index[0]], h[b_edge_label_index[1]]
|
|
80
|
+
pred = (h_src * h_dst).sum(dim=-1)
|
|
81
|
+
|
|
82
|
+
probabilities = torch.sigmoid(pred)
|
|
83
|
+
predictions = (probabilities >= 0.5).float()
|
|
84
|
+
|
|
85
|
+
acc = (predictions == b_edge_label).float().mean().item()
|
|
86
|
+
batch_acc.append(acc)
|
|
87
|
+
|
|
88
|
+
del batch # free GPU memory
|
|
89
|
+
|
|
90
|
+
all_acc.append(np.mean(batch_acc))
|
|
91
|
+
|
|
92
|
+
del val_mini_loader, d_batch
|
|
93
|
+
|
|
94
|
+
return float(np.mean(all_acc))
|
|
95
|
+
|
|
96
|
+
def train(
|
|
97
|
+
model,
|
|
98
|
+
train_loader,
|
|
99
|
+
device,
|
|
100
|
+
num_classes: int,
|
|
101
|
+
optimizer =None,
|
|
102
|
+
batch_size: int = 2048,
|
|
103
|
+
num_neighbors: list[int] = [20, 10],
|
|
104
|
+
verbose: bool = True,
|
|
105
|
+
num_train_edges: int = 5000,
|
|
106
|
+
seed: int = 42
|
|
107
|
+
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
Run one epoch of training for link prediction.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
model (torch.nn.Module): The GNN model to train.
|
|
114
|
+
train_loader (torch.utils.data.DataLoader): Loader with training graphs.
|
|
115
|
+
device (torch.device): Device for computation.
|
|
116
|
+
num_classes (int): Number of node feature classes (for one-hot encoding).Equals the number of unique gene ids in all samples.
|
|
117
|
+
optimizer: Torch optimizer, eg. torch.optim.Adam(model.parameters(), lr=0.001)
|
|
118
|
+
batch_size (int, optional): Mini-batch size. Defaults to 2048.
|
|
119
|
+
num_neighbors (list[int], optional): Neighbor sampling sizes. Defaults to [20, 10].
|
|
120
|
+
verbose (bool, optional): Show progress bars. Defaults to True.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
tuple[float, float]: Mean loss and mean accuracy across batches.
|
|
124
|
+
"""
|
|
125
|
+
rn.seed(seed)
|
|
126
|
+
model.train()
|
|
127
|
+
d_batch_loss, d_batch_acc = [], []
|
|
128
|
+
|
|
129
|
+
for d_i, d_batch in enumerate(train_loader):
|
|
130
|
+
if verbose:
|
|
131
|
+
print(f"Training on batch graph {d_i}...")
|
|
132
|
+
sampled_edge_index = getattr(d_batch, "edge_label_index", None)
|
|
133
|
+
if sampled_edge_index is None:
|
|
134
|
+
sampled_edge_index = d_batch.edge_index[:,rn.sample(range(d_batch.edge_index.shape[1]),num_train_edges)]
|
|
135
|
+
|
|
136
|
+
train_mini_loader = LinkNeighborLoader(
|
|
137
|
+
d_batch,
|
|
138
|
+
batch_size=batch_size,
|
|
139
|
+
shuffle=True,
|
|
140
|
+
edge_label_index=sampled_edge_index,
|
|
141
|
+
neg_sampling=NegativeSampling(mode="triplet", amount=1),
|
|
142
|
+
num_neighbors=num_neighbors,
|
|
143
|
+
disjoint=False,
|
|
144
|
+
subgraph_type="bidirectional",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
total_loss, correct, total_edges = 0, 0, 0
|
|
148
|
+
|
|
149
|
+
for batch in tqdm(train_mini_loader, desc=f"Train minibatching {d_i}", disable=not verbose):
|
|
150
|
+
batch = batch.to(device)
|
|
151
|
+
optimizer.zero_grad()
|
|
152
|
+
|
|
153
|
+
# Edge labels
|
|
154
|
+
pos_labels = torch.ones(batch.src_index.size(0), device=device)
|
|
155
|
+
neg_labels = torch.zeros(batch.src_index.size(0), device=device)
|
|
156
|
+
b_edge_label = torch.cat([pos_labels, neg_labels])
|
|
157
|
+
b_edge_label_index = torch.cat([
|
|
158
|
+
torch.stack([batch.src_index, batch.dst_pos_index]),
|
|
159
|
+
torch.stack([batch.src_index, batch.dst_neg_index])
|
|
160
|
+
], dim=1)
|
|
161
|
+
|
|
162
|
+
# Forward
|
|
163
|
+
batch.x = F.one_hot(batch.x, num_classes=num_classes).float().squeeze(1)
|
|
164
|
+
h = model(batch.x, batch.edge_index)
|
|
165
|
+
h_src, h_dst = h[b_edge_label_index[0]], h[b_edge_label_index[1]]
|
|
166
|
+
pred = (h_src * h_dst).sum(dim=-1)
|
|
167
|
+
|
|
168
|
+
# Loss
|
|
169
|
+
loss = F.binary_cross_entropy_with_logits(pred, b_edge_label)
|
|
170
|
+
loss.backward()
|
|
171
|
+
optimizer.step()
|
|
172
|
+
|
|
173
|
+
# Metrics
|
|
174
|
+
total_loss += loss.item() * pred.size(0)
|
|
175
|
+
probs = torch.sigmoid(pred)
|
|
176
|
+
preds = (probs >= 0.5).float()
|
|
177
|
+
correct += (preds == b_edge_label).sum().item()
|
|
178
|
+
total_edges += pred.size(0)
|
|
179
|
+
|
|
180
|
+
del batch # free GPU memory
|
|
181
|
+
|
|
182
|
+
d_batch_loss.append(total_loss / total_edges)
|
|
183
|
+
d_batch_acc.append(correct / total_edges)
|
|
184
|
+
|
|
185
|
+
del train_mini_loader, d_batch
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
return float(np.mean(d_batch_loss)), float(np.mean(d_batch_acc))
|
|
189
|
+
|
|
190
|
+
def train_label_pred(
|
|
191
|
+
model,
|
|
192
|
+
train_loader,
|
|
193
|
+
device,
|
|
194
|
+
num_classes: int,
|
|
195
|
+
optimizer = None,
|
|
196
|
+
batch_size: int = 2048,
|
|
197
|
+
num_neighbors: list[int] = [20, 10],
|
|
198
|
+
verbose: bool = True,
|
|
199
|
+
num_train_nodes: int = 10000,
|
|
200
|
+
seed: int = 42
|
|
201
|
+
):
|
|
202
|
+
"""
|
|
203
|
+
Run one epoch of training for link prediction.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
model (torch.nn.Module): The GNN model to train.
|
|
207
|
+
train_loader (torch.utils.data.DataLoader): Loader with training graphs.
|
|
208
|
+
device (torch.device): Device for computation.
|
|
209
|
+
num_classes (int): Number of node feature classes (for one-hot encoding).Equals the number of unique gene ids in all samples.
|
|
210
|
+
optimizer:Torch optimier, eg. torch.optim.Adam(model.parameters(), lr=0.001)
|
|
211
|
+
batch_size (int, optional): Mini-batch size. Defaults to 2048.
|
|
212
|
+
num_neighbors (list[int], optional): Neighbor sampling sizes. Defaults to [20, 10].
|
|
213
|
+
verbose (bool, optional): Show progress bars. Defaults to True.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
tuple[float, float]: Mean loss and mean accuracy across batches.
|
|
217
|
+
"""
|
|
218
|
+
rn.seed(seed)
|
|
219
|
+
model.train()
|
|
220
|
+
batch_losses, batch_accuracies = [], []
|
|
221
|
+
|
|
222
|
+
for d_i, d_batch in enumerate(train_loader):
|
|
223
|
+
if verbose:
|
|
224
|
+
print(f"Training on batch graph {d_i}...")
|
|
225
|
+
|
|
226
|
+
train_mask = getattr(d_batch, "train_mask", None)
|
|
227
|
+
if train_mask is None:
|
|
228
|
+
# random subset of nodes for training
|
|
229
|
+
train_mask = torch.tensor(
|
|
230
|
+
rn.sample(range(d_batch.x.shape[0]), num_train_nodes),
|
|
231
|
+
dtype=torch.long
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
train_mask = train_mask.nonzero(as_tuple=False).view(-1)
|
|
235
|
+
|
|
236
|
+
# Create mini-batches with neighbor sampling
|
|
237
|
+
train_mini_loader = NeighborLoader(
|
|
238
|
+
d_batch,
|
|
239
|
+
batch_size=batch_size,
|
|
240
|
+
shuffle=True,
|
|
241
|
+
num_neighbors=num_neighbors,
|
|
242
|
+
input_nodes=train_mask,
|
|
243
|
+
subgraph_type = "bidirectional"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for batch in tqdm(train_mini_loader, disable=not verbose):
|
|
247
|
+
batch = batch.to(device)
|
|
248
|
+
optimizer.zero_grad()
|
|
249
|
+
batch.x = F.one_hot(batch.x, num_classes=num_classes).float().squeeze(1)
|
|
250
|
+
# Restrict loss computation to labeled nodes in the mini-batch. Head nodes of batch_size
|
|
251
|
+
out = model(batch.x, batch.edge_index)[:batch.batch_size]
|
|
252
|
+
y_head = batch.y[:batch.batch_size]
|
|
253
|
+
|
|
254
|
+
loss = F.cross_entropy(out, y_head)
|
|
255
|
+
loss.backward()
|
|
256
|
+
optimizer.step()
|
|
257
|
+
|
|
258
|
+
batch_losses.append(loss.item())
|
|
259
|
+
preds = out.argmax(dim=1)
|
|
260
|
+
correct = (preds == y_head).sum().item()
|
|
261
|
+
acc = correct / y_head.size(0)
|
|
262
|
+
batch_accuracies.append(acc)
|
|
263
|
+
|
|
264
|
+
mean_loss = float(sum(batch_losses) / len(batch_losses))
|
|
265
|
+
mean_acc = float(sum(batch_accuracies) / len(batch_accuracies))
|
|
266
|
+
return mean_loss, mean_acc
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@torch.no_grad
|
|
271
|
+
def inference(
|
|
272
|
+
model,
|
|
273
|
+
device,
|
|
274
|
+
sample_name,
|
|
275
|
+
root = "../data/",
|
|
276
|
+
tile_id = [0],
|
|
277
|
+
num_neighbors = [20,10],
|
|
278
|
+
num_classes: int = 500,
|
|
279
|
+
batch_size: int = 2048,
|
|
280
|
+
verbose = True) -> None:
|
|
281
|
+
"""
|
|
282
|
+
Run one epoch of training for link prediction.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
model (torch.nn.Module): The GNN model with trained weights.
|
|
286
|
+
device (torch.device): Device for computation.
|
|
287
|
+
sample_name (str): sample name.
|
|
288
|
+
tile_id ([int]): The tile ids to process for the sample_name.
|
|
289
|
+
num_classes (int): Number of node feature classes (for one-hot encoding).Equals the number of unique gene ids in all samples.
|
|
290
|
+
batch_size (int, optional): Mini-batch size. Defaults to 2048.
|
|
291
|
+
num_neighbors (list[int], optional): Neighbor sampling sizes. Defaults to [20, 10].
|
|
292
|
+
verbose (bool, optional): Show progress bars. Defaults to True.
|
|
293
|
+
"""
|
|
294
|
+
for t_id in tile_id:
|
|
295
|
+
data_path = osp.join(root,"processed",f'{sample_name}_data_tile{t_id}.pt')
|
|
296
|
+
assert osp.exists(data_path)
|
|
297
|
+
data = torch.load(data_path)
|
|
298
|
+
data = Data(**data[0])
|
|
299
|
+
subgraph_loader = NeighborLoader(
|
|
300
|
+
data,
|
|
301
|
+
input_nodes=data.core_mask,
|
|
302
|
+
#num_neighbors=[-1],
|
|
303
|
+
num_neighbors=num_neighbors,
|
|
304
|
+
batch_size=batch_size,
|
|
305
|
+
replace=False,
|
|
306
|
+
shuffle=False,
|
|
307
|
+
subgraph_type = "bidirectional"
|
|
308
|
+
)
|
|
309
|
+
xs = []
|
|
310
|
+
for batch in tqdm(subgraph_loader, disable=not verbose):
|
|
311
|
+
batch = batch.to(device)
|
|
312
|
+
batch.x = torch.nn.functional.one_hot(batch.x, num_classes=num_classes).float().squeeze(1)
|
|
313
|
+
|
|
314
|
+
out = model(batch.x, batch.edge_index)
|
|
315
|
+
# NOTE Only consider predictions and labels of seed nodes:
|
|
316
|
+
out = out[:batch.batch_size]
|
|
317
|
+
xs.append(out.cpu())
|
|
318
|
+
|
|
319
|
+
x_all = torch.cat(xs, dim=0)
|
|
320
|
+
# Define directory for embeddings
|
|
321
|
+
emb_dir = osp.join(root, "embedding")
|
|
322
|
+
os.makedirs(emb_dir, exist_ok=True)
|
|
323
|
+
# Define output .npy file path
|
|
324
|
+
out_embs_npy = osp.join(emb_dir, f"{sample_name}_data_tile{t_id}.npy")
|
|
325
|
+
np.save(out_embs_npy,x_all)
|
|
326
|
+
pd.DataFrame({"tx_id":data.trans_id[data.core_mask.cpu()]}).to_csv(out_embs_npy.replace(".npy","input_tx_id.csv"), index=False)
|
|
327
|
+
None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
import torch
|
|
331
|
+
from torch_geometric.loader import NeighborLoader
|
|
332
|
+
from tqdm import tqdm
|
|
333
|
+
|
|
334
|
+
def validate_label_pred(
|
|
335
|
+
model,
|
|
336
|
+
val_loader,
|
|
337
|
+
device,
|
|
338
|
+
batch_size: int = 2048,
|
|
339
|
+
num_neighbors: list[int] = [20, 10],
|
|
340
|
+
verbose: bool = True
|
|
341
|
+
):
|
|
342
|
+
"""
|
|
343
|
+
Run one epoch of validation for node classification.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
model (torch.nn.Module): Trained GNN model.
|
|
347
|
+
val_loader (torch.utils.data.DataLoader): Loader with validation graphs.
|
|
348
|
+
device (torch.device): Device for computation.
|
|
349
|
+
batch_size (int, optional): Mini-batch size. Defaults to 2048.
|
|
350
|
+
num_neighbors (list[int], optional): Neighbor sampling sizes. Defaults to [20, 10].
|
|
351
|
+
verbose (bool, optional): Show progress bars. Defaults to True.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
float: Mean accuracy across validation batches.
|
|
355
|
+
"""
|
|
356
|
+
model.eval()
|
|
357
|
+
batch_accuracies = []
|
|
358
|
+
|
|
359
|
+
with torch.no_grad():
|
|
360
|
+
for d_i, d_batch in enumerate(val_loader):
|
|
361
|
+
if verbose:
|
|
362
|
+
print(f"Validating on batch graph {d_i}...")
|
|
363
|
+
|
|
364
|
+
val_mask = getattr(d_batch, "val_mask", None)
|
|
365
|
+
if val_mask is not None:
|
|
366
|
+
val_nodes = val_mask.nonzero(as_tuple=False).view(-1)
|
|
367
|
+
|
|
368
|
+
val_mini_loader = NeighborLoader(
|
|
369
|
+
d_batch,
|
|
370
|
+
batch_size=batch_size,
|
|
371
|
+
shuffle=False,
|
|
372
|
+
num_neighbors=num_neighbors,
|
|
373
|
+
input_nodes=val_nodes,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
for batch in tqdm(val_mini_loader, disable=not verbose):
|
|
377
|
+
batch = batch.to(device)
|
|
378
|
+
out = model(batch.x, batch.edge_index)[:batch.batch_size]
|
|
379
|
+
y_head = batch.y[:batch.batch_size]
|
|
380
|
+
|
|
381
|
+
preds = out.argmax(dim=1)
|
|
382
|
+
correct = (preds == y_head).sum().item()
|
|
383
|
+
acc = correct / y_head.size(0)
|
|
384
|
+
batch_accuracies.append(acc)
|
|
385
|
+
|
|
386
|
+
mean_acc = float(sum(batch_accuracies) / len(batch_accuracies))
|
|
387
|
+
return mean_acc
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import matplotlib.colors as mcolors
|
|
5
|
+
import os
|
|
6
|
+
import os.path as osp
|
|
7
|
+
from scipy.stats import mode
|
|
8
|
+
import cv2
|
|
9
|
+
|
|
10
|
+
def get_tx_plot_df(root="multi_sample_label_pred",
|
|
11
|
+
sample_name ="lung1",
|
|
12
|
+
n_clusters = 10):
|
|
13
|
+
"""
|
|
14
|
+
Gather clustering results and transcript meta data into one DataFrame
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
root (str): Path of root data dir. Transcript meta file is expected in {root}/sample_name/raw/sample_name.csv
|
|
18
|
+
n_clusters: The number of K clusters in previous run_kmeans function call
|
|
19
|
+
"""
|
|
20
|
+
tx_meta_file = osp.join(root,sample_name,"raw",f"{sample_name}.csv")
|
|
21
|
+
assert osp.exists(tx_meta_file)
|
|
22
|
+
tx_meta = pd.read_csv(tx_meta_file)
|
|
23
|
+
tx_meta = tx_meta.reset_index(drop=True)
|
|
24
|
+
tx_meta["tx_ids"] = tx_meta.index
|
|
25
|
+
|
|
26
|
+
cluster_res = osp.join(root,sample_name,"clusters",f"{sample_name}.{n_clusters}clusters.csv")
|
|
27
|
+
assert osp.exists(cluster_res)
|
|
28
|
+
cluster_res_df = pd.read_csv(cluster_res)
|
|
29
|
+
# --- Reorder cluster_res_df to match tx_meta.index ---
|
|
30
|
+
cluster_res_df = cluster_res_df.set_index("tx_ids").loc[tx_meta.index].reset_index()
|
|
31
|
+
|
|
32
|
+
# Now assign labels without reordering tx_meta
|
|
33
|
+
tx_meta["cluster_labels"] = cluster_res_df["cluster_labels"].values
|
|
34
|
+
|
|
35
|
+
return tx_meta
|
|
36
|
+
def plot_pixel(tx_meta,
|
|
37
|
+
pixel_size: float = 10,
|
|
38
|
+
min_points: int = 5,
|
|
39
|
+
x='X',
|
|
40
|
+
y='Y',
|
|
41
|
+
cluster_labels="cluster_labels",
|
|
42
|
+
figsize=(8, 8),
|
|
43
|
+
cmap=None,
|
|
44
|
+
join_method="avg", # "avg" or "major"
|
|
45
|
+
background_color="white",
|
|
46
|
+
output_path=None,
|
|
47
|
+
**kwargs):
|
|
48
|
+
"""
|
|
49
|
+
Pixelate points and color each pixel by either:
|
|
50
|
+
- average RGB of all points ("avg"), or
|
|
51
|
+
- major label color ("major").
|
|
52
|
+
Pixels with < min_points are set to background.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(cmap, dict):
|
|
55
|
+
raise ValueError("cmap must be a dict mapping cluster labels to hex colors")
|
|
56
|
+
if join_method not in {"avg", "major"}:
|
|
57
|
+
raise ValueError("join_method must be 'avg' or 'major'")
|
|
58
|
+
|
|
59
|
+
tx_meta = tx_meta.copy()
|
|
60
|
+
|
|
61
|
+
# Map cluster labels → RGB (0-1 range)
|
|
62
|
+
tx_meta['RGB'] = tx_meta[cluster_labels].map(
|
|
63
|
+
lambda lbl: mcolors.to_rgb(cmap.get(lbl, background_color))
|
|
64
|
+
)
|
|
65
|
+
tx_meta[['R', 'G', 'B']] = pd.DataFrame(tx_meta['RGB'].tolist(), index=tx_meta.index)
|
|
66
|
+
|
|
67
|
+
# Compute pixel indices
|
|
68
|
+
tx_meta['px'] = (tx_meta[x] // pixel_size).astype(int)
|
|
69
|
+
tx_meta['py'] = (tx_meta[y] // pixel_size).astype(int)
|
|
70
|
+
|
|
71
|
+
# Function to compute pixel color per group
|
|
72
|
+
def pixel_color(df):
|
|
73
|
+
if len(df) < min_points:
|
|
74
|
+
return pd.Series([np.nan, np.nan, np.nan], index=['R','G','B'])
|
|
75
|
+
if join_method == "avg":
|
|
76
|
+
return pd.Series([df['R'].mean(), df['G'].mean(), df['B'].mean()], index=['R','G','B'])
|
|
77
|
+
else: # "major"
|
|
78
|
+
major_label = df[cluster_labels].mode().iloc[0]
|
|
79
|
+
return pd.Series(mcolors.to_rgb(cmap.get(major_label, background_color)), index=['R','G','B'])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Only keep the columns needed by pixel_color
|
|
83
|
+
subset = tx_meta[['px', 'py', 'R', 'G', 'B', cluster_labels]]
|
|
84
|
+
# Group by px, py and apply pixel_color
|
|
85
|
+
pixel_df = (
|
|
86
|
+
subset.groupby(['px', 'py'])[['R','G','B', cluster_labels]]
|
|
87
|
+
.apply(pixel_color)
|
|
88
|
+
.reset_index()
|
|
89
|
+
)
|
|
90
|
+
# Pivot into 2D grid
|
|
91
|
+
pivot_r = pixel_df.pivot(index='py', columns='px', values='R').iloc[::-1]
|
|
92
|
+
pivot_g = pixel_df.pivot(index='py', columns='px', values='G').iloc[::-1]
|
|
93
|
+
pivot_b = pixel_df.pivot(index='py', columns='px', values='B').iloc[::-1]
|
|
94
|
+
|
|
95
|
+
img = np.dstack([pivot_r, pivot_g, pivot_b])
|
|
96
|
+
|
|
97
|
+
# Fill NaNs with background color
|
|
98
|
+
bg_rgb = np.array(mcolors.to_rgb(background_color))
|
|
99
|
+
nan_mask = np.isnan(img)
|
|
100
|
+
img[nan_mask] = np.take(bg_rgb, nan_mask.nonzero()[2])
|
|
101
|
+
|
|
102
|
+
if output_path is None:
|
|
103
|
+
fig, ax = plt.subplots(1, 1, figsize=figsize, **kwargs)
|
|
104
|
+
ax.imshow(img, interpolation="none", origin="upper")
|
|
105
|
+
ax.axis("off")
|
|
106
|
+
ax.set_title(f"Pixelated ({join_method}) (min_points={min_points})")
|
|
107
|
+
return fig, ax
|
|
108
|
+
else:
|
|
109
|
+
# Convert to uint8 and BGR for OpenCV
|
|
110
|
+
img_uint8 = np.clip(img*255, 0, 255).astype(np.uint8)
|
|
111
|
+
img_bgr = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2BGR)
|
|
112
|
+
cv2.imwrite(output_path, img_bgr)
|
|
113
|
+
print(f"Saved pixelated RGB image to {output_path}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def plot_hex_bin(tx_meta,
|
|
117
|
+
x="X",
|
|
118
|
+
y="Y",
|
|
119
|
+
cluster_labels="cluster_labels",
|
|
120
|
+
bin_thresh=10,
|
|
121
|
+
bin_width=5,
|
|
122
|
+
figsize=(12, 10),
|
|
123
|
+
cmap=None,
|
|
124
|
+
background_color="white",
|
|
125
|
+
**kwargs):
|
|
126
|
+
"""
|
|
127
|
+
Hex-binning of points, colored by majority cluster label.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
# majority rule
|
|
131
|
+
def get_major_cluster(values):
|
|
132
|
+
if len(values) < bin_thresh:
|
|
133
|
+
return np.nan
|
|
134
|
+
result = mode(values, keepdims=True)
|
|
135
|
+
return result.mode[0] if result.mode.size > 0 else np.nan
|
|
136
|
+
|
|
137
|
+
# bin resolution
|
|
138
|
+
x_bins = int((tx_meta[x].max() - tx_meta[x].min()) / bin_width)
|
|
139
|
+
y_bins = int((tx_meta[y].max() - tx_meta[y].min()) / bin_width)
|
|
140
|
+
|
|
141
|
+
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True, **kwargs)
|
|
142
|
+
|
|
143
|
+
# handle cmap (dict or colormap string)
|
|
144
|
+
if isinstance(cmap, dict):
|
|
145
|
+
# map labels → ints for hexbin
|
|
146
|
+
labels_unique = sorted(tx_meta[cluster_labels].dropna().unique())
|
|
147
|
+
label_to_int = {lbl: i for i, lbl in enumerate(labels_unique)}
|
|
148
|
+
int_to_color = {label_to_int[k]: mcolors.to_rgba(v) for k, v in cmap.items()}
|
|
149
|
+
# convert data to ints
|
|
150
|
+
data_vals = tx_meta[cluster_labels].map(label_to_int)
|
|
151
|
+
cmap_obj = mcolors.ListedColormap([int_to_color.get(i, mcolors.to_rgba(background_color))
|
|
152
|
+
for i in range(len(label_to_int))])
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
# numeric encoding for labels
|
|
156
|
+
labels_unique = sorted(tx_meta[cluster_labels].dropna().unique())
|
|
157
|
+
label_to_int = {lbl: i for i, lbl in enumerate(labels_unique)}
|
|
158
|
+
data_vals = tx_meta[cluster_labels].map(label_to_int)
|
|
159
|
+
cmap_obj = plt.get_cmap(cmap or "tab10", len(labels_unique))
|
|
160
|
+
|
|
161
|
+
# hexbin majority aggregation
|
|
162
|
+
hb = ax.hexbin(
|
|
163
|
+
tx_meta[x],
|
|
164
|
+
tx_meta[y],
|
|
165
|
+
C=data_vals,
|
|
166
|
+
gridsize=(x_bins, y_bins),
|
|
167
|
+
reduce_C_function=get_major_cluster,
|
|
168
|
+
linewidths=0.05,
|
|
169
|
+
cmap=cmap_obj
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# axis cosmetics
|
|
173
|
+
ax.invert_yaxis()
|
|
174
|
+
ax.set_xlabel(x, fontsize=12)
|
|
175
|
+
ax.set_ylabel(y, fontsize=12)
|
|
176
|
+
ax.tick_params(axis='both', which='both', colors='black')
|
|
177
|
+
for spine in ["top", "right"]:
|
|
178
|
+
ax.spines[spine].set_visible(False)
|
|
179
|
+
for spine in ["left", "bottom"]:
|
|
180
|
+
ax.spines[spine].set_linewidth(1.5)
|
|
181
|
+
ax.grid(False)
|
|
182
|
+
ax.set_aspect('equal', adjustable='box') # or 'datalim'
|
|
183
|
+
|
|
184
|
+
# colorbar
|
|
185
|
+
cb = fig.colorbar(hb, ax=ax, orientation="vertical")
|
|
186
|
+
cb.set_label("Major Cluster", fontsize=10)
|
|
187
|
+
|
|
188
|
+
return fig, ax
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: spatialrna
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A package for spatial RNA graph data processing and analysis.
|
|
5
5
|
Author-email: Ruqian Lyu <rlyu@svi.edu.au>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: repository, https://
|
|
7
|
+
Project-URL: repository, https://github.com/ruqianl/spatialrna.git
|
|
8
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.10
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE.txt
|
|
13
14
|
Requires-Dist: torch
|
|
14
15
|
Requires-Dist: torch_geometric
|
|
15
16
|
Requires-Dist: pandas
|
|
@@ -18,6 +19,7 @@ Provides-Extra: dev
|
|
|
18
19
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
19
20
|
Requires-Dist: twine>=4.0.2; extra == "dev"
|
|
20
21
|
Requires-Dist: build; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
21
23
|
|
|
22
24
|
## A package that provides easier inferface to GNN models for single-molecule ST data
|
|
23
25
|
|
|
@@ -43,6 +45,14 @@ pip install torch_geometric==2.6.1
|
|
|
43
45
|
|
|
44
46
|
pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.4.0+cu124.html
|
|
45
47
|
|
|
48
|
+
## clustering and plotting requires
|
|
49
|
+
|
|
50
|
+
pip install tqdm \
|
|
51
|
+
scipy \
|
|
52
|
+
scikit-learn \
|
|
53
|
+
pandas \
|
|
54
|
+
matplotlib \
|
|
55
|
+
opencv-python
|
|
46
56
|
```
|
|
47
57
|
|
|
48
58
|
## Install spatialrna
|
|
@@ -50,3 +60,7 @@ pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -
|
|
|
50
60
|
```
|
|
51
61
|
pip install spatialrna
|
|
52
62
|
```
|
|
63
|
+
|
|
64
|
+
## Documentations
|
|
65
|
+
|
|
66
|
+
https://ruqianl.github.io/spatialrna_docs/
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
LICENSE.txt
|
|
1
2
|
README.md
|
|
2
3
|
pyproject.toml
|
|
3
4
|
spatialrna/__init__.py
|
|
5
|
+
spatialrna/run_kmeans.py
|
|
4
6
|
spatialrna/spatialrna.py
|
|
7
|
+
spatialrna/spatialrna_ondisk.py
|
|
8
|
+
spatialrna/train_val.py
|
|
9
|
+
spatialrna/viz.py
|
|
5
10
|
spatialrna.egg-info/PKG-INFO
|
|
6
11
|
spatialrna.egg-info/SOURCES.txt
|
|
7
12
|
spatialrna.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|