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,115 @@
1
+ """Multi-dataset concatenation source for perturbation experiments.
2
+
3
+ Combines multiple PerturbationAnnDataSource instances into a single unified
4
+ source with global indexing. Validates metadata consistency across sources.
5
+
6
+ References:
7
+ - cell-load/src/cell_load/dataset/_metadata.py (MetadataConcatDataset)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from collections.abc import Iterator
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PerturbationConcatSource:
22
+ """Concatenation of multiple PerturbationAnnDataSource instances.
23
+
24
+ Provides unified indexing across multiple sources. Global index ``i``
25
+ is mapped to the correct underlying source and local index.
26
+
27
+ Validates that all sources share consistent metadata column names
28
+ (control_pert, pert_col, etc.).
29
+
30
+ Args:
31
+ sources: List of PerturbationAnnDataSource instances.
32
+
33
+ Raises:
34
+ ValueError: If no sources are provided.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ sources: list[Any],
40
+ ) -> None:
41
+ if not sources:
42
+ raise ValueError("PerturbationConcatSource requires at least one source.")
43
+
44
+ self._sources = sources
45
+
46
+ # Build cumulative length offsets for global -> local index mapping
47
+ self._offsets: list[int] = []
48
+ cumulative = 0
49
+ for s in sources:
50
+ self._offsets.append(cumulative)
51
+ cumulative += len(s)
52
+ self._total_length = cumulative
53
+
54
+ def __len__(self) -> int:
55
+ """Return total number of cells across all sources."""
56
+ return self._total_length
57
+
58
+ def __getitem__(self, idx: int) -> dict[str, Any]:
59
+ """Get element by global index.
60
+
61
+ Args:
62
+ idx: Global cell index.
63
+
64
+ Returns:
65
+ Per-cell dictionary from the appropriate source.
66
+
67
+ Raises:
68
+ IndexError: If index is out of range.
69
+ """
70
+ if idx < 0:
71
+ idx = self._total_length + idx
72
+ if idx < 0 or idx >= self._total_length:
73
+ raise IndexError(
74
+ f"Index {idx} out of range for concat source with {self._total_length} cells"
75
+ )
76
+
77
+ source_idx, local_idx = self._global_to_local(idx)
78
+ return self._sources[source_idx][local_idx]
79
+
80
+ def __iter__(self) -> Iterator[dict[str, Any]]:
81
+ """Iterate over all cells across all sources in order."""
82
+ for source in self._sources:
83
+ yield from source
84
+
85
+ def get_control_mask(self) -> np.ndarray:
86
+ """Return concatenated boolean control mask."""
87
+ return np.concatenate([s.get_control_mask() for s in self._sources])
88
+
89
+ def get_group_codes(self) -> np.ndarray:
90
+ """Return concatenated group codes."""
91
+ return np.concatenate([s.get_group_codes() for s in self._sources])
92
+
93
+ def get_pert_codes(self) -> np.ndarray:
94
+ """Return concatenated perturbation codes."""
95
+ return np.concatenate([s.get_pert_codes() for s in self._sources])
96
+
97
+ def get_cell_type_codes(self) -> np.ndarray:
98
+ """Return concatenated cell type codes."""
99
+ return np.concatenate([s.get_cell_type_codes() for s in self._sources])
100
+
101
+ def get_batch_codes(self) -> np.ndarray:
102
+ """Return concatenated batch codes."""
103
+ return np.concatenate([s.get_batch_codes() for s in self._sources])
104
+
105
+ @property
106
+ def sources(self) -> list[Any]:
107
+ """Return the underlying source list."""
108
+ return self._sources
109
+
110
+ def _global_to_local(self, global_idx: int) -> tuple[int, int]:
111
+ """Map a global index to (source_index, local_index)."""
112
+ for i in range(len(self._sources) - 1, -1, -1):
113
+ if global_idx >= self._offsets[i]:
114
+ return i, global_idx - self._offsets[i]
115
+ return 0, global_idx # pragma: no cover
@@ -0,0 +1,215 @@
1
+ """Control cell mapping strategies for perturbation experiments.
2
+
3
+ Maps perturbed cells to control cells using batch-based or random strategies.
4
+ Mappings are precomputed at setup time as numpy index arrays.
5
+
6
+ References:
7
+ - cell-load/src/cell_load/mapping_strategies/batch.py
8
+ - cell-load/src/cell_load/mapping_strategies/random.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from datarax.core.config import StructuralConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ControlMappingConfig(StructuralConfig):
26
+ """Configuration for control cell mapping.
27
+
28
+ Attributes:
29
+ strategy: Mapping strategy (``"batch"`` or ``"random"``).
30
+ n_basal_samples: Number of control cells per perturbed cell.
31
+ seed: Random seed for reproducibility.
32
+ map_controls: Whether to also map control cells to other controls.
33
+ cache_pairs: Whether to cache the mapping after first computation.
34
+ """
35
+
36
+ strategy: str = "random"
37
+ n_basal_samples: int = 1
38
+ seed: int = 42
39
+ map_controls: bool = False
40
+ cache_pairs: bool = False
41
+
42
+
43
+ def _build_ctrl_pools_by_ct(ctrl_mask: np.ndarray, ct_codes: np.ndarray) -> dict[int, np.ndarray]:
44
+ """Build control index pools grouped by cell type."""
45
+ pools: dict[int, list[int]] = {}
46
+ for idx in np.where(ctrl_mask)[0]:
47
+ ct = int(ct_codes[idx])
48
+ if ct not in pools:
49
+ pools[ct] = []
50
+ pools[ct].append(idx)
51
+ return {k: np.array(v) for k, v in pools.items()}
52
+
53
+
54
+ def _map_cells_to_controls(
55
+ cell_indices: np.ndarray,
56
+ ct_codes: np.ndarray,
57
+ ctrl_by_ct: dict[int, np.ndarray],
58
+ n_basal: int,
59
+ rng: np.random.Generator,
60
+ ) -> np.ndarray:
61
+ """Map a set of cell indices to control cells from the same cell type."""
62
+ mapping = np.empty((len(cell_indices), n_basal), dtype=np.int64)
63
+ for i, cidx in enumerate(cell_indices):
64
+ ct = int(ct_codes[cidx])
65
+ pool = ctrl_by_ct.get(ct, np.array([], dtype=np.int64))
66
+ if len(pool) == 0:
67
+ mapping[i] = -1
68
+ continue
69
+ chosen = rng.choice(pool, size=n_basal, replace=len(pool) < n_basal)
70
+ mapping[i] = chosen
71
+ return mapping
72
+
73
+
74
+ class RandomControlMapping:
75
+ """Map perturbed cells to random controls of the same cell type.
76
+
77
+ For each perturbed cell, randomly selects ``n_basal_samples`` control cells
78
+ from the same cell type, pooled across all batches.
79
+
80
+ When ``map_controls=True``, also maps each control cell to another random
81
+ control of the same cell type.
82
+
83
+ When ``cache_pairs=True``, the mapping is computed once and cached for
84
+ subsequent calls.
85
+
86
+ Args:
87
+ config: Mapping configuration.
88
+ """
89
+
90
+ def __init__(self, config: ControlMappingConfig) -> None:
91
+ self._config = config
92
+ self._cached_mapping: np.ndarray | None = None
93
+
94
+ def build_mapping(self, source: Any) -> np.ndarray:
95
+ """Build mapping from cells to control cells.
96
+
97
+ Args:
98
+ source: A PerturbationAnnDataSource or PerturbationConcatSource.
99
+
100
+ Returns:
101
+ Array of shape ``(n_mapped, n_basal_samples)`` with control
102
+ cell indices. When ``map_controls=False``, ``n_mapped`` equals
103
+ the number of perturbed cells. When ``True``, ``n_mapped``
104
+ equals the total number of cells.
105
+ """
106
+ if self._config.cache_pairs and self._cached_mapping is not None:
107
+ return self._cached_mapping
108
+
109
+ ctrl_mask = source.get_control_mask()
110
+ ct_codes = source.get_cell_type_codes()
111
+ n_basal = self._config.n_basal_samples
112
+ rng = np.random.default_rng(self._config.seed)
113
+
114
+ ctrl_by_ct = _build_ctrl_pools_by_ct(ctrl_mask, ct_codes)
115
+
116
+ if self._config.map_controls:
117
+ # Map ALL cells (perturbed + controls) to controls
118
+ all_indices = np.arange(len(ctrl_mask))
119
+ mapping = _map_cells_to_controls(all_indices, ct_codes, ctrl_by_ct, n_basal, rng)
120
+ else:
121
+ # Map only perturbed cells
122
+ pert_indices = np.where(~ctrl_mask)[0]
123
+ mapping = _map_cells_to_controls(pert_indices, ct_codes, ctrl_by_ct, n_basal, rng)
124
+
125
+ if self._config.cache_pairs:
126
+ self._cached_mapping = mapping
127
+
128
+ return mapping
129
+
130
+
131
+ class BatchControlMapping:
132
+ """Map perturbed cells to controls within the same batch and cell type.
133
+
134
+ Prefers controls from the same (batch, cell_type) group. Falls back to
135
+ all controls from the same cell type if the batch group is empty.
136
+
137
+ When ``map_controls=True``, also maps control cells.
138
+ When ``cache_pairs=True``, caches after first computation.
139
+
140
+ Args:
141
+ config: Mapping configuration.
142
+ """
143
+
144
+ def __init__(self, config: ControlMappingConfig) -> None:
145
+ self._config = config
146
+ self._cached_mapping: np.ndarray | None = None
147
+
148
+ def build_mapping(self, source: Any) -> np.ndarray:
149
+ """Build mapping from cells to control cells.
150
+
151
+ Args:
152
+ source: A PerturbationAnnDataSource or PerturbationConcatSource.
153
+
154
+ Returns:
155
+ Array of shape ``(n_mapped, n_basal_samples)`` with control
156
+ cell indices.
157
+ """
158
+ if self._config.cache_pairs and self._cached_mapping is not None:
159
+ return self._cached_mapping
160
+
161
+ ctrl_mask = source.get_control_mask()
162
+ ct_codes = source.get_cell_type_codes()
163
+ batch_codes = source.get_batch_codes()
164
+ n_basal = self._config.n_basal_samples
165
+ rng = np.random.default_rng(self._config.seed)
166
+
167
+ # Build control pools by (batch, cell_type) and by cell_type
168
+ ctrl_indices = np.where(ctrl_mask)[0]
169
+
170
+ ctrl_by_batch_ct: dict[tuple[int, int], list[int]] = {}
171
+ ctrl_by_ct: dict[int, list[int]] = {}
172
+
173
+ for idx in ctrl_indices:
174
+ ct = int(ct_codes[idx])
175
+ batch = int(batch_codes[idx])
176
+ key = (batch, ct)
177
+ if key not in ctrl_by_batch_ct:
178
+ ctrl_by_batch_ct[key] = []
179
+ ctrl_by_batch_ct[key].append(idx)
180
+
181
+ if ct not in ctrl_by_ct:
182
+ ctrl_by_ct[ct] = []
183
+ ctrl_by_ct[ct].append(idx)
184
+
185
+ ctrl_by_batch_ct_arr = {k: np.array(v) for k, v in ctrl_by_batch_ct.items()}
186
+ ctrl_by_ct_arr = {k: np.array(v) for k, v in ctrl_by_ct.items()}
187
+
188
+ # Determine which cells to map
189
+ if self._config.map_controls:
190
+ cells_to_map = np.arange(len(ctrl_mask))
191
+ else:
192
+ cells_to_map = np.where(~ctrl_mask)[0]
193
+
194
+ mapping = np.empty((len(cells_to_map), n_basal), dtype=np.int64)
195
+
196
+ for i, cidx in enumerate(cells_to_map):
197
+ ct = int(ct_codes[cidx])
198
+ batch = int(batch_codes[cidx])
199
+ key = (batch, ct)
200
+
201
+ pool = ctrl_by_batch_ct_arr.get(key)
202
+ if pool is None or len(pool) == 0:
203
+ pool = ctrl_by_ct_arr.get(ct, np.array([], dtype=np.int64))
204
+
205
+ if len(pool) == 0:
206
+ mapping[i] = -1
207
+ continue
208
+
209
+ chosen = rng.choice(pool, size=n_basal, replace=len(pool) < n_basal)
210
+ mapping[i] = chosen
211
+
212
+ if self._config.cache_pairs:
213
+ self._cached_mapping = mapping
214
+
215
+ return mapping
@@ -0,0 +1,261 @@
1
+ """Experiment configuration for perturbation experiments.
2
+
3
+ Parses TOML configuration files following the cell-load schema and provides
4
+ frozen dataclass representations of experiment settings: dataset paths,
5
+ training assignments, zero-shot cell types, and few-shot perturbation splits.
6
+
7
+ References:
8
+ - cell-load/src/cell_load/config.py (ExperimentConfig)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from datarax.core.config import StructuralConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _require_toml() -> Any:
24
+ """Import tomllib from the standard library (Python 3.11+)."""
25
+ import tomllib # noqa: PLC0415
26
+
27
+ return tomllib
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Data classes
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DatasetEntry:
37
+ """A single dataset in an experiment.
38
+
39
+ Attributes:
40
+ name: Identifier for the dataset.
41
+ path: Filesystem path to the dataset directory or file.
42
+ """
43
+
44
+ name: str
45
+ path: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ZeroshotEntry:
50
+ """A cell type held out entirely for zero-shot evaluation.
51
+
52
+ Attributes:
53
+ dataset: Name of the dataset containing this cell type.
54
+ cell_type: Cell type to hold out.
55
+ split: Target split (``"val"`` or ``"test"``).
56
+ """
57
+
58
+ dataset: str
59
+ cell_type: str
60
+ split: str
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class FewshotEntry:
65
+ """Perturbations held out within a cell type for few-shot evaluation.
66
+
67
+ Attributes:
68
+ dataset: Name of the dataset containing this cell type.
69
+ cell_type: Cell type within which perturbations are split.
70
+ val_perturbations: Perturbation names assigned to validation.
71
+ test_perturbations: Perturbation names assigned to testing.
72
+ """
73
+
74
+ dataset: str
75
+ cell_type: str
76
+ val_perturbations: tuple[str, ...] = ()
77
+ test_perturbations: tuple[str, ...] = ()
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ExperimentConfig(StructuralConfig):
82
+ """Top-level experiment configuration.
83
+
84
+ Encapsulates all settings parsed from a TOML file: dataset paths,
85
+ training assignments, zero-shot cell type holdouts, and few-shot
86
+ perturbation splits.
87
+
88
+ Attributes:
89
+ datasets: Registered dataset entries.
90
+ training_datasets: Names of datasets used for training.
91
+ zeroshot: Cell types held out entirely.
92
+ fewshot: Perturbation-level splits within cell types.
93
+ """
94
+
95
+ datasets: tuple[DatasetEntry, ...] = ()
96
+ training_datasets: tuple[str, ...] = ()
97
+ zeroshot: tuple[ZeroshotEntry, ...] = ()
98
+ fewshot: tuple[FewshotEntry, ...] = ()
99
+
100
+ def get_all_datasets(self) -> set[str]:
101
+ """Return the set of all dataset names referenced in the config."""
102
+ names = set(self.training_datasets)
103
+ for z in self.zeroshot:
104
+ names.add(z.dataset)
105
+ for f in self.fewshot:
106
+ names.add(f.dataset)
107
+ return names
108
+
109
+ def get_zeroshot_celltypes(self, dataset: str) -> dict[str, str]:
110
+ """Get zero-shot cell types and their target splits for a dataset.
111
+
112
+ Args:
113
+ dataset: Dataset name to filter by.
114
+
115
+ Returns:
116
+ Dict mapping cell type names to split names.
117
+ """
118
+ return {z.cell_type: z.split for z in self.zeroshot if z.dataset == dataset}
119
+
120
+ def get_fewshot_celltypes(self, dataset: str) -> dict[str, FewshotEntry]:
121
+ """Get few-shot cell type entries for a dataset.
122
+
123
+ Args:
124
+ dataset: Dataset name to filter by.
125
+
126
+ Returns:
127
+ Dict mapping cell type names to FewshotEntry objects.
128
+ """
129
+ return {f.cell_type: f for f in self.fewshot if f.dataset == dataset}
130
+
131
+ def validate(self) -> None:
132
+ """Validate configuration consistency.
133
+
134
+ Raises:
135
+ ValueError: If referenced datasets lack paths or splits are invalid.
136
+ """
137
+ all_referenced = self.get_all_datasets()
138
+ dataset_names = {d.name for d in self.datasets}
139
+ missing = all_referenced - dataset_names
140
+ if missing:
141
+ raise ValueError(f"Missing dataset paths for: {missing}")
142
+
143
+ valid_splits = {"train", "val", "test"}
144
+ for z in self.zeroshot:
145
+ if z.split not in valid_splits:
146
+ raise ValueError(
147
+ f"Invalid split '{z.split}' for zeroshot entry "
148
+ f"{z.dataset}.{z.cell_type}. Must be one of {valid_splits}"
149
+ )
150
+
151
+ logger.info("Configuration validation passed")
152
+
153
+ def save_config(self, path: Path) -> None:
154
+ """Save configuration to a TOML file.
155
+
156
+ Args:
157
+ path: Output file path.
158
+ """
159
+ lines: list[str] = ["[datasets]"]
160
+ for d in self.datasets:
161
+ lines.append(f'{d.name} = "{d.path}"')
162
+
163
+ lines.append("")
164
+ lines.append("[training]")
165
+ for name in self.training_datasets:
166
+ lines.append(f'{name} = "train"')
167
+
168
+ if self.zeroshot:
169
+ lines.append("")
170
+ lines.append("[zeroshot]")
171
+ for z in self.zeroshot:
172
+ lines.append(f'"{z.dataset}.{z.cell_type}" = "{z.split}"')
173
+
174
+ for f in self.fewshot:
175
+ lines.append("")
176
+ lines.append(f'[fewshot."{f.dataset}.{f.cell_type}"]')
177
+ if f.val_perturbations:
178
+ val_list = ", ".join(f'"{p}"' for p in f.val_perturbations)
179
+ lines.append(f"val = [{val_list}]")
180
+ if f.test_perturbations:
181
+ test_list = ", ".join(f'"{p}"' for p in f.test_perturbations)
182
+ lines.append(f"test = [{test_list}]")
183
+
184
+ path.write_text("\n".join(lines) + "\n")
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # TOML loading
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def load_experiment_config(path: Path) -> ExperimentConfig:
193
+ """Load an experiment configuration from a TOML file.
194
+
195
+ Supports the cell-load TOML schema::
196
+
197
+ [datasets]
198
+ dataset_name = "/path/to/data"
199
+
200
+ [training]
201
+ dataset_name = "train"
202
+
203
+ [zeroshot]
204
+ "dataset.celltype" = "test"
205
+
206
+ [fewshot."dataset.celltype"]
207
+ val = ["Gene1"]
208
+ test = ["Gene2"]
209
+
210
+ Args:
211
+ path: Path to the TOML file.
212
+
213
+ Returns:
214
+ Parsed ExperimentConfig.
215
+
216
+ Raises:
217
+ FileNotFoundError: If the TOML file does not exist.
218
+ """
219
+ path = Path(path)
220
+ if not path.exists():
221
+ raise FileNotFoundError(f"TOML config file not found: {path}")
222
+
223
+ tomllib = _require_toml()
224
+ with open(path, "rb") as f:
225
+ raw = tomllib.load(f)
226
+
227
+ # Parse [datasets]
228
+ datasets_raw: dict[str, str] = raw.get("datasets", {})
229
+ datasets = tuple(DatasetEntry(name=name, path=p) for name, p in datasets_raw.items())
230
+
231
+ # Parse [training]
232
+ training_raw: dict[str, str] = raw.get("training", {})
233
+ training_datasets = tuple(training_raw.keys())
234
+
235
+ # Parse [zeroshot] -- keys are "dataset.celltype"
236
+ zeroshot_raw: dict[str, str] = raw.get("zeroshot", {})
237
+ zeroshot_entries: list[ZeroshotEntry] = []
238
+ for key, split in zeroshot_raw.items():
239
+ dataset, cell_type = key.split(".", 1)
240
+ zeroshot_entries.append(ZeroshotEntry(dataset=dataset, cell_type=cell_type, split=split))
241
+
242
+ # Parse [fewshot] -- keys are "dataset.celltype", values are {split: [perts]}
243
+ fewshot_raw: dict[str, dict[str, list[str]]] = raw.get("fewshot", {})
244
+ fewshot_entries: list[FewshotEntry] = []
245
+ for key, pert_config in fewshot_raw.items():
246
+ dataset, cell_type = key.split(".", 1)
247
+ fewshot_entries.append(
248
+ FewshotEntry(
249
+ dataset=dataset,
250
+ cell_type=cell_type,
251
+ val_perturbations=tuple(pert_config.get("val", [])),
252
+ test_perturbations=tuple(pert_config.get("test", [])),
253
+ )
254
+ )
255
+
256
+ return ExperimentConfig(
257
+ datasets=datasets,
258
+ training_datasets=training_datasets,
259
+ zeroshot=tuple(zeroshot_entries),
260
+ fewshot=tuple(fewshot_entries),
261
+ )