spatialrna 0.3.0.dev3__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.
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/PKG-INFO +1 -1
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/pyproject.toml +1 -1
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/__init__.py +0 -3
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/cluster.py +0 -13
- spatialrna-0.3.0.dev4/src/spatialrna/gene_encoding.py +187 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/graph_build.py +141 -4
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/graph_zarr_io.py +152 -115
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/train_val.py +0 -18
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/viz.py +0 -5
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/PKG-INFO +1 -1
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/SOURCES.txt +2 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/tests/test_cluster_viz.py +0 -4
- spatialrna-0.3.0.dev4/tests/test_parallel_tiles.py +377 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/tests/test_readers.py +0 -4
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/tests/test_tiling.py +0 -4
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/tests/test_zarr_roundtrip.py +0 -16
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/README.md +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/setup.cfg +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/datasets.py +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna/readers.py +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/dependency_links.txt +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/requires.txt +0 -0
- {spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/top_level.txt +0 -0
|
@@ -6,7 +6,7 @@ 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.
|
|
9
|
+
version = "0.3.0.dev4"
|
|
10
10
|
authors = [{ name = "Yan Pan" }, { name = "Ruqian Lyu" }]
|
|
11
11
|
maintainers = [{ name = "Yan Pan", email = "anne.pan@svi.edu.au" }]
|
|
12
12
|
license = { text = "MIT" }
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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):
|
|
350
|
-
one-hot tensor)
|
|
351
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
760
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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=
|
|
802
|
-
edge_index=edge_index,
|
|
803
|
-
trans_id=torch.
|
|
804
|
-
core_mask=torch.
|
|
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
|
|
807
|
-
data_1.pos = torch.
|
|
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
|
|
@@ -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
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spatialrna-0.3.0.dev3 → spatialrna-0.3.0.dev4}/src/spatialrna.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|