spatial-aim 1.0.0__py3-none-any.whl

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 (76) hide show
  1. aim/__init__.py +47 -0
  2. aim/__main__.py +6 -0
  3. aim/adata_schema.py +80 -0
  4. aim/aggregation.py +118 -0
  5. aim/aim_config.py +76 -0
  6. aim/analysis/__init__.py +13 -0
  7. aim/analysis/analysis.py +72 -0
  8. aim/analysis/biology.py +205 -0
  9. aim/analysis/confidence.py +49 -0
  10. aim/analysis/ksweep.py +184 -0
  11. aim/analysis/loading.py +70 -0
  12. aim/analysis/onehot.py +41 -0
  13. aim/analysis/reconstruction.py +131 -0
  14. aim/analysis/topology.py +142 -0
  15. aim/analysis/utils.py +16 -0
  16. aim/cli.py +371 -0
  17. aim/clustering.py +242 -0
  18. aim/data/__init__.py +0 -0
  19. aim/data/prepare/__init__.py +0 -0
  20. aim/data/prepare/build_imaging_pairs.py +397 -0
  21. aim/data/prepare/build_xenium_breast_pair.py +310 -0
  22. aim/data/prepare/convertTsvToCsv.py +39 -0
  23. aim/data/prepare/convert_csv_to_h5ad.py +217 -0
  24. aim/data/prepare/createOnlyHVGDataset.py +69 -0
  25. aim/data/prepare/createSmallerDataset.py +102 -0
  26. aim/data/prepare/normalize_gene_names.py +56 -0
  27. aim/data/prepare/perturb_sc_noise.py +107 -0
  28. aim/data/prepare/register_st_patches.py +180 -0
  29. aim/data/prepare/split_st_into_islands.py +551 -0
  30. aim/data/prepare/split_st_into_patches.py +240 -0
  31. aim/data/prepare/subset_st_hvg.py +43 -0
  32. aim/data/validate/__init__.py +35 -0
  33. aim/data/validate/common.py +141 -0
  34. aim/data/validate/runner.py +335 -0
  35. aim/data/validate/validate_pair.py +53 -0
  36. aim/data/validate/validate_sc.py +103 -0
  37. aim/data/validate/validate_st.py +74 -0
  38. aim/gui/__init__.py +8 -0
  39. aim/gui/__main__.py +77 -0
  40. aim/gui/app.py +1784 -0
  41. aim/gui/compute.py +106 -0
  42. aim/gui/data_access.py +292 -0
  43. aim/gui/render.py +1679 -0
  44. aim/gui/scaffold.py +129 -0
  45. aim/gui/widgets.py +675 -0
  46. aim/io.py +196 -0
  47. aim/mapping/__init__.py +14 -0
  48. aim/mapping/base.py +67 -0
  49. aim/mapping/confidence.py +51 -0
  50. aim/mapping/nearest_centroid.py +33 -0
  51. aim/mapping/reference.py +156 -0
  52. aim/mapping/wann.py +208 -0
  53. aim/metrics/__init__.py +26 -0
  54. aim/metrics/biology.py +69 -0
  55. aim/metrics/cossim.py +127 -0
  56. aim/metrics/kselection.py +272 -0
  57. aim/metrics/onehot.py +54 -0
  58. aim/metrics/reconstruction.py +73 -0
  59. aim/metrics/topology.py +62 -0
  60. aim/reference_aligners/__init__.py +0 -0
  61. aim/reference_aligners/environment_dot.yml +23 -0
  62. aim/reference_aligners/environment_tacco.yml +28 -0
  63. aim/reference_aligners/environment_tangram.yml +16 -0
  64. aim/reference_aligners/registry.py +350 -0
  65. aim/reference_aligners/run_dot.R +172 -0
  66. aim/reference_aligners/run_dot.py +116 -0
  67. aim/reference_aligners/run_tacco.py +143 -0
  68. aim/reference_aligners/run_tangram.py +210 -0
  69. aim/sweep.py +261 -0
  70. aim/tree.py +86 -0
  71. spatial_aim-1.0.0.dist-info/METADATA +1005 -0
  72. spatial_aim-1.0.0.dist-info/RECORD +76 -0
  73. spatial_aim-1.0.0.dist-info/WHEEL +5 -0
  74. spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
  75. spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
  76. spatial_aim-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,397 @@
