kintsugi-st 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kintsugi/__init__.py +168 -0
- kintsugi/_demo.py +169 -0
- kintsugi/_poisson_log_var.py +104 -0
- kintsugi/aggregate.py +138 -0
- kintsugi/demo/__init__.py +5 -0
- kintsugi/demo/__main__.py +5 -0
- kintsugi/graph.py +74 -0
- kintsugi/io/__init__.py +20 -0
- kintsugi/io/anndata.py +134 -0
- kintsugi/io/grid.py +139 -0
- kintsugi/io/visium_hd.py +165 -0
- kintsugi/models.py +183 -0
- kintsugi/partition.py +304 -0
- kintsugi/report.py +294 -0
- kintsugi/tensor.py +68 -0
- kintsugi/variogram.py +150 -0
- kintsugi_st-0.1.0.dist-info/METADATA +394 -0
- kintsugi_st-0.1.0.dist-info/RECORD +21 -0
- kintsugi_st-0.1.0.dist-info/WHEEL +4 -0
- kintsugi_st-0.1.0.dist-info/entry_points.txt +2 -0
- kintsugi_st-0.1.0.dist-info/licenses/LICENSE +21 -0
kintsugi/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Kintsugi: adaptive spatial tessellation for sub-cellular transcriptomics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models import TessellationResult
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"GridData",
|
|
15
|
+
"TessellationReport",
|
|
16
|
+
"TessellationResult",
|
|
17
|
+
"build_regular_grid",
|
|
18
|
+
"load_10x_feature_matrix",
|
|
19
|
+
"load_visium_hd",
|
|
20
|
+
"load_visium_hd_from_dir",
|
|
21
|
+
"parse_visium_barcode_coordinates",
|
|
22
|
+
"read_tissue_positions",
|
|
23
|
+
"validate_grid_data",
|
|
24
|
+
"adaptive_tessellation",
|
|
25
|
+
"aggregate_counts",
|
|
26
|
+
"boundary_tensor",
|
|
27
|
+
"build_spatial_graph",
|
|
28
|
+
"directional_semivariance",
|
|
29
|
+
"poisson_baseline",
|
|
30
|
+
"poisson_log_variance",
|
|
31
|
+
"make_toy_dataset",
|
|
32
|
+
"tessellate",
|
|
33
|
+
"tessellation_report",
|
|
34
|
+
"to_anndata",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_LAZY_EXPORTS = {
|
|
38
|
+
"GridData": (".models", "GridData"),
|
|
39
|
+
"TessellationReport": (".report", "TessellationReport"),
|
|
40
|
+
"TessellationResult": (".models", "TessellationResult"),
|
|
41
|
+
"validate_grid_data": (".models", "validate_grid_data"),
|
|
42
|
+
"build_regular_grid": (".io", "build_regular_grid"),
|
|
43
|
+
"load_10x_feature_matrix": (".io", "load_10x_feature_matrix"),
|
|
44
|
+
"load_visium_hd": (".io", "load_visium_hd"),
|
|
45
|
+
"load_visium_hd_from_dir": (".io", "load_visium_hd_from_dir"),
|
|
46
|
+
"parse_visium_barcode_coordinates": (".io", "parse_visium_barcode_coordinates"),
|
|
47
|
+
"read_tissue_positions": (".io", "read_tissue_positions"),
|
|
48
|
+
"to_anndata": (".io", "to_anndata"),
|
|
49
|
+
"directional_semivariance": (".variogram", "directional_semivariance"),
|
|
50
|
+
"poisson_baseline": (".variogram", "poisson_baseline"),
|
|
51
|
+
"poisson_log_variance": ("._poisson_log_var", "poisson_log_variance"),
|
|
52
|
+
"boundary_tensor": (".tensor", "boundary_tensor"),
|
|
53
|
+
"adaptive_tessellation": (".partition", "adaptive_tessellation"),
|
|
54
|
+
"aggregate_counts": (".aggregate", "aggregate_counts"),
|
|
55
|
+
"build_spatial_graph": (".graph", "build_spatial_graph"),
|
|
56
|
+
"tessellation_report": (".report", "tessellation_report"),
|
|
57
|
+
"make_toy_dataset": ("._demo", "make_toy_dataset"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def __getattr__(name: str):
|
|
62
|
+
if name not in _LAZY_EXPORTS:
|
|
63
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
64
|
+
from importlib import import_module
|
|
65
|
+
|
|
66
|
+
module_name, attr_name = _LAZY_EXPORTS[name]
|
|
67
|
+
value = getattr(import_module(module_name, __name__), attr_name)
|
|
68
|
+
globals()[name] = value
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def tessellate(
|
|
73
|
+
counts,
|
|
74
|
+
rows: int | None = None,
|
|
75
|
+
cols: int | None = None,
|
|
76
|
+
mask=None,
|
|
77
|
+
lag: int = 2,
|
|
78
|
+
kappa: float = 2.0,
|
|
79
|
+
min_seed_distance: int = 4,
|
|
80
|
+
smooth_sigma: float = 4.0,
|
|
81
|
+
) -> "TessellationResult":
|
|
82
|
+
"""Run the full Kintsugi pipeline: variogram -> tensor -> partition -> aggregate -> graph.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
counts : GridData or sparse matrix
|
|
87
|
+
Either a normalized ``GridData`` container or a sparse UMI count
|
|
88
|
+
matrix with shape ``(rows * cols, genes)`` in row-major grid order.
|
|
89
|
+
rows, cols : int
|
|
90
|
+
Grid dimensions. Required when ``counts`` is not a ``GridData`` instance.
|
|
91
|
+
mask : ndarray, shape (rows, cols), dtype bool, optional
|
|
92
|
+
True for in-tissue bins. Ignored when ``counts`` is a ``GridData`` instance.
|
|
93
|
+
lag : int
|
|
94
|
+
Variogram lag in bin units (2 bins = 4 um on a 2 um grid).
|
|
95
|
+
kappa : float
|
|
96
|
+
Stationarity tolerance (in SE units) for region growing.
|
|
97
|
+
min_seed_distance : int
|
|
98
|
+
Minimum seed separation in bins.
|
|
99
|
+
smooth_sigma : float
|
|
100
|
+
Gaussian sigma (bins) for trace smoothing before seed detection.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
result : TessellationResult with fields
|
|
105
|
+
``"labels"`` : ndarray (rows, cols) int32, region labels (0-indexed, -1 outside mask)
|
|
106
|
+
``"residuals"`` : ndarray (K, G) float64, Pearson residuals
|
|
107
|
+
``"areas"`` : ndarray (K,) float64, bins per region
|
|
108
|
+
``"depths"`` : ndarray (K,) float64, total UMI per region
|
|
109
|
+
``"centroids"`` : ndarray (K, 2) float64, (row, col) centroids
|
|
110
|
+
``"adjacency"`` : csr_matrix (K, K), spatial adjacency graph
|
|
111
|
+
``"trace"`` : ndarray (rows, cols) float64, boundary-tensor trace
|
|
112
|
+
"""
|
|
113
|
+
from .models import GridData, TessellationResult
|
|
114
|
+
|
|
115
|
+
if isinstance(counts, GridData):
|
|
116
|
+
if rows is not None or cols is not None or mask is not None:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"rows, cols, and mask must not be passed separately when counts is a GridData instance."
|
|
119
|
+
)
|
|
120
|
+
grid = counts
|
|
121
|
+
counts = grid.counts
|
|
122
|
+
rows = grid.rows
|
|
123
|
+
cols = grid.cols
|
|
124
|
+
mask = grid.mask
|
|
125
|
+
else:
|
|
126
|
+
if rows is None or cols is None:
|
|
127
|
+
raise TypeError(
|
|
128
|
+
"rows and cols are required when counts is not a GridData instance."
|
|
129
|
+
)
|
|
130
|
+
grid = GridData(counts, rows=rows, cols=cols, mask=mask)
|
|
131
|
+
counts = grid.counts
|
|
132
|
+
rows = grid.rows
|
|
133
|
+
cols = grid.cols
|
|
134
|
+
mask = grid.mask
|
|
135
|
+
|
|
136
|
+
import numpy as np
|
|
137
|
+
|
|
138
|
+
from .aggregate import aggregate_counts
|
|
139
|
+
from .graph import build_spatial_graph
|
|
140
|
+
from .partition import adaptive_tessellation
|
|
141
|
+
from .tensor import boundary_tensor
|
|
142
|
+
from .variogram import directional_semivariance
|
|
143
|
+
|
|
144
|
+
umi_flat = np.asarray(counts.sum(axis=1)).ravel()
|
|
145
|
+
umi = umi_flat.reshape(rows, cols)
|
|
146
|
+
|
|
147
|
+
excess = directional_semivariance(umi, lag=lag, mask=mask)
|
|
148
|
+
trace, _lambda1, _lambda2, evec1 = boundary_tensor(excess)
|
|
149
|
+
labels = adaptive_tessellation(
|
|
150
|
+
umi, trace, evec1,
|
|
151
|
+
kappa=kappa,
|
|
152
|
+
min_seed_distance=min_seed_distance,
|
|
153
|
+
smooth_sigma=smooth_sigma,
|
|
154
|
+
mask=mask,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
residuals, areas, depths, centroids = aggregate_counts(counts, labels, mask=mask)
|
|
158
|
+
adjacency = build_spatial_graph(labels)
|
|
159
|
+
|
|
160
|
+
return TessellationResult(
|
|
161
|
+
labels=labels,
|
|
162
|
+
residuals=residuals,
|
|
163
|
+
areas=areas,
|
|
164
|
+
depths=depths,
|
|
165
|
+
centroids=centroids,
|
|
166
|
+
adjacency=adjacency,
|
|
167
|
+
trace=trace,
|
|
168
|
+
)
|
kintsugi/_demo.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Built-in demo: toy data generation and one-command pipeline.
|
|
2
|
+
|
|
3
|
+
This module is shipped inside the wheel so that ``pip install`` users
|
|
4
|
+
can run the demo without cloning the repository::
|
|
5
|
+
|
|
6
|
+
kintsugi-demo # CLI entry point
|
|
7
|
+
python -m kintsugi.demo # module invocation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import scipy.sparse as sp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Toy dataset (self-contained, no external dependency)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def make_toy_dataset(
|
|
24
|
+
rows: int = 60,
|
|
25
|
+
cols: int = 60,
|
|
26
|
+
n_genes: int = 100,
|
|
27
|
+
seed: int = 2024,
|
|
28
|
+
):
|
|
29
|
+
"""Create a synthetic spatial transcriptomics grid.
|
|
30
|
+
|
|
31
|
+
Returns a ``kintsugi.GridData`` with four spatially distinct domains:
|
|
32
|
+
|
|
33
|
+
- **Top-left** (domain 0): high density, marker genes in first quarter.
|
|
34
|
+
- **Top-right** (domain 1): medium density, marker genes in second quarter.
|
|
35
|
+
- **Bottom-left** (domain 2): medium density, marker genes in third quarter.
|
|
36
|
+
- **Bottom-right** (domain 3): low density, marker genes in last quarter.
|
|
37
|
+
|
|
38
|
+
A circular tissue mask excludes corner bins.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
rows, cols : int
|
|
43
|
+
Grid dimensions (default 60×60 = 3,600 bins).
|
|
44
|
+
n_genes : int
|
|
45
|
+
Number of genes (default 100).
|
|
46
|
+
seed : int
|
|
47
|
+
Random seed for reproducibility.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
grid : kintsugi.GridData
|
|
52
|
+
Normalized grid ready for ``grid.tessellate()``.
|
|
53
|
+
"""
|
|
54
|
+
from . import GridData
|
|
55
|
+
|
|
56
|
+
rng = np.random.default_rng(seed)
|
|
57
|
+
|
|
58
|
+
rr, cc = np.mgrid[:rows, :cols]
|
|
59
|
+
centre_r, centre_c = rows / 2 - 0.5, cols / 2 - 0.5
|
|
60
|
+
dist = np.sqrt((rr - centre_r) ** 2 + (cc - centre_c) ** 2)
|
|
61
|
+
mask = dist <= 28.0
|
|
62
|
+
|
|
63
|
+
domain = np.full((rows, cols), -1, dtype=np.int32)
|
|
64
|
+
domain[(rr < rows // 2) & (cc < cols // 2)] = 0
|
|
65
|
+
domain[(rr < rows // 2) & (cc >= cols // 2)] = 1
|
|
66
|
+
domain[(rr >= rows // 2) & (cc < cols // 2)] = 2
|
|
67
|
+
domain[(rr >= rows // 2) & (cc >= cols // 2)] = 3
|
|
68
|
+
|
|
69
|
+
base_rates = {0: 12.0, 1: 7.0, 2: 7.0, 3: 4.0}
|
|
70
|
+
|
|
71
|
+
block = n_genes // 4
|
|
72
|
+
gene_profiles = np.ones((4, n_genes)) * 0.1
|
|
73
|
+
gene_profiles[0, 0:block] = 2.0
|
|
74
|
+
gene_profiles[1, block:2 * block] = 2.0
|
|
75
|
+
gene_profiles[2, 2 * block:3 * block] = 2.0
|
|
76
|
+
gene_profiles[3, 3 * block:n_genes] = 2.0
|
|
77
|
+
gene_profiles = gene_profiles / gene_profiles.sum(axis=1, keepdims=True)
|
|
78
|
+
|
|
79
|
+
N = rows * cols
|
|
80
|
+
data_rows = []
|
|
81
|
+
data_cols = []
|
|
82
|
+
data_vals = []
|
|
83
|
+
|
|
84
|
+
for r in range(rows):
|
|
85
|
+
for c in range(cols):
|
|
86
|
+
if not mask[r, c]:
|
|
87
|
+
continue
|
|
88
|
+
d = domain[r, c]
|
|
89
|
+
if d < 0:
|
|
90
|
+
continue
|
|
91
|
+
total_umi = rng.poisson(base_rates[d])
|
|
92
|
+
if total_umi == 0:
|
|
93
|
+
continue
|
|
94
|
+
gene_counts = rng.multinomial(total_umi, gene_profiles[d])
|
|
95
|
+
nz_idx = np.where(gene_counts > 0)[0]
|
|
96
|
+
flat_idx = r * cols + c
|
|
97
|
+
for g in nz_idx:
|
|
98
|
+
data_rows.append(flat_idx)
|
|
99
|
+
data_cols.append(g)
|
|
100
|
+
data_vals.append(float(gene_counts[g]))
|
|
101
|
+
|
|
102
|
+
counts = sp.coo_matrix(
|
|
103
|
+
(data_vals, (data_rows, data_cols)),
|
|
104
|
+
shape=(N, n_genes),
|
|
105
|
+
).tocsr()
|
|
106
|
+
|
|
107
|
+
gene_names = np.array([f"Gene_{g:03d}" for g in range(n_genes)])
|
|
108
|
+
|
|
109
|
+
return GridData(
|
|
110
|
+
counts, rows=rows, cols=cols, mask=mask, gene_names=gene_names,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Demo entry point
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def main() -> None:
|
|
119
|
+
"""Run the full Kintsugi demo pipeline."""
|
|
120
|
+
from . import __version__, tessellation_report, to_anndata
|
|
121
|
+
|
|
122
|
+
print(f"Kintsugi v{__version__}")
|
|
123
|
+
print("=" * 55)
|
|
124
|
+
|
|
125
|
+
print("\n1. Generating toy dataset...")
|
|
126
|
+
t0 = time.perf_counter()
|
|
127
|
+
grid = make_toy_dataset()
|
|
128
|
+
t_data = time.perf_counter() - t0
|
|
129
|
+
print(f" Grid: {grid.rows}\u00d7{grid.cols}, {grid.n_genes} genes, "
|
|
130
|
+
f"{int(grid.mask.sum()):,} tissue bins")
|
|
131
|
+
print(f" Time: {t_data:.2f}s")
|
|
132
|
+
|
|
133
|
+
print("\n2. Running tessellation...")
|
|
134
|
+
t0 = time.perf_counter()
|
|
135
|
+
result = grid.tessellate()
|
|
136
|
+
t_tess = time.perf_counter() - t0
|
|
137
|
+
print(f" Regions: {result.n_regions}")
|
|
138
|
+
print(f" Time: {t_tess:.2f}s")
|
|
139
|
+
|
|
140
|
+
print("\n3. Diagnostic report:")
|
|
141
|
+
t0 = time.perf_counter()
|
|
142
|
+
report = tessellation_report(result, grid)
|
|
143
|
+
t_report = time.perf_counter() - t0
|
|
144
|
+
print(report)
|
|
145
|
+
print(f" Report time: {t_report:.2f}s")
|
|
146
|
+
|
|
147
|
+
label_hash = hashlib.sha256(result.labels.tobytes()).hexdigest()[:16]
|
|
148
|
+
print("\n4. Determinism check:")
|
|
149
|
+
print(f" Label hash (sha256[:16]): {label_hash}")
|
|
150
|
+
|
|
151
|
+
print("\n5. AnnData export:")
|
|
152
|
+
try:
|
|
153
|
+
adata = to_anndata(result, grid=grid, use_raw_counts=True)
|
|
154
|
+
print(f" Shape: {adata.shape}")
|
|
155
|
+
print(f" obs: {list(adata.obs.columns)}")
|
|
156
|
+
print(f" obsm: {list(adata.obsm.keys())}")
|
|
157
|
+
print(f" obsp: {list(adata.obsp.keys())}")
|
|
158
|
+
print(f" layers: {list(adata.layers.keys())}")
|
|
159
|
+
except ImportError:
|
|
160
|
+
print(" Skipped (anndata not installed).")
|
|
161
|
+
print(" Install with: pip install 'kintsugi-st[anndata]'")
|
|
162
|
+
|
|
163
|
+
total = t_data + t_tess + t_report
|
|
164
|
+
print(f"\nTotal runtime: {total:.2f}s")
|
|
165
|
+
print("Done.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Poisson-log variance: Var[log(N+1)] for N ~ Poisson(lambda).
|
|
2
|
+
|
|
3
|
+
The trigamma function psi^(1)(lambda+1) equals Var[log(X)] for
|
|
4
|
+
X ~ Gamma(lambda+1, 1), which is a continuous approximation. The exact
|
|
5
|
+
discrete quantity requires summing over the Poisson PMF. The two
|
|
6
|
+
converge as lambda -> inf (relative error < 0.8% for lambda >= 10),
|
|
7
|
+
but diverge substantially at small lambda (2.69x overestimate at
|
|
8
|
+
lambda = 1).
|
|
9
|
+
|
|
10
|
+
This module provides a vectorized implementation using a precomputed
|
|
11
|
+
lookup table with linear interpolation for lambda in [0, 500], and
|
|
12
|
+
falls back to the trigamma for lambda > 500 (relative error < 1e-8).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from scipy.special import gammaln, polygamma
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Scalar computation (used to build the lookup table)
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def _exact_scalar(lam: float) -> float:
|
|
26
|
+
"""Exact Var[log(N+1)] for a single lambda value."""
|
|
27
|
+
if lam <= 0.0:
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
# Truncation bound: tail probability < 1e-15.
|
|
31
|
+
n_max = int(max(20, lam + 12.0 * np.sqrt(lam) + 10))
|
|
32
|
+
n = np.arange(0, n_max + 1, dtype=np.float64)
|
|
33
|
+
|
|
34
|
+
# Log-space Poisson PMF for numerical stability.
|
|
35
|
+
log_pmf = n * np.log(lam) - lam - gammaln(n + 1.0)
|
|
36
|
+
pmf = np.exp(log_pmf)
|
|
37
|
+
|
|
38
|
+
log_np1 = np.log(n + 1.0)
|
|
39
|
+
|
|
40
|
+
m1 = np.dot(pmf, log_np1) # E[log(N+1)]
|
|
41
|
+
m2 = np.dot(pmf, log_np1 ** 2) # E[log(N+1)^2]
|
|
42
|
+
return float(m2 - m1 * m1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_LUT_STEP = 0.1
|
|
46
|
+
_LUT_MAX = 500.0
|
|
47
|
+
_LUT_N = int(_LUT_MAX / _LUT_STEP) + 1
|
|
48
|
+
_LUT_LAMBDAS: np.ndarray | None = None
|
|
49
|
+
_LUT_VALUES: np.ndarray | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lookup_table() -> tuple[np.ndarray, np.ndarray]:
|
|
53
|
+
global _LUT_LAMBDAS, _LUT_VALUES
|
|
54
|
+
if _LUT_LAMBDAS is None or _LUT_VALUES is None:
|
|
55
|
+
lambdas = np.linspace(0.0, _LUT_MAX, _LUT_N)
|
|
56
|
+
values = np.array([_exact_scalar(lam) for lam in lambdas], dtype=np.float64)
|
|
57
|
+
_LUT_LAMBDAS = lambdas
|
|
58
|
+
_LUT_VALUES = values
|
|
59
|
+
return _LUT_LAMBDAS, _LUT_VALUES
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Public vectorized function
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def poisson_log_variance(lam: np.ndarray | float) -> np.ndarray | float:
|
|
67
|
+
r"""Approximate Var[log(N+1)] for N ~ Poisson(lam), vectorized.
|
|
68
|
+
|
|
69
|
+
For lam <= 500: linear interpolation on a lazily built exact table
|
|
70
|
+
(absolute error < 1e-4 at worst, typically < 1e-5).
|
|
71
|
+
For lam > 500: trigamma(lam+1) (relative error < 1e-8).
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
lam : array_like
|
|
76
|
+
Poisson rate parameter(s). Must be non-negative.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
var : same shape as lam
|
|
81
|
+
Var[log(N+1)] for each element.
|
|
82
|
+
"""
|
|
83
|
+
scalar = np.ndim(lam) == 0
|
|
84
|
+
lam_arr = np.asarray(lam, dtype=np.float64)
|
|
85
|
+
if np.any(~np.isfinite(lam_arr)) or np.any(lam_arr < 0):
|
|
86
|
+
raise ValueError("lam must contain only finite non-negative values.")
|
|
87
|
+
|
|
88
|
+
shape = lam_arr.shape
|
|
89
|
+
flat = lam_arr.ravel()
|
|
90
|
+
|
|
91
|
+
result = np.empty_like(flat)
|
|
92
|
+
|
|
93
|
+
lo = flat <= _LUT_MAX
|
|
94
|
+
hi = ~lo
|
|
95
|
+
|
|
96
|
+
if np.any(lo):
|
|
97
|
+
lut_lambdas, lut_values = _lookup_table()
|
|
98
|
+
result[lo] = np.interp(flat[lo], lut_lambdas, lut_values)
|
|
99
|
+
if np.any(hi):
|
|
100
|
+
result[hi] = polygamma(1, flat[hi] + 1.0)
|
|
101
|
+
|
|
102
|
+
if scalar:
|
|
103
|
+
return float(result[0])
|
|
104
|
+
return result.reshape(shape)
|
kintsugi/aggregate.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Null-aware count aggregation and Pearson residual normalization.
|
|
2
|
+
|
|
3
|
+
Aggregates a sparse (N x G) count matrix over an irregular partition
|
|
4
|
+
into a dense (K x G) residual matrix, where K << N. All operations
|
|
5
|
+
keep the input sparse until the final K x G product.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import scipy.sparse as sp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def aggregate_counts(
|
|
15
|
+
counts: sp.csr_matrix,
|
|
16
|
+
labels: np.ndarray,
|
|
17
|
+
mask: np.ndarray | None = None,
|
|
18
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
19
|
+
"""Aggregate bin-level counts into region-level Pearson residuals.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
counts : csr_matrix, shape (N, G)
|
|
24
|
+
Sparse UMI count matrix. Row order must match the row-major
|
|
25
|
+
(C-order) flattening of the 2D lattice: bin (r, c) maps to
|
|
26
|
+
row ``r * C + c``.
|
|
27
|
+
labels : ndarray, shape (R, C), dtype int32
|
|
28
|
+
Region labels from ``adaptive_tessellation``. Values in
|
|
29
|
+
``[0, K)``. Bins with label -1 are excluded.
|
|
30
|
+
mask : ndarray, shape (R, C), dtype bool, optional
|
|
31
|
+
If provided, only bins where mask is True are aggregated.
|
|
32
|
+
Bins outside the mask are ignored regardless of their label.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
residuals : ndarray, shape (K, G)
|
|
37
|
+
Pearson residuals ``(observed - expected) / sqrt(expected)``.
|
|
38
|
+
areas : ndarray, shape (K,)
|
|
39
|
+
Number of native bins per region.
|
|
40
|
+
depths : ndarray, shape (K,)
|
|
41
|
+
Total UMI per region.
|
|
42
|
+
centroids : ndarray, shape (K, 2)
|
|
43
|
+
Region centroids in (row, col) grid coordinates.
|
|
44
|
+
"""
|
|
45
|
+
if labels.ndim != 2:
|
|
46
|
+
raise ValueError("labels must be a 2D array.")
|
|
47
|
+
if not sp.issparse(counts):
|
|
48
|
+
raise TypeError("counts must be a SciPy sparse matrix.")
|
|
49
|
+
if not sp.isspmatrix_csr(counts):
|
|
50
|
+
counts = counts.tocsr()
|
|
51
|
+
if counts.data.size and (
|
|
52
|
+
np.any(~np.isfinite(counts.data)) or np.any(counts.data < 0)
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("counts must contain only finite non-negative values.")
|
|
55
|
+
|
|
56
|
+
R, C = labels.shape
|
|
57
|
+
N = R * C
|
|
58
|
+
|
|
59
|
+
if counts.shape[0] != N:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Count matrix has {counts.shape[0]} rows but label grid has "
|
|
62
|
+
f"{N} bins ({R} x {C})."
|
|
63
|
+
)
|
|
64
|
+
if mask is not None:
|
|
65
|
+
mask = np.asarray(mask, dtype=bool)
|
|
66
|
+
if mask.shape != labels.shape:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"mask has shape {mask.shape}, expected {labels.shape}."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
flat_labels = labels.ravel() # (N,)
|
|
72
|
+
|
|
73
|
+
# Build inclusion mask.
|
|
74
|
+
include = flat_labels >= 0
|
|
75
|
+
if mask is not None:
|
|
76
|
+
include = include & mask.ravel()
|
|
77
|
+
|
|
78
|
+
G = counts.shape[1]
|
|
79
|
+
|
|
80
|
+
# --- Sparse indicator matrix (K x N) ----------------------------------
|
|
81
|
+
valid_idx = np.where(include)[0]
|
|
82
|
+
if valid_idx.size == 0:
|
|
83
|
+
empty = np.zeros((0, G), dtype=np.float64)
|
|
84
|
+
return (
|
|
85
|
+
empty,
|
|
86
|
+
np.zeros(0, dtype=np.float64),
|
|
87
|
+
np.zeros(0, dtype=np.float64),
|
|
88
|
+
np.zeros((0, 2), dtype=np.float64),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
valid_labels = flat_labels[valid_idx].astype(np.int64, copy=False)
|
|
92
|
+
K = int(valid_labels.max()) + 1
|
|
93
|
+
|
|
94
|
+
indicator = sp.csr_matrix(
|
|
95
|
+
(np.ones(valid_idx.size, dtype=np.uint8), (valid_labels, valid_idx)),
|
|
96
|
+
shape=(K, N),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# --- Aggregated counts Y' = indicator @ counts (K x G) ---------------
|
|
100
|
+
# counts is (N, G) sparse; indicator is (K, N) sparse.
|
|
101
|
+
# Result is (K, G) — dense since K ~ 10^3-10^5, fits in memory.
|
|
102
|
+
agg_counts = (indicator @ counts).toarray().astype(np.float64, copy=False) # (K, G)
|
|
103
|
+
|
|
104
|
+
# --- Region statistics ------------------------------------------------
|
|
105
|
+
areas = np.bincount(valid_labels, minlength=K).astype(np.float64, copy=False)
|
|
106
|
+
depths = agg_counts.sum(axis=1) # (K,)
|
|
107
|
+
N_total = depths.sum()
|
|
108
|
+
|
|
109
|
+
# Gene totals across all included bins.
|
|
110
|
+
gene_totals = np.asarray(agg_counts.sum(axis=0)).ravel() # (G,)
|
|
111
|
+
|
|
112
|
+
# --- Centroids --------------------------------------------------------
|
|
113
|
+
row_coords, col_coords = np.divmod(valid_idx, C)
|
|
114
|
+
centroid_rows = np.bincount(
|
|
115
|
+
valid_labels,
|
|
116
|
+
weights=row_coords.astype(np.float64),
|
|
117
|
+
minlength=K,
|
|
118
|
+
)
|
|
119
|
+
centroid_cols = np.bincount(
|
|
120
|
+
valid_labels,
|
|
121
|
+
weights=col_coords.astype(np.float64),
|
|
122
|
+
minlength=K,
|
|
123
|
+
)
|
|
124
|
+
safe_areas = np.maximum(areas, 1.0)
|
|
125
|
+
centroids = np.column_stack([centroid_rows / safe_areas, centroid_cols / safe_areas])
|
|
126
|
+
|
|
127
|
+
# --- Pearson residuals ------------------------------------------------
|
|
128
|
+
# mu_kj = depth_k * gene_j / N_total
|
|
129
|
+
if N_total == 0:
|
|
130
|
+
return np.zeros((K, G), dtype=np.float64), areas, depths, centroids
|
|
131
|
+
|
|
132
|
+
expected = (depths[:, None] * gene_totals[None, :]) / N_total
|
|
133
|
+
np.subtract(agg_counts, expected, out=agg_counts)
|
|
134
|
+
np.maximum(expected, 1e-12, out=expected)
|
|
135
|
+
np.sqrt(expected, out=expected)
|
|
136
|
+
np.divide(agg_counts, expected, out=agg_counts)
|
|
137
|
+
|
|
138
|
+
return agg_counts, areas, depths, centroids
|
kintsugi/graph.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Spatial graph construction from an irregular tessellation.
|
|
2
|
+
|
|
3
|
+
Builds a region adjacency graph: two regions are connected if they share
|
|
4
|
+
at least one pair of rook-adjacent (4-connected) native bins.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import scipy.sparse as sp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_spatial_graph(
|
|
14
|
+
labels: np.ndarray,
|
|
15
|
+
) -> sp.csr_matrix:
|
|
16
|
+
"""Build a symmetric binary adjacency matrix from region labels.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
labels : ndarray, shape (R, C), dtype int32
|
|
21
|
+
Region labels from ``adaptive_tessellation``. Values in
|
|
22
|
+
``[0, K)``. Bins with label -1 are ignored.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
adjacency : csr_matrix, shape (K, K)
|
|
27
|
+
Symmetric binary adjacency (no self-loops).
|
|
28
|
+
"""
|
|
29
|
+
if labels.ndim != 2:
|
|
30
|
+
raise ValueError("labels must be a 2D array.")
|
|
31
|
+
|
|
32
|
+
R, C = labels.shape
|
|
33
|
+
valid_labels = labels[labels >= 0]
|
|
34
|
+
if valid_labels.size == 0:
|
|
35
|
+
return sp.csr_matrix((0, 0), dtype=np.uint8)
|
|
36
|
+
K = int(valid_labels.max()) + 1
|
|
37
|
+
|
|
38
|
+
edges_r: list[np.ndarray] = []
|
|
39
|
+
edges_c: list[np.ndarray] = []
|
|
40
|
+
|
|
41
|
+
# Horizontal neighbours: compare labels[:, :-1] with labels[:, 1:]
|
|
42
|
+
left = labels[:, :-1].ravel()
|
|
43
|
+
right = labels[:, 1:].ravel()
|
|
44
|
+
valid = (left >= 0) & (right >= 0) & (left != right)
|
|
45
|
+
edges_r.append(left[valid])
|
|
46
|
+
edges_c.append(right[valid])
|
|
47
|
+
|
|
48
|
+
# Vertical neighbours: compare labels[:-1, :] with labels[1:, :]
|
|
49
|
+
top = labels[:-1, :].ravel()
|
|
50
|
+
bot = labels[1:, :].ravel()
|
|
51
|
+
valid = (top >= 0) & (bot >= 0) & (top != bot)
|
|
52
|
+
edges_r.append(top[valid])
|
|
53
|
+
edges_c.append(bot[valid])
|
|
54
|
+
|
|
55
|
+
if not any(edge.size for edge in edges_r):
|
|
56
|
+
return sp.csr_matrix((K, K), dtype=np.uint8)
|
|
57
|
+
|
|
58
|
+
row = np.concatenate(edges_r)
|
|
59
|
+
col = np.concatenate(edges_c)
|
|
60
|
+
|
|
61
|
+
# Symmetrize.
|
|
62
|
+
row_sym = np.concatenate([row, col])
|
|
63
|
+
col_sym = np.concatenate([col, row])
|
|
64
|
+
data = np.ones(row_sym.size, dtype=np.uint8)
|
|
65
|
+
|
|
66
|
+
adj = sp.coo_matrix((data, (row_sym, col_sym)), shape=(K, K))
|
|
67
|
+
# Remove duplicates and ensure binary.
|
|
68
|
+
adj = adj.tocsr()
|
|
69
|
+
adj.data[:] = 1
|
|
70
|
+
# Remove any self-loops (should not exist but be safe).
|
|
71
|
+
adj.setdiag(0)
|
|
72
|
+
adj.eliminate_zeros()
|
|
73
|
+
|
|
74
|
+
return adj
|
kintsugi/io/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Input/output adapters for Kintsugi grid data."""
|
|
2
|
+
|
|
3
|
+
from .anndata import to_anndata
|
|
4
|
+
from .grid import build_regular_grid, parse_visium_barcode_coordinates
|
|
5
|
+
from .visium_hd import (
|
|
6
|
+
load_10x_feature_matrix,
|
|
7
|
+
load_visium_hd,
|
|
8
|
+
load_visium_hd_from_dir,
|
|
9
|
+
read_tissue_positions,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"build_regular_grid",
|
|
14
|
+
"load_10x_feature_matrix",
|
|
15
|
+
"load_visium_hd",
|
|
16
|
+
"load_visium_hd_from_dir",
|
|
17
|
+
"parse_visium_barcode_coordinates",
|
|
18
|
+
"read_tissue_positions",
|
|
19
|
+
"to_anndata",
|
|
20
|
+
]
|