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,429 @@
1
+ """Differentiable imputation operators for single-cell data.
2
+
3
+ This module provides two complementary imputation strategies:
4
+
5
+ 1. **DifferentiableDiffusionImputer**: MAGIC-style diffusion imputation that
6
+ constructs a cell-cell affinity graph using an alpha-decaying kernel,
7
+ builds a row-stochastic Markov matrix ``M = D^{-1} A``, and computes
8
+ ``M^t`` via repeated matrix multiplication for diffusion-based imputation.
9
+
10
+ 2. **DifferentiableTransformerDenoiser**: Transformer-based gene denoiser that
11
+ treats genes as tokens, randomly masks a fraction of them, and predicts
12
+ the masked gene expression values from the unmasked context using a
13
+ transformer encoder. Reuses ``TransformerSequenceEncoder`` from the
14
+ foundation models module (DRY).
15
+
16
+ Applications: Denoising dropout events in scRNA-seq count matrices, recovering
17
+ gene-gene relationships masked by technical noise.
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, PyTree
30
+
31
+ from diffbio.constants import DISTANCE_MASK_SENTINEL
32
+ from diffbio.core import soft_ops
33
+ from diffbio.core.graph_utils import (
34
+ compute_pairwise_distances,
35
+ symmetrize_graph,
36
+ )
37
+ from diffbio.operators._masked_gene_transformer import (
38
+ MaskedGeneTransformerConfigBase,
39
+ MaskedGeneTransformerOperatorMixin,
40
+ build_masked_gene_transformer_encoder,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class DiffusionImputerConfig(OperatorConfig):
48
+ """Configuration for MAGIC-style diffusion imputation.
49
+
50
+ Attributes:
51
+ n_neighbors: Number of neighbors for local bandwidth estimation.
52
+ diffusion_t: Number of diffusion time steps (matrix power).
53
+ n_pca_components: Number of PCA components (reserved for future use).
54
+ decay: Exponent for the alpha-decaying kernel (MAGIC default is 1).
55
+ metric: Distance metric, either ``"euclidean"`` or ``"cosine"``.
56
+ """
57
+
58
+ n_neighbors: int = 5
59
+ diffusion_t: int = 3
60
+ n_pca_components: int = 100
61
+ decay: float = 1.0
62
+ metric: str = "euclidean"
63
+
64
+
65
+ class DifferentiableDiffusionImputer(OperatorModule):
66
+ """Differentiable MAGIC-style diffusion imputation.
67
+
68
+ Constructs a cell-cell affinity graph using an alpha-decaying kernel,
69
+ symmetrizes it, builds a row-stochastic Markov matrix ``M = D^{-1} A``,
70
+ and computes ``M^t`` via repeated matrix multiplication for imputation.
71
+ This avoids eigendecomposition (whose backward pass produces NaN when
72
+ eigenvalues are near-degenerate) while remaining fully differentiable.
73
+
74
+ Algorithm:
75
+ 1. Compute pairwise distances between cells
76
+ 2. Build alpha-decay affinity: ``K(i,j) = exp(-(d/sigma_i)^decay)``
77
+ 3. Symmetrize the affinity via fuzzy set union
78
+ 4. Row-normalize to Markov matrix ``M = D^{-1} A``
79
+ 5. Compute ``M^t`` via repeated matrix multiplication (t iterations)
80
+ 6. Impute: ``imputed = M^t @ counts``
81
+
82
+ Args:
83
+ config: DiffusionImputerConfig with operator parameters.
84
+ rngs: Flax NNX random number generators (not used, kept for API).
85
+ name: Optional operator name.
86
+
87
+ Example:
88
+ >>> config = DiffusionImputerConfig(n_neighbors=5, diffusion_t=3)
89
+ >>> imputer = DifferentiableDiffusionImputer(config, rngs=nnx.Rngs(0))
90
+ >>> data = {"counts": jnp.ones((100, 2000))}
91
+ >>> result, state, meta = imputer.apply(data, {}, None)
92
+ >>> result["imputed_counts"].shape
93
+ (100, 2000)
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ config: DiffusionImputerConfig,
99
+ *,
100
+ rngs: nnx.Rngs | None = None,
101
+ name: str | None = None,
102
+ ) -> None:
103
+ """Initialize the diffusion imputer.
104
+
105
+ Args:
106
+ config: Imputation configuration.
107
+ rngs: Random number generators (unused, present for API consistency).
108
+ name: Optional operator name.
109
+ """
110
+ super().__init__(config, rngs=rngs, name=name)
111
+
112
+ def _build_alpha_decay_affinity(
113
+ self,
114
+ distances: Float[Array, "n n"],
115
+ k: int,
116
+ decay: float,
117
+ ) -> Float[Array, "n n"]:
118
+ """Build alpha-decaying kernel following MAGIC.
119
+
120
+ ``K(i,j) = exp(-(d(i,j) / sigma_i)^decay)`` where sigma_i is the
121
+ distance to the k-th nearest neighbor.
122
+
123
+ Args:
124
+ distances: Pairwise distance matrix with diagonal masked to DISTANCE_MASK_SENTINEL.
125
+ k: Number of neighbors for local bandwidth estimation.
126
+ decay: Exponent for the alpha-decaying kernel.
127
+
128
+ Returns:
129
+ Affinity matrix of shape ``(n, n)`` with zero diagonal.
130
+ """
131
+ n = distances.shape[0]
132
+ k_eff = min(k, n - 2)
133
+
134
+ # Local bandwidth: k-th nearest neighbor distance
135
+ sorted_dists = soft_ops.sort(distances, axis=-1, softness=0.1)
136
+ sigma = jnp.maximum(sorted_dists[:, k_eff], 1e-8)
137
+
138
+ # Alpha-decay kernel
139
+ affinity = jnp.exp(-((distances / sigma[:, None]) ** decay))
140
+
141
+ # Zero diagonal
142
+ affinity = affinity * (1.0 - jnp.eye(n))
143
+
144
+ return affinity
145
+
146
+ def _build_symmetric_affinity(
147
+ self,
148
+ counts: Float[Array, "n_cells n_genes"],
149
+ ) -> Float[Array, "n_cells n_cells"]:
150
+ """Build the symmetric affinity matrix from counts.
151
+
152
+ Args:
153
+ counts: Gene expression matrix of shape ``(n_cells, n_genes)``.
154
+
155
+ Returns:
156
+ Symmetric affinity matrix of shape ``(n_cells, n_cells)``.
157
+ """
158
+ n_cells = counts.shape[0]
159
+
160
+ # Pairwise distances
161
+ distances = compute_pairwise_distances(counts, metric=self.config.metric)
162
+
163
+ # Mask diagonal
164
+ distances = distances + jnp.eye(n_cells) * DISTANCE_MASK_SENTINEL
165
+
166
+ # Alpha-decay kernel
167
+ affinity = self._build_alpha_decay_affinity(
168
+ distances, self.config.n_neighbors, self.config.decay
169
+ )
170
+
171
+ # Symmetrize via fuzzy set union
172
+ return symmetrize_graph(affinity)
173
+
174
+ def _diffuse(
175
+ self,
176
+ affinity_sym: Float[Array, "n_cells n_cells"],
177
+ counts: Float[Array, "n_cells n_genes"],
178
+ t: int,
179
+ ) -> tuple[Float[Array, "n_cells n_genes"], Float[Array, "n_cells n_cells"]]:
180
+ """Compute M^t via repeated matrix multiplication of the Markov matrix.
181
+
182
+ The Markov matrix is ``M = D^{-1} A`` where ``A`` is the symmetric
183
+ affinity and ``D`` is the diagonal degree matrix. We compute ``M^t``
184
+ by repeatedly multiplying ``M`` by itself ``t`` times. This avoids
185
+ eigendecomposition (whose backward pass produces NaN gradients when
186
+ eigenvalues are near-degenerate) while remaining fully differentiable.
187
+
188
+ Args:
189
+ affinity_sym: Symmetric affinity matrix.
190
+ counts: Original gene expression counts.
191
+ t: Diffusion time (exponent).
192
+
193
+ Returns:
194
+ Tuple of (imputed counts, diffusion operator M^t).
195
+ """
196
+ n_cells = affinity_sym.shape[0]
197
+
198
+ if t == 0:
199
+ identity = jnp.eye(n_cells)
200
+ return counts, identity
201
+
202
+ # Build row-stochastic Markov matrix M = D^{-1} A
203
+ degree = jnp.sum(affinity_sym, axis=1, keepdims=True)
204
+ markov = affinity_sym / jnp.maximum(degree, 1e-10)
205
+
206
+ # Compute M^t via repeated matrix multiplication
207
+ diffusion_op = markov
208
+ for _ in range(t - 1):
209
+ diffusion_op = diffusion_op @ markov
210
+
211
+ # Ensure row-stochasticity after powering (numerical correction)
212
+ row_sums = jnp.sum(diffusion_op, axis=1, keepdims=True)
213
+ diffusion_op = diffusion_op / jnp.maximum(row_sums, 1e-10)
214
+
215
+ # Impute
216
+ imputed = diffusion_op @ counts
217
+
218
+ return imputed, diffusion_op
219
+
220
+ def apply(
221
+ self,
222
+ data: PyTree,
223
+ state: PyTree,
224
+ metadata: dict[str, Any] | None,
225
+ random_params: Any = None,
226
+ stats: dict[str, Any] | None = None,
227
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
228
+ """Apply diffusion imputation to single-cell count data.
229
+
230
+ Args:
231
+ data: Dictionary containing:
232
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
233
+ state: Element state (passed through unchanged).
234
+ metadata: Element metadata (passed through unchanged).
235
+ random_params: Not used (deterministic operator).
236
+ stats: Not used.
237
+
238
+ Returns:
239
+ Tuple of (transformed_data, state, metadata):
240
+ - transformed_data contains:
241
+
242
+ - ``"counts"``: Original counts
243
+ - ``"imputed_counts"``: Diffusion-imputed counts
244
+ - ``"diffusion_operator"``: The M^t matrix
245
+ - state is passed through unchanged
246
+ - metadata is passed through unchanged
247
+ """
248
+ counts = data["counts"]
249
+
250
+ # Build symmetric affinity matrix
251
+ affinity_sym = self._build_symmetric_affinity(counts)
252
+
253
+ # Diffuse via repeated matrix multiplication of Markov matrix
254
+ imputed, diffusion_op = self._diffuse(affinity_sym, counts, self.config.diffusion_t)
255
+
256
+ transformed_data = {
257
+ **data,
258
+ "imputed_counts": imputed,
259
+ "diffusion_operator": diffusion_op,
260
+ }
261
+
262
+ return transformed_data, state, metadata
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class TransformerDenoiserConfig(MaskedGeneTransformerConfigBase):
267
+ """Configuration for transformer-based gene denoising.
268
+
269
+ The denoiser treats genes as tokens: each gene has an expression value and
270
+ a gene ID. A random fraction of genes is masked (expression zeroed) and the
271
+ transformer predicts the original expression from the unmasked context.
272
+ """
273
+
274
+
275
+ class DifferentiableTransformerDenoiser(
276
+ MaskedGeneTransformerOperatorMixin,
277
+ OperatorModule,
278
+ ):
279
+ """Transformer-based gene denoiser for single-cell expression data.
280
+
281
+ Genes are treated as tokens in a sequence. For each cell the operator:
282
+
283
+ 1. Randomly masks ``mask_ratio`` fraction of genes (sets expression to 0).
284
+ 2. Projects gene IDs into embeddings via ``TransformerSequenceEncoder``
285
+ (token_embedding mode) and adds a learned projection of the expression
286
+ value so the transformer receives both identity and magnitude.
287
+ 3. Passes the sequence through a transformer encoder to obtain
288
+ contextualised gene representations.
289
+ 4. Predicts masked gene expression from context via a linear output head.
290
+ 5. Returns imputed counts where masked positions are replaced with
291
+ predictions and unmasked positions are kept from the original input.
292
+
293
+ Each cell is processed independently via ``jax.vmap`` over the cell
294
+ dimension.
295
+
296
+ Args:
297
+ config: TransformerDenoiserConfig with operator parameters.
298
+ rngs: Flax NNX random number generators.
299
+ name: Optional operator name.
300
+
301
+ Example:
302
+ >>> config = TransformerDenoiserConfig(n_genes=2000, hidden_dim=128)
303
+ >>> denoiser = DifferentiableTransformerDenoiser(
304
+ ... config, rngs=nnx.Rngs(params=0, sample=1, dropout=2))
305
+ >>> rp = denoiser.generate_random_params(
306
+ ... jax.random.key(0), {"counts": (100, 2000)})
307
+ >>> data = {"counts": counts, "gene_ids": jnp.arange(2000)}
308
+ >>> result, state, meta = denoiser.apply(data, {}, None, random_params=rp)
309
+ >>> result["imputed_counts"].shape
310
+ (100, 2000)
311
+ """
312
+
313
+ def __init__(
314
+ self,
315
+ config: TransformerDenoiserConfig,
316
+ *,
317
+ rngs: nnx.Rngs | None = None,
318
+ name: str | None = None,
319
+ ) -> None:
320
+ """Initialize the transformer denoiser.
321
+
322
+ Args:
323
+ config: Denoiser configuration.
324
+ rngs: Random number generators for parameter initialisation.
325
+ name: Optional operator name.
326
+ """
327
+ super().__init__(config, rngs=rngs, name=name)
328
+
329
+ if rngs is None:
330
+ rngs = nnx.Rngs(params=0, sample=1, dropout=2)
331
+
332
+ # Reuse the shared masked-gene token encoder contract.
333
+ self.encoder = build_masked_gene_transformer_encoder(config, rngs=rngs)
334
+
335
+ # Learned projection from scalar expression value to hidden_dim
336
+ self.expression_projection = nnx.Linear(1, config.hidden_dim, rngs=rngs)
337
+
338
+ # Output head: hidden_dim -> 1 (predict scalar expression per gene)
339
+ self.output_head = nnx.Linear(config.hidden_dim, 1, rngs=rngs)
340
+
341
+ def _impute_single_cell(
342
+ self,
343
+ expression: Float[Array, "n_genes"],
344
+ gene_ids: Float[Array, "n_genes"],
345
+ mask: Float[Array, "n_genes"],
346
+ ) -> Float[Array, "n_genes"]:
347
+ """Impute expression for a single cell.
348
+
349
+ Args:
350
+ expression: Gene expression values for one cell.
351
+ gene_ids: Integer gene IDs.
352
+ mask: Binary mask (1 = masked / to-predict, 0 = observed).
353
+
354
+ Returns:
355
+ Imputed expression values for all genes.
356
+ """
357
+ # Zero out masked gene expression (the transformer must predict these)
358
+ masked_expression = expression * (1.0 - mask)
359
+
360
+ # Embed gene IDs via the encoder's input projection (nnx.Embed)
361
+ gene_embeddings = self.encoder.input_projection(gene_ids)
362
+
363
+ # Add expression information: project scalar expression to hidden_dim
364
+ expr_projected = self.expression_projection(
365
+ masked_expression[:, None]
366
+ ) # (n_genes, hidden_dim)
367
+ hidden = gene_embeddings + expr_projected # (n_genes, hidden_dim)
368
+
369
+ # Add batch dimension for transformer (expects [batch, seq, hidden])
370
+ hidden = hidden[None, :, :] # (1, n_genes, hidden_dim)
371
+
372
+ # Apply transformer encoder
373
+ hidden = self.encoder.transformer(hidden, mask=None, deterministic=True)
374
+
375
+ # Remove batch dimension
376
+ hidden = hidden[0] # (n_genes, hidden_dim)
377
+
378
+ # Predict expression from contextualised representations
379
+ predictions = self.output_head(hidden).squeeze(-1) # (n_genes,)
380
+
381
+ # Replace masked positions with predictions, keep originals for unmasked
382
+ imputed = jnp.where(mask > 0.5, predictions, expression)
383
+
384
+ return imputed
385
+
386
+ def apply(
387
+ self,
388
+ data: PyTree,
389
+ state: PyTree,
390
+ metadata: dict[str, Any] | None,
391
+ random_params: Any = None,
392
+ stats: dict[str, Any] | None = None,
393
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
394
+ """Apply transformer denoising to single-cell count data.
395
+
396
+ Args:
397
+ data: Dictionary containing:
398
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
399
+ - ``"gene_ids"``: Integer gene IDs ``(n_genes,)``
400
+ state: Element state (passed through unchanged).
401
+ metadata: Element metadata (passed through unchanged).
402
+ random_params: JAX random key for mask generation.
403
+ stats: Not used.
404
+
405
+ Returns:
406
+ Tuple of (transformed_data, state, metadata):
407
+ - transformed_data contains:
408
+
409
+ - All original keys from data
410
+ - ``"imputed_counts"``: Denoised expression ``(n_cells, n_genes)``
411
+ - ``"mask"``: Binary mask used ``(n_genes,)``
412
+ - state is passed through unchanged
413
+ - metadata is passed through unchanged
414
+ """
415
+ counts, gene_ids_int, mask = self.prepare_masked_gene_batch(data, random_params)
416
+
417
+ # Process each cell independently via vmap
418
+ imputed = jax.vmap(
419
+ self._impute_single_cell,
420
+ in_axes=(0, None, None),
421
+ )(counts, gene_ids_int, mask)
422
+
423
+ transformed_data = {
424
+ **data,
425
+ "imputed_counts": imputed,
426
+ "mask": mask,
427
+ }
428
+
429
+ return transformed_data, state, metadata
@@ -0,0 +1,176 @@
1
+ """Three-stage on-target knockdown quality filter.
2
+
3
+ Ports cell-load's three-stage filtering for perturbation experiments:
4
+ 1. Perturbation-level: filter perturbations by average knockdown.
5
+ 2. Cell-level: filter individual cells by residual expression.
6
+ 3. Minimum count: remove perturbations with too few remaining cells.
7
+
8
+ Controls are always preserved.
9
+
10
+ References:
11
+ - cell-load/src/cell_load/utils/data_utils.py (filter_on_target_knockdown)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+
22
+ from datarax.core.config import StructuralConfig
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def is_on_target_knockdown(
28
+ gene_expression: np.ndarray,
29
+ pert_mask: np.ndarray,
30
+ ctrl_mask: np.ndarray,
31
+ residual_expression: float = 0.30,
32
+ ) -> bool:
33
+ """Check if a perturbation shows on-target knockdown for a single gene.
34
+
35
+ Returns True if the average expression in perturbed cells is below
36
+ ``residual_expression`` times the control mean.
37
+
38
+ Args:
39
+ gene_expression: 1D array of expression values for the target gene.
40
+ pert_mask: Boolean mask for perturbed cells.
41
+ ctrl_mask: Boolean mask for control cells.
42
+ residual_expression: Maximum allowed ratio of perturbed to control mean.
43
+
44
+ Returns:
45
+ True if knockdown is on-target (expression sufficiently reduced).
46
+ """
47
+ ctrl_mean = float(gene_expression[ctrl_mask].mean())
48
+ if np.isclose(ctrl_mean, 0.0):
49
+ return False
50
+
51
+ pert_mean = float(gene_expression[pert_mask].mean())
52
+ return (pert_mean / ctrl_mean) < residual_expression
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class KnockdownFilterConfig(StructuralConfig):
57
+ """Configuration for OnTargetKnockdownFilter.
58
+
59
+ Attributes:
60
+ pert_col: Obs column name for perturbation identity.
61
+ control_pert: Label identifying control cells.
62
+ residual_expression: Stage 1 threshold (perturbation-level).
63
+ cell_residual_expression: Stage 2 threshold (per-cell).
64
+ min_cells: Stage 3 minimum cells per perturbation.
65
+ var_gene_col: Column in var for gene names. If None, uses var index.
66
+ """
67
+
68
+ pert_col: str = "perturbation"
69
+ control_pert: str = "non-targeting"
70
+ residual_expression: float = 0.30
71
+ cell_residual_expression: float = 0.50
72
+ min_cells: int = 30
73
+ var_gene_col: str | None = None
74
+
75
+
76
+ class OnTargetKnockdownFilter:
77
+ """Three-stage quality control filter for perturbation experiments.
78
+
79
+ **Stage 1** (perturbation-level): Filters perturbations where average
80
+ target gene expression in perturbed cells is >= ``residual_expression``
81
+ times the control mean.
82
+
83
+ **Stage 2** (cell-level): Filters individual cells where target gene
84
+ expression remains too high relative to the control mean.
85
+
86
+ **Stage 3** (count threshold): Removes perturbations with fewer than
87
+ ``min_cells`` remaining after stages 1 and 2.
88
+
89
+ Controls are always preserved in all stages.
90
+
91
+ Args:
92
+ config: Filter configuration.
93
+ """
94
+
95
+ def __init__(self, config: KnockdownFilterConfig) -> None:
96
+ self._config = config
97
+
98
+ def process(self, source: Any) -> np.ndarray:
99
+ """Apply three-stage filtering and return a boolean cell mask.
100
+
101
+ Args:
102
+ source: A PerturbationAnnDataSource with ``load()``,
103
+ ``get_control_mask()``, and perturbation metadata.
104
+
105
+ Returns:
106
+ Boolean array of shape ``(n_cells,)`` — True for cells passing
107
+ all three filter stages.
108
+ """
109
+ data = source.load()
110
+ counts = np.asarray(data["counts"])
111
+ obs = data["obs"]
112
+ var = data["var"]
113
+
114
+ pert_labels = np.asarray(obs[self._config.pert_col])
115
+ ctrl_mask = source.get_control_mask()
116
+ n_cells = len(pert_labels)
117
+
118
+ # Build gene name -> index mapping
119
+ if self._config.var_gene_col is not None and self._config.var_gene_col in var:
120
+ gene_names = np.asarray(var[self._config.var_gene_col])
121
+ else:
122
+ gene_names = np.asarray(list(var.keys()))
123
+
124
+ gene_to_idx: dict[str, int] = {}
125
+ for i, name in enumerate(gene_names):
126
+ name_str = str(name)
127
+ if name_str not in gene_to_idx:
128
+ gene_to_idx[name_str] = i
129
+
130
+ unique_perts = set(pert_labels) - {self._config.control_pert}
131
+
132
+ # --- Stage 1: Perturbation-level filter ---
133
+ perts_passing_stage1: set[str] = set()
134
+ for pert in unique_perts:
135
+ if pert not in gene_to_idx:
136
+ continue
137
+ gene_idx = gene_to_idx[pert]
138
+ gene_expr = counts[:, gene_idx]
139
+ pert_mask = pert_labels == pert
140
+
141
+ if is_on_target_knockdown(
142
+ gene_expr, pert_mask, ctrl_mask, self._config.residual_expression
143
+ ):
144
+ perts_passing_stage1.add(pert)
145
+
146
+ # --- Stage 2: Cell-level filter ---
147
+ keep_mask = np.zeros(n_cells, dtype=bool)
148
+ keep_mask[ctrl_mask] = True # Always keep controls
149
+
150
+ ctrl_mean_cache: dict[str, float] = {}
151
+
152
+ for pert in perts_passing_stage1:
153
+ if pert not in gene_to_idx:
154
+ continue
155
+ gene_idx = gene_to_idx[pert]
156
+
157
+ if pert not in ctrl_mean_cache:
158
+ ctrl_mean_cache[pert] = float(counts[ctrl_mask, gene_idx].mean())
159
+ ctrl_mean = ctrl_mean_cache[pert]
160
+
161
+ if np.isclose(ctrl_mean, 0.0):
162
+ continue
163
+
164
+ pert_cell_mask = pert_labels == pert
165
+ ratios = counts[pert_cell_mask, gene_idx] / ctrl_mean
166
+ passing = ratios < self._config.cell_residual_expression
167
+ pert_indices = np.where(pert_cell_mask)[0]
168
+ keep_mask[pert_indices[passing]] = True
169
+
170
+ # --- Stage 3: Minimum cell filter ---
171
+ for pert in unique_perts:
172
+ pert_cell_mask = (pert_labels == pert) & keep_mask
173
+ if pert_cell_mask.sum() < self._config.min_cells:
174
+ keep_mask[pert_cell_mask] = False
175
+
176
+ return keep_mask