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,330 @@
1
+ """Molecular splitters for drug discovery applications.
2
+
3
+ This module provides molecular-aware splitting utilities:
4
+ - ScaffoldSplitter: Split by Bemis-Murcko scaffold for drug discovery
5
+ - TanimotoClusterSplitter: Split by fingerprint similarity clustering
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import Any, Sequence
11
+
12
+ import jax.numpy as jnp
13
+ from flax import nnx
14
+
15
+ from datarax.core.data_source import DataSourceModule
16
+
17
+ from diffbio.splitters.base import SplitResult, SplitterConfig, SplitterModule
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ScaffoldSplitterConfig(SplitterConfig):
24
+ """Configuration for scaffold splitter.
25
+
26
+ Attributes:
27
+ smiles_key: Key in data element containing SMILES string (default: "smiles")
28
+ """
29
+
30
+ smiles_key: str = "smiles"
31
+
32
+
33
+ class ScaffoldSplitter(SplitterModule):
34
+ """Split molecules by Bemis-Murcko scaffold.
35
+
36
+ Inherits from SplitterModule (StructuralModule) because:
37
+
38
+ - Non-parametric: scaffold extraction is deterministic
39
+ - Frozen config: splitting strategy doesn't change
40
+ - Domain-specific: requires RDKit and molecular knowledge
41
+
42
+ Ensures train/test sets have different molecular scaffolds,
43
+ preventing data leakage from structurally similar molecules.
44
+ This is the industry standard for drug discovery benchmarks.
45
+
46
+ Requires RDKit installation.
47
+
48
+ Example:
49
+ ```python
50
+ config = ScaffoldSplitterConfig(smiles_key="mol_smiles")
51
+ splitter = ScaffoldSplitter(config)
52
+ result = splitter.split(molecule_source)
53
+ ```
54
+
55
+ References:
56
+ Bemis, Guy W., and Mark A. Murcko. "The properties of known drugs.
57
+ 1. Molecular frameworks." Journal of medicinal chemistry 39.15 (1996): 2887-2893.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ config: ScaffoldSplitterConfig,
63
+ *,
64
+ rngs: nnx.Rngs | None = None,
65
+ name: str | None = None,
66
+ ):
67
+ """Initialize ScaffoldSplitter.
68
+
69
+ Args:
70
+ config: Scaffold splitter configuration
71
+ rngs: Random number generators (unused for scaffold splitting)
72
+ name: Optional module name
73
+
74
+ Raises:
75
+ ImportError: If RDKit is not installed
76
+ """
77
+ super().__init__(config, rngs=rngs, name=name)
78
+ try:
79
+ from rdkit import Chem
80
+ from rdkit.Chem.Scaffolds import MurckoScaffold
81
+
82
+ self._Chem = Chem
83
+ self._MurckoScaffold = MurckoScaffold
84
+ except ImportError as e:
85
+ raise ImportError("ScaffoldSplitter requires RDKit: pip install rdkit") from e
86
+
87
+ def _generate_scaffolds(self, smiles_list: Sequence[str]) -> dict[str, list[int]]:
88
+ """Generate Bemis-Murcko scaffolds for molecules.
89
+
90
+ Args:
91
+ smiles_list: List of SMILES strings
92
+
93
+ Returns:
94
+ Dictionary mapping scaffold SMILES to list of molecule indices
95
+ """
96
+ scaffolds: dict[str, list[int]] = {}
97
+ for idx, smiles in enumerate(smiles_list):
98
+ mol = self._Chem.MolFromSmiles(smiles)
99
+ if mol is None:
100
+ # Invalid SMILES - assign to empty scaffold group
101
+ scaffold = ""
102
+ else:
103
+ try:
104
+ scaffold = self._MurckoScaffold.MurckoScaffoldSmiles(mol=mol)
105
+ except (ValueError, RuntimeError):
106
+ # Some molecules may not have a valid scaffold
107
+ scaffold = ""
108
+
109
+ if scaffold not in scaffolds:
110
+ scaffolds[scaffold] = []
111
+ scaffolds[scaffold].append(idx)
112
+
113
+ return scaffolds
114
+
115
+ def split(self, data_source: DataSourceModule) -> SplitResult:
116
+ """Split by scaffold, largest scaffolds go to train first.
117
+
118
+ Args:
119
+ data_source: Datarax DataSourceModule to split
120
+
121
+ Returns:
122
+ SplitResult with scaffold-based train/valid/test indices
123
+ """
124
+ # Extract SMILES from data source
125
+ smiles_list = [data_source[i].data[self.config.smiles_key] for i in range(len(data_source))]
126
+
127
+ scaffolds = self._generate_scaffolds(smiles_list)
128
+
129
+ # Sort scaffold groups by size (largest first)
130
+ scaffold_sets = sorted(scaffolds.values(), key=len, reverse=True)
131
+ return self.assign_groups_to_splits(scaffold_sets, len(data_source))
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class TanimotoClusterSplitterConfig(SplitterConfig):
136
+ """Configuration for Tanimoto cluster splitter.
137
+
138
+ Attributes:
139
+ smiles_key: Key in data element containing SMILES string (default: "smiles")
140
+ fingerprint_type: Type of fingerprint ("morgan", "rdkit", "maccs")
141
+ fingerprint_radius: Radius for Morgan fingerprints (default: 2)
142
+ fingerprint_bits: Number of bits for fingerprints (default: 2048)
143
+ similarity_cutoff: Tanimoto similarity cutoff for clustering (default: 0.6)
144
+ """
145
+
146
+ smiles_key: str = "smiles"
147
+ fingerprint_type: str = "morgan"
148
+ fingerprint_radius: int = 2
149
+ fingerprint_bits: int = 2048
150
+ similarity_cutoff: float = 0.6
151
+
152
+
153
+ class TanimotoClusterSplitter(SplitterModule):
154
+ """Split by Tanimoto similarity clustering (Butina algorithm).
155
+
156
+ Groups similar molecules together using fingerprint similarity,
157
+ then assigns clusters to train/valid/test to ensure structural
158
+ diversity between splits.
159
+
160
+ Inherits from SplitterModule (StructuralModule) because:
161
+
162
+ - Non-parametric: clustering is deterministic given fingerprints
163
+ - Frozen config: splitting strategy doesn't change
164
+ - Domain-specific: requires RDKit fingerprints
165
+
166
+ Requires RDKit installation.
167
+
168
+ Example:
169
+ ```python
170
+ config = TanimotoClusterSplitterConfig(similarity_cutoff=0.6)
171
+ splitter = TanimotoClusterSplitter(config)
172
+ result = splitter.split(molecule_source)
173
+ ```
174
+
175
+ References:
176
+ Butina, Darko. "Unsupervised data base clustering based on daylight's
177
+ fingerprint and Tanimoto similarity." JCICS 39.4 (1999): 747-750.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ config: TanimotoClusterSplitterConfig,
183
+ *,
184
+ rngs: nnx.Rngs | None = None,
185
+ name: str | None = None,
186
+ ):
187
+ """Initialize TanimotoClusterSplitter.
188
+
189
+ Args:
190
+ config: Tanimoto cluster splitter configuration
191
+ rngs: Random number generators (unused)
192
+ name: Optional module name
193
+
194
+ Raises:
195
+ ImportError: If RDKit is not installed
196
+ """
197
+ super().__init__(config, rngs=rngs, name=name)
198
+ try:
199
+ from rdkit import Chem, DataStructs
200
+ from rdkit.Chem import AllChem, MACCSkeys
201
+ from rdkit.ML.Cluster import Butina
202
+
203
+ self._Chem = Chem
204
+ self._DataStructs = DataStructs
205
+ self._AllChem = AllChem
206
+ self._MACCSkeys = MACCSkeys
207
+ self._Butina = Butina
208
+ except ImportError as e:
209
+ raise ImportError("TanimotoClusterSplitter requires RDKit: pip install rdkit") from e
210
+
211
+ def _compute_fingerprint(self, mol: Any) -> Any | None:
212
+ """Compute fingerprint for a molecule.
213
+
214
+ Args:
215
+ mol: RDKit molecule object
216
+
217
+ Returns:
218
+ Fingerprint object or None if computation fails
219
+ """
220
+ if mol is None:
221
+ return None
222
+
223
+ try:
224
+ if self.config.fingerprint_type == "morgan":
225
+ return self._AllChem.GetMorganFingerprintAsBitVect(
226
+ mol,
227
+ self.config.fingerprint_radius,
228
+ nBits=self.config.fingerprint_bits,
229
+ )
230
+ elif self.config.fingerprint_type == "rdkit":
231
+ return self._Chem.RDKFingerprint(mol, fpSize=self.config.fingerprint_bits)
232
+ elif self.config.fingerprint_type == "maccs":
233
+ return self._MACCSkeys.GenMACCSKeys(mol)
234
+ else:
235
+ raise ValueError(f"Unknown fingerprint type: {self.config.fingerprint_type}")
236
+ except (ValueError, RuntimeError):
237
+ return None
238
+
239
+ def _compute_fingerprints(self, smiles_list: Sequence[str]) -> list[tuple[int, Any]]:
240
+ """Compute fingerprints for all molecules.
241
+
242
+ Args:
243
+ smiles_list: List of SMILES strings
244
+
245
+ Returns:
246
+ List of (index, fingerprint) tuples for valid molecules
247
+ """
248
+ valid_fps: list[tuple[int, Any]] = []
249
+ for idx, smiles in enumerate(smiles_list):
250
+ mol = self._Chem.MolFromSmiles(smiles)
251
+ fp = self._compute_fingerprint(mol)
252
+ if fp is not None:
253
+ valid_fps.append((idx, fp))
254
+ return valid_fps
255
+
256
+ @staticmethod
257
+ def _all_train_result(size: int) -> SplitResult:
258
+ """Create a split result with all items assigned to train."""
259
+ return SplitResult(
260
+ train_indices=jnp.array(list(range(size)), dtype=jnp.int32),
261
+ valid_indices=jnp.array([], dtype=jnp.int32),
262
+ test_indices=jnp.array([], dtype=jnp.int32),
263
+ )
264
+
265
+ def _cluster_fingerprints(self, fp_list: list[Any]) -> list[tuple[int, ...]]:
266
+ """Cluster fingerprints using Butina on condensed Tanimoto distances."""
267
+ n_valid = len(fp_list)
268
+ dists = []
269
+ for i in range(1, n_valid):
270
+ sims = self._DataStructs.BulkTanimotoSimilarity(fp_list[i], fp_list[:i])
271
+ dists.extend([1 - s for s in sims])
272
+
273
+ dist_threshold = 1 - self.config.similarity_cutoff
274
+ clusters = self._Butina.ClusterData(dists, n_valid, dist_threshold, isDistData=True)
275
+ return sorted(clusters, key=len, reverse=True)
276
+
277
+ def _assign_clusters_to_splits(
278
+ self,
279
+ sorted_clusters: list[tuple[int, ...]],
280
+ indices: list[int],
281
+ total_size: int,
282
+ ) -> tuple[list[int], list[int], list[int]]:
283
+ """Assign clusters to train/valid/test while preserving cluster membership."""
284
+ remapped_clusters = ([indices[i] for i in cluster] for cluster in sorted_clusters)
285
+ split_result = self.assign_groups_to_splits(remapped_clusters, total_size)
286
+ return (
287
+ split_result.train_indices.tolist(),
288
+ split_result.valid_indices.tolist(),
289
+ split_result.test_indices.tolist(),
290
+ )
291
+
292
+ def split(self, data_source: DataSourceModule) -> SplitResult:
293
+ """Cluster by Tanimoto similarity and split.
294
+
295
+ Args:
296
+ data_source: Datarax DataSourceModule to split
297
+
298
+ Returns:
299
+ SplitResult with cluster-based train/valid/test indices
300
+ """
301
+ total_size = len(data_source)
302
+ smiles_list = [data_source[i].data[self.config.smiles_key] for i in range(total_size)]
303
+
304
+ # Compute fingerprints
305
+ valid_fps = self._compute_fingerprints(smiles_list)
306
+
307
+ # Track invalid molecules (those without valid fingerprints)
308
+ valid_indices = {idx for idx, _ in valid_fps}
309
+ invalid_indices = [i for i in range(total_size) if i not in valid_indices]
310
+
311
+ if len(valid_fps) == 0:
312
+ return self._all_train_result(total_size)
313
+
314
+ # Unpack indices and fingerprints
315
+ indices, fp_list = zip(*valid_fps)
316
+ indices = list(indices)
317
+ fp_list = list(fp_list)
318
+ sorted_clusters = self._cluster_fingerprints(fp_list)
319
+ train_inds, valid_inds, test_inds = self._assign_clusters_to_splits(
320
+ sorted_clusters, indices, total_size
321
+ )
322
+
323
+ # Add invalid molecules to train (they couldn't be clustered)
324
+ train_inds.extend(invalid_indices)
325
+
326
+ return SplitResult(
327
+ train_indices=jnp.array(train_inds, dtype=jnp.int32),
328
+ valid_indices=jnp.array(valid_inds, dtype=jnp.int32),
329
+ test_indices=jnp.array(test_inds, dtype=jnp.int32),
330
+ )
@@ -0,0 +1,199 @@
1
+ """Perturbation-aware splitters for zero-shot and few-shot evaluation.
2
+
3
+ Provides specialized splitting strategies for single-cell perturbation
4
+ experiments: holding out entire cell types (zero-shot) or specific
5
+ perturbations within cell types (few-shot).
6
+
7
+ References:
8
+ - cell-load/src/cell_load/config.py (zeroshot/fewshot split logic)
9
+ - cell-load/src/cell_load/utils/data_utils.py (split_perturbations_by_cell_fraction)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import jax.numpy as jnp
19
+ import numpy as np
20
+
21
+ from diffbio.splitters.base import SplitResult, SplitterConfig, SplitterModule
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ZeroShotSplitterConfig(SplitterConfig):
28
+ """Configuration for ZeroShotSplitter.
29
+
30
+ Attributes:
31
+ held_out_cell_types: Cell types held out entirely for test.
32
+ pert_col: Obs column name for perturbation identity.
33
+ cell_type_col: Obs column name for cell type.
34
+ """
35
+
36
+ held_out_cell_types: tuple[str, ...] = ()
37
+ pert_col: str = "perturbation"
38
+ cell_type_col: str = "cell_type"
39
+
40
+
41
+ class ZeroShotSplitter(SplitterModule):
42
+ """Hold out entire cell types for zero-shot evaluation.
43
+
44
+ All cells of specified cell types go to the test set. Remaining
45
+ cells are split into train and validation by the configured fractions.
46
+
47
+ Args:
48
+ config: Splitter configuration.
49
+ rngs: Optional RNG state.
50
+ name: Optional module name.
51
+ """
52
+
53
+ def split(self, data_source: Any) -> SplitResult:
54
+ """Split data source by cell type holdout.
55
+
56
+ Args:
57
+ data_source: A PerturbationAnnDataSource or similar source
58
+ providing element dicts with perturbation metadata.
59
+
60
+ Returns:
61
+ SplitResult with train/valid/test indices.
62
+ """
63
+ n = len(data_source)
64
+ held_out = set(self.config.held_out_cell_types)
65
+
66
+ test_indices: list[int] = []
67
+ remaining_indices: list[int] = []
68
+
69
+ for i in range(n):
70
+ elem = data_source[i]
71
+ ct = elem.get(
72
+ "cell_type_name",
73
+ str(elem.get("obs", {}).get(self.config.cell_type_col, "")),
74
+ )
75
+ if ct in held_out:
76
+ test_indices.append(i)
77
+ else:
78
+ remaining_indices.append(i)
79
+
80
+ # Split remaining into train/val
81
+ rng = np.random.default_rng(self.config.seed)
82
+ remaining = np.array(remaining_indices)
83
+ rng.shuffle(remaining)
84
+
85
+ # Adjust fractions for the remaining subset
86
+ total_remaining = len(remaining)
87
+ train_frac = self.config.train_frac
88
+ val_frac = self.config.valid_frac
89
+ total_frac = train_frac + val_frac
90
+ if total_frac > 0:
91
+ adjusted_train = train_frac / total_frac
92
+ else:
93
+ adjusted_train = 0.5
94
+
95
+ n_train = int(total_remaining * adjusted_train)
96
+ train = remaining[:n_train].tolist()
97
+ valid = remaining[n_train:].tolist()
98
+
99
+ return SplitResult(
100
+ train_indices=jnp.array(train, dtype=jnp.int32),
101
+ valid_indices=jnp.array(valid, dtype=jnp.int32),
102
+ test_indices=jnp.array(test_indices, dtype=jnp.int32),
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class FewShotSplitterConfig(SplitterConfig):
108
+ """Configuration for FewShotSplitter.
109
+
110
+ Attributes:
111
+ held_out_perturbations: Perturbation names assigned to test.
112
+ pert_col: Obs column name for perturbation identity.
113
+ cell_type_col: Obs column name for cell type.
114
+ control_pert: Label identifying control cells (always in train).
115
+ val_subsample_fraction: Fraction of validation data to keep.
116
+ """
117
+
118
+ held_out_perturbations: tuple[str, ...] = ()
119
+ pert_col: str = "perturbation"
120
+ cell_type_col: str = "cell_type"
121
+ control_pert: str = "non-targeting"
122
+ val_subsample_fraction: float | None = None
123
+
124
+
125
+ class FewShotSplitter(SplitterModule):
126
+ """Hold out specific perturbations for few-shot evaluation.
127
+
128
+ Cells with held-out perturbations go to test. Control cells always
129
+ go to train. Remaining perturbed cells are split between train and
130
+ validation.
131
+
132
+ Args:
133
+ config: Splitter configuration.
134
+ rngs: Optional RNG state.
135
+ name: Optional module name.
136
+ """
137
+
138
+ def split(self, data_source: Any) -> SplitResult:
139
+ """Split data source by perturbation holdout.
140
+
141
+ Args:
142
+ data_source: A PerturbationAnnDataSource or similar source.
143
+
144
+ Returns:
145
+ SplitResult with train/valid/test indices.
146
+ """
147
+ n = len(data_source)
148
+ held_out = set(self.config.held_out_perturbations)
149
+ control = self.config.control_pert
150
+
151
+ test_indices: list[int] = []
152
+ control_indices: list[int] = []
153
+ remaining_indices: list[int] = []
154
+
155
+ for i in range(n):
156
+ elem = data_source[i]
157
+ pert = elem.get(
158
+ "pert_name",
159
+ str(elem.get("obs", {}).get(self.config.pert_col, "")),
160
+ )
161
+
162
+ if pert in held_out:
163
+ test_indices.append(i)
164
+ elif pert == control:
165
+ control_indices.append(i)
166
+ else:
167
+ remaining_indices.append(i)
168
+
169
+ # Split remaining non-control, non-test cells into train/val
170
+ rng = np.random.default_rng(self.config.seed)
171
+ remaining = np.array(remaining_indices)
172
+ rng.shuffle(remaining)
173
+
174
+ train_frac = self.config.train_frac
175
+ val_frac = self.config.valid_frac
176
+ total_frac = train_frac + val_frac
177
+ if total_frac > 0:
178
+ adjusted_train = train_frac / total_frac
179
+ else:
180
+ adjusted_train = 0.5
181
+
182
+ n_train = int(len(remaining) * adjusted_train)
183
+ train_from_remaining = remaining[:n_train].tolist()
184
+ valid_from_remaining = remaining[n_train:].tolist()
185
+
186
+ # Controls always go to train
187
+ train = control_indices + train_from_remaining
188
+
189
+ # Apply validation subsample
190
+ valid = valid_from_remaining
191
+ if self.config.val_subsample_fraction is not None and len(valid) > 0:
192
+ n_keep = max(1, int(len(valid) * self.config.val_subsample_fraction))
193
+ valid = valid[:n_keep]
194
+
195
+ return SplitResult(
196
+ train_indices=jnp.array(train, dtype=jnp.int32),
197
+ valid_indices=jnp.array(valid, dtype=jnp.int32),
198
+ test_indices=jnp.array(test_indices, dtype=jnp.int32),
199
+ )