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/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)
|
aim/analysis/loading.py
ADDED
|
@@ -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")
|
aim/analysis/topology.py
ADDED
|
@@ -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)
|