kinetic-st 0.1.0__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.
- kinetic_st-0.1.0/PKG-INFO +76 -0
- kinetic_st-0.1.0/README.md +47 -0
- kinetic_st-0.1.0/pyproject.toml +45 -0
- kinetic_st-0.1.0/setup.cfg +4 -0
- kinetic_st-0.1.0/src/kinetic_st/__init__.py +34 -0
- kinetic_st-0.1.0/src/kinetic_st/core/__init__.py +1 -0
- kinetic_st-0.1.0/src/kinetic_st/core/boltzmann.py +87 -0
- kinetic_st-0.1.0/src/kinetic_st/core/collision.py +106 -0
- kinetic_st-0.1.0/src/kinetic_st/core/domains.py +147 -0
- kinetic_st-0.1.0/src/kinetic_st/core/fluid_limit.py +116 -0
- kinetic_st-0.1.0/src/kinetic_st/core/graph_utils.py +72 -0
- kinetic_st-0.1.0/src/kinetic_st/core/latent.py +90 -0
- kinetic_st-0.1.0/src/kinetic_st/core/markers.py +85 -0
- kinetic_st-0.1.0/src/kinetic_st/core/simulator.py +133 -0
- kinetic_st-0.1.0/src/kinetic_st/core/stochastic_pde.py +156 -0
- kinetic_st-0.1.0/src/kinetic_st/core/time_slicing.py +158 -0
- kinetic_st-0.1.0/src/kinetic_st/core/wave_turbulence.py +133 -0
- kinetic_st-0.1.0/src/kinetic_st/data/__init__.py +1 -0
- kinetic_st-0.1.0/src/kinetic_st/data/simulation.py +121 -0
- kinetic_st-0.1.0/src/kinetic_st/demo.py +109 -0
- kinetic_st-0.1.0/src/kinetic_st/pipeline.py +201 -0
- kinetic_st-0.1.0/src/kinetic_st/py.typed +0 -0
- kinetic_st-0.1.0/src/kinetic_st.egg-info/PKG-INFO +76 -0
- kinetic_st-0.1.0/src/kinetic_st.egg-info/SOURCES.txt +25 -0
- kinetic_st-0.1.0/src/kinetic_st.egg-info/dependency_links.txt +1 -0
- kinetic_st-0.1.0/src/kinetic_st.egg-info/requires.txt +10 -0
- kinetic_st-0.1.0/src/kinetic_st.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kinetic-st
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Kinetic-theory framework for multiscale modelling of spatial transcriptomics
|
|
5
|
+
Author: KineticST Project Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/whirlsyu/kinetic_st
|
|
8
|
+
Project-URL: Repository, https://github.com/whirlsyu/kinetic_st.git
|
|
9
|
+
Project-URL: Issues, https://github.com/whirlsyu/kinetic_st/issues
|
|
10
|
+
Keywords: spatial-transcriptomics,kinetic-theory,bioinformatics,single-cell
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: numpy>=1.24
|
|
22
|
+
Requires-Dist: scipy>=1.10
|
|
23
|
+
Requires-Dist: scikit-learn>=1.3
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: matplotlib>=3.7; extra == "dev"
|
|
27
|
+
Provides-Extra: demo
|
|
28
|
+
Requires-Dist: matplotlib>=3.7; extra == "demo"
|
|
29
|
+
|
|
30
|
+
# KineticST
|
|
31
|
+
|
|
32
|
+
Kinetic-theory framework for multiscale modelling of spatial transcriptomics.
|
|
33
|
+
|
|
34
|
+
Bridges microscopic cell-cell interactions to macroscopic tissue organisation
|
|
35
|
+
via a Boltzmann-like collision operator on latent cell states, integrated on
|
|
36
|
+
a spatial graph with stochastic dynamics and invariant-measure analysis.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import numpy as np
|
|
48
|
+
from kinetic_st import KineticST, KineticSTConfig, generate_kinetic_simulation
|
|
49
|
+
|
|
50
|
+
# Synthetic data
|
|
51
|
+
sim = generate_kinetic_simulation(n_types=6, n_spots=1600, seed=42)
|
|
52
|
+
|
|
53
|
+
# Fit
|
|
54
|
+
model = KineticST(KineticSTConfig(n_steps=150, latent_dim=10, verbose=True))
|
|
55
|
+
model.fit(sim.Y, sim.coords)
|
|
56
|
+
|
|
57
|
+
# Results
|
|
58
|
+
labels = model.get_domain_labels()
|
|
59
|
+
field = model.get_expression_field()
|
|
60
|
+
print(model.summary())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Pipeline stages
|
|
64
|
+
|
|
65
|
+
1. **Latent embedding** — PCA on log-CPM expression to a low-dimensional cell-state space
|
|
66
|
+
2. **Spatial graph** — kNN adjacency from coordinates
|
|
67
|
+
3. **Collision kernel** — ligand-receptor rule with irreversible attractor bias
|
|
68
|
+
4. **Kinetic integration** — deterministic (Boltzmann), stochastic (Euler-Maruyama), or time-sliced solver
|
|
69
|
+
5. **Moment closure** — macroscopic tissue expression fields via local KDE moments
|
|
70
|
+
6. **Invariant measure** — non-equilibrium steady-state estimation (KDE or GMM)
|
|
71
|
+
7. **Spatial domains** — segmentation via local invariant-measure divergence
|
|
72
|
+
8. **Supervised markers** — one-vs-rest z-scores on the dynamics-smoothed field
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# KineticST
|
|
2
|
+
|
|
3
|
+
Kinetic-theory framework for multiscale modelling of spatial transcriptomics.
|
|
4
|
+
|
|
5
|
+
Bridges microscopic cell-cell interactions to macroscopic tissue organisation
|
|
6
|
+
via a Boltzmann-like collision operator on latent cell states, integrated on
|
|
7
|
+
a spatial graph with stochastic dynamics and invariant-measure analysis.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import numpy as np
|
|
19
|
+
from kinetic_st import KineticST, KineticSTConfig, generate_kinetic_simulation
|
|
20
|
+
|
|
21
|
+
# Synthetic data
|
|
22
|
+
sim = generate_kinetic_simulation(n_types=6, n_spots=1600, seed=42)
|
|
23
|
+
|
|
24
|
+
# Fit
|
|
25
|
+
model = KineticST(KineticSTConfig(n_steps=150, latent_dim=10, verbose=True))
|
|
26
|
+
model.fit(sim.Y, sim.coords)
|
|
27
|
+
|
|
28
|
+
# Results
|
|
29
|
+
labels = model.get_domain_labels()
|
|
30
|
+
field = model.get_expression_field()
|
|
31
|
+
print(model.summary())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Pipeline stages
|
|
35
|
+
|
|
36
|
+
1. **Latent embedding** — PCA on log-CPM expression to a low-dimensional cell-state space
|
|
37
|
+
2. **Spatial graph** — kNN adjacency from coordinates
|
|
38
|
+
3. **Collision kernel** — ligand-receptor rule with irreversible attractor bias
|
|
39
|
+
4. **Kinetic integration** — deterministic (Boltzmann), stochastic (Euler-Maruyama), or time-sliced solver
|
|
40
|
+
5. **Moment closure** — macroscopic tissue expression fields via local KDE moments
|
|
41
|
+
6. **Invariant measure** — non-equilibrium steady-state estimation (KDE or GMM)
|
|
42
|
+
7. **Spatial domains** — segmentation via local invariant-measure divergence
|
|
43
|
+
8. **Supervised markers** — one-vs-rest z-scores on the dynamics-smoothed field
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kinetic-st"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Kinetic-theory framework for multiscale modelling of spatial transcriptomics"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "KineticST Project Team"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["spatial-transcriptomics", "kinetic-theory", "bioinformatics", "single-cell"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"numpy>=1.24",
|
|
28
|
+
"scipy>=1.10",
|
|
29
|
+
"scikit-learn>=1.3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7.0", "matplotlib>=3.7"]
|
|
34
|
+
demo = ["matplotlib>=3.7"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/whirlsyu/kinetic_st"
|
|
38
|
+
Repository = "https://github.com/whirlsyu/kinetic_st.git"
|
|
39
|
+
Issues = "https://github.com/whirlsyu/kinetic_st/issues"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
kinetic_st = ["py.typed"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""KineticST: kinetic-theory framework for spatial transcriptomics.
|
|
2
|
+
|
|
3
|
+
Bridges microscopic cell-cell interactions to macroscopic tissue organisation
|
|
4
|
+
via a Boltzmann-like collision operator on latent cell states, integrated on
|
|
5
|
+
a spatial graph with stochastic dynamics and invariant-measure analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__author__ = "KineticST Project Team"
|
|
10
|
+
|
|
11
|
+
from kinetic_st.core.latent import LatentEmbedding, fit_latent
|
|
12
|
+
from kinetic_st.core.collision import CollisionKernel, LigandReceptorRule
|
|
13
|
+
from kinetic_st.core.boltzmann import BoltzmannSolver, solve_kinetic
|
|
14
|
+
from kinetic_st.core.fluid_limit import FluidLimiter, compute_moments
|
|
15
|
+
from kinetic_st.core.stochastic_pde import StochasticKineticSolver, InvariantMeasureEstimator
|
|
16
|
+
from kinetic_st.core.time_slicing import TimeSlicer
|
|
17
|
+
from kinetic_st.core.domains import DomainIdentifier
|
|
18
|
+
from kinetic_st.core.wave_turbulence import WaveKineticAnalyzer
|
|
19
|
+
from kinetic_st.core.simulator import TissueSimulator
|
|
20
|
+
from kinetic_st.core.markers import KineticMarkerFinder, find_kinetic_markers, markers_from_model
|
|
21
|
+
from kinetic_st.data.simulation import generate_kinetic_simulation
|
|
22
|
+
from kinetic_st.pipeline import KineticST, KineticSTConfig
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"KineticST", "KineticSTConfig",
|
|
26
|
+
"LatentEmbedding", "fit_latent",
|
|
27
|
+
"CollisionKernel", "LigandReceptorRule",
|
|
28
|
+
"BoltzmannSolver", "solve_kinetic",
|
|
29
|
+
"FluidLimiter", "compute_moments",
|
|
30
|
+
"StochasticKineticSolver", "InvariantMeasureEstimator",
|
|
31
|
+
"TimeSlicer", "DomainIdentifier", "WaveKineticAnalyzer",
|
|
32
|
+
"TissueSimulator", "KineticMarkerFinder", "find_kinetic_markers",
|
|
33
|
+
"markers_from_model", "generate_kinetic_simulation",
|
|
34
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Core modules
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Mesoscopic kinetic equation solver via operator splitting.
|
|
2
|
+
|
|
3
|
+
Integrates df/dt = jump + (1/tau)*Q(f,f) on a spatial graph, where Q is
|
|
4
|
+
the collision integral realized by CollisionKernel.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
from typing import Optional, Dict, Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from kinetic_st.core.collision import CollisionKernel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class KineticConfig:
|
|
17
|
+
"""Solver configuration."""
|
|
18
|
+
tau: float = 1.0
|
|
19
|
+
jump_rate: float = 0.1
|
|
20
|
+
dt: float = 0.05
|
|
21
|
+
n_steps: int = 200
|
|
22
|
+
drift_field: Optional[Callable] = None
|
|
23
|
+
n_pairs_per_spot: int = 4
|
|
24
|
+
record_every: int = 10
|
|
25
|
+
seed: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class KineticTrajectory:
|
|
30
|
+
"""Recorded trajectory."""
|
|
31
|
+
states: np.ndarray # (n_spots, d) final states
|
|
32
|
+
history: np.ndarray # (T, n_spots, d)
|
|
33
|
+
times: np.ndarray # (T,)
|
|
34
|
+
info: Dict = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BoltzmannSolver:
|
|
38
|
+
"""Integrate the cell-state kinetic equation via operator splitting.
|
|
39
|
+
|
|
40
|
+
Steps per time step: streaming (spatial jump) -> collision -> drift.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, kernel: CollisionKernel, config: Optional[KineticConfig] = None):
|
|
44
|
+
self.kernel = kernel
|
|
45
|
+
self.config = config or KineticConfig()
|
|
46
|
+
self.trajectory_ = None
|
|
47
|
+
|
|
48
|
+
def solve(self, states0, adjacency, Y_lr=None, verbose=False) -> KineticTrajectory:
|
|
49
|
+
"""Integrate forward in time."""
|
|
50
|
+
cfg = self.config
|
|
51
|
+
rng = np.random.RandomState(cfg.seed)
|
|
52
|
+
states = np.array(states0, dtype=np.float64, copy=True)
|
|
53
|
+
n, d = states.shape
|
|
54
|
+
|
|
55
|
+
A = adjacency.tocsr().astype(np.float64)
|
|
56
|
+
deg = np.maximum(np.asarray(A.sum(axis=1)).flatten(), 1.0)
|
|
57
|
+
P = (sparse.diags(1.0 / deg) @ A).tocsr()
|
|
58
|
+
|
|
59
|
+
history = [states.copy()]
|
|
60
|
+
times = [0.0]
|
|
61
|
+
for step in range(cfg.n_steps):
|
|
62
|
+
t = (step + 1) * cfg.dt
|
|
63
|
+
# Streaming
|
|
64
|
+
states += cfg.dt * cfg.jump_rate * (P @ states - states)
|
|
65
|
+
# Collision
|
|
66
|
+
collided = self.kernel.apply_collisions(
|
|
67
|
+
states, adjacency, Y_lr=Y_lr,
|
|
68
|
+
n_pairs_per_spot=cfg.n_pairs_per_spot, rng=rng)
|
|
69
|
+
states += (cfg.dt / cfg.tau) * (collided - states)
|
|
70
|
+
# Drift
|
|
71
|
+
if cfg.drift_field is not None:
|
|
72
|
+
states += cfg.dt * cfg.drift_field(states, t)
|
|
73
|
+
if (step + 1) % cfg.record_every == 0:
|
|
74
|
+
history.append(states.copy())
|
|
75
|
+
times.append(t)
|
|
76
|
+
if verbose and (step + 1) % (cfg.record_every * 10) == 0:
|
|
77
|
+
print(f" [Boltzmann] t={t:.3f} step={step+1}/{cfg.n_steps}")
|
|
78
|
+
|
|
79
|
+
self.trajectory_ = KineticTrajectory(
|
|
80
|
+
states=states, history=np.array(history), times=np.array(times),
|
|
81
|
+
info={"n_steps": cfg.n_steps, "dt": cfg.dt, "n_spots": n, "dim": d})
|
|
82
|
+
return self.trajectory_
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def solve_kinetic(states0, adjacency, kernel, config=None, Y_lr=None) -> KineticTrajectory:
|
|
86
|
+
"""Functional API for BoltzmannSolver.solve."""
|
|
87
|
+
return BoltzmannSolver(kernel, config).solve(states0, adjacency, Y_lr=Y_lr)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Ligand-receptor collision operator on latent cell states.
|
|
2
|
+
|
|
3
|
+
A "collision" is a ligand-receptor-mediated interaction updating two
|
|
4
|
+
neighboring cell states. Irreversibility is baked in via attractor bias
|
|
5
|
+
(gamma), since biological interactions are intrinsically irreversible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
from scipy import sparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LigandReceptorRule:
|
|
14
|
+
"""Collision rule: v' = (1-a)*v + b*v* + g*a(v), and vice versa.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
alpha : self-relaxation rate (default 0.1)
|
|
19
|
+
beta : cross-influence rate (default 0.2)
|
|
20
|
+
gamma : attractor bias, irreversible differentiation (default 0.05)
|
|
21
|
+
attractors : (n_types, d) cell-type centroids; None = irreversibility off
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, alpha: float = 0.1, beta: float = 0.2,
|
|
25
|
+
gamma: float = 0.05, attractors: Optional[np.ndarray] = None):
|
|
26
|
+
for v in (alpha, beta, gamma):
|
|
27
|
+
if not 0.0 <= v <= 1.0:
|
|
28
|
+
raise ValueError("alpha, beta, gamma must be in [0, 1].")
|
|
29
|
+
self.alpha, self.beta, self.gamma = alpha, beta, gamma
|
|
30
|
+
self.attractors = attractors
|
|
31
|
+
|
|
32
|
+
def apply(self, v: np.ndarray, vs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
33
|
+
"""Apply collision to cell pairs. v, vs: (m, d) -> vp, vsp: (m, d)."""
|
|
34
|
+
vp = (1.0 - self.alpha) * v + self.beta * vs
|
|
35
|
+
vsp = (1.0 - self.alpha) * vs + self.beta * v
|
|
36
|
+
if self.gamma > 0 and self.attractors is not None:
|
|
37
|
+
vp += self.gamma * (_nearest_attractor(vp, self.attractors) - vp)
|
|
38
|
+
vsp += self.gamma * (_nearest_attractor(vsp, self.attractors) - vsp)
|
|
39
|
+
return vp, vsp
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CollisionKernel:
|
|
43
|
+
"""Collision operator combining a rule with a cross-section sigma(v, v*).
|
|
44
|
+
|
|
45
|
+
sigma = sigma_LR * sigma_space, where sigma_LR depends on ligand-receptor
|
|
46
|
+
expression and sigma_space on spatial adjacency.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
rule : LigandReceptorRule
|
|
51
|
+
lr_matrix : (n_lr,) per-pair strengths; None = all 1
|
|
52
|
+
lr_index : (ligand_idx, receptor_idx) for expression-based sigma
|
|
53
|
+
sigma_scale : overall collision rate scale
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, rule: LigandReceptorRule,
|
|
57
|
+
lr_matrix: Optional[np.ndarray] = None,
|
|
58
|
+
lr_index: Optional[Tuple[np.ndarray, np.ndarray]] = None,
|
|
59
|
+
sigma_scale: float = 1.0):
|
|
60
|
+
self.rule = rule
|
|
61
|
+
self.lr_matrix = lr_matrix
|
|
62
|
+
self.lr_index = lr_index
|
|
63
|
+
self.sigma_scale = sigma_scale
|
|
64
|
+
|
|
65
|
+
def cross_section(self, states, adjacency, Y_lr=None) -> sparse.spmatrix:
|
|
66
|
+
"""Sparse cross-section matrix over spatial edges."""
|
|
67
|
+
A = adjacency.tocoo()
|
|
68
|
+
n = adjacency.shape[0]
|
|
69
|
+
if self.lr_index is not None and Y_lr is not None:
|
|
70
|
+
lig_idx, rec_idx = self.lr_index
|
|
71
|
+
w = np.asarray((Y_lr[A.row][:, lig_idx] * Y_lr[A.col][:, rec_idx]).sum(axis=1)).flatten()
|
|
72
|
+
else:
|
|
73
|
+
d2 = np.sum((states[A.row] - states[A.col]) ** 2, axis=1)
|
|
74
|
+
w = np.exp(-0.5 * d2 / (np.median(d2) + 1e-8))
|
|
75
|
+
if self.lr_matrix is not None:
|
|
76
|
+
w *= float(np.mean(self.lr_matrix))
|
|
77
|
+
w *= self.sigma_scale
|
|
78
|
+
return sparse.coo_matrix((w, (A.row, A.col)), shape=(n, n)).tocsr()
|
|
79
|
+
|
|
80
|
+
def apply_collisions(self, states, adjacency, Y_lr=None,
|
|
81
|
+
n_pairs_per_spot=4, rng=None) -> np.ndarray:
|
|
82
|
+
"""One Monte-Carlo collision step on all cells."""
|
|
83
|
+
rng = rng or np.random.RandomState(0)
|
|
84
|
+
n, d = states.shape
|
|
85
|
+
Sigma = self.cross_section(states, adjacency, Y_lr).tocsr()
|
|
86
|
+
new_states = states.copy()
|
|
87
|
+
for i in range(n):
|
|
88
|
+
s, e = Sigma.indptr[i], Sigma.indptr[i + 1]
|
|
89
|
+
if e == s:
|
|
90
|
+
continue
|
|
91
|
+
nbrs, w = Sigma.indices[s:e], Sigma.data[s:e]
|
|
92
|
+
wsum = w.sum()
|
|
93
|
+
if wsum <= 0:
|
|
94
|
+
continue
|
|
95
|
+
m = min(n_pairs_per_spot, len(nbrs))
|
|
96
|
+
chosen = rng.choice(nbrs, size=m, replace=True, p=w / wsum)
|
|
97
|
+
vp, _ = self.rule.apply(
|
|
98
|
+
np.broadcast_to(states[i], (m, d)), states[chosen])
|
|
99
|
+
new_states[i] = vp.mean(axis=0)
|
|
100
|
+
return new_states
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _nearest_attractor(states, attractors):
|
|
104
|
+
"""Nearest attractor centroid for each state."""
|
|
105
|
+
d2 = ((states[:, None, :] - attractors[None, :, :]) ** 2).sum(axis=2)
|
|
106
|
+
return attractors[np.argmin(d2, axis=1)]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Spatial domain identification via local invariant measure divergence.
|
|
2
|
+
|
|
3
|
+
A domain is a maximal contiguous region where the local invariant measure
|
|
4
|
+
mu(x, v) is approximately constant. Replaces heuristic graph clustering with
|
|
5
|
+
a dynamics-grounded definition.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy import sparse
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from kinetic_st.core.boltzmann import KineticTrajectory
|
|
14
|
+
from kinetic_st.core.stochastic_pde import InvariantMeasureEstimator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DomainResult:
|
|
19
|
+
"""Domain identification output."""
|
|
20
|
+
labels: np.ndarray
|
|
21
|
+
n_domains: int
|
|
22
|
+
domain_field: np.ndarray
|
|
23
|
+
local_log_density: np.ndarray
|
|
24
|
+
centroids: np.ndarray
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DomainIdentifier:
|
|
28
|
+
"""Identify spatial domains from a kinetic trajectory.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
n_neighbors : spatial neighborhood for local mu_i estimation
|
|
33
|
+
n_domains : int or "auto" (BIC-like penalty)
|
|
34
|
+
divergence : "kl", "l2", or "pairwise"
|
|
35
|
+
representation : invariant-measure representation ("kde" or "gmm")
|
|
36
|
+
bandwidth : KDE bandwidth
|
|
37
|
+
n_components : GMM components
|
|
38
|
+
seed : random seed
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, n_neighbors: int = 12, n_domains="auto",
|
|
42
|
+
divergence: str = "kl", representation: str = "gmm",
|
|
43
|
+
bandwidth: Optional[float] = None, n_components: int = 10,
|
|
44
|
+
seed: int = 0):
|
|
45
|
+
self.n_neighbors = n_neighbors
|
|
46
|
+
self.n_domains = n_domains
|
|
47
|
+
self.divergence = divergence
|
|
48
|
+
self.representation = representation
|
|
49
|
+
self.bandwidth = bandwidth
|
|
50
|
+
self.n_components = n_components
|
|
51
|
+
self.seed = seed
|
|
52
|
+
self.result_ = None
|
|
53
|
+
|
|
54
|
+
def fit(self, trajectory: KineticTrajectory, adjacency: sparse.spmatrix,
|
|
55
|
+
verbose: bool = False) -> DomainResult:
|
|
56
|
+
"""Identify spatial domains."""
|
|
57
|
+
n = adjacency.shape[0]
|
|
58
|
+
A = adjacency.tocsr()
|
|
59
|
+
nbr_lists = _neighbor_lists(A, self.n_neighbors)
|
|
60
|
+
|
|
61
|
+
global_est = InvariantMeasureEstimator(
|
|
62
|
+
representation=self.representation, bandwidth=self.bandwidth,
|
|
63
|
+
n_components=self.n_components, seed=self.seed).fit(trajectory)
|
|
64
|
+
self.global_measure_ = global_est
|
|
65
|
+
|
|
66
|
+
final_states = trajectory.states
|
|
67
|
+
d = final_states.shape[1]
|
|
68
|
+
global_logp = global_est.log_density(final_states)
|
|
69
|
+
|
|
70
|
+
if self.divergence == "kl":
|
|
71
|
+
field = -global_logp
|
|
72
|
+
elif self.divergence == "l2":
|
|
73
|
+
local_means = np.array([final_states[idx].mean(axis=0) for idx in nbr_lists])
|
|
74
|
+
field = np.linalg.norm(local_means - global_est.samples_.mean(axis=0), axis=1)
|
|
75
|
+
elif self.divergence == "pairwise":
|
|
76
|
+
field = np.zeros(n)
|
|
77
|
+
for i in range(n):
|
|
78
|
+
lp_local = _local_logpdf(final_states[nbr_lists[i]], final_states[i])
|
|
79
|
+
field[i] = global_logp[i] - lp_local
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(f"Unknown divergence: {self.divergence}")
|
|
82
|
+
|
|
83
|
+
field /= field.std() + 1e-8
|
|
84
|
+
labels = self._segment(field, final_states, A)
|
|
85
|
+
|
|
86
|
+
n_dom = int(labels.max() + 1)
|
|
87
|
+
centroids = np.array([final_states[labels == k].mean(axis=0)
|
|
88
|
+
for k in range(n_dom) if (labels == k).any()])
|
|
89
|
+
|
|
90
|
+
self.result_ = DomainResult(
|
|
91
|
+
labels=labels, n_domains=n_dom, domain_field=field,
|
|
92
|
+
local_log_density=global_logp, centroids=centroids)
|
|
93
|
+
if verbose:
|
|
94
|
+
print(f" [DomainIdentifier] identified {n_dom} domains")
|
|
95
|
+
return self.result_
|
|
96
|
+
|
|
97
|
+
def _segment(self, field, states, A):
|
|
98
|
+
"""Segment tissue into domains via KMeans + spatial contiguity."""
|
|
99
|
+
n = len(states)
|
|
100
|
+
if self.n_domains == "auto":
|
|
101
|
+
from sklearn.cluster import KMeans
|
|
102
|
+
best_k, best_score = 2, -np.inf
|
|
103
|
+
for k in range(2, min(15, n // 20) + 1):
|
|
104
|
+
km = KMeans(n_clusters=k, random_state=self.seed, n_init=3).fit(states)
|
|
105
|
+
score = np.sum((km.cluster_centers_ - states.mean(axis=0)) ** 2) * k / max(km.inertia_, 1e-8) - 0.05 * k
|
|
106
|
+
if score > best_score:
|
|
107
|
+
best_score, best_k = score, k
|
|
108
|
+
n_dom = best_k
|
|
109
|
+
else:
|
|
110
|
+
n_dom = int(self.n_domains)
|
|
111
|
+
|
|
112
|
+
from sklearn.cluster import KMeans
|
|
113
|
+
km = KMeans(n_clusters=n_dom, random_state=self.seed, n_init=5).fit(states)
|
|
114
|
+
return _enforce_contiguity(km.labels_, A)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _neighbor_lists(A, k):
|
|
118
|
+
A = A.tocsr()
|
|
119
|
+
return [np.concatenate([[i], A.indices[A.indptr[i]:A.indptr[i + 1]][:k]])
|
|
120
|
+
for i in range(A.shape[0])]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _local_logpdf(samples, x):
|
|
124
|
+
"""Crude local KDE log-density."""
|
|
125
|
+
d = samples.shape[1]
|
|
126
|
+
bw = np.std(samples, axis=0) + 1e-3
|
|
127
|
+
logk = -0.5 * np.sum(((samples - x) / bw) ** 2, axis=1) - np.sum(np.log(bw)) - 0.5 * d * np.log(2 * np.pi)
|
|
128
|
+
m = logk.max()
|
|
129
|
+
return float(m + np.log(np.exp(logk - m).mean()))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _enforce_contiguity(labels, A):
|
|
133
|
+
"""Split non-contiguous clusters into connected components."""
|
|
134
|
+
from scipy.sparse.csgraph import connected_components
|
|
135
|
+
A = A.tocsr()
|
|
136
|
+
new_labels = np.full(len(labels), -1, dtype=int)
|
|
137
|
+
next_id = 0
|
|
138
|
+
for lab in np.unique(labels):
|
|
139
|
+
mask = labels == lab
|
|
140
|
+
idx_global = np.where(mask)[0]
|
|
141
|
+
if len(idx_global) == 0:
|
|
142
|
+
continue
|
|
143
|
+
n_cc, cc_labels = connected_components(A[mask][:, mask], directed=False)
|
|
144
|
+
for c in range(n_cc):
|
|
145
|
+
new_labels[idx_global[cc_labels == c]] = next_id
|
|
146
|
+
next_id += 1
|
|
147
|
+
return new_labels
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Macroscopic tissue fields via moment closure of the cell-state density.
|
|
2
|
+
|
|
3
|
+
Moments: density rho, mean state u (expression field), covariance P
|
|
4
|
+
(heterogeneity tensor). A relaxation_indicator reports how close each spot
|
|
5
|
+
is to local quasi-equilibrium (fluid-limit regime).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy import sparse
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from kinetic_st.core.boltzmann import KineticTrajectory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TissueField:
|
|
18
|
+
"""Macroscopic fields at one time point."""
|
|
19
|
+
density: np.ndarray
|
|
20
|
+
mean_state: np.ndarray
|
|
21
|
+
covariance: np.ndarray
|
|
22
|
+
heterogeneity: np.ndarray
|
|
23
|
+
time: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FluidLimiter:
|
|
27
|
+
"""Compute tissue fields by taking moments of the cell-state density.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
bandwidth : KDE bandwidth; None = Silverman's rule
|
|
32
|
+
n_neighbors : spatial neighborhood size for moment support
|
|
33
|
+
use_local_kde : estimate f_i as Gaussian KDE over neighborhood
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, bandwidth: Optional[float] = None,
|
|
37
|
+
n_neighbors: int = 12, use_local_kde: bool = True):
|
|
38
|
+
self.bandwidth = bandwidth
|
|
39
|
+
self.n_neighbors = n_neighbors
|
|
40
|
+
self.use_local_kde = use_local_kde
|
|
41
|
+
self.fields_ = None
|
|
42
|
+
|
|
43
|
+
def compute(self, trajectory: KineticTrajectory, adjacency: sparse.spmatrix) -> list:
|
|
44
|
+
"""Compute fields for every recorded time."""
|
|
45
|
+
A = adjacency.tocsr()
|
|
46
|
+
n = A.shape[0]
|
|
47
|
+
nbr_lists = _neighbor_lists(A, self.n_neighbors)
|
|
48
|
+
history, times = trajectory.history, trajectory.times
|
|
49
|
+
T, _, d = history.shape
|
|
50
|
+
|
|
51
|
+
if self.bandwidth is None:
|
|
52
|
+
all_states = history.reshape(-1, d)
|
|
53
|
+
self.bandwidth_ = np.median(all_states.std(axis=0) + 1e-8) * (n ** (-1.0 / (d + 4)))
|
|
54
|
+
else:
|
|
55
|
+
self.bandwidth_ = self.bandwidth
|
|
56
|
+
|
|
57
|
+
fields = []
|
|
58
|
+
for ti in range(T):
|
|
59
|
+
states = history[ti]
|
|
60
|
+
if self.use_local_kde:
|
|
61
|
+
mean_state, cov_diag = _local_moments(states, nbr_lists, self.bandwidth_)
|
|
62
|
+
else:
|
|
63
|
+
mean_state, cov_diag = states.copy(), np.zeros((n, d))
|
|
64
|
+
covariance = np.array([np.diag(c) for c in cov_diag])
|
|
65
|
+
fields.append(TissueField(
|
|
66
|
+
density=np.ones(n), mean_state=mean_state,
|
|
67
|
+
covariance=covariance, heterogeneity=cov_diag.sum(axis=1),
|
|
68
|
+
time=float(times[ti])))
|
|
69
|
+
self.fields_ = fields
|
|
70
|
+
return fields
|
|
71
|
+
|
|
72
|
+
def relaxation_indicator(self, trajectory: KineticTrajectory) -> np.ndarray:
|
|
73
|
+
"""Local-equilibrium indicator per spot (0 = quasi-equilibrium)."""
|
|
74
|
+
if self.fields_ is None:
|
|
75
|
+
raise RuntimeError("Call compute() first.")
|
|
76
|
+
all_h = np.array([f.heterogeneity for f in self.fields_])
|
|
77
|
+
return (all_h / (all_h.mean() + 1e-8)).mean(axis=0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compute_moments(states, adjacency, n_neighbors=12) -> TissueField:
|
|
81
|
+
"""One-shot moment computation for a single snapshot."""
|
|
82
|
+
limiter = FluidLimiter(n_neighbors=n_neighbors, use_local_kde=True)
|
|
83
|
+
A = adjacency.tocsr()
|
|
84
|
+
nbr_lists = _neighbor_lists(A, n_neighbors)
|
|
85
|
+
n, d = states.shape
|
|
86
|
+
bw = np.median(states.std(axis=0) + 1e-8) * (n ** (-1.0 / (d + 4)))
|
|
87
|
+
mean_state, cov_diag = _local_moments(states, nbr_lists, bw)
|
|
88
|
+
return TissueField(
|
|
89
|
+
density=np.ones(n), mean_state=mean_state,
|
|
90
|
+
covariance=np.array([np.diag(c) for c in cov_diag]),
|
|
91
|
+
heterogeneity=cov_diag.sum(axis=1), time=0.0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _neighbor_lists(A, k):
|
|
95
|
+
"""Self + up to k neighbors per node."""
|
|
96
|
+
A = A.tocsr()
|
|
97
|
+
out = []
|
|
98
|
+
for i in range(A.shape[0]):
|
|
99
|
+
nbrs = A.indices[A.indptr[i]:A.indptr[i + 1]][:k]
|
|
100
|
+
out.append(np.concatenate([[i], nbrs]))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _local_moments(states, nbr_lists, bandwidth):
|
|
105
|
+
"""Weighted local mean and variance (Gaussian KDE)."""
|
|
106
|
+
n, d = states.shape
|
|
107
|
+
mean_state = np.zeros((n, d))
|
|
108
|
+
cov_diag = np.zeros((n, d))
|
|
109
|
+
bw2 = 2.0 * bandwidth ** 2 + 1e-12
|
|
110
|
+
for i in range(n):
|
|
111
|
+
local = states[nbr_lists[i]]
|
|
112
|
+
w = np.exp(-np.sum((local - states[i]) ** 2, axis=1) / bw2)
|
|
113
|
+
w /= w.sum() + 1e-12
|
|
114
|
+
mean_state[i] = (w[:, None] * local).sum(axis=0)
|
|
115
|
+
cov_diag[i] = (w[:, None] * (local - mean_state[i]) ** 2).sum(axis=0)
|
|
116
|
+
return mean_state, cov_diag
|