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,409 @@
1
+ """Task adapters mapping benchmark problems to DiffBio operator invocations.
2
+
3
+ The ``TaskAdapter`` dispatches benchmark tasks to the appropriate DiffBio
4
+ operators, executes them on the provided data, and extracts grader-ready
5
+ answers from operator outputs.
6
+
7
+ Calibrax metrics are used to compute quality scores on operator outputs
8
+ (clustering silhouette, batch correction MMD, etc.) alongside the grader
9
+ answer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any
16
+
17
+ import jax
18
+ import jax.numpy as jnp
19
+ import numpy as np
20
+ from flax import nnx
21
+
22
+ from diffbio.core import soft_ops
23
+ from diffbio.evaluation.problem import BenchmarkProblem
24
+ from diffbio.sources.anndata_interop import to_grader_answer
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TaskAdapter:
30
+ """Dispatches benchmark tasks to DiffBio operators.
31
+
32
+ Each public method handles a specific task category, instantiates the
33
+ appropriate operator with default or problem-specific config, runs it
34
+ on the data dict, and returns the answer in grader-expected format.
35
+
36
+ Args:
37
+ seed: Random seed for operator initialisation.
38
+ """
39
+
40
+ def __init__(self, *, seed: int = 42) -> None:
41
+ self._seed = seed
42
+ self._rngs = nnx.Rngs(seed)
43
+ self._dispatch: dict[str, Any] = {
44
+ "qc_filtering": self._run_qc_filtering,
45
+ "clustering": self._run_clustering,
46
+ "differential_expression": self._run_de,
47
+ "batch_correction": self._run_batch_correction,
48
+ "normalization": self._run_normalization,
49
+ "trajectory": self._run_trajectory,
50
+ "spatial_analysis": self._run_spatial_analysis,
51
+ "cell_annotation": self._run_cell_annotation,
52
+ }
53
+
54
+ def solve(
55
+ self,
56
+ problem: BenchmarkProblem,
57
+ data_dict: dict[str, Any],
58
+ ) -> Any:
59
+ """Run the appropriate operator for a benchmark problem.
60
+
61
+ Args:
62
+ problem: The benchmark problem definition.
63
+ data_dict: Operator-ready input dict (from
64
+ ``from_anndata_to_operator_input``).
65
+
66
+ Returns:
67
+ Answer in the format expected by the problem's grader.
68
+
69
+ Raises:
70
+ ValueError: If the task_type is not supported.
71
+ """
72
+ handler = self._dispatch.get(problem.task_type)
73
+ if handler is None:
74
+ raise ValueError(
75
+ f"Unsupported task_type {problem.task_type!r}. Supported: {sorted(self._dispatch)}"
76
+ )
77
+ task_config = problem.task_config
78
+ operator_output = handler(data_dict, task_config)
79
+ return to_grader_answer(operator_output, problem.task_type)
80
+
81
+ def solve_with_metrics(
82
+ self,
83
+ problem: BenchmarkProblem,
84
+ data_dict: dict[str, Any],
85
+ ) -> tuple[Any, dict[str, float]]:
86
+ """Run operator and compute calibrax quality metrics on the output.
87
+
88
+ Like ``solve``, but also returns quality metrics computed via
89
+ calibrax on the raw operator output (e.g., silhouette score for
90
+ clustering, MMD for batch correction).
91
+
92
+ Args:
93
+ problem: The benchmark problem definition.
94
+ data_dict: Operator-ready input dict.
95
+
96
+ Returns:
97
+ Tuple of (grader_answer, quality_metrics_dict).
98
+ """
99
+ handler = self._dispatch.get(problem.task_type)
100
+ if handler is None:
101
+ raise ValueError(
102
+ f"Unsupported task_type {problem.task_type!r}. Supported: {sorted(self._dispatch)}"
103
+ )
104
+ operator_output = handler(data_dict, problem.task_config)
105
+ answer = to_grader_answer(operator_output, problem.task_type)
106
+ metrics = compute_quality_metrics(operator_output, problem.task_type, data_dict)
107
+ return answer, metrics
108
+
109
+ def _run_qc_filtering(
110
+ self, data_dict: dict[str, Any], config: dict[str, Any]
111
+ ) -> dict[str, Any]:
112
+ """Run quality filtering and return output dict."""
113
+ from diffbio.operators.quality_filter import ( # noqa: PLC0415
114
+ DifferentiableQualityFilter,
115
+ QualityFilterConfig,
116
+ )
117
+
118
+ threshold = config.get("initial_threshold", 20.0)
119
+ op_config = QualityFilterConfig(initial_threshold=threshold)
120
+ operator = DifferentiableQualityFilter(op_config, rngs=nnx.Rngs(self._seed))
121
+
122
+ # QC filter expects sequence + quality_scores; for count-based tasks,
123
+ # synthesise quality scores from library size
124
+ if "sequence" not in data_dict and "counts" in data_dict:
125
+ counts = data_dict["counts"]
126
+ lib_size = jnp.sum(counts, axis=1)
127
+ # Simulate quality as log library size scaled to Phred range
128
+ quality = jnp.log1p(lib_size) * 5.0
129
+ n_cells = counts.shape[0]
130
+ # Create per-cell sequence (just ones) and quality
131
+ adapted = {
132
+ "sequence": jnp.ones((n_cells, 1)),
133
+ "quality_scores": quality[:, None],
134
+ }
135
+ else:
136
+ adapted = data_dict
137
+
138
+ result, _, _ = operator.apply(adapted, {}, None)
139
+ # Propagate retention info
140
+ if "retention_weights" not in result and "quality_scores" in adapted:
141
+ quality = adapted["quality_scores"]
142
+ result["retention_weights"] = soft_ops.greater(quality, threshold, softness=1.0)
143
+ return result
144
+
145
+ def _run_clustering(self, data_dict: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
146
+ """Run soft k-means clustering."""
147
+ from diffbio.operators.singlecell import ( # noqa: PLC0415
148
+ SoftClusteringConfig,
149
+ SoftKMeansClustering,
150
+ )
151
+
152
+ embeddings = data_dict["embeddings"]
153
+ n_clusters = config.get("n_clusters", 5)
154
+ n_features = embeddings.shape[-1]
155
+ temperature = config.get("temperature", 1.0)
156
+
157
+ op_config = SoftClusteringConfig(
158
+ n_clusters=n_clusters,
159
+ n_features=n_features,
160
+ temperature=temperature,
161
+ )
162
+ operator = SoftKMeansClustering(op_config, rngs=nnx.Rngs(self._seed))
163
+ result, _, _ = operator.apply(data_dict, {}, None)
164
+ return result
165
+
166
+ def _run_de(self, data_dict: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
167
+ """Run differential expression pipeline."""
168
+ from diffbio.pipelines.differential_expression import ( # noqa: PLC0415
169
+ DEPipelineConfig,
170
+ DifferentialExpressionPipeline,
171
+ )
172
+
173
+ n_genes = data_dict["counts"].shape[1]
174
+ n_conditions = data_dict["design"].shape[1] if "design" in data_dict else 1
175
+
176
+ op_config = DEPipelineConfig(
177
+ n_genes=n_genes,
178
+ n_conditions=n_conditions,
179
+ )
180
+ pipeline = DifferentialExpressionPipeline(op_config, rngs=nnx.Rngs(self._seed))
181
+ result, _, _ = pipeline.apply(data_dict, {}, None)
182
+
183
+ # Add gene names for grader extraction
184
+ if "gene_names" not in result:
185
+ result["gene_names"] = [f"Gene_{i}" for i in range(n_genes)]
186
+ return result
187
+
188
+ def _run_batch_correction(
189
+ self, data_dict: dict[str, Any], config: dict[str, Any]
190
+ ) -> dict[str, Any]:
191
+ """Run Harmony batch correction."""
192
+ from diffbio.operators.singlecell import ( # noqa: PLC0415
193
+ BatchCorrectionConfig,
194
+ DifferentiableHarmony,
195
+ )
196
+
197
+ n_features = data_dict["embeddings"].shape[-1]
198
+ n_clusters = config.get("n_clusters", 5)
199
+
200
+ op_config = BatchCorrectionConfig(
201
+ n_features=n_features,
202
+ n_clusters=n_clusters,
203
+ )
204
+ operator = DifferentiableHarmony(op_config, rngs=nnx.Rngs(self._seed))
205
+ result, _, _ = operator.apply(data_dict, {}, None)
206
+ return result
207
+
208
+ def _run_normalization(
209
+ self, data_dict: dict[str, Any], config: dict[str, Any]
210
+ ) -> dict[str, Any]:
211
+ """Run VAE normalization."""
212
+ from diffbio.operators.normalization import ( # noqa: PLC0415
213
+ VAENormalizer,
214
+ VAENormalizerConfig,
215
+ )
216
+
217
+ n_genes = data_dict["counts"].shape[1]
218
+ latent_dim = config.get("latent_dim", 10)
219
+
220
+ op_config = VAENormalizerConfig(
221
+ n_genes=n_genes,
222
+ latent_dim=latent_dim,
223
+ )
224
+ operator = VAENormalizer(op_config, rngs=nnx.Rngs(self._seed))
225
+ result, _, _ = operator.apply(data_dict, {}, None)
226
+ return result
227
+
228
+ def _run_trajectory(self, data_dict: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
229
+ """Run pseudotime trajectory inference."""
230
+ from diffbio.operators.singlecell import ( # noqa: PLC0415
231
+ DifferentiablePseudotime,
232
+ PseudotimeConfig,
233
+ )
234
+
235
+ n_neighbors = config.get("n_neighbors", 15)
236
+ n_diffusion_components = config.get("n_diffusion_components", 10)
237
+
238
+ op_config = PseudotimeConfig(
239
+ n_neighbors=n_neighbors,
240
+ n_diffusion_components=n_diffusion_components,
241
+ )
242
+ operator = DifferentiablePseudotime(op_config, rngs=nnx.Rngs(self._seed))
243
+ result, _, _ = operator.apply(data_dict, {}, None)
244
+ return result
245
+
246
+ def _run_spatial_analysis(
247
+ self, data_dict: dict[str, Any], config: dict[str, Any]
248
+ ) -> dict[str, Any]:
249
+ """Run spatial domain identification."""
250
+ from diffbio.operators.singlecell import ( # noqa: PLC0415
251
+ DifferentiableSpatialDomain,
252
+ SpatialDomainConfig,
253
+ )
254
+
255
+ n_genes = data_dict["counts"].shape[1]
256
+ n_domains = config.get("n_domains", 5)
257
+
258
+ op_config = SpatialDomainConfig(
259
+ n_genes=n_genes,
260
+ n_domains=n_domains,
261
+ )
262
+ operator = DifferentiableSpatialDomain(op_config, rngs=nnx.Rngs(self._seed))
263
+ result, _, _ = operator.apply(data_dict, {}, None)
264
+ return result
265
+
266
+ def _run_cell_annotation(
267
+ self, data_dict: dict[str, Any], config: dict[str, Any]
268
+ ) -> dict[str, Any]:
269
+ """Run cell annotation via clustering and majority vote."""
270
+ # Use clustering as a proxy for annotation
271
+ result = self._run_clustering(data_dict, config)
272
+
273
+ # Extract dominant cluster label as "cell_type"
274
+ if "cluster_labels" in result:
275
+ labels = np.asarray(result["cluster_labels"])
276
+ unique, counts = np.unique(labels, return_counts=True)
277
+ result["cell_type"] = str(unique[np.argmax(counts)])
278
+
279
+ return result
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Calibrax quality metrics
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def _extract_labels(output: dict[str, Any]) -> jnp.ndarray | None:
288
+ """Extract integer cluster labels from operator output."""
289
+ if "cluster_labels" in output:
290
+ return jnp.asarray(output["cluster_labels"], dtype=jnp.int32)
291
+ if "cluster_assignments" in output:
292
+ assignments = jnp.asarray(output["cluster_assignments"])
293
+ if assignments.ndim == 2:
294
+ return jnp.argmax(assignments, axis=-1)
295
+ return jnp.asarray(assignments, dtype=jnp.int32)
296
+ return None
297
+
298
+
299
+ def compute_quality_metrics(
300
+ operator_output: dict[str, Any],
301
+ task_type: str,
302
+ data_dict: dict[str, Any],
303
+ ) -> dict[str, float]:
304
+ """Compute calibrax quality metrics on operator output.
305
+
306
+ Returns task-appropriate quality metrics using calibrax's functional
307
+ metrics. These supplement the grader pass/fail with continuous quality
308
+ signals.
309
+
310
+ Args:
311
+ operator_output: Raw output dict from operator apply().
312
+ task_type: Task category determining which metrics to compute.
313
+ data_dict: Original input data dict (needed for features/labels).
314
+
315
+ Returns:
316
+ Dict mapping metric names to float values. Empty dict if no
317
+ metrics are applicable or computation fails.
318
+ """
319
+ metrics: dict[str, float] = {}
320
+
321
+ try:
322
+ if task_type in ("clustering", "cell_annotation"):
323
+ metrics.update(_clustering_metrics(operator_output, data_dict))
324
+ elif task_type == "batch_correction":
325
+ metrics.update(_batch_correction_metrics(operator_output, data_dict))
326
+ except Exception as exc:
327
+ logger.debug("Quality metrics computation failed for %s: %s", task_type, exc)
328
+
329
+ return metrics
330
+
331
+
332
+ def _clustering_metrics(
333
+ output: dict[str, Any],
334
+ data_dict: dict[str, Any],
335
+ ) -> dict[str, float]:
336
+ """Compute clustering quality metrics via calibrax.
337
+
338
+ Metrics:
339
+ - silhouette_score: Mean silhouette coefficient (higher is better).
340
+ - calinski_harabasz_score: Variance ratio criterion (higher is better).
341
+ """
342
+ from calibrax.metrics.functional.clustering import ( # noqa: PLC0415
343
+ calinski_harabasz_score,
344
+ silhouette_score,
345
+ )
346
+
347
+ labels = _extract_labels(output)
348
+ if labels is None:
349
+ return {}
350
+
351
+ features = data_dict.get("embeddings")
352
+ if features is None:
353
+ return {}
354
+
355
+ features = jnp.asarray(features, dtype=jnp.float32)
356
+ n_unique = len(jnp.unique(labels))
357
+ if n_unique < 2:
358
+ return {}
359
+
360
+ metrics: dict[str, float] = {}
361
+ metrics["silhouette_score"] = float(silhouette_score(features, labels))
362
+ metrics["calinski_harabasz_score"] = float(calinski_harabasz_score(features, labels))
363
+ return metrics
364
+
365
+
366
+ def _batch_correction_metrics(
367
+ output: dict[str, Any],
368
+ data_dict: dict[str, Any],
369
+ ) -> dict[str, float]:
370
+ """Compute batch correction quality metrics via calibrax.
371
+
372
+ Metrics:
373
+ - mmd: Maximum Mean Discrepancy between batches (lower is better).
374
+ - kl_divergence: KL divergence between batch distributions (lower is better).
375
+ """
376
+ from calibrax.metrics.functional.divergence import ( # noqa: PLC0415
377
+ kl_divergence,
378
+ mmd,
379
+ )
380
+
381
+ corrected = output.get("corrected_embeddings")
382
+ batch_labels = data_dict.get("batch_labels")
383
+ if corrected is None or batch_labels is None:
384
+ return {}
385
+
386
+ corrected = jnp.asarray(corrected, dtype=jnp.float32)
387
+ batch_labels = jnp.asarray(batch_labels, dtype=jnp.int32)
388
+ unique_batches = jnp.unique(batch_labels)
389
+
390
+ if len(unique_batches) < 2:
391
+ return {}
392
+
393
+ # Compute MMD between first two batches
394
+ mask_0 = batch_labels == unique_batches[0]
395
+ mask_1 = batch_labels == unique_batches[1]
396
+ batch_0 = corrected[mask_0]
397
+ batch_1 = corrected[mask_1]
398
+
399
+ metrics: dict[str, float] = {}
400
+ if batch_0.shape[0] > 0 and batch_1.shape[0] > 0:
401
+ metrics["mmd"] = float(mmd(batch_0, batch_1))
402
+
403
+ # KL on marginal distributions (mean over features)
404
+ eps = 1e-8
405
+ p = jax.nn.softmax(jnp.mean(batch_0, axis=0))
406
+ q = jax.nn.softmax(jnp.mean(batch_1, axis=0))
407
+ metrics["kl_divergence"] = float(kl_divergence(p + eps, q + eps))
408
+
409
+ return metrics
@@ -0,0 +1,223 @@
1
+ """Grader algorithms for benchmark evaluation.
2
+
3
+ Implements 5 pure-Python grading algorithms used to compare DiffBio operator
4
+ outputs against ground-truth answers from scBench and SpatialBench problems.
5
+
6
+ Each grader returns a ``GradeResult`` indicating pass/fail with a numeric
7
+ score and optional detail message.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True, slots=True, kw_only=True)
16
+ class GradeResult:
17
+ """Result of grading a single benchmark problem.
18
+
19
+ Attributes:
20
+ passed: Whether the prediction met the acceptance criteria.
21
+ score: Numeric score in [0, 1] indicating quality of the prediction.
22
+ detail: Human-readable explanation of the grading outcome.
23
+ """
24
+
25
+ passed: bool
26
+ score: float
27
+ detail: str
28
+
29
+
30
+ def grade_numeric_tolerance(
31
+ predicted: float,
32
+ truth: float,
33
+ *,
34
+ tolerance: float = 0.1,
35
+ mode: str = "absolute",
36
+ ) -> GradeResult:
37
+ """Grade a numeric prediction against ground truth with tolerance.
38
+
39
+ Args:
40
+ predicted: The predicted numeric value.
41
+ truth: The ground-truth numeric value.
42
+ tolerance: Acceptable deviation threshold.
43
+ mode: Tolerance mode — one of:
44
+ - ``"absolute"``: ``|predicted - truth| <= tolerance``
45
+ - ``"relative"``: ``|predicted - truth| / max(|truth|, 1e-8) <= tolerance``
46
+ - ``"min"``: ``predicted >= truth - tolerance``
47
+ - ``"max"``: ``predicted <= truth + tolerance``
48
+
49
+ Returns:
50
+ GradeResult with pass/fail and score.
51
+
52
+ Raises:
53
+ ValueError: If mode is not one of the supported modes.
54
+ """
55
+ valid_modes = {"absolute", "relative", "min", "max"}
56
+ if mode not in valid_modes:
57
+ raise ValueError(f"Unknown tolerance mode {mode!r}, expected one of {valid_modes}")
58
+
59
+ diff = abs(predicted - truth)
60
+
61
+ if mode == "absolute":
62
+ passed = diff <= tolerance
63
+ score = max(0.0, 1.0 - diff / max(tolerance, 1e-12))
64
+ elif mode == "relative":
65
+ rel_diff = diff / max(abs(truth), 1e-8)
66
+ passed = rel_diff <= tolerance
67
+ score = max(0.0, 1.0 - rel_diff / max(tolerance, 1e-12))
68
+ elif mode == "min":
69
+ passed = predicted >= truth - tolerance
70
+ overshoot = (truth - tolerance - predicted) / max(tolerance, 1e-12)
71
+ score = 1.0 if passed else max(0.0, 1.0 - overshoot)
72
+ else: # mode == "max"
73
+ passed = predicted <= truth + tolerance
74
+ overshoot = (predicted - truth - tolerance) / max(tolerance, 1e-12)
75
+ score = 1.0 if passed else max(0.0, 1.0 - overshoot)
76
+
77
+ return GradeResult(
78
+ passed=passed,
79
+ score=min(1.0, max(0.0, score)),
80
+ detail=f"pred={predicted}, truth={truth}, diff={diff:.6g}, mode={mode}, tol={tolerance}",
81
+ )
82
+
83
+
84
+ def grade_multiple_choice(predicted: str, truth: str) -> GradeResult:
85
+ """Grade a multiple-choice answer by case-insensitive string equality.
86
+
87
+ Args:
88
+ predicted: The predicted answer string.
89
+ truth: The ground-truth answer string.
90
+
91
+ Returns:
92
+ GradeResult with exact match result.
93
+ """
94
+ match = predicted.strip().upper() == truth.strip().upper()
95
+ return GradeResult(
96
+ passed=match,
97
+ score=1.0 if match else 0.0,
98
+ detail=f"pred={predicted.strip()!r}, truth={truth.strip()!r}",
99
+ )
100
+
101
+
102
+ def grade_marker_gene_precision_recall(
103
+ predicted: list[str],
104
+ truth: list[str],
105
+ *,
106
+ k: int | None = None,
107
+ ) -> GradeResult:
108
+ """Grade predicted marker genes by precision@K and recall@K.
109
+
110
+ Computes precision and recall of the top-K predicted genes against
111
+ the ground-truth gene list. The final score is the F1 harmonic mean.
112
+
113
+ Args:
114
+ predicted: Ordered list of predicted marker gene names.
115
+ truth: Ground-truth list of marker gene names.
116
+ k: Number of top predictions to evaluate. Defaults to ``len(truth)``.
117
+
118
+ Returns:
119
+ GradeResult with F1 score and precision/recall detail.
120
+ """
121
+ if not truth:
122
+ passed = len(predicted) == 0
123
+ return GradeResult(passed=passed, score=1.0 if passed else 0.0, detail="empty truth set")
124
+
125
+ if k is None:
126
+ k = len(truth)
127
+
128
+ top_k = predicted[:k]
129
+ truth_set = set(truth)
130
+ hits = sum(1 for gene in top_k if gene in truth_set)
131
+
132
+ precision = hits / len(top_k) if top_k else 0.0
133
+ recall = hits / len(truth_set)
134
+
135
+ if precision + recall > 0:
136
+ f1 = 2 * precision * recall / (precision + recall)
137
+ else:
138
+ f1 = 0.0
139
+
140
+ return GradeResult(
141
+ passed=f1 >= 0.5,
142
+ score=f1,
143
+ detail=f"P@{k}={precision:.3f}, R@{k}={recall:.3f}, F1={f1:.3f}, hits={hits}/{len(top_k)}",
144
+ )
145
+
146
+
147
+ def grade_distribution_comparison(
148
+ predicted: dict[str, float],
149
+ truth: dict[str, float],
150
+ *,
151
+ tolerance: float = 0.1,
152
+ ) -> GradeResult:
153
+ """Grade predicted distribution against truth by per-category absolute diff.
154
+
155
+ For each category in the ground truth, checks that
156
+ ``|predicted[cat] - truth[cat]| <= tolerance``. Missing categories in the
157
+ prediction are treated as zero.
158
+
159
+ Args:
160
+ predicted: Predicted distribution mapping category names to values.
161
+ truth: Ground-truth distribution mapping category names to values.
162
+ tolerance: Maximum absolute deviation per category.
163
+
164
+ Returns:
165
+ GradeResult with proportion of categories within tolerance.
166
+ """
167
+ if not truth:
168
+ return GradeResult(passed=True, score=1.0, detail="empty truth distribution")
169
+
170
+ within_tol = 0
171
+ details: list[str] = []
172
+ all_keys = set(truth) | set(predicted)
173
+
174
+ for cat in sorted(all_keys):
175
+ pred_val = predicted.get(cat, 0.0)
176
+ truth_val = truth.get(cat, 0.0)
177
+ diff = abs(pred_val - truth_val)
178
+ ok = diff <= tolerance
179
+ if ok:
180
+ within_tol += 1
181
+ else:
182
+ details.append(f"{cat}: |{pred_val:.3f}-{truth_val:.3f}|={diff:.3f}>{tolerance}")
183
+
184
+ score = within_tol / len(all_keys)
185
+ passed = score >= 0.8 # At least 80% of categories within tolerance
186
+
187
+ detail_str = f"{within_tol}/{len(all_keys)} categories within tol={tolerance}"
188
+ if details:
189
+ detail_str += f"; violations: {', '.join(details[:3])}"
190
+
191
+ return GradeResult(passed=passed, score=score, detail=detail_str)
192
+
193
+
194
+ def grade_label_set_jaccard(
195
+ predicted: set[str],
196
+ truth: set[str],
197
+ *,
198
+ threshold: float = 0.5,
199
+ ) -> GradeResult:
200
+ """Grade predicted label set by Jaccard similarity.
201
+
202
+ Computes ``|A & B| / |A | B|`` and checks if it meets the threshold.
203
+
204
+ Args:
205
+ predicted: Predicted set of labels.
206
+ truth: Ground-truth set of labels.
207
+ threshold: Minimum Jaccard index to pass.
208
+
209
+ Returns:
210
+ GradeResult with Jaccard score.
211
+ """
212
+ if not truth and not predicted:
213
+ return GradeResult(passed=True, score=1.0, detail="both sets empty")
214
+
215
+ intersection = len(predicted & truth)
216
+ union = len(predicted | truth)
217
+ jaccard = intersection / union if union > 0 else 0.0
218
+
219
+ return GradeResult(
220
+ passed=jaccard >= threshold,
221
+ score=jaccard,
222
+ detail=f"Jaccard={jaccard:.3f}, |A&B|={intersection}, |A|B|={union}, threshold={threshold}",
223
+ )