spatialrna 0.3.0.dev2__tar.gz → 0.3.0.dev4__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 (23) hide show
  1. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/PKG-INFO +3 -3
  2. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/pyproject.toml +3 -2
  3. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/__init__.py +0 -3
  4. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/cluster.py +0 -13
  5. spatialrna-0.3.0.dev4/src/spatialrna/gene_encoding.py +187 -0
  6. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/graph_build.py +141 -4
  7. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/graph_zarr_io.py +152 -115
  8. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/train_val.py +0 -18
  9. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/viz.py +0 -5
  10. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/PKG-INFO +3 -3
  11. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/SOURCES.txt +2 -0
  12. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/tests/test_cluster_viz.py +0 -4
  13. spatialrna-0.3.0.dev4/tests/test_parallel_tiles.py +377 -0
  14. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/tests/test_readers.py +0 -4
  15. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/tests/test_tiling.py +0 -4
  16. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/tests/test_zarr_roundtrip.py +0 -16
  17. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/README.md +0 -0
  18. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/setup.cfg +0 -0
  19. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/datasets.py +0 -0
  20. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna/readers.py +0 -0
  21. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/dependency_links.txt +0 -0
  22. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/requires.txt +0 -0
  23. {spatialrna-0.3.0.dev2 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialrna
3
- Version: 0.3.0.dev2
3
+ Version: 0.3.0.dev4
4
4
  Summary: A package for spatial RNA graph data processing and analysis.
5
- Author: Ruqian Lyu
6
- Author-email: Anne Pan <anne.pan@svi.edu.au>
5
+ Author: Yan Pan, Ruqian Lyu
6
+ Maintainer-email: Yan Pan <anne.pan@svi.edu.au>
7
7
  License: MIT
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Operating System :: OS Independent
@@ -6,8 +6,9 @@ build-backend = "setuptools.build_meta"
6
6
  name="spatialrna"
7
7
  description = "A package for spatial RNA graph data processing and analysis."
8
8
  readme = "README.md"
9
- version = "0.3.0.dev2"
10
- authors = [{ name = "Anne Pan", email = "anne.pan@svi.edu.au" },{ name = "Ruqian Lyu" }]
9
+ version = "0.3.0.dev4"
10
+ authors = [{ name = "Yan Pan" }, { name = "Ruqian Lyu" }]
11
+ maintainers = [{ name = "Yan Pan", email = "anne.pan@svi.edu.au" }]
11
12
  license = { text = "MIT" }
12
13
  requires-python = ">=3.10"
13
14
  classifiers = [
@@ -6,9 +6,6 @@ any GNN (train / validate / embed) -> clusters -> spatial maps.
6
6
  df = sr.readers.load_transcripts("/path/to/xenium_or_atera_output")
7
7
  from spatialrna import SpatialRNA # tile graphs / subgraphs; needs torch + PyG
8
8
 
9
- The ISTIST pipeline builds on this with its own model (CrossSampleGAT), transcript
10
- annotation, statistics and report.
11
-
12
9
  Submodules (imported on first use, so the light ones need neither torch nor a GPU):
13
10
 
14
11
  readers Xenium / Atera transcripts.zarr.zip -> transcripts
@@ -6,19 +6,6 @@
6
6
 
7
7
  GaussianMixtureGPU -- a Gaussian mixture model (EM) that runs on the GPU with PyTorch.
8
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
9
  """
23
10
  from __future__ import annotations
24
11
 
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import pickle
6
+ import sys
7
+ from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Union
8
+
9
+ import numpy as np
10
+
11
+ PathLike = Union[str, os.PathLike]
12
+ Encoding = Dict[object, object]
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Building
17
+ # ---------------------------------------------------------------------------
18
+ def build_gene_encoding(gene_names: Sequence[str], drop_controls: bool = False) -> Dict[str, int]:
19
+ """{gene: index} in the given order (index = position). Duplicates are an error,
20
+ since two genes sharing an index would be indistinguishable to the model.
21
+ drop_controls: leave out control-probe / blank-codeword names (readers.CONTROL_NAME)."""
22
+ names = [str(g) for g in gene_names]
23
+ if drop_controls:
24
+ from .readers import CONTROL_NAME
25
+ names = [g for g in names if not CONTROL_NAME.match(g)]
26
+ dup = sorted({g for g in names if names.count(g) > 1}) if len(set(names)) != len(names) else []
27
+ if dup:
28
+ raise ValueError(f"duplicate gene names: {dup[:20]}")
29
+ return {g: i for i, g in enumerate(names)}
30
+
31
+
32
+ def cohort_gene_names(cohort_zarr_path: PathLike) -> List[str]:
33
+ """Root attr `gene_names` of a cohort store (what its gene_identity codes index)."""
34
+ import zarr
35
+ root = zarr.open_group(str(cohort_zarr_path), mode="r")
36
+ names = root.attrs.get("gene_names")
37
+ if not names:
38
+ raise ValueError(f"{cohort_zarr_path} has no root attribute 'gene_names'")
39
+ return [str(g) for g in names]
40
+
41
+
42
+ def encoding_from_cohort(cohort_zarr_path: PathLike, drop_controls: bool = False) -> Dict[str, int]:
43
+ """{gene: index} from a cohort store's gene_names. Index = the gene_identity code,
44
+ so stored codes and encoding agree."""
45
+ return build_gene_encoding(cohort_gene_names(cohort_zarr_path), drop_controls=drop_controls)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Saving / loading
50
+ # ---------------------------------------------------------------------------
51
+ def save_gene_encoding(encoding: Mapping, path: PathLike) -> str:
52
+ """Write the encoding atomically (temp file + rename). `.json` -> JSON (index maps
53
+ only; readable without Python), anything else -> pickle (the earlier .pkl format)."""
54
+ path = os.fspath(path)
55
+ enc = dict(encoding)
56
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
57
+ tmp = f"{path}.tmp{os.getpid()}"
58
+ if path.endswith(".json"):
59
+ if not is_index_encoding(enc):
60
+ raise ValueError("a .json gene encoding must map genes to integers (use .pkl, or to_index_encoding)")
61
+ with open(tmp, "w") as fh:
62
+ json.dump({str(k): int(v) for k, v in enc.items()}, fh, indent=0)
63
+ else:
64
+ with open(tmp, "wb") as fh:
65
+ pickle.dump(enc, fh, protocol=4)
66
+ os.replace(tmp, path)
67
+ return path
68
+
69
+
70
+ def load_gene_encoding(path: PathLike) -> Encoding:
71
+ """Read a saved encoding (.json, or a pickle -- only open pickles you trust)."""
72
+ path = os.fspath(path)
73
+ if path.endswith(".json"):
74
+ with open(path) as fh:
75
+ enc = {k: int(v) for k, v in json.load(fh).items()}
76
+ else:
77
+ with open(path, "rb") as fh:
78
+ enc = pickle.load(fh)
79
+ if not isinstance(enc, Mapping) or not enc:
80
+ raise ValueError(f"{path} does not hold a non-empty gene -> index mapping")
81
+ return dict(enc)
82
+
83
+
84
+ def resolve_gene_encoding(encoding: Union[Mapping, PathLike, None]) -> Encoding:
85
+ """A dict is returned as is; a path is loaded."""
86
+ if encoding is None:
87
+ raise ValueError("a gene encoding (dict or path to one) is required")
88
+ if isinstance(encoding, Mapping):
89
+ if not encoding:
90
+ raise ValueError("the gene encoding is empty")
91
+ return dict(encoding)
92
+ return load_gene_encoding(encoding)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Inspecting / converting
97
+ # ---------------------------------------------------------------------------
98
+ def _is_int(v) -> bool:
99
+ return isinstance(v, (int, np.integer)) and not isinstance(v, (bool, np.bool_))
100
+
101
+
102
+ def is_index_encoding(encoding: Mapping) -> bool:
103
+ """True when the values are integers (not one-hot vectors)."""
104
+ return _is_int(next(iter(encoding.values())))
105
+
106
+
107
+ def _as_numpy(v) -> np.ndarray:
108
+ if hasattr(v, "detach"): # torch tensor, without importing torch
109
+ v = v.detach().cpu().numpy()
110
+ return np.asarray(v)
111
+
112
+
113
+ def to_index_encoding(encoding: Mapping) -> Dict[object, int]:
114
+ """One-hot vector values -> their position (argmax). Index maps pass through."""
115
+ if is_index_encoding(encoding):
116
+ return {k: int(v) for k, v in encoding.items()}
117
+ out = {}
118
+ for k, v in encoding.items():
119
+ a = _as_numpy(v).reshape(-1)
120
+ if np.count_nonzero(a) != 1:
121
+ raise ValueError(f"value for {k!r} is not a one-hot vector")
122
+ out[k] = int(np.argmax(a))
123
+ return out
124
+
125
+
126
+ def num_genes(encoding: Mapping) -> int:
127
+ """Width of the model input: max index + 1 (index maps) or the vector length."""
128
+ if is_index_encoding(encoding):
129
+ return int(max(encoding.values())) + 1
130
+ return int(_as_numpy(next(iter(encoding.values()))).size)
131
+
132
+
133
+ def same_encoding(a: Mapping, b: Mapping) -> bool:
134
+ """True when two encodings give every gene the same index."""
135
+ return to_index_encoding(a) == to_index_encoding(b)
136
+
137
+
138
+ def encoding_lut(categories: Sequence, encoding: Mapping,
139
+ gene_names: Optional[Sequence[str]] = None) -> np.ndarray:
140
+ """Feature for each distinct value of the feature column, in the order of
141
+ `categories`: int64 indices (index maps) or a (C, F) array (one-hot maps).
142
+
143
+ Integer categories (a cohort store's gene_identity codes) that are not themselves
144
+ keys are looked up by name: gene_names[code] (the store's root attr gene_names).
145
+ Raises KeyError naming the genes the encoding lacks -- never silently drops them.
146
+ """
147
+ cats = list(categories)
148
+ keys = cats
149
+ if cats and _is_int(cats[0]) and not all(c in encoding for c in cats):
150
+ if gene_names is None:
151
+ raise KeyError("the feature column holds integer codes but the gene encoding is keyed by "
152
+ "gene name -- pass the cohort's gene_names to translate them")
153
+ bad = [c for c in cats if not 0 <= int(c) < len(gene_names)]
154
+ if bad:
155
+ raise KeyError(f"gene codes outside gene_names (0..{len(gene_names) - 1}): {bad[:20]}")
156
+ keys = [gene_names[int(c)] for c in cats]
157
+ missing = [k for k in keys if k not in encoding]
158
+ if missing:
159
+ raise KeyError(f"{len(missing)} gene(s) are not in the gene encoding, e.g. {missing[:20]} -- "
160
+ "rebuild the encoding from the full cohort, or filter these genes at ingest")
161
+ if is_index_encoding(encoding):
162
+ return np.fromiter((int(encoding[k]) for k in keys), dtype=np.int64, count=len(keys))
163
+ rows = [_as_numpy(encoding[k]).reshape(-1) for k in keys]
164
+ return np.stack(rows) if rows else np.empty((0, num_genes(encoding)))
165
+
166
+
167
+ def main(argv: Optional[Iterable[str]] = None) -> None:
168
+ import argparse
169
+ p = argparse.ArgumentParser(description="Write the gene encoding of a cohort store.")
170
+ p.add_argument("cohort_zarr", help="cohort.zarr (root attr gene_names)")
171
+ p.add_argument("out", help="output .pkl (or .json)")
172
+ p.add_argument("--drop-controls", action="store_true", help="leave out control / blank codewords")
173
+ a = p.parse_args(list(argv) if argv is not None else None)
174
+ enc = encoding_from_cohort(a.cohort_zarr, drop_controls=a.drop_controls)
175
+ if os.path.exists(a.out):
176
+ old = load_gene_encoding(a.out)
177
+ if not same_encoding(old, enc):
178
+ sys.exit(f"{a.out} exists with a DIFFERENT encoding ({len(old)} genes vs {len(enc)}); "
179
+ "graphs and models built with it would no longer match. Remove it to rebuild.")
180
+ print(f"{a.out}: unchanged ({len(enc)} genes)")
181
+ return
182
+ save_gene_encoding(enc, a.out)
183
+ print(f"{a.out}: {len(enc)} genes")
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
@@ -10,14 +10,19 @@ sampler), vectorised so whole samples (1M+ transcripts) are fast:
10
10
  fanout (-1 = all neighbours); frontier expansion is vectorised over CSR rows.
11
11
  * link_based_subgraph -- num_sampled_edges anchor edges + k-hop expansion (the old
12
12
  build_subgraph(subgraph_mode="link_based")).
13
+ * plan_tiles / plan_strips + tile_arrays -- which transcripts make up each tile
14
+ (core + padding) and the arrays of one tile graph; map_tiles runs tiles in a
15
+ forked process pool that shares the sample's arrays (SpatialRNA.process(n_procs)).
13
16
 
14
- Conventions (same as before): edge_index rows are (source, target); target is the
15
- query node and source a neighbour within r -- PyG's flow="source_to_target".
16
17
  """
17
18
  from __future__ import annotations
18
19
 
19
20
  import itertools
20
- from typing import Optional, Sequence, Tuple
21
+ import multiprocessing as mp
22
+ import os
23
+ import sys
24
+ import time
25
+ from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
21
26
 
22
27
  import numpy as np
23
28
  from scipy.spatial import cKDTree
@@ -321,7 +326,7 @@ class TilePlan:
321
326
  def plan_tiles(x: np.ndarray, y: np.ndarray, n_tiles: int, mode: str = "grid") -> TilePlan:
322
327
  """Assign every transcript to one of `n_tiles` tiles (mode "grid"; see TilePlan)."""
323
328
  if mode != "grid":
324
- raise ValueError(f"plan_tiles supports mode='grid' (strips are cut in SpatialRNA.process), not {mode!r}")
329
+ raise ValueError(f"plan_tiles supports mode='grid' (strips: plan_strips), not {mode!r}")
325
330
  x = np.asarray(x, dtype=np.float64)
326
331
  y = np.asarray(y, dtype=np.float64)
327
332
  n = len(x)
@@ -348,3 +353,135 @@ def plan_tiles(x: np.ndarray, y: np.ndarray, n_tiles: int, mode: str = "grid") -
348
353
  tile_of[xo[col_cut[c]:col_cut[c + 1]]] = first + c
349
354
  first += cols[r]
350
355
  return TilePlan(x, y, tile_of, n_tiles)
356
+
357
+
358
+ class StripPlan:
359
+ """The original SpatialRNA strips: `n_tiles` equal-WIDTH bands along one axis
360
+ (pandas.cut with bins=n_tiles), padding = transcripts within `pad` below / above
361
+ the band's own min / max along that axis.
362
+
363
+ Same rows, in the same order, as the earlier per-tile pandas code
364
+ (core rows, then left padding, then right padding, each in original row order),
365
+ but every tile is cut from arrays sorted once, not by masking the whole sample.
366
+
367
+ tile_of[i] = strip of transcript i (-1 if its coordinate is NaN).
368
+ """
369
+
370
+ def __init__(self, v: np.ndarray, tile_of: np.ndarray, n_tiles: int):
371
+ # keep the column's own dtype: the padding bounds are compared in it, exactly
372
+ # as the earlier pandas code did (float32 coordinates stay float32)
373
+ self.v = np.asarray(v)
374
+ self.tile_of = np.asarray(tile_of, dtype=np.int64)
375
+ self.n_tiles = n_tiles
376
+ self._vorder = np.argsort(self.v, kind="stable")
377
+ self._vsorted = self.v[self._vorder]
378
+ order = np.argsort(self.tile_of, kind="stable")
379
+ bounds = np.searchsorted(self.tile_of[order], np.arange(n_tiles + 1))
380
+ self._cores = [np.sort(order[bounds[t]:bounds[t + 1]]) for t in range(n_tiles)]
381
+
382
+ def sizes(self) -> np.ndarray:
383
+ return np.array([len(c) for c in self._cores])
384
+
385
+ def members(self, t: int, pad: float) -> Tuple[np.ndarray, np.ndarray]:
386
+ core = self._cores[t]
387
+ if len(core) == 0:
388
+ return core, core
389
+ lo, hi = self.v[core].min(), self.v[core].max()
390
+ vs, vo = self._vsorted, self._vorder
391
+ left = np.sort(vo[np.searchsorted(vs, lo - pad, "left"):np.searchsorted(vs, lo, "left")])
392
+ right = np.sort(vo[np.searchsorted(vs, hi, "right"):np.searchsorted(vs, hi + pad, "right")])
393
+ return core, np.concatenate([left, right])
394
+
395
+
396
+ def plan_strips(v: np.ndarray, n_tiles: int) -> StripPlan:
397
+ """Strips along `v` with exactly pandas.cut(v, bins=n_tiles)'s bin edges."""
398
+ import pandas as pd
399
+
400
+ codes = pd.cut(pd.Series(np.asarray(v)), bins=n_tiles, labels=False)
401
+ tile_of = codes.fillna(-1).to_numpy().astype(np.int64)
402
+ return StripPlan(v, tile_of, n_tiles)
403
+
404
+
405
+ def tile_arrays(core: np.ndarray, padding: np.ndarray, codes: np.ndarray, lut: np.ndarray,
406
+ graph_pos: np.ndarray, r: float, max_num_neighbors: Optional[int],
407
+ store_pos: Optional[np.ndarray] = None, workers: int = -1,
408
+ method: str = "auto") -> Dict[str, np.ndarray]:
409
+ """The arrays of one tile graph, from whole-sample arrays (read-only):
410
+
411
+ node order: core transcripts, then padding (as members() returns them)
412
+ x lut[codes[node]] (int64 gene index, or a feature row per node)
413
+ edge_index radius graph over graph_pos[node] (scipy KDTree, radius_graph_kdtree;
414
+ method "auto" | "ball" | "knn", see there)
415
+ trans_id the node's row in the sample (= its index in the cohort store)
416
+ core_mask True for the core part
417
+ pos store_pos[node] as float32, if store_pos is given
418
+ """
419
+ node = np.concatenate([np.asarray(core, dtype=np.int64), np.asarray(padding, dtype=np.int64)])
420
+ out = {
421
+ "x": lut[codes[node]],
422
+ "edge_index": radius_graph_kdtree(graph_pos[node], r=r, max_num_neighbors=max_num_neighbors,
423
+ workers=workers, method=method),
424
+ "trans_id": node,
425
+ "core_mask": np.r_[np.ones(len(core), bool), np.zeros(len(padding), bool)],
426
+ }
427
+ if store_pos is not None:
428
+ out["pos"] = np.asarray(store_pos[node], dtype=np.float32)
429
+ return out
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Forked process pool over tiles
434
+ # ---------------------------------------------------------------------------
435
+ _POOL_TASK: List[Optional[Callable]] = [None] # set in the parent right before forking
436
+
437
+
438
+ def _pool_call(tile):
439
+ return tile, _POOL_TASK[0](tile)
440
+
441
+
442
+ def available_cpus() -> int:
443
+ """CPUs this process may use (SLURM / taskset aware), not the whole node."""
444
+ try:
445
+ return len(os.sched_getaffinity(0))
446
+ except AttributeError:
447
+ return os.cpu_count() or 1
448
+
449
+
450
+ def map_tiles(task: Callable, tiles: Iterable[int], n_procs: int = 1,
451
+ initializer: Optional[Callable] = None, log: bool = True) -> Dict[int, object]:
452
+ """task(tile) for every tile; returns {tile: result}.
453
+
454
+ n_procs > 1: a 'fork' process pool. `task` (with whatever it closes over -- the
455
+ whole sample's arrays, the tile plan) is never pickled: it is set as a module
456
+ global just before the pool forks, so every worker sees the parent's memory
457
+ copy-on-write. Only tile ids go to the workers and only task's (small) return
458
+ values come back. Numpy arrays are shared without copying as long as nobody
459
+ writes to them; keep the shared data in numpy arrays, not in Python objects
460
+ (touching an object's refcount copies its memory page).
461
+
462
+ `initializer` runs once in each worker after the fork (e.g. to reset a library's
463
+ background thread). Errors in a worker are re-raised here. Without the 'fork'
464
+ start method (Windows; macOS works) this falls back to running serially.
465
+ """
466
+ tiles = list(tiles)
467
+ n_procs = max(1, min(int(n_procs or 1), len(tiles)))
468
+ if n_procs > 1 and "fork" not in mp.get_all_start_methods():
469
+ print("WARNING: no 'fork' start method on this platform -- running tiles serially.",
470
+ file=sys.stderr, flush=True)
471
+ n_procs = 1
472
+ if n_procs == 1:
473
+ return {t: task(t) for t in tiles}
474
+
475
+ out: Dict[int, object] = {}
476
+ t0 = time.time()
477
+ _POOL_TASK[0] = task
478
+ try:
479
+ with mp.get_context("fork").Pool(n_procs, initializer=initializer) as pool:
480
+ for i, (tile, res) in enumerate(pool.imap_unordered(_pool_call, tiles, chunksize=1), 1):
481
+ out[tile] = res
482
+ if log:
483
+ print(f"[{i}/{len(tiles)}] tile {tile} done ({time.time() - t0:.0f}s elapsed, "
484
+ f"{n_procs} processes)", flush=True)
485
+ finally:
486
+ _POOL_TASK[0] = None
487
+ return out
@@ -1,51 +1,13 @@
1
1
  """
2
2
  SpatialRNA dataset backed by zarr storage.
3
3
 
4
- Why zarr instead of torch.save/.pt for the processed tile graphs:
5
- - Chunked + compressed on-disk arrays: a tile's `x` / `edge_index` can be
6
- read without ever fully deserializing the tile through pickle, and only
7
- the chunks that are actually indexed get decompressed.
8
- - Cloud/object-store friendly: a zarr group is a directory of small,
9
- independently addressable files, so tiles can live on S3/GCS behind
10
- fsspec without a custom loader.
11
- - Cross-tool interop: other steps in the pipeline (QC notebooks, R via
12
- Rarr, dask) can read the same tiles without a torch_geometric
13
- dependency.
14
-
15
- This module keeps `SpatialRNA` as a torch_geometric `InMemoryDataset` for
16
- raw/processed path bookkeeping (`raw_paths`, `processed_dir`, `download`,
17
- etc.), but replaces the two places that used torch's own (de)serialization
18
- (`self.save` / `self.load`, and `torch.load` inside `generate_subgraph`)
19
- with explicit zarr I/O. Because each tile file always holds exactly one
20
- `Data` object, `len()`/`get()` are overridden directly instead of relying on
21
- `InMemoryDataset`'s collate/slice machinery, which assumed a torch .pt blob.
22
-
23
- Requires: zarr>=2.16 (either the 2.x or 3.x major version -- see the
24
- `_ZARR_MAJOR` shim below, which is what actually differs between them:
25
- store construction, `create_dataset` vs `create_array`, and where the
26
- compressor codec classes live).
27
-
28
- --- Canonical module (post-consolidation) ---------------------------------
29
-
30
- This used to live as a standalone `scripts/spatialrna_zarr.py` (imported via
31
- sys.path hacks) with a second, incompatible zarr I/O layer duplicated under
32
- `step1_old_zarr/spatialrna/graph_zarr_io.py` (a `tile_store_path` /
33
- `read_tile_graph` / `store_exists` / `_write_arrays` / `_read_arrays` API
34
- used only by the niche-inference step). Both wrote/read the exact same
35
- on-disk tile layout (`x`, `edge_index`, `trans_id`, `core_mask` arrays in a
36
- zarr group), so rather than keep two parallel implementations, this module
37
- is now the single source of truth: the `SpatialRNA` class and
38
- `save_data_zarr`/`load_data_zarr` below are unchanged, and a thin
39
- backward-compatible API (`tile_store_path`, `store_exists`,
40
- `read_tile_graph`, `_write_arrays`, `_read_arrays`, etc. -- see the bottom
41
- of this file) is provided purely so `niche_zarr_io.py` and the
42
- inference/GMM scripts can import `spatialrna.graph_zarr_io` without any
43
- change to their own logic. `step1_old_zarr/` is no longer used.
4
+ Requires: zarr>=2.16 (either the 2.x or 3.x major version).
44
5
  """
45
6
  from torch_geometric.data import InMemoryDataset, Data
46
- from .graph_build import plan_tiles, radius_graph_kdtree
7
+ from .graph_build import plan_tiles, plan_strips, tile_arrays, map_tiles, available_cpus
8
+ from .gene_encoding import resolve_gene_encoding, encoding_lut
47
9
  from torch_geometric.loader import LinkNeighborLoader
48
- from typing import Callable, Optional, List, Dict, Any
10
+ from typing import Callable, Optional, List, Dict, Any, Mapping, Union
49
11
  import os
50
12
  import os.path as osp
51
13
  import pandas as pd
@@ -108,6 +70,43 @@ else:
108
70
  )
109
71
 
110
72
 
73
+ def _after_fork_in_child() -> None:
74
+ """Run in each forked tile worker before it touches torch or zarr.
75
+
76
+ A forked child gets a copy of the parent's memory but none of its threads. Two
77
+ libraries keep state that points at parent threads:
78
+ * zarr-python 3 runs all I/O on a background asyncio loop thread, created the
79
+ first time the parent read anything; the child would submit work to a loop
80
+ nobody runs and hang. Dropping zarr's references makes it start a new loop
81
+ in the child on first use.
82
+ * Blosc's internal thread pool -> turned off in the child (zarr writes are
83
+ already parallel across workers).
84
+ torch is limited to one thread per worker for the same reason (and to avoid
85
+ oversubscription; the workers only wrap arrays, they compute nothing in torch).
86
+ """
87
+ try:
88
+ torch.set_num_threads(1)
89
+ except Exception:
90
+ pass
91
+ try:
92
+ import numcodecs.blosc as _blosc
93
+ _blosc.use_threads = False
94
+ except Exception:
95
+ pass
96
+ if _ZARR_MAJOR >= 3:
97
+ try:
98
+ from zarr.core import sync as _zsync
99
+ for name in ("loop", "iothread"):
100
+ ref = getattr(_zsync, name, None)
101
+ if isinstance(ref, list) and ref:
102
+ ref[0] = None
103
+ for name in ("_lock", "_executor"):
104
+ if hasattr(_zsync, name):
105
+ setattr(_zsync, name, None)
106
+ except Exception:
107
+ pass
108
+
109
+
111
110
  # Rows-per-chunk default. Individual arrays fall back to their own length if
112
111
  # the tile is smaller than this (chunks can't exceed an array's own extent).
113
112
  _DEFAULT_CHUNK_ROWS = 200_000
@@ -346,9 +345,28 @@ class SpatialRNA(InMemoryDataset):
346
345
  load_tile_id (int): Select one tile to load.
347
346
  process_tile_ids (List[int]): Select tiles to process (tile graph
348
347
  + subgraph generation).
349
- one_hot_encoding (dict): Maps gene names to an integer (or a
350
- one-hot tensor). Must be identical across every sample analyzed
351
- together.
348
+ one_hot_encoding (dict or path): The gene encoding: gene -> integer index
349
+ (or -> one-hot tensor), or the path of a saved one (.pkl / .json; see
350
+ `spatialrna.gene_encoding`). Must be identical across every sample
351
+ analyzed together. A name-keyed encoding also works on an integer
352
+ `feature_col` (the cohort store's `gene_identity`): codes are mapped
353
+ to names through the cohort root attr `gene_names`.
354
+ n_procs (int): Tiles built in parallel (default 1 = serial). The sample is
355
+ read and tiled once; N forked worker processes then share it
356
+ copy-on-write. Peak memory ~ the sample's arrays + N tiles in flight,
357
+ so size N by the largest tile (radius graph + edge list), not by cores.
358
+ Output is identical to n_procs=1.
359
+ radius_method (str): How the radius graph is built -- always on a scipy
360
+ KDTree (graph_build.radius_graph_kdtree; torch_cluster is not used).
361
+ "ball": every neighbour within radius_r, then the nearest
362
+ max_num_neighbors (exact, ties by index). "knn": a k-nearest query
363
+ bounded by radius_r (same graph up to ties at equal distance; much
364
+ faster on dense whole-transcriptome data). "auto" (default): knn when
365
+ most nodes exceed max_num_neighbors, else ball. Stored per tile as attr
366
+ `radius_method`.
367
+ graph_workers (int, optional): Threads per radius-graph query (scipy
368
+ cKDTree `workers`). Default: all CPUs when n_procs=1, else the job's
369
+ CPUs divided by n_procs (no oversubscription).
352
370
  transform, pre_transform: as in `InMemoryDataset`.
353
371
  force_reload (bool): Whether to re-process the dataset.
354
372
  force_resample (bool): Whether to re-process the tile dataset to
@@ -399,7 +417,10 @@ class SpatialRNA(InMemoryDataset):
399
417
  feature_col="gene",
400
418
  max_num_neighbors: int = 500,
401
419
  process_tile_ids: List[int] = None,
402
- one_hot_encoding: dict = None,
420
+ one_hot_encoding: Union[Mapping, str, os.PathLike] = None,
421
+ n_procs: int = 1,
422
+ graph_workers: Optional[int] = None,
423
+ radius_method: str = "auto",
403
424
  subgraph_mode: str = "link_based",
404
425
  num_sampled_edges: int = 5000,
405
426
  num_seed_nodes: int = 5000,
@@ -434,7 +455,12 @@ class SpatialRNA(InMemoryDataset):
434
455
  self.load_tile_id = load_tile_id
435
456
  self.pad_hops = pad_hops
436
457
  self.max_num_neighbors = max_num_neighbors
437
- self.one_hot_encoding = one_hot_encoding
458
+ assert one_hot_encoding is not None, "one_hot_encoding (a gene encoding dict or its path) is required"
459
+ self.one_hot_encoding = resolve_gene_encoding(one_hot_encoding)
460
+ self.n_procs = max(1, int(n_procs or 1))
461
+ self.graph_workers = graph_workers
462
+ assert radius_method in ("auto", "ball", "knn"), f"radius_method: auto, ball or knn, not {radius_method!r}"
463
+ self.radius_method = radius_method
438
464
  self.seed = seed
439
465
  self.feature_col = feature_col
440
466
  self.num_sampled_edges = num_sampled_edges
@@ -445,7 +471,6 @@ class SpatialRNA(InMemoryDataset):
445
471
  self.max_subgraph_edges = max_subgraph_edges
446
472
 
447
473
  assert tile_by_dim in [dim_x, dim_y]
448
- assert one_hot_encoding is not None
449
474
 
450
475
  self.dim_x = dim_x
451
476
  self.dim_y = dim_y
@@ -549,6 +574,9 @@ class SpatialRNA(InMemoryDataset):
549
574
  def get_padding(self, log: bool, tile_df, full_df, padding: float):
550
575
  r"""Padding-area transcripts surrounding a tile's core area.
551
576
 
577
+ Kept for callers of the earlier API; process() now uses
578
+ graph_build.StripPlan.members, which selects the same rows.
579
+
552
580
  Args:
553
581
  log: whether to print logs.
554
582
  tile_df: the data.frame for the current tile.
@@ -694,7 +722,23 @@ class SpatialRNA(InMemoryDataset):
694
722
  out_path = osp.join(self.subgraph_dir, f"tile{s_id}")
695
723
  save_data_zarr(out_path, subgraph_g)
696
724
 
697
- def process(self) -> None:
725
+ # --- tile graphs ------------------------------------------------------
726
+ def _cohort_gene_names(self) -> Optional[List[str]]:
727
+ try:
728
+ names = _zopen_read(self.cohort_zarr_path).attrs.get("gene_names")
729
+ except Exception:
730
+ names = None
731
+ return [str(g) for g in names] if names else None
732
+
733
+ def process(self, n_procs: Optional[int] = None) -> None:
734
+ """Build tiles/tile<t> for every tile in process_tile_ids.
735
+
736
+ The sample is read once and turned into flat arrays (gene index per
737
+ transcript, coordinates) and a tile plan; each tile is then cut from those
738
+ arrays. With n_procs > 1 (default: the n_procs given at construction) the
739
+ tiles run in forked worker processes that share these arrays.
740
+ """
741
+ n_procs = self.n_procs if n_procs is None else max(1, int(n_procs))
698
742
  if self.log:
699
743
  print("Processing raw file ", self.raw_paths[0])
700
744
  print("To create processed files ", self.processed_paths)
@@ -718,6 +762,7 @@ class SpatialRNA(InMemoryDataset):
718
762
  if use_z:
719
763
  raw_columns = sorted(set(raw_columns) | {self.dim_z})
720
764
  coord_cols = [self.dim_x, self.dim_y] + ([self.dim_z] if use_z else [])
765
+ graph_cols = coord_cols if self.radius_3d else [self.dim_x, self.dim_y]
721
766
  raw_data = load_raw_transcripts_zarr(self.raw_paths[0], columns=raw_columns)
722
767
  if self.log:
723
768
  print(f"Raw data shape {raw_data.shape[0]}")
@@ -731,7 +776,21 @@ class SpatialRNA(InMemoryDataset):
731
776
  if self.log:
732
777
  print(f"Building 3-D radius graphs on ({self.dim_x}, {self.dim_y}, {self.dim_z})")
733
778
 
734
- raw_data["trans_id"] = np.arange(raw_data.shape[0], dtype=np.int64)
779
+ # --- whole-sample arrays, built once and shared by every tile ----------
780
+ # gene feature: one encoding lookup per distinct gene, then a per-transcript code
781
+ cat = pd.Categorical(raw_data[self.feature_col].to_numpy())
782
+ codes = np.asarray(cat.codes, dtype=np.int64)
783
+ if (codes < 0).any():
784
+ raise ValueError(f"{self.sample_name}: {int((codes < 0).sum())} transcripts have no "
785
+ f"'{self.feature_col}' value")
786
+ categories = list(cat.categories)
787
+ gene_names = (self._cohort_gene_names()
788
+ if categories and isinstance(categories[0], (int, np.integer)) else None)
789
+ lut = encoding_lut(categories, self.one_hot_encoding, gene_names=gene_names)
790
+ # row i = transcript i = trans_id i (row index into cohort.zarr/<sample>)
791
+ graph_pos = np.ascontiguousarray(raw_data[graph_cols].to_numpy(np.float64))
792
+ store_pos = (np.ascontiguousarray(raw_data[coord_cols].to_numpy(np.float32))
793
+ if self.store_coords else None)
735
794
  padding = self.pad_hops * self.radius_r
736
795
 
737
796
  if self.tile_mode == "grid":
@@ -742,81 +801,59 @@ class SpatialRNA(InMemoryDataset):
742
801
  print(f"grid tiling: {plan.n_tiles} tiles, {sz.min():,}-{sz.max():,} core transcripts each")
743
802
  for tile in self.process_tile_ids:
744
803
  assert 0 <= tile < plan.n_tiles, f"tile {tile} is not in 0:{plan.n_tiles - 1}"
745
- core, pad_idx = plan.members(tile, padding)
746
- tile_df = pd.concat([raw_data.iloc[core].assign(core_mask=True),
747
- raw_data.iloc[pad_idx].assign(core_mask=False)])
748
- if self.log:
749
- print(f"tile {tile}: core {len(core):,}, core plus padding {tile_df.shape[0]:,}")
750
- self._write_tile(tile, tile_df, coord_cols)
751
- return
752
-
753
- raw_data["tiles_range"] = pd.cut(
754
- raw_data[self.tile_by_dim],
755
- bins=self.num_tiles,
756
- labels=["tile" + str(x) for x in range(self.num_tiles)],
757
- )
804
+ else:
805
+ plan = plan_strips(raw_data[self.tile_by_dim].to_numpy(), self.num_tiles)
806
+ sizes = plan.sizes()
807
+ for tile in self.process_tile_ids:
808
+ assert 0 <= tile < plan.n_tiles and sizes[tile] > 0, (
809
+ f"tile {tile}: the requested tile contains no transcripts, likely due to the "
810
+ "tile id not being in the range 0:(num_tiles-1) -- or an empty strip "
811
+ "(a gap in the tissue); graph.tile_mode: grid never makes empty tiles"
812
+ )
813
+ del raw_data, cat # only numpy arrays are shared with the workers
758
814
 
759
- for tile in self.process_tile_ids:
760
- tile_df = raw_data[raw_data.tiles_range == ("tile" + str(tile))]
761
- if self.log:
762
- print("tile core area shape ", tile_df.shape)
763
- assert tile_df.shape[0] > 0, (
764
- "The requested tile contains no transcripts, likely due to the "
765
- "tile id not being in the range 0:(num_tiles-1) -- or an empty strip "
766
- "(a gap in the tissue); graph.tile_mode: grid never makes empty tiles"
767
- )
768
- tile_df = tile_df.assign(core_mask=True)
769
- padding_df = self.get_padding(
770
- log=self.log,
771
- tile_df=tile_df,
772
- full_df=raw_data,
773
- padding=padding,
774
- )
775
- tile_df = pd.concat([tile_df, padding_df])
776
- if self.log:
777
- print("core area plus paddings, shape ", tile_df.shape)
778
- self._write_tile(tile, tile_df, coord_cols)
779
-
780
- def _write_tile(self, tile, tile_df, coord_cols) -> None:
781
- """Radius graph over one tile's core + padding transcripts -> tiles/tile<t>."""
782
- t0 = time.time()
783
- first = next(iter(self.one_hot_encoding.values()))
784
- if isinstance(first, (int, np.integer)):
785
- # one lookup per distinct gene, not per transcript
786
- cat = pd.Categorical(tile_df[self.feature_col].to_numpy())
787
- lut = np.fromiter((self.one_hot_encoding[c] for c in cat.categories), dtype=np.int64,
788
- count=len(cat.categories))
789
- feature_x = torch.from_numpy(lut[cat.codes])
815
+ if self.graph_workers:
816
+ workers = int(self.graph_workers)
790
817
  else:
791
- feature_x = torch.stack([self.one_hot_encoding[x] for x in tile_df[self.feature_col]])
792
- graph_cols = coord_cols if self.radius_3d else [self.dim_x, self.dim_y]
793
- # scipy cKDTree radius graph (graph_build.py): same builder as the whole-sample
794
- # training graphs, so tile (inference) and sample (training) graphs agree.
795
- graph_pos = np.asarray(tile_df[graph_cols], dtype=np.float64)
796
- edge_index = torch.as_tensor(
797
- radius_graph_kdtree(graph_pos, r=self.radius_r, max_num_neighbors=self.max_num_neighbors),
798
- dtype=torch.long)
818
+ workers = -1 if n_procs == 1 else max(1, available_cpus() // n_procs)
819
+ if self.log and n_procs > 1:
820
+ print(f"{len(self.process_tile_ids)} tiles on {n_procs} processes, "
821
+ f"{workers} radius-query thread(s) each", flush=True)
822
+
823
+ def build_one(tile):
824
+ t0 = time.time()
825
+ core, pad_idx = plan.members(tile, padding)
826
+ arrays = tile_arrays(core, pad_idx, codes, lut, graph_pos, self.radius_r,
827
+ self.max_num_neighbors, store_pos=store_pos, workers=workers,
828
+ method=self.radius_method)
829
+ t1 = time.time()
830
+ self._save_tile(tile, arrays, graph_cols, coord_cols)
831
+ n, n_edges = len(arrays["trans_id"]), arrays["edge_index"].shape[1]
832
+ if self.log:
833
+ print(f"tile {tile}: core {len(core):,}, core plus padding {n:,}; {n_edges:,} edges "
834
+ f"(mean degree {n_edges / max(n, 1):.1f}), graph {t1 - t0:.0f}s, "
835
+ f"write {time.time() - t1:.0f}s", flush=True)
836
+ return {"core": int(len(core)), "nodes": int(n), "edges": int(n_edges)}
837
+
838
+ map_tiles(build_one, self.process_tile_ids, n_procs=n_procs,
839
+ initializer=_after_fork_in_child, log=self.log)
799
840
 
841
+ def _save_tile(self, tile, arrays: Dict[str, np.ndarray], graph_cols, coord_cols) -> None:
842
+ """One tile's arrays (graph_build.tile_arrays) -> tiles/tile<t>."""
800
843
  data_1 = Data(
801
- x=feature_x,
802
- edge_index=edge_index,
803
- trans_id=torch.tensor(tile_df.trans_id.to_numpy()),
804
- core_mask=torch.tensor(tile_df.core_mask.to_numpy()),
844
+ x=torch.from_numpy(arrays["x"]),
845
+ edge_index=torch.from_numpy(arrays["edge_index"]),
846
+ trans_id=torch.from_numpy(arrays["trans_id"]),
847
+ core_mask=torch.from_numpy(arrays["core_mask"]),
805
848
  )
806
- if self.store_coords:
807
- data_1.pos = torch.tensor(np.asarray(tile_df[coord_cols], dtype=np.float32))
808
-
849
+ if "pos" in arrays:
850
+ data_1.pos = torch.from_numpy(arrays["pos"])
809
851
  out_path = osp.join(self.processed_dir, f"tile{tile}")
810
- t1 = time.time()
811
852
  save_data_zarr(out_path, data_1)
812
- if self.log:
813
- n = int(tile_df.shape[0])
814
- print(f"tile {tile}: {n:,} nodes, {edge_index.size(1):,} edges "
815
- f"(mean degree {edge_index.size(1) / max(n, 1):.1f}), graph {t1 - t0:.0f}s, "
816
- f"write {time.time() - t1:.0f}s", flush=True)
817
853
  meta = _zopen_append(out_path)
818
854
  meta.attrs["graph_dims"] = list(graph_cols)
819
855
  meta.attrs["tile_mode"] = self.tile_mode
856
+ meta.attrs["radius_method"] = self.radius_method
820
857
  if self.store_coords:
821
858
  meta.attrs["pos_columns"] = list(coord_cols)
822
859
 
@@ -1,21 +1,3 @@
1
- """Model-agnostic GNN training and inference on spatial transcript graphs
2
- (spatialrna 0.2's train_val, for the zarr tile / subgraph stores).
3
-
4
- Bring any PyG model with the signature ``model(x, edge_index) -> embeddings``; genes
5
- are integer codes in ``data.x`` and are one-hot encoded batch by batch.
6
-
7
- from spatialrna import train_val as tv
8
- opt = torch.optim.Adam(model.parameters(), lr=1e-3)
9
- for epoch in range(5):
10
- loss, acc = tv.train_link_prediction(model, graphs, opt, device, num_genes)
11
- val_acc = tv.validate_link_prediction(model, val_graphs, device, num_genes)
12
- emb, tx_id = tv.embed_tile(model, tile, device, num_genes) # core transcripts only
13
-
14
- `graphs` is any iterable of torch_geometric Data (e.g. spatialrna.datasets.ZarrGraphDataset).
15
- Link prediction: positive pairs are graph edges (``edge_label_index`` if the graph has
16
- one, else a random sample of edges), negatives are random partners; the score is the
17
- dot product of the two embeddings.
18
- """
19
1
  from __future__ import annotations
20
2
 
21
3
  import random
@@ -1,10 +1,5 @@
1
1
  """Spatial maps of transcript labels (clusters / niches / cell types), as pixel images.
2
2
 
3
- Like spatialrna 0.2's plot_pixel: the tissue is cut into px x px um squares and each
4
- square is coloured by the most frequent label among its transcripts (counts smoothed
5
- over ~1 neighbouring square first), so every transcript is used and maps stay readable
6
- at any size. Dots are available too.
7
-
8
3
  from spatialrna import viz
9
4
  fig = viz.pixel_map(x, y, labels) # one panel
10
5
  d = viz.make_data(x, y, label, conf, label_names, niche) # two label sets
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialrna
3
- Version: 0.3.0.dev2
3
+ Version: 0.3.0.dev4
4
4
  Summary: A package for spatial RNA graph data processing and analysis.
5
- Author: Ruqian Lyu
6
- Author-email: Anne Pan <anne.pan@svi.edu.au>
5
+ Author: Yan Pan, Ruqian Lyu
6
+ Maintainer-email: Yan Pan <anne.pan@svi.edu.au>
7
7
  License: MIT
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Operating System :: OS Independent
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  src/spatialrna/__init__.py
4
4
  src/spatialrna/cluster.py
5
5
  src/spatialrna/datasets.py
6
+ src/spatialrna/gene_encoding.py
6
7
  src/spatialrna/graph_build.py
7
8
  src/spatialrna/graph_zarr_io.py
8
9
  src/spatialrna/readers.py
@@ -14,6 +15,7 @@ src/spatialrna.egg-info/dependency_links.txt
14
15
  src/spatialrna.egg-info/requires.txt
15
16
  src/spatialrna.egg-info/top_level.txt
16
17
  tests/test_cluster_viz.py
18
+ tests/test_parallel_tiles.py
17
19
  tests/test_readers.py
18
20
  tests/test_tiling.py
19
21
  tests/test_zarr_roundtrip.py
@@ -1,7 +1,3 @@
1
- """spatialrna.cluster and spatialrna.viz on synthetic data (no torch needed).
2
-
3
- pytest tests/test_cluster_viz.py
4
- """
5
1
  import matplotlib
6
2
  matplotlib.use("Agg")
7
3
  import numpy as np
@@ -0,0 +1,377 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import time
5
+ import types
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ HERE = os.path.dirname(os.path.abspath(__file__))
11
+ _ROOT = os.path.dirname(HERE)
12
+ # repo layout (src/spatialrna) or a flat checkout; an installed spatialrna otherwise
13
+ sys.path.insert(0, os.path.join(_ROOT, "src") if os.path.isdir(os.path.join(_ROOT, "src", "spatialrna")) else _ROOT)
14
+
15
+
16
+ def _have(*mods):
17
+ import importlib.util
18
+ return all(importlib.util.find_spec(m) is not None for m in mods)
19
+
20
+
21
+ HAVE_STACK = _have("torch", "torch_geometric", "zarr")
22
+
23
+ from spatialrna import graph_build as gb # noqa: E402
24
+ from spatialrna import gene_encoding as ge # noqa: E402
25
+
26
+ RNG = np.random.default_rng(7)
27
+ N_GENES = 300
28
+ GENE_NAMES = [f"G{i:03d}" for i in range(N_GENES)]
29
+
30
+
31
+ def synthetic_sample(n=60_000):
32
+ """Two blobs with a gap between them (an empty strip), float32 like the cohort
33
+ store, some duplicated positions, dense enough that some nodes hit the cap."""
34
+ a = RNG.normal([0, 0], [60, 40], size=(n // 2, 2))
35
+ b = RNG.normal([400, 300], [50, 50], size=(n - n // 2, 2))
36
+ xy = np.vstack([a, b]).astype(np.float32)
37
+ xy[:200] = xy[200:400] # duplicated positions
38
+ z = RNG.uniform(0, 10, size=n).astype(np.float32)
39
+ code = RNG.integers(0, N_GENES, size=n).astype(np.int32)
40
+ return pd.DataFrame({"x_location": xy[:, 0], "y_location": xy[:, 1], "z_location": z,
41
+ "gene_identity": code})
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # The earlier implementation (graph_zarr_io.py of 2026-10-23), verbatim logic
46
+ # ---------------------------------------------------------------------------
47
+ def old_tiles(df, enc, num_tiles, tile_mode, tile_by_dim, radius_r, pad_hops, max_nn,
48
+ coord_cols, radius_3d, store_coords, tile_ids, feature_col="gene"):
49
+ raw_data = df.copy()
50
+ raw_data["trans_id"] = np.arange(raw_data.shape[0], dtype=np.int64)
51
+ padding = pad_hops * radius_r
52
+ out = {}
53
+
54
+ def write_tile(tile, tile_df):
55
+ first = next(iter(enc.values()))
56
+ if isinstance(first, (int, np.integer)):
57
+ cat = pd.Categorical(tile_df[feature_col].to_numpy())
58
+ lut = np.fromiter((enc[c] for c in cat.categories), dtype=np.int64, count=len(cat.categories))
59
+ feature_x = lut[cat.codes]
60
+ else:
61
+ feature_x = np.stack([enc[x] for x in tile_df[feature_col]])
62
+ graph_cols = coord_cols if radius_3d else coord_cols[:2]
63
+ graph_pos = np.asarray(tile_df[graph_cols], dtype=np.float64)
64
+ ei = gb.radius_graph_kdtree(graph_pos, r=radius_r, max_num_neighbors=max_nn)
65
+ d = {"x": feature_x, "edge_index": ei, "trans_id": tile_df.trans_id.to_numpy(),
66
+ "core_mask": tile_df.core_mask.to_numpy()}
67
+ if store_coords:
68
+ d["pos"] = np.asarray(tile_df[coord_cols], dtype=np.float32)
69
+ out[tile] = d
70
+
71
+ if tile_mode == "grid":
72
+ plan = gb.plan_tiles(raw_data[coord_cols[0]].to_numpy(), raw_data[coord_cols[1]].to_numpy(),
73
+ num_tiles, mode="grid")
74
+ for tile in tile_ids:
75
+ core, pad_idx = plan.members(tile, padding)
76
+ tile_df = pd.concat([raw_data.iloc[core].assign(core_mask=True),
77
+ raw_data.iloc[pad_idx].assign(core_mask=False)])
78
+ write_tile(tile, tile_df)
79
+ return out
80
+
81
+ raw_data["tiles_range"] = pd.cut(raw_data[tile_by_dim], bins=num_tiles,
82
+ labels=["tile" + str(x) for x in range(num_tiles)])
83
+ for tile in tile_ids:
84
+ tile_df = raw_data[raw_data.tiles_range == ("tile" + str(tile))]
85
+ if tile_df.shape[0] == 0:
86
+ continue
87
+ tile_df = tile_df.assign(core_mask=True)
88
+ lm, rm = tile_df[tile_by_dim].min(), tile_df[tile_by_dim].max()
89
+ fl = (raw_data[tile_by_dim] >= (lm - padding)) & (raw_data[tile_by_dim] < lm)
90
+ fr = (raw_data[tile_by_dim] > rm) & (raw_data[tile_by_dim] <= (rm + padding))
91
+ pad_df = pd.concat([raw_data[fl].assign(core_mask=False), raw_data[fr].assign(core_mask=False)])
92
+ write_tile(tile, pd.concat([tile_df, pad_df]))
93
+ return out
94
+
95
+
96
+ def new_tiles(df, enc, num_tiles, tile_mode, tile_by_dim, radius_r, pad_hops, max_nn,
97
+ coord_cols, radius_3d, store_coords, tile_ids, feature_col, gene_names=None):
98
+ cat = pd.Categorical(df[feature_col].to_numpy())
99
+ codes = np.asarray(cat.codes, dtype=np.int64)
100
+ lut = ge.encoding_lut(list(cat.categories), enc, gene_names=gene_names)
101
+ graph_cols = coord_cols if radius_3d else coord_cols[:2]
102
+ gpos = np.ascontiguousarray(df[graph_cols].to_numpy(np.float64))
103
+ spos = np.ascontiguousarray(df[coord_cols].to_numpy(np.float32)) if store_coords else None
104
+ plan = (gb.plan_tiles(df[coord_cols[0]].to_numpy(), df[coord_cols[1]].to_numpy(), num_tiles)
105
+ if tile_mode == "grid" else gb.plan_strips(df[tile_by_dim].to_numpy(), num_tiles))
106
+ pad = pad_hops * radius_r
107
+ out = {}
108
+ for t in tile_ids:
109
+ core, p = plan.members(t, pad)
110
+ if len(core) == 0:
111
+ continue
112
+ out[t] = gb.tile_arrays(core, p, codes, lut, gpos, radius_r, max_nn, store_pos=spos)
113
+ return out
114
+
115
+
116
+ def same(a, b, label):
117
+ assert a.keys() == b.keys(), (label, sorted(a), sorted(b))
118
+ for t in a:
119
+ assert a[t].keys() == b[t].keys(), (label, t, a[t].keys(), b[t].keys())
120
+ for k in a[t]:
121
+ x, y = np.asarray(a[t][k]), np.asarray(b[t][k])
122
+ assert x.dtype == y.dtype, (label, t, k, x.dtype, y.dtype)
123
+ assert x.shape == y.shape and np.array_equal(x, y), (label, t, k, x.shape, y.shape)
124
+
125
+
126
+ def test_core_matches_old_code():
127
+ df = synthetic_sample()
128
+ order = RNG.permutation(N_GENES) # encoding index != gene code
129
+ enc_named = {g: int(order[i]) for i, g in enumerate(GENE_NAMES)}
130
+ df_named = df.assign(gene=np.asarray(GENE_NAMES, dtype=object)[df.gene_identity])
131
+ eye = np.eye(N_GENES, dtype=np.float32)
132
+ enc_onehot = {g: eye[order[i]] for i, g in enumerate(GENE_NAMES)}
133
+ cols3 = ["x_location", "y_location", "z_location"]
134
+ cases = [
135
+ dict(num_tiles=12, tile_mode="strips", tile_by_dim="y_location", max_nn=20, radius_3d=False, store=True),
136
+ dict(num_tiles=7, tile_mode="strips", tile_by_dim="x_location", max_nn=None, radius_3d=False, store=False),
137
+ dict(num_tiles=25, tile_mode="grid", tile_by_dim="y_location", max_nn=20, radius_3d=False, store=True),
138
+ dict(num_tiles=9, tile_mode="grid", tile_by_dim="y_location", max_nn=15, radius_3d=True, store=True),
139
+ ]
140
+ n_cmp = 0
141
+ for c in cases:
142
+ common = dict(num_tiles=c["num_tiles"], tile_mode=c["tile_mode"], tile_by_dim=c["tile_by_dim"],
143
+ radius_r=6.0, pad_hops=2, max_nn=c["max_nn"], coord_cols=cols3,
144
+ radius_3d=c["radius_3d"], store_coords=c["store"], tile_ids=list(range(c["num_tiles"])))
145
+ old = old_tiles(df_named, enc_named, **common)
146
+ # new code on the cohort's INTEGER codes + name-keyed encoding (translated via gene_names)
147
+ new = new_tiles(df, enc_named, feature_col="gene_identity", gene_names=GENE_NAMES, **common)
148
+ same(old, new, f"index {c}")
149
+ old1 = old_tiles(df_named, enc_onehot, **common)
150
+ new1 = new_tiles(df_named, enc_onehot, feature_col="gene", **common)
151
+ same(old1, new1, f"one-hot {c}")
152
+ n_cmp += len(old) + len(old1)
153
+ empty = [t for t in range(12) if t not in old_tiles(df_named, enc_named, 12, "strips", "y_location", 6.0,
154
+ 2, 20, cols3, False, False, list(range(12)))]
155
+ print(f" core == old pandas code: {n_cmp} tiles compared (4 configs x 2 encodings; "
156
+ f"empty strips skipped by both: {empty})")
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # map_tiles
161
+ # ---------------------------------------------------------------------------
162
+ def _private_mb():
163
+ with open("/proc/self/smaps_rollup") as fh:
164
+ kb = sum(int(l.split()[1]) for l in fh if l.startswith(("Private_Dirty", "Private_Clean")))
165
+ return kb / 1024
166
+
167
+
168
+ def test_pool_equals_serial_and_shares_memory():
169
+ df = synthetic_sample(120_000)
170
+ cat = pd.Categorical(df.gene_identity.to_numpy())
171
+ codes = np.asarray(cat.codes, dtype=np.int64)
172
+ lut = np.arange(N_GENES, dtype=np.int64)
173
+ gpos = np.ascontiguousarray(df[["x_location", "y_location"]].to_numpy(np.float64))
174
+ plan = gb.plan_tiles(gpos[:, 0], gpos[:, 1], 16)
175
+ big = np.ones(400 * 2**20 // 8) # 400 MB the workers read but never write
176
+ tmp = tempfile.mkdtemp()
177
+
178
+ def task(t):
179
+ core, p = plan.members(t, 12.0)
180
+ a = gb.tile_arrays(core, p, codes, lut, gpos, 6.0, 20, workers=1)
181
+ s = float(big[::4096].sum()) # read the shared array
182
+ np.savez(os.path.join(tmp, f"tile{t}.npz"), **a)
183
+ return {"edges": a["edge_index"].shape[1], "private_mb": _private_mb(), "s": s}
184
+
185
+ t0 = time.time()
186
+ serial = {t: gb.tile_arrays(*plan.members(t, 12.0), codes, lut, gpos, 6.0, 20, workers=1) for t in range(16)}
187
+ t_serial = time.time() - t0
188
+ t0 = time.time()
189
+ res = gb.map_tiles(task, range(16), n_procs=2, log=False)
190
+ t_par = time.time() - t0
191
+ par = {t: dict(np.load(os.path.join(tmp, f"tile{t}.npz"))) for t in range(16)}
192
+ same(serial, par, "pool vs serial")
193
+ worst = max(r["private_mb"] for r in res.values())
194
+ assert worst < 200, f"a worker holds {worst:.0f} MB private memory -- shared arrays were copied"
195
+ assert gb._POOL_TASK[0] is None
196
+ print(f" pool (2 procs) == serial for 16 tiles; worker private memory <= {worst:.0f} MB "
197
+ f"with a 400 MB + sample shared in the parent; serial {t_serial:.1f}s (graphs only) "
198
+ f"vs pool {t_par:.1f}s (graphs + writes)")
199
+
200
+ def bad(t):
201
+ if t == 3:
202
+ raise RuntimeError("tile 3 failed")
203
+ return t
204
+ try:
205
+ gb.map_tiles(bad, range(6), n_procs=2, log=False)
206
+ raise AssertionError("worker error was swallowed")
207
+ except RuntimeError as e:
208
+ assert "tile 3" in str(e)
209
+ assert gb.map_tiles(lambda t: t * 2, [4], n_procs=8) == {4: 8} # n_procs capped by #tiles
210
+ print(" worker errors propagate; n_procs capped at the number of tiles")
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # The real SpatialRNA.process() with stand-in torch / PyG / zarr
215
+ # ---------------------------------------------------------------------------
216
+ def _install_stubs():
217
+ torch = types.ModuleType("torch")
218
+ torch.from_numpy = lambda a: a
219
+ torch.set_num_threads = lambda n: None
220
+ torch.is_tensor = lambda v: False
221
+ torch.long, torch.bool = "long", "bool"
222
+ torch.dtype = object
223
+ torch.Tensor = np.ndarray
224
+ sys.modules["torch"] = torch
225
+
226
+ pyg = types.ModuleType("torch_geometric")
227
+ data = types.ModuleType("torch_geometric.data")
228
+
229
+ class Data(dict):
230
+ def __init__(self, **kw):
231
+ super().__init__(**kw)
232
+
233
+ def __setattr__(self, k, v):
234
+ self[k] = v
235
+
236
+ class InMemoryDataset:
237
+ @property
238
+ def raw_paths(self):
239
+ return [os.path.join(self.raw_dir, f) for f in self.raw_file_names]
240
+
241
+ @property
242
+ def processed_paths(self):
243
+ return [os.path.join(self.processed_dir, f) for f in self.processed_file_names]
244
+
245
+ def __init__(self, root, *a, **k):
246
+ self.root = root
247
+ self.process()
248
+
249
+ data.Data, data.InMemoryDataset = Data, InMemoryDataset
250
+ loader = types.ModuleType("torch_geometric.loader")
251
+ loader.LinkNeighborLoader = object
252
+ sys.modules.update({"torch_geometric": pyg, "torch_geometric.data": data,
253
+ "torch_geometric.loader": loader})
254
+ zarr = types.ModuleType("zarr")
255
+ zarr.__version__ = "3.1.0"
256
+ codecs = types.ModuleType("zarr.codecs")
257
+ codecs.BloscCodec = lambda **k: None
258
+ zarr.codecs = codecs
259
+ sys.modules.update({"zarr": zarr, "zarr.codecs": codecs})
260
+
261
+
262
+ def test_spatialrna_process():
263
+ """Without torch / PyG / zarr only: stand-ins replace them in sys.modules, which must
264
+ never happen in an environment that has the real ones (see the _real test)."""
265
+ if HAVE_STACK:
266
+ print(" skipped (real torch / PyG / zarr installed: test_spatialrna_process_real runs instead)")
267
+ return
268
+ _install_stubs()
269
+ from spatialrna import graph_zarr_io as gz
270
+ df = synthetic_sample(80_000)
271
+ order = RNG.permutation(N_GENES)
272
+ enc = {g: int(order[i]) for i, g in enumerate(GENE_NAMES)}
273
+ tmp = tempfile.mkdtemp()
274
+ enc_path = ge.save_gene_encoding(enc, os.path.join(tmp, "gene_encoding.pkl"))
275
+
276
+ gz.load_raw_transcripts_zarr = lambda path, columns=None: df[columns].copy()
277
+
278
+ class Attrs:
279
+ def __init__(self):
280
+ self.attrs = {"gene_names": GENE_NAMES}
281
+
282
+ def array_keys(self):
283
+ return list(df.columns)
284
+ gz._zopen_read = lambda path: Attrs()
285
+
286
+ results = {}
287
+ for n_procs in (1, 2):
288
+ out = os.path.join(tmp, f"S1_{n_procs}.zarr")
289
+
290
+ def save_tile(self, tile, arrays, graph_cols, coord_cols, out=out):
291
+ os.makedirs(out, exist_ok=True)
292
+ np.savez(os.path.join(out, f"tile{tile}.npz"), **arrays)
293
+ gz.SpatialRNA._save_tile = save_tile
294
+ gz.SpatialRNA(root=out, sample_name="S1", cohort_zarr_path=tmp, radius_r=6.0,
295
+ dim_x="x_location", dim_y="y_location", dim_z="z_location", tile_mode="grid", tile_by_dim="y_location",
296
+ store_coords=True, num_tiles=20, process_tile_ids=list(range(20)),
297
+ feature_col="gene_identity", max_num_neighbors=20,
298
+ one_hot_encoding=enc_path, n_procs=n_procs, load_type="blank", log=False)
299
+ results[n_procs] = {t: dict(np.load(os.path.join(out, f"tile{t}.npz"))) for t in range(20)}
300
+ same(results[1], results[2], "SpatialRNA.process n_procs 1 vs 2")
301
+ # gene index = encoding of gene_names[code], not the raw code
302
+ t0 = results[1][0]
303
+ exp = np.array([enc[GENE_NAMES[c]] for c in df.gene_identity.to_numpy()[t0["trans_id"]]])
304
+ assert np.array_equal(t0["x"], exp)
305
+ # every transcript is core in exactly one tile
306
+ core_ids = np.concatenate([r["trans_id"][r["core_mask"]] for r in results[1].values()])
307
+ assert np.array_equal(np.sort(core_ids), np.arange(len(df)))
308
+ print(" SpatialRNA.process(): n_procs=1 == n_procs=2 (20 grid tiles, 3-D pos); encoding loaded "
309
+ "from .pkl path; gene_identity codes mapped through gene_names; every transcript core once")
310
+
311
+
312
+ def test_spatialrna_process_real():
313
+ """End to end with the real stack: cohort.zarr -> SpatialRNA tiles with n_procs=1 and
314
+ n_procs=2 (zarr written from forked workers) must be identical."""
315
+ if not HAVE_STACK:
316
+ print(" skipped (needs torch, torch_geometric and zarr)")
317
+ return
318
+ import zarr
319
+ from spatialrna import graph_zarr_io as gz
320
+ df = synthetic_sample(80_000)
321
+ tmp = tempfile.mkdtemp()
322
+ cohort = os.path.join(tmp, "cohort.zarr")
323
+ zarr.open_group(cohort, mode="w").attrs["gene_names"] = GENE_NAMES
324
+ gz.save_raw_transcripts_zarr(os.path.join(cohort, "S1"), df)
325
+ order = RNG.permutation(N_GENES)
326
+ enc_path = ge.save_gene_encoding({g: int(order[i]) for i, g in enumerate(GENE_NAMES)},
327
+ os.path.join(tmp, "gene_encoding.pkl"))
328
+ out = {}
329
+ for n_procs in (1, 2):
330
+ store = os.path.join(tmp, f"run{n_procs}", "S1.zarr")
331
+ gz.ensure_sample_store(store, "S1")
332
+ t0 = time.time()
333
+ gz.SpatialRNA(root=store, sample_name="S1", cohort_zarr_path=cohort, radius_r=6.0,
334
+ dim_x="x_location", dim_y="y_location", tile_by_dim="y_location", dim_z="z_location",
335
+ tile_mode="grid", store_coords=True, num_tiles=12, process_tile_ids=list(range(12)),
336
+ feature_col="gene_identity", max_num_neighbors=20, one_hot_encoding=enc_path,
337
+ n_procs=n_procs, load_type="blank", log=False)
338
+ out[n_procs] = {t: {k: np.asarray(v[:]) for k, v in zarr.open_group(
339
+ os.path.join(store, "tiles", f"tile{t}"), mode="r").arrays()} for t in range(12)}
340
+ print(f" n_procs={n_procs}: 12 tiles in {time.time() - t0:.1f}s")
341
+ same(out[1], out[2], "real SpatialRNA n_procs 1 vs 2")
342
+ print(" real zarr + torch: n_procs=1 == n_procs=2 (forked workers wrote every tile)")
343
+
344
+
345
+ def test_gene_encoding():
346
+ tmp = tempfile.mkdtemp()
347
+ enc = ge.build_gene_encoding(["CD3E", "KRT8", "NegControlProbe_1", "COL1A1"], drop_controls=False)
348
+ assert enc == {"CD3E": 0, "KRT8": 1, "NegControlProbe_1": 2, "COL1A1": 3}
349
+ for ext in ("pkl", "json"):
350
+ p = ge.save_gene_encoding(enc, os.path.join(tmp, f"e.{ext}"))
351
+ assert ge.load_gene_encoding(p) == enc
352
+ assert ge.resolve_gene_encoding(enc) == enc and ge.num_genes(enc) == 4
353
+ eye = np.eye(4, dtype=np.float32)
354
+ onehot = {g: eye[i] for g, i in enc.items()}
355
+ assert ge.to_index_encoding(onehot) == enc and ge.same_encoding(onehot, enc) and ge.num_genes(onehot) == 4
356
+ ge.save_gene_encoding(onehot, os.path.join(tmp, "oh.pkl")) # old-style one-hot pkl
357
+ assert ge.same_encoding(ge.load_gene_encoding(os.path.join(tmp, "oh.pkl")), enc)
358
+ for bad, msg in ((lambda: ge.build_gene_encoding(["A", "B", "A"]), "duplicate"),
359
+ (lambda: ge.encoding_lut(["CD3E", "MS4A1"], enc), "MS4A1"),
360
+ (lambda: ge.encoding_lut([0, 7], enc, gene_names=list(enc)), "outside"),
361
+ (lambda: ge.encoding_lut([0, 1], enc), "gene_names"),
362
+ (lambda: ge.save_gene_encoding(onehot, os.path.join(tmp, "x.json")), "integers")):
363
+ try:
364
+ bad()
365
+ raise AssertionError(f"no error for {msg}")
366
+ except (ValueError, KeyError) as e:
367
+ assert msg in str(e), (msg, e)
368
+ assert list(ge.encoding_lut([3, 0], enc, gene_names=list(enc))) == [3, 0]
369
+ print(" gene_encoding: build / .pkl + .json round trip / one-hot -> index / clear errors")
370
+
371
+
372
+ if __name__ == "__main__":
373
+ for f in (test_gene_encoding, test_core_matches_old_code, test_pool_equals_serial_and_shares_memory,
374
+ test_spatialrna_process, test_spatialrna_process_real):
375
+ print(f.__name__)
376
+ f()
377
+ print("ALL PASSED")
@@ -1,7 +1,3 @@
1
- """spatialrna.readers on tiny Xenium- and Atera-shaped transcripts.zarr.zip files.
2
-
3
- pytest tests/test_readers.py
4
- """
5
1
  import json
6
2
  import os
7
3
 
@@ -1,7 +1,3 @@
1
- """spatialrna.graph_build.plan_tiles: balanced grid tiles (numpy + scipy only).
2
-
3
- pytest tests/test_tiling.py
4
- """
5
1
  import numpy as np
6
2
  from scipy.spatial import cKDTree
7
3
 
@@ -1,19 +1,3 @@
1
- """
2
- Smoke test for spatialrna.graph_zarr_io's save/load round-trip on zarr 3.x.
3
-
4
- Run (with the `spatialrna` package installed -- `pip install -e .` from the
5
- repo root) BEFORE pointing the dataset/training scripts at real data:
6
-
7
- pytest tests/test_zarr_roundtrip.py
8
- # or standalone:
9
- python tests/test_zarr_roundtrip.py
10
-
11
- It builds a tiny fake tile (10 nodes, a handful of edges, a bool mask),
12
- writes it with `save_data_zarr`, reads it back with `load_data_zarr`, and
13
- asserts every tensor matches exactly (dtype and values). It also checks
14
- `zarr_group_exists` and the `keys=` partial-read path used by the training
15
- script (reading only `x`/`edge_index` without touching `core_mask`).
16
- """
17
1
  import shutil
18
2
  import tempfile
19
3
  import os.path as osp