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,267 @@
1
+ """Differentiable MACCS structural keys fingerprint operator.
2
+
3
+ This module implements a differentiable version of the 166 MACCS
4
+ (Molecular ACCess System) structural keys fingerprint.
5
+
6
+ MACCS keys are predefined structural patterns (SMARTS) that encode
7
+ the presence/absence of specific molecular substructures. This
8
+ implementation provides a differentiable approximation using
9
+ learned pattern matching networks.
10
+
11
+ References:
12
+ - https://rdkit.org/docs/source/rdkit.Chem.MACCSkeys.html
13
+ - Durant et al. "Reoptimization of MDL Keys for Use in Drug Discovery" JCIM 2002
14
+ """
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import jax.numpy as jnp
21
+ from datarax.core.config import OperatorConfig
22
+ from datarax.core.operator import OperatorModule
23
+ from flax import nnx
24
+
25
+ from diffbio.core import soft_ops
26
+ from diffbio.operators.drug_discovery._graph_utils import (
27
+ attach_fingerprint,
28
+ ensure_rngs,
29
+ initialize_graph_encoder,
30
+ stabilize_operator_id,
31
+ unpack_graph_inputs,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class MACCSKeysConfig(OperatorConfig):
39
+ """Configuration for MACCS keys fingerprint operator.
40
+
41
+ Attributes:
42
+ n_bits: Number of fingerprint bits (default: 166 for standard MACCS).
43
+ differentiable: Use learned pattern matching (default: True).
44
+ temperature: Temperature for soft bit assignment (default: 1.0).
45
+ hidden_dim: Hidden dimension for pattern networks (default: 64).
46
+ num_layers: Number of message passing layers (default: 2).
47
+ in_features: Number of input node features (default: 4).
48
+ """
49
+
50
+ n_bits: int = 166
51
+ differentiable: bool = True
52
+ temperature: float = 1.0
53
+ hidden_dim: int = 64
54
+ num_layers: int = 2
55
+ in_features: int = 4
56
+
57
+
58
+ class MACCSKeysOperator(OperatorModule):
59
+ """Differentiable MACCS structural keys fingerprint operator.
60
+
61
+ For differentiable=True:
62
+ Uses message passing and learned pattern detectors to approximate
63
+ MACCS key detection. Each of the 166 keys is represented by a
64
+ learned pattern matching network that outputs a soft presence score.
65
+
66
+ For differentiable=False:
67
+ Would use RDKit's exact MACCS implementation (not differentiable).
68
+
69
+ The differentiable version enables gradient flow for end-to-end
70
+ optimization while approximating the structural pattern detection
71
+ of traditional MACCS keys.
72
+
73
+ MACCS keys encode various structural features:
74
+
75
+ - Atom types (C, N, O, S, halides, etc.)
76
+ - Functional groups (carbonyl, hydroxyl, amine, etc.)
77
+ - Ring systems (aromatic, aliphatic)
78
+ - Bond patterns and connectivity
79
+
80
+ Example:
81
+ ```python
82
+ config = MACCSKeysConfig(temperature=1.0)
83
+ op = MACCSKeysOperator(config, rngs=nnx.Rngs(42))
84
+ data = {"node_features": nodes, "adjacency": adj}
85
+ result, _, _ = op.apply(data, {}, None)
86
+ fingerprint = result["fingerprint"] # shape: (166,)
87
+ ```
88
+
89
+ References:
90
+ - Durant et al. "Reoptimization of MDL Keys" JCIM 2002
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ config: MACCSKeysConfig,
96
+ *,
97
+ rngs: nnx.Rngs | None = None,
98
+ ):
99
+ """Initialize MACCS keys operator.
100
+
101
+ Args:
102
+ config: MACCS keys configuration.
103
+ rngs: Flax NNX random number generators.
104
+ """
105
+ super().__init__(config, rngs=rngs)
106
+
107
+ rngs = ensure_rngs(rngs)
108
+
109
+ if config.differentiable:
110
+ # Message passing for local structure aggregation
111
+ rngs = initialize_graph_encoder(
112
+ self,
113
+ rngs=rngs,
114
+ hidden_dim=config.hidden_dim,
115
+ num_layers=config.num_layers,
116
+ in_features=config.in_features,
117
+ attr="encoder",
118
+ )
119
+
120
+ # Pattern detectors: one network per MACCS key
121
+ # Each outputs a score indicating pattern presence
122
+ self.pattern_detectors = nnx.Sequential(
123
+ nnx.Linear(config.hidden_dim, config.hidden_dim, rngs=rngs),
124
+ nnx.relu,
125
+ nnx.Linear(config.hidden_dim, config.n_bits, rngs=rngs),
126
+ )
127
+ else:
128
+ # RDKit mode
129
+ stabilize_operator_id(self)
130
+ try:
131
+ from rdkit import Chem
132
+ from rdkit.Chem import MACCSkeys as RDKitMACCS
133
+
134
+ self._Chem = Chem
135
+ self._MACCSkeys = RDKitMACCS
136
+ except ImportError as e:
137
+ raise ImportError(
138
+ "MACCSKeysOperator with differentiable=False requires RDKit: pip install rdkit"
139
+ ) from e
140
+
141
+ def _compute_differentiable_fp(
142
+ self,
143
+ node_features: jnp.ndarray,
144
+ adjacency: jnp.ndarray,
145
+ edge_features: jnp.ndarray | None = None,
146
+ node_mask: jnp.ndarray | None = None,
147
+ ) -> jnp.ndarray:
148
+ """Compute differentiable MACCS-style fingerprint.
149
+
150
+ Uses message passing to aggregate local structure information,
151
+ then applies learned pattern detectors with soft thresholding.
152
+
153
+ Args:
154
+ node_features: (num_nodes, num_features) atom features
155
+ adjacency: (num_nodes, num_nodes) adjacency matrix
156
+ edge_features: Optional edge features
157
+ node_mask: Optional mask for valid nodes
158
+
159
+ Returns:
160
+ Fingerprint vector of shape (n_bits,)
161
+ """
162
+ # Message passing to capture local structure
163
+ node_hidden = self.encoder(node_features, adjacency, edge_features)
164
+
165
+ # Apply node mask
166
+ if node_mask is not None:
167
+ node_hidden = node_hidden * node_mask[:, None]
168
+
169
+ # Pattern detection at each atom
170
+ # pattern_logits: (num_nodes, n_bits)
171
+ pattern_logits = self.pattern_detectors(node_hidden)
172
+
173
+ # Apply temperature-scaled sigmoid for soft pattern matching
174
+ soft_patterns = soft_ops.greater(pattern_logits, 0.0, softness=self.config.temperature)
175
+
176
+ # Aggregate across atoms using max (OR-like)
177
+ # If any atom matches a pattern, the bit is set
178
+ fingerprint = soft_ops.max(soft_patterns, axis=0, softness=self.config.temperature)
179
+
180
+ return fingerprint
181
+
182
+ def apply(
183
+ self,
184
+ data: dict[str, Any],
185
+ state: dict[str, Any],
186
+ metadata: dict[str, Any] | None,
187
+ random_params: Any = None, # noqa: ARG002
188
+ stats: dict[str, Any] | None = None, # noqa: ARG002
189
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]:
190
+ """Compute MACCS keys fingerprint.
191
+
192
+ Args:
193
+ data: Input data containing:
194
+ For differentiable=True:
195
+ - node_features: (num_nodes, num_features) atom features
196
+ - adjacency: (num_nodes, num_nodes) adjacency matrix
197
+ - node_mask: (num_nodes,) optional mask for valid nodes
198
+ For differentiable=False:
199
+ - smiles: SMILES string
200
+ state: Per-element state (passed through).
201
+ metadata: Optional metadata.
202
+ random_params: Unused random parameters.
203
+ stats: Optional statistics dictionary.
204
+
205
+ Returns:
206
+ Tuple of:
207
+ - data with added "fingerprint" key
208
+ - unchanged state
209
+ - unchanged metadata
210
+ """
211
+ del random_params, stats # Unused
212
+
213
+ if self.config.differentiable:
214
+ node_features, adjacency, edge_features, node_mask = unpack_graph_inputs(data)
215
+ fp = self._compute_differentiable_fp(node_features, adjacency, edge_features, node_mask)
216
+ else:
217
+ smiles = data["smiles"]
218
+ fp = self._compute_rdkit_fp(smiles)
219
+
220
+ return attach_fingerprint(data, fp), state, metadata
221
+
222
+ def _compute_rdkit_fp(self, smiles: str) -> jnp.ndarray:
223
+ """Compute exact MACCS keys using RDKit.
224
+
225
+ Args:
226
+ smiles: SMILES string
227
+
228
+ Returns:
229
+ Binary fingerprint vector of shape (166,)
230
+ """
231
+ import numpy as np
232
+
233
+ mol = self._Chem.MolFromSmiles(smiles)
234
+ if mol is None:
235
+ return jnp.zeros(self.config.n_bits, dtype=jnp.float32)
236
+
237
+ fp = self._MACCSkeys.GenMACCSKeys(mol)
238
+
239
+ # Convert to numpy (RDKit returns 167 bits, we use 1-166)
240
+ arr = np.zeros(167, dtype=np.float32)
241
+ for i in range(167):
242
+ arr[i] = fp.GetBit(i)
243
+
244
+ # Return bits 1-166 (index 0 is unused in standard MACCS)
245
+ return jnp.asarray(arr[1:167])
246
+
247
+
248
+ def create_maccs_operator(
249
+ differentiable: bool = True,
250
+ temperature: float = 1.0,
251
+ seed: int = 42,
252
+ ) -> MACCSKeysOperator:
253
+ """Create a MACCS keys fingerprint operator.
254
+
255
+ Args:
256
+ differentiable: Use learned pattern matching.
257
+ temperature: Temperature for soft matching.
258
+ seed: Random seed.
259
+
260
+ Returns:
261
+ Configured MACCSKeysOperator.
262
+ """
263
+ config = MACCSKeysConfig(
264
+ differentiable=differentiable,
265
+ temperature=temperature,
266
+ )
267
+ return MACCSKeysOperator(config, rngs=nnx.Rngs(seed))
@@ -0,0 +1,200 @@
1
+ """Message passing neural network layers for molecular graphs.
2
+
3
+ This module implements directed message passing neural network (D-MPNN)
4
+ layers following the ChemProp architecture for molecular property prediction.
5
+ """
6
+
7
+ import logging
8
+
9
+ import jax.numpy as jnp
10
+ from flax import nnx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class MessagePassingLayer(nnx.Module):
16
+ """Directed message passing layer for molecular graphs.
17
+
18
+ Implements the D-MPNN message passing scheme where messages are passed
19
+ along directed edges. Each node aggregates messages from its neighbors
20
+ and updates its representation.
21
+
22
+ Attributes:
23
+ hidden_dim: Dimension of hidden node representations.
24
+ in_features: Number of input node features.
25
+ num_edge_features: Number of edge features (default 4 for bond types).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ hidden_dim: int,
31
+ in_features: int = 4,
32
+ num_edge_features: int = 4,
33
+ *,
34
+ rngs: nnx.Rngs,
35
+ ):
36
+ """Initialize message passing layer.
37
+
38
+ Args:
39
+ hidden_dim: Dimension of hidden representations.
40
+ in_features: Number of input node features (default 4 for tests).
41
+ num_edge_features: Number of edge/bond features.
42
+ rngs: Flax NNX random number generators.
43
+ """
44
+ self.hidden_dim = hidden_dim
45
+ self.in_features = in_features
46
+ self.num_edge_features = num_edge_features
47
+
48
+ # Node encoder - eagerly initialized with specified in_features
49
+ self.node_encoder = nnx.Linear(
50
+ in_features=in_features,
51
+ out_features=hidden_dim,
52
+ rngs=rngs,
53
+ )
54
+
55
+ # Edge encoder
56
+ self.edge_encoder = nnx.Linear(
57
+ in_features=num_edge_features,
58
+ out_features=hidden_dim,
59
+ rngs=rngs,
60
+ )
61
+
62
+ # Message transformation
63
+ self.message_layer = nnx.Linear(
64
+ in_features=hidden_dim * 3, # src_node + edge + dst_node
65
+ out_features=hidden_dim,
66
+ rngs=rngs,
67
+ )
68
+
69
+ # Update function (GRU-like update)
70
+ self.update_layer = nnx.Linear(
71
+ in_features=hidden_dim * 2, # current + aggregated
72
+ out_features=hidden_dim,
73
+ rngs=rngs,
74
+ )
75
+
76
+ def __call__(
77
+ self,
78
+ node_features: jnp.ndarray,
79
+ adjacency: jnp.ndarray,
80
+ edge_features: jnp.ndarray | None = None,
81
+ ) -> jnp.ndarray:
82
+ """Perform one step of message passing.
83
+
84
+ Args:
85
+ node_features: Node features of shape (num_nodes, in_features).
86
+ adjacency: Adjacency matrix of shape (num_nodes, num_nodes).
87
+ edge_features: Optional edge features of shape
88
+ (num_nodes, num_nodes, num_edge_features).
89
+
90
+ Returns:
91
+ Updated node features of shape (num_nodes, hidden_dim).
92
+ """
93
+ num_nodes = node_features.shape[0]
94
+
95
+ # Encode node features to hidden dimension
96
+ node_hidden = nnx.relu(self.node_encoder(node_features))
97
+
98
+ # Handle edge features
99
+ if edge_features is not None:
100
+ edge_hidden = nnx.relu(self.edge_encoder(edge_features))
101
+ else:
102
+ # Use zeros if no edge features provided
103
+ edge_hidden = jnp.zeros((num_nodes, num_nodes, self.hidden_dim), dtype=jnp.float32)
104
+
105
+ # Compute messages for all pairs
106
+ # For each edge (i, j), message = f(node_i, edge_ij, node_j)
107
+ # Expand dimensions for broadcasting
108
+ src_nodes = node_hidden[:, None, :] # (N, 1, H)
109
+ dst_nodes = node_hidden[None, :, :] # (1, N, H)
110
+
111
+ # Broadcast to (N, N, H)
112
+ src_expanded = jnp.broadcast_to(src_nodes, (num_nodes, num_nodes, self.hidden_dim))
113
+ dst_expanded = jnp.broadcast_to(dst_nodes, (num_nodes, num_nodes, self.hidden_dim))
114
+
115
+ # Concatenate [src, edge, dst]
116
+ message_input = jnp.concatenate([src_expanded, edge_hidden, dst_expanded], axis=-1)
117
+
118
+ # Compute messages
119
+ messages = nnx.relu(self.message_layer(message_input))
120
+
121
+ # Mask messages by adjacency (only neighbors contribute)
122
+ masked_messages = messages * adjacency[:, :, None]
123
+
124
+ # Aggregate messages (sum over neighbors)
125
+ aggregated = jnp.sum(masked_messages, axis=1) # (N, H)
126
+
127
+ # Update node representations
128
+ update_input = jnp.concatenate([node_hidden, aggregated], axis=-1)
129
+ updated = nnx.relu(self.update_layer(update_input))
130
+
131
+ return updated
132
+
133
+
134
+ class StackedMessagePassing(nnx.Module):
135
+ """Stack of message passing layers.
136
+
137
+ Applies multiple rounds of message passing to capture higher-order
138
+ neighborhood information.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ hidden_dim: int,
144
+ num_layers: int,
145
+ in_features: int = 4,
146
+ num_edge_features: int = 4,
147
+ *,
148
+ rngs: nnx.Rngs,
149
+ ):
150
+ """Initialize stacked message passing.
151
+
152
+ Args:
153
+ hidden_dim: Hidden dimension for all layers.
154
+ num_layers: Number of message passing iterations.
155
+ in_features: Number of input node features (default 4 for tests).
156
+ num_edge_features: Number of edge features.
157
+ rngs: Flax NNX random number generators.
158
+ """
159
+ self.hidden_dim = hidden_dim
160
+ self.num_layers = num_layers
161
+ self.in_features = in_features
162
+
163
+ # Build layers with proper input dimensions:
164
+ # - First layer: in_features -> hidden_dim
165
+ # - Subsequent layers: hidden_dim -> hidden_dim
166
+ layers = []
167
+ for i in range(num_layers):
168
+ layer_in_features = in_features if i == 0 else hidden_dim
169
+ layers.append(
170
+ MessagePassingLayer(
171
+ hidden_dim=hidden_dim,
172
+ in_features=layer_in_features,
173
+ num_edge_features=num_edge_features,
174
+ rngs=rngs,
175
+ )
176
+ )
177
+ self.layers = nnx.List(layers)
178
+
179
+ def __call__(
180
+ self,
181
+ node_features: jnp.ndarray,
182
+ adjacency: jnp.ndarray,
183
+ edge_features: jnp.ndarray | None = None,
184
+ ) -> jnp.ndarray:
185
+ """Apply multiple rounds of message passing.
186
+
187
+ Args:
188
+ node_features: Initial node features.
189
+ adjacency: Adjacency matrix.
190
+ edge_features: Optional edge features.
191
+
192
+ Returns:
193
+ Final node representations.
194
+ """
195
+ h = node_features
196
+
197
+ for layer in self.layers:
198
+ h = layer(h, adjacency, edge_features)
199
+
200
+ return h
@@ -0,0 +1,242 @@
1
+ """Primitive functions for molecular graph processing.
2
+
3
+ This module provides utility functions for converting SMILES strings to
4
+ molecular graphs suitable for differentiable neural network processing.
5
+ RDKit is used for parsing only; all graph operations use JAX arrays.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import jax.numpy as jnp
13
+ from rdkit import Chem
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AtomFeatureConfig:
20
+ """Configuration for atom feature extraction.
21
+
22
+ The default configuration produces 34 features:
23
+
24
+ - Atom type: 12 dimensions (C, N, O, S, F, Cl, Br, I, P, Si, B, Other)
25
+ - Degree: 7 dimensions (0-6)
26
+ - Formal charge: 5 dimensions (-2 to +2)
27
+ - Hybridization: 4 dimensions (SP, SP2, SP3, SP3D)
28
+ - Aromaticity: 1 dimension (binary)
29
+ - Num hydrogens: 5 dimensions (0-4)
30
+ """
31
+
32
+ num_atom_types: int = 12
33
+ max_degree: int = 6 # Creates max_degree + 1 dimensions
34
+ charge_range: tuple[int, int] = (-2, 2) # Creates 5 dimensions
35
+ num_hybridization_types: int = 4
36
+ max_num_hydrogens: int = 4 # Creates max_num_hydrogens + 1 dimensions
37
+
38
+ @property
39
+ def total_features(self) -> int:
40
+ """Calculate total number of atom features."""
41
+ return (
42
+ self.num_atom_types
43
+ + (self.max_degree + 1)
44
+ + (self.charge_range[1] - self.charge_range[0] + 1)
45
+ + self.num_hybridization_types
46
+ + 1 # aromaticity
47
+ + (self.max_num_hydrogens + 1)
48
+ )
49
+
50
+
51
+ # Default configuration
52
+ DEFAULT_ATOM_CONFIG = AtomFeatureConfig()
53
+ DEFAULT_ATOM_FEATURES = DEFAULT_ATOM_CONFIG.total_features # 34 features
54
+
55
+ # Atom type vocabulary for one-hot encoding
56
+ ATOM_TYPES = ["C", "N", "O", "S", "F", "Cl", "Br", "I", "P", "Si", "B", "Other"]
57
+
58
+
59
+ def get_atom_features(atom: Any, config: AtomFeatureConfig | None = None) -> jnp.ndarray:
60
+ """Extract features from an RDKit atom object.
61
+
62
+ Features include:
63
+ - Atom type (one-hot, config.num_atom_types)
64
+ - Degree (one-hot, 0 to config.max_degree)
65
+ - Formal charge (one-hot, config.charge_range)
66
+ - Hybridization (one-hot, config.num_hybridization_types)
67
+ - Aromaticity (binary)
68
+ - Number of hydrogens (one-hot, 0 to config.max_num_hydrogens)
69
+
70
+ Args:
71
+ atom: RDKit atom object.
72
+ config: Feature extraction configuration. Defaults to DEFAULT_ATOM_CONFIG.
73
+
74
+ Returns:
75
+ Feature vector of shape (config.total_features,).
76
+ """
77
+ if config is None:
78
+ config = DEFAULT_ATOM_CONFIG
79
+
80
+ features: list[float] = []
81
+
82
+ # Atom type
83
+ symbol = atom.GetSymbol()
84
+ atom_type_idx = ATOM_TYPES.index(symbol) if symbol in ATOM_TYPES else len(ATOM_TYPES) - 1
85
+ atom_type_onehot = [0.0] * config.num_atom_types
86
+ atom_type_onehot[min(atom_type_idx, config.num_atom_types - 1)] = 1.0
87
+ features.extend(atom_type_onehot)
88
+
89
+ # Degree
90
+ degree = min(atom.GetDegree(), config.max_degree)
91
+ degree_onehot = [0.0] * (config.max_degree + 1)
92
+ degree_onehot[degree] = 1.0
93
+ features.extend(degree_onehot)
94
+
95
+ # Formal charge
96
+ charge = atom.GetFormalCharge()
97
+ charge_min, charge_max = config.charge_range
98
+ charge_idx = max(charge_min, min(charge_max, charge)) - charge_min
99
+ charge_dim = charge_max - charge_min + 1
100
+ charge_onehot = [0.0] * charge_dim
101
+ charge_onehot[charge_idx] = 1.0
102
+ features.extend(charge_onehot)
103
+
104
+ # Hybridization
105
+ hybridization = atom.GetHybridization()
106
+ hyb_types = [
107
+ Chem.rdchem.HybridizationType.SP,
108
+ Chem.rdchem.HybridizationType.SP2,
109
+ Chem.rdchem.HybridizationType.SP3,
110
+ Chem.rdchem.HybridizationType.SP3D,
111
+ ]
112
+ hyb_onehot = [0.0] * config.num_hybridization_types
113
+ for i, h in enumerate(hyb_types[: config.num_hybridization_types]):
114
+ if hybridization == h:
115
+ hyb_onehot[i] = 1.0
116
+ break
117
+ features.extend(hyb_onehot)
118
+
119
+ # Aromaticity
120
+ features.append(1.0 if atom.GetIsAromatic() else 0.0)
121
+
122
+ # Number of hydrogens
123
+ num_h = min(atom.GetTotalNumHs(), config.max_num_hydrogens)
124
+ h_onehot = [0.0] * (config.max_num_hydrogens + 1)
125
+ h_onehot[num_h] = 1.0
126
+ features.extend(h_onehot)
127
+
128
+ return jnp.array(features, dtype=jnp.float32)
129
+
130
+
131
+ def get_bond_features(bond: Any) -> jnp.ndarray:
132
+ """Extract features from an RDKit bond object.
133
+
134
+ Features include:
135
+ - Bond type (one-hot: single, double, triple, aromatic)
136
+
137
+ Args:
138
+ bond: RDKit bond object.
139
+
140
+ Returns:
141
+ Feature vector of shape (4,).
142
+ """
143
+ bond_type = bond.GetBondType()
144
+ features = [
145
+ 1 if bond_type == Chem.rdchem.BondType.SINGLE else 0,
146
+ 1 if bond_type == Chem.rdchem.BondType.DOUBLE else 0,
147
+ 1 if bond_type == Chem.rdchem.BondType.TRIPLE else 0,
148
+ 1 if bond_type == Chem.rdchem.BondType.AROMATIC else 0,
149
+ ]
150
+ return jnp.array(features, dtype=jnp.float32)
151
+
152
+
153
+ def smiles_to_graph(smiles: str) -> dict[str, Any]:
154
+ """Convert a SMILES string to a molecular graph.
155
+
156
+ Args:
157
+ smiles: SMILES string representing a molecule.
158
+
159
+ Returns:
160
+ Dictionary containing:
161
+ - node_features: (num_atoms, num_features) atom feature matrix
162
+ - adjacency: (num_atoms, num_atoms) adjacency matrix
163
+ - edge_features: (num_atoms, num_atoms, num_edge_features) bond features
164
+ - num_nodes: number of atoms
165
+
166
+ Raises:
167
+ ValueError: If SMILES string is invalid.
168
+ """
169
+ mol = Chem.MolFromSmiles(smiles)
170
+ if mol is None:
171
+ raise ValueError(f"Invalid SMILES string: {smiles}")
172
+
173
+ num_atoms = mol.GetNumAtoms()
174
+
175
+ # Extract node features
176
+ node_features = []
177
+ for atom in mol.GetAtoms():
178
+ node_features.append(get_atom_features(atom))
179
+ node_features = jnp.stack(node_features)
180
+
181
+ # Build adjacency matrix and edge features
182
+ adjacency = jnp.zeros((num_atoms, num_atoms), dtype=jnp.float32)
183
+ edge_features = jnp.zeros((num_atoms, num_atoms, 4), dtype=jnp.float32)
184
+
185
+ for bond in mol.GetBonds():
186
+ i = bond.GetBeginAtomIdx()
187
+ j = bond.GetEndAtomIdx()
188
+ bond_feat = get_bond_features(bond)
189
+
190
+ # Symmetric (undirected graph)
191
+ adjacency = adjacency.at[i, j].set(1.0)
192
+ adjacency = adjacency.at[j, i].set(1.0)
193
+ edge_features = edge_features.at[i, j].set(bond_feat)
194
+ edge_features = edge_features.at[j, i].set(bond_feat)
195
+
196
+ return {
197
+ "node_features": node_features,
198
+ "adjacency": adjacency,
199
+ "edge_features": edge_features,
200
+ "num_nodes": num_atoms,
201
+ }
202
+
203
+
204
+ def batch_smiles_to_graphs(smiles_list: list[str]) -> dict[str, Any]:
205
+ """Convert a batch of SMILES strings to padded graph tensors.
206
+
207
+ Args:
208
+ smiles_list: List of SMILES strings.
209
+
210
+ Returns:
211
+ Dictionary containing:
212
+ - node_features: (batch_size, max_nodes, num_features)
213
+ - adjacency: (batch_size, max_nodes, max_nodes)
214
+ - edge_features: (batch_size, max_nodes, max_nodes, num_edge_features)
215
+ - node_mask: (batch_size, max_nodes) mask for valid nodes
216
+ """
217
+ graphs = [smiles_to_graph(s) for s in smiles_list]
218
+
219
+ max_nodes = max(g["num_nodes"] for g in graphs)
220
+ batch_size = len(graphs)
221
+ num_features = graphs[0]["node_features"].shape[1]
222
+ num_edge_features = graphs[0]["edge_features"].shape[2]
223
+
224
+ # Initialize padded tensors
225
+ node_features = jnp.zeros((batch_size, max_nodes, num_features))
226
+ adjacency = jnp.zeros((batch_size, max_nodes, max_nodes))
227
+ edge_features = jnp.zeros((batch_size, max_nodes, max_nodes, num_edge_features))
228
+ node_mask = jnp.zeros((batch_size, max_nodes))
229
+
230
+ for i, g in enumerate(graphs):
231
+ n = g["num_nodes"]
232
+ node_features = node_features.at[i, :n, :].set(g["node_features"])
233
+ adjacency = adjacency.at[i, :n, :n].set(g["adjacency"])
234
+ edge_features = edge_features.at[i, :n, :n, :].set(g["edge_features"])
235
+ node_mask = node_mask.at[i, :n].set(1.0)
236
+
237
+ return {
238
+ "node_features": node_features,
239
+ "adjacency": adjacency,
240
+ "edge_features": edge_features,
241
+ "node_mask": node_mask,
242
+ }