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.
- aim/__init__.py +47 -0
- aim/__main__.py +6 -0
- aim/adata_schema.py +80 -0
- aim/aggregation.py +118 -0
- aim/aim_config.py +76 -0
- aim/analysis/__init__.py +13 -0
- aim/analysis/analysis.py +72 -0
- aim/analysis/biology.py +205 -0
- aim/analysis/confidence.py +49 -0
- aim/analysis/ksweep.py +184 -0
- aim/analysis/loading.py +70 -0
- aim/analysis/onehot.py +41 -0
- aim/analysis/reconstruction.py +131 -0
- aim/analysis/topology.py +142 -0
- aim/analysis/utils.py +16 -0
- aim/cli.py +371 -0
- aim/clustering.py +242 -0
- aim/data/__init__.py +0 -0
- aim/data/prepare/__init__.py +0 -0
- aim/data/prepare/build_imaging_pairs.py +397 -0
- aim/data/prepare/build_xenium_breast_pair.py +310 -0
- aim/data/prepare/convertTsvToCsv.py +39 -0
- aim/data/prepare/convert_csv_to_h5ad.py +217 -0
- aim/data/prepare/createOnlyHVGDataset.py +69 -0
- aim/data/prepare/createSmallerDataset.py +102 -0
- aim/data/prepare/normalize_gene_names.py +56 -0
- aim/data/prepare/perturb_sc_noise.py +107 -0
- aim/data/prepare/register_st_patches.py +180 -0
- aim/data/prepare/split_st_into_islands.py +551 -0
- aim/data/prepare/split_st_into_patches.py +240 -0
- aim/data/prepare/subset_st_hvg.py +43 -0
- aim/data/validate/__init__.py +35 -0
- aim/data/validate/common.py +141 -0
- aim/data/validate/runner.py +335 -0
- aim/data/validate/validate_pair.py +53 -0
- aim/data/validate/validate_sc.py +103 -0
- aim/data/validate/validate_st.py +74 -0
- aim/gui/__init__.py +8 -0
- aim/gui/__main__.py +77 -0
- aim/gui/app.py +1784 -0
- aim/gui/compute.py +106 -0
- aim/gui/data_access.py +292 -0
- aim/gui/render.py +1679 -0
- aim/gui/scaffold.py +129 -0
- aim/gui/widgets.py +675 -0
- aim/io.py +196 -0
- aim/mapping/__init__.py +14 -0
- aim/mapping/base.py +67 -0
- aim/mapping/confidence.py +51 -0
- aim/mapping/nearest_centroid.py +33 -0
- aim/mapping/reference.py +156 -0
- aim/mapping/wann.py +208 -0
- aim/metrics/__init__.py +26 -0
- aim/metrics/biology.py +69 -0
- aim/metrics/cossim.py +127 -0
- aim/metrics/kselection.py +272 -0
- aim/metrics/onehot.py +54 -0
- aim/metrics/reconstruction.py +73 -0
- aim/metrics/topology.py +62 -0
- aim/reference_aligners/__init__.py +0 -0
- aim/reference_aligners/environment_dot.yml +23 -0
- aim/reference_aligners/environment_tacco.yml +28 -0
- aim/reference_aligners/environment_tangram.yml +16 -0
- aim/reference_aligners/registry.py +350 -0
- aim/reference_aligners/run_dot.R +172 -0
- aim/reference_aligners/run_dot.py +116 -0
- aim/reference_aligners/run_tacco.py +143 -0
- aim/reference_aligners/run_tangram.py +210 -0
- aim/sweep.py +261 -0
- aim/tree.py +86 -0
- spatial_aim-1.0.0.dist-info/METADATA +1005 -0
- spatial_aim-1.0.0.dist-info/RECORD +76 -0
- spatial_aim-1.0.0.dist-info/WHEEL +5 -0
- spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
- spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
- 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
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]()
|
aim/analysis/__init__.py
ADDED
|
@@ -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}")
|
aim/analysis/analysis.py
ADDED
|
@@ -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)
|
aim/analysis/biology.py
ADDED
|
@@ -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)
|