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,444 @@
1
+ """Splatter-style differentiable single-cell count simulator.
2
+
3
+ This module provides a fully differentiable implementation of the Splatter
4
+ simulation algorithm (Zappia et al., 2017) using a Gamma-Poisson model with
5
+ learnable parameters. The simulator generates realistic scRNA-seq count
6
+ matrices with group-specific differential expression, batch effects, and
7
+ expression-dependent dropout.
8
+
9
+ Key techniques:
10
+ - Gamma-distributed gene means with softplus-parameterized learnable logits
11
+ - LogNormal library sizes for cell-level sequencing depth variation
12
+ - Soft group assignments via learnable logits and softmax
13
+ - Logistic dropout model as a function of mean expression
14
+ - Continuous relaxation of Poisson sampling for differentiability
15
+
16
+ Applications: Benchmarking single-cell methods, data augmentation for
17
+ downstream analysis, parameter estimation via gradient-based optimization.
18
+ """
19
+
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ import jax
25
+ import jax.numpy as jnp
26
+ from datarax.core.config import OperatorConfig
27
+ from datarax.core.operator import OperatorModule
28
+ from flax import nnx
29
+ from jaxtyping import Array, Float, Int, PyTree
30
+
31
+ from diffbio.constants import EPSILON
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class _SimulationSizeConfig:
38
+ """Cell, gene, group, and batch sizing for the simulator."""
39
+
40
+ n_cells: int = 500
41
+ n_genes: int = 200
42
+ n_groups: int = 3
43
+ n_batches: int = 1
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _SimulationDistributionConfig:
48
+ """Sampling distributions for means, library sizes, and DE effects."""
49
+
50
+ mean_shape: float = 0.6
51
+ mean_rate: float = 0.3
52
+ lib_loc: float = 11.0
53
+ lib_scale: float = 0.2
54
+ de_prob: float = 0.1
55
+ de_fac_loc: float = 0.1
56
+ de_fac_scale: float = 0.4
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _SimulationDropoutConfig:
61
+ """Expression-dependent dropout parameters."""
62
+
63
+ dropout_mid: float = -1.0
64
+ dropout_shape: float = -0.5
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SimulationConfig(
69
+ _SimulationSizeConfig,
70
+ _SimulationDistributionConfig,
71
+ _SimulationDropoutConfig,
72
+ OperatorConfig,
73
+ ):
74
+ """Configuration for DifferentiableSimulator."""
75
+
76
+ def __post_init__(self) -> None:
77
+ """Set stochastic defaults and validate simulation assumptions."""
78
+ object.__setattr__(self, "stochastic", True)
79
+ if self.stream_name is None:
80
+ object.__setattr__(self, "stream_name", "sample")
81
+ if self.n_cells <= 0:
82
+ raise ValueError("n_cells must be positive")
83
+ if self.n_genes <= 0:
84
+ raise ValueError("n_genes must be positive")
85
+ if self.n_groups <= 0:
86
+ raise ValueError("n_groups must be positive")
87
+ if self.n_groups > self.n_cells:
88
+ raise ValueError("n_groups cannot exceed n_cells for even cell-group assignment")
89
+ if self.n_batches <= 0:
90
+ raise ValueError("n_batches must be positive")
91
+ if self.n_batches > self.n_cells:
92
+ raise ValueError("n_batches cannot exceed n_cells for even batch assignment")
93
+ if self.mean_shape <= 0.0:
94
+ raise ValueError("mean_shape must be positive")
95
+ if self.mean_rate <= 0.0:
96
+ raise ValueError("mean_rate must be positive")
97
+ if self.lib_scale < 0.0:
98
+ raise ValueError("lib_scale must be non-negative")
99
+ if not 0.0 <= self.de_prob <= 1.0:
100
+ raise ValueError("de_prob must be in [0, 1]")
101
+ if self.de_fac_scale < 0.0:
102
+ raise ValueError("de_fac_scale must be non-negative")
103
+ if self.dropout_shape >= 0.0:
104
+ raise ValueError("dropout_shape must be negative to model lower-expression dropout")
105
+ super().__post_init__()
106
+
107
+
108
+ class DifferentiableSimulator(OperatorModule):
109
+ """Splatter-style differentiable single-cell count simulator.
110
+
111
+ Generates realistic scRNA-seq count matrices following the Splatter
112
+ generative model (Zappia et al., 2017), with all steps implemented
113
+ as differentiable JAX operations.
114
+
115
+ Algorithm:
116
+ 1. Gene means: softplus-transformed learnable logits, scaled by
117
+ Gamma(shape, rate) random perturbation.
118
+ 2. Cell library sizes: LogNormal(lib_loc, lib_scale) sampling.
119
+ 3. Group assignments: cells divided evenly across groups, with
120
+ learnable group logits enabling soft assignment.
121
+ 4. DE fold-changes: per-group per-gene LogNormal fold-changes,
122
+ masked by a Bernoulli(de_prob) DE indicator.
123
+ 5. Cell means: lib_sizes * gene_means * group_fold_change * batch_effect.
124
+ 6. Batch effects: exp(learnable batch_shift) multiplicative scaling.
125
+ 7. Dropout: sigmoid-based keep probability as function of log(cell_means).
126
+ 8. Counts: cell_means * keep_prob (continuous relaxation of Poisson).
127
+
128
+ Args:
129
+ config: SimulationConfig with model parameters.
130
+ rngs: Flax NNX random number generators.
131
+ name: Optional operator name.
132
+
133
+ Example:
134
+ >>> config = SimulationConfig(n_cells=100, n_genes=50, n_groups=2)
135
+ >>> sim = DifferentiableSimulator(config, rngs=nnx.Rngs(0, sample=1))
136
+ >>> rng = jax.random.key(0)
137
+ >>> rp = sim.generate_random_params(rng, {})
138
+ >>> result, state, meta = sim.apply({}, {}, None, random_params=rp)
139
+ >>> result["counts"].shape
140
+ (100, 50)
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ config: SimulationConfig,
146
+ *,
147
+ rngs: nnx.Rngs | None = None,
148
+ name: str | None = None,
149
+ ) -> None:
150
+ """Initialize the differentiable simulator.
151
+
152
+ Args:
153
+ config: Simulation configuration.
154
+ rngs: Random number generators for parameter initialization.
155
+ name: Optional operator name.
156
+ """
157
+ super().__init__(config, rngs=rngs, name=name)
158
+
159
+ safe_rngs = rngs or nnx.Rngs(0)
160
+
161
+ # Learnable gene-mean logits: softplus maps these to positive means
162
+ key = safe_rngs.params()
163
+ init_logits = jax.random.normal(key, (config.n_genes,)) * 0.5
164
+ self.gene_means_logits = nnx.Param(init_logits)
165
+
166
+ # Learnable group logits for soft cell-group assignment
167
+ key = safe_rngs.params()
168
+ init_group_logits = jax.random.normal(key, (config.n_groups,)) * 0.1
169
+ self.group_logits = nnx.Param(init_group_logits)
170
+
171
+ # Learnable batch shift (additive on log scale)
172
+ self.batch_shift = nnx.Param(jnp.zeros(config.n_batches))
173
+
174
+ def generate_random_params(
175
+ self,
176
+ rng: jax.Array,
177
+ data_shapes: PyTree,
178
+ ) -> dict[str, jax.Array]:
179
+ """Generate random keys for all stochastic sampling steps.
180
+
181
+ Args:
182
+ rng: JAX random key.
183
+ data_shapes: PyTree with shapes (unused, kept for interface).
184
+
185
+ Returns:
186
+ Dictionary of JAX random keys for each sampling step.
187
+ """
188
+ keys = jax.random.split(rng, 6)
189
+ return {
190
+ "gene_means_key": keys[0],
191
+ "lib_sizes_key": keys[1],
192
+ "group_key": keys[2],
193
+ "de_mask_key": keys[3],
194
+ "de_fold_key": keys[4],
195
+ "poisson_key": keys[5],
196
+ }
197
+
198
+ def _sample_gene_means(
199
+ self,
200
+ key: jax.Array,
201
+ ) -> Float[Array, "n_genes"]:
202
+ """Sample gene means from Gamma distribution scaled by learnable logits.
203
+
204
+ The base gene means come from softplus(learnable_logits), then are
205
+ perturbed by Gamma(shape, 1) / rate to maintain the Splatter prior.
206
+
207
+ Args:
208
+ key: JAX random key for Gamma sampling.
209
+
210
+ Returns:
211
+ Positive gene mean expression levels of shape (n_genes,).
212
+ """
213
+ config = self.config
214
+
215
+ # Learnable base means via softplus (always positive)
216
+ base_means = jax.nn.softplus(self.gene_means_logits[...])
217
+
218
+ # Stochastic Gamma perturbation: gamma(shape) / rate
219
+ gamma_samples = jax.random.gamma(key, config.mean_shape, shape=(config.n_genes,))
220
+ gamma_factor = gamma_samples / (config.mean_rate + EPSILON)
221
+
222
+ # Combine learnable and stochastic components
223
+ return base_means * gamma_factor + EPSILON
224
+
225
+ def _sample_library_sizes(
226
+ self,
227
+ key: jax.Array,
228
+ ) -> Float[Array, "n_cells"]:
229
+ """Sample cell library sizes from LogNormal distribution.
230
+
231
+ Args:
232
+ key: JAX random key for Normal sampling.
233
+
234
+ Returns:
235
+ Positive library sizes of shape (n_cells,).
236
+ """
237
+ config = self.config
238
+ log_lib = config.lib_loc + config.lib_scale * jax.random.normal(key, (config.n_cells,))
239
+ return jnp.exp(log_lib)
240
+
241
+ def _assign_groups(
242
+ self,
243
+ key: jax.Array,
244
+ ) -> tuple[Int[Array, "n_cells"], Float[Array, "n_cells n_groups"]]:
245
+ """Assign cells to groups using even division with learnable logits.
246
+
247
+ Cells are divided evenly across groups. The soft assignment
248
+ probabilities are derived from learnable group logits via softmax,
249
+ enabling gradient flow through group membership.
250
+
251
+ Args:
252
+ key: JAX random key (unused, kept for interface consistency).
253
+
254
+ Returns:
255
+ Tuple of (hard_labels, soft_assignments) where:
256
+ - hard_labels: Integer group labels of shape (n_cells,).
257
+ - soft_assignments: Soft probabilities of shape (n_cells, n_groups).
258
+ """
259
+ config = self.config
260
+
261
+ # Even division of cells across groups
262
+ cells_per_group = config.n_cells // config.n_groups
263
+ hard_labels = jnp.repeat(jnp.arange(config.n_groups), cells_per_group)
264
+ # Handle remainder cells
265
+ remainder = config.n_cells - len(hard_labels)
266
+ if remainder > 0:
267
+ extra = jnp.full(remainder, config.n_groups - 1)
268
+ hard_labels = jnp.concatenate([hard_labels, extra])
269
+
270
+ # Soft assignments from learnable group logits
271
+ group_probs = jax.nn.softmax(self.group_logits[...]) # (n_groups,)
272
+ # Expand to full soft assignment matrix via one-hot weighting
273
+ one_hot = jax.nn.one_hot(hard_labels, config.n_groups) # (n_cells, n_groups)
274
+ # Scale by learnable logits for gradient flow
275
+ soft_assignments_full = one_hot * group_probs[None, :] # (n_cells, n_groups)
276
+ soft_assignments_full = soft_assignments_full / (
277
+ jnp.sum(soft_assignments_full, axis=-1, keepdims=True) + EPSILON
278
+ )
279
+
280
+ return hard_labels, soft_assignments_full
281
+
282
+ def _compute_de_fold_changes(
283
+ self,
284
+ de_mask_key: jax.Array,
285
+ de_fold_key: jax.Array,
286
+ ) -> tuple[Float[Array, "n_groups n_genes"], Float[Array, "n_groups n_genes"]]:
287
+ """Compute per-group DE fold-changes with Bernoulli masking.
288
+
289
+ For each group, a subset of genes (determined by de_prob) receives
290
+ a LogNormal fold-change; non-DE genes have fold-change 1.0.
291
+
292
+ Args:
293
+ de_mask_key: JAX random key for DE mask sampling.
294
+ de_fold_key: JAX random key for fold-change sampling.
295
+
296
+ Returns:
297
+ Tuple of (fold_changes, de_mask) where:
298
+ - fold_changes: Multiplicative fold-changes (n_groups, n_genes).
299
+ - de_mask: Binary DE indicator (n_groups, n_genes).
300
+ """
301
+ config = self.config
302
+
303
+ # Bernoulli mask: which genes are DE in each group
304
+ de_mask = jax.random.bernoulli(
305
+ de_mask_key, config.de_prob, shape=(config.n_groups, config.n_genes)
306
+ ).astype(jnp.float32)
307
+
308
+ # LogNormal fold-changes for DE genes
309
+ log_fc = config.de_fac_loc + config.de_fac_scale * jax.random.normal(
310
+ de_fold_key, (config.n_groups, config.n_genes)
311
+ )
312
+ fold_changes_raw = jnp.exp(log_fc)
313
+
314
+ # Non-DE genes get fold-change of 1.0
315
+ fold_changes = jnp.where(de_mask > 0.5, fold_changes_raw, 1.0)
316
+
317
+ return fold_changes, de_mask
318
+
319
+ def _apply_batch_effects(
320
+ self,
321
+ cell_means: Float[Array, "n_cells n_genes"],
322
+ batch_labels: Int[Array, "n_cells"],
323
+ ) -> Float[Array, "n_cells n_genes"]:
324
+ """Apply multiplicative batch effects to cell means.
325
+
326
+ Batch effects are exp(learnable_shift), applied multiplicatively.
327
+
328
+ Args:
329
+ cell_means: Pre-batch cell expression means.
330
+ batch_labels: Integer batch assignment per cell.
331
+
332
+ Returns:
333
+ Batch-corrected cell means of shape (n_cells, n_genes).
334
+ """
335
+ batch_factors = jnp.exp(self.batch_shift[...])[batch_labels] # (n_cells,)
336
+ return cell_means * batch_factors[:, None]
337
+
338
+ def _apply_dropout(
339
+ self,
340
+ cell_means: Float[Array, "n_cells n_genes"],
341
+ ) -> Float[Array, "n_cells n_genes"]:
342
+ """Apply expression-dependent dropout via logistic function.
343
+
344
+ keep_prob = sigmoid(dropout_shape * (log(cell_means) - dropout_mid))
345
+
346
+ At low expression, keep_prob is low (more zeros); at high expression,
347
+ keep_prob approaches 1.
348
+
349
+ Args:
350
+ cell_means: Cell expression means (positive values).
351
+
352
+ Returns:
353
+ Dropout-adjusted cell means.
354
+ """
355
+ config = self.config
356
+ log_means = jnp.log(cell_means + EPSILON)
357
+ keep_prob = jax.nn.sigmoid(config.dropout_shape * (log_means - config.dropout_mid))
358
+ return cell_means * keep_prob
359
+
360
+ def apply(
361
+ self,
362
+ data: PyTree,
363
+ state: PyTree,
364
+ metadata: dict[str, Any] | None,
365
+ random_params: Any = None,
366
+ stats: dict[str, Any] | None = None,
367
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
368
+ """Simulate a single-cell count matrix.
369
+
370
+ Follows the Splatter generative model with all steps differentiable:
371
+ gene means, library sizes, group DE, batch effects, dropout, and
372
+ Poisson count generation (continuous relaxation).
373
+
374
+ Args:
375
+ data: Input dictionary (may be empty; existing keys are preserved).
376
+ state: Element state (passed through unchanged).
377
+ metadata: Element metadata (passed through unchanged).
378
+ random_params: Dictionary of JAX random keys from generate_random_params.
379
+ stats: Not used.
380
+
381
+ Returns:
382
+ Tuple of (output_data, state, metadata) where output_data contains:
383
+ - All original data keys preserved.
384
+ - "counts": Simulated count matrix (n_cells, n_genes).
385
+ - "group_labels": Hard group assignments (n_cells,).
386
+ - "batch_labels": Batch assignments (n_cells,).
387
+ - "gene_means": Per-gene expression means (n_genes,).
388
+ - "de_mask": Binary DE indicator (n_groups, n_genes).
389
+ """
390
+ rp = random_params or {}
391
+
392
+ # Step 1: Gene means from learnable logits + Gamma perturbation
393
+ gene_means = self._sample_gene_means(rp["gene_means_key"])
394
+
395
+ # Step 2: Library sizes from LogNormal
396
+ lib_sizes = self._sample_library_sizes(rp["lib_sizes_key"])
397
+
398
+ # Step 3: Group assignments
399
+ group_labels, soft_assignments = self._assign_groups(rp["group_key"])
400
+
401
+ # Step 4: DE fold-changes per group
402
+ fold_changes, de_mask = self._compute_de_fold_changes(rp["de_mask_key"], rp["de_fold_key"])
403
+
404
+ # Step 5: Compute cell means = lib_size * gene_mean * group_fold_change
405
+ # Use soft assignments for differentiability:
406
+ # effective_fold = sum_g(soft_assign[c,g] * fold_change[g,:])
407
+ effective_fold = jnp.einsum(
408
+ "cg,gn->cn", soft_assignments, fold_changes
409
+ ) # (n_cells, n_genes)
410
+ cell_means = lib_sizes[:, None] * gene_means[None, :] * effective_fold
411
+
412
+ # Step 6: Batch effects
413
+ # Assign cells evenly across batches
414
+ config = self.config
415
+ cells_per_batch = config.n_cells // config.n_batches
416
+ batch_labels = jnp.repeat(jnp.arange(config.n_batches), cells_per_batch)
417
+ remainder = config.n_cells - len(batch_labels)
418
+ if remainder > 0:
419
+ extra = jnp.full(remainder, config.n_batches - 1)
420
+ batch_labels = jnp.concatenate([batch_labels, extra])
421
+
422
+ cell_means = self._apply_batch_effects(cell_means, batch_labels)
423
+
424
+ # Step 7: Dropout
425
+ cell_means = self._apply_dropout(cell_means)
426
+
427
+ # Step 8: Continuous relaxation of Poisson sampling
428
+ # Use the reparameterization: counts ~ Poisson(lambda) ≈ lambda + sqrt(lambda) * noise
429
+ noise = jax.random.normal(rp["poisson_key"], cell_means.shape)
430
+ counts = cell_means + jnp.sqrt(cell_means + EPSILON) * noise
431
+ # Ensure non-negative counts
432
+ counts = jax.nn.relu(counts)
433
+
434
+ # Build output
435
+ output_data = {
436
+ **data,
437
+ "counts": counts,
438
+ "group_labels": group_labels,
439
+ "batch_labels": batch_labels,
440
+ "gene_means": gene_means,
441
+ "de_mask": de_mask,
442
+ }
443
+
444
+ return output_data, state, metadata
@@ -0,0 +1,247 @@
1
+ """SINDy-based gene regulatory network inference.
2
+
3
+ Implements Sparse Identification of Nonlinear Dynamics (SINDy) for
4
+ discovering gene regulatory equations from expression time-series or
5
+ pseudotime-ordered single-cell data.
6
+
7
+ Unlike the GATv2-based ``DifferentiableGRN`` which learns regulatory
8
+ strengths via attention weights, SINDy discovers explicit governing
9
+ equations by fitting sparse coefficient vectors to a polynomial library
10
+ of candidate terms.
11
+
12
+ Algorithm:
13
+ 1. Build a polynomial feature library from expression data.
14
+ 2. Compute numerical derivatives (finite differences along time axis).
15
+ 3. Solve for sparse coefficients via differentiable soft thresholding
16
+ (proximal gradient descent).
17
+ 4. The coefficient matrix encodes which genes regulate which.
18
+
19
+ Reference:
20
+ Brunton, Proctor & Kutz (2016). Discovering governing equations from
21
+ data by sparse identification of nonlinear dynamical systems. PNAS.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from typing import Any
29
+
30
+ import jax
31
+ import jax.numpy as jnp
32
+ from datarax.core.config import OperatorConfig
33
+ from datarax.core.operator import OperatorModule
34
+ from flax import nnx
35
+ from jaxtyping import Array, Float, PyTree
36
+
37
+ from diffbio.core import soft_ops
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SINDyGRNConfig(OperatorConfig):
44
+ """Configuration for SINDy GRN inference.
45
+
46
+ Attributes:
47
+ n_genes: Number of genes in the expression matrix.
48
+ polynomial_degree: Maximum polynomial degree for the feature library.
49
+ sparsity_threshold: Soft thresholding level for sparse regression.
50
+ Higher values produce sparser coefficient matrices.
51
+ n_iterations: Number of iterative thresholding steps (STRidge).
52
+ ridge_alpha: Ridge regression regularisation parameter.
53
+ """
54
+
55
+ n_genes: int = 100
56
+ polynomial_degree: int = 2
57
+ sparsity_threshold: float = 0.1
58
+ n_iterations: int = 10
59
+ ridge_alpha: float = 0.01
60
+
61
+
62
+ def build_polynomial_library(
63
+ x: Float[Array, "n_samples n_features"],
64
+ degree: int = 2,
65
+ ) -> Float[Array, "n_samples n_library"]:
66
+ """Build a polynomial feature library from input data.
67
+
68
+ For degree=1, returns x unchanged (linear terms only).
69
+ For degree=2, appends all pairwise products (x_i * x_j for i <= j).
70
+
71
+ Args:
72
+ x: Input data of shape (n_samples, n_features).
73
+ degree: Maximum polynomial degree (1 or 2).
74
+
75
+ Returns:
76
+ Library matrix with columns for each polynomial term.
77
+ """
78
+ terms = [x]
79
+
80
+ if degree >= 2:
81
+ n_features = x.shape[1]
82
+ # Upper-triangular indices for pairwise products (i <= j)
83
+ i_idx, j_idx = jnp.triu_indices(n_features)
84
+ quadratic = x[:, i_idx] * x[:, j_idx] # (n_samples, n_pairs)
85
+ terms.append(quadratic)
86
+
87
+ return jnp.concatenate(terms, axis=1)
88
+
89
+
90
+ def _soft_threshold(
91
+ x: Array,
92
+ threshold: float,
93
+ ) -> Array:
94
+ """Differentiable soft thresholding (proximal operator for L1).
95
+
96
+ Args:
97
+ x: Input array.
98
+ threshold: Threshold level.
99
+
100
+ Returns:
101
+ Soft-thresholded array: sign(x) * max(|x| - threshold, 0).
102
+ """
103
+ return soft_ops.sign(x, softness=0.1) * soft_ops.relu(
104
+ soft_ops.abs(x, softness=0.1) - threshold, softness=0.1
105
+ )
106
+
107
+
108
+ def _stridge_solve(
109
+ theta: Float[Array, "n_samples n_library"],
110
+ dx: Float[Array, "n_samples n_genes"],
111
+ *,
112
+ sparsity_threshold: float,
113
+ n_iterations: int,
114
+ ridge_alpha: float,
115
+ ) -> Float[Array, "n_library n_genes"]:
116
+ """Sequentially Thresholded Ridge Regression (STRidge).
117
+
118
+ Iteratively solves ridge regression then soft-thresholds small
119
+ coefficients to promote sparsity.
120
+
121
+ Args:
122
+ theta: Feature library matrix (n_samples, n_library).
123
+ dx: Numerical derivatives (n_samples, n_genes).
124
+ sparsity_threshold: Soft thresholding level.
125
+ n_iterations: Number of threshold iterations.
126
+ ridge_alpha: Ridge regularisation parameter.
127
+
128
+ Returns:
129
+ Sparse coefficient matrix (n_library, n_genes).
130
+ """
131
+ n_lib = theta.shape[1]
132
+ regulariser = ridge_alpha * jnp.eye(n_lib)
133
+
134
+ # Initial ridge regression: xi = (Theta^T Theta + alpha*I)^{-1} Theta^T dx
135
+ gram = theta.T @ theta + regulariser
136
+ xi = jnp.linalg.solve(gram, theta.T @ dx)
137
+
138
+ def _threshold_step(
139
+ xi: Float[Array, "n_library n_genes"],
140
+ _: None,
141
+ ) -> tuple[Float[Array, "n_library n_genes"], None]:
142
+ return _soft_threshold(xi, sparsity_threshold), None
143
+
144
+ xi, _ = jax.lax.scan(_threshold_step, xi, None, length=n_iterations)
145
+ return xi
146
+
147
+
148
+ class SINDyGRNOperator(OperatorModule):
149
+ """SINDy-based differentiable gene regulatory network inference.
150
+
151
+ Discovers sparse governing equations for gene expression dynamics
152
+ by fitting a polynomial library to numerical derivatives of expression
153
+ data. The resulting coefficient matrix encodes which genes (and their
154
+ interactions) regulate which target genes.
155
+
156
+ Complements ``DifferentiableGRN`` (GATv2-based) by providing an
157
+ equation-discovery approach rather than an attention-based one.
158
+
159
+ Input data:
160
+ - ``"counts"``: Expression matrix ``(n_timepoints, n_genes)``
161
+ ordered by time or pseudotime.
162
+
163
+ Output adds:
164
+ - ``"grn_coefficients"``: Sparse coefficient matrix
165
+ ``(n_library, n_genes)`` encoding regulatory relationships.
166
+ - ``"grn_equations"``: Same as coefficients (alias for clarity).
167
+
168
+ Args:
169
+ config: SINDyGRNConfig with model parameters.
170
+ rngs: Flax NNX random number generators.
171
+ name: Optional operator name.
172
+
173
+ Example:
174
+ >>> config = SINDyGRNConfig(n_genes=5, polynomial_degree=1)
175
+ >>> op = SINDyGRNOperator(config, rngs=nnx.Rngs(0))
176
+ >>> data = {"counts": time_ordered_expression}
177
+ >>> result, _, _ = op.apply(data, {}, None)
178
+ >>> result["grn_coefficients"].shape
179
+ (5, 5)
180
+ """
181
+
182
+ def __init__(
183
+ self,
184
+ config: SINDyGRNConfig,
185
+ *,
186
+ rngs: nnx.Rngs | None = None,
187
+ name: str | None = None,
188
+ ) -> None:
189
+ """Initialize SINDy GRN operator.
190
+
191
+ Args:
192
+ config: SINDy configuration.
193
+ rngs: Random number generators.
194
+ name: Optional operator name.
195
+ """
196
+ super().__init__(config, rngs=rngs, name=name)
197
+ self.config: SINDyGRNConfig = config
198
+
199
+ def apply(
200
+ self,
201
+ data: PyTree,
202
+ state: PyTree,
203
+ metadata: dict[str, Any] | None,
204
+ random_params: Any = None, # noqa: ARG002
205
+ stats: dict[str, Any] | None = None, # noqa: ARG002
206
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
207
+ """Apply SINDy GRN inference.
208
+
209
+ Args:
210
+ data: Dictionary containing:
211
+ - ``"counts"``: Expression matrix ``(n_timepoints, n_genes)``
212
+ state: Element state (passed through).
213
+ metadata: Element metadata (passed through).
214
+ random_params: Unused.
215
+ stats: Unused.
216
+
217
+ Returns:
218
+ Tuple of (output_data, state, metadata).
219
+ """
220
+ counts = data["counts"] # (n_timepoints, n_genes)
221
+ cfg = self.config
222
+
223
+ # Compute numerical derivatives (forward differences)
224
+ dx = counts[1:] - counts[:-1] # (n_timepoints - 1, n_genes)
225
+ x_mid = counts[:-1] # Use start-of-interval values
226
+
227
+ # Build polynomial feature library
228
+ theta = build_polynomial_library(x_mid, degree=cfg.polynomial_degree)
229
+
230
+ # Solve sparse regression
231
+ coefficients = _stridge_solve(
232
+ theta,
233
+ dx,
234
+ sparsity_threshold=cfg.sparsity_threshold,
235
+ n_iterations=cfg.n_iterations,
236
+ ridge_alpha=cfg.ridge_alpha,
237
+ )
238
+
239
+ return (
240
+ {
241
+ **data,
242
+ "grn_coefficients": coefficients,
243
+ "grn_equations": coefficients,
244
+ },
245
+ state,
246
+ metadata,
247
+ )