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,657 @@
1
+ """Differentiable doublet detection for single-cell data.
2
+
3
+ This module provides two complementary doublet detection strategies:
4
+
5
+ 1. **Scrublet-style (DifferentiableDoubletScorer)**: Generates synthetic
6
+ doublets by summing random cell pairs, then scores real cells via a
7
+ Bayesian k-NN likelihood ratio in PCA space.
8
+
9
+ 2. **Solo-style VAE (DifferentiableSoloDetector)**: Encodes cells through a
10
+ VAE into a latent space, generates synthetic doublets, and trains a binary
11
+ classifier on latent representations to distinguish singlets from doublets.
12
+ Based on Bernstein et al., Cell Systems 2020.
13
+
14
+ Key techniques:
15
+ - Soft k-NN neighbor counting with differentiable thresholding (Scrublet)
16
+ - VAE reparameterization + latent-space classifier (Solo)
17
+
18
+ Applications: Identifying doublet artifacts in scRNA-seq data as a
19
+ preprocessing step before downstream analysis (clustering, trajectory, DE).
20
+ """
21
+
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ import jax
27
+ import jax.numpy as jnp
28
+ from artifex.generative_models.core.losses.divergence import gaussian_kl_divergence
29
+ from datarax.core.config import OperatorConfig
30
+ from datarax.core.operator import OperatorModule
31
+ from flax import nnx
32
+ from jaxtyping import Array, Float, PyTree
33
+
34
+ from diffbio.constants import DISTANCE_MASK_SENTINEL
35
+ from diffbio.core import soft_ops
36
+ from diffbio.core.base_operators import EncoderDecoderOperator
37
+ from diffbio.core.graph_utils import compute_pairwise_distances
38
+ from diffbio.operators._count_vae import CountVAEBackboneMixin
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def generate_synthetic_doublets(
44
+ counts: Float[Array, "n_cells n_genes"],
45
+ rng: jax.Array,
46
+ sim_doublet_ratio: float,
47
+ ) -> Float[Array, "n_synthetic n_genes"]:
48
+ """Generate synthetic doublets by summing random cell pairs.
49
+
50
+ The number of synthetic doublets is ``n_cells * sim_doublet_ratio``,
51
+ matching Scrublet's default 2:1 synthetic-to-real ratio.
52
+
53
+ Args:
54
+ counts: Real count matrix of shape ``(n_cells, n_genes)``.
55
+ rng: JAX random key for pair selection.
56
+ sim_doublet_ratio: Ratio of synthetic doublets to real cells.
57
+
58
+ Returns:
59
+ Synthetic doublet profiles of shape
60
+ ``(n_cells * sim_doublet_ratio, n_genes)``.
61
+ """
62
+ n_cells = counts.shape[0]
63
+ n_synthetic = int(n_cells * sim_doublet_ratio)
64
+ k1, k2 = jax.random.split(rng)
65
+ idx_a = jax.random.randint(k1, (n_synthetic,), 0, n_cells)
66
+ idx_b = jax.random.randint(k2, (n_synthetic,), 0, n_cells)
67
+ return counts[idx_a] + counts[idx_b]
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class DoubletScorerConfig(OperatorConfig):
72
+ """Configuration for Scrublet-style doublet detection.
73
+
74
+ Attributes:
75
+ n_neighbors: Base number of nearest neighbors for scoring (adjusted
76
+ upward to account for synthetic pool size).
77
+ expected_doublet_rate: Prior expected fraction of doublets (rho in
78
+ the Bayesian likelihood ratio).
79
+ sim_doublet_ratio: Ratio of synthetic doublets to real cells. Scrublet
80
+ default is 2.0, meaning 2x as many synthetics as real cells.
81
+ n_pca_components: Number of PCA components for embedding.
82
+ n_genes: Number of genes in expression profiles.
83
+ threshold_temperature: Temperature for sigmoid doublet thresholding.
84
+ """
85
+
86
+ n_neighbors: int = 30
87
+ expected_doublet_rate: float = 0.06
88
+ sim_doublet_ratio: float = 2.0
89
+ n_pca_components: int = 30
90
+ n_genes: int = 2000
91
+ threshold_temperature: float = 10.0
92
+
93
+ def __post_init__(self) -> None:
94
+ """Set stochastic defaults and validate."""
95
+ object.__setattr__(self, "stochastic", True)
96
+ if self.stream_name is None:
97
+ object.__setattr__(self, "stream_name", "sample")
98
+ super().__post_init__()
99
+
100
+
101
+ class DifferentiableDoubletScorer(OperatorModule):
102
+ """Differentiable Scrublet-style doublet detection operator.
103
+
104
+ Detects doublets by generating synthetic doublet profiles from random
105
+ cell pairs, embedding real and synthetic cells into PCA space, and
106
+ scoring each real cell via the Bayesian k-NN likelihood ratio from
107
+ Scrublet (Wolock et al., 2019).
108
+
109
+ Algorithm:
110
+ 1. Generate ``n_cells * sim_doublet_ratio`` synthetic doublets
111
+ 2. Concatenate real and synthetic cells
112
+ 3. PCA-embed via truncated SVD
113
+ 4. Compute pairwise distances in PCA space
114
+ 5. Adjust k upward: ``k_adj = round(k * (1 + n_syn / n_cells))``
115
+ 6. Count soft synthetic neighbors in each real cell's k-NN
116
+ 7. Compute Laplace-smoothed fraction ``q`` of synthetic neighbors
117
+ 8. Bayesian likelihood ratio: ``Ld = q * rho / r / denom``
118
+ 9. Apply sigmoid threshold for predicted doublet calls
119
+
120
+ Args:
121
+ config: DoubletScorerConfig with operator parameters.
122
+ rngs: Flax NNX random number generators.
123
+ name: Optional operator name.
124
+
125
+ Example:
126
+ >>> config = DoubletScorerConfig(n_neighbors=30, n_pca_components=30,
127
+ ... n_genes=2000)
128
+ >>> scorer = DifferentiableDoubletScorer(config, rngs=nnx.Rngs(0))
129
+ >>> rng = jax.random.key(0)
130
+ >>> rp = scorer.generate_random_params(rng, {"counts": (500, 2000)})
131
+ >>> result, state, meta = scorer.apply({"counts": counts}, {}, None,
132
+ ... random_params=rp)
133
+ >>> result["doublet_scores"].shape
134
+ (500,)
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ config: DoubletScorerConfig,
140
+ *,
141
+ rngs: nnx.Rngs | None = None,
142
+ name: str | None = None,
143
+ ) -> None:
144
+ """Initialize the doublet scorer.
145
+
146
+ Args:
147
+ config: Doublet scorer configuration.
148
+ rngs: Random number generators for stochastic operations.
149
+ name: Optional operator name.
150
+ """
151
+ super().__init__(config, rngs=rngs, name=name)
152
+
153
+ def generate_random_params(
154
+ self,
155
+ rng: jax.Array,
156
+ data_shapes: PyTree,
157
+ ) -> jax.Array:
158
+ """Generate random parameters for doublet pair selection.
159
+
160
+ Produces two arrays of random cell indices used to form synthetic
161
+ doublets by pairwise summation.
162
+
163
+ Args:
164
+ rng: JAX random key.
165
+ data_shapes: PyTree with shapes, must contain ``"counts"`` key
166
+ whose first dimension is the number of cells.
167
+
168
+ Returns:
169
+ A JAX random key for reproducible pair generation inside apply.
170
+ """
171
+ return rng
172
+
173
+ def _pca_embed(
174
+ self,
175
+ data: Float[Array, "n_total n_genes"],
176
+ n_components: int,
177
+ ) -> Float[Array, "n_total n_components"]:
178
+ """Embed data into PCA space via truncated SVD.
179
+
180
+ Centers the data, computes SVD, and projects onto the top
181
+ ``n_components`` principal components.
182
+
183
+ Args:
184
+ data: Input matrix of shape ``(n_total, n_genes)``.
185
+ n_components: Number of PCA dimensions to keep.
186
+
187
+ Returns:
188
+ PCA embeddings of shape ``(n_total, n_components)``.
189
+ """
190
+ centered = data - jnp.mean(data, axis=0, keepdims=True)
191
+ # Truncated SVD: project onto top-n_components right singular vectors
192
+ _, _, vt = jnp.linalg.svd(centered, full_matrices=False)
193
+ # Clamp n_components to available dimensions
194
+ n_components_eff = min(n_components, vt.shape[0])
195
+ projection = vt[:n_components_eff] # (n_components_eff, n_genes)
196
+ return centered @ projection.T
197
+
198
+ def _compute_soft_knn_synthetic_count(
199
+ self,
200
+ distances: Float[Array, "n_real n_total"],
201
+ n_real: int,
202
+ n_total: int,
203
+ k_adj: int,
204
+ ) -> Float[Array, "n_real"]:
205
+ """Compute soft count of synthetic neighbors in adjusted k-NN.
206
+
207
+ Uses a softmax-weighted membership approach: for each real cell,
208
+ the ``k_adj`` smallest distances are selected, and the weighted
209
+ count of synthetic neighbors is returned for the Bayesian
210
+ likelihood-ratio formula.
211
+
212
+ Args:
213
+ distances: Distance matrix from real cells to all cells,
214
+ shape ``(n_real, n_total)``.
215
+ n_real: Number of real cells.
216
+ n_total: Total number of cells (real + synthetic).
217
+ k_adj: Adjusted number of nearest neighbors (accounts for
218
+ synthetic pool size).
219
+
220
+ Returns:
221
+ Soft synthetic neighbor count per real cell, shape ``(n_real,)``.
222
+ """
223
+ k_eff = min(k_adj, n_total - 1)
224
+
225
+ # Create label vector: 0 for real, 1 for synthetic
226
+ is_synthetic = jnp.concatenate(
227
+ [
228
+ jnp.zeros(n_real),
229
+ jnp.ones(n_total - n_real),
230
+ ]
231
+ )
232
+
233
+ # Mask self-distances for real cells (first n_real columns correspond to real)
234
+ self_mask = jnp.eye(n_real, n_total) * DISTANCE_MASK_SENTINEL
235
+ masked_distances = distances + self_mask
236
+
237
+ # Soft k-NN: convert distances to weights via negative exponential
238
+ # Use temperature scaling for smoother gradients
239
+ sigma = jnp.sort(masked_distances, axis=-1)[:, k_eff - 1 : k_eff]
240
+ sigma = jnp.maximum(sigma, 1e-8)
241
+ weights = jnp.exp(-masked_distances / sigma)
242
+
243
+ # Zero out self-connections
244
+ weights = weights * (1.0 - jnp.eye(n_real, n_total))
245
+
246
+ # Get top-k mask via soft approximation: use sorted threshold
247
+ sorted_dists = jnp.sort(masked_distances, axis=-1)
248
+ kth_dist = sorted_dists[:, k_eff - 1 : k_eff] # (n_real, 1)
249
+ # Soft indicator for being within k-NN (sigmoid approximation)
250
+ temperature = 10.0
251
+ knn_mask = soft_ops.greater(kth_dist, masked_distances, softness=1.0 / temperature)
252
+
253
+ # Weighted synthetic count (not fraction -- the Bayesian formula needs count)
254
+ masked_weights = weights * knn_mask
255
+ synthetic_weight = masked_weights @ is_synthetic
256
+
257
+ return synthetic_weight
258
+
259
+ def apply(
260
+ self,
261
+ data: PyTree,
262
+ state: PyTree,
263
+ metadata: dict[str, Any] | None,
264
+ random_params: Any = None,
265
+ stats: dict[str, Any] | None = None,
266
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
267
+ """Apply doublet detection to single-cell count data.
268
+
269
+ Implements Scrublet's Bayesian k-NN likelihood-ratio scoring:
270
+
271
+ 1. Generate ``n_cells * sim_doublet_ratio`` synthetic doublets
272
+ 2. Adjust k upward: ``k_adj = round(k * (1 + n_syn / n_cells))``
273
+ 3. For each real cell, count synthetic neighbors in its k-NN
274
+ 4. Compute Laplace-smoothed fraction ``q = (syn_count + 1) / (k_adj + 2)``
275
+ 5. Bayesian likelihood ratio:
276
+ ``Ld = q * rho / r / (1 - rho - q*(1 - rho - rho/r))``
277
+
278
+ Args:
279
+ data: Dictionary containing:
280
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
281
+ state: Element state (passed through unchanged).
282
+ metadata: Element metadata (passed through unchanged).
283
+ random_params: JAX random key for synthetic doublet generation.
284
+ stats: Not used.
285
+
286
+ Returns:
287
+ Tuple of (transformed_data, state, metadata):
288
+ - transformed_data contains:
289
+
290
+ - ``"counts"``: Original counts
291
+ - ``"doublet_scores"``: Bayesian likelihood ratio per cell
292
+ - ``"predicted_doublets"``: Soft doublet predictions in [0, 1]
293
+ - state is passed through unchanged
294
+ - metadata is passed through unchanged
295
+ """
296
+ counts = data["counts"]
297
+ n_cells = counts.shape[0]
298
+ config = self.config
299
+
300
+ # Use provided random key or fallback
301
+ rng = random_params if random_params is not None else jax.random.key(0)
302
+
303
+ # Step 1: Generate synthetic doublets (n_cells * sim_doublet_ratio)
304
+ synthetic = generate_synthetic_doublets(counts, rng, config.sim_doublet_ratio)
305
+ n_synthetic = synthetic.shape[0]
306
+
307
+ # Step 2: Combine real + synthetic
308
+ combined = jnp.concatenate([counts, synthetic], axis=0)
309
+ n_total = combined.shape[0]
310
+
311
+ # Step 3: PCA embed
312
+ pca = self._pca_embed(combined, config.n_pca_components)
313
+
314
+ # Step 4: Pairwise distances in PCA space, then extract real-to-all block
315
+ distances = compute_pairwise_distances(pca)
316
+ real_to_all = distances[:n_cells]
317
+
318
+ # Step 5: Adjust k for the enlarged pool (Scrublet convention)
319
+ k_adj = round(config.n_neighbors * (1 + n_synthetic / n_cells))
320
+
321
+ # Step 6: Soft k-NN synthetic neighbor count
322
+ syn_neighbor_count = self._compute_soft_knn_synthetic_count(
323
+ real_to_all, n_cells, n_total, k_adj
324
+ )
325
+
326
+ # Step 7: Bayesian likelihood-ratio scoring (Scrublet formula)
327
+ # Clip soft synthetic count so it cannot exceed k_adj (soft weights
328
+ # may overshoot the hard budget, making the denominator negative).
329
+ syn_neighbor_count = jnp.clip(syn_neighbor_count, 0.0, float(k_adj))
330
+
331
+ # q = Laplace-smoothed fraction of synthetic neighbors
332
+ q = (syn_neighbor_count + 1) / (k_adj + 2)
333
+ rho = config.expected_doublet_rate
334
+ r = n_synthetic / n_cells # synthetic-to-real ratio
335
+
336
+ # Denominator with numerical guard to avoid division by zero
337
+ denominator = jnp.maximum(1 - rho - q * (1 - rho - rho / r), 1e-8)
338
+ doublet_scores = q * rho / r / denominator
339
+
340
+ # Step 8: Soft threshold for predicted doublets (sigmoid on score)
341
+ predicted_doublets = soft_ops.greater(
342
+ doublet_scores, 0.5, softness=1.0 / config.threshold_temperature
343
+ )
344
+
345
+ transformed_data = {
346
+ **data,
347
+ "doublet_scores": doublet_scores,
348
+ "predicted_doublets": predicted_doublets,
349
+ }
350
+
351
+ return transformed_data, state, metadata
352
+
353
+
354
+ # =============================================================================
355
+ # Solo-style VAE Doublet Detector
356
+ # =============================================================================
357
+
358
+
359
+ @dataclass(frozen=True)
360
+ class SoloDetectorConfig(OperatorConfig):
361
+ """Configuration for Solo-style VAE doublet detection.
362
+
363
+ Solo (Bernstein et al., Cell Systems 2020) trains a VAE on real cells,
364
+ generates synthetic doublets, encodes both into latent space, and trains
365
+ a binary classifier to distinguish singlets from doublets.
366
+
367
+ Attributes:
368
+ n_genes: Number of genes in expression profiles.
369
+ latent_dim: Dimension of the VAE latent space.
370
+ hidden_dims: Hidden layer dimensions for encoder/decoder.
371
+ classifier_hidden_dim: Hidden dimension for the latent-space classifier.
372
+ sim_doublet_ratio: Ratio of synthetic doublets to real cells.
373
+ """
374
+
375
+ n_genes: int = 2000
376
+ latent_dim: int = 10
377
+ hidden_dims: list[int] = field(default_factory=lambda: [128, 64])
378
+ classifier_hidden_dim: int = 64
379
+ sim_doublet_ratio: float = 2.0
380
+
381
+ def __post_init__(self) -> None:
382
+ """Set stochastic defaults and validate."""
383
+ object.__setattr__(self, "stochastic", True)
384
+ if self.stream_name is None:
385
+ object.__setattr__(self, "stream_name", "sample")
386
+ super().__post_init__()
387
+
388
+
389
+ class DifferentiableSoloDetector(CountVAEBackboneMixin, EncoderDecoderOperator):
390
+ """Solo-style VAE doublet detector.
391
+
392
+ Detects doublets by encoding cells through a VAE, generating synthetic
393
+ doublets in count space, then classifying real vs synthetic cells in the
394
+ VAE latent space.
395
+
396
+ Algorithm:
397
+ 1. Generate synthetic doublets by summing random cell pairs
398
+ 2. Concatenate real and synthetic counts
399
+ 3. Encode all cells through the VAE encoder to obtain (mean, logvar)
400
+ 4. Sample latent z via the reparameterization trick
401
+ 5. Run a binary classifier on real-cell latents
402
+ 6. Return doublet probabilities, labels, and latent representations
403
+
404
+ Architecture:
405
+ - Encoder: counts -> log1p -> hidden layers (ReLU) -> (mean, logvar)
406
+ - Decoder: z -> hidden layers (ReLU) -> log_rate
407
+ - Classifier: z -> Linear -> ReLU -> Linear -> sigmoid
408
+
409
+ Args:
410
+ config: SoloDetectorConfig with model parameters.
411
+ rngs: Flax NNX random number generators.
412
+ name: Optional operator name.
413
+
414
+ Example:
415
+ >>> config = SoloDetectorConfig(n_genes=2000, latent_dim=10)
416
+ >>> detector = DifferentiableSoloDetector(config, rngs=nnx.Rngs(42))
417
+ >>> rng = jax.random.key(0)
418
+ >>> rp = detector.generate_random_params(rng, {"counts": (500, 2000)})
419
+ >>> result, _, _ = detector.apply({"counts": counts}, {}, None, random_params=rp)
420
+ >>> result["doublet_probabilities"].shape
421
+ (500,)
422
+ """
423
+
424
+ def __init__(
425
+ self,
426
+ config: SoloDetectorConfig,
427
+ *,
428
+ rngs: nnx.Rngs | None = None,
429
+ name: str | None = None,
430
+ ) -> None:
431
+ """Initialize the Solo VAE doublet detector.
432
+
433
+ Args:
434
+ config: Solo detector configuration.
435
+ rngs: Random number generators for initialization and sampling.
436
+ name: Optional operator name.
437
+ """
438
+ super().__init__(config, rngs=rngs, name=name)
439
+
440
+ safe_rngs = self._init_count_vae_operator(config=config, rngs=rngs)
441
+
442
+ # --- Classifier (operates on latent z) ---
443
+ self.classifier_hidden = nnx.Linear(
444
+ in_features=config.latent_dim,
445
+ out_features=config.classifier_hidden_dim,
446
+ rngs=safe_rngs,
447
+ )
448
+ self.classifier_output = nnx.Linear(
449
+ in_features=config.classifier_hidden_dim,
450
+ out_features=1,
451
+ rngs=safe_rngs,
452
+ )
453
+
454
+ def generate_random_params(
455
+ self,
456
+ rng: jax.Array,
457
+ data_shapes: PyTree,
458
+ ) -> jax.Array:
459
+ """Generate random parameters for synthetic doublet pair selection.
460
+
461
+ Args:
462
+ rng: JAX random key.
463
+ data_shapes: PyTree with shapes (must contain ``"counts"`` key).
464
+
465
+ Returns:
466
+ A JAX random key for reproducible pair generation inside apply.
467
+ """
468
+ return rng
469
+
470
+ def decode(
471
+ self,
472
+ z: Float[Array, "batch latent_dim"],
473
+ ) -> Float[Array, "batch n_genes"]:
474
+ """Decode latent representation to gene expression log-rates.
475
+
476
+ Args:
477
+ z: Latent representation, shape ``(batch, latent_dim)``.
478
+
479
+ Returns:
480
+ Log rates for each gene, shape ``(batch, n_genes)``.
481
+ """
482
+ return self.decode_rates(z)
483
+
484
+ def classify(
485
+ self,
486
+ z: Float[Array, "batch latent_dim"],
487
+ ) -> Float[Array, "batch"]:
488
+ """Classify latent representations as singlet vs doublet.
489
+
490
+ Args:
491
+ z: Latent representations, shape ``(batch, latent_dim)``.
492
+
493
+ Returns:
494
+ Doublet probabilities in [0, 1], shape ``(batch,)``.
495
+ """
496
+ h = nnx.relu(self.classifier_hidden(z))
497
+ logits = self.classifier_output(h)
498
+ return jax.nn.sigmoid(logits).squeeze(-1)
499
+
500
+ def compute_elbo_loss(
501
+ self,
502
+ counts: Float[Array, "batch n_genes"],
503
+ ) -> Float[Array, ""]:
504
+ """Compute negative ELBO loss for the VAE.
505
+
506
+ Uses gaussian_kl_divergence from artifex for the KL term and
507
+ Poisson NLL for reconstruction.
508
+
509
+ Args:
510
+ counts: Gene expression counts, shape ``(batch, n_genes)``.
511
+
512
+ Returns:
513
+ Negative ELBO (reconstruction loss + KL divergence).
514
+ """
515
+ mean, logvar = self.encode(counts)
516
+ z = self.reparameterize(mean, logvar)
517
+ log_rate = self.decode(z)
518
+
519
+ # Poisson NLL reconstruction loss
520
+ rate = jnp.exp(log_rate)
521
+ recon_loss = jnp.sum(rate - counts * log_rate)
522
+
523
+ # KL divergence via artifex
524
+ kl = gaussian_kl_divergence(mean, logvar, reduction="sum")
525
+
526
+ return recon_loss + kl
527
+
528
+ def compute_solo_loss(
529
+ self,
530
+ counts: Float[Array, "batch n_genes"],
531
+ random_params: jax.Array,
532
+ classifier_weight: float = 1.0,
533
+ ) -> dict[str, Float[Array, ""]]:
534
+ """Full Solo training loss: VAE ELBO + classifier binary cross-entropy.
535
+
536
+ Generates synthetic doublets, encodes all cells (real + synthetic)
537
+ through the VAE, computes the ELBO on the combined set, and adds
538
+ a binary cross-entropy term from the classifier distinguishing
539
+ singlets from doublets in latent space.
540
+
541
+ Args:
542
+ counts: Real gene expression counts, shape ``(n_real, n_genes)``.
543
+ random_params: JAX random key for synthetic doublet generation.
544
+ classifier_weight: Weight for the classifier BCE term
545
+ (default 1.0).
546
+
547
+ Returns:
548
+ Dictionary with ``"total_loss"``, ``"elbo"``, and
549
+ ``"classifier_loss"`` scalar entries.
550
+ """
551
+ n_real = counts.shape[0]
552
+
553
+ # 1. Generate synthetic doublets
554
+ synthetic = generate_synthetic_doublets(
555
+ counts, random_params, self.config.sim_doublet_ratio
556
+ )
557
+ n_synthetic = synthetic.shape[0]
558
+
559
+ # 2. Combine real + synthetic
560
+ combined = jnp.concatenate([counts, synthetic], axis=0)
561
+
562
+ # 3. Encode all to latent space
563
+ mean, logvar = self.encode(combined)
564
+ z = self.reparameterize(mean, logvar)
565
+
566
+ # 4. VAE ELBO on all cells
567
+ log_rate = self.decode(z)
568
+ rate = jnp.exp(log_rate)
569
+ recon_loss = jnp.sum(rate - combined * log_rate)
570
+ kl = gaussian_kl_divergence(mean, logvar, reduction="sum")
571
+ elbo = recon_loss + kl
572
+
573
+ # 5. Classifier BCE on all cells
574
+ # Labels: 0 for real (first n_real), 1 for synthetic
575
+ labels = jnp.concatenate([jnp.zeros(n_real), jnp.ones(n_synthetic)])
576
+ probs = self.classify(z) # returns sigmoid probabilities
577
+ # classify returns sigmoid(logits), so we use log-sigmoid formulation
578
+ bce = -jnp.mean(
579
+ labels * jnp.log(probs + 1e-8) + (1.0 - labels) * jnp.log(1.0 - probs + 1e-8)
580
+ )
581
+
582
+ # 6. Total = ELBO + classifier_weight * BCE
583
+ total = elbo + classifier_weight * bce
584
+
585
+ return {"total_loss": total, "elbo": elbo, "classifier_loss": bce}
586
+
587
+ def apply(
588
+ self,
589
+ data: PyTree,
590
+ state: PyTree,
591
+ metadata: dict[str, Any] | None,
592
+ random_params: Any = None,
593
+ stats: dict[str, Any] | None = None,
594
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
595
+ """Apply Solo-style VAE doublet detection.
596
+
597
+ Steps:
598
+ 1. Generate synthetic doublets from random cell pairs
599
+ 2. Concatenate real and synthetic counts
600
+ 3. Encode all cells to latent space (mean, logvar)
601
+ 4. Sample z via reparameterization trick
602
+ 5. Run classifier on real-cell latents
603
+ 6. Return probabilities, labels, and latent for real cells only
604
+
605
+ Args:
606
+ data: Dictionary containing:
607
+ - ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
608
+ state: Element state (passed through unchanged).
609
+ metadata: Element metadata (passed through unchanged).
610
+ random_params: JAX random key for synthetic doublet generation.
611
+ stats: Not used.
612
+
613
+ Returns:
614
+ Tuple of (transformed_data, state, metadata):
615
+ - transformed_data contains:
616
+
617
+ - ``"counts"``: Original counts
618
+ - ``"doublet_probabilities"``: Per-cell doublet probability
619
+ - ``"doublet_labels"``: Soft binary labels (sigmoid-thresholded)
620
+ - ``"latent"``: Latent representations for real cells
621
+ - state is passed through unchanged
622
+ - metadata is passed through unchanged
623
+ """
624
+ counts = data["counts"]
625
+ n_cells = counts.shape[0]
626
+ config = self.config
627
+
628
+ # Use provided random key or fallback
629
+ rng = random_params if random_params is not None else jax.random.key(0)
630
+
631
+ # Step 1: Generate synthetic doublets
632
+ synthetic = generate_synthetic_doublets(counts, rng, config.sim_doublet_ratio)
633
+
634
+ # Step 2: Combine real + synthetic counts
635
+ combined = jnp.concatenate([counts, synthetic], axis=0)
636
+
637
+ # Step 3: Encode all to latent space
638
+ mean, logvar = self.encode(combined)
639
+
640
+ # Step 4: Sample z via reparameterization trick
641
+ z = self.reparameterize(mean, logvar)
642
+
643
+ # Step 5: Extract real-cell latents and classify
644
+ z_real = z[:n_cells]
645
+ doublet_probabilities = self.classify(z_real)
646
+
647
+ # Step 6: Soft binary labels via thresholding at 0.5
648
+ doublet_labels = jnp.round(doublet_probabilities)
649
+
650
+ transformed_data = {
651
+ **data,
652
+ "doublet_probabilities": doublet_probabilities,
653
+ "doublet_labels": doublet_labels,
654
+ "latent": z_real,
655
+ }
656
+
657
+ return transformed_data, state, metadata