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
aim/analysis/ksweep.py ADDED
@@ -0,0 +1,184 @@
1
+ """Cross-K comparison: gather each K's key analysis metrics from its per-K
2
+ ``analysis/data`` outputs into one table at the run root (``k_comparison.csv``),
3
+ score them, and record which K each criterion selects (``k_selection.json``).
4
+
5
+ ``k_comparison.csv`` holds the measured metrics *and* what they reduce to: one
6
+ combined score per criterion, the overall score across criteria, and a ``pareto``
7
+ flag. Reads the files each K's post-mapping analysis already writes
8
+ (``cossim_summary.csv``, ``topology_metrics.json``, ``modularity_metrics.json``),
9
+ so it runs after the K-loop with no recomputation — including the label-shuffle
10
+ nulls those steps measured, which the GUI needs alongside the observed values.
11
+ Metrics missing for a given K come through as NaN. The scoring itself lives in
12
+ :mod:`aim.metrics.kselection`, so a CLI run and the GUI's "Comparing K" card
13
+ report the same numbers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from aim.metrics import kselection
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _load_json(path: Path) -> dict:
31
+ """Load a JSON dict, or return an empty dict if the file is absent/unreadable."""
32
+ if not path.exists():
33
+ return {}
34
+ try:
35
+ with open(path, encoding="utf-8") as fh:
36
+ return json.load(fh)
37
+ except (json.JSONDecodeError, OSError):
38
+ return {}
39
+
40
+
41
+ def _finite(x) -> float:
42
+ """Coerce a value to float, mapping non-finite (inf / NaN / None) to NaN so it
43
+ leaves a gap in the plot rather than distorting an autoscaled axis."""
44
+ try:
45
+ f = float(x)
46
+ except (TypeError, ValueError):
47
+ return float("nan")
48
+ return f if np.isfinite(f) else float("nan")
49
+
50
+
51
+ def _cossim_cell(cs: pd.DataFrame, row: str, column: str) -> float:
52
+ """One cell of ``cossim_summary.csv`` as a float, NaN when absent."""
53
+ if row not in cs.index or column not in cs.columns:
54
+ return float("nan")
55
+ return _finite(cs.loc[row, column])
56
+
57
+
58
+ def collect_k_metrics(output_folder: Path, ks: list[int]) -> pd.DataFrame:
59
+ """Gather the per-K comparison metrics into one DataFrame (one row per K, ascending).
60
+
61
+ Columns: ``k``, ``cossim_hard_norm_spot``, ``cossim_hard_norm_gene``,
62
+ ``cossim_hard_raw_spot``, ``cossim_hard_raw_gene``, ``nhood_mean_self_zscore``,
63
+ ``local_purity_zscore``, ``modularity_shared``, ``modularity_st_expression``,
64
+ ``mean_confidence``, plus the ``*_null`` label-shuffle baseline of every metric
65
+ that has one (the two z-scores are already measured against that shuffle, so
66
+ their null is 0 by construction and is not repeated here).
67
+ """
68
+ rows = []
69
+ for k in ks:
70
+ data_dir = output_folder / f"k_{k:03d}" / "analysis" / "data"
71
+
72
+ cossim_norm_spot = cossim_norm_gene = np.nan
73
+ cossim_raw_spot = cossim_raw_gene = np.nan
74
+ cossim_raw_spot_null = cossim_raw_gene_null = np.nan
75
+ cossim_path = data_dir / "cossim_summary.csv"
76
+ if cossim_path.exists():
77
+ cs = pd.read_csv(cossim_path, index_col=0)
78
+ cossim_norm_spot = _cossim_cell(cs, "hard-norm", "median_spot")
79
+ cossim_norm_gene = _cossim_cell(cs, "hard-norm", "median_gene")
80
+ cossim_raw_spot = _cossim_cell(cs, "hard-raw", "median_spot")
81
+ cossim_raw_gene = _cossim_cell(cs, "hard-raw", "median_gene")
82
+ cossim_raw_spot_null = _cossim_cell(cs, "hard-raw", "median_spot_null")
83
+ cossim_raw_gene_null = _cossim_cell(cs, "hard-raw", "median_gene_null")
84
+
85
+ topo = _load_json(data_dir / "topology_metrics.json")
86
+ # z-scores can be non-finite (inf) when a permutation null has zero variance
87
+ # for a degenerate/tiny state; coerce to NaN so they leave a gap instead of
88
+ # blowing up the plot's autoscaled y-axis.
89
+ # These sub-dicts are stored as null when a K has too few states for the
90
+ # test (e.g. K=1), so coalesce None -> {} before indexing.
91
+ nhood = topo.get("nhood_enrichment") or {}
92
+ purity = topo.get("local_purity") or {}
93
+ self_z = _finite(nhood.get("mean_self_zscore", np.nan))
94
+ purity_z = _finite(purity.get("z_score", np.nan))
95
+
96
+ modularity = _load_json(data_dir / "modularity_metrics.json")
97
+ mod_shared = _finite(modularity.get("modularity_shared", np.nan))
98
+ mod_st = _finite(modularity.get("modularity_st_expression", np.nan))
99
+
100
+ # Present only for mappers that define a confidence (nearest / wann);
101
+ # absent -> NaN -> the curve simply does not appear for this run.
102
+ confidence = _load_json(data_dir / "confidence_summary.json")
103
+ mean_conf = confidence.get("mean", np.nan)
104
+
105
+ rows.append(
106
+ {
107
+ "k": k,
108
+ "cossim_hard_norm_spot": cossim_norm_spot,
109
+ "cossim_hard_norm_gene": cossim_norm_gene,
110
+ "cossim_hard_raw_spot": cossim_raw_spot,
111
+ "cossim_hard_raw_gene": cossim_raw_gene,
112
+ "cossim_hard_raw_spot_null": cossim_raw_spot_null,
113
+ "cossim_hard_raw_gene_null": cossim_raw_gene_null,
114
+ "nhood_mean_self_zscore": self_z,
115
+ "local_purity_zscore": purity_z,
116
+ "modularity_shared": mod_shared,
117
+ "modularity_st_expression": mod_st,
118
+ "mean_confidence": mean_conf,
119
+ }
120
+ )
121
+
122
+ return pd.DataFrame(rows).sort_values("k").reset_index(drop=True)
123
+
124
+
125
+ def score_k_metrics(df: pd.DataFrame) -> pd.DataFrame:
126
+ """``df`` with the K-selection columns appended: one combined score per
127
+ criterion, the ``overall`` score across them, and a ``pareto`` flag.
128
+
129
+ The combined scores' own label-shuffle nulls are not carried here — only
130
+ reconstruction has one, and the GUI recomputes it in memory for the chance
131
+ crosshair; the measured nulls it is derived from are already columns of
132
+ ``df``.
133
+ """
134
+ table = kselection.score_table(df)
135
+ out = df.copy()
136
+ for criterion in kselection.CRITERIA:
137
+ out[criterion.key] = table[criterion.key].to_numpy()
138
+ out["overall"] = kselection.overall(table)
139
+ out["pareto"] = kselection.pareto_mask(table)
140
+ return out
141
+
142
+
143
+ def select_k(output_folder: Path, df: pd.DataFrame) -> dict:
144
+ """Write ``k_selection.json``: which K wins, per criterion and overall.
145
+
146
+ The proposal is the Pareto-optimal K plus the best K for each criterion and
147
+ for the harmonic mean across all of them, each with its score. Same
148
+ computation the GUI's "Comparing K" card performs, so the CLI no longer
149
+ hides it. Returns the proposal dict.
150
+ """
151
+ table = kselection.score_table(df)
152
+ best = kselection.best_ks(table)
153
+ best_json: dict[str, dict[str, float] | None] = {
154
+ name: (None if hit is None else {"k": hit[0], "score": hit[1]})
155
+ for name, hit in best.items()
156
+ }
157
+ proposal = {
158
+ "pareto_ks": [int(k) for k in table.loc[kselection.pareto_mask(table), "k"]],
159
+ "best": best_json,
160
+ "criteria": {c.key: c.label for c in kselection.CRITERIA},
161
+ }
162
+ json_path = output_folder / "k_selection.json"
163
+ with open(json_path, "w", encoding="utf-8") as fh:
164
+ json.dump(proposal, fh, indent=2)
165
+
166
+ chosen = ", ".join(
167
+ f"{name}={hit[0]}" for name, hit in best.items() if hit is not None
168
+ )
169
+ logger.info("K selection written to %s (%s)", json_path, chosen)
170
+ return proposal
171
+
172
+
173
+ def compare_k_runs(output_folder: Path, ks: list[int]) -> None:
174
+ """Write ``k_comparison.csv`` at the run root: the swept K-runs' reconstruction
175
+ cosine similarity, spatial organisation and mapping modularity, together with
176
+ the criterion scores those reduce to, the overall score and the Pareto flag
177
+ (the GUI renders the comparison plots from it on demand). The winning K per
178
+ criterion and overall goes to ``k_selection.json`` (see :func:`select_k`)."""
179
+ output_folder = Path(output_folder)
180
+ df = collect_k_metrics(output_folder, ks)
181
+ csv_path = output_folder / "k_comparison.csv"
182
+ score_k_metrics(df).to_csv(csv_path, index=False)
183
+ logger.info("K-sweep comparison written to %s", csv_path)
184
+ select_k(output_folder, df)
@@ -0,0 +1,70 @@
1
+ """Load one K's saved sweep outputs for the post-mapping analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import anndata as ad
9
+ import numpy as np
10
+ import pandas as pd
11
+ from anndata import AnnData
12
+
13
+ from aim.adata_schema import (
14
+ OBSM_MAPPING_SOFT,
15
+ OBS_MAPPING_HARD,
16
+ OBS_MAPPING_CONFIDENCE,
17
+ OBS_START_CLUSTER,
18
+ OBS_COMPUTED_STATE,
19
+ )
20
+ from aim.io import START_CLUSTER_TO_STATE_FILENAME
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def load_spot_to_state_mapping_soft_and_hard(run_dir: Path, adata_st: AnnData) -> None:
26
+ """Load the spot->state matrix P from spot_to_state_mapping_soft.h5ad in run_dir.
27
+
28
+ Adds: adata_st.obsm[OBSM_MAPPING_SOFT] (S x K), assigned positionally, and,
29
+ when the mapper wrote one, adata_st.obs[OBS_MAPPING_CONFIDENCE] (S,).
30
+ Raises ValueError if the file's spot order does not match adata_st.obs_names.
31
+ """
32
+ run_dir = Path(run_dir)
33
+ mapping_path = run_dir / "spot_to_state_mapping_soft.h5ad"
34
+ if not mapping_path.exists():
35
+ raise FileNotFoundError(f"Required mapping output missing: {mapping_path}")
36
+
37
+ mapping_ad = ad.read_h5ad(mapping_path)
38
+ if not mapping_ad.obs_names.equals(adata_st.obs_names):
39
+ raise ValueError(
40
+ f"Spot order in {mapping_path} does not match adata_st.obs_names."
41
+ )
42
+ adata_st.obsm[OBSM_MAPPING_SOFT] = np.asarray(mapping_ad.X, dtype=np.float64)
43
+ adata_st.obs[OBS_MAPPING_HARD] = adata_st.obsm[OBSM_MAPPING_SOFT].argmax(axis=1)
44
+ if OBS_MAPPING_CONFIDENCE in mapping_ad.obs:
45
+ adata_st.obs[OBS_MAPPING_CONFIDENCE] = mapping_ad.obs[
46
+ OBS_MAPPING_CONFIDENCE
47
+ ].to_numpy()
48
+
49
+
50
+ def load_start_cluster_to_state(run_dir: Path) -> np.ndarray:
51
+ """Load the start-cluster->state label array (L,), values 0..K-1, from
52
+ start_cluster_to_state.csv in run_dir.
53
+
54
+ Falls back to the pre-rename ``leiden_to_state.csv`` so run roots written by an
55
+ older sweep still load; only the ``state`` column is read either way.
56
+ """
57
+ run_dir = Path(run_dir)
58
+ path = run_dir / START_CLUSTER_TO_STATE_FILENAME
59
+ if not path.exists():
60
+ legacy = run_dir / "leiden_to_state.csv"
61
+ if not legacy.exists():
62
+ raise FileNotFoundError(f"Required mapping output missing: {path}")
63
+ path = legacy
64
+ return pd.read_csv(path)["state"].to_numpy()
65
+
66
+
67
+ def infer_cell_to_state_cluster(adata_sc: AnnData, start_cluster_to_state: np.ndarray):
68
+ start_cluster_idx = adata_sc.obs[OBS_START_CLUSTER].astype(int).to_numpy()
69
+ cell_states = start_cluster_to_state[start_cluster_idx]
70
+ adata_sc.obs[OBS_COMPUTED_STATE] = pd.Categorical(cell_states.astype(str))
aim/analysis/onehot.py ADDED
@@ -0,0 +1,41 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ import pandas as pd
5
+ from anndata import AnnData
6
+
7
+ from aim.adata_schema import (
8
+ OBSM_MAPPING_SOFT,
9
+ )
10
+ from aim.metrics.onehot import onehot_metrics
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def analyse_spot_to_state_one_hotness(
16
+ adata_st: AnnData,
17
+ data_dir: Path,
18
+ ):
19
+ """One-hotness metrics for the spot->state mapping P.
20
+
21
+ Requires: adata_st.obsm[OBSM_MAPPING_SOFT].
22
+ Writes onehot_per_row_mapping.csv and onehot_summary_mapping.json under
23
+ data_dir.
24
+ """
25
+
26
+ m = onehot_metrics(adata_st.obsm[OBSM_MAPPING_SOFT])
27
+
28
+ pd.DataFrame(
29
+ {
30
+ "id": adata_st.obs_names,
31
+ "max_prob": m["max_prob"],
32
+ "gini_impurity": m["gini_impurity"],
33
+ "entropy": m["entropy"],
34
+ }
35
+ ).to_csv(data_dir / "onehot_per_row_mapping.csv", index=False)
36
+ with open(data_dir / "onehot_summary_mapping.json", "w") as f:
37
+ json.dump(
38
+ {"n_rows": m["n_rows"], "n_cols": m["n_cols"], "summary": m["summary"]},
39
+ f,
40
+ indent=2,
41
+ )
@@ -0,0 +1,131 @@
1
+ """Reconstruction cosine similarity of the mapped spots vs. measured ST expression."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import anndata as ad
10
+ import pandas as pd
11
+
12
+ from aim.adata_schema import (
13
+ OBS_MAPPING_HARD,
14
+ OBSM_LOGNORM_SHARED_GENES,
15
+ OBSM_MAPPING_SOFT,
16
+ UNS_SHARED_GENES,
17
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES,
18
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM,
19
+ UNS_START_CLUSTER_SIZES,
20
+ )
21
+ from aim.metrics import (
22
+ CossimResult,
23
+ assemble_state_centroids,
24
+ compute_and_save_cossim,
25
+ cossim_null_medians,
26
+ predict_expression,
27
+ )
28
+
29
+ from .utils import to_dense
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ N_PERM_RECONSTRUCTION = 20 # label shuffles for the reconstruction null
35
+
36
+
37
+ def analyse_reconstruction(
38
+ adata_sc: ad.AnnData,
39
+ adata_st: ad.AnnData,
40
+ start_cluster_to_state: np.ndarray,
41
+ output_data_dir: Path,
42
+ n_perm: int = N_PERM_RECONSTRUCTION,
43
+ seed: int = 0,
44
+ ) -> None:
45
+ """Cosine similarity of predicted (``mapping @ state_centroids``) vs. measured
46
+ spot expression, per gene and per spot, for the four soft/hard x raw/norm combos.
47
+
48
+ Each *hard* combo also gets a label-shuffling null (``median_*_null``): the same
49
+ medians with the spot->state labels permuted, averaged over ``n_perm`` shuffles.
50
+ That is the chance level of the reconstruction — with only positive expression
51
+ profiles in play it is far above zero, so it is the reference the K-sweep view
52
+ compares against. The soft combos have no null (their assignment is a
53
+ distribution, not a label) and come through as NaN.
54
+
55
+ ``start_cluster_to_state`` is this K's start-cluster->state cut (L,).
56
+ Requires: adata_st.obsm[OBSM_MAPPING_SOFT], adata_st.obs[OBS_MAPPING_HARD],
57
+ adata_st.obsm[OBSM_LOGNORM_SHARED_GENES], adata_sc.uns[UNS_SHARED_GENES],
58
+ adata_sc.uns[UNS_START_CLUSTER_SIZES],
59
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES],
60
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM].
61
+ Writes cossim_summary.csv and per-combo cossim JSONs under output_data_dir.
62
+ """
63
+
64
+ P = adata_st.obsm[OBSM_MAPPING_SOFT]
65
+ spot_states_hard = adata_st.obs[OBS_MAPPING_HARD].to_numpy()
66
+ k = P.shape[1]
67
+
68
+ shared_genes = list(adata_sc.uns[UNS_SHARED_GENES])
69
+ sizes = adata_sc.uns[UNS_START_CLUSTER_SIZES]
70
+
71
+ # Measured ST expression on the shared genes: raw counts from a view, and the
72
+ # normalize+log1p variant precomputed over the shared genes only.
73
+ adata_st_shared = adata_st[:, shared_genes]
74
+ adata_st_norm = ad.AnnData(
75
+ X=to_dense(adata_st.obsm[OBSM_LOGNORM_SHARED_GENES]),
76
+ obs=pd.DataFrame(index=adata_st.obs_names),
77
+ var=pd.DataFrame(index=shared_genes),
78
+ )
79
+
80
+ centroids_raw = assemble_state_centroids(
81
+ start_cluster_to_state,
82
+ k,
83
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES],
84
+ sizes,
85
+ )
86
+ centroids_norm = assemble_state_centroids(
87
+ start_cluster_to_state,
88
+ k,
89
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM],
90
+ sizes,
91
+ )
92
+
93
+ # "hard" combos index each spot's winning-state centroid row directly;
94
+ # "soft" combos are a genuine mapping @ centroids product.
95
+ spot_names = adata_st.obs_names.tolist()
96
+ combos = {
97
+ "soft-raw": (P, centroids_raw, adata_st_shared, False),
98
+ "hard-raw": (spot_states_hard, centroids_raw, adata_st_shared, True),
99
+ "soft-norm": (P, centroids_norm, adata_st_norm, False),
100
+ "hard-norm": (spot_states_hard, centroids_norm, adata_st_norm, True),
101
+ }
102
+ cossim_summary: dict[str, dict] = {}
103
+ cossim_dir = output_data_dir / "cossim"
104
+ cossim_results: dict[str, CossimResult] = {}
105
+ rng = np.random.default_rng(seed)
106
+ for label, (m, centroids, st_ref, is_hard) in combos.items():
107
+ pred = centroids[m] if is_hard else predict_expression(m, centroids)
108
+ pred_adata = ad.AnnData(
109
+ X=pred.T.astype(np.float32),
110
+ obs=pd.DataFrame(index=shared_genes),
111
+ var=pd.DataFrame(index=spot_names),
112
+ )
113
+ result = compute_and_save_cossim(
114
+ st_ref, pred_adata, cossim_dir, suffix=f"-{label}"
115
+ )
116
+ cossim_results[label] = result
117
+ # ``st_ref`` is already restricted to the shared genes in centroid column
118
+ # order, so the null reduces the very same matrices as the observed value.
119
+ null = (
120
+ cossim_null_medians(to_dense(st_ref), centroids, m, n_perm=n_perm, rng=rng)
121
+ if label == "hard-raw"
122
+ else {"median_spot": float("nan"), "median_gene": float("nan")}
123
+ )
124
+ cossim_summary[label] = {
125
+ "median_gene": result.median_gene,
126
+ "median_spot": result.median_spot,
127
+ "median_gene_null": null["median_gene"],
128
+ "median_spot_null": null["median_spot"],
129
+ }
130
+
131
+ pd.DataFrame(cossim_summary).T.to_csv(output_data_dir / "cossim_summary.csv")
@@ -0,0 +1,142 @@
1
+ """Spatial organisation of the mapped spots for the post-mapping analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import anndata as ad
11
+ import squidpy as sq
12
+
13
+ from aim.adata_schema import (
14
+ OBS_MAPPING_HARD,
15
+ OBSM_SPATIAL,
16
+ OBSP_SPATIAL_CONNECTIVITIES,
17
+ )
18
+ from aim.metrics.topology import local_spatial_purity, permutation_test
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ K_SPATIAL = 6 # neighbours for the squidpy spatial KNN graph
24
+ N_PERM_SPATIAL = 100 # permutations for the spatial-organisation null
25
+
26
+
27
+ def spatial_connectivities(adata_st: ad.AnnData, k: int):
28
+ """Squidpy spatial KNN connectivity matrix, built once and cached on adata_st.
29
+
30
+ Depends only on the spot coordinates, which are constant across the K-sweep,
31
+ so it is computed on the first K and reused for every later K. Serves both the
32
+ local spatial purity metric and the neighbourhood enrichment. Returns the
33
+ (n_spots x n_spots) sparse adjacency in adata_st.obsp[OBSP_SPATIAL_CONNECTIVITIES].
34
+ """
35
+ if OBSP_SPATIAL_CONNECTIVITIES not in adata_st.obsp:
36
+ sq.gr.spatial_neighbors_knn(adata_st, n_neighs=k)
37
+ return adata_st.obsp[OBSP_SPATIAL_CONNECTIVITIES]
38
+
39
+
40
+ def analyse_spatial_organization(
41
+ adata_st: ad.AnnData,
42
+ output_data_dir: Path,
43
+ k: int = K_SPATIAL,
44
+ n_perm: int = N_PERM_SPATIAL,
45
+ seed: int = 0,
46
+ ):
47
+ """Local spatial purity + self neighbourhood enrichment of the mapped spot states.
48
+
49
+ Local spatial purity comes with a permutation-null z-score; the self
50
+ neighbourhood-enrichment z-score summarises how much each state preferentially
51
+ borders itself (see ``_nhood_enrichment``). Individually undefined metrics come
52
+ back as NaN/None; asserts len(coords) == len(spot_states) and
53
+ len(spot_states) > k.
54
+
55
+ Requires: adata_st.obs[OBS_MAPPING_HARD], adata_st.obsm[OBSM_SPATIAL].
56
+ Writes topology_metrics.json under output_data_dir.
57
+ """
58
+ spot_states = adata_st.obs[OBS_MAPPING_HARD].to_numpy()
59
+ coords = np.asarray(adata_st.obsm[OBSM_SPATIAL])
60
+
61
+ result: dict = {
62
+ "n_spots": int(len(spot_states)),
63
+ "n_mapped_states": int(len(np.unique(spot_states))),
64
+ "k": int(k),
65
+ "n_perm": int(n_perm),
66
+ "local_purity": None,
67
+ "nhood_enrichment": None,
68
+ }
69
+ assert len(coords) == len(
70
+ spot_states
71
+ ), "Spatial organisation skipped: coordinates length mismatch "
72
+ assert len(spot_states) > k, "too few spots"
73
+
74
+ rng = np.random.default_rng(seed)
75
+
76
+ conn = spatial_connectivities(adata_st, k)
77
+ obs_lsp = local_spatial_purity(spot_states, conn)
78
+ result["local_purity"] = permutation_test(
79
+ obs_lsp, spot_states, lambda l: local_spatial_purity(l, conn), n_perm, rng
80
+ )
81
+
82
+ result["nhood_enrichment"] = _nhood_enrichment(
83
+ adata_st, spot_states, k, n_perm, seed
84
+ )
85
+
86
+ with open(output_data_dir / "topology_metrics.json", "w") as f:
87
+ json.dump(result, f, indent=4)
88
+
89
+
90
+ def _nhood_enrichment(
91
+ adata_st: ad.AnnData,
92
+ spot_states: np.ndarray,
93
+ k: int,
94
+ n_perm: int,
95
+ seed: int,
96
+ ) -> dict | None:
97
+ """Self neighbourhood-enrichment z-score of the mapped states, averaged over
98
+ states. Needs >= 2 mapped states (returns None otherwise).
99
+
100
+ Only the *self* term is computed -- the diagonal of squidpy's neighbourhood
101
+ enrichment -- since ``mean_self_zscore`` is the sole value consumed downstream.
102
+ For each state ``s`` we count the spatial-graph edges whose two endpoints are
103
+ both in ``s`` and z-score that against a label-shuffling null (the same null
104
+ ``sq.gr.nhood_enrichment`` uses), then average the per-state z-scores. This
105
+ skips squidpy's full K x K enrichment matrix and its per-K call overhead, so it
106
+ is markedly cheaper than a full ``nhood_enrichment`` run. A high value means
107
+ states preferentially border their own kind.
108
+
109
+ Values are comparable to (not bit-identical with) squidpy's diagonal: same
110
+ graph and null, different permutation RNG.
111
+ """
112
+ if len(np.unique(spot_states)) < 2:
113
+ return None
114
+
115
+ A = spatial_connectivities(adata_st, k).tocoo()
116
+ row, col = A.row, A.col
117
+ n_states = int(spot_states.max()) + 1
118
+
119
+ def within_state_edges(labels: np.ndarray) -> np.ndarray:
120
+ """Per-state count of spatial edges whose endpoints share that state."""
121
+ same = labels[row] == labels[col]
122
+ return np.bincount(labels[row][same], minlength=n_states).astype(np.float64)
123
+
124
+ observed = within_state_edges(spot_states)
125
+ rng = np.random.default_rng(seed)
126
+ null = np.stack(
127
+ [within_state_edges(rng.permutation(spot_states)) for _ in range(n_perm)]
128
+ )
129
+ mean = null.mean(axis=0)
130
+ std = null.std(axis=0)
131
+
132
+ present = np.unique(spot_states)
133
+ z = np.full(n_states, np.nan)
134
+ ok = std > 0
135
+ z[ok] = (observed[ok] - mean[ok]) / std[ok]
136
+ self_z = z[present]
137
+ return {
138
+ "n_states": int(len(present)),
139
+ "mean_self_zscore": (
140
+ float(np.nanmean(self_z)) if np.isfinite(self_z).any() else float("nan")
141
+ ),
142
+ }
aim/analysis/utils.py ADDED
@@ -0,0 +1,16 @@
1
+ """Shared array/AnnData helpers for the post-mapping analysis."""
2
+
3
+ from __future__ import annotations
4
+ import numpy as np
5
+
6
+ __all__ = ["to_dense"]
7
+
8
+
9
+ def to_dense(x, dtype=np.float32) -> np.ndarray:
10
+ """Return ``x`` as a dense ndarray, unwrapping AnnData ``.X`` and densifying
11
+ scipy-sparse inputs."""
12
+ if hasattr(x, "X"):
13
+ x = x.X
14
+ if hasattr(x, "toarray"):
15
+ x = x.toarray()
16
+ return np.asarray(x, dtype=dtype)