1
+ """
2
+ Ingest three imaging-based spatial <-> scRNA pairs harvested from the Li et al. 2022
3
+ Nature Methods benchmark into the database:
4
+
5
+ ob dataset 12 mouse olfactory bulb seqFISH+ (Eng 2019) x Drop-seq (GSE148360)
6
+ mtg dataset 13 human middle temporal gyrus ISS (SpaceTx) x Allen Human MTG Smart-seq (Hodge 2019)
7
+ cortex dataset 14 mouse cortex seqFISH+ (Eng 2019) x Allen Mouse V1/ALM Smart-seq (Tasic 2018)
8
+
9
+ Produces scRNA/<name>.h5ad + ST/<name>.h5ad (database h5ad contract: raw counts,
10
+ uppercase unique var, obs['cellType'] on scRNA, obsm['spatial'] on ST) and appends a
11
+ row to scRNA/index.csv, ST/index.csv and pairs.csv (originals backed up to *.bak).
12
+
13
+ seqFISH/seqFISH+ spatial data is tiled into multiple fields of view with per-FOV local
14
+ coordinates; we grid-offset the FOVs so the section is one non-overlapping spatial map
15
+ and keep the FOV id in obs so it can be re-split later.
16
+
17
+ Usage:
18
+ python -m data_preparation.build_imaging_pairs --pair ob --dl-dir <scratch/dl> [--data-root ...] [--dry-run]
19
+ python -m data_preparation.build_imaging_pairs --pair mtg --dl-dir <scratch/dl> [...]
20
+ python -m data_preparation.build_imaging_pairs --pair cortex --dl-dir <scratch/dl> [...]
21
+ """
22
+
23
+ import argparse
24
+ import csv
25
+ import logging
26
+ import shutil
27
+ import sys
28
+ import zipfile
29
+ from pathlib import Path
30
+
31
+ import anndata as ad
32
+ import numpy as np
33
+ import pandas as pd
34
+ import scipy.sparse as sp
35
+
36
+ from aim.data_preparation.build_xenium_breast_pair import (
37
+ _uppercase_and_collapse,
38
+ _append_row,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ DEFAULT_DATA_ROOT = Path(r"/01_Datasets")
44
+
45
+ META = {
46
+ "ob": dict(
47
+ did="12",
48
+ name="12_mouse-olfactory-bulb",
49
+ tissue="Mouse olfactory bulb",
50
+ sc_autor="Brann et al",
51
+ sc_tech="Drop-seq",
52
+ sc_quelle="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE148360",
53
+ sc_link="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE148360",
54
+ st_autor="Eng et al",
55
+ st_tech="seqFISH+",
56
+ st_quelle="https://www.nature.com/articles/s41586-019-1049-y",
57
+ st_link="https://github.com/CaiGroup/seqFISH-PLUS",
58
+ featured="Li2022",
59
+ match="-",
60
+ ),
61
+ "mtg": dict(
62
+ did="12",
63
+ name="12_human-mtg",
64
+ tissue="Human middle temporal gyrus",
65
+ sc_autor="Hodge et al",
66
+ sc_tech="Smart-seq",
67
+ sc_quelle="https://www.nature.com/articles/s41586-019-1506-7",
68
+ sc_link="https://portal.brain-map.org/atlases-and-data/rnaseq/human-mtg-smart-seq",
69
+ st_autor="SpaceTx consortium",
70
+ st_tech="ISS",
71
+ st_quelle="https://www.nature.com/articles/s41592-022-01480-9",
72
+ st_link="https://github.com/spacetx-spacejam/data",
73
+ featured="Li2022",
74
+ match="same_consortium",
75
+ ),
76
+ "cortex": dict(
77
+ did="14",
78
+ name="14_mouse-cortex",
79
+ tissue="Mouse cortex (VISp/ALM)",
80
+ sc_autor="Tasic et al",
81
+ sc_tech="Smart-seq",
82
+ sc_quelle="https://www.nature.com/articles/s41586-018-0654-5",
83
+ sc_link="https://portal.brain-map.org/atlases-and-data/rnaseq/mouse-v1-and-alm-smart-seq",
84
+ st_autor="Eng et al",
85
+ st_tech="seqFISH+",
86
+ st_quelle="https://www.nature.com/articles/s41586-019-1049-y",
87
+ st_link="https://github.com/CaiGroup/seqFISH-PLUS",
88
+ featured="Li2022",
89
+ match="-",
90
+ ),
91
+ }
92
+
93
+
94
+ # --------------------------------------------------------------------------- #
95
+ # helpers
96
+ # --------------------------------------------------------------------------- #
97
+ def _grid_offset_fov(coords: np.ndarray, fov: np.ndarray) -> np.ndarray:
98
+ """Lay per-FOV local coordinates onto a padded grid so tiles do not overlap."""
99
+ coords = coords.astype(float)
100
+ fovs = np.unique(fov)
101
+ ncol = int(np.ceil(np.sqrt(len(fovs))))
102
+ tile_w = (coords[:, 0].max() - coords[:, 0].min()) * 1.1
103
+ tile_h = (coords[:, 1].max() - coords[:, 1].min()) * 1.1
104
+ out = coords.copy()
105
+ for k, f in enumerate(fovs):
106
+ r, c = divmod(k, ncol)
107
+ m = fov == f
108
+ out[m, 0] = coords[m, 0] - coords[m, 0].min() + c * tile_w
109
+ out[m, 1] = coords[m, 1] - coords[m, 1].min() + r * tile_h
110
+ return out.astype(np.float32)
111
+
112
+
113
+ def _finalize_sc(adata: ad.AnnData) -> ad.AnnData:
114
+ adata.X = sp.csr_matrix(adata.X).astype(np.float32)
115
+ adata = _uppercase_and_collapse(adata)
116
+ adata.obs_names = [str(b) for b in adata.obs_names]
117
+ adata.obs_names_make_unique()
118
+ return adata
119
+
120
+
121
+ def _load_allen(zip_paths: list[Path], region_tags: list[str]) -> ad.AnnData:
122
+ """Load Allen Smart-seq region zip(s): exon+intron summed, cells x gene-symbols,
123
+ obs['cellType']=cluster. Detects whether the matrix row key is symbol or entrez.
124
+
125
+ Memory-safe: extracts the 4 members to disk, then streams exon+intron in gene
126
+ chunks summing into one preallocated int32 array (avoids pandas int64 blow-up)."""
127
+ parts = []
128
+ for zp, tag in zip(zip_paths, region_tags):
129
+ logger.info(" reading Allen zip %s", zp.name)
130
+ tmp = zp.parent / f"allen_tmp_{tag}"
131
+ tmp.mkdir(exist_ok=True)
132
+ with zipfile.ZipFile(zp) as z:
133
+ names = z.namelist()
134
+ pick = lambda suf: next(n for n in names if n.endswith(suf)) # noqa: E731
135
+ for suf in (
136
+ "genes-rows.csv",
137
+ "samples-columns.csv",
138
+ "exon-matrix.csv",
139
+ "intron-matrix.csv",
140
+ ):
141
+ out = tmp / suf
142
+ if not out.exists() or out.stat().st_size == 0:
143
+ with z.open(pick(suf)) as src, open(out, "wb") as dst:
144
+ shutil.copyfileobj(src, dst, length=1 << 24)
145
+
146
+ grows = pd.read_csv(tmp / "genes-rows.csv")
147
+ scols = pd.read_csv(tmp / "samples-columns.csv")
148
+ cells = pd.read_csv(
149
+ tmp / "exon-matrix.csv", index_col=0, nrows=0
150
+ ).columns.to_numpy()
151
+ n_genes, n_cells = len(grows), len(cells)
152
+ X = np.empty((n_genes, n_cells), dtype=np.int32)
153
+ gene_key = np.empty(n_genes, dtype=object)
154
+ pos = 0
155
+ ex_it = pd.read_csv(
156
+ tmp / "exon-matrix.csv", index_col=0, dtype=np.int32, chunksize=4000
157
+ )
158
+ in_it = pd.read_csv(
159
+ tmp / "intron-matrix.csv", index_col=0, dtype=np.int32, chunksize=4000
160
+ )
161
+ for ce, ci in zip(ex_it, in_it):
162
+ k = ce.shape[0]
163
+ X[pos : pos + k] = ce.to_numpy() + ci.to_numpy()
164
+ gene_key[pos : pos + k] = ce.index.to_numpy().astype(str)
165
+ pos += k
166
+ X, gene_key = X[:pos], gene_key[:pos]
167
+ shutil.rmtree(tmp, ignore_errors=True)
168
+
169
+ # map matrix row key -> gene symbol
170
+ sym_col = "gene_symbol" if "gene_symbol" in grows.columns else "gene"
171
+ best, key_col = -1, None
172
+ for c in grows.columns:
173
+ ov = np.isin(gene_key, grows[c].astype(str).to_numpy()).mean()
174
+ if ov > best:
175
+ best, key_col = ov, c
176
+ logger.info(
177
+ " gene key column=%s (match=%.2f) -> symbol column=%s",
178
+ key_col,
179
+ best,
180
+ sym_col,
181
+ )
182
+ id2sym = dict(zip(grows[key_col].astype(str), grows[sym_col].astype(str)))
183
+ var_syms = [id2sym.get(str(g), str(g)) for g in gene_key]
184
+
185
+ scols = scols.set_index(scols.columns[0])
186
+ clusters = (
187
+ scols.reindex([str(c) for c in cells])["cluster"].astype(str).to_numpy()
188
+ )
189
+
190
+ a = ad.AnnData(
191
+ X=sp.csr_matrix(X.T).astype(np.float32),
192
+ obs=pd.DataFrame(
193
+ {"cellType": clusters, "region": tag},
194
+ index=[f"{tag}_{c}" for c in cells],
195
+ ),
196
+ var=pd.DataFrame(index=var_syms),
197
+ )
198
+ parts.append(a)
199
+
200
+ if len(parts) > 1:
201
+ adata = ad.concat(parts, join="outer")
202
+ adata.X = sp.csr_matrix(np.nan_to_num(adata.X.toarray())).astype(np.float32)
203
+ else:
204
+ adata = parts[0]
205
+ # drop cells without a real cluster label
206
+ bad = {"nan", "NA", "", "none", "low quality", "Low Quality"}
207
+ keep = ~pd.Series(adata.obs["cellType"].astype(str).to_numpy()).isin(bad).to_numpy()
208
+ adata = adata[keep].copy()
209
+ return adata
210
+
211
+
212
+ # --------------------------------------------------------------------------- #
213
+ # per-pair builders
214
+ # --------------------------------------------------------------------------- #
215
+ def build_ob(dl: Path) -> tuple[ad.AnnData, ad.AnnData]:
216
+ sd = dl / "seqfish" / "sourcedata"
217
+ counts = pd.read_csv(sd / "ob_counts.csv") # cells x genes
218
+ cen = pd.read_csv(sd / "ob_cellcentroids.csv") # FoV, Cell ID, X, Y
219
+ ann = pd.read_csv(
220
+ dl / "seqfish_ann" / "OB_cell_type_annotations.csv"
221
+ ) # index, louvain
222
+ st = ad.AnnData(
223
+ X=sp.csr_matrix(counts.to_numpy(dtype=np.float32)),
224
+ var=pd.DataFrame(index=counts.columns.astype(str)),
225
+ )
226
+ st.obs_names = [f"ob_{i}" for i in range(st.n_obs)]
227
+ st.obs["fov"] = cen["Field of View"].to_numpy()
228
+ st.obs["louvain"] = ann["louvain"].to_numpy()
229
+ st.obsm["spatial"] = _grid_offset_fov(
230
+ cen[["X", "Y"]].to_numpy(), cen["Field of View"].to_numpy()
231
+ )
232
+ st = _uppercase_and_collapse(st)
233
+
234
+ logger.info(" reading OB scRNA (genes x cells)")
235
+ gep = pd.read_csv(dl / "ob_scrna_counts.csv.gz", index_col=0) # genes x cells
236
+ meta = pd.read_csv(dl / "meta_datta.csv.gz", index_col=0) # barcode -> Cell_class
237
+ keep = [b for b in gep.columns if b in meta.index]
238
+ sc = ad.AnnData(
239
+ X=sp.csr_matrix(gep[keep].T.to_numpy(dtype=np.float32)),
240
+ var=pd.DataFrame(index=gep.index.astype(str)),
241
+ )
242
+ sc.obs_names = keep
243
+ sc.obs["cellType"] = meta.reindex(keep)["Cell_class"].astype(str).to_numpy()
244
+ sc.obs["louvain"] = meta.reindex(keep)["seurat_clusters"].astype(str).to_numpy()
245
+ sc = _finalize_sc(sc)
246
+ return sc, st
247
+
248
+
249
+ def build_cortex(dl: Path) -> tuple[ad.AnnData, ad.AnnData]:
250
+ sd = dl / "seqfish" / "sourcedata"
251
+ counts = pd.read_csv(sd / "cortex_svz_counts.csv")
252
+ cen = pd.read_csv(sd / "cortex_svz_cellcentroids.csv")
253
+ ann = pd.read_csv(dl / "seqfish_ann" / "cortex_svz_cell_type_annotations.csv")
254
+ mask = (cen["Region"] == "Cortex").to_numpy()
255
+ st = ad.AnnData(
256
+ X=sp.csr_matrix(counts.to_numpy(dtype=np.float32)[mask]),
257
+ var=pd.DataFrame(index=counts.columns.astype(str)),
258
+ )
259
+ cen, louv = cen[mask].reset_index(drop=True), ann["louvain"].to_numpy()[mask]
260
+ st.obs_names = [f"cortex_{i}" for i in range(st.n_obs)]
261
+ st.obs["fov"] = cen["Field of View"].to_numpy()
262
+ st.obs["louvain"] = louv
263
+ st.obsm["spatial"] = _grid_offset_fov(
264
+ cen[["X", "Y"]].to_numpy(), cen["Field of View"].to_numpy()
265
+ )
266
+ st = _uppercase_and_collapse(st)
267
+
268
+ sc = (
269
+ _load_allen([dl / "dl_visp", dl / "dl_alm"], ["VISp", "ALM"])
270
+ if (dl / "dl_visp").exists()
271
+ else _load_allen([dl / "visp.zip", dl / "alm.zip"], ["VISp", "ALM"])
272
+ )
273
+ sc = _finalize_sc(sc)
274
+ return sc, st
275
+
276
+
277
+ def build_mtg(dl: Path) -> tuple[ad.AnnData, ad.AnnData]:
278
+ df = pd.read_csv(dl / "iss_mtg_540.csv") # CellID, genes..., centroidX/Y
279
+ gene_cols = [c for c in df.columns if c not in ("CellID", "centroidX", "centroidY")]
280
+ st = ad.AnnData(
281
+ X=sp.csr_matrix(df[gene_cols].to_numpy(dtype=np.float32)),
282
+ var=pd.DataFrame(index=[g.upper() for g in gene_cols]),
283
+ )
284
+ st.obs_names = [f"iss_{c}" for c in df["CellID"].to_numpy()]
285
+ st.obsm["spatial"] = df[["centroidX", "centroidY"]].to_numpy(dtype=np.float32)
286
+ st = _uppercase_and_collapse(st)
287
+
288
+ sc = _load_allen([dl / "human_mtg.zip"], ["MTG"])
289
+ sc = _finalize_sc(sc)
290
+ return sc, st
291
+
292
+
293
+ BUILDERS = {"ob": build_ob, "mtg": build_mtg, "cortex": build_cortex}
294
+
295
+
296
+ # --------------------------------------------------------------------------- #
297
+ def main() -> None:
298
+ logging.basicConfig(
299
+ stream=sys.stdout,
300
+ level=logging.INFO,
301
+ format="%(asctime)s %(levelname)s %(message)s",
302
+ )
303
+ p = argparse.ArgumentParser(
304
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
305
+ )
306
+ p.add_argument("--pair", choices=list(BUILDERS), required=True)
307
+ p.add_argument(
308
+ "--dl-dir", type=Path, required=True, help="scratch dir with downloaded inputs"
309
+ )
310
+ p.add_argument("--data-root", type=Path, default=DEFAULT_DATA_ROOT)
311
+ p.add_argument("--dry-run", action="store_true")
312
+ args = p.parse_args()
313
+
314
+ m = META[args.pair]
315
+ sc, st = BUILDERS[args.pair](args.dl_dir)
316
+ shared = sc.var_names.intersection(st.var_names)
317
+ n_types = int(sc.obs["cellType"].nunique())
318
+ logger.info(
319
+ "%s: sc=%d x %d (%d types), st=%d x %d, shared=%d",
320
+ m["name"],
321
+ sc.n_obs,
322
+ sc.n_vars,
323
+ n_types,
324
+ st.n_obs,
325
+ st.n_vars,
326
+ len(shared),
327
+ )
328
+
329
+ sc_out = args.data_root / "scRNA" / f"{m['name']}.h5ad"
330
+ st_out = args.data_root / "ST" / f"{m['name']}.h5ad"
331
+ if args.dry_run:
332
+ logger.info("[dry-run] would write %s and %s", sc_out, st_out)
333
+ return
334
+
335
+ sc.write_h5ad(sc_out)
336
+ logger.info("wrote %s", sc_out)
337
+ st.write_h5ad(st_out)
338
+ logger.info("wrote %s", st_out)
339
+
340
+ _append_row(
341
+ args.data_root / "scRNA" / "index.csv",
342
+ {
343
+ "Name": m["name"],
344
+ "Tissue": m["tissue"],
345
+ "Quelle": m["sc_quelle"],
346
+ "Autor": m["sc_autor"],
347
+ "Tech": m["sc_tech"],
348
+ "FeaturedIn": m["featured"],
349
+ "CellCount": sc.n_obs,
350
+ "GeneCount": sc.n_vars,
351
+ "CellTypeKey0": "cellType",
352
+ "NumberCellTypes0": n_types,
353
+ "Links": m["sc_link"],
354
+ },
355
+ False,
356
+ )
357
+ _append_row(
358
+ args.data_root / "ST" / "index.csv",
359
+ {
360
+ "Name": m["name"],
361
+ "Tissue": m["tissue"],
362
+ "Quelle": m["st_quelle"],
363
+ "Autor": m["st_autor"],
364
+ "Tech": m["st_tech"],
365
+ "FeaturedIn": m["featured"],
366
+ "SpotCount": st.n_obs,
367
+ "GeneCount": st.n_vars,
368
+ "Links": m["st_link"],
369
+ },
370
+ False,
371
+ )
372
+ pairs_path = args.data_root / "pairs.csv"
373
+ with open(pairs_path, newline="") as f:
374
+ existing = list(csv.DictReader(f))
375
+ next_id = (
376
+ max(
377
+ (int(r["PairID"]) for r in existing if r.get("PairID", "").isdigit()),
378
+ default=-1,
379
+ )
380
+ + 1
381
+ )
382
+ _append_row(
383
+ pairs_path,
384
+ {
385
+ "PairID": next_id,
386
+ "scName": m["name"],
387
+ "stName": m["name"],
388
+ "NumberSharedGenes": len(shared),
389
+ "ExperimentMatch": m["match"],
390
+ },
391
+ False,
392
+ )
393
+ logger.info("Done %s.", m["name"])
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()