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.
- sigma_omics-0.1.0a1/.github/workflows/publish.yml +32 -0
- sigma_omics-0.1.0a1/.gitignore +9 -0
- sigma_omics-0.1.0a1/LICENSE +21 -0
- sigma_omics-0.1.0a1/PKG-INFO +80 -0
- sigma_omics-0.1.0a1/README.md +47 -0
- sigma_omics-0.1.0a1/environment.yml +21 -0
- sigma_omics-0.1.0a1/examples/basic_usage.py +7 -0
- sigma_omics-0.1.0a1/pyproject.toml +43 -0
- sigma_omics-0.1.0a1/scripts/build_hbc515_clean_notebooks.py +333 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/__init__.py +22 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/boundary.py +36 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/graph.py +76 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/metrics.py +22 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/model.py +58 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/pipeline.py +105 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/plotting.py +100 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/preprocessing.py +39 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/simulation.py +114 -0
- sigma_omics-0.1.0a1/src/sigma_spatial/utils.py +15 -0
- sigma_omics-0.1.0a1/tests/test_import.py +3 -0
- sigma_omics-0.1.0a1/tests/test_reference_preservation.py +50 -0
- sigma_omics-0.1.0a1/tests/test_simulation_preservation.py +21 -0
|
@@ -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,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,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
|
+
]
|