spatialrna 0.2.0__tar.gz → 0.3.0.dev1__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.3.0.dev1/PKG-INFO +34 -0
- spatialrna-0.3.0.dev1/README.md +10 -0
- spatialrna-0.3.0.dev1/pyproject.toml +40 -0
- spatialrna-0.3.0.dev1/src/spatialrna/__init__.py +60 -0
- spatialrna-0.3.0.dev1/src/spatialrna/cluster.py +473 -0
- spatialrna-0.3.0.dev1/src/spatialrna/datasets.py +51 -0
- spatialrna-0.3.0.dev1/src/spatialrna/graph_build.py +350 -0
- spatialrna-0.3.0.dev1/src/spatialrna/graph_zarr_io.py +1022 -0
- spatialrna-0.3.0.dev1/src/spatialrna/readers.py +313 -0
- spatialrna-0.3.0.dev1/src/spatialrna/train_val.py +129 -0
- spatialrna-0.3.0.dev1/src/spatialrna/viz.py +326 -0
- spatialrna-0.3.0.dev1/src/spatialrna.egg-info/PKG-INFO +34 -0
- spatialrna-0.3.0.dev1/src/spatialrna.egg-info/SOURCES.txt +19 -0
- spatialrna-0.3.0.dev1/src/spatialrna.egg-info/requires.txt +15 -0
- spatialrna-0.3.0.dev1/tests/test_cluster_viz.py +30 -0
- spatialrna-0.3.0.dev1/tests/test_readers.py +99 -0
- spatialrna-0.3.0.dev1/tests/test_tiling.py +38 -0
- spatialrna-0.3.0.dev1/tests/test_zarr_roundtrip.py +133 -0
- spatialrna-0.2.0/LICENSE.txt +0 -21
- spatialrna-0.2.0/PKG-INFO +0 -66
- spatialrna-0.2.0/README.md +0 -43
- spatialrna-0.2.0/pyproject.toml +0 -48
- spatialrna-0.2.0/spatialrna/__init__.py +0 -1
- spatialrna-0.2.0/spatialrna/run_kmeans.py +0 -113
- spatialrna-0.2.0/spatialrna/spatialrna.py +0 -352
- spatialrna-0.2.0/spatialrna/spatialrna_ondisk.py +0 -74
- spatialrna-0.2.0/spatialrna/train_val.py +0 -387
- spatialrna-0.2.0/spatialrna/viz.py +0 -188
- spatialrna-0.2.0/spatialrna.egg-info/PKG-INFO +0 -66
- spatialrna-0.2.0/spatialrna.egg-info/SOURCES.txt +0 -18
- spatialrna-0.2.0/spatialrna.egg-info/requires.txt +0 -9
- spatialrna-0.2.0/tests/test_data.py +0 -225
- spatialrna-0.2.0/tests/test_reload.py +0 -137
- spatialrna-0.2.0/tests/test_subgraph.py +0 -98
- spatialrna-0.2.0/tests/test_subgraph_mode.py +0 -136
- {spatialrna-0.2.0 → spatialrna-0.3.0.dev1}/setup.cfg +0 -0
- {spatialrna-0.2.0 → spatialrna-0.3.0.dev1/src}/spatialrna.egg-info/dependency_links.txt +0 -0
- {spatialrna-0.2.0 → spatialrna-0.3.0.dev1/src}/spatialrna.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spatialrna
|
|
3
|
+
Version: 0.3.0.dev1
|
|
4
|
+
Summary: Spatial RNA graph data processing and analysis for iST (Xenium / Atera): graphs, GNN training / embedding, clustering, spatial maps.
|
|
5
|
+
Author-email: Anne Pan <anne.pan@svi.edu.au>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: pandas
|
|
13
|
+
Requires-Dist: scipy
|
|
14
|
+
Requires-Dist: zarr>=3
|
|
15
|
+
Requires-Dist: numcodecs
|
|
16
|
+
Requires-Dist: tqdm
|
|
17
|
+
Requires-Dist: scikit-learn
|
|
18
|
+
Requires-Dist: matplotlib
|
|
19
|
+
Provides-Extra: gnn
|
|
20
|
+
Requires-Dist: torch; extra == "gnn"
|
|
21
|
+
Requires-Dist: torch_geometric; extra == "gnn"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
|
|
25
|
+
description = "A package for spatial RNA graph data processing and analysis."
|
|
26
|
+
readme = "README.md"
|
|
27
|
+
version = "0.3.0.dev1"
|
|
28
|
+
authors = [{ name = "Anne Pan", email = "anne.pan@svi.edu.au" },{ name = "Ruqian Lyu" }]
|
|
29
|
+
license = { text = "MIT" }
|
|
30
|
+
requires-python = ">=3.12"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://gitlab.svi.edu.au/biocellgen-public/spatialrna_pypi_public"
|
|
34
|
+
Documentation = "https://ruqianl.github.io/spatialrna_docs/"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
description = "A package for spatial RNA graph data processing and analysis."
|
|
2
|
+
readme = "README.md"
|
|
3
|
+
version = "0.3.0.dev1"
|
|
4
|
+
authors = [{ name = "Anne Pan", email = "anne.pan@svi.edu.au" },{ name = "Ruqian Lyu" }]
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
|
|
8
|
+
[project.urls]
|
|
9
|
+
Repository = "https://gitlab.svi.edu.au/biocellgen-public/spatialrna_pypi_public"
|
|
10
|
+
Documentation = "https://ruqianl.github.io/spatialrna_docs/"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spatialrna"
|
|
7
|
+
version = "0.3.0.dev1"
|
|
8
|
+
description = "Spatial RNA graph data processing and analysis for iST (Xenium / Atera): graphs, GNN training / embedding, clustering, spatial maps."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [{ name = "Anne Pan", email = "anne.pan@svi.edu.au" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
16
|
+
]
|
|
17
|
+
# Core: readers, graph builders, clustering, maps. No torch needed.
|
|
18
|
+
dependencies = [
|
|
19
|
+
"numpy",
|
|
20
|
+
"pandas",
|
|
21
|
+
"scipy",
|
|
22
|
+
"zarr>=3",
|
|
23
|
+
"numcodecs",
|
|
24
|
+
"tqdm",
|
|
25
|
+
"scikit-learn",
|
|
26
|
+
"matplotlib",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# SpatialRNA dataset, datasets, train_val. Install torch and the PyG wheels matching
|
|
31
|
+
# your CUDA first -- see README.
|
|
32
|
+
gnn = ["torch", "torch_geometric"]
|
|
33
|
+
dev = ["pytest"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
include = ["spatialrna*"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""spatialrna: spatial RNA graph data processing and analysis for single-molecule
|
|
2
|
+
spatial transcriptomics (Xenium / Atera): transcripts -> tile graphs / subgraphs ->
|
|
3
|
+
any GNN (train / validate / embed) -> clusters -> spatial maps.
|
|
4
|
+
|
|
5
|
+
import spatialrna as sr
|
|
6
|
+
df = sr.readers.load_transcripts("/path/to/xenium_or_atera_output")
|
|
7
|
+
from spatialrna import SpatialRNA # tile graphs / subgraphs; needs torch + PyG
|
|
8
|
+
|
|
9
|
+
The ISTIST pipeline builds on this with its own model (CrossSampleGAT), transcript
|
|
10
|
+
annotation, statistics and report.
|
|
11
|
+
|
|
12
|
+
Submodules (imported on first use, so the light ones need neither torch nor a GPU):
|
|
13
|
+
|
|
14
|
+
readers Xenium / Atera transcripts.zarr.zip -> transcripts
|
|
15
|
+
graph_build radius graphs (KD-tree) and subgraph sampling
|
|
16
|
+
graph_zarr_io SpatialRNA tile dataset (strips or grid tiles) + zarr I/O [torch]
|
|
17
|
+
datasets ZarrGraphDataset over many tile / subgraph stores [torch]
|
|
18
|
+
train_val model-agnostic link-prediction training, validation, tile embedding [torch]
|
|
19
|
+
cluster Gaussian mixture (GPU or numpy) and k-means on embeddings
|
|
20
|
+
viz pixel maps of labels / niches / cell types
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib
|
|
26
|
+
|
|
27
|
+
__version__ = "0.3.0.dev0"
|
|
28
|
+
|
|
29
|
+
_SUBMODULES = {"readers", "graph_build", "graph_zarr_io", "datasets", "train_val", "cluster", "viz"}
|
|
30
|
+
|
|
31
|
+
# name -> submodule, for `from spatialrna import SpatialRNA` etc. (resolved lazily, so
|
|
32
|
+
# `import spatialrna` works without torch; the graph_zarr_io names need it).
|
|
33
|
+
_EXPORTS = {
|
|
34
|
+
**{n: "graph_zarr_io" for n in (
|
|
35
|
+
"SpatialRNA", "save_data_zarr", "load_data_zarr", "zarr_group_exists", "save_raw_transcripts_zarr",
|
|
36
|
+
"load_raw_transcripts_zarr", "store_exists", "tile_store_path", "sample_store_path",
|
|
37
|
+
"ensure_sample_store", "subgraph_store_path", "read_tile_graph", "write_tile_graph",
|
|
38
|
+
"read_subgraph", "write_subgraph")},
|
|
39
|
+
"load_transcripts": "readers",
|
|
40
|
+
"ZarrGraphDataset": "datasets",
|
|
41
|
+
"plan_tiles": "graph_build",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
__all__ = sorted(_SUBMODULES | set(_EXPORTS)) + ["__version__"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def __getattr__(name):
|
|
48
|
+
if name in _SUBMODULES:
|
|
49
|
+
mod = importlib.import_module(f".{name}", __name__)
|
|
50
|
+
globals()[name] = mod
|
|
51
|
+
return mod
|
|
52
|
+
if name in _EXPORTS:
|
|
53
|
+
value = getattr(importlib.import_module(f".{_EXPORTS[name]}", __name__), name)
|
|
54
|
+
globals()[name] = value
|
|
55
|
+
return value
|
|
56
|
+
raise AttributeError(f"module 'spatialrna' has no attribute {name!r}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def __dir__():
|
|
60
|
+
return __all__
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Clustering transcript embeddings into niches (spatialrna 0.2's run_kmeans, extended).
|
|
2
|
+
|
|
3
|
+
from spatialrna import cluster
|
|
4
|
+
labels, model = cluster.gmm(X, 12) # Gaussian mixture, GPU if available
|
|
5
|
+
labels, model = cluster.kmeans(X, 12) # scikit-learn k-means
|
|
6
|
+
|
|
7
|
+
GaussianMixtureGPU -- a Gaussian mixture model (EM) that runs on the GPU with PyTorch.
|
|
8
|
+
|
|
9
|
+
Replaces PyCave (torch < 2 only, unmaintained) for niche clustering. Same method:
|
|
10
|
+
greedy k-means++ seeding and a few k-means iterations, then expectation-maximisation, with diagonal ("diag") or full
|
|
11
|
+
covariance matrices. Data stay on the GPU in float32 and are processed in chunks;
|
|
12
|
+
sufficient statistics (sums) are accumulated in float64 for numerical stability.
|
|
13
|
+
|
|
14
|
+
The algorithm is written once against a few array operations (`_TorchOps` for the
|
|
15
|
+
pipeline, `_NumpyOps` for testing it against scikit-learn without a GPU), so both
|
|
16
|
+
run the identical code path.
|
|
17
|
+
|
|
18
|
+
gmm = GaussianMixtureGPU(n_components=12, covariance_type="diag", device="cuda")
|
|
19
|
+
gmm.fit(X) # X: (n, d) numpy float32
|
|
20
|
+
labels = gmm.predict(X) # numpy int64
|
|
21
|
+
gmm.save("gmm.pt")
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import time
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Array backends
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
class _TorchOps:
|
|
36
|
+
def __init__(self, device: str):
|
|
37
|
+
import torch
|
|
38
|
+
self.t = torch
|
|
39
|
+
if device == "auto":
|
|
40
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
41
|
+
if device.startswith("cuda") and not torch.cuda.is_available():
|
|
42
|
+
raise RuntimeError("GMM device 'cuda' requested but no GPU is visible "
|
|
43
|
+
"(torch.cuda.is_available() is False).")
|
|
44
|
+
self.device = torch.device(device)
|
|
45
|
+
self.f32, self.f64, self.i64 = torch.float32, torch.float64, torch.int64
|
|
46
|
+
|
|
47
|
+
def from_numpy(self, a, dtype=None):
|
|
48
|
+
return self.t.as_tensor(a, dtype=dtype or self.f32, device=self.device)
|
|
49
|
+
|
|
50
|
+
def to_numpy(self, a):
|
|
51
|
+
return a.detach().cpu().numpy()
|
|
52
|
+
|
|
53
|
+
def zeros(self, shape, dtype):
|
|
54
|
+
return self.t.zeros(shape, dtype=dtype, device=self.device)
|
|
55
|
+
|
|
56
|
+
def eye(self, n, dtype):
|
|
57
|
+
return self.t.eye(n, dtype=dtype, device=self.device)
|
|
58
|
+
|
|
59
|
+
def astype(self, a, dtype):
|
|
60
|
+
return a.to(dtype)
|
|
61
|
+
|
|
62
|
+
def sum(self, a, axis):
|
|
63
|
+
return a.sum(dim=axis)
|
|
64
|
+
|
|
65
|
+
def logsumexp(self, a, axis):
|
|
66
|
+
return self.t.logsumexp(a, dim=axis)
|
|
67
|
+
|
|
68
|
+
def argmax(self, a, axis):
|
|
69
|
+
return a.argmax(dim=axis)
|
|
70
|
+
|
|
71
|
+
def min(self, a, axis):
|
|
72
|
+
return a.min(dim=axis).values
|
|
73
|
+
|
|
74
|
+
def log(self, a):
|
|
75
|
+
return self.t.log(a)
|
|
76
|
+
|
|
77
|
+
def exp(self, a):
|
|
78
|
+
return self.t.exp(a)
|
|
79
|
+
|
|
80
|
+
def clip_min(self, a, v):
|
|
81
|
+
return a.clamp_min(v)
|
|
82
|
+
|
|
83
|
+
def cholesky(self, a):
|
|
84
|
+
return self.t.linalg.cholesky(a)
|
|
85
|
+
|
|
86
|
+
def inv(self, a):
|
|
87
|
+
return self.t.linalg.inv(a)
|
|
88
|
+
|
|
89
|
+
def diagonal(self, a): # batched (k, d, d) -> (k, d)
|
|
90
|
+
return self.t.diagonal(a, dim1=-2, dim2=-1)
|
|
91
|
+
|
|
92
|
+
def transpose_last(self, a):
|
|
93
|
+
return a.transpose(-1, -2)
|
|
94
|
+
|
|
95
|
+
def bincount(self, a, n):
|
|
96
|
+
return self.t.bincount(a, minlength=n)
|
|
97
|
+
|
|
98
|
+
def item(self, a):
|
|
99
|
+
return float(a.item())
|
|
100
|
+
|
|
101
|
+
def sync(self):
|
|
102
|
+
if self.device.type == "cuda":
|
|
103
|
+
self.t.cuda.synchronize()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _NumpyOps:
|
|
107
|
+
"""Same interface on NumPy -- used to test the algorithm without a GPU."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, device: str = "cpu"):
|
|
110
|
+
self.device = "cpu"
|
|
111
|
+
self.f32, self.f64, self.i64 = np.float32, np.float64, np.int64
|
|
112
|
+
|
|
113
|
+
def from_numpy(self, a, dtype=None):
|
|
114
|
+
return np.asarray(a, dtype=dtype or self.f32)
|
|
115
|
+
|
|
116
|
+
def to_numpy(self, a):
|
|
117
|
+
return np.asarray(a)
|
|
118
|
+
|
|
119
|
+
def zeros(self, shape, dtype):
|
|
120
|
+
return np.zeros(shape, dtype=dtype)
|
|
121
|
+
|
|
122
|
+
def eye(self, n, dtype):
|
|
123
|
+
return np.eye(n, dtype=dtype)
|
|
124
|
+
|
|
125
|
+
def astype(self, a, dtype):
|
|
126
|
+
return a.astype(dtype)
|
|
127
|
+
|
|
128
|
+
def sum(self, a, axis):
|
|
129
|
+
return a.sum(axis=axis)
|
|
130
|
+
|
|
131
|
+
def logsumexp(self, a, axis):
|
|
132
|
+
m = a.max(axis=axis, keepdims=True)
|
|
133
|
+
return (m + np.log(np.exp(a - m).sum(axis=axis, keepdims=True))).squeeze(axis)
|
|
134
|
+
|
|
135
|
+
def argmax(self, a, axis):
|
|
136
|
+
return a.argmax(axis=axis)
|
|
137
|
+
|
|
138
|
+
def min(self, a, axis):
|
|
139
|
+
return a.min(axis=axis)
|
|
140
|
+
|
|
141
|
+
def log(self, a):
|
|
142
|
+
return np.log(a)
|
|
143
|
+
|
|
144
|
+
def exp(self, a):
|
|
145
|
+
return np.exp(a)
|
|
146
|
+
|
|
147
|
+
def clip_min(self, a, v):
|
|
148
|
+
return np.maximum(a, v)
|
|
149
|
+
|
|
150
|
+
def cholesky(self, a):
|
|
151
|
+
return np.linalg.cholesky(a)
|
|
152
|
+
|
|
153
|
+
def inv(self, a):
|
|
154
|
+
return np.linalg.inv(a)
|
|
155
|
+
|
|
156
|
+
def diagonal(self, a):
|
|
157
|
+
return np.diagonal(a, axis1=-2, axis2=-1)
|
|
158
|
+
|
|
159
|
+
def transpose_last(self, a):
|
|
160
|
+
return np.swapaxes(a, -1, -2)
|
|
161
|
+
|
|
162
|
+
def bincount(self, a, n):
|
|
163
|
+
return np.bincount(a, minlength=n)
|
|
164
|
+
|
|
165
|
+
def item(self, a):
|
|
166
|
+
return float(a)
|
|
167
|
+
|
|
168
|
+
def sync(self):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# GMM
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
class GaussianMixtureGPU:
|
|
176
|
+
def __init__(self, n_components: int, covariance_type: str = "diag", max_iter: int = 500,
|
|
177
|
+
tol: float = 1e-3, reg_covar: float = 1e-6, seed: int = 0, device: str = "auto",
|
|
178
|
+
chunk_size: int = 1_000_000, init_sample: int = 200_000, verbose: bool = True,
|
|
179
|
+
backend: str = "torch", n_init: int = 1, kmeans_iter: int = 10):
|
|
180
|
+
if covariance_type not in ("diag", "full"):
|
|
181
|
+
raise ValueError("covariance_type must be 'diag' or 'full'")
|
|
182
|
+
self.k = int(n_components)
|
|
183
|
+
self.covariance_type = covariance_type
|
|
184
|
+
self.max_iter, self.tol, self.reg_covar = max_iter, tol, reg_covar
|
|
185
|
+
self.seed, self.chunk_size, self.init_sample = seed, chunk_size, init_sample
|
|
186
|
+
self.verbose = verbose
|
|
187
|
+
self.n_init, self.kmeans_iter = max(1, int(n_init)), int(kmeans_iter)
|
|
188
|
+
self.ops = _TorchOps(device) if backend == "torch" else _NumpyOps()
|
|
189
|
+
self.converged_, self.n_iter_, self.lower_bound_ = False, 0, -math.inf
|
|
190
|
+
|
|
191
|
+
# -- internals ----------------------------------------------------------
|
|
192
|
+
def _log(self, msg):
|
|
193
|
+
if self.verbose:
|
|
194
|
+
print(msg, flush=True)
|
|
195
|
+
|
|
196
|
+
def _chunks(self, n):
|
|
197
|
+
for s in range(0, n, self.chunk_size):
|
|
198
|
+
yield s, min(n, s + self.chunk_size)
|
|
199
|
+
|
|
200
|
+
def _weighted_log_prob(self, Xc):
|
|
201
|
+
"""(rows, k): log pi_k + log N(x | mu_k, Sigma_k), from the cached precisions."""
|
|
202
|
+
o, d = self.ops, Xc.shape[1]
|
|
203
|
+
const = -0.5 * d * math.log(2 * math.pi)
|
|
204
|
+
if self.covariance_type == "diag":
|
|
205
|
+
prec = self._prec # (k, d) = 1 / var
|
|
206
|
+
mp = self.means_ * prec # (k, d)
|
|
207
|
+
maha = (Xc * Xc) @ o.transpose_last(prec) - 2.0 * (Xc @ o.transpose_last(mp)) \
|
|
208
|
+
+ o.sum(self.means_ * mp, 1)[None, :]
|
|
209
|
+
maha = o.clip_min(maha, 0.0)
|
|
210
|
+
else:
|
|
211
|
+
cols = []
|
|
212
|
+
for j in range(self.k): # whitened residuals
|
|
213
|
+
y = (Xc - self.means_[j][None, :]) @ self._prec_chol[j]
|
|
214
|
+
cols.append(o.sum(y * y, 1)[:, None])
|
|
215
|
+
maha = cols[0] if self.k == 1 else _concat(o, cols)
|
|
216
|
+
return const - 0.5 * maha + self._half_logdet[None, :] + self._log_w[None, :]
|
|
217
|
+
|
|
218
|
+
def _refresh_precisions(self):
|
|
219
|
+
o = self.ops
|
|
220
|
+
if self.covariance_type == "diag":
|
|
221
|
+
self._prec = 1.0 / self.covariances_
|
|
222
|
+
self._half_logdet = 0.5 * o.sum(o.log(self._prec), 1)
|
|
223
|
+
else:
|
|
224
|
+
L = o.cholesky(self.covariances_) # cov = L L^T
|
|
225
|
+
self._prec_chol = o.transpose_last(o.inv(L)) # (x-mu) @ prec_chol = whitened
|
|
226
|
+
self._half_logdet = -o.sum(o.log(o.diagonal(L)), 1)
|
|
227
|
+
self._log_w = o.log(self.weights_)
|
|
228
|
+
|
|
229
|
+
def _m_step(self, nk, sx, sxx, n):
|
|
230
|
+
"""Parameters from float64 sufficient statistics."""
|
|
231
|
+
o, d = self.ops, sx.shape[1]
|
|
232
|
+
nk = o.clip_min(nk, 10 * np.finfo(np.float64).eps)
|
|
233
|
+
means = sx / nk[:, None]
|
|
234
|
+
if self.covariance_type == "diag":
|
|
235
|
+
var = sxx / nk[:, None] - means * means
|
|
236
|
+
var = o.clip_min(var, 0.0) + self.reg_covar
|
|
237
|
+
else:
|
|
238
|
+
outer = means[:, :, None] * means[:, None, :]
|
|
239
|
+
var = sxx / nk[:, None, None] - outer
|
|
240
|
+
var = 0.5 * (var + o.transpose_last(var)) + self.reg_covar * o.eye(d, o.f64)[None]
|
|
241
|
+
self.weights_ = o.astype(nk / n, o.f32)
|
|
242
|
+
self.means_ = o.astype(means, o.f32)
|
|
243
|
+
self.covariances_ = o.astype(var, o.f32)
|
|
244
|
+
self._refresh_precisions()
|
|
245
|
+
|
|
246
|
+
def _stats(self, X, resp_fn):
|
|
247
|
+
"""One pass over X: per-component counts, sums, (co)variance sums (float64)
|
|
248
|
+
under responsibilities resp_fn(chunk) -> (rows, k); plus total log-likelihood."""
|
|
249
|
+
o, d = self.ops, X.shape[1]
|
|
250
|
+
nk, sx = o.zeros((self.k,), o.f64), o.zeros((self.k, d), o.f64)
|
|
251
|
+
sxx = o.zeros((self.k, d), o.f64) if self.covariance_type == "diag" else o.zeros((self.k, d, d), o.f64)
|
|
252
|
+
ll = 0.0
|
|
253
|
+
for s, e in self._chunks(X.shape[0]):
|
|
254
|
+
Xc = X[s:e]
|
|
255
|
+
resp, chunk_ll = resp_fn(Xc)
|
|
256
|
+
ll += chunk_ll
|
|
257
|
+
rT = o.transpose_last(resp) # (k, rows)
|
|
258
|
+
nk = nk + o.astype(o.sum(resp, 0), o.f64)
|
|
259
|
+
sx = sx + o.astype(rT @ Xc, o.f64)
|
|
260
|
+
if self.covariance_type == "diag":
|
|
261
|
+
sxx = sxx + o.astype(rT @ (Xc * Xc), o.f64)
|
|
262
|
+
else:
|
|
263
|
+
for j in range(self.k):
|
|
264
|
+
Xw = Xc * resp[:, j][:, None]
|
|
265
|
+
sxx[j] = sxx[j] + o.astype(o.transpose_last(Xw) @ Xc, o.f64)
|
|
266
|
+
return nk, sx, sxx, ll
|
|
267
|
+
|
|
268
|
+
def _e_resp(self, Xc):
|
|
269
|
+
o = self.ops
|
|
270
|
+
wlp = self._weighted_log_prob(Xc)
|
|
271
|
+
lse = o.logsumexp(wlp, 1)
|
|
272
|
+
return o.exp(wlp - lse[:, None]), o.item(o.sum(o.astype(lse, o.f64), 0))
|
|
273
|
+
|
|
274
|
+
def _kmeanspp_init(self, X, rng):
|
|
275
|
+
"""Greedy k-means++ seeds (as scikit-learn: 2 + log(k) candidates per step, keep
|
|
276
|
+
the one that most reduces inertia) on a random subsample, refined by a few
|
|
277
|
+
k-means (Lloyd) iterations; then hard-assign ALL points to the nearest centre
|
|
278
|
+
to get the starting weights / means / covariances."""
|
|
279
|
+
o = self.ops
|
|
280
|
+
n = X.shape[0]
|
|
281
|
+
idx = np.sort(rng.choice(n, size=min(n, self.init_sample), replace=False))
|
|
282
|
+
S = X[o.from_numpy(idx, o.i64)] if isinstance(o, _TorchOps) else X[idx]
|
|
283
|
+
m = S.shape[0]
|
|
284
|
+
n_trials = 2 + int(math.log(self.k))
|
|
285
|
+
S_sq = o.sum(S * S, 1)
|
|
286
|
+
|
|
287
|
+
def sqdist_to(P): # (m, p) squared distances
|
|
288
|
+
return o.clip_min(S_sq[:, None] - 2.0 * (S @ o.transpose_last(P))
|
|
289
|
+
+ o.sum(P * P, 1)[None, :], 0.0)
|
|
290
|
+
|
|
291
|
+
first = int(rng.integers(m))
|
|
292
|
+
centres = [first]
|
|
293
|
+
d2 = sqdist_to(S[first][None, :])[:, 0]
|
|
294
|
+
for _ in range(1, self.k):
|
|
295
|
+
p = o.to_numpy(o.astype(d2, o.f64))
|
|
296
|
+
total = p.sum()
|
|
297
|
+
p = p / total if total > 0 else np.full(m, 1.0 / m)
|
|
298
|
+
cand = rng.choice(m, size=n_trials, p=p)
|
|
299
|
+
cand_idx = o.from_numpy(cand, o.i64) if isinstance(o, _TorchOps) else cand
|
|
300
|
+
dc = _minimum(o, d2[:, None], sqdist_to(S[cand_idx])) # (m, trials)
|
|
301
|
+
best = int(np.argmin(o.to_numpy(o.astype(o.sum(dc, 0), o.f64))))
|
|
302
|
+
centres.append(int(cand[best]))
|
|
303
|
+
d2 = dc[:, best]
|
|
304
|
+
C = _stack_rows(o, [S[c] for c in centres]) # (k, d)
|
|
305
|
+
|
|
306
|
+
for _ in range(self.kmeans_iter): # Lloyd refinement on the subsample
|
|
307
|
+
lab = o.argmax(-sqdist_to(C), 1)
|
|
308
|
+
oh = _one_hot(o, lab, self.k)
|
|
309
|
+
cnt = o.sum(oh, 0)
|
|
310
|
+
newC = (o.transpose_last(oh) @ S) / o.clip_min(cnt, 1.0)[:, None]
|
|
311
|
+
empty = o.to_numpy(cnt) == 0 # keep old centre if a cluster empties
|
|
312
|
+
if empty.any():
|
|
313
|
+
newC = _where_rows(o, empty, C, newC)
|
|
314
|
+
C = newC
|
|
315
|
+
|
|
316
|
+
def hard_resp(Xc):
|
|
317
|
+
dist = o.sum(Xc * Xc, 1)[:, None] - 2.0 * (Xc @ o.transpose_last(C)) + o.sum(C * C, 1)[None, :]
|
|
318
|
+
return _one_hot(o, o.argmax(-dist, 1), self.k), 0.0
|
|
319
|
+
|
|
320
|
+
nk, sx, sxx, _ = self._stats(X, hard_resp)
|
|
321
|
+
self._m_step(nk, sx, sxx, X.shape[0])
|
|
322
|
+
|
|
323
|
+
def _fit_once(self, X, rng):
|
|
324
|
+
o, n = self.ops, X.shape[0]
|
|
325
|
+
t0 = time.time()
|
|
326
|
+
self._kmeanspp_init(X, rng)
|
|
327
|
+
self._log(f" init (greedy k-means++ + {self.kmeans_iter} k-means iterations) "
|
|
328
|
+
f"{time.time() - t0:.1f}s on {o.device}")
|
|
329
|
+
prev, converged, it = -math.inf, False, 0
|
|
330
|
+
for it in range(1, self.max_iter + 1):
|
|
331
|
+
nk, sx, sxx, ll = self._stats(X, self._e_resp)
|
|
332
|
+
mean_ll = ll / n # under the parameters BEFORE this M-step
|
|
333
|
+
self._m_step(nk, sx, sxx, n)
|
|
334
|
+
change = mean_ll - prev
|
|
335
|
+
if self.verbose and (it == 1 or it % 10 == 0):
|
|
336
|
+
self._log(f" iter {it:4d} mean log-likelihood {mean_ll:.6f} change {change:.2e}")
|
|
337
|
+
prev = mean_ll
|
|
338
|
+
if abs(change) < self.tol:
|
|
339
|
+
converged = True
|
|
340
|
+
break
|
|
341
|
+
final_ll = self._mean_ll(X) # under the final parameters
|
|
342
|
+
o.sync()
|
|
343
|
+
self._log(f" EM {'converged' if converged else 'stopped at max_iter'} after {it} iterations "
|
|
344
|
+
f"({time.time() - t0:.1f}s); mean log-likelihood {final_ll:.6f}")
|
|
345
|
+
return final_ll, converged, it
|
|
346
|
+
|
|
347
|
+
def _mean_ll(self, X):
|
|
348
|
+
o, total = self.ops, 0.0
|
|
349
|
+
for s, e in self._chunks(X.shape[0]):
|
|
350
|
+
total += o.item(o.sum(o.astype(o.logsumexp(self._weighted_log_prob(X[s:e]), 1), o.f64), 0))
|
|
351
|
+
return total / X.shape[0]
|
|
352
|
+
|
|
353
|
+
# -- public API ---------------------------------------------------------
|
|
354
|
+
def fit(self, X_np: np.ndarray):
|
|
355
|
+
"""Fit with n_init independent starts; keep the one with the highest likelihood."""
|
|
356
|
+
o = self.ops
|
|
357
|
+
X = o.from_numpy(np.ascontiguousarray(X_np, dtype=np.float32))
|
|
358
|
+
rng = np.random.default_rng(self.seed)
|
|
359
|
+
best = None
|
|
360
|
+
for run in range(self.n_init):
|
|
361
|
+
if self.n_init > 1:
|
|
362
|
+
self._log(f" start {run + 1}/{self.n_init}")
|
|
363
|
+
ll, conv, n_it = self._fit_once(X, rng)
|
|
364
|
+
if best is None or ll > best[0]:
|
|
365
|
+
best = (ll, conv, n_it, self.weights_, self.means_, self.covariances_)
|
|
366
|
+
self.lower_bound_, self.converged_, self.n_iter_ = best[0], best[1], best[2]
|
|
367
|
+
self.weights_, self.means_, self.covariances_ = best[3], best[4], best[5]
|
|
368
|
+
self._refresh_precisions()
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
def predict(self, X_np: np.ndarray) -> np.ndarray:
|
|
372
|
+
o = self.ops
|
|
373
|
+
X = o.from_numpy(np.ascontiguousarray(X_np, dtype=np.float32))
|
|
374
|
+
out = []
|
|
375
|
+
for s, e in self._chunks(X.shape[0]):
|
|
376
|
+
out.append(o.to_numpy(o.argmax(self._weighted_log_prob(X[s:e]), 1)))
|
|
377
|
+
return np.concatenate(out).astype(np.int64)
|
|
378
|
+
|
|
379
|
+
def score(self, X_np: np.ndarray) -> float:
|
|
380
|
+
"""Mean log-likelihood per sample."""
|
|
381
|
+
return self._mean_ll(self.ops.from_numpy(np.ascontiguousarray(X_np, dtype=np.float32)))
|
|
382
|
+
|
|
383
|
+
def n_parameters(self, d: int) -> int:
|
|
384
|
+
cov = self.k * d if self.covariance_type == "diag" else self.k * d * (d + 1) // 2
|
|
385
|
+
return int(cov + self.k * d + self.k - 1)
|
|
386
|
+
|
|
387
|
+
def bic(self, X_np) -> float:
|
|
388
|
+
n, d = X_np.shape
|
|
389
|
+
return -2 * self.score(X_np) * n + self.n_parameters(d) * math.log(n)
|
|
390
|
+
|
|
391
|
+
def aic(self, X_np) -> float:
|
|
392
|
+
n, d = X_np.shape
|
|
393
|
+
return -2 * self.score(X_np) * n + 2 * self.n_parameters(d)
|
|
394
|
+
|
|
395
|
+
def state(self) -> dict:
|
|
396
|
+
o = self.ops
|
|
397
|
+
return {
|
|
398
|
+
"n_components": self.k, "covariance_type": self.covariance_type,
|
|
399
|
+
"weights": o.to_numpy(self.weights_), "means": o.to_numpy(self.means_),
|
|
400
|
+
"covariances": o.to_numpy(self.covariances_),
|
|
401
|
+
"converged": self.converged_, "n_iter": self.n_iter_,
|
|
402
|
+
"reg_covar": self.reg_covar, "seed": self.seed,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def save(self, path: str) -> None:
|
|
406
|
+
np.savez(path, **{k: v for k, v in self.state().items()})
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# small backend-neutral helpers
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
def _concat(o, cols):
|
|
413
|
+
if isinstance(o, _TorchOps):
|
|
414
|
+
return o.t.cat(cols, dim=1)
|
|
415
|
+
return np.concatenate(cols, axis=1)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _stack_rows(o, rows):
|
|
419
|
+
if isinstance(o, _TorchOps):
|
|
420
|
+
return o.t.stack(rows, dim=0)
|
|
421
|
+
return np.stack(rows, axis=0)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _minimum(o, a, b):
|
|
425
|
+
if isinstance(o, _TorchOps):
|
|
426
|
+
return o.t.minimum(a, b)
|
|
427
|
+
return np.minimum(a, b)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _one_hot(o, labels, k):
|
|
431
|
+
if isinstance(o, _TorchOps):
|
|
432
|
+
return o.t.nn.functional.one_hot(labels, num_classes=k).to(o.f32)
|
|
433
|
+
out = np.zeros((labels.shape[0], k), dtype=np.float32)
|
|
434
|
+
out[np.arange(labels.shape[0]), labels] = 1.0
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _where_rows(o, mask_np, a, b):
|
|
439
|
+
"""Rows of `a` where mask (numpy bool, length k) is True, else rows of `b`."""
|
|
440
|
+
if isinstance(o, _TorchOps):
|
|
441
|
+
m = o.t.as_tensor(mask_np, device=o.device)[:, None]
|
|
442
|
+
return o.t.where(m, a, b)
|
|
443
|
+
return np.where(mask_np[:, None], a, b)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# convenience functions on an (n, d) embedding array
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
def _has_torch_cuda() -> bool:
|
|
450
|
+
try:
|
|
451
|
+
import torch
|
|
452
|
+
return bool(torch.cuda.is_available())
|
|
453
|
+
except ImportError:
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def gmm(X, n_components: int, covariance_type: str = "diag", backend: str = "auto", seed: int = 0, **kwargs):
|
|
458
|
+
"""Fit a Gaussian mixture to X (n x d) -> (labels, model). backend "auto" = torch on
|
|
459
|
+
a GPU if there is one, else the numpy implementation (same algorithm)."""
|
|
460
|
+
if backend == "auto":
|
|
461
|
+
backend = "torch" if _has_torch_cuda() else "numpy"
|
|
462
|
+
model = GaussianMixtureGPU(n_components, covariance_type=covariance_type, seed=seed,
|
|
463
|
+
backend=backend, **kwargs)
|
|
464
|
+
X = np.ascontiguousarray(X, dtype=np.float32)
|
|
465
|
+
model.fit(X)
|
|
466
|
+
return np.asarray(model.predict(X)), model
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def kmeans(X, n_clusters: int = 10, seed: int = 0, **kwargs):
|
|
470
|
+
"""scikit-learn KMeans on X (n x d) -> (labels, model)."""
|
|
471
|
+
from sklearn.cluster import KMeans
|
|
472
|
+
model = KMeans(n_clusters=n_clusters, random_state=seed, **kwargs).fit(X)
|
|
473
|
+
return model.labels_.astype(np.int64), model
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Datasets over many stored tile / subgraph graphs (spatialrna 0.2's on-disk datasets,
|
|
2
|
+
for zarr stores). Graphs are read lazily, one at a time.
|
|
3
|
+
|
|
4
|
+
from spatialrna.datasets import ZarrGraphDataset
|
|
5
|
+
ds = ZarrGraphDataset.from_sample_store("data/S01.zarr", kind="subgraphs")
|
|
6
|
+
ds = ZarrGraphDataset.from_manifest("data/manifest.json")
|
|
7
|
+
for data in ds: ... # torch_geometric Data per tile / subgraph
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import os.path as osp
|
|
14
|
+
import re
|
|
15
|
+
from typing import List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from torch.utils.data import Dataset
|
|
18
|
+
|
|
19
|
+
from .graph_zarr_io import load_data_zarr, zarr_group_exists
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ZarrGraphDataset(Dataset):
|
|
23
|
+
"""One torch_geometric Data per zarr graph group (tile or subgraph store).
|
|
24
|
+
keys: only these arrays (e.g. ["x", "edge_index"]); None = all."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, paths: Sequence[str], keys: Optional[Sequence[str]] = None):
|
|
27
|
+
self.paths: List[str] = [str(p) for p in paths]
|
|
28
|
+
self.keys = list(keys) if keys else None
|
|
29
|
+
|
|
30
|
+
def __len__(self) -> int:
|
|
31
|
+
return len(self.paths)
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, i: int):
|
|
34
|
+
return load_data_zarr(self.paths[i], keys=self.keys) if self.keys else load_data_zarr(self.paths[i])
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_manifest(cls, manifest_path: str, keys=None) -> "ZarrGraphDataset":
|
|
38
|
+
"""A manifest written by the pipeline: a JSON list of {"path": ...} entries."""
|
|
39
|
+
with open(manifest_path) as f:
|
|
40
|
+
entries = json.load(f)
|
|
41
|
+
return cls([e["path"] if isinstance(e, dict) else e for e in entries], keys)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_sample_store(cls, sample_store: str, kind: str = "tiles", keys=None) -> "ZarrGraphDataset":
|
|
45
|
+
"""Every tile<t> group under <sample>.zarr/tiles (kind="tiles") or /subgraphs,
|
|
46
|
+
in tile order; empty / unfinished groups are skipped."""
|
|
47
|
+
root = osp.join(sample_store, kind)
|
|
48
|
+
names = [n for n in os.listdir(root) if re.match(r"^tile\d+$", n)] if osp.isdir(root) else []
|
|
49
|
+
names.sort(key=lambda n: int(n[4:]))
|
|
50
|
+
paths = [osp.join(root, n) for n in names if zarr_group_exists(osp.join(root, n))]
|
|
51
|
+
return cls(paths, keys)
|