sigma-omics 0.1.0a1__tar.gz

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.
@@ -0,0 +1,32 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: "3.11"
14
+ - run: python -m pip install --upgrade build
15
+ - run: python -m build
16
+ - uses: actions/upload-artifact@v4
17
+ with:
18
+ name: python-package-distributions
19
+ path: dist/
20
+
21
+ publish:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/download-artifact@v4
29
+ with:
30
+ name: python-package-distributions
31
+ path: dist/
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .ipynb_checkpoints/
4
+ .venv/
5
+ env/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bingxue Du
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: sigma-omics
3
+ Version: 0.1.0a1
4
+ Summary: Spatial interface analysis for spatial metabolomics and transcriptomics
5
+ Project-URL: Homepage, https://github.com/Elsa-bingxue/SIGMA
6
+ Project-URL: Repository, https://github.com/Elsa-bingxue/SIGMA
7
+ Project-URL: Issues, https://github.com/Elsa-bingxue/SIGMA/issues
8
+ Author-email: Bingxue Du <dubingxue73@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: graph neural network,mass spectrometry imaging,spatial metabolomics,spatial omics,tumor boundary
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: anndata>=0.10
18
+ Requires-Dist: numpy>=1.24
19
+ Requires-Dist: pandas>=2.0
20
+ Requires-Dist: scanpy>=1.10
21
+ Requires-Dist: scikit-learn>=1.3
22
+ Requires-Dist: scipy>=1.10
23
+ Requires-Dist: torch-geometric>=2.5
24
+ Requires-Dist: torch>=2.1
25
+ Provides-Extra: dev
26
+ Requires-Dist: build>=1.2; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Requires-Dist: twine>=5; extra == 'dev'
30
+ Provides-Extra: plot
31
+ Requires-Dist: matplotlib>=3.8; extra == 'plot'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # SIGMA (`sigma-omics`)
35
+
36
+ SIGMA is a Python toolkit for spatial interface analysis integrating mass-spectrometry imaging (MSI) and spatial transcriptomic representations.
37
+
38
+ > This package is currently an alpha release. Its scientific definitions are
39
+ > preserved from the HBC515 reference implementation while the API and
40
+ > multi-dataset validation are being completed.
41
+
42
+ ## Install
43
+
44
+ After the first PyPI release:
45
+
46
+ ```bash
47
+ pip install sigma-omics
48
+ ```
49
+
50
+ The distribution name is `sigma-omics`; the Python import remains
51
+ `sigma_spatial`.
52
+
53
+ ## Install from source
54
+
55
+ ```bash
56
+ pip install -e .
57
+ ```
58
+
59
+ ## Minimal usage
60
+
61
+ ```python
62
+ import scanpy as sc
63
+ from sigma_spatial import SIGMA
64
+
65
+ adata = sc.read_h5ad("sample.h5ad")
66
+ SIGMA(seed=0).fit(
67
+ adata,
68
+ annotation_key="annotation",
69
+ tumor_label="Tumor",
70
+ stroma_label="Stroma",
71
+ rna_key="X_harmony",
72
+ )
73
+
74
+ # Main outputs
75
+ adata.obs[["sigma_region_probability", "sigma_boundary", "sigma_d_signed"]]
76
+ ```
77
+
78
+ ## Scope
79
+
80
+ The package should contain reusable SIGMA computation only. Manuscript-specific simulation, benchmarking, GO enrichment, plotting, and sample-specific analyses should live under `examples/` or a separate analysis repository.
@@ -0,0 +1,47 @@
1
+ # SIGMA (`sigma-omics`)
2
+
3
+ SIGMA is a Python toolkit for spatial interface analysis integrating mass-spectrometry imaging (MSI) and spatial transcriptomic representations.
4
+
5
+ > This package is currently an alpha release. Its scientific definitions are
6
+ > preserved from the HBC515 reference implementation while the API and
7
+ > multi-dataset validation are being completed.
8
+
9
+ ## Install
10
+
11
+ After the first PyPI release:
12
+
13
+ ```bash
14
+ pip install sigma-omics
15
+ ```
16
+
17
+ The distribution name is `sigma-omics`; the Python import remains
18
+ `sigma_spatial`.
19
+
20
+ ## Install from source
21
+
22
+ ```bash
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Minimal usage
27
+
28
+ ```python
29
+ import scanpy as sc
30
+ from sigma_spatial import SIGMA
31
+
32
+ adata = sc.read_h5ad("sample.h5ad")
33
+ SIGMA(seed=0).fit(
34
+ adata,
35
+ annotation_key="annotation",
36
+ tumor_label="Tumor",
37
+ stroma_label="Stroma",
38
+ rna_key="X_harmony",
39
+ )
40
+
41
+ # Main outputs
42
+ adata.obs[["sigma_region_probability", "sigma_boundary", "sigma_d_signed"]]
43
+ ```
44
+
45
+ ## Scope
46
+
47
+ The package should contain reusable SIGMA computation only. Manuscript-specific simulation, benchmarking, GO enrichment, plotting, and sample-specific analyses should live under `examples/` or a separate analysis repository.
@@ -0,0 +1,21 @@
1
+ name: sigma
2
+ channels:
3
+ - pytorch
4
+ - pyg
5
+ - conda-forge
6
+ - defaults
7
+ dependencies:
8
+ - python=3.10
9
+ - numpy
10
+ - pandas
11
+ - scipy
12
+ - scikit-learn
13
+ - matplotlib
14
+ - scanpy
15
+ - anndata
16
+ - pytorch
17
+ - pytorch-geometric
18
+ - pip
19
+ - pip:
20
+ - build
21
+ - twine
@@ -0,0 +1,7 @@
1
+ import scanpy as sc
2
+ from sigma_spatial import SIGMA
3
+
4
+ adata = sc.read_h5ad("BC_515_Section_1.h5ad")
5
+ model = SIGMA(n_components=64, k=15, seed=0)
6
+ model.fit(adata, annotation_key="annotation", rna_key="X_harmony", epochs=1000)
7
+ adata.write_h5ad("BC_515_SIGMA.h5ad")
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sigma-omics"
7
+ version = "0.1.0a1"
8
+ description = "Spatial interface analysis for spatial metabolomics and transcriptomics"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Bingxue Du", email = "dubingxue73@gmail.com"}
14
+ ]
15
+ keywords = ["spatial omics", "spatial metabolomics", "mass spectrometry imaging", "graph neural network", "tumor boundary"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
21
+ ]
22
+ dependencies = [
23
+ "numpy>=1.24",
24
+ "pandas>=2.0",
25
+ "scipy>=1.10",
26
+ "scikit-learn>=1.3",
27
+ "scanpy>=1.10",
28
+ "anndata>=0.10",
29
+ "torch>=2.1",
30
+ "torch-geometric>=2.5",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ plot = ["matplotlib>=3.8"]
35
+ dev = ["build>=1.2", "twine>=5", "pytest>=8", "ruff>=0.6"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Elsa-bingxue/SIGMA"
39
+ Repository = "https://github.com/Elsa-bingxue/SIGMA"
40
+ Issues = "https://github.com/Elsa-bingxue/SIGMA/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/sigma_spatial"]
@@ -0,0 +1,333 @@
1
+ """Generate the four curated HBC_515 notebooks without touching the original."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ ORIGINAL = ROOT / "HBC_515" / "HBC_515_SIGMA.ipynb"
9
+ OUT = ROOT / "HBC_515" / "clean"
10
+
11
+
12
+ def md(text):
13
+ return {"cell_type": "markdown", "metadata": {}, "source": text.splitlines(True)}
14
+
15
+
16
+ def code(text):
17
+ return {"cell_type": "code", "execution_count": None, "metadata": {},
18
+ "outputs": [], "source": text.splitlines(True)}
19
+
20
+
21
+ def notebook(cells):
22
+ return {"cells": cells, "metadata": {"kernelspec": {"display_name": "Python 3",
23
+ "language": "python", "name": "python3"},
24
+ "language_info": {"name": "python", "version": "3.10"}},
25
+ "nbformat": 4, "nbformat_minor": 5}
26
+
27
+
28
+ def write(name, cells):
29
+ OUT.mkdir(parents=True, exist_ok=True)
30
+ (OUT / name).write_text(json.dumps(notebook(cells), indent=1) + "\n")
31
+
32
+
33
+ def source_cell(original, index):
34
+ cell = original["cells"][index]
35
+ return code("".join(cell.get("source", [])))
36
+
37
+
38
+ common_setup = '''from pathlib import Path
39
+ import sys
40
+
41
+ PROJECT_ROOT = Path.cwd().resolve().parents[1]
42
+ PACKAGE_SRC = PROJECT_ROOT / "sigma_spatial_pypi_starter" / "src"
43
+ if str(PACKAGE_SRC) not in sys.path:
44
+ sys.path.insert(0, str(PACKAGE_SRC))
45
+
46
+ DATA_DIR = PROJECT_ROOT / "HBC_515"
47
+ RESULT_DIR = DATA_DIR / "clean" / "results"
48
+ FIGURE_DIR = RESULT_DIR / "figures"
49
+ RESULT_DIR.mkdir(parents=True, exist_ok=True)
50
+ FIGURE_DIR.mkdir(parents=True, exist_ok=True)
51
+ '''
52
+
53
+
54
+ def build_core():
55
+ cells = [
56
+ md("# HBC_515 — SIGMA core\n\nClean reference workflow derived from original cells 0–43. The original notebook is not modified. Mathematical definitions, graph direction, probability normalization, thresholds, architecture, and hyperparameters are preserved."),
57
+ md("## 1. Environment and paths"), code(common_setup + '''\nimport scanpy as sc
58
+ from sigma_spatial import SIGMA
59
+ from sigma_spatial.plotting import set_publication_style
60
+
61
+ set_publication_style()
62
+ SEED = 0
63
+ '''),
64
+ md("## 2. Load HBC_515"), code('''adata = sc.read_h5ad(DATA_DIR / "BC_515_Section_1.h5ad")
65
+ print(adata)
66
+ print("MSI storage:", "uns[msi]" if "msi" in adata.uns else "layers[raw]" if "raw" in adata.layers else "X")
67
+ '''),
68
+ md("## 3. Fit the preserved SIGMA model\n\nThis calls extracted package functions that match original cells 4–12. Training is intentionally not executed when this notebook is generated."),
69
+ code('''sigma = SIGMA(n_components=64, k=15, seed=SEED)
70
+ sigma.fit(
71
+ adata,
72
+ annotation_key="annotation",
73
+ tumor_label="Tumor",
74
+ stroma_label="Stroma",
75
+ rna_key="X_harmony",
76
+ z_dim=32,
77
+ epochs=1000,
78
+ lr=1e-3,
79
+ lambda_sup=0.05,
80
+ beta_rna=0.05,
81
+ boundary_k=10,
82
+ )
83
+ '''),
84
+ md("## 4. Essential boundary QC"), code('''import matplotlib.pyplot as plt
85
+ from sigma_spatial.plotting import figure_size, save_figure, shared_colorbar, style_spatial_axis
86
+
87
+ xy = adata.obsm["spatial"]
88
+ fig, axes = plt.subplots(1, 3, figsize=figure_size("double"), constrained_layout=True)
89
+ items = [
90
+ ("sigma_region_probability", "Region probability", "viridis"),
91
+ ("sigma_boundary", "Boundary", "Greys"),
92
+ ("sigma_d_signed", "Signed distance", "coolwarm"),
93
+ ]
94
+ for ax, (key, title, cmap) in zip(axes, items):
95
+ artist = ax.scatter(xy[:, 0], xy[:, 1], c=adata.obs[key], s=5, cmap=cmap, rasterized=True)
96
+ ax.set_title(title)
97
+ style_spatial_axis(ax)
98
+ fig.colorbar(artist, ax=ax, shrink=0.72)
99
+ save_figure(fig, FIGURE_DIR / "HBC515_SIGMA_core_QC", formats=("pdf", "svg", "png"))
100
+ '''),
101
+ md("## 5. Persist essential outputs"), code('''output_path = RESULT_DIR / "HBC515_SIGMA_core.h5ad"
102
+ adata.write_h5ad(output_path)
103
+ print(output_path)
104
+ '''),
105
+ md("## 6. Interface profiles, lambda, and metabolite ranking\n\nThe original lambda/ranking implementations have multiple variants. Until golden numerical tables are frozen, cells 21–43 remain provenance-locked and are not silently replaced by the simplified starter implementation. Run the migration cells below only after the core result exists."),
106
+ ]
107
+ return cells
108
+
109
+
110
+ def build_simulation(original):
111
+ cells = [
112
+ md("# HBC_515 — simulation\n\nDerived from original cells 44–61. Simulation generation is imported from the package; metric and selection definitions below remain verbatim pending their next extraction test."),
113
+ md("## 1. Environment and core result"), code(common_setup + '''\nimport numpy as np
114
+ import pandas as pd
115
+ import anndata as ad
116
+ import matplotlib.pyplot as plt
117
+ from scipy.stats import mannwhitneyu, spearmanr
118
+ from sklearn.linear_model import LinearRegression
119
+ from sklearn.metrics import average_precision_score, roc_auc_score
120
+ from sklearn.preprocessing import StandardScaler
121
+ from sigma_spatial.simulation import (
122
+ evaluate_simulation_feature_selection,
123
+ simulate_interface_features,
124
+ zscore_vec,
125
+ )
126
+ from sigma_spatial.plotting import add_panel_labels, figure_size, save_figure, set_publication_style, style_spatial_axis
127
+ set_publication_style()
128
+ adata = ad.read_h5ad(RESULT_DIR / "HBC515_SIGMA_core.h5ad")
129
+ coords = np.asarray(adata.obsm["spatial"])
130
+ d_signed = adata.obs["sigma_d_signed"].to_numpy(float)
131
+ d_abs = np.abs(d_signed)
132
+ tumor_mask = adata.obs["sigma_inside"].to_numpy(bool)
133
+ '''),
134
+ md("## 2. Preserved feature metrics and selection"),
135
+ source_cell(original, 47), source_cell(original, 48), source_cell(original, 50),
136
+ md("## 3. Representative simulation example"), code('''X_sim, meta_rows = simulate_interface_features(
137
+ d_abs=d_abs, d_signed=d_signed, coords=coords, tumor_mask=tumor_mask,
138
+ n_positive=1, n_negative=1, n_region_only=1, n_spatial_background=0,
139
+ n_noise=1, effect_size=0.25, noise_sd=0.5, lam=100,
140
+ random_state=0,
141
+ )
142
+ meta = pd.DataFrame(meta_rows)
143
+ metrics = compute_boundary_feature_metrics(X_sim, d_abs, d_signed=d_signed, tumor_mask=tumor_mask)
144
+ metrics = metrics.merge(meta, on="j", how="left", suffixes=("", "_truth"))
145
+ metrics = add_region_adjusted_distance_metrics(
146
+ df_metrics=metrics, X=X_sim, d_abs=d_abs, tumor_mask=tumor_mask
147
+ )
148
+ selected = select_positive_negative_interface_features_strict(metrics)
149
+ display(selected[["mz", "sim_type", "true_direction", "pred_direction", "is_selected_boundary", "auc", "decay_r2"]])
150
+
151
+ examples = [
152
+ selected[selected["sim_type"] == "positive_boundary"].iloc[0],
153
+ selected[selected["sim_type"] == "negative_boundary"].iloc[0],
154
+ ]
155
+ fig, axes = plt.subplots(2, 3, figsize=(9.2, 6.2), constrained_layout=True)
156
+ column_titles = ["Ground-truth pattern", "Simulated observation (with noise)",
157
+ "SIGMA-recovered pattern"]
158
+ for row_index, row in enumerate(examples):
159
+ j = int(row["j"])
160
+ true_lambda = float(row["true_lambda"])
161
+ lambda_hat = float(row["lambda_hat"]) if np.isfinite(row["lambda_hat"]) else true_lambda
162
+ decay_true = np.exp(-d_abs / (true_lambda + 1e-8))
163
+ decay_hat = np.exp(-d_abs / (lambda_hat + 1e-8))
164
+ if row["sim_type"] == "positive_boundary":
165
+ true_signal, recovered_signal = decay_true, decay_hat
166
+ auc_display = float(row["auc"])
167
+ row_label = "Positive"
168
+ else:
169
+ true_signal, recovered_signal = 1.0 - decay_true, 1.0 - decay_hat
170
+ auc_display = float(row["auc_neg"])
171
+ row_label = "Negative"
172
+ for col_index, values in enumerate([true_signal, X_sim[:, j], recovered_signal]):
173
+ ax = axes[row_index, col_index]
174
+ artist = ax.scatter(coords[:, 0], coords[:, 1], c=values, s=5,
175
+ cmap="viridis", rasterized=True)
176
+ fig.colorbar(artist, ax=ax, shrink=0.72)
177
+ if row_index == 0:
178
+ ax.set_title(column_titles[col_index])
179
+ style_spatial_axis(ax)
180
+ axes[row_index, 0].set_ylabel(
181
+ f"{row_label}\\nAUC={auc_display:.3f}; decay R²={row['decay_r2']:.3f}\\n"
182
+ f"λ true={true_lambda:.1f}; λ estimated={lambda_hat:.1f}",
183
+ fontsize=8,
184
+ )
185
+ fig.suptitle("Representative positive and negative interface simulations")
186
+ save_figure(fig, FIGURE_DIR / "HBC515_representative_simulation_positive_negative",
187
+ formats=("pdf", "svg", "png"))
188
+ '''),
189
+ md("## 4. Effect-size and sensitivity grid"), source_cell(original, 53),
190
+ code('''eval_all_strict, detail_all_strict = run_sensitivity_analysis_strict(
191
+ d_abs=d_abs, d_signed=d_signed, coords=coords, tumor_mask=tumor_mask,
192
+ effect_sizes=[0.25, 0.5, 1.0, 2.0], noise_sds=[0.5],
193
+ lambdas=[200], n_repeats=1, n_positive=100,
194
+ n_negative=100, n_region_only=300, n_spatial_background=300,
195
+ n_noise=500, random_seed=0, p_cutoff=0.05, decay_r2_cutoff=0.05,
196
+ auc_pos_cutoff=0.65, auc_neg_cutoff=0.35,
197
+ )
198
+ eval_all_strict.to_csv(RESULT_DIR / "HBC515_simulation_recovery.csv", index=False)
199
+ detail_all_strict.to_pickle(RESULT_DIR / "HBC515_simulation_detail.pkl")
200
+ '''),
201
+ ]
202
+ return cells
203
+
204
+
205
+ def build_benchmark():
206
+ return [
207
+ md("# HBC_515 — benchmark\n\nBenchmark workflow derived from original cells 62–104. It consumes frozen core and simulation outputs and adds an explicit ROC curve that was absent from the original notebook."),
208
+ md("## 1. Environment and preserved outputs"), code(common_setup + '''\nimport numpy as np
209
+ import pandas as pd
210
+ import matplotlib.pyplot as plt
211
+ from sklearn.metrics import auc, average_precision_score, precision_recall_curve, roc_curve
212
+ from sigma_spatial.plotting import add_panel_labels, figure_size, legend_outside, save_figure, set_publication_style
213
+ from sigma_spatial.simulation import recovery_curve_data
214
+ set_publication_style()
215
+ recovery = pd.read_csv(RESULT_DIR / "HBC515_simulation_recovery.csv")
216
+ detail = pd.read_pickle(RESULT_DIR / "HBC515_simulation_detail.pkl")
217
+ '''),
218
+ md("## 2. Effect-size sensitivity ROC curves\n\nThis is the requested effect-size figure: noise SD, interface width λ, and replicate are fixed, while four effect sizes are compared on the same ROC axes."), code('''from sklearn.metrics import roc_auc_score
219
+
220
+ NOISE_SD = 0.5
221
+ LAMBDA = 200
222
+ REP = 0
223
+ EFFECT_SIZES = [0.25, 0.5, 1.0, 2.0]
224
+ curve_data = detail[
225
+ (detail["noise_sd"] == NOISE_SD)
226
+ & (detail["lambda"] == LAMBDA)
227
+ & (detail["rep"] == REP)
228
+ ].copy()
229
+
230
+ fig, ax = plt.subplots(figsize=(7.2, 5.4), constrained_layout=True)
231
+ colors = ["#0072B2", "#E69F00", "#009E73", "#D55E00"]
232
+ for effect_size, color in zip(EFFECT_SIZES, colors):
233
+ subset = curve_data[curve_data["effect_size"] == effect_size]
234
+ curves = recovery_curve_data(subset)
235
+ ax.plot(
236
+ curves["fpr"], curves["tpr"], color=color, lw=2,
237
+ label=f"Effect={effect_size:g} (AUROC={curves['auroc']:.3f})",
238
+ )
239
+ ax.plot([0, 1], [0, 1], ls="--", lw=1.2, color="#8A5AC2", label="Random")
240
+ ax.set(
241
+ xlabel="False Positive Rate", ylabel="True Positive Rate",
242
+ xlim=(0, 1), ylim=(0, 1.02),
243
+ title=f"Effect-size sensitivity\\nNoise SD={NOISE_SD:g}, λ={LAMBDA}, Rep={REP}",
244
+ )
245
+ ax.legend(loc="lower right", frameon=True, framealpha=0.95)
246
+ ax.spines["top"].set_visible(False)
247
+ ax.spines["right"].set_visible(False)
248
+ save_figure(fig, FIGURE_DIR / "HBC515_effect_size_sensitivity_ROC", formats=("pdf", "svg", "png"))
249
+
250
+ auc_summary = recovery[
251
+ (recovery["noise_sd"] == NOISE_SD)
252
+ & (recovery["lambda"] == LAMBDA)
253
+ & (recovery["rep"] == REP)
254
+ ].sort_values("effect_size")
255
+ fig, ax = plt.subplots(figsize=(4.8, 3.8), constrained_layout=True)
256
+ bars = ax.bar(
257
+ auc_summary["effect_size"].astype(str), auc_summary["auroc"],
258
+ color=colors, width=0.72,
259
+ )
260
+ for bar, value in zip(bars, auc_summary["auroc"]):
261
+ ax.annotate(f"{value:.3f}",
262
+ (bar.get_x() + bar.get_width() / 2, bar.get_height()),
263
+ xytext=(0, 3), textcoords="offset points",
264
+ ha="center", va="bottom", fontsize=8)
265
+ ax.set(
266
+ xlabel="Effect size", ylabel="AUROC", ylim=(0, 1.07),
267
+ title=f"Effect-size sensitivity of AUROC\\nNoise SD={NOISE_SD:g}, λ={LAMBDA}, Rep={REP}",
268
+ )
269
+ ax.spines["top"].set_visible(False)
270
+ ax.spines["right"].set_visible(False)
271
+ save_figure(fig, FIGURE_DIR / "HBC515_effect_size_AUROC", formats=("pdf", "svg", "png"))
272
+ '''),
273
+ md("## 3. AUROC, ROC curve, and AUPRC/AP\n\nThe scalar definitions are unchanged from original cell 49. The ROC/PR curves are new visualizations of the same truth labels and continuous scores."),
274
+ code('''example = detail[(detail["effect_size"] == 1.0) & (detail["noise_sd"] == 0.5) & (detail["lambda"] == 200) & (detail["rep"] == 0)].copy()
275
+ curves = recovery_curve_data(example)
276
+ precision, recall, _ = precision_recall_curve(curves["truth"], curves["score"])
277
+ ap = average_precision_score(curves["truth"], curves["score"])
278
+ fig, axes = plt.subplots(1, 2, figsize=figure_size("double"), constrained_layout=True)
279
+ axes[0].plot(curves["fpr"], curves["tpr"], label=f"SIGMA AUROC={curves['auroc']:.3f}")
280
+ axes[0].plot([0, 1], [0, 1], ls="--", color="0.6")
281
+ axes[0].set(xlabel="False-positive rate", ylabel="True-positive rate", title="ROC curve", xlim=(0, 1), ylim=(0, 1))
282
+ axes[1].plot(recall, precision, label=f"SIGMA AP={ap:.3f}")
283
+ axes[1].set(xlabel="Recall", ylabel="Precision", title="Precision–recall curve", xlim=(0, 1), ylim=(0, 1))
284
+ for ax in axes: ax.legend(frameon=False, loc="lower right")
285
+ add_panel_labels(axes)
286
+ save_figure(fig, FIGURE_DIR / "HBC515_ROC_AUPRC", formats=("pdf", "svg", "png"))
287
+ '''),
288
+ md("## 4. Comparison methods\n\nThe MET-MAP, SpatialDE, Region-DE, top-k, and overlap implementations remain provenance-locked in original cells 63–102. They require optional dependencies and frozen intermediate tables before safe extraction. The existing HBC_515 slide summaries can be inspected without overwriting them."),
289
+ code('''slide_summary = PROJECT_ROOT / "4 slides" / "BC_515_topk_500_summary.csv"
290
+ if slide_summary.exists():
291
+ benchmark_summary = pd.read_csv(slide_summary)
292
+ display(benchmark_summary)
293
+ else:
294
+ print("Missing slide benchmark summary:", slide_summary)
295
+ '''),
296
+ ]
297
+
298
+
299
+ def build_biology(original):
300
+ cells = [
301
+ md("# HBC_515 — biological interpretation and manuscript figures\n\nCurated from original cells 105–156. This notebook consumes a completed core result; it does not retrain SIGMA. Long duplicated plotting implementations are intentionally not copied."),
302
+ md("## 1. Environment and core result"), code(common_setup + '''\nimport numpy as np
303
+ import pandas as pd
304
+ import anndata as ad
305
+ import matplotlib.pyplot as plt
306
+ from sigma_spatial.plotting import add_panel_labels, figure_size, save_figure, set_publication_style, style_spatial_axis
307
+ set_publication_style()
308
+ adata = ad.read_h5ad(RESULT_DIR / "HBC515_SIGMA_core.h5ad")
309
+ coords = np.asarray(adata.obsm["spatial"])
310
+ d_signed = adata.obs["sigma_d_signed"].to_numpy(float)
311
+ '''),
312
+ md("## 2. Transition thickness"), source_cell(original, 106), source_cell(original, 107),
313
+ md("## 3. Region-only versus boundary association"), source_cell(original, 109),
314
+ md("## 4. Real-data validation"), source_cell(original, 116), source_cell(original, 117),
315
+ md("## 5. Metabolic clusters and representative profiles"), source_cell(original, 120), source_cell(original, 121), source_cell(original, 122),
316
+ md("## 6. Near-versus-far and RNA signatures"), source_cell(original, 130), source_cell(original, 134),
317
+ md("## 7. Heatmaps"), source_cell(original, 136), source_cell(original, 138),
318
+ md("## 8. GO enrichment"), source_cell(original, 148), source_cell(original, 149), source_cell(original, 150),
319
+ md("## Migration note\n\nCells 125–129, 131–133, 135, 137, 139–146, and 151–156 contain alternative or duplicated plotting implementations and were not copied. They remain available unchanged in the original notebook pending numerical fixture creation."),
320
+ ]
321
+ return cells
322
+
323
+
324
+ def main():
325
+ original = json.loads(ORIGINAL.read_text())
326
+ write("01_HBC515_SIGMA_core.ipynb", build_core())
327
+ write("02_HBC515_simulation.ipynb", build_simulation(original))
328
+ write("03_HBC515_benchmark.ipynb", build_benchmark())
329
+ write("04_HBC515_biology_figures.ipynb", build_biology(original))
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -0,0 +1,22 @@
1
+ """SIGMA: spatial interface analysis for spatial metabolomics and transcriptomics."""
2
+ from .preprocessing import get_msi_matrix, msi_to_embedding
3
+ from .boundary import (
4
+ build_boundary_from_binary_mask,
5
+ build_boundary_from_field,
6
+ signed_distance_from_boundary_points,
7
+ )
8
+
9
+ __version__ = "0.1.0a1"
10
+ __all__ = [
11
+ "SIGMA", "get_msi_matrix", "msi_to_embedding",
12
+ "build_boundary_from_binary_mask", "build_boundary_from_field",
13
+ "signed_distance_from_boundary_points",
14
+ ]
15
+
16
+
17
+ def __getattr__(name):
18
+ """Keep light-weight utilities importable without the optional torch stack."""
19
+ if name == "SIGMA":
20
+ from .pipeline import SIGMA
21
+ return SIGMA
22
+ raise AttributeError(name)
@@ -0,0 +1,36 @@
1
+ import numpy as np
2
+ from sklearn.neighbors import NearestNeighbors
3
+
4
+
5
+ def build_boundary_from_binary_mask(xy, inside_mask, k_nn=10):
6
+ """Return the reference notebook's same-and-opposite-neighbour boundary."""
7
+ xy = np.asarray(xy, dtype=float)
8
+ inside = np.asarray(inside_mask, dtype=bool)
9
+ k_nn = min(int(k_nn), xy.shape[0] - 1)
10
+ nn = NearestNeighbors(n_neighbors=k_nn + 1).fit(xy)
11
+ idx = nn.kneighbors(xy, return_distance=False)[:, 1:]
12
+ same = np.any(inside[idx] == inside[:, None], axis=1)
13
+ opposite = np.any(inside[idx] != inside[:, None], axis=1)
14
+ return same & opposite
15
+
16
+
17
+ def build_boundary_from_field(xy, field, level=0.5, k_nn=10):
18
+ """Threshold a continuous field and construct the reference boundary."""
19
+ field = np.asarray(field, dtype=float)
20
+ if not np.all(np.isfinite(field)):
21
+ raise ValueError("The boundary-defining field contains non-finite values.")
22
+ inside = field >= float(level)
23
+ boundary = build_boundary_from_binary_mask(xy, inside, k_nn=k_nn)
24
+ return boundary, inside
25
+
26
+
27
+ def signed_distance_from_boundary_points(xy, boundary_mask, inside_mask):
28
+ """Distance to nearest boundary point; tumor/inside side negative, outside positive."""
29
+ xy = np.asarray(xy, dtype=float)
30
+ b = np.asarray(boundary_mask, dtype=bool)
31
+ inside = np.asarray(inside_mask, dtype=bool)
32
+ if not np.any(b):
33
+ raise ValueError("No boundary points were detected.")
34
+ nn = NearestNeighbors(n_neighbors=1).fit(xy[b])
35
+ dist = nn.kneighbors(xy, return_distance=True)[0][:, 0]
36
+ return np.where(inside, -dist, dist)
@@ -0,0 +1,76 @@
1
+ import numpy as np
2
+ from sklearn.neighbors import NearestNeighbors
3
+
4
+
5
+ def gaussian_knn_graph(xy, k=15, sigma=None, symmetrize=False):
6
+ """Build the Gaussian-weighted spatial kNN graph used by SIGMA.
7
+
8
+ The reference notebooks use directed ``i -> neighbour`` edges. The
9
+ ``symmetrize`` option is retained for experiments, but is deliberately
10
+ disabled by default to preserve the published notebook behaviour.
11
+ """
12
+ xy = np.asarray(xy, dtype=np.float32)
13
+ if xy.ndim != 2 or xy.shape[1] < 2:
14
+ raise ValueError("xy must have shape (n_spots, >=2).")
15
+ k = min(int(k), xy.shape[0] - 1)
16
+ nn = NearestNeighbors(n_neighbors=k + 1).fit(xy)
17
+ dist, idx = nn.kneighbors(xy)
18
+ dist, idx = dist[:, 1:], idx[:, 1:]
19
+ if sigma is None:
20
+ sigma = float(np.median(dist[dist > 0]))
21
+ sigma = max(float(sigma), 1e-12)
22
+ w = np.exp(-(dist ** 2) / (2 * sigma ** 2)).astype(np.float32)
23
+ src = np.repeat(np.arange(xy.shape[0]), k)
24
+ dst = idx.reshape(-1)
25
+ ew = w.reshape(-1)
26
+ if symmetrize:
27
+ src0, dst0, ew0 = src, dst, ew
28
+ src = np.concatenate([src0, dst0])
29
+ dst = np.concatenate([dst0, src0])
30
+ ew = np.concatenate([ew0, ew0])
31
+ # merge duplicate edges by maximum weight
32
+ key = src.astype(np.int64) * xy.shape[0] + dst.astype(np.int64)
33
+ order = np.argsort(key)
34
+ key, src, dst, ew = key[order], src[order], dst[order], ew[order]
35
+ starts = np.r_[0, np.flatnonzero(np.diff(key)) + 1]
36
+ src = src[starts]
37
+ dst = dst[starts]
38
+ ew = np.maximum.reduceat(ew, starts)
39
+ return np.vstack([src, dst]).astype(np.int64), ew.astype(np.float32), sigma
40
+
41
+
42
+ def gaussian_label_smoothing(edge_index, edge_weight, y, labeled_mask,
43
+ n_iter=50, alpha=0.85):
44
+ """Diffuse binary anchors while clamping labeled nodes each iteration."""
45
+ y = np.asarray(y)
46
+ labeled_mask = np.asarray(labeled_mask, dtype=bool)
47
+ n = y.shape[0]
48
+ field = np.zeros(n, dtype=np.float32)
49
+ field[labeled_mask] = y[labeled_mask].astype(np.float32)
50
+ anchor = field.copy()
51
+ src, dst = np.asarray(edge_index)
52
+ degree = np.zeros(n, dtype=np.float32)
53
+ np.add.at(degree, src, edge_weight)
54
+ degree = np.maximum(degree, 1e-12)
55
+ for _ in range(int(n_iter)):
56
+ message = np.zeros(n, dtype=np.float32)
57
+ np.add.at(message, src, edge_weight * field[dst])
58
+ updated = alpha * message / degree + (1.0 - alpha) * anchor
59
+ updated[labeled_mask] = anchor[labeled_mask]
60
+ field = updated
61
+ return field
62
+
63
+
64
+ def smooth_embedding(edge_index, edge_weight, e, n_iter=10, alpha=0.9):
65
+ """Spatial diffusion of an embedding on a weighted graph."""
66
+ e = np.asarray(e, dtype=np.float32)
67
+ f = e.copy()
68
+ src, dst = edge_index
69
+ deg = np.zeros(e.shape[0], dtype=np.float32)
70
+ np.add.at(deg, src, edge_weight)
71
+ deg = np.maximum(deg, 1e-12)
72
+ for _ in range(int(n_iter)):
73
+ msg = np.zeros_like(f)
74
+ np.add.at(msg, src, edge_weight[:, None] * f[dst])
75
+ f = alpha * msg / deg[:, None] + (1.0 - alpha) * e
76
+ return f
@@ -0,0 +1,22 @@
1
+ import numpy as np
2
+ from scipy.stats import spearmanr
3
+
4
+
5
+ def boundary_enrichment_score(values, d_signed, width):
6
+ """Mean interface signal relative to the remaining tissue."""
7
+ x = np.asarray(values, dtype=float)
8
+ d = np.asarray(d_signed, dtype=float)
9
+ near = np.abs(d) <= float(width)
10
+ valid = np.isfinite(x) & np.isfinite(d)
11
+ if (near & valid).sum() < 3 or ((~near) & valid).sum() < 3:
12
+ return np.nan
13
+ return float(np.nanmean(x[near & valid]) - np.nanmean(x[(~near) & valid]))
14
+
15
+
16
+ def distance_association(values, d_signed):
17
+ x = np.asarray(values, dtype=float)
18
+ d = np.asarray(d_signed, dtype=float)
19
+ valid = np.isfinite(x) & np.isfinite(d)
20
+ if valid.sum() < 3:
21
+ return np.nan, np.nan
22
+ return spearmanr(np.abs(d[valid]), x[valid])
@@ -0,0 +1,58 @@
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from torch_geometric.nn import MessagePassing
5
+ from torch_geometric.utils import add_self_loops, degree
6
+
7
+
8
+ class GraphLowPass(MessagePassing):
9
+ def __init__(self):
10
+ super().__init__(aggr="add")
11
+
12
+ def forward(self, x, edge_index, edge_weight=None):
13
+ n = x.size(0)
14
+ if edge_weight is None:
15
+ edge_weight = torch.ones(edge_index.size(1), device=x.device, dtype=x.dtype)
16
+ edge_index, edge_weight = add_self_loops(edge_index, edge_weight, fill_value=1.0, num_nodes=n)
17
+ row, col = edge_index
18
+ # Preserve the normalization used in all reference notebooks.
19
+ deg = degree(col, n, dtype=x.dtype)
20
+ deg_inv_sqrt = deg.pow(-0.5)
21
+ deg_inv_sqrt[torch.isinf(deg_inv_sqrt)] = 0
22
+ norm = deg_inv_sqrt[row] * edge_weight * deg_inv_sqrt[col]
23
+ return self.propagate(edge_index, x=x, norm=norm)
24
+
25
+ def message(self, x_j, norm):
26
+ return norm.view(-1, 1) * x_j
27
+
28
+
29
+ class SpectralHighPassBlock(nn.Module):
30
+ def __init__(self, in_dim, out_dim, dropout=0.1):
31
+ super().__init__()
32
+ self.lowpass = GraphLowPass()
33
+ self.lin = nn.Linear(in_dim, out_dim)
34
+ self.norm = nn.LayerNorm(out_dim)
35
+ self.dropout = nn.Dropout(dropout)
36
+
37
+ def forward(self, x, edge_index, edge_weight=None):
38
+ hp = x - self.lowpass(x, edge_index, edge_weight)
39
+ return self.dropout(F.relu(self.norm(self.lin(hp))))
40
+
41
+
42
+ class SpectralResidualNet(nn.Module):
43
+ def __init__(self, in_dim, hidden=128, out_dim=64, r_dim=None, z_dim=32, use_region_head=True):
44
+ super().__init__()
45
+ r_dim = in_dim if r_dim is None else r_dim
46
+ self.hp1 = SpectralHighPassBlock(in_dim, hidden)
47
+ self.hp2 = SpectralHighPassBlock(hidden, out_dim)
48
+ self.residual_head = nn.Linear(out_dim, r_dim)
49
+ self.rna_head = nn.Linear(out_dim, z_dim)
50
+ self.use_region_head = use_region_head
51
+ self.region_head = nn.Linear(out_dim, 1) if use_region_head else None
52
+
53
+ def forward(self, x, edge_index, edge_weight=None):
54
+ h = self.hp2(self.hp1(x, edge_index, edge_weight), edge_index, edge_weight)
55
+ out = {"r": self.residual_head(h), "z_hat": self.rna_head(h), "h": h}
56
+ if self.region_head is not None:
57
+ out["logit"] = self.region_head(h).squeeze(-1)
58
+ return out
@@ -0,0 +1,105 @@
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from torch_geometric.data import Data
5
+ from .preprocessing import get_msi_matrix, msi_to_embedding
6
+ from .graph import gaussian_knn_graph, gaussian_label_smoothing, smooth_embedding
7
+ from .model import SpectralResidualNet
8
+ from .boundary import build_boundary_from_field, signed_distance_from_boundary_points
9
+ from .utils import set_seed
10
+
11
+
12
+ class SIGMA:
13
+ """Minimal end-to-end SIGMA estimator.
14
+
15
+ Core workflow only: MSI embedding -> spatial graph -> Gaussian low-pass prior ->
16
+ spectral residual learning -> region field -> boundary -> signed distance.
17
+ """
18
+ def __init__(self, n_components=64, k=15, seed=0, device=None):
19
+ self.n_components = n_components
20
+ self.k = k
21
+ self.seed = seed
22
+ self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
23
+
24
+ def fit(self, adata, annotation_key="annotation", tumor_label="Tumor", stroma_label="Stroma",
25
+ rna_key="X_harmony", z_dim=32, epochs=1000, lr=1e-3,
26
+ lambda_sup=0.05, beta_rna=0.05, boundary_k=10):
27
+ set_seed(self.seed)
28
+ if "spatial" not in adata.obsm:
29
+ raise KeyError("adata.obsm['spatial'] is required")
30
+ if annotation_key not in adata.obs:
31
+ raise KeyError(f"adata.obs[{annotation_key!r}] is required")
32
+ if rna_key not in adata.obsm:
33
+ raise KeyError(f"adata.obsm[{rna_key!r}] is required")
34
+
35
+ xy = np.asarray(adata.obsm["spatial"], dtype=np.float32)
36
+ ann = adata.obs[annotation_key].astype(str).to_numpy()
37
+ x_msi = get_msi_matrix(adata)
38
+ e = msi_to_embedding(x_msi, self.n_components, self.seed)
39
+ edge_index, edge_w, sigma = gaussian_knn_graph(xy, self.k, symmetrize=False)
40
+ e_gauss = smooth_embedding(edge_index, edge_w, e)
41
+ r_target = e - e_gauss
42
+
43
+ y = np.full(adata.n_obs, -1, dtype=np.int64)
44
+ y[ann == tumor_label] = 1
45
+ y[ann == stroma_label] = 0
46
+ mask = y >= 0
47
+ if not np.any(y[mask] == 1) or not np.any(y[mask] == 0):
48
+ raise ValueError("Both tumor and stroma anchors are required.")
49
+ f_gauss = gaussian_label_smoothing(
50
+ edge_index, edge_w, y, mask, n_iter=50, alpha=0.85
51
+ )
52
+
53
+ z = np.asarray(adata.obsm[rna_key][:, :z_dim], dtype=np.float32)
54
+ z = (z - z.mean(0)) / (z.std(0) + 1e-6)
55
+ data = Data(
56
+ x=torch.tensor(e_gauss, dtype=torch.float32),
57
+ edge_index=torch.tensor(edge_index, dtype=torch.long),
58
+ edge_attr=torch.tensor(edge_w, dtype=torch.float32),
59
+ ).to(self.device)
60
+ r_t = torch.tensor(r_target, dtype=torch.float32, device=self.device)
61
+ z_t = torch.tensor(z, dtype=torch.float32, device=self.device)
62
+ idx = torch.tensor(np.where(mask)[0], dtype=torch.long, device=self.device)
63
+ y_t = torch.tensor(y[mask], dtype=torch.float32, device=self.device)
64
+
65
+ self.model_ = SpectralResidualNet(e.shape[1], hidden=128, out_dim=e.shape[1], r_dim=e.shape[1], z_dim=z.shape[1]).to(self.device)
66
+ opt = torch.optim.Adam(self.model_.parameters(), lr=lr, weight_decay=1e-4)
67
+ pos, neg = (y[mask] == 1).sum(), (y[mask] == 0).sum()
68
+ bce_weight = torch.tensor([neg / max(pos, 1)], dtype=torch.float32, device=self.device)
69
+
70
+ self.loss_history_ = []
71
+ for _ in range(int(epochs)):
72
+ self.model_.train(); opt.zero_grad()
73
+ out = self.model_(data.x, data.edge_index, data.edge_attr)
74
+ loss_rec = F.smooth_l1_loss(out["r"], r_t)
75
+ loss_sup = F.binary_cross_entropy_with_logits(out["logit"][idx], y_t, pos_weight=bce_weight)
76
+ loss_rna = F.mse_loss(out["z_hat"], z_t)
77
+ loss = loss_rec + lambda_sup * loss_sup + beta_rna * loss_rna
78
+ loss.backward(); opt.step()
79
+ self.loss_history_.append(float(loss.detach().cpu()))
80
+
81
+ self.model_.eval()
82
+ with torch.no_grad():
83
+ out = self.model_(data.x, data.edge_index, data.edge_attr)
84
+ r = out["r"].cpu().numpy()
85
+ p_raw = torch.sigmoid(out["logit"]).cpu().numpy()
86
+ p = (p_raw - np.nanmin(p_raw)) / (
87
+ np.nanmax(p_raw) - np.nanmin(p_raw) + 1e-8
88
+ )
89
+
90
+ boundary, inside = build_boundary_from_field(xy, p, level=0.5, k_nn=boundary_k)
91
+ d_signed = signed_distance_from_boundary_points(xy, boundary, inside)
92
+
93
+ adata.obsm["X_sigma_msi"] = e
94
+ adata.obsm["X_sigma_gauss"] = e_gauss
95
+ adata.obsm["X_sigma_residual"] = r
96
+ adata.obsm["X_sigma_corrected"] = e_gauss + r
97
+ adata.obs["sigma_gaussian_anchor"] = f_gauss
98
+ adata.obs["sigma_region_probability_raw"] = p_raw
99
+ adata.obs["sigma_region_probability"] = p
100
+ adata.obs["sigma_inside"] = inside
101
+ adata.obs["sigma_boundary"] = boundary
102
+ adata.obs["sigma_d_signed"] = d_signed
103
+ adata.uns["sigma"] = {"sigma": sigma, "k": self.k, "seed": self.seed}
104
+ self.adata_ = adata
105
+ return self
@@ -0,0 +1,100 @@
1
+ """Shared, layout-safe plotting helpers for SIGMA analyses."""
2
+
3
+ from pathlib import Path
4
+ import string
5
+
6
+ import matplotlib as mpl
7
+ import matplotlib.pyplot as plt
8
+
9
+
10
+ FIGURE_SIZES = {
11
+ "single": (3.35, 2.7),
12
+ "single_square": (3.35, 3.35),
13
+ "double": (7.0, 4.2),
14
+ "double_square": (7.0, 7.0),
15
+ "full_page": (7.0, 9.0),
16
+ "slides_wide": (10.0, 4.8),
17
+ }
18
+
19
+
20
+ def set_publication_style(font_family="Arial", base_font_size=9):
21
+ """Apply a consistent vector-friendly manuscript style."""
22
+ mpl.rcParams.update({
23
+ "font.family": font_family,
24
+ "font.size": base_font_size,
25
+ "axes.titlesize": base_font_size + 1,
26
+ "axes.labelsize": base_font_size,
27
+ "xtick.labelsize": base_font_size - 1,
28
+ "ytick.labelsize": base_font_size - 1,
29
+ "legend.fontsize": base_font_size - 1,
30
+ "figure.titlesize": base_font_size + 2,
31
+ "axes.linewidth": 0.8,
32
+ "lines.linewidth": 1.2,
33
+ "pdf.fonttype": 42,
34
+ "ps.fonttype": 42,
35
+ "svg.fonttype": "none",
36
+ "savefig.dpi": 300,
37
+ "savefig.bbox": None,
38
+ })
39
+
40
+
41
+ def figure_size(name="double"):
42
+ """Return a standard manuscript figure size."""
43
+ try:
44
+ return FIGURE_SIZES[name]
45
+ except KeyError as exc:
46
+ raise ValueError(f"Unknown figure size {name!r}") from exc
47
+
48
+
49
+ def add_panel_labels(axes, labels=None, x=-0.12, y=1.06, fontsize=11):
50
+ """Add consistent panel labels in axes coordinates."""
51
+ axes = list(axes)
52
+ labels = list(labels or string.ascii_uppercase[:len(axes)])
53
+ for ax, label in zip(axes, labels):
54
+ ax.text(x, y, label, transform=ax.transAxes, ha="left", va="top",
55
+ fontsize=fontsize, fontweight="bold", clip_on=False)
56
+
57
+
58
+ def legend_outside(ax, *, loc="upper left", anchor=(1.02, 1.0), **kwargs):
59
+ """Place a legend outside the data axes."""
60
+ return ax.legend(loc=loc, bbox_to_anchor=anchor, borderaxespad=0,
61
+ frameon=False, **kwargs)
62
+
63
+
64
+ def shared_colorbar(fig, mappable, axes, *, label=None, location="right", **kwargs):
65
+ """Create one colorbar for a collection of axes."""
66
+ colorbar = fig.colorbar(mappable, ax=list(axes), location=location, **kwargs)
67
+ if label:
68
+ colorbar.set_label(label)
69
+ return colorbar
70
+
71
+
72
+ def style_spatial_axis(ax):
73
+ """Remove decorations that are not meaningful for tissue coordinates."""
74
+ ax.set_aspect("equal")
75
+ ax.set_xticks([])
76
+ ax.set_yticks([])
77
+ ax.set_xlabel("")
78
+ ax.set_ylabel("")
79
+ for spine in ax.spines.values():
80
+ spine.set_visible(False)
81
+
82
+
83
+ def save_figure(fig, path, *, formats=("pdf", "svg"), dpi=300,
84
+ transparent=False, close=False):
85
+ """Save a figure in consistent vector and optional raster formats.
86
+
87
+ ``path`` is a stem or a filename. Layout is expected to be solved by
88
+ constrained layout/GridSpec rather than repaired with ``bbox_inches``.
89
+ """
90
+ path = Path(path)
91
+ stem = path.with_suffix("") if path.suffix else path
92
+ stem.parent.mkdir(parents=True, exist_ok=True)
93
+ written = []
94
+ for fmt in formats:
95
+ output = stem.with_suffix(f".{fmt.lstrip('.')}")
96
+ fig.savefig(output, dpi=dpi, transparent=transparent, facecolor=fig.get_facecolor())
97
+ written.append(output)
98
+ if close:
99
+ plt.close(fig)
100
+ return written
@@ -0,0 +1,39 @@
1
+ import numpy as np
2
+ from scipy.sparse import issparse
3
+ from sklearn.decomposition import TruncatedSVD
4
+ from sklearn.preprocessing import RobustScaler
5
+
6
+
7
+ def get_msi_matrix(adata, layer=None):
8
+ """Return an n_spots x n_features MSI matrix from AnnData."""
9
+ if layer is not None:
10
+ if layer not in adata.layers:
11
+ raise KeyError(f"Layer {layer!r} not found in adata.layers")
12
+ return adata.layers[layer]
13
+ if "msi" in adata.uns:
14
+ x = adata.uns["msi"]
15
+ if hasattr(x, "shape") and x.shape[0] == adata.n_obs:
16
+ return x
17
+ if "raw" in adata.layers:
18
+ x = adata.layers["raw"]
19
+ if hasattr(x, "shape") and x.shape[0] == adata.n_obs:
20
+ return x
21
+ return adata.X
22
+
23
+
24
+ def msi_to_embedding(x, n_components=64, random_state=0):
25
+ """log1p + robust scaling (dense only) + TruncatedSVD + z-score."""
26
+ max_components = min(x.shape) - 1
27
+ k = min(int(n_components), max_components)
28
+ if k < 1:
29
+ raise ValueError("MSI matrix is too small for SVD.")
30
+ if issparse(x):
31
+ xlog = x.copy()
32
+ xlog.data = np.log1p(xlog.data)
33
+ e = TruncatedSVD(n_components=k, random_state=random_state).fit_transform(xlog)
34
+ else:
35
+ xlog = np.log1p(np.asarray(x, dtype=np.float32))
36
+ xs = RobustScaler(quantile_range=(10, 90)).fit_transform(xlog)
37
+ e = TruncatedSVD(n_components=k, random_state=random_state).fit_transform(xs)
38
+ e = np.asarray(e, dtype=np.float32)
39
+ return (e - e.mean(0)) / (e.std(0) + 1e-6)
@@ -0,0 +1,114 @@
1
+ """Simulation helpers preserved from HBC_515 cells 46 and 49."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from sklearn.metrics import (
6
+ average_precision_score, f1_score, precision_score, recall_score,
7
+ roc_auc_score, roc_curve,
8
+ )
9
+
10
+
11
+ def zscore_vec(x):
12
+ x = np.asarray(x, dtype=float)
13
+ return (x - np.nanmean(x)) / (np.nanstd(x) + 1e-8)
14
+
15
+
16
+ def simulate_interface_features(d_abs, d_signed=None, coords=None, tumor_mask=None,
17
+ n_positive=100, n_negative=100,
18
+ n_region_only=300, n_spatial_background=300,
19
+ n_noise=500, effect_size=1.0, noise_sd=0.5,
20
+ lam=200.0, random_state=0):
21
+ """Generate the five feature classes used in HBC_515 simulation cell 46."""
22
+ rng = np.random.default_rng(random_state)
23
+ d_abs = np.asarray(d_abs, dtype=float)
24
+ n = len(d_abs)
25
+ if d_signed is None:
26
+ d_signed = d_abs.copy()
27
+ if tumor_mask is None:
28
+ tumor_mask = np.asarray(d_signed) < 0
29
+ tumor_mask = np.asarray(tumor_mask, dtype=bool)
30
+ if coords is None:
31
+ coords = np.column_stack([rng.normal(size=n), rng.normal(size=n)])
32
+ coords = np.asarray(coords, dtype=float)
33
+ x_coord, y_coord = zscore_vec(coords[:, 0]), zscore_vec(coords[:, 1])
34
+ features, rows = [], []
35
+
36
+ def add_feature(vec, sim_type, direction, true_lambda=np.nan):
37
+ j = len(features)
38
+ vec = np.asarray(vec, dtype=float)
39
+ vec = vec - np.nanmin(vec)
40
+ vec = vec / (np.nanstd(vec) + 1e-8)
41
+ features.append(vec)
42
+ rows.append({"j": j, "mz": f"sim_mz_{j}", "sim_type": sim_type,
43
+ "true_direction": direction, "true_lambda": true_lambda,
44
+ "is_boundary_truth": sim_type in ("positive_boundary", "negative_boundary"),
45
+ "is_positive_truth": sim_type == "positive_boundary",
46
+ "is_negative_truth": sim_type == "negative_boundary"})
47
+
48
+ for _ in range(n_positive):
49
+ local_lam = lam * rng.lognormal(mean=0, sigma=0.25)
50
+ add_feature(effect_size * np.exp(-d_abs / (local_lam + 1e-8)) +
51
+ rng.normal(0, noise_sd, n), "positive_boundary", "positive", local_lam)
52
+ for _ in range(n_negative):
53
+ local_lam = lam * rng.lognormal(mean=0, sigma=0.25)
54
+ add_feature(effect_size * (1 - np.exp(-d_abs / (local_lam + 1e-8))) +
55
+ rng.normal(0, noise_sd, n), "negative_boundary", "negative", local_lam)
56
+ for _ in range(n_region_only):
57
+ sign = rng.choice([-1, 1])
58
+ add_feature(effect_size * sign * tumor_mask.astype(float) +
59
+ rng.normal(0, noise_sd, n), "region_only", "region")
60
+ for _ in range(n_spatial_background):
61
+ angle = rng.uniform(0, 2 * np.pi)
62
+ signal = zscore_vec(np.cos(angle) * x_coord + np.sin(angle) * y_coord)
63
+ add_feature(effect_size * signal + rng.normal(0, noise_sd, n),
64
+ "spatial_background", "background")
65
+ for _ in range(n_noise):
66
+ add_feature(rng.normal(0, noise_sd, n), "noise", "noise")
67
+ # The original returns a list of records despite documenting a DataFrame.
68
+ return np.vstack(features).T, rows
69
+
70
+
71
+ def recovery_curve_data(df):
72
+ """Return ROC/AP inputs using the exact continuous score from HBC_515 cell 49."""
73
+ truth = df["is_boundary_truth"].astype(int).to_numpy()
74
+ score = np.maximum(df["score_positive"].fillna(-np.inf).to_numpy(),
75
+ df["score_negative"].fillna(-np.inf).to_numpy())
76
+ fpr, tpr, thresholds = roc_curve(truth, score)
77
+ return {"truth": truth, "score": score, "fpr": fpr, "tpr": tpr,
78
+ "thresholds": thresholds, "auroc": roc_auc_score(truth, score),
79
+ "average_precision": average_precision_score(truth, score)}
80
+
81
+
82
+ def evaluate_simulation_feature_selection(df):
83
+ """Preserve the scalar recovery metrics from HBC_515 cell 49."""
84
+ truth = df["is_boundary_truth"].astype(int).to_numpy()
85
+ selected = df["is_selected_boundary"].astype(int).to_numpy()
86
+ curves = recovery_curve_data(df)
87
+ selected_true = df[df["is_selected_boundary"] & df["is_boundary_truth"]]
88
+ direction_accuracy = (np.mean(selected_true["pred_direction"].to_numpy() ==
89
+ selected_true["true_direction"].to_numpy())
90
+ if len(selected_true) else np.nan)
91
+ pos_recall = recall_score(df["is_positive_truth"].astype(int),
92
+ (df["pred_direction"] == "positive").astype(int), zero_division=0)
93
+ neg_recall = recall_score(df["is_negative_truth"].astype(int),
94
+ (df["pred_direction"] == "negative").astype(int), zero_division=0)
95
+ lambda_df = df[(df["true_direction"] == "positive") &
96
+ np.isfinite(df["true_lambda"]) & np.isfinite(df["lambda_hat"])]
97
+ if len(lambda_df):
98
+ error = np.abs(lambda_df["lambda_hat"] - lambda_df["true_lambda"])
99
+ lambda_mae = error.mean()
100
+ lambda_relative_error = (error / (lambda_df["true_lambda"] + 1e-8)).mean()
101
+ else:
102
+ lambda_mae = lambda_relative_error = np.nan
103
+ return pd.DataFrame([{
104
+ "precision": precision_score(truth, selected, zero_division=0),
105
+ "recall": recall_score(truth, selected, zero_division=0),
106
+ "f1": f1_score(truth, selected, zero_division=0),
107
+ "auroc": curves["auroc"], "auprc": curves["average_precision"],
108
+ "direction_accuracy": direction_accuracy, "positive_recall": pos_recall,
109
+ "negative_recall": neg_recall, "lambda_mae": lambda_mae,
110
+ "lambda_relative_error": lambda_relative_error,
111
+ "n_selected": int(selected.sum()), "n_true_boundary": int(truth.sum()),
112
+ "n_positive_selected": int((df["pred_direction"] == "positive").sum()),
113
+ "n_negative_selected": int((df["pred_direction"] == "negative").sum()),
114
+ }])
@@ -0,0 +1,15 @@
1
+ import os
2
+ import random
3
+ import numpy as np
4
+ import torch
5
+
6
+
7
+ def set_seed(seed=0):
8
+ os.environ["PYTHONHASHSEED"] = str(seed)
9
+ random.seed(seed)
10
+ np.random.seed(seed)
11
+ torch.manual_seed(seed)
12
+ if torch.cuda.is_available():
13
+ torch.cuda.manual_seed_all(seed)
14
+ torch.backends.cudnn.deterministic = True
15
+ torch.backends.cudnn.benchmark = False
@@ -0,0 +1,3 @@
1
+ def test_import():
2
+ import sigma_spatial
3
+ assert sigma_spatial.__version__ == "0.1.0a1"
@@ -0,0 +1,50 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ from sigma_spatial.boundary import build_boundary_from_binary_mask
7
+ from sigma_spatial.graph import gaussian_knn_graph, gaussian_label_smoothing, smooth_embedding
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[2]
11
+ ORIGINAL = ROOT / "HBC_515" / "HBC_515_SIGMA.ipynb"
12
+ ORIGINAL_SHA256 = "58cdc1c0d18eddcbeab4718fe9fc5748ae852b3f5ff7dc485d49c2400c4f45d4"
13
+
14
+
15
+ def test_original_hbc515_notebook_is_unchanged():
16
+ assert hashlib.sha256(ORIGINAL.read_bytes()).hexdigest() == ORIGINAL_SHA256
17
+
18
+
19
+ def test_reference_directed_gaussian_graph_values():
20
+ xy = np.array([[0., 0.], [1., 0.], [0., 2.], [3., 0.]], dtype=np.float32)
21
+ edge_index, weights, sigma = gaussian_knn_graph(xy, k=2, symmetrize=False)
22
+ expected_edges = np.array([[0, 0, 1, 1, 2, 2, 3, 3],
23
+ [1, 2, 0, 3, 0, 1, 1, 0]])
24
+ expected_dist = np.array([1., 2., 1., 2., 2., np.sqrt(5), 2., 3.])
25
+ expected_sigma = np.median(expected_dist.reshape(4, 2))
26
+ np.testing.assert_array_equal(edge_index, expected_edges)
27
+ np.testing.assert_allclose(sigma, expected_sigma)
28
+ np.testing.assert_allclose(weights, np.exp(-(expected_dist ** 2) /
29
+ (2 * expected_sigma ** 2)), rtol=1e-6)
30
+
31
+
32
+ def test_label_and_embedding_diffusion_match_frozen_values():
33
+ edges = np.array([[0, 0, 1, 1, 2, 2], [1, 2, 0, 2, 0, 1]])
34
+ weights = np.ones(6, dtype=np.float32)
35
+ y = np.array([1, -1, 0])
36
+ mask = y >= 0
37
+ field = gaussian_label_smoothing(edges, weights, y, mask, n_iter=2, alpha=0.5)
38
+ np.testing.assert_allclose(field, [1.0, 0.25, 0.0], rtol=0, atol=1e-7)
39
+ embedding = np.array([[1., 0.], [0., 1.], [0., 0.]], dtype=np.float32)
40
+ smoothed = smooth_embedding(edges, weights, embedding, n_iter=1, alpha=0.5)
41
+ np.testing.assert_allclose(smoothed, [[0.5, 0.25], [0.25, 0.5], [0.25, 0.25]])
42
+
43
+
44
+ def test_boundary_requires_same_and_opposite_neighbours():
45
+ xy = np.array([[0., 0.], [1., 0.], [2., 0.], [3., 0.]])
46
+ inside = np.array([True, True, False, False])
47
+ np.testing.assert_array_equal(
48
+ build_boundary_from_binary_mask(xy, inside, k_nn=2),
49
+ np.array([True, True, True, True]),
50
+ )
@@ -0,0 +1,21 @@
1
+ import numpy as np
2
+
3
+ from sigma_spatial.simulation import simulate_interface_features
4
+
5
+
6
+ def test_simulation_is_seeded_and_retains_reference_return_type():
7
+ distance = np.linspace(0, 10, 12)
8
+ coords = np.column_stack([distance, np.zeros_like(distance)])
9
+ kwargs = dict(d_abs=distance, coords=coords, n_positive=2, n_negative=1,
10
+ n_region_only=1, n_spatial_background=1, n_noise=1,
11
+ effect_size=0.5, noise_sd=0.2, lam=100, random_state=7)
12
+ x1, meta1 = simulate_interface_features(**kwargs)
13
+ x2, meta2 = simulate_interface_features(**kwargs)
14
+ np.testing.assert_array_equal(x1, x2)
15
+ assert meta1 == meta2
16
+ assert isinstance(meta1, list) # Preserve HBC_515 cell 46 behaviour.
17
+ assert x1.shape == (12, 6)
18
+ assert [row["sim_type"] for row in meta1] == [
19
+ "positive_boundary", "positive_boundary", "negative_boundary",
20
+ "region_only", "spatial_background", "noise",
21
+ ]