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/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """AIM (Annotation Independent Mapping): over-cluster an scRNA reference, agglomerate
2
+ the subclusters into K states, and map ST spots onto those states across a K sweep.
3
+ Public surface: ``AIMConfig``, ``MAPPING_CHOICES``, ``run``, ``SpotStateMapper``.
4
+
5
+ These names are exposed lazily (PEP 562) so that importing ``aim``, or a
6
+ submodule such as ``aim.reference_aligners.run_tangram`` from inside a reference
7
+ aligner's own conda env, does not pull in the heavy sweep/analysis stack
8
+ (scanpy, squidpy, ...), which is only present in ``aim_env``. The heavy modules
9
+ load on first attribute access instead.
10
+ """
11
+
12
+ from importlib import import_module
13
+ from typing import TYPE_CHECKING
14
+
15
+ _LAZY = {
16
+ "AIMConfig": ("aim.aim_config", "AIMConfig"),
17
+ "LINKAGE_METHODS": ("aim.aim_config", "LINKAGE_METHODS"),
18
+ "MAPPING_CHOICES": ("aim.aim_config", "MAPPING_CHOICES"),
19
+ "SpotStateMapper": ("aim.mapping", "SpotStateMapper"),
20
+ "run": ("aim.sweep", "run"),
21
+ }
22
+
23
+ __all__ = [
24
+ "AIMConfig",
25
+ "LINKAGE_METHODS",
26
+ "MAPPING_CHOICES",
27
+ "run",
28
+ "SpotStateMapper",
29
+ ]
30
+
31
+
32
+ def __getattr__(name: str):
33
+ try:
34
+ module_name, attr = _LAZY[name]
35
+ except KeyError:
36
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
37
+ return getattr(import_module(module_name), attr)
38
+
39
+
40
+ def __dir__() -> list[str]:
41
+ return sorted(__all__)
42
+
43
+
44
+ if TYPE_CHECKING: # let type checkers and IDEs see the real symbols
45
+ from aim.aim_config import LINKAGE_METHODS, MAPPING_CHOICES, AIMConfig
46
+ from aim.mapping import SpotStateMapper
47
+ from aim.sweep import run
aim/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m aim ...`` as an alias for the ``aim`` console script."""
2
+
3
+ from aim.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
aim/adata_schema.py ADDED
@@ -0,0 +1,80 @@
1
+ """Canonical obs/var/uns/obsm/obsp/layers key names for the sc/st AnnData
2
+ objects, each with a one-line description of what it holds. ``L`` = number of
3
+ start clusters (the partition the agglomeration tree is built over — either the
4
+ all-gene Leiden over-clustering or a pre-existing annotation), ``G_shared`` =
5
+ number of shared genes, ``K`` = number of states at the current cut.
6
+ """
7
+
8
+ # ── adata_sc ───────────────────────────────────────────────────────────────
9
+
10
+ # obs
11
+ OBS_START_CLUSTER = "start_cluster" # (n_cells,) int — start-cluster label (0..L-1): the all-gene Leiden over-cluster, or the annotated type in start-from-annotation mode
12
+ OBS_LEIDEN_SHARED_GENES = (
13
+ "leiden_shared_genes" # (n_cells,) int — Leiden label from shared genes only
14
+ )
15
+ OBS_COMPUTED_STATE = (
16
+ "computed_state" # (n_cells,) str categorical — cell's state at the current K
17
+ )
18
+
19
+ # uns — start clusters + Leiden run parameters
20
+ UNS_N_START_CLUSTERS = "n_start_clusters" # int — start-cluster count L
21
+ UNS_START_CLUSTER_NAMES = "start_cluster_names" # list[str] — display name per start cluster, index-aligned to OBS_START_CLUSTER: the annotated type names, or "cluster_<i>" in Leiden mode
22
+ UNS_LEIDEN_RESOLUTION_ALL_GENES = "leiden_resolution" # float — resolution of the all-gene Leiden over-clustering (absent in start-from-annotation mode)
23
+ UNS_LEIDEN_RESOLUTION_SHARED_GENES = (
24
+ "leiden_resolution_shared_genes" # float — resolution (shared genes)
25
+ )
26
+ UNS_LEIDEN_NUMBER_STATES_SHARED_GENES = (
27
+ "leiden_number_states_shared_genes" # int — cluster count (shared genes)
28
+ )
29
+ UNS_MODULARITY_SHARED_LEIDEN = "modularity_shared_leiden" # float — modularity of the shared-gene Leiden partition (K-independent; cached across the sweep)
30
+
31
+ # obsm/uns/obsp — shared-gene PCA + neighbor graph
32
+ OBSM_PCA_SHARED_GENES = (
33
+ "X_pca_shared_genes" # (n_cells x n_comps) — PCA of the shared-gene lognorm matrix
34
+ )
35
+ UNS_NEIGHBORS_SHARED_GENES = (
36
+ "neighbors_shared_genes" # dict — scanpy neighbors params for the shared-gene graph
37
+ )
38
+ OBSP_DISTANCES_SHARED_GENES = "neighbors_shared_genes_distances" # (n_cells x n_cells) sparse — neighbor distances
39
+ OBSP_CONNECTIVITIES_SHARED_GENES = "neighbors_shared_genes_connectivities" # (n_cells x n_cells) sparse — neighbor connectivities
40
+
41
+ # uns — per-start-cluster aggregates, all column-aligned to UNS_SHARED_GENES
42
+ UNS_SHARED_GENES = "shared_genes" # list[str] — sc/st gene intersection; column order for every *_SHARED array
43
+ UNS_START_CLUSTER_SIZES = "start_cluster_sizes" # (L,) — cells per start cluster
44
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES = "start_cluster_expr_sums_shared" # (L x G_shared) — summed raw expression per start cluster
45
+ UNS_START_CLUSTER_CENTROIDS_SHARED_GENES = "start_cluster_centroids_shared" # (L x G_shared) — mean raw expression per start cluster (all-zero rows set to 1e-6)
46
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM = "start_cluster_expr_sums_shared_norm" # (L x G_shared) — summed lognorm expression per start cluster
47
+ UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM = "start_cluster_centroids_shared_norm" # (L x G_shared) — mean lognorm expression per start cluster
48
+
49
+ # ── adata_st ───────────────────────────────────────────────────────────────
50
+
51
+ # obsm/obs — one K's spot->state mapping
52
+ OBSM_SPATIAL = "spatial" # (n_spots x 2) — spatial coordinates
53
+ OBSM_MAPPING_SOFT = (
54
+ "mapping_soft" # (n_spots x K) — soft spot->state assignment P at the current K
55
+ )
56
+ OBS_MAPPING_HARD = (
57
+ "mapping_hard" # (n_spots,) int — argmax state index (0..K-1) at the current K
58
+ )
59
+ OBS_MAPPING_STATE_CAT = "mapping_state" # (n_spots,) str categorical — hard state label as a category, for squidpy neighbourhood tools (nhood_enrichment)
60
+ OBS_MAPPING_CONFIDENCE = "mapping_confidence" # (n_spots,) float in [0,1] — per-spot assignment confidence at the current K; absent when the mapper defines no confidence (reference)
61
+
62
+ # obsm/obsp/uns — K-independent spatial/expression graphs, built once and reused across the K-sweep
63
+ OBSP_SPATIAL_CONNECTIVITIES = "spatial_connectivities" # (n_spots x n_spots) sparse — squidpy spatial KNN graph (local purity + neighbourhood enrichment)
64
+ OBSP_ST_EXPR_CONNECTIVITIES = "expr_connectivities" # (n_spots x n_spots) sparse — ST all-gene expression KNN graph for the mapping modularity
65
+ UNS_NHOOD_ENRICHMENT = f"{OBS_MAPPING_STATE_CAT}_nhood_enrichment" # dict {zscore, count} — squidpy neighbourhood-enrichment result over the mapped states
66
+
67
+ # ── shared across adata_sc and adata_st ─────────────────────────────────────
68
+
69
+ LAYER_LOGNORM = (
70
+ "lognorm" # (n_obs x n_vars) — normalize_total(1e4) + log1p over all genes
71
+ )
72
+ OBSM_LOGNORM_SHARED_GENES = "lognorm_shared" # (n_obs x G_shared) — normalize_total(1e4) + log1p over shared genes only, aligned to UNS_SHARED_GENES
73
+
74
+ # ── scanpy default keys, referenced by name ─────────────────────────────────
75
+
76
+ OBSM_PCA = "X_pca" # (n_cells x n_comps) — PCA of the all-gene lognorm layer
77
+ OBSM_UMAP = "X_umap" # (n_cells x 2) — UMAP from the all-gene neighbor graph
78
+ OBSM_UMAP_SHARED_GENES = (
79
+ "X_umap_shared_genes" # (n_cells x 2) — UMAP from the shared-gene neighbor graph
80
+ )
aim/aggregation.py ADDED
@@ -0,0 +1,118 @@
1
+ """Reference aggregation: per-start-cluster expression sums, sizes, and centroids,
2
+ and the size-weighted per-state expression profiles M for a given K cut."""
3
+
4
+ import numpy as np
5
+ from anndata import AnnData
6
+
7
+ from aim.adata_schema import (
8
+ OBS_START_CLUSTER,
9
+ OBSM_LOGNORM_SHARED_GENES,
10
+ UNS_SHARED_GENES,
11
+ UNS_START_CLUSTER_CENTROIDS_SHARED_GENES,
12
+ UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM,
13
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES,
14
+ UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM,
15
+ UNS_START_CLUSTER_SIZES,
16
+ )
17
+
18
+
19
+ def _sum_by_cluster(
20
+ expr: np.ndarray, labels: np.ndarray, n_clusters: int
21
+ ) -> np.ndarray:
22
+ """Sum rows of ``expr`` into ``n_clusters`` buckets given per-row cluster labels."""
23
+ sums = np.zeros((n_clusters, expr.shape[1]), dtype=np.float32)
24
+ np.add.at(sums, labels, expr)
25
+ return sums
26
+
27
+
28
+ def compute_start_cluster_aggregates(adata_sc: AnnData) -> None:
29
+ """
30
+ Aggregate the reference over its start clusters on the shared genes, raw and
31
+ normalized, storing the per-cluster sums, sizes, and centroids (all L x G_shared,
32
+ column-aligned to UNS_SHARED_GENES).
33
+
34
+ Mapper-facing and mode-independent: the start clusters are the Leiden
35
+ over-clusters or the annotated types, and this reads them the same way either way.
36
+
37
+ Requires: adata_sc.uns[UNS_SHARED_GENES], adata_sc.obsm[OBSM_LOGNORM_SHARED_GENES],
38
+ adata_sc.obs[OBS_START_CLUSTER].
39
+ Adds: adata_sc.uns[UNS_START_CLUSTER_SIZES],
40
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES],
41
+ adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES],
42
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM],
43
+ adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM].
44
+
45
+ All-zero raw centroid rows are nudged to a tiny uniform value so their cosine
46
+ distance stays defined for the agglomeration tree; the normalized centroids are not.
47
+ """
48
+ adata_shared = adata_sc[:, adata_sc.uns[UNS_SHARED_GENES]]
49
+
50
+ raw = adata_shared.X
51
+ if hasattr(raw, "toarray"):
52
+ raw = raw.toarray()
53
+ raw = np.asarray(raw, dtype=np.float32)
54
+
55
+ norm = adata_sc.obsm[OBSM_LOGNORM_SHARED_GENES]
56
+ if hasattr(norm, "toarray"):
57
+ norm = norm.toarray()
58
+ norm = np.asarray(norm, dtype=np.float32)
59
+
60
+ labels = adata_sc.obs[OBS_START_CLUSTER].astype(int).to_numpy()
61
+ n_clusters = int(labels.max()) + 1
62
+
63
+ sizes = np.bincount(labels, minlength=n_clusters).astype(np.float32)
64
+
65
+ expr_sums = _sum_by_cluster(raw, labels, n_clusters)
66
+ centroids = expr_sums / (sizes[:, None] + 1e-8)
67
+ zero_rows = np.where(~centroids.any(axis=1))[0]
68
+ if zero_rows.size:
69
+ centroids[zero_rows] = 1e-6
70
+
71
+ expr_sums_norm = _sum_by_cluster(norm, labels, n_clusters)
72
+ centroids_norm = expr_sums_norm / (sizes[:, None] + 1e-8)
73
+
74
+ adata_sc.uns[UNS_START_CLUSTER_SIZES] = sizes
75
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES] = expr_sums
76
+ adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES] = centroids
77
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES_NORM] = expr_sums_norm
78
+ adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM] = centroids_norm
79
+
80
+
81
+ def _size_weighted_state_profiles(
82
+ labels_k: np.ndarray,
83
+ k: int,
84
+ expr_sums: np.ndarray,
85
+ sizes: np.ndarray,
86
+ eps: float,
87
+ ) -> np.ndarray:
88
+ """Pool per-start-cluster expression sums into size-weighted per-state means M
89
+ (k x G_shared): M[s] = sum of expr_sums over its start clusters / sum of sizes."""
90
+ state_sums = np.zeros((k, expr_sums.shape[1]), dtype=np.float32)
91
+ np.add.at(state_sums, labels_k, expr_sums)
92
+ state_sizes = np.zeros(k, dtype=np.float32)
93
+ np.add.at(state_sizes, labels_k, sizes)
94
+
95
+ m = state_sums / (state_sizes[:, None] + eps)
96
+ return m.astype(np.float32, copy=False)
97
+
98
+
99
+ def assemble_state_profiles_shared_genes(
100
+ labels_k: np.ndarray,
101
+ k: int,
102
+ adata_sc: AnnData,
103
+ eps: float = 1e-8,
104
+ ) -> np.ndarray:
105
+ """
106
+ Size-weighted per-state raw expression profiles M (k x G_shared) for a
107
+ start-cluster->state label array (mean raw counts per state).
108
+
109
+ Requires: adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES],
110
+ adata_sc.uns[UNS_START_CLUSTER_SIZES].
111
+ """
112
+ return _size_weighted_state_profiles(
113
+ labels_k,
114
+ k,
115
+ adata_sc.uns[UNS_START_CLUSTER_EXPR_SUMS_SHARED_GENES],
116
+ adata_sc.uns[UNS_START_CLUSTER_SIZES],
117
+ eps,
118
+ )
aim/aim_config.py ADDED
@@ -0,0 +1,76 @@
1
+ """Run configuration for AIM: the ``AIMConfig`` knobs, the mapper registry, and the
2
+ ``build_mapper`` factory that turns a config into a ``SpotStateMapper``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from aim.reference_aligners.registry import REFERENCE_ALIGNERS
10
+
11
+ if TYPE_CHECKING:
12
+ from .mapping import SpotStateMapper
13
+
14
+ # In-process mapping strategy names. Kept as plain strings (not derived from the
15
+ # mapper classes) so ``MAPPING_CHOICES`` — used to build the CLI parser — needs no
16
+ # import of the mapper modules. ``build_mapper`` below asserts these stay in sync
17
+ # with the classes' own ``.name``.
18
+ _INPROCESS_METHODS = ("nearest_centroid", "wann")
19
+ # External reference aligners, all served by ReferenceMapper and selected by name.
20
+ # Sourced from the aligner registry so adding one there is the only edit needed.
21
+ _REFERENCE_METHODS = tuple(REFERENCE_ALIGNERS)
22
+
23
+ MAPPING_CHOICES = _INPROCESS_METHODS + _REFERENCE_METHODS
24
+
25
+ # Linkage choices for the agglomeration tree over the start clusters, first
26
+ # entry being the default. Single source of truth: the CLI parser and the GUI
27
+ # sidebar offer these, and ``tree.build_agglomeration_tree`` validates against them.
28
+ LINKAGE_METHODS = ("average", "ward")
29
+
30
+
31
+ @dataclass
32
+ class AIMConfig:
33
+ """Per-run knobs for the AIM sweep: mapping choice, start clusters,
34
+ hyperparameters, and K range."""
35
+
36
+ mapping: str = "nearest_centroid"
37
+ leiden_resolution: float = 3.0
38
+ # Agglomeration-tree linkage: "average" (UPGMA) or "ward" (balanced states).
39
+ linkage_method: str = LINKAGE_METHODS[0]
40
+ # Start clusters (what the agglomeration tree is built over): None = Leiden
41
+ # over-clustering; an obs column name = use that annotation instead, in which
42
+ # case no all-gene Leiden is computed and K sweeps from the number of types.
43
+ start_from_annotation: str | None = None
44
+ # K sweep range
45
+ k_min: int | None = None
46
+ k_max: int | None = None
47
+ k_step: int = 1
48
+
49
+ def build_mapper(self) -> "SpotStateMapper":
50
+ """Build the ``SpotStateMapper`` named by ``self.mapping``.
51
+
52
+ The mapper modules are imported here, lazily, so merely reading
53
+ ``AIMConfig``/``MAPPING_CHOICES`` does not import them.
54
+ """
55
+ from .mapping import (
56
+ NearestCentroidMapper,
57
+ ReferenceMapper,
58
+ WANNMapper,
59
+ )
60
+
61
+ inprocess: dict[str, type[SpotStateMapper]] = {
62
+ NearestCentroidMapper.name: NearestCentroidMapper,
63
+ WANNMapper.name: WANNMapper,
64
+ }
65
+ assert tuple(inprocess) == _INPROCESS_METHODS, (
66
+ "MAPPING_CHOICES out of sync with mapper class names: "
67
+ f"{tuple(inprocess)} != {_INPROCESS_METHODS}"
68
+ )
69
+
70
+ if self.mapping in _REFERENCE_METHODS:
71
+ return ReferenceMapper(reference_method=self.mapping)
72
+ if self.mapping not in inprocess:
73
+ raise ValueError(
74
+ f"mapping must be one of {MAPPING_CHOICES}, got {self.mapping!r}"
75
+ )
76
+ return inprocess[self.mapping]()
@@ -0,0 +1,13 @@
1
+ """Post-mapping analysis package; exposes ``run_analysis``."""
2
+
3
+ __all__ = ["run_analysis"]
4
+
5
+
6
+ def __getattr__(name: str):
7
+ # Lazy import to avoid an import cycle: analysis.analysis imports metrics,
8
+ # which imports leaf helpers from this package.
9
+ if name == "run_analysis":
10
+ from .analysis import run_analysis
11
+
12
+ return run_analysis
13
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from anndata import AnnData
7
+
8
+ from .biology import (
9
+ analyse_substate_coherence,
10
+ analyse_modularities,
11
+ )
12
+ from .confidence import analyse_spot_confidence
13
+ from .reconstruction import analyse_reconstruction
14
+ from .topology import analyse_spatial_organization
15
+
16
+ from .loading import (
17
+ load_spot_to_state_mapping_soft_and_hard,
18
+ load_start_cluster_to_state,
19
+ infer_cell_to_state_cluster,
20
+ )
21
+ from .onehot import analyse_spot_to_state_one_hotness
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def run_analysis(adata_sc: AnnData, adata_st: AnnData, run_dir: Path) -> None:
27
+ """Run the full post-mapping analysis for one K, writing machine-readable
28
+ metrics under ``run_dir/analysis/data/``.
29
+
30
+ Loads this K's mapping and computes state stats, one-hotness, spatial
31
+ organisation, substate coherence, reconstruction cosine similarity and
32
+ modularity. No figures are rendered: the interactive GUI (``python -m gui``)
33
+ builds every plot on demand from these outputs.
34
+
35
+ Requires: adata_sc.obs[OBS_START_CLUSTER],
36
+ adata_sc.obs[OBS_LEIDEN_SHARED_GENES],
37
+ adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM] (and the further
38
+ uns keys read by the delegate analyses). run_dir must contain
39
+ spot_to_state_mapping_soft.h5ad and start_cluster_to_state.csv.
40
+ Adds: adata_st.obsm[OBSM_MAPPING_SOFT], adata_st.obs[OBS_MAPPING_HARD],
41
+ adata_sc.obs[OBS_COMPUTED_STATE].
42
+ """
43
+
44
+ run_dir = Path(run_dir)
45
+ analysis_dir = run_dir / "analysis"
46
+ data_dir = analysis_dir / "data"
47
+ data_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # Load start-cluster to state mapping & infer cell to state mapping
50
+ start_cluster_to_state = load_start_cluster_to_state(run_dir)
51
+ infer_cell_to_state_cluster(adata_sc, start_cluster_to_state)
52
+
53
+ # Load spot to state mapping
54
+ load_spot_to_state_mapping_soft_and_hard(run_dir, adata_st)
55
+
56
+ # logger.info("Computing one-hot metrics...")
57
+ analyse_spot_to_state_one_hotness(adata_st, data_dir)
58
+
59
+ # logger.info("Summarising per-spot mapping confidence (if available)...")
60
+ analyse_spot_confidence(adata_st, data_dir)
61
+
62
+ # logger.info("Computing spatial organisation of mapped spots...")
63
+ analyse_spatial_organization(adata_st, data_dir)
64
+
65
+ # logger.info("Computing substate merge coherence...")
66
+ analyse_substate_coherence(adata_sc, start_cluster_to_state, data_dir)
67
+
68
+ # logger.info("Computing reconstruction cosine similarities...")
69
+ analyse_reconstruction(adata_sc, adata_st, start_cluster_to_state, data_dir)
70
+
71
+ # logger.info("Computing modularity for computed assignment...")
72
+ analyse_modularities(adata_sc, adata_st, data_dir)
@@ -0,0 +1,205 @@
1
+ """Start-cluster merge coherence and computed-state modularity for the post-mapping
2
+ analysis."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import scanpy as sc
12
+ from anndata import AnnData
13
+
14
+ from aim.adata_schema import (
15
+ LAYER_LOGNORM,
16
+ OBS_COMPUTED_STATE,
17
+ OBS_LEIDEN_SHARED_GENES,
18
+ OBS_MAPPING_HARD,
19
+ OBSP_CONNECTIVITIES_SHARED_GENES,
20
+ OBSP_ST_EXPR_CONNECTIVITIES,
21
+ UNS_MODULARITY_SHARED_LEIDEN,
22
+ UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM,
23
+ )
24
+ from aim.metrics.biology import (
25
+ _pairwise_cosine_stats,
26
+ start_cluster_state_groups,
27
+ compute_modularity,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ N_PERM_COHERENCE = 200 # permutations for the substate-coherence null
34
+ SIG_ALPHA = 0.05 # p-value threshold for "significant"
35
+
36
+
37
+ def analyse_substate_coherence(
38
+ adata_sc: AnnData,
39
+ labels_k: np.ndarray,
40
+ output_data_dir: Path,
41
+ n_perm: int = N_PERM_COHERENCE,
42
+ seed: int = 0,
43
+ ):
44
+ """Coherence of the start clusters merged into each state.
45
+
46
+ For every state merging >=2 start clusters (with at least one start cluster in
47
+ another state), permutation-tests the mean/median pairwise cosine similarity
48
+ of the merged start clusters' ``centroids`` (L x G_shared) against ``n_perm``
49
+ same-sized random draws of start clusters from other states. All-zero (empty)
50
+ centroid rows are dropped. ``labels_k`` (L,) is the start-cluster->state grouping.
51
+
52
+ Writes biology_metrics.json under output_data_dir.
53
+ """
54
+
55
+ centroids = adata_sc.uns[UNS_START_CLUSTER_CENTROIDS_SHARED_GENES_NORM]
56
+ groups = start_cluster_state_groups(labels_k)
57
+ # Only start clusters with a non-zero centroid: a zero centroid has undefined
58
+ # cosine similarity.
59
+ valid_start_clusters = {
60
+ int(l) for l in range(len(centroids)) if np.any(centroids[l] != 0.0)
61
+ }
62
+
63
+ per_state: dict[int, dict] = {}
64
+ for state, state_start_clusters in sorted(groups.items()):
65
+ targets = [l for l in state_start_clusters if l in valid_start_clusters]
66
+ others = [l for l in valid_start_clusters if l not in state_start_clusters]
67
+ if len(targets) < 2:
68
+ per_state[state] = {
69
+ "n_start_clusters_merged": len(targets),
70
+ "skipped_reason": "fewer than 2 non-empty merged start clusters",
71
+ }
72
+ continue
73
+ if not others:
74
+ per_state[state] = {
75
+ "n_start_clusters_merged": len(targets),
76
+ "skipped_reason": "no start clusters in other states to draw a null from",
77
+ }
78
+ continue
79
+
80
+ rng = np.random.default_rng(seed + state)
81
+ obs_mean, obs_median = _pairwise_cosine_stats(centroids[targets])
82
+
83
+ n_draw = len(targets)
84
+ replace = n_draw > len(others)
85
+ others_arr = np.array(others)
86
+ null_means = np.empty(n_perm)
87
+ for i in range(n_perm):
88
+ draw = rng.choice(others_arr, size=n_draw, replace=replace)
89
+ null_means[i], _ = _pairwise_cosine_stats(centroids[draw])
90
+
91
+ p_mean = float((null_means >= obs_mean).mean())
92
+ z_mean = float((obs_mean - null_means.mean()) / (null_means.std() + 1e-12))
93
+ per_state[state] = {
94
+ "n_start_clusters_merged": len(targets),
95
+ "mean_cossim": obs_mean,
96
+ "median_cossim": obs_median,
97
+ "null_mean": float(null_means.mean()),
98
+ "p_value_mean": p_mean,
99
+ "z_score_mean": z_mean,
100
+ "skipped_reason": None,
101
+ }
102
+
103
+ tested = [m for m in per_state.values() if m.get("skipped_reason") is None]
104
+ if tested:
105
+ aggregate = {
106
+ "n_tested_states": len(tested),
107
+ "mean_cossim": float(np.mean([m["mean_cossim"] for m in tested])),
108
+ "mean_z_score": float(np.mean([m["z_score_mean"] for m in tested])),
109
+ "frac_significant": float(
110
+ np.mean([m["p_value_mean"] < SIG_ALPHA for m in tested])
111
+ ),
112
+ }
113
+ else:
114
+ aggregate = {
115
+ "n_tested_states": 0,
116
+ "mean_cossim": float("nan"),
117
+ "mean_z_score": float("nan"),
118
+ "frac_significant": float("nan"),
119
+ }
120
+
121
+ with open(output_data_dir / "biology_metrics.json", "w") as f:
122
+ json.dump(
123
+ {"n_perm": int(n_perm), "aggregate": aggregate, "per_state": per_state},
124
+ f,
125
+ indent=4,
126
+ )
127
+
128
+
129
+ def _spatial_expression_modularity(adata_st: AnnData) -> float:
130
+ """Modularity of the hard spot->state labels on the ST expression KNN graph.
131
+
132
+ Measures transcriptional coherence of the mapping within the spatial data:
133
+ builds an expression KNN graph over all genes of ``adata_st`` (PCA of the
134
+ lognorm layer + scanpy neighbors, matching the reference all-genes graph)
135
+ and computes the modularity of ``adata_st.obs[OBS_MAPPING_HARD]`` on it.
136
+
137
+ Requires: adata_st.layers[LAYER_LOGNORM], adata_st.obs[OBS_MAPPING_HARD].
138
+ """
139
+ # The expression KNN graph is K-independent (only the hard labels change per
140
+ # K), so build it once and cache the connectivities on adata_st.
141
+ if OBSP_ST_EXPR_CONNECTIVITIES not in adata_st.obsp:
142
+ # Throwaway AnnData so the PCA / neighbor graph never touches adata_st.
143
+ st_expr = AnnData(
144
+ X=adata_st.layers[LAYER_LOGNORM].copy(),
145
+ obs=adata_st.obs[[]].copy(),
146
+ var=adata_st.var[[]].copy(),
147
+ )
148
+ n = min(30, st_expr.n_obs - 1, st_expr.n_vars - 1)
149
+ sc.pp.pca(st_expr, n_comps=n)
150
+ sc.pp.neighbors(st_expr)
151
+ adata_st.obsp[OBSP_ST_EXPR_CONNECTIVITIES] = st_expr.obsp["connectivities"]
152
+
153
+ graph = AnnData(
154
+ X=np.zeros((adata_st.n_obs, 1), dtype=np.float32),
155
+ obsp={"connectivities": adata_st.obsp[OBSP_ST_EXPR_CONNECTIVITIES]},
156
+ )
157
+ hard_labels = adata_st.obs[OBS_MAPPING_HARD].astype(int).to_numpy()
158
+ return compute_modularity(graph, hard_labels)
159
+
160
+
161
+ def analyse_modularities(
162
+ adata_sc: AnnData,
163
+ adata_st: AnnData,
164
+ output_data_dir: Path,
165
+ ):
166
+ """Modularity of the computed-state partition on the reference KNN graphs.
167
+
168
+ Also measures transcriptional coherence of the mapping within the spatial
169
+ data via ``_spatial_expression_modularity`` (hard spot->state labels on the
170
+ ST expression KNN graph over all genes).
171
+
172
+ Requires: adata_sc.obs[OBS_COMPUTED_STATE], adata_sc.obs[OBS_LEIDEN_SHARED_GENES],
173
+ adata_sc.obsp[OBSP_CONNECTIVITIES_SHARED_GENES],
174
+ adata_st.layers[LAYER_LOGNORM], adata_st.obs[OBS_MAPPING_HARD].
175
+ Writes modularity_metrics.json under output_data_dir.
176
+ """
177
+
178
+ cell_states = adata_sc.obs[OBS_COMPUTED_STATE].astype(int).to_numpy()
179
+
180
+ modularity_all = compute_modularity(adata_sc, cell_states)
181
+ modularity_shared = compute_modularity(
182
+ adata_sc, cell_states, obsp_key=OBSP_CONNECTIVITIES_SHARED_GENES
183
+ )
184
+ # The shared-gene Leiden partition does not depend on K, so compute its
185
+ # modularity once and cache it on adata_sc.
186
+ if UNS_MODULARITY_SHARED_LEIDEN not in adata_sc.uns:
187
+ adata_sc.uns[UNS_MODULARITY_SHARED_LEIDEN] = compute_modularity(
188
+ adata_sc,
189
+ adata_sc.obs[OBS_LEIDEN_SHARED_GENES].astype(int).to_numpy(),
190
+ obsp_key=OBSP_CONNECTIVITIES_SHARED_GENES,
191
+ )
192
+ modularity_shared_leiden = adata_sc.uns[UNS_MODULARITY_SHARED_LEIDEN]
193
+ modularity_st_expression = _spatial_expression_modularity(adata_st)
194
+
195
+ with open(output_data_dir / "modularity_metrics.json", "w") as f:
196
+ json.dump(
197
+ {
198
+ "modularity_all": float(modularity_all),
199
+ "modularity_shared": float(modularity_shared),
200
+ "modularity_shared_leiden": float(modularity_shared_leiden),
201
+ "modularity_st_expression": float(modularity_st_expression),
202
+ },
203
+ f,
204
+ indent=4,
205
+ )
@@ -0,0 +1,49 @@
1
+ """Per-spot mapping-confidence summary for the post-mapping analysis.
2
+
3
+ Only produced when the mapper defined a confidence (``obs[OBS_MAPPING_CONFIDENCE]``,
4
+ loaded by ``loading.py``); the reference mappers define none, so the step is a
5
+ no-op for them and the GUI simply omits the confidence view.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from anndata import AnnData
17
+
18
+ from aim.adata_schema import OBS_MAPPING_CONFIDENCE
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def analyse_spot_confidence(adata_st: AnnData, data_dir: Path) -> None:
24
+ """Summarise the per-spot mapping confidence, when the mapper defined one.
25
+
26
+ Requires: adata_st.obs[OBS_MAPPING_CONFIDENCE] (skipped if absent). Writes
27
+ confidence_per_spot.csv and confidence_summary.json under data_dir.
28
+ """
29
+ if OBS_MAPPING_CONFIDENCE not in adata_st.obs:
30
+ logger.info("No per-spot confidence for this mapping; skipping.")
31
+ return
32
+
33
+ conf = adata_st.obs[OBS_MAPPING_CONFIDENCE].to_numpy(dtype=float)
34
+
35
+ pd.DataFrame({"id": adata_st.obs_names, "confidence": conf}).to_csv(
36
+ data_dir / "confidence_per_spot.csv", index=False
37
+ )
38
+ summary = {
39
+ "n_spots": int(conf.size),
40
+ "mean": float(np.mean(conf)),
41
+ "median": float(np.median(conf)),
42
+ "min": float(np.min(conf)),
43
+ "max": float(np.max(conf)),
44
+ "std": float(np.std(conf)),
45
+ "frac_above_0.5": float(np.mean(conf >= 0.5)),
46
+ "frac_above_0.9": float(np.mean(conf >= 0.9)),
47
+ }
48
+ with open(data_dir / "confidence_summary.json", "w") as f:
49
+ json.dump(summary, f, indent=2)