diffbio 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.
Files changed (202) hide show
  1. diffbio/__init__.py +39 -0
  2. diffbio/configs.py +75 -0
  3. diffbio/constants.py +204 -0
  4. diffbio/core/__init__.py +127 -0
  5. diffbio/core/base_operators.py +612 -0
  6. diffbio/core/data_types.py +260 -0
  7. diffbio/core/gnn_components.py +629 -0
  8. diffbio/core/graph_utils.py +149 -0
  9. diffbio/core/neural_components.py +270 -0
  10. diffbio/core/optimal_transport.py +133 -0
  11. diffbio/core/soft_ops/__init__.py +216 -0
  12. diffbio/core/soft_ops/_projections_permutahedron.py +1864 -0
  13. diffbio/core/soft_ops/_projections_simplex.py +240 -0
  14. diffbio/core/soft_ops/_projections_transport.py +508 -0
  15. diffbio/core/soft_ops/_sorting_network.py +204 -0
  16. diffbio/core/soft_ops/_types.py +15 -0
  17. diffbio/core/soft_ops/_utils.py +342 -0
  18. diffbio/core/soft_ops/autograd_safe.py +120 -0
  19. diffbio/core/soft_ops/comparison.py +235 -0
  20. diffbio/core/soft_ops/elementwise.py +309 -0
  21. diffbio/core/soft_ops/logical.py +146 -0
  22. diffbio/core/soft_ops/quantile.py +376 -0
  23. diffbio/core/soft_ops/selection.py +236 -0
  24. diffbio/core/soft_ops/sorting.py +926 -0
  25. diffbio/core/soft_ops/straight_through.py +261 -0
  26. diffbio/core/uncertainty.py +279 -0
  27. diffbio/evaluation/__init__.py +42 -0
  28. diffbio/evaluation/adapters.py +409 -0
  29. diffbio/evaluation/graders.py +223 -0
  30. diffbio/evaluation/problem.py +157 -0
  31. diffbio/evaluation/runner.py +277 -0
  32. diffbio/losses/__init__.py +59 -0
  33. diffbio/losses/alignment_losses.py +222 -0
  34. diffbio/losses/biological_regularization.py +288 -0
  35. diffbio/losses/metric_losses.py +139 -0
  36. diffbio/losses/singlecell_losses.py +387 -0
  37. diffbio/losses/statistical_losses.py +345 -0
  38. diffbio/operators/__init__.py +60 -0
  39. diffbio/operators/_count_vae.py +197 -0
  40. diffbio/operators/_loss_balancing.py +65 -0
  41. diffbio/operators/_masked_gene_transformer.py +118 -0
  42. diffbio/operators/_transformer_validation.py +50 -0
  43. diffbio/operators/alignment/__init__.py +51 -0
  44. diffbio/operators/alignment/profile_hmm.py +350 -0
  45. diffbio/operators/alignment/scoring.py +127 -0
  46. diffbio/operators/alignment/smith_waterman.py +261 -0
  47. diffbio/operators/alignment/soft_msa.py +419 -0
  48. diffbio/operators/assembly/__init__.py +27 -0
  49. diffbio/operators/assembly/gnn_assembly.py +252 -0
  50. diffbio/operators/assembly/metagenomic_binning.py +296 -0
  51. diffbio/operators/crispr/__init__.py +17 -0
  52. diffbio/operators/crispr/guide_scoring.py +269 -0
  53. diffbio/operators/drug_discovery/__init__.py +133 -0
  54. diffbio/operators/drug_discovery/_graph_utils.py +142 -0
  55. diffbio/operators/drug_discovery/admet_predictor.py +285 -0
  56. diffbio/operators/drug_discovery/attentive_fp.py +411 -0
  57. diffbio/operators/drug_discovery/dti.py +261 -0
  58. diffbio/operators/drug_discovery/fingerprint.py +490 -0
  59. diffbio/operators/drug_discovery/maccs_keys.py +267 -0
  60. diffbio/operators/drug_discovery/message_passing.py +200 -0
  61. diffbio/operators/drug_discovery/primitives.py +242 -0
  62. diffbio/operators/drug_discovery/property_predictor.py +163 -0
  63. diffbio/operators/drug_discovery/similarity.py +193 -0
  64. diffbio/operators/epigenomics/__init__.py +35 -0
  65. diffbio/operators/epigenomics/chromatin_state.py +491 -0
  66. diffbio/operators/epigenomics/contextual.py +288 -0
  67. diffbio/operators/epigenomics/fno_peak_calling.py +153 -0
  68. diffbio/operators/epigenomics/peak_calling.py +555 -0
  69. diffbio/operators/foundation_models/__init__.py +119 -0
  70. diffbio/operators/foundation_models/adapters.py +114 -0
  71. diffbio/operators/foundation_models/contracts.py +245 -0
  72. diffbio/operators/foundation_models/embedding_probe.py +83 -0
  73. diffbio/operators/foundation_models/experimental.py +128 -0
  74. diffbio/operators/foundation_models/foundation_model.py +332 -0
  75. diffbio/operators/foundation_models/frozen.py +59 -0
  76. diffbio/operators/foundation_models/precomputed.py +270 -0
  77. diffbio/operators/foundation_models/transformer_encoder.py +564 -0
  78. diffbio/operators/mapping/__init__.py +17 -0
  79. diffbio/operators/mapping/neural_mapper.py +493 -0
  80. diffbio/operators/metabolomics/__init__.py +39 -0
  81. diffbio/operators/metabolomics/spectral_similarity.py +315 -0
  82. diffbio/operators/molecular_dynamics/__init__.py +51 -0
  83. diffbio/operators/molecular_dynamics/force_field.py +265 -0
  84. diffbio/operators/molecular_dynamics/integrator.py +304 -0
  85. diffbio/operators/molecular_dynamics/primitives.py +115 -0
  86. diffbio/operators/multiomics/__init__.py +38 -0
  87. diffbio/operators/multiomics/hic_contact.py +377 -0
  88. diffbio/operators/multiomics/multiomics_vae.py +325 -0
  89. diffbio/operators/multiomics/spatial_deconvolution.py +316 -0
  90. diffbio/operators/multiomics/spatial_gene_detection.py +493 -0
  91. diffbio/operators/normalization/__init__.py +42 -0
  92. diffbio/operators/normalization/embedding.py +222 -0
  93. diffbio/operators/normalization/phate.py +400 -0
  94. diffbio/operators/normalization/umap.py +261 -0
  95. diffbio/operators/normalization/vae_normalizer.py +258 -0
  96. diffbio/operators/population/__init__.py +17 -0
  97. diffbio/operators/population/ancestry_estimation.py +274 -0
  98. diffbio/operators/preprocessing/__init__.py +76 -0
  99. diffbio/operators/preprocessing/adapter_removal.py +311 -0
  100. diffbio/operators/preprocessing/duplicate_filter.py +317 -0
  101. diffbio/operators/preprocessing/error_correction.py +287 -0
  102. diffbio/operators/protein/__init__.py +31 -0
  103. diffbio/operators/protein/secondary_structure.py +509 -0
  104. diffbio/operators/quality_filter.py +128 -0
  105. diffbio/operators/rna_structure/__init__.py +35 -0
  106. diffbio/operators/rna_structure/rna_folding.py +509 -0
  107. diffbio/operators/rnaseq/__init__.py +23 -0
  108. diffbio/operators/rnaseq/motif_discovery.py +251 -0
  109. diffbio/operators/rnaseq/splicing_psi.py +216 -0
  110. diffbio/operators/singlecell/__init__.py +193 -0
  111. diffbio/operators/singlecell/ambient_removal.py +333 -0
  112. diffbio/operators/singlecell/archetypes.py +191 -0
  113. diffbio/operators/singlecell/batch_correction.py +288 -0
  114. diffbio/operators/singlecell/cell_annotation.py +519 -0
  115. diffbio/operators/singlecell/communication.py +704 -0
  116. diffbio/operators/singlecell/differential_distribution.py +243 -0
  117. diffbio/operators/singlecell/doublet_detection.py +657 -0
  118. diffbio/operators/singlecell/downsampling.py +166 -0
  119. diffbio/operators/singlecell/enhanced_batch_correction.py +519 -0
  120. diffbio/operators/singlecell/grn_inference.py +336 -0
  121. diffbio/operators/singlecell/imputation.py +429 -0
  122. diffbio/operators/singlecell/knockdown_filter.py +176 -0
  123. diffbio/operators/singlecell/ot_trajectory.py +277 -0
  124. diffbio/operators/singlecell/simulation.py +444 -0
  125. diffbio/operators/singlecell/sindy_grn.py +247 -0
  126. diffbio/operators/singlecell/soft_clustering.py +211 -0
  127. diffbio/operators/singlecell/spatial_domains.py +677 -0
  128. diffbio/operators/singlecell/switch_de.py +184 -0
  129. diffbio/operators/singlecell/trajectory.py +447 -0
  130. diffbio/operators/singlecell/velocity.py +361 -0
  131. diffbio/operators/statistical/__init__.py +35 -0
  132. diffbio/operators/statistical/em_quantification.py +260 -0
  133. diffbio/operators/statistical/hmm.py +234 -0
  134. diffbio/operators/statistical/nb_glm.py +272 -0
  135. diffbio/operators/variant/__init__.py +64 -0
  136. diffbio/operators/variant/classifier.py +333 -0
  137. diffbio/operators/variant/cnn_classifier.py +255 -0
  138. diffbio/operators/variant/cnv_segmentation.py +678 -0
  139. diffbio/operators/variant/deepvariant_pileup.py +426 -0
  140. diffbio/operators/variant/pileup.py +240 -0
  141. diffbio/operators/variant/quality_recalibration.py +274 -0
  142. diffbio/pipelines/__init__.py +65 -0
  143. diffbio/pipelines/differential_expression.py +279 -0
  144. diffbio/pipelines/enhanced_variant_calling.py +326 -0
  145. diffbio/pipelines/perturbation.py +407 -0
  146. diffbio/pipelines/preprocessing.py +267 -0
  147. diffbio/pipelines/single_cell.py +366 -0
  148. diffbio/pipelines/variant_calling.py +490 -0
  149. diffbio/samplers/__init__.py +9 -0
  150. diffbio/samplers/perturbation_sampler.py +142 -0
  151. diffbio/sequences/__init__.py +34 -0
  152. diffbio/sequences/dna.py +239 -0
  153. diffbio/sources/__init__.py +149 -0
  154. diffbio/sources/_anndata_shared.py +89 -0
  155. diffbio/sources/_batch_iteration.py +37 -0
  156. diffbio/sources/_benchmark_source.py +152 -0
  157. diffbio/sources/_indexed_batch_source.py +38 -0
  158. diffbio/sources/_utils.py +45 -0
  159. diffbio/sources/anndata_interop.py +387 -0
  160. diffbio/sources/anndata_source.py +361 -0
  161. diffbio/sources/archive_ii.py +174 -0
  162. diffbio/sources/balifam.py +207 -0
  163. diffbio/sources/bam.py +265 -0
  164. diffbio/sources/bengrn_ground_truth.py +306 -0
  165. diffbio/sources/contextual_epigenomics.py +242 -0
  166. diffbio/sources/dti.py +359 -0
  167. diffbio/sources/embeddings.py +203 -0
  168. diffbio/sources/encode_peaks.py +223 -0
  169. diffbio/sources/fasta.py +226 -0
  170. diffbio/sources/immune_human.py +172 -0
  171. diffbio/sources/indexed_embeddings.py +128 -0
  172. diffbio/sources/indexed_view.py +191 -0
  173. diffbio/sources/molnet.py +493 -0
  174. diffbio/sources/multiomics.py +279 -0
  175. diffbio/sources/pancreas.py +108 -0
  176. diffbio/sources/perturbation/__init__.py +69 -0
  177. diffbio/sources/perturbation/_types.py +51 -0
  178. diffbio/sources/perturbation/_utils.py +125 -0
  179. diffbio/sources/perturbation/concat_source.py +115 -0
  180. diffbio/sources/perturbation/control_mapping.py +215 -0
  181. diffbio/sources/perturbation/experiment_config.py +261 -0
  182. diffbio/sources/perturbation/h5_metadata_cache.py +218 -0
  183. diffbio/sources/perturbation/output_space.py +52 -0
  184. diffbio/sources/perturbation/perturbation_source.py +513 -0
  185. diffbio/sources/seqfish.py +145 -0
  186. diffbio/sources/sequence_foundation.py +68 -0
  187. diffbio/sources/singlecell_foundation.py +68 -0
  188. diffbio/splitters/__init__.py +63 -0
  189. diffbio/splitters/base.py +251 -0
  190. diffbio/splitters/molecular.py +330 -0
  191. diffbio/splitters/perturbation.py +199 -0
  192. diffbio/splitters/random.py +217 -0
  193. diffbio/splitters/sequence.py +201 -0
  194. diffbio/utils/__init__.py +55 -0
  195. diffbio/utils/dependency_runtime.py +115 -0
  196. diffbio/utils/nn_utils.py +157 -0
  197. diffbio/utils/quality.py +45 -0
  198. diffbio/utils/training.py +585 -0
  199. diffbio-0.1.0.dist-info/METADATA +480 -0
  200. diffbio-0.1.0.dist-info/RECORD +202 -0
  201. diffbio-0.1.0.dist-info/WHEEL +4 -0
  202. diffbio-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,677 @@
