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,426 @@
1
+ """DeepVariant-style pileup image generation for variant calling."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from flax import nnx
9
+ from jaxtyping import Array, Float, Int, PyTree
10
+
11
+ from diffbio.configs import TemperatureConfig
12
+ from diffbio.core.base_operators import TemperatureOperator
13
+
14
+ _DEFAULT_DEEPVARIANT_CHANNELS = (
15
+ "base",
16
+ "base_quality",
17
+ "mapping_quality",
18
+ "strand",
19
+ "supports_variant",
20
+ "differs_from_ref",
21
+ )
22
+ _DEEPVARIANT_CHANNEL_WIDTHS = {
23
+ "base": 4,
24
+ "base_quality": 1,
25
+ "mapping_quality": 1,
26
+ "strand": 1,
27
+ "supports_variant": 1,
28
+ "differs_from_ref": 1,
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DeepVariantPileupConfig(TemperatureConfig):
34
+ """Configuration for DeepVariant-style pileup generation.
35
+
36
+ Inherits from TemperatureConfig to get temperature parameter for
37
+ soft/differentiable operations.
38
+
39
+ Attributes:
40
+ window_size: Width of pileup image in base pairs (default: 221)
41
+ max_reads: Height of pileup image / max reads to include (default: 100)
42
+ channels: Ordered channel set to emit in the pileup image.
43
+ quality_max: Maximum quality score for normalization (default: 40)
44
+ mapq_max: Maximum mapping quality for normalization (default: 60)
45
+ """
46
+
47
+ window_size: int = 221
48
+ max_reads: int = 100
49
+ channels: tuple[str, ...] = _DEFAULT_DEEPVARIANT_CHANNELS
50
+ quality_max: float = 40.0
51
+ mapq_max: float = 60.0
52
+
53
+ def __post_init__(self) -> None:
54
+ """Validate the supported DeepVariant pileup configuration surface."""
55
+ super().__post_init__()
56
+
57
+ if self.window_size <= 0:
58
+ raise ValueError(f"window_size must be positive, got {self.window_size}")
59
+ if self.max_reads <= 0:
60
+ raise ValueError(f"max_reads must be positive, got {self.max_reads}")
61
+ if self.quality_max <= 0.0:
62
+ raise ValueError(f"quality_max must be positive, got {self.quality_max}")
63
+ if self.mapq_max <= 0.0:
64
+ raise ValueError(f"mapq_max must be positive, got {self.mapq_max}")
65
+ if not self.channels:
66
+ raise ValueError("channels must contain at least one DeepVariant channel")
67
+
68
+ invalid_channels = tuple(
69
+ channel for channel in self.channels if channel not in _DEEPVARIANT_CHANNEL_WIDTHS
70
+ )
71
+ if invalid_channels:
72
+ invalid = ", ".join(invalid_channels)
73
+ raise ValueError(f"channels contains unsupported values: {invalid}")
74
+
75
+ if len(set(self.channels)) != len(self.channels):
76
+ raise ValueError("channels must not contain duplicates")
77
+
78
+
79
+ class DeepVariantStylePileup(TemperatureOperator):
80
+ """DeepVariant-style multi-channel pileup image generator.
81
+
82
+ Generates pileup images compatible with DeepVariant's CNN architecture
83
+ while maintaining full differentiability for end-to-end training.
84
+
85
+ The pileup image has shape (max_reads, window_size, num_channels) where
86
+ each read occupies a row and each column represents a base position.
87
+
88
+ Inherits from TemperatureOperator to get:
89
+
90
+ - _temperature property for temperature-controlled smoothing
91
+ - soft_max() for logsumexp-based smooth maximum
92
+ - soft_argmax() for soft position selection
93
+
94
+ Example:
95
+ ```python
96
+ config = DeepVariantPileupConfig(window_size=101, max_reads=50)
97
+ pileup = DeepVariantStylePileup(config)
98
+ data = {
99
+ "reads": reads, # (num_reads, read_length, 4)
100
+ "reference": reference, # (window_size, 4)
101
+ "base_qualities": qualities, # (num_reads, read_length)
102
+ "mapping_qualities": mapq, # (num_reads,)
103
+ "strands": strands, # (num_reads,)
104
+ "positions": positions, # (num_reads,)
105
+ }
106
+ result, _, _ = pileup.apply(data, {}, None)
107
+ pileup_image = result["pileup_image"] # (50, 101, num_channels)
108
+ ```
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ config: DeepVariantPileupConfig,
114
+ *,
115
+ rngs: nnx.Rngs | None = None,
116
+ name: str | None = None,
117
+ ):
118
+ """Initialize DeepVariantStylePileup.
119
+
120
+ Args:
121
+ config: Pileup configuration
122
+ rngs: Random number generators (optional)
123
+ name: Optional operator name
124
+ """
125
+ super().__init__(config, rngs=rngs, name=name)
126
+
127
+ # Calculate number of output channels
128
+ self._num_channels = sum(
129
+ _DEEPVARIANT_CHANNEL_WIDTHS[channel] for channel in config.channels
130
+ )
131
+
132
+ @property
133
+ def num_channels(self) -> int:
134
+ """Return the number of output channels."""
135
+ return self._num_channels
136
+
137
+ def _scatter_reads_to_image(
138
+ self,
139
+ values: Float[Array, "num_reads read_length ..."],
140
+ positions: Int[Array, "num_reads"],
141
+ read_length: int,
142
+ ) -> Float[Array, "max_reads window_size ..."]:
143
+ """Scatter per-read, per-position values into a 2D (or 3D) pileup image.
144
+
145
+ This is the generic helper that handles the common scan+fori_loop pattern
146
+ used by all channel computation methods. It processes reads one at a time
147
+ via lax.scan, and for each read scatters its values into the correct
148
+ window positions via lax.fori_loop.
149
+
150
+ Supports both scalar per-position values (image shape: max_reads x window_size)
151
+ and vector per-position values (image shape: max_reads x window_size x D).
152
+
153
+ Args:
154
+ values: Pre-computed values to scatter. Shape is either
155
+ (num_reads, read_length) for scalar channels or
156
+ (num_reads, read_length, D) for vector channels (e.g., one-hot bases).
157
+ positions: Starting position of each read in the window (num_reads,).
158
+ read_length: Length of each read.
159
+
160
+ Returns:
161
+ Image with scattered values. Shape is (max_reads, window_size) for
162
+ scalar values or (max_reads, window_size, D) for vector values.
163
+ """
164
+ config = self.config
165
+ is_vector = values.ndim == 3
166
+ if is_vector:
167
+ value_dim = values.shape[2]
168
+ image = jnp.zeros((config.max_reads, config.window_size, value_dim), dtype=jnp.float32)
169
+ else:
170
+ image = jnp.zeros((config.max_reads, config.window_size), dtype=jnp.float32)
171
+
172
+ def scatter_read(carry, read_data):
173
+ img, read_idx = carry
174
+ read_vals, pos = read_data
175
+
176
+ valid = read_idx < config.max_reads
177
+ read_positions = pos + jnp.arange(read_length)
178
+ in_bounds = (read_positions >= 0) & (read_positions < config.window_size)
179
+
180
+ def update_position(i, current_img):
181
+ pos_idx = jnp.clip(read_positions[i], 0, config.window_size - 1)
182
+ is_valid = valid & in_bounds[i]
183
+ if is_vector:
184
+ return jnp.where(
185
+ is_valid,
186
+ current_img.at[read_idx, pos_idx, :].set(read_vals[i]),
187
+ current_img,
188
+ )
189
+ return jnp.where(
190
+ is_valid,
191
+ current_img.at[read_idx, pos_idx].set(read_vals[i]),
192
+ current_img,
193
+ )
194
+
195
+ new_img = jax.lax.fori_loop(0, read_length, update_position, img)
196
+ return (new_img, read_idx + 1), None
197
+
198
+ (image, _), _ = jax.lax.scan(
199
+ scatter_read,
200
+ (image, 0),
201
+ (values, positions),
202
+ )
203
+
204
+ return image
205
+
206
+ def compute_pileup_image(
207
+ self,
208
+ reads: Float[Array, "num_reads read_length 4"],
209
+ reference: Float[Array, "window_size 4"],
210
+ base_qualities: Float[Array, "num_reads read_length"],
211
+ mapping_qualities: Float[Array, "num_reads"],
212
+ strands: Float[Array, "num_reads"],
213
+ positions: Int[Array, "num_reads"],
214
+ ) -> Float[Array, "max_reads window_size num_channels"]:
215
+ """Compute DeepVariant-style pileup image.
216
+
217
+ Args:
218
+ reads: One-hot encoded reads (num_reads, read_length, 4)
219
+ reference: One-hot encoded reference (window_size, 4)
220
+ base_qualities: Phred quality scores (num_reads, read_length)
221
+ mapping_qualities: Mapping quality scores (num_reads,)
222
+ strands: Strand orientation, 0=forward, 1=reverse (num_reads,)
223
+ positions: Starting position of each read in window (num_reads,)
224
+
225
+ Returns:
226
+ Pileup image of shape (max_reads, window_size, num_channels)
227
+ """
228
+ config = self.config
229
+ read_length = reads.shape[1]
230
+
231
+ # Initialize output image with zeros
232
+ pileup_image = jnp.zeros(
233
+ (config.max_reads, config.window_size, self._num_channels),
234
+ dtype=jnp.float32,
235
+ )
236
+
237
+ # Build the pileup image channel by channel
238
+ channel_idx = 0
239
+
240
+ for channel in config.channels:
241
+ if channel == "base":
242
+ base_image = self._compute_base_channels(reads, positions, read_length)
243
+ pileup_image = pileup_image.at[:, :, channel_idx : channel_idx + 4].set(base_image)
244
+ channel_idx += 4
245
+ elif channel == "base_quality":
246
+ quality_image = self._compute_quality_channel(
247
+ base_qualities, positions, read_length
248
+ )
249
+ pileup_image = pileup_image.at[:, :, channel_idx].set(quality_image)
250
+ channel_idx += 1
251
+ elif channel == "mapping_quality":
252
+ mapq_image = self._compute_mapq_channel(mapping_qualities, positions, read_length)
253
+ pileup_image = pileup_image.at[:, :, channel_idx].set(mapq_image)
254
+ channel_idx += 1
255
+ elif channel == "strand":
256
+ strand_image = self._compute_strand_channel(strands, positions, read_length)
257
+ pileup_image = pileup_image.at[:, :, channel_idx].set(strand_image)
258
+ channel_idx += 1
259
+ elif channel == "supports_variant":
260
+ variant_image = self._compute_variant_support_channel(
261
+ reads, reference, positions, read_length
262
+ )
263
+ pileup_image = pileup_image.at[:, :, channel_idx].set(variant_image)
264
+ channel_idx += 1
265
+ else:
266
+ diff_image = self._compute_diff_from_ref_channel(
267
+ reads, reference, positions, read_length
268
+ )
269
+ pileup_image = pileup_image.at[:, :, channel_idx].set(diff_image)
270
+ channel_idx += 1
271
+
272
+ return pileup_image
273
+
274
+ def _compute_base_channels(
275
+ self,
276
+ reads: Float[Array, "num_reads read_length 4"],
277
+ positions: Int[Array, "num_reads"],
278
+ read_length: int,
279
+ ) -> Float[Array, "max_reads window_size 4"]:
280
+ """Compute base identity channels (one-hot A/C/G/T).
281
+
282
+ Places each read's bases at the correct position in the image.
283
+ Values are the one-hot base vectors from the reads directly.
284
+ """
285
+ return self._scatter_reads_to_image(reads, positions, read_length)
286
+
287
+ def _compute_quality_channel(
288
+ self,
289
+ base_qualities: Float[Array, "num_reads read_length"],
290
+ positions: Int[Array, "num_reads"],
291
+ read_length: int,
292
+ ) -> Float[Array, "max_reads window_size"]:
293
+ """Compute base quality channel normalized to [0, 1]."""
294
+ normalized_qual = jnp.clip(base_qualities / self.config.quality_max, 0.0, 1.0)
295
+ return self._scatter_reads_to_image(normalized_qual, positions, read_length)
296
+
297
+ def _compute_mapq_channel(
298
+ self,
299
+ mapping_qualities: Float[Array, "num_reads"],
300
+ positions: Int[Array, "num_reads"],
301
+ read_length: int,
302
+ ) -> Float[Array, "max_reads window_size"]:
303
+ """Compute mapping quality channel normalized to [0, 1].
304
+
305
+ MAPQ is constant across a read, so we broadcast to all positions.
306
+ """
307
+ normalized_mapq = jnp.clip(mapping_qualities / self.config.mapq_max, 0.0, 1.0)
308
+ # Broadcast constant MAPQ to all positions: (num_reads,) -> (num_reads, read_length)
309
+ mapq_per_position = jnp.broadcast_to(
310
+ normalized_mapq[:, None], (normalized_mapq.shape[0], read_length)
311
+ )
312
+ return self._scatter_reads_to_image(mapq_per_position, positions, read_length)
313
+
314
+ def _compute_strand_channel(
315
+ self,
316
+ strands: Float[Array, "num_reads"],
317
+ positions: Int[Array, "num_reads"],
318
+ read_length: int,
319
+ ) -> Float[Array, "max_reads window_size"]:
320
+ """Compute strand channel (0=forward, 1=reverse).
321
+
322
+ Strand is constant across a read, so we broadcast to all positions.
323
+ """
324
+ # Broadcast constant strand to all positions: (num_reads,) -> (num_reads, read_length)
325
+ strand_per_position = jnp.broadcast_to(strands[:, None], (strands.shape[0], read_length))
326
+ return self._scatter_reads_to_image(strand_per_position, positions, read_length)
327
+
328
+ def _compute_variant_support_channel(
329
+ self,
330
+ reads: Float[Array, "num_reads read_length 4"],
331
+ reference: Float[Array, "window_size 4"],
332
+ positions: Int[Array, "num_reads"],
333
+ read_length: int,
334
+ ) -> Float[Array, "max_reads window_size"]:
335
+ """Compute variant support channel.
336
+
337
+ This is a soft indicator of whether a read base differs from reference,
338
+ which can indicate support for a variant allele.
339
+
340
+ Uses soft comparison for differentiability: mismatch = 1 - dot(read, ref).
341
+ """
342
+ # Pre-compute mismatch values for all reads and positions
343
+ # ref_positions[r, i] = positions[r] + i
344
+ ref_positions = positions[:, None] + jnp.arange(read_length)[None, :]
345
+ clipped_ref_positions = jnp.clip(ref_positions, 0, self.config.window_size - 1)
346
+
347
+ # Look up reference bases at each position: (num_reads, read_length, 4)
348
+ ref_bases = reference[clipped_ref_positions]
349
+
350
+ # Soft mismatch: 1 - dot product of one-hot vectors
351
+ match_scores = jnp.sum(reads * ref_bases, axis=-1) # (num_reads, read_length)
352
+ mismatch_values = 1.0 - match_scores
353
+
354
+ return self._scatter_reads_to_image(mismatch_values, positions, read_length)
355
+
356
+ def _compute_diff_from_ref_channel(
357
+ self,
358
+ reads: Float[Array, "num_reads read_length 4"],
359
+ reference: Float[Array, "window_size 4"],
360
+ positions: Int[Array, "num_reads"],
361
+ read_length: int,
362
+ ) -> Float[Array, "max_reads window_size"]:
363
+ """Compute 'differs from reference' channel.
364
+
365
+ Similar to variant support but provides a direct mismatch signal.
366
+ In practice, this is equivalent to variant_support for standard pileups.
367
+ """
368
+ # For standard pileups, this is the same as variant support
369
+ # DeepVariant distinguishes them for multi-allelic calling
370
+ return self._compute_variant_support_channel(reads, reference, positions, read_length)
371
+
372
+ def apply(
373
+ self,
374
+ data: PyTree,
375
+ state: PyTree,
376
+ metadata: dict[str, Any] | None,
377
+ random_params: Any = None, # noqa: ARG002
378
+ stats: dict[str, Any] | None = None, # noqa: ARG002
379
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
380
+ """Apply DeepVariant-style pileup generation.
381
+
382
+ Args:
383
+ data: Dictionary containing:
384
+ - "reads": One-hot encoded reads (num_reads, read_length, 4)
385
+ - "reference": One-hot encoded reference (window_size, 4)
386
+ - "base_qualities": Phred quality scores (num_reads, read_length)
387
+ - "mapping_qualities": Mapping quality scores (num_reads,)
388
+ - "strands": Strand orientation (num_reads,)
389
+ - "positions": Read start positions in window (num_reads,)
390
+ state: Element state (passed through unchanged)
391
+ metadata: Element metadata (passed through unchanged)
392
+ random_params: Not used (deterministic operator)
393
+ stats: Not used
394
+
395
+ Returns:
396
+ Tuple of (transformed_data, state, metadata):
397
+ - transformed_data contains input data plus pileup_image
398
+ - state is passed through unchanged
399
+ - metadata is passed through unchanged
400
+ """
401
+ del random_params, stats # Unused parameters
402
+
403
+ reads = data["reads"]
404
+ reference = data["reference"]
405
+ base_qualities = data["base_qualities"]
406
+ mapping_qualities = data["mapping_qualities"]
407
+ strands = data["strands"]
408
+ positions = data["positions"]
409
+
410
+ # Compute pileup image
411
+ pileup_image = self.compute_pileup_image(
412
+ reads=reads,
413
+ reference=reference,
414
+ base_qualities=base_qualities,
415
+ mapping_qualities=mapping_qualities,
416
+ strands=strands,
417
+ positions=positions,
418
+ )
419
+
420
+ # Build output data - preserve input keys for Datarax compatibility
421
+ transformed_data = {
422
+ **data,
423
+ "pileup_image": pileup_image,
424
+ }
425
+
426
+ return transformed_data, state, metadata
@@ -0,0 +1,240 @@
1
+ """Differentiable pileup generation for variant calling."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from flax import nnx
9
+ from jaxtyping import Array, Float, Int, PyTree
10
+
11
+ from diffbio.configs import TemperatureConfig
12
+ from diffbio.constants import EPSILON
13
+ from diffbio.core.base_operators import TemperatureOperator
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PileupConfig(TemperatureConfig):
18
+ """Configuration for differentiable pileup.
19
+
20
+ Inherits from TemperatureConfig to get temperature and learnable_temperature fields.
21
+
22
+ Attributes:
23
+ use_quality_weights: Whether to weight bases by quality scores.
24
+ reference_length: Length of reference sequence (required for batch processing).
25
+ All reads in a batch must align to the same reference length.
26
+ return_coverage: Whether to return coverage channel in output.
27
+ return_quality: Whether to return mean quality channel in output.
28
+ apply_softmax: Whether to apply softmax to final pileup (set False to preserve
29
+ raw weighted sums, which is better for variant detection).
30
+ """
31
+
32
+ use_quality_weights: bool = True
33
+ reference_length: int = 100
34
+ return_coverage: bool = False
35
+ return_quality: bool = False
36
+ apply_softmax: bool = True
37
+
38
+ def __post_init__(self) -> None:
39
+ """Validate the supported pileup configuration surface."""
40
+ super().__post_init__()
41
+
42
+ if self.reference_length <= 0:
43
+ raise ValueError(f"reference_length must be positive, got {self.reference_length}")
44
+
45
+
46
+ class DifferentiablePileup(TemperatureOperator):
47
+ """Differentiable pileup generator.
48
+
49
+ Aggregates aligned reads into a position-wise nucleotide distribution
50
+ that can be used for variant calling. Unlike traditional pileup which
51
+ simply counts bases, this implementation uses soft weighting that
52
+ allows gradients to flow through.
53
+
54
+ Inherits from TemperatureOperator to get:
55
+
56
+ - _temperature property for temperature-controlled smoothing
57
+ - soft_max() for logsumexp-based smooth maximum
58
+ - soft_argmax() for soft position selection
59
+
60
+ Args:
61
+ config: Pileup configuration.
62
+ rngs: Flax NNX random number generators.
63
+ name: Optional operator name.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ config: PileupConfig,
69
+ *,
70
+ rngs: nnx.Rngs | None = None,
71
+ name: str | None = None,
72
+ ):
73
+ """Initialize differentiable pileup.
74
+
75
+ Args:
76
+ config: Pileup configuration.
77
+ rngs: Random number generators (optional).
78
+ name: Optional operator name.
79
+ """
80
+ super().__init__(config, rngs=rngs, name=name)
81
+ # Temperature is now managed by TemperatureOperator via self._temperature
82
+
83
+ def compute_pileup(
84
+ self,
85
+ reads: Float[Array, "num_reads read_length 4"],
86
+ positions: Int[Array, "num_reads"],
87
+ quality: Float[Array, "num_reads read_length"],
88
+ reference_length: int,
89
+ ) -> dict[str, Float[Array, "..."]]:
90
+ """Generate pileup from aligned reads.
91
+
92
+ Args:
93
+ reads: One-hot encoded reads (num_reads, read_length, 4).
94
+ positions: Starting position of each read (num_reads,).
95
+ quality: Quality scores for each base (num_reads, read_length).
96
+ reference_length: Length of reference sequence.
97
+
98
+ Returns:
99
+ Dictionary containing:
100
+ - pileup: (reference_length, 4) nucleotide distributions
101
+ - coverage: (reference_length, 1) read depth at each position (if return_coverage)
102
+ - mean_quality: (reference_length, 1) mean quality at each position (if return_quality)
103
+ """
104
+ _, read_length, _ = reads.shape
105
+
106
+ # Convert quality scores to weights
107
+ if self.config.use_quality_weights:
108
+ # Phred to probability: p_error = 10^(-Q/10)
109
+ # Weight = 1 - p_error
110
+ p_error = jnp.power(10.0, -quality / 10.0)
111
+ weights = 1.0 - p_error
112
+ else:
113
+ weights = jnp.ones_like(quality)
114
+
115
+ # Create position indices for all bases in all reads
116
+ # For each read i at position p[i], base j maps to reference position p[i] + j
117
+ read_offsets = jnp.arange(read_length) # [0, 1, ..., read_length-1]
118
+ # Broadcast to get absolute positions: (num_reads, read_length)
119
+ absolute_positions = positions[:, None] + read_offsets[None, :]
120
+
121
+ # Flatten everything for scatter operation
122
+ flat_positions = absolute_positions.reshape(-1) # (num_reads * read_length,)
123
+ flat_reads = reads.reshape(-1, 4) # (num_reads * read_length, 4)
124
+ flat_weights = weights.reshape(-1, 1) # (num_reads * read_length, 1)
125
+ flat_quality = quality.reshape(-1, 1) # (num_reads * read_length, 1)
126
+
127
+ # Mask out-of-bounds positions
128
+ in_bounds = (flat_positions >= 0) & (flat_positions < reference_length)
129
+ in_bounds_mask = in_bounds[:, None].astype(jnp.float32)
130
+ flat_weights_masked = flat_weights * in_bounds_mask
131
+
132
+ # Weighted reads
133
+ weighted_reads = flat_reads * flat_weights_masked
134
+
135
+ # Use segment_sum to aggregate bases at each position
136
+ # First, clip positions to valid range (we've already masked weights for invalid)
137
+ clipped_positions = jnp.clip(flat_positions, 0, reference_length - 1)
138
+
139
+ # Aggregate nucleotide counts at each position
140
+ pileup = jax.ops.segment_sum(
141
+ weighted_reads,
142
+ clipped_positions.astype(jnp.int32),
143
+ num_segments=reference_length,
144
+ )
145
+
146
+ # Aggregate coverage at each position (sum of weights)
147
+ coverage = jax.ops.segment_sum(
148
+ flat_weights_masked,
149
+ clipped_positions.astype(jnp.int32),
150
+ num_segments=reference_length,
151
+ )
152
+
153
+ # Normalize by coverage to get nucleotide distribution
154
+ # Add small epsilon to avoid division by zero
155
+ coverage_safe = jnp.maximum(coverage, EPSILON)
156
+ pileup_normalized = pileup / coverage_safe
157
+
158
+ # Optionally apply softmax
159
+ # Use inherited _temperature property from TemperatureOperator
160
+ if self.config.apply_softmax:
161
+ pileup_normalized = jax.nn.softmax(pileup_normalized / self._temperature, axis=-1)
162
+
163
+ result = {"pileup": pileup_normalized}
164
+
165
+ # Add coverage channel if requested
166
+ if self.config.return_coverage:
167
+ result["coverage"] = coverage
168
+
169
+ # Add mean quality channel if requested
170
+ if self.config.return_quality:
171
+ # Aggregate quality * weight, then divide by weight sum
172
+ weighted_quality = flat_quality * flat_weights_masked
173
+ quality_sum = jax.ops.segment_sum(
174
+ weighted_quality,
175
+ clipped_positions.astype(jnp.int32),
176
+ num_segments=reference_length,
177
+ )
178
+ mean_quality = quality_sum / coverage_safe
179
+ result["mean_quality"] = mean_quality
180
+
181
+ return result
182
+
183
+ def apply(
184
+ self,
185
+ data: PyTree,
186
+ state: PyTree,
187
+ metadata: dict[str, Any] | None,
188
+ random_params: Any = None,
189
+ stats: dict[str, Any] | None = None,
190
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
191
+ """Apply pileup generation to read data.
192
+
193
+ This method implements the OperatorModule interface for batch processing.
194
+ It expects data containing reads and their positions, and returns pileup.
195
+
196
+ Note: reference_length is taken from config (not data) because it must be
197
+ static for JAX's segment_sum. All reads in a batch must align to the same
198
+ reference. Output preserves input keys for Datarax vmap compatibility.
199
+
200
+ Args:
201
+ data: Dictionary containing:
202
+ - "reads": One-hot encoded reads (num_reads, read_length, 4)
203
+ - "positions": Starting position of each read (num_reads,)
204
+ - "quality": Quality scores for each base (num_reads, read_length)
205
+ state: Element state (passed through unchanged)
206
+ metadata: Element metadata (passed through unchanged)
207
+ random_params: Not used (deterministic operator)
208
+ stats: Not used
209
+
210
+ Returns:
211
+ Tuple of (transformed_data, state, metadata):
212
+ - transformed_data contains input data plus pileup array
213
+ - state is passed through unchanged
214
+ - metadata is passed through unchanged
215
+ """
216
+ reads = data["reads"]
217
+ positions = data["positions"]
218
+ quality = data["quality"]
219
+
220
+ # Use reference_length from config (must be static for segment_sum)
221
+ reference_length = self.config.reference_length
222
+
223
+ # Compute pileup (returns dict with pileup and optional coverage/quality)
224
+ pileup_result = self.compute_pileup(reads, positions, quality, reference_length)
225
+
226
+ # Build output data - preserve input keys for Datarax vmap compatibility
227
+ transformed_data = {
228
+ "reads": reads,
229
+ "positions": positions,
230
+ "quality": quality,
231
+ "pileup": pileup_result["pileup"],
232
+ }
233
+
234
+ # Add coverage and mean_quality if present
235
+ if "coverage" in pileup_result:
236
+ transformed_data["coverage"] = pileup_result["coverage"]
237
+ if "mean_quality" in pileup_result:
238
+ transformed_data["mean_quality"] = pileup_result["mean_quality"]
239
+
240
+ return transformed_data, state, metadata