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,704 @@
1
+ """Differentiable cell-cell communication analysis.
2
+
3
+ This module provides two complementary operators for analysing cell-cell
4
+ communication in single-cell data:
5
+
6
+ 1. **DifferentiableLigandReceptor** -- ligand-receptor co-expression scoring
7
+ using fuzzy k-NN adjacency graphs and analytical z-score significance.
8
+ 2. **DifferentiableCellCommunication** -- GNN-based communication analysis
9
+ using GATv2 graph attention on a spatial cell graph with per-edge
10
+ L-R expression features.
11
+
12
+ Key techniques:
13
+ - Soft adjacency weighting via fuzzy k-NN (L-R scoring)
14
+ - GATv2 message passing on spatial cell graphs (cell communication)
15
+ - Temperature-controlled smooth approximations throughout
16
+
17
+ Applications: CellChat/CellPhoneDB-style communication analysis, spatial
18
+ transcriptomics niche identification, pathway-level signaling inference.
19
+ """
20
+
21
+ import logging
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+ from datarax.core.config import OperatorConfig
28
+ from flax import nnx
29
+ from jaxtyping import Array, Float, Int, PyTree
30
+
31
+ from diffbio.constants import DISTANCE_MASK_SENTINEL, EPSILON
32
+ from diffbio.core import soft_ops
33
+ from diffbio.core.base_operators import GraphOperator, TemperatureOperator
34
+ from diffbio.core.gnn_components import GATv2Layer
35
+ from diffbio.core.graph_utils import (
36
+ compute_fuzzy_membership,
37
+ compute_pairwise_distances,
38
+ symmetrize_graph,
39
+ )
40
+ from diffbio.utils.nn_utils import ensure_rngs
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class LRScoringConfig(OperatorConfig):
47
+ """Configuration for ligand-receptor co-expression scoring.
48
+
49
+ Attributes:
50
+ n_neighbors: Number of nearest neighbors for k-NN graph.
51
+ temperature: Temperature for soft p-value sigmoid.
52
+ learnable_temperature: Whether the temperature is a learnable parameter.
53
+ metric: Distance metric for k-NN, either ``"euclidean"`` or ``"cosine"``.
54
+ kh: Hill function half-maximal constant (CellChat default 0.5).
55
+ hill_n: Hill function cooperativity coefficient (CellChat default 1.0).
56
+ """
57
+
58
+ n_neighbors: int = 15
59
+ temperature: float = 1.0
60
+ learnable_temperature: bool = False
61
+ metric: str = "euclidean"
62
+ kh: float = 0.5
63
+ hill_n: float = 1.0
64
+
65
+
66
+ class DifferentiableLigandReceptor(TemperatureOperator):
67
+ """Differentiable ligand-receptor co-expression scoring operator.
68
+
69
+ Scores cell-cell communication by computing adjacency-weighted co-expression
70
+ of ligand-receptor gene pairs. For each pair, the score at each receiver
71
+ cell is the sum of sender ligand expression times receiver receptor
72
+ expression, weighted by a fuzzy k-NN adjacency graph.
73
+
74
+ Algorithm:
75
+ 1. Build a symmetric fuzzy k-NN adjacency from the count matrix using
76
+ ``compute_pairwise_distances``, ``compute_fuzzy_membership``, and
77
+ ``symmetrize_graph``.
78
+ 2. For each L-R pair (ligand_idx, receptor_idx):
79
+ - ``score_i = sum_j(adjacency[i,j] * L[j] * R[i])``
80
+ where L[j] is the sender's ligand expression and R[i] is the
81
+ receiver's receptor expression.
82
+ 3. Compute analytical soft p-values via z-score comparison against
83
+ an expected null distribution.
84
+
85
+ Inherits from TemperatureOperator to get:
86
+
87
+ - _temperature property for temperature-controlled smoothing
88
+ - soft_max() for logsumexp-based smooth maximum
89
+ - soft_argmax() for soft position selection
90
+
91
+ Args:
92
+ config: LRScoringConfig with operator parameters.
93
+ rngs: Flax NNX random number generators.
94
+ name: Optional operator name.
95
+
96
+ Example:
97
+ >>> config = LRScoringConfig(n_neighbors=15)
98
+ >>> op = DifferentiableLigandReceptor(config, rngs=nnx.Rngs(0))
99
+ >>> data = {"counts": counts, "lr_pairs": jnp.array([[0, 1]])}
100
+ >>> result, state, meta = op.apply(data, {}, None)
101
+ >>> result["lr_scores"].shape
102
+ (n_cells, 1)
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ config: LRScoringConfig,
108
+ *,
109
+ rngs: nnx.Rngs | None = None,
110
+ name: str | None = None,
111
+ ) -> None:
112
+ """Initialize the ligand-receptor scoring operator.
113
+
114
+ Args:
115
+ config: L-R scoring configuration.
116
+ rngs: Random number generators for parameter initialization.
117
+ name: Optional operator name.
118
+ """
119
+ super().__init__(config, rngs=rngs, name=name)
120
+
121
+ def _build_adjacency(
122
+ self,
123
+ counts: Float[Array, "n_cells n_genes"],
124
+ ) -> Float[Array, "n_cells n_cells"]:
125
+ """Build symmetric fuzzy k-NN adjacency from expression counts.
126
+
127
+ Args:
128
+ counts: Gene expression matrix.
129
+
130
+ Returns:
131
+ Symmetric adjacency matrix of shape ``(n_cells, n_cells)``.
132
+ """
133
+ # Mask self-distances with large sentinel before k-NN computation
134
+ n_cells = counts.shape[0]
135
+ distances = compute_pairwise_distances(counts, metric=self.config.metric)
136
+ distances = distances + jnp.eye(n_cells) * DISTANCE_MASK_SENTINEL
137
+
138
+ membership = compute_fuzzy_membership(distances, k=self.config.n_neighbors)
139
+ return symmetrize_graph(membership)
140
+
141
+ def _score_lr_pair(
142
+ self,
143
+ adjacency: Float[Array, "n_cells n_cells"],
144
+ ligand_expression: Float[Array, "n_cells"],
145
+ receptor_expression: Float[Array, "n_cells"],
146
+ ) -> Float[Array, "n_cells"]:
147
+ """Score L-R pair using Hill function (CellChat-style).
148
+
149
+ For each receiver cell *i* the score is a saturating Hill function
150
+ of the neighbor-averaged ligand signal times the receiver's own
151
+ receptor expression:
152
+
153
+ ``P = (L*R)^n / (Kh^n + (L*R)^n)``
154
+
155
+ where ``Kh`` and ``n`` are configured via ``LRScoringConfig.kh``
156
+ and ``LRScoringConfig.hill_n``.
157
+
158
+ Args:
159
+ adjacency: Symmetric adjacency matrix.
160
+ ligand_expression: Ligand gene expression per cell.
161
+ receptor_expression: Receptor gene expression per cell.
162
+
163
+ Returns:
164
+ Per-cell interaction score of shape ``(n_cells,)``.
165
+ """
166
+ # Neighbor-averaged ligand expression (sender perspective)
167
+ neighbor_ligand = adjacency @ ligand_expression # (n_cells,)
168
+
169
+ # L-R product per cell (receiver's receptor * sender's ligand)
170
+ lr_product = neighbor_ligand * receptor_expression
171
+
172
+ # Hill function for saturation (CellChat: Kh=0.5, n=1)
173
+ kh: float = self.config.kh
174
+ n: float = self.config.hill_n
175
+ score = lr_product**n / (kh**n + lr_product**n + EPSILON)
176
+
177
+ return score
178
+
179
+ def _compute_soft_pvalues(
180
+ self,
181
+ scores: Float[Array, "n_cells n_pairs"],
182
+ adjacency: Float[Array, "n_cells n_cells"],
183
+ counts: Float[Array, "n_cells n_genes"],
184
+ lr_pairs: Int[Array, "n_pairs 2"],
185
+ ) -> Float[Array, "n_pairs"]:
186
+ """Compute soft p-values for each L-R pair via analytical z-score.
187
+
188
+ Under a null where adjacency is independent of expression, the expected
189
+ score for each cell is ``E[score_i] = mean(L) * R[i] * sum_j(A[i,j])``.
190
+ The aggregate z-score is computed from the total observed vs. expected
191
+ score, and converted to a soft p-value via sigmoid.
192
+
193
+ Args:
194
+ scores: Per-cell L-R scores of shape ``(n_cells, n_pairs)``.
195
+ adjacency: Adjacency matrix.
196
+ counts: Expression matrix for computing means.
197
+ lr_pairs: L-R pair indices.
198
+
199
+ Returns:
200
+ Soft p-values per pair, shape ``(n_pairs,)``.
201
+ """
202
+ row_sums = jnp.sum(adjacency, axis=1) # (n_cells,)
203
+ temperature = self._temperature
204
+
205
+ def _pvalue_for_pair(pair_idx: Int[Array, ""]) -> Float[Array, ""]:
206
+ ligand_idx = lr_pairs[pair_idx, 0]
207
+ receptor_idx = lr_pairs[pair_idx, 1]
208
+
209
+ ligand_expr = counts[:, ligand_idx]
210
+ receptor_expr = counts[:, receptor_idx]
211
+
212
+ # Observed total score
213
+ observed = jnp.sum(scores[:, pair_idx])
214
+
215
+ # Expected under null: E[score_i] = mean(L) * R[i] * degree_i
216
+ mean_ligand = jnp.mean(ligand_expr)
217
+ mean_receptor = jnp.mean(receptor_expr)
218
+ expected = jnp.sum(mean_ligand * receptor_expr * row_sums)
219
+
220
+ # Correct variance for Var(sum_j A[i,j]*L[j]*R[i]) under
221
+ # independence of L and R:
222
+ # Var(sum) = sum(A^2) * (Var(L)*Var(R)
223
+ # + Var(L)*E[R]^2 + Var(R)*E[L]^2)
224
+ var_ligand = jnp.var(ligand_expr) + EPSILON
225
+ var_receptor = jnp.var(receptor_expr) + EPSILON
226
+ sum_adj_sq = jnp.sum(adjacency**2)
227
+ std_approx = jnp.sqrt(
228
+ sum_adj_sq
229
+ * (
230
+ var_ligand * var_receptor
231
+ + var_ligand * mean_receptor**2
232
+ + var_receptor * mean_ligand**2
233
+ )
234
+ + EPSILON
235
+ )
236
+
237
+ z_score = (observed - expected) / (std_approx + EPSILON)
238
+
239
+ # Soft p-value: high z-score -> low p-value
240
+ return soft_ops.less(z_score, 0.0, softness=temperature)
241
+
242
+ n_pairs = lr_pairs.shape[0]
243
+ pair_indices = jnp.arange(n_pairs)
244
+ return jax.vmap(_pvalue_for_pair)(pair_indices)
245
+
246
+ def apply(
247
+ self,
248
+ data: PyTree,
249
+ state: PyTree,
250
+ metadata: dict[str, Any] | None,
251
+ random_params: Any = None,
252
+ stats: dict[str, Any] | None = None,
253
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
254
+ """Apply ligand-receptor co-expression scoring.
255
+
256
+ Args:
257
+ data: Dictionary containing:
258
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
259
+ - ``"lr_pairs"``: L-R pair indices ``(n_pairs, 2)`` where each
260
+ row is ``[ligand_gene_idx, receptor_gene_idx]``
261
+ state: Element state (passed through unchanged).
262
+ metadata: Element metadata (passed through unchanged).
263
+ random_params: Not used (non-stochastic operator).
264
+ stats: Not used.
265
+
266
+ Returns:
267
+ Tuple of (transformed_data, state, metadata):
268
+ - transformed_data contains:
269
+
270
+ - all original data keys
271
+ - ``"lr_scores"``: Per-cell interaction scores ``(n_cells, n_pairs)``
272
+ - ``"lr_pvalues"``: Soft p-values per pair ``(n_pairs,)``
273
+ - state is passed through unchanged
274
+ - metadata is passed through unchanged
275
+ """
276
+ counts = data["counts"]
277
+ lr_pairs = data["lr_pairs"]
278
+
279
+ # Step 1: Build k-NN adjacency graph
280
+ adjacency = self._build_adjacency(counts)
281
+
282
+ # Step 2: Score each L-R pair across all cells
283
+ def _score_pair(pair: Int[Array, "2"]) -> Float[Array, "n_cells"]:
284
+ ligand_expr = counts[:, pair[0]]
285
+ receptor_expr = counts[:, pair[1]]
286
+ return self._score_lr_pair(adjacency, ligand_expr, receptor_expr)
287
+
288
+ lr_scores = jax.vmap(_score_pair)(lr_pairs).T # (n_cells, n_pairs)
289
+
290
+ # Step 3: Compute soft p-values
291
+ lr_pvalues = self._compute_soft_pvalues(lr_scores, adjacency, counts, lr_pairs)
292
+
293
+ transformed_data = {
294
+ **data,
295
+ "lr_scores": lr_scores,
296
+ "lr_pvalues": lr_pvalues,
297
+ }
298
+
299
+ return transformed_data, state, metadata
300
+
301
+
302
+ # =============================================================================
303
+ # GNN-based cell-cell communication
304
+ # =============================================================================
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class _CellCommunicationInputConfig:
309
+ """Input-space sizing for cell communication analysis."""
310
+
311
+ n_genes: int = 2000
312
+ n_lr_pairs: int = 10
313
+ edge_features_dim: int = 8
314
+
315
+
316
+ @dataclass(frozen=True)
317
+ class _CellCommunicationModelConfig:
318
+ """Graph-attention architecture for communication inference."""
319
+
320
+ hidden_dim: int = 64
321
+ num_heads: int = 4
322
+ num_gnn_layers: int = 2
323
+ n_pathways: int = 20
324
+ dropout_rate: float = 0.1
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class CellCommunicationConfig(
329
+ _CellCommunicationInputConfig,
330
+ _CellCommunicationModelConfig,
331
+ OperatorConfig,
332
+ ):
333
+ """Configuration for GNN-based cell-cell communication analysis."""
334
+
335
+ def __post_init__(self) -> None:
336
+ """Validate graph-attention communication configuration."""
337
+ if self.n_genes <= 0:
338
+ raise ValueError("n_genes must be positive")
339
+ if self.n_lr_pairs <= 0:
340
+ raise ValueError("n_lr_pairs must be positive")
341
+ if self.hidden_dim <= 0:
342
+ raise ValueError("hidden_dim must be positive")
343
+ if self.num_heads <= 0:
344
+ raise ValueError("num_heads must be positive")
345
+ if self.hidden_dim % self.num_heads != 0:
346
+ raise ValueError("hidden_dim must be divisible by num_heads")
347
+ if self.edge_features_dim <= 0:
348
+ raise ValueError("edge_features_dim must be positive")
349
+ if self.num_gnn_layers <= 0:
350
+ raise ValueError("num_gnn_layers must be positive")
351
+ if self.n_pathways <= 0:
352
+ raise ValueError("n_pathways must be positive")
353
+ if not 0.0 <= self.dropout_rate < 1.0:
354
+ raise ValueError("dropout_rate must be in [0, 1)")
355
+ super().__post_init__()
356
+
357
+
358
+ class SpatialAttentionGNN(nnx.Module):
359
+ """Stacked GATv2 layers with residual connections for spatial cell graphs.
360
+
361
+ Each layer applies GATv2 attention followed by a LayerNorm and residual
362
+ connection. An input projection maps node features to the hidden dimension
363
+ before the first GATv2 layer.
364
+
365
+ Args:
366
+ in_features: Dimension of input node features.
367
+ hidden_dim: Hidden dimension (must be divisible by num_heads).
368
+ num_heads: Number of attention heads per GATv2 layer.
369
+ edge_features_dim: Edge feature dimension.
370
+ num_layers: Number of GATv2 layers.
371
+ dropout_rate: Dropout rate.
372
+ rngs: Flax NNX random number generators.
373
+ """
374
+
375
+ def __init__(
376
+ self,
377
+ in_features: int,
378
+ hidden_dim: int,
379
+ num_heads: int,
380
+ edge_features_dim: int,
381
+ num_layers: int,
382
+ dropout_rate: float,
383
+ *,
384
+ rngs: nnx.Rngs,
385
+ ) -> None:
386
+ """Initialize the spatial attention GNN.
387
+
388
+ Args:
389
+ in_features: Input feature dimension.
390
+ hidden_dim: Hidden dimension.
391
+ num_heads: Number of attention heads.
392
+ edge_features_dim: Edge feature dimension.
393
+ num_layers: Number of GATv2 layers.
394
+ dropout_rate: Dropout rate.
395
+ rngs: Random number generators.
396
+ """
397
+ super().__init__()
398
+
399
+ # Project input features to hidden dimension
400
+ self.input_proj = nnx.Linear(
401
+ in_features=in_features,
402
+ out_features=hidden_dim,
403
+ rngs=rngs,
404
+ )
405
+
406
+ self.gat_layers = nnx.List(
407
+ [
408
+ GATv2Layer(
409
+ in_features=hidden_dim,
410
+ out_features=hidden_dim,
411
+ num_heads=num_heads,
412
+ edge_features=edge_features_dim,
413
+ dropout_rate=dropout_rate,
414
+ rngs=rngs,
415
+ )
416
+ for _ in range(num_layers)
417
+ ]
418
+ )
419
+
420
+ self.layer_norms = nnx.List(
421
+ [nnx.LayerNorm(num_features=hidden_dim, rngs=rngs) for _ in range(num_layers)]
422
+ )
423
+
424
+ def __call__(
425
+ self,
426
+ node_features: Float[Array, "n_nodes in_features"],
427
+ edge_index: Int[Array, "2 n_edges"],
428
+ edge_features: Float[Array, "n_edges edge_features_dim"],
429
+ *,
430
+ deterministic: bool = True,
431
+ ) -> Float[Array, "n_nodes hidden_dim"]:
432
+ """Run stacked GATv2 attention with residual connections.
433
+
434
+ Args:
435
+ node_features: Input node features.
436
+ edge_index: Edge indices ``(source, target)`` of shape ``(2, n_edges)``.
437
+ edge_features: Per-edge features.
438
+ deterministic: If True, disable dropout.
439
+
440
+ Returns:
441
+ Updated node embeddings of shape ``(n_nodes, hidden_dim)``.
442
+ """
443
+ x = self.input_proj(node_features)
444
+
445
+ for gat_layer, norm in zip(self.gat_layers, self.layer_norms):
446
+ residual = x
447
+ x = gat_layer(x, edge_index, edge_features, deterministic=deterministic)
448
+ x = norm(x + residual)
449
+
450
+ return x
451
+
452
+
453
+ class SignalingDecoder(nnx.Module):
454
+ """Map node embeddings to pathway activities and communication scores.
455
+
456
+ Two heads:
457
+ - **Pathway head**: ``Linear(hidden_dim, n_pathways)`` produces per-node
458
+ pathway activity.
459
+ - **Communication head**: ``Linear(hidden_dim, n_lr_pairs)`` produces
460
+ per-node communication scores for each L-R pair.
461
+
462
+ Args:
463
+ hidden_dim: Input embedding dimension.
464
+ n_pathways: Number of output pathways.
465
+ n_lr_pairs: Number of L-R pairs (communication score outputs).
466
+ rngs: Flax NNX random number generators.
467
+ """
468
+
469
+ def __init__(
470
+ self,
471
+ hidden_dim: int,
472
+ n_pathways: int,
473
+ n_lr_pairs: int,
474
+ *,
475
+ rngs: nnx.Rngs,
476
+ ) -> None:
477
+ """Initialize the signaling decoder.
478
+
479
+ Args:
480
+ hidden_dim: Hidden dimension.
481
+ n_pathways: Number of signaling pathways.
482
+ n_lr_pairs: Number of L-R pairs.
483
+ rngs: Random number generators.
484
+ """
485
+ super().__init__()
486
+
487
+ self.pathway_head = nnx.Linear(
488
+ in_features=hidden_dim,
489
+ out_features=n_pathways,
490
+ rngs=rngs,
491
+ )
492
+ self.comm_head = nnx.Linear(
493
+ in_features=hidden_dim,
494
+ out_features=n_lr_pairs,
495
+ rngs=rngs,
496
+ )
497
+
498
+ def __call__(
499
+ self,
500
+ node_embeddings: Float[Array, "n_nodes hidden_dim"],
501
+ ) -> tuple[
502
+ Float[Array, "n_nodes n_pathways"],
503
+ Float[Array, "n_nodes n_lr_pairs"],
504
+ ]:
505
+ """Decode node embeddings into pathway activities and communication scores.
506
+
507
+ Args:
508
+ node_embeddings: Node embedding matrix.
509
+
510
+ Returns:
511
+ Tuple of (signaling_activity, communication_scores).
512
+ """
513
+ signaling_activity = self.pathway_head(node_embeddings)
514
+ communication_scores = self.comm_head(node_embeddings)
515
+ return signaling_activity, communication_scores
516
+
517
+
518
+ class DifferentiableCellCommunication(GraphOperator):
519
+ """GNN-based differentiable cell-cell communication analysis.
520
+
521
+ Analyses inter-cellular signaling by applying GATv2 graph attention on a
522
+ spatial cell graph whose edges carry ligand-receptor expression features.
523
+
524
+ Algorithm:
525
+ 1. Build per-edge L-R expression features from ``counts`` and
526
+ ``lr_pairs``: for each edge (i, j) the feature vector is the
527
+ concatenation of ``[L_expr[source], R_expr[target]]`` across all
528
+ L-R pairs, projected to ``edge_features_dim``.
529
+ 2. Project per-node gene expression to initial node embeddings.
530
+ 3. Apply stacked GATv2 layers (``SpatialAttentionGNN``) for message
531
+ passing on the spatial cell graph.
532
+ 4. Decode node embeddings into per-node pathway activity and per-node
533
+ communication scores via ``SignalingDecoder``.
534
+
535
+ Inherits from GraphOperator to get:
536
+
537
+ - scatter_aggregate() for message aggregation
538
+ - global_pool() for graph-level pooling
539
+
540
+ Args:
541
+ config: CellCommunicationConfig with model parameters.
542
+ rngs: Flax NNX random number generators.
543
+ name: Optional operator name.
544
+
545
+ Example:
546
+ >>> config = CellCommunicationConfig(n_genes=50, n_lr_pairs=3, hidden_dim=32)
547
+ >>> op = DifferentiableCellCommunication(config, rngs=nnx.Rngs(0))
548
+ >>> data = {"counts": counts, "spatial_graph": graph, "lr_pairs": pairs}
549
+ >>> result, state, meta = op.apply(data, {}, None)
550
+ >>> result["communication_scores"].shape
551
+ (n_cells, 3)
552
+ """
553
+
554
+ def __init__(
555
+ self,
556
+ config: CellCommunicationConfig,
557
+ *,
558
+ rngs: nnx.Rngs | None = None,
559
+ name: str | None = None,
560
+ ) -> None:
561
+ """Initialize the cell communication operator.
562
+
563
+ Args:
564
+ config: Cell communication configuration.
565
+ rngs: Random number generators for parameter initialization.
566
+ name: Optional operator name.
567
+ """
568
+ super().__init__(config, rngs=rngs, name=name)
569
+
570
+ rngs = ensure_rngs(rngs)
571
+
572
+ # Node feature projection: n_genes -> hidden_dim
573
+ self.node_proj = nnx.Linear(
574
+ in_features=config.n_genes,
575
+ out_features=config.hidden_dim,
576
+ rngs=rngs,
577
+ )
578
+
579
+ # Edge feature projection: raw LR features (2 * n_lr_pairs) -> edge_features_dim
580
+ self.edge_proj = nnx.Linear(
581
+ in_features=2 * config.n_lr_pairs,
582
+ out_features=config.edge_features_dim,
583
+ rngs=rngs,
584
+ )
585
+
586
+ # GATv2 stack
587
+ self.spatial_gnn = SpatialAttentionGNN(
588
+ in_features=config.hidden_dim,
589
+ hidden_dim=config.hidden_dim,
590
+ num_heads=config.num_heads,
591
+ edge_features_dim=config.edge_features_dim,
592
+ num_layers=config.num_gnn_layers,
593
+ dropout_rate=config.dropout_rate,
594
+ rngs=rngs,
595
+ )
596
+
597
+ # Signaling decoder
598
+ self.decoder = SignalingDecoder(
599
+ hidden_dim=config.hidden_dim,
600
+ n_pathways=config.n_pathways,
601
+ n_lr_pairs=config.n_lr_pairs,
602
+ rngs=rngs,
603
+ )
604
+
605
+ def _build_edge_features(
606
+ self,
607
+ counts: Float[Array, "n_cells n_genes"],
608
+ spatial_graph: Int[Array, "2 n_edges"],
609
+ lr_pairs: Int[Array, "n_pairs 2"],
610
+ ) -> Float[Array, "n_edges edge_features_dim"]:
611
+ """Compute per-edge L-R expression features.
612
+
613
+ For each edge (i, j) and each L-R pair (l, r) the raw feature is
614
+ ``[counts[source, l], counts[target, r]]``. These are concatenated
615
+ across all pairs then projected to ``edge_features_dim``.
616
+
617
+ Args:
618
+ counts: Gene expression matrix ``(n_cells, n_genes)``.
619
+ spatial_graph: Edge indices ``(source, target)`` ``(2, n_edges)``.
620
+ lr_pairs: L-R pair gene indices ``(n_pairs, 2)``.
621
+
622
+ Returns:
623
+ Projected edge features ``(n_edges, edge_features_dim)``.
624
+ """
625
+ sources = spatial_graph[0] # (n_edges,)
626
+ targets = spatial_graph[1] # (n_edges,)
627
+
628
+ # For each LR pair, gather ligand expression of source and receptor
629
+ # expression of target. Shape per pair: (n_edges, 2)
630
+ def _pair_features(pair: Int[Array, "2"]) -> Float[Array, "n_edges 2"]:
631
+ ligand_idx = pair[0]
632
+ receptor_idx = pair[1]
633
+ l_expr = counts[sources, ligand_idx] # (n_edges,)
634
+ r_expr = counts[targets, receptor_idx] # (n_edges,)
635
+ return jnp.stack([l_expr, r_expr], axis=-1)
636
+
637
+ # (n_pairs, n_edges, 2)
638
+ raw_features = jax.vmap(_pair_features)(lr_pairs)
639
+ # Reshape to (n_edges, 2*n_pairs)
640
+ n_edges = spatial_graph.shape[1]
641
+ raw_features = raw_features.transpose(1, 0, 2).reshape(n_edges, -1)
642
+
643
+ return self.edge_proj(raw_features)
644
+
645
+ def apply(
646
+ self,
647
+ data: PyTree,
648
+ state: PyTree,
649
+ metadata: dict[str, Any] | None,
650
+ random_params: Any = None,
651
+ stats: dict[str, Any] | None = None,
652
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
653
+ """Apply GNN-based cell-cell communication analysis.
654
+
655
+ Args:
656
+ data: Dictionary containing:
657
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
658
+ - ``"spatial_graph"``: Edge indices ``(2, n_edges)`` where
659
+ row 0 = source nodes, row 1 = target nodes
660
+ - ``"lr_pairs"``: L-R pair gene indices ``(n_pairs, 2)``
661
+ state: Element state (passed through unchanged).
662
+ metadata: Element metadata (passed through unchanged).
663
+ random_params: Not used (non-stochastic operator).
664
+ stats: Not used.
665
+
666
+ Returns:
667
+ Tuple of (transformed_data, state, metadata):
668
+ - transformed_data contains all original keys plus:
669
+
670
+ - ``"communication_scores"``: ``(n_cells, n_pairs)``
671
+ - ``"signaling_activity"``: ``(n_cells, n_pathways)``
672
+ - ``"niche_embeddings"``: ``(n_cells, hidden_dim)``
673
+ - state is passed through unchanged
674
+ - metadata is passed through unchanged
675
+ """
676
+ counts: Float[Array, "n_cells n_genes"] = data["counts"]
677
+ spatial_graph: Int[Array, "2 n_edges"] = data["spatial_graph"]
678
+ lr_pairs: Int[Array, "n_pairs 2"] = data["lr_pairs"]
679
+
680
+ # Step 1: Build per-edge L-R features
681
+ edge_features = self._build_edge_features(counts, spatial_graph, lr_pairs)
682
+
683
+ # Step 2: Project per-node gene expression to hidden dim
684
+ node_features = self.node_proj(counts) # (n_cells, hidden_dim)
685
+
686
+ # Step 3: GATv2 message passing
687
+ niche_embeddings = self.spatial_gnn(
688
+ node_features,
689
+ spatial_graph,
690
+ edge_features,
691
+ deterministic=True,
692
+ )
693
+
694
+ # Step 4: Decode to pathway activities and communication scores
695
+ signaling_activity, communication_scores = self.decoder(niche_embeddings)
696
+
697
+ transformed_data = {
698
+ **data,
699
+ "communication_scores": communication_scores,
700
+ "signaling_activity": signaling_activity,
701
+ "niche_embeddings": niche_embeddings,
702
+ }
703
+
704
+ return transformed_data, state, metadata