1
+ """Spatial domain identification and slice alignment operators.
2
+
3
+ This module provides two complementary operators for spatial transcriptomics:
4
+
5
+ 1. **DifferentiableSpatialDomain** -- STAGATE-inspired graph attention autoencoder
6
+ that identifies spatial domains by combining gene expression with spatial
7
+ coordinates. Uses GATv2 attention with dual-graph pruning (alpha-weighted
8
+ combination of full and pruned adjacency) for encoding, followed by soft
9
+ domain assignment via learned prototypes.
10
+
11
+ 2. **DifferentiablePASTEAlignment** -- PASTE-inspired fused Gromov-Wasserstein
12
+ optimal transport for aligning two spatial transcriptomics slices. Balances
13
+ expression dissimilarity with spatial structure preservation via entropy-
14
+ regularised Sinkhorn transport.
15
+
16
+ Key techniques:
17
+ - GATv2 graph attention on spatial k-NN graphs (STAGATE)
18
+ - Autoencoder reconstruction loss for representation learning (STAGATE)
19
+ - Fused expression + spatial Gromov-Wasserstein cost (PASTE)
20
+ - Sinkhorn optimal transport for differentiable alignment (PASTE)
21
+
22
+ References:
23
+ - Dong & Zhang, "STAGATE: Deciphering spatial domains from spatially resolved
24
+ transcriptomics with graph attention auto-encoder", Nature Communications 2022.
25
+ - Zeira et al., "Alignment and integration of spatial transcriptomics data",
26
+ Nature Methods 2022.
27
+ """
28
+
29
+ import logging
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ import jax
34
+ import jax.numpy as jnp
35
+ from datarax.core.config import OperatorConfig
36
+ from flax import nnx
37
+ from jaxtyping import Array, Float, Int, PyTree
38
+
39
+ from diffbio.constants import DISTANCE_MASK_SENTINEL, EPSILON
40
+ from diffbio.core.base_operators import GraphOperator
41
+ from diffbio.core.gnn_components import GATv2Layer
42
+ from diffbio.core.graph_utils import compute_knn_graph, compute_pairwise_distances
43
+ from diffbio.core.optimal_transport import SinkhornLayer
44
+ from diffbio.utils.nn_utils import ensure_rngs
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # =============================================================================
50
+ # STAGATE-inspired spatial domain identification
51
+ # =============================================================================
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SpatialDomainConfig(OperatorConfig):
56
+ """Configuration for STAGATE-style spatial domain identification.
57
+
58
+ Attributes:
59
+ n_genes: Number of input genes.
60
+ hidden_dim: Latent embedding dimension. Must be divisible by num_heads.
61
+ num_heads: Number of GATv2 attention heads.
62
+ n_domains: Number of spatial domains to identify.
63
+ alpha: Weight for pruned graph in dual-graph attention (STAGATE default 0.8).
64
+ At alpha=0, only the full k-NN graph is used. At alpha=1, only the
65
+ pruned (mutual k-NN) graph is used.
66
+ n_neighbors: Number of nearest neighbors for spatial k-NN graph.
67
+ """
68
+
69
+ n_genes: int = 2000
70
+ hidden_dim: int = 64
71
+ num_heads: int = 4
72
+ n_domains: int = 7
73
+ alpha: float = 0.8
74
+ n_neighbors: int = 15
75
+
76
+
77
+ class _SpatialGATEncoder(nnx.Module):
78
+ """GATv2-based encoder for spatial transcriptomics (STAGATE-style).
79
+
80
+ Encodes gene expression using dual-graph attention: a combination of
81
+ attention on the full k-NN graph and a pruned (mutual k-NN) graph,
82
+ weighted by alpha. This follows the STAGATE architecture where the
83
+ pruned graph encourages attention to spatially similar neighbors.
84
+
85
+ Args:
86
+ n_genes: Input gene expression dimension.
87
+ hidden_dim: Output embedding dimension.
88
+ num_heads: Number of GATv2 attention heads.
89
+ rngs: Flax NNX random number generators.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ n_genes: int,
95
+ hidden_dim: int,
96
+ num_heads: int,
97
+ *,
98
+ rngs: nnx.Rngs,
99
+ ) -> None:
100
+ """Initialize the spatial GAT encoder.
101
+
102
+ Args:
103
+ n_genes: Input feature dimension.
104
+ hidden_dim: Hidden / output dimension.
105
+ num_heads: Number of attention heads.
106
+ rngs: Random number generators.
107
+ """
108
+ super().__init__()
109
+
110
+ # Input projection: n_genes -> hidden_dim
111
+ self.input_proj = nnx.Linear(
112
+ in_features=n_genes,
113
+ out_features=hidden_dim,
114
+ rngs=rngs,
115
+ )
116
+
117
+ # GATv2 layer for full k-NN graph
118
+ self.gat_full = GATv2Layer(
119
+ in_features=hidden_dim,
120
+ out_features=hidden_dim,
121
+ num_heads=num_heads,
122
+ edge_features=1,
123
+ dropout_rate=0.0,
124
+ rngs=rngs,
125
+ )
126
+
127
+ # GATv2 layer for pruned (mutual k-NN) graph
128
+ self.gat_pruned = GATv2Layer(
129
+ in_features=hidden_dim,
130
+ out_features=hidden_dim,
131
+ num_heads=num_heads,
132
+ edge_features=1,
133
+ dropout_rate=0.0,
134
+ rngs=rngs,
135
+ )
136
+
137
+ self.layer_norm = nnx.LayerNorm(num_features=hidden_dim, rngs=rngs)
138
+
139
+ def __call__(
140
+ self,
141
+ node_features: Float[Array, "n_cells n_genes"],
142
+ full_edge_index: Int[Array, "2 n_full_edges"],
143
+ full_edge_weights: Float[Array, "n_full_edges 1"],
144
+ pruned_edge_index: Int[Array, "2 n_pruned_edges"],
145
+ pruned_edge_weights: Float[Array, "n_pruned_edges 1"],
146
+ alpha: float,
147
+ ) -> Float[Array, "n_cells hidden_dim"]:
148
+ """Encode gene expression using dual-graph GATv2 attention.
149
+
150
+ Args:
151
+ node_features: Gene expression matrix.
152
+ full_edge_index: Edge indices for full k-NN graph (2, n_full_edges).
153
+ full_edge_weights: Edge weights for full graph.
154
+ pruned_edge_index: Edge indices for pruned mutual k-NN graph.
155
+ pruned_edge_weights: Edge weights for pruned graph.
156
+ alpha: Weight for pruned graph (0 = full only, 1 = pruned only).
157
+
158
+ Returns:
159
+ Spatial embeddings of shape (n_cells, hidden_dim).
160
+ """
161
+ # Project input to hidden dim
162
+ h = self.input_proj(node_features)
163
+
164
+ # Dual-graph attention (STAGATE: (1-alpha)*full + alpha*pruned)
165
+ h_full = self.gat_full(h, full_edge_index, full_edge_weights, deterministic=True)
166
+ h_pruned = self.gat_pruned(h, pruned_edge_index, pruned_edge_weights, deterministic=True)
167
+
168
+ h = (1.0 - alpha) * h_full + alpha * h_pruned
169
+
170
+ # Apply ELU activation (following STAGATE) + LayerNorm
171
+ h = nnx.elu(h)
172
+ h = self.layer_norm(h)
173
+
174
+ return h
175
+
176
+
177
+ class _ExpressionDecoder(nnx.Module):
178
+ """Decoder that reconstructs gene expression from spatial embeddings.
179
+
180
+ Simple linear decoder mirroring the STAGATE architecture where the
181
+ decoder uses transposed weights for reconstruction.
182
+
183
+ Args:
184
+ hidden_dim: Input embedding dimension.
185
+ n_genes: Output gene expression dimension.
186
+ rngs: Flax NNX random number generators.
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ hidden_dim: int,
192
+ n_genes: int,
193
+ *,
194
+ rngs: nnx.Rngs,
195
+ ) -> None:
196
+ """Initialize the expression decoder.
197
+
198
+ Args:
199
+ hidden_dim: Embedding dimension.
200
+ n_genes: Number of output genes.
201
+ rngs: Random number generators.
202
+ """
203
+ super().__init__()
204
+ self.linear = nnx.Linear(
205
+ in_features=hidden_dim,
206
+ out_features=n_genes,
207
+ rngs=rngs,
208
+ )
209
+
210
+ def __call__(
211
+ self,
212
+ embeddings: Float[Array, "n_cells hidden_dim"],
213
+ ) -> Float[Array, "n_cells n_genes"]:
214
+ """Reconstruct gene expression from embeddings.
215
+
216
+ Args:
217
+ embeddings: Spatial embeddings.
218
+
219
+ Returns:
220
+ Reconstructed gene expression.
221
+ """
222
+ return self.linear(embeddings)
223
+
224
+
225
+ class DifferentiableSpatialDomain(GraphOperator):
226
+ """STAGATE-inspired differentiable spatial domain identification.
227
+
228
+ Identifies spatial domains by combining gene expression with spatial
229
+ coordinates through a graph attention autoencoder. The encoder uses
230
+ dual-graph GATv2 attention (full + pruned k-NN graphs), and soft domain
231
+ assignments are computed via learned prototypes with softmax.
232
+
233
+ Algorithm:
234
+ 1. Build spatial k-NN graph from coordinates (full + pruned/mutual).
235
+ 2. Apply GATv2 encoder: counts -> spatial embeddings (dual-graph
236
+ attention weighted by alpha).
237
+ 3. Decoder: reconstruct gene expression from embeddings (autoencoder).
238
+ 4. Soft domain assignment via softmax on learned domain prototypes.
239
+
240
+ Inherits from GraphOperator to get:
241
+
242
+ - scatter_aggregate() for message aggregation
243
+ - global_pool() for graph-level pooling
244
+
245
+ Args:
246
+ config: SpatialDomainConfig with model parameters.
247
+ rngs: Flax NNX random number generators.
248
+ name: Optional operator name.
249
+ """
250
+
251
+ def __init__(
252
+ self,
253
+ config: SpatialDomainConfig,
254
+ *,
255
+ rngs: nnx.Rngs | None = None,
256
+ name: str | None = None,
257
+ ) -> None:
258
+ """Initialize the spatial domain identification operator.
259
+
260
+ Args:
261
+ config: Spatial domain configuration.
262
+ rngs: Random number generators for parameter initialization.
263
+ name: Optional operator name.
264
+ """
265
+ super().__init__(config, rngs=rngs, name=name)
266
+
267
+ rngs = ensure_rngs(rngs)
268
+
269
+ self.alpha = config.alpha
270
+ self.n_neighbors = config.n_neighbors
271
+ self.n_domains = config.n_domains
272
+ self.hidden_dim = config.hidden_dim
273
+
274
+ # Encoder: GATv2-based spatial graph attention
275
+ self.encoder = _SpatialGATEncoder(
276
+ n_genes=config.n_genes,
277
+ hidden_dim=config.hidden_dim,
278
+ num_heads=config.num_heads,
279
+ rngs=rngs,
280
+ )
281
+
282
+ # Decoder: reconstruct gene expression
283
+ self.decoder = _ExpressionDecoder(
284
+ hidden_dim=config.hidden_dim,
285
+ n_genes=config.n_genes,
286
+ rngs=rngs,
287
+ )
288
+
289
+ # Domain prototypes for soft assignment
290
+ key = rngs.params()
291
+ init_prototypes = jax.random.normal(key, (config.n_domains, config.hidden_dim)) * 0.1
292
+ self.domain_prototypes = nnx.Param(init_prototypes)
293
+
294
+ def _build_spatial_graphs(
295
+ self,
296
+ spatial_coords: Float[Array, "n_cells 2"],
297
+ ) -> tuple[
298
+ Int[Array, "2 n_full_edges"],
299
+ Float[Array, "n_full_edges 1"],
300
+ Int[Array, "2 n_pruned_edges"],
301
+ Float[Array, "n_pruned_edges 1"],
302
+ ]:
303
+ """Build full and pruned (mutual) k-NN graphs from spatial coordinates.
304
+
305
+ The full graph connects each cell to its k nearest spatial neighbors.
306
+ The pruned graph keeps only mutual neighbors (edges present in both
307
+ directions), following the STAGATE strategy.
308
+
309
+ Args:
310
+ spatial_coords: Spatial coordinates of shape (n_cells, 2).
311
+
312
+ Returns:
313
+ Tuple of (full_edge_index, full_edge_weights,
314
+ pruned_edge_index, pruned_edge_weights).
315
+ """
316
+ n_cells = spatial_coords.shape[0]
317
+
318
+ # Compute pairwise spatial distances
319
+ distances = compute_pairwise_distances(spatial_coords, metric="euclidean")
320
+ # Mask self-distances
321
+ distances = distances + jnp.eye(n_cells) * DISTANCE_MASK_SENTINEL
322
+
323
+ # Full k-NN graph
324
+ edge_indices, edge_weights = compute_knn_graph(distances, k=self.n_neighbors)
325
+ # edge_indices: (n_edges, 2), edge_weights: (n_edges,)
326
+ full_edge_index = edge_indices.T # (2, n_edges)
327
+ full_edge_weights = edge_weights[:, None] # (n_edges, 1)
328
+
329
+ # Pruned (mutual) k-NN graph: keep only mutual edges
330
+ # Build adjacency indicator for fast mutual check
331
+ adj_indicator = jnp.zeros((n_cells, n_cells))
332
+ adj_indicator = adj_indicator.at[edge_indices[:, 0], edge_indices[:, 1]].set(1.0)
333
+
334
+ # Edge is mutual if both (i,j) and (j,i) are in the k-NN graph
335
+ mutual_mask = (
336
+ adj_indicator[edge_indices[:, 0], edge_indices[:, 1]]
337
+ * adj_indicator[edge_indices[:, 1], edge_indices[:, 0]]
338
+ )
339
+
340
+ # Use the same edges but weight by mutual membership
341
+ # (soft pruning for differentiability)
342
+ pruned_edge_index = full_edge_index
343
+ pruned_edge_weights = full_edge_weights * mutual_mask[:, None]
344
+
345
+ return full_edge_index, full_edge_weights, pruned_edge_index, pruned_edge_weights
346
+
347
+ def _compute_domain_assignments(
348
+ self,
349
+ embeddings: Float[Array, "n_cells hidden_dim"],
350
+ ) -> Float[Array, "n_cells n_domains"]:
351
+ """Compute soft domain assignments via learned prototypes.
352
+
353
+ Distance from each cell embedding to each domain prototype is computed,
354
+ then converted to assignment probabilities via softmax over negative
355
+ squared distances.
356
+
357
+ Args:
358
+ embeddings: Spatial embeddings.
359
+
360
+ Returns:
361
+ Soft domain assignment probabilities.
362
+ """
363
+ prototypes = self.domain_prototypes[...] # (n_domains, hidden_dim)
364
+
365
+ # Squared distances: ||embedding - prototype||^2
366
+ # Using expansion: ||e||^2 + ||p||^2 - 2*e.p
367
+ emb_sq = jnp.sum(embeddings**2, axis=-1, keepdims=True) # (n, 1)
368
+ proto_sq = jnp.sum(prototypes**2, axis=-1) # (d,)
369
+ dot = jnp.einsum("nf,df->nd", embeddings, prototypes) # (n, d)
370
+ distances_sq = emb_sq + proto_sq - 2.0 * dot
371
+
372
+ # Soft assignment via softmax over negative distances
373
+ return jax.nn.softmax(-distances_sq, axis=-1)
374
+
375
+ def apply(
376
+ self,
377
+ data: PyTree,
378
+ state: PyTree,
379
+ metadata: dict[str, Any] | None,
380
+ random_params: Any = None,
381
+ stats: dict[str, Any] | None = None,
382
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
383
+ """Apply spatial domain identification to spatial transcriptomics data.
384
+
385
+ Args:
386
+ data: Dictionary containing:
387
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
388
+ - ``"spatial_coords"``: Spatial coordinates ``(n_cells, 2)``
389
+ state: Element state (passed through unchanged).
390
+ metadata: Element metadata (passed through unchanged).
391
+ random_params: Not used (non-stochastic operator).
392
+ stats: Not used.
393
+
394
+ Returns:
395
+ Tuple of (transformed_data, state, metadata):
396
+ - transformed_data contains all original keys plus:
397
+
398
+ - ``"domain_assignments"``: Soft domain probabilities
399
+ ``(n_cells, n_domains)``
400
+ - ``"spatial_embeddings"``: Latent embeddings
401
+ ``(n_cells, hidden_dim)``
402
+ - state is passed through unchanged
403
+ - metadata is passed through unchanged
404
+ """
405
+ counts: Float[Array, "n_cells n_genes"] = data["counts"]
406
+ spatial_coords: Float[Array, "n_cells 2"] = data["spatial_coords"]
407
+
408
+ # Step 1: Build spatial k-NN graphs (full + pruned)
409
+ (
410
+ full_edge_index,
411
+ full_edge_weights,
412
+ pruned_edge_index,
413
+ pruned_edge_weights,
414
+ ) = self._build_spatial_graphs(spatial_coords)
415
+
416
+ # Step 2: Encode via dual-graph GATv2 attention
417
+ embeddings = self.encoder(
418
+ counts,
419
+ full_edge_index,
420
+ full_edge_weights,
421
+ pruned_edge_index,
422
+ pruned_edge_weights,
423
+ self.alpha,
424
+ )
425
+
426
+ # Step 3: Decode (autoencoder reconstruction -- loss can be computed externally)
427
+ _reconstructed = self.decoder(embeddings) # noqa: F841
428
+
429
+ # Step 4: Soft domain assignment via prototypes
430
+ domain_assignments = self._compute_domain_assignments(embeddings)
431
+
432
+ transformed_data = {
433
+ **data,
434
+ "domain_assignments": domain_assignments,
435
+ "spatial_embeddings": embeddings,
436
+ }
437
+
438
+ return transformed_data, state, metadata
439
+
440
+
441
+ # =============================================================================
442
+ # PASTE-inspired slice alignment
443
+ # =============================================================================
444
+
445
+
446
+ @dataclass(frozen=True)
447
+ class PASTEAlignmentConfig(OperatorConfig):
448
+ """Configuration for PASTE-style spatial transcriptomics slice alignment.
449
+
450
+ Attributes:
451
+ alpha: Balance between expression dissimilarity (linear term) and
452
+ spatial Gromov-Wasserstein cost (quadratic term). 0 = pure expression
453
+ matching, 1 = pure spatial structure matching. PASTE default: 0.1.
454
+ sinkhorn_epsilon: Entropy regularisation strength for the Sinkhorn
455
+ optimal transport solver.
456
+ sinkhorn_iters: Number of Sinkhorn iterations.
457
+ """
458
+
459
+ alpha: float = 0.1
460
+ sinkhorn_epsilon: float = 0.1
461
+ sinkhorn_iters: int = 100
462
+
463
+
464
+ class DifferentiablePASTEAlignment(GraphOperator):
465
+ """PASTE-inspired differentiable spatial transcriptomics slice alignment.
466
+
467
+ Aligns two spatial transcriptomics slices by computing a fused cost that
468
+ balances expression dissimilarity with spatial structure (Gromov-Wasserstein)
469
+ and solving for the optimal transport plan via differentiable Sinkhorn.
470
+
471
+ Algorithm:
472
+ 1. Compute expression dissimilarity between slices (Euclidean distance).
473
+ 2. Compute intra-slice spatial distance matrices.
474
+ 3. Compute Gromov-Wasserstein spatial cost that penalizes distortion
475
+ of pairwise spatial relationships.
476
+ 4. Fuse costs: alpha * expression_cost + (1 - alpha) * spatial_GW_cost.
477
+ 5. Solve OT via SinkhornLayer for the differentiable transport plan.
478
+ 6. Align slice 2 coordinates using the transport plan.
479
+
480
+ Inherits from GraphOperator to get:
481
+
482
+ - scatter_aggregate() for message aggregation
483
+ - global_pool() for graph-level pooling
484
+
485
+ Args:
486
+ config: PASTEAlignmentConfig with alignment parameters.
487
+ rngs: Flax NNX random number generators.
488
+ name: Optional operator name.
489
+ """
490
+
491
+ def __init__(
492
+ self,
493
+ config: PASTEAlignmentConfig,
494
+ *,
495
+ rngs: nnx.Rngs | None = None,
496
+ name: str | None = None,
497
+ ) -> None:
498
+ """Initialize the PASTE alignment operator.
499
+
500
+ Args:
501
+ config: PASTE alignment configuration.
502
+ rngs: Random number generators.
503
+ name: Optional operator name.
504
+ """
505
+ super().__init__(config, rngs=rngs, name=name)
506
+
507
+ rngs = ensure_rngs(rngs)
508
+
509
+ self.alpha_cost = config.alpha
510
+ self.sinkhorn = SinkhornLayer(
511
+ epsilon=config.sinkhorn_epsilon,
512
+ num_iters=config.sinkhorn_iters,
513
+ rngs=rngs,
514
+ )
515
+
516
+ def _compute_expression_cost(
517
+ self,
518
+ counts1: Float[Array, "n1 g"],
519
+ counts2: Float[Array, "n2 g"],
520
+ ) -> Float[Array, "n1 n2"]:
521
+ """Compute pairwise expression dissimilarity between two slices.
522
+
523
+ Uses squared Euclidean distance, normalized by number of genes
524
+ for numerical stability.
525
+
526
+ Args:
527
+ counts1: Expression matrix for slice 1.
528
+ counts2: Expression matrix for slice 2.
529
+
530
+ Returns:
531
+ Expression cost matrix of shape (n1, n2).
532
+ """
533
+ n_genes = counts1.shape[1]
534
+ # ||c1_i - c2_j||^2 = ||c1_i||^2 + ||c2_j||^2 - 2 * c1_i . c2_j
535
+ sq1 = jnp.sum(counts1**2, axis=-1, keepdims=True) # (n1, 1)
536
+ sq2 = jnp.sum(counts2**2, axis=-1) # (n2,)
537
+ dot = jnp.dot(counts1, counts2.T) # (n1, n2)
538
+ cost = sq1 + sq2 - 2.0 * dot
539
+ # Normalize by number of genes for stability
540
+ return jnp.maximum(cost, 0.0) / (n_genes + EPSILON)
541
+
542
+ def _compute_spatial_distances(
543
+ self,
544
+ coords: Float[Array, "n 2"],
545
+ ) -> Float[Array, "n n"]:
546
+ """Compute intra-slice pairwise spatial distance matrix.
547
+
548
+ Args:
549
+ coords: Spatial coordinates of shape (n, 2).
550
+
551
+ Returns:
552
+ Distance matrix of shape (n, n).
553
+ """
554
+ return compute_pairwise_distances(coords, metric="euclidean")
555
+
556
+ def _compute_gromov_wasserstein_cost(
557
+ self,
558
+ dist_a: Float[Array, "n1 n1"],
559
+ dist_b: Float[Array, "n2 n2"],
560
+ transport_plan: Float[Array, "n1 n2"],
561
+ ) -> Float[Array, "n1 n2"]:
562
+ """Compute the Gromov-Wasserstein gradient term for the fused cost.
563
+
564
+ The GW cost measures how well the transport plan preserves pairwise
565
+ spatial relationships::
566
+
567
+ L(D_A, D_B, T) = sum |D_A[i,k] - D_B[j,l]|^2 * T[i,j] * T[k,l]
568
+
569
+ This computes the gradient of the GW cost with respect to T, which
570
+ gives the linear cost matrix for the next Sinkhorn iteration.
571
+
572
+ Args:
573
+ dist_a: Spatial distance matrix for slice 1.
574
+ dist_b: Spatial distance matrix for slice 2.
575
+ transport_plan: Current transport plan estimate.
576
+
577
+ Returns:
578
+ GW cost gradient matrix of shape (n1, n2).
579
+ """
580
+ # Square loss GW: sum_{ijkl} (D_A[i,k] - D_B[j,l])^2 * T[k,l]
581
+ # Gradient w.r.t. T[i,j] = 2 * (D_A^2 @ T @ 1 + 1 @ T @ D_B^2 - 2 * D_A @ T @ D_B)
582
+ # Simplified constant parts + linear in T:
583
+ da_sq = dist_a**2
584
+ db_sq = dist_b**2
585
+
586
+ # Term 1: D_A^2 @ T @ ones_n2 (broadcast) -> (n1, n2) contribution
587
+ term1 = da_sq @ transport_plan # (n1, n2)
588
+
589
+ # Term 2: ones_n1^T @ T @ D_B^2 (broadcast) -> (n1, n2) contribution
590
+ term2 = transport_plan @ db_sq # (n1, n2)
591
+
592
+ # Term 3: D_A @ T @ D_B^T (cross term)
593
+ cross = dist_a @ transport_plan @ dist_b # (n1, n2)
594
+
595
+ gw_cost = term1 + term2 - 2.0 * cross
596
+ return gw_cost
597
+
598
+ def apply(
599
+ self,
600
+ data: PyTree,
601
+ state: PyTree,
602
+ metadata: dict[str, Any] | None,
603
+ random_params: Any = None,
604
+ stats: dict[str, Any] | None = None,
605
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
606
+ """Apply PASTE-style alignment between two spatial transcriptomics slices.
607
+
608
+ Args:
609
+ data: Dictionary containing:
610
+ - ``"slice1_counts"``: Expression matrix for slice 1 ``(n1, g)``
611
+ - ``"slice2_counts"``: Expression matrix for slice 2 ``(n2, g)``
612
+ - ``"slice1_coords"``: Spatial coordinates for slice 1 ``(n1, 2)``
613
+ - ``"slice2_coords"``: Spatial coordinates for slice 2 ``(n2, 2)``
614
+ state: Element state (passed through unchanged).
615
+ metadata: Element metadata (passed through unchanged).
616
+ random_params: Not used (non-stochastic operator).
617
+ stats: Not used.
618
+
619
+ Returns:
620
+ Tuple of (transformed_data, state, metadata):
621
+ - transformed_data contains all original keys plus:
622
+
623
+ - ``"transport_plan"``: OT plan ``(n1, n2)``
624
+ - ``"aligned_coords"``: Aligned slice 2 coordinates ``(n2, 2)``
625
+ - state is passed through unchanged
626
+ - metadata is passed through unchanged
627
+ """
628
+ counts1: Float[Array, "n1 g"] = data["slice1_counts"]
629
+ counts2: Float[Array, "n2 g"] = data["slice2_counts"]
630
+ coords1: Float[Array, "n1 2"] = data["slice1_coords"]
631
+ coords2: Float[Array, "n2 2"] = data["slice2_coords"]
632
+
633
+ n1 = counts1.shape[0]
634
+ n2 = counts2.shape[0]
635
+
636
+ # Step 1: Expression dissimilarity cost
637
+ expression_cost = self._compute_expression_cost(counts1, counts2)
638
+
639
+ # Step 2: Intra-slice spatial distances
640
+ dist_a = self._compute_spatial_distances(coords1)
641
+ dist_b = self._compute_spatial_distances(coords2)
642
+
643
+ # Step 3: Initial transport plan (uniform) for GW cost estimation
644
+ init_plan = jnp.ones((n1, n2)) / (n1 * n2)
645
+
646
+ # Step 4: Compute Gromov-Wasserstein spatial cost
647
+ gw_cost = self._compute_gromov_wasserstein_cost(dist_a, dist_b, init_plan)
648
+
649
+ # Normalize costs to comparable scales
650
+ expr_max = jnp.max(expression_cost) + EPSILON
651
+ gw_max = jnp.max(gw_cost) + EPSILON
652
+ expression_cost_norm = expression_cost / expr_max
653
+ gw_cost_norm = gw_cost / gw_max
654
+
655
+ # Step 5: Fused cost = (1-alpha) * expression + alpha * GW_spatial
656
+ fused_cost = (1.0 - self.alpha_cost) * expression_cost_norm + self.alpha_cost * gw_cost_norm
657
+
658
+ # Step 6: Solve OT via Sinkhorn
659
+ a = jnp.ones(n1) / n1 # uniform source marginal
660
+ b = jnp.ones(n2) / n2 # uniform target marginal
661
+ transport_plan = self.sinkhorn(fused_cost, a, b)
662
+
663
+ # Step 7: Align slice 2 coordinates using the transport plan
664
+ # Normalized plan rows: T_norm[i, :] = T[i, :] / sum_j T[i, j]
665
+ plan_col_normalized = transport_plan / (
666
+ jnp.sum(transport_plan, axis=0, keepdims=True) + EPSILON
667
+ )
668
+ # aligned_coords[j] = sum_i plan_col_norm[i, j] * coords1[i]
669
+ aligned_coords = plan_col_normalized.T @ coords1
670
+
671
+ transformed_data = {
672
+ **data,
673
+ "transport_plan": transport_plan,
674
+ "aligned_coords": aligned_coords,
675
+ }
676
+
677
+ return transformed_data, state, metadata