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.
Files changed (38) hide show
  1. spatialrna-0.3.0.dev1/PKG-INFO +34 -0
  2. spatialrna-0.3.0.dev1/README.md +10 -0
  3. spatialrna-0.3.0.dev1/pyproject.toml +40 -0
  4. spatialrna-0.3.0.dev1/src/spatialrna/__init__.py +60 -0
  5. spatialrna-0.3.0.dev1/src/spatialrna/cluster.py +473 -0
  6. spatialrna-0.3.0.dev1/src/spatialrna/datasets.py +51 -0
  7. spatialrna-0.3.0.dev1/src/spatialrna/graph_build.py +350 -0
  8. spatialrna-0.3.0.dev1/src/spatialrna/graph_zarr_io.py +1022 -0
  9. spatialrna-0.3.0.dev1/src/spatialrna/readers.py +313 -0
  10. spatialrna-0.3.0.dev1/src/spatialrna/train_val.py +129 -0
  11. spatialrna-0.3.0.dev1/src/spatialrna/viz.py +326 -0
  12. spatialrna-0.3.0.dev1/src/spatialrna.egg-info/PKG-INFO +34 -0
  13. spatialrna-0.3.0.dev1/src/spatialrna.egg-info/SOURCES.txt +19 -0
  14. spatialrna-0.3.0.dev1/src/spatialrna.egg-info/requires.txt +15 -0
  15. spatialrna-0.3.0.dev1/tests/test_cluster_viz.py +30 -0
  16. spatialrna-0.3.0.dev1/tests/test_readers.py +99 -0
  17. spatialrna-0.3.0.dev1/tests/test_tiling.py +38 -0
  18. spatialrna-0.3.0.dev1/tests/test_zarr_roundtrip.py +133 -0
  19. spatialrna-0.2.0/LICENSE.txt +0 -21
  20. spatialrna-0.2.0/PKG-INFO +0 -66
  21. spatialrna-0.2.0/README.md +0 -43
  22. spatialrna-0.2.0/pyproject.toml +0 -48
  23. spatialrna-0.2.0/spatialrna/__init__.py +0 -1
  24. spatialrna-0.2.0/spatialrna/run_kmeans.py +0 -113
  25. spatialrna-0.2.0/spatialrna/spatialrna.py +0 -352
  26. spatialrna-0.2.0/spatialrna/spatialrna_ondisk.py +0 -74
  27. spatialrna-0.2.0/spatialrna/train_val.py +0 -387
  28. spatialrna-0.2.0/spatialrna/viz.py +0 -188
  29. spatialrna-0.2.0/spatialrna.egg-info/PKG-INFO +0 -66
  30. spatialrna-0.2.0/spatialrna.egg-info/SOURCES.txt +0 -18
  31. spatialrna-0.2.0/spatialrna.egg-info/requires.txt +0 -9
  32. spatialrna-0.2.0/tests/test_data.py +0 -225
  33. spatialrna-0.2.0/tests/test_reload.py +0 -137
  34. spatialrna-0.2.0/tests/test_subgraph.py +0 -98
  35. spatialrna-0.2.0/tests/test_subgraph_mode.py +0 -136
  36. {spatialrna-0.2.0 → spatialrna-0.3.0.dev1}/setup.cfg +0 -0
  37. {spatialrna-0.2.0 → spatialrna-0.3.0.dev1/src}/spatialrna.egg-info/dependency_links.txt +0 -0
  38. {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)