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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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