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,629 @@
1
+ """Shared graph attention components for GNN-based operators.
2
+
3
+ Ownership note: DiffBio retains these sparse GAT/GATv2 layers because sibling
4
+ repos do not currently expose a GATv2-compatible graph-attention block with the
5
+ same edge-feature and segment-softmax contract. Generic model pieces should
6
+ still come from Artifex where an exact reusable layer exists.
7
+
8
+ This module provides reusable graph attention building blocks that are needed
9
+ by multiple downstream operators (assembly, cell-cell communication, GRN
10
+ inference, spatial domain identification, etc.).
11
+
12
+ Components:
13
+
14
+ - **GraphAttentionLayer**: Multi-head GAT-style attention (dot-product Q*K).
15
+ - **GraphAttentionBlock**: GAT attention + LayerNorm + residual + FFN.
16
+ - **GATv2Layer**: GATv2-style attention that applies LeakyReLU *before* the
17
+ attention vector dot product, making it strictly more expressive than GAT.
18
+ - **GATv2Block**: GATv2 attention + LayerNorm + residual + FFN.
19
+
20
+ These are architecturally distinct from ``GraphMessagePassing`` in
21
+ ``neural_components.py``, which uses simpler sum/mean/max aggregation
22
+ without attention.
23
+ """
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+ from flax import nnx
28
+ from jaxtyping import Array, Float, Int
29
+
30
+ __all__ = [
31
+ "GraphAttentionLayer",
32
+ "GraphAttentionBlock",
33
+ "GATv2Layer",
34
+ "GATv2Block",
35
+ ]
36
+
37
+
38
+ class _BidirectionalProjection(nnx.Module):
39
+ """Pair of linear projections used by attention mechanisms."""
40
+
41
+ def __init__(
42
+ self,
43
+ in_features: int,
44
+ out_features: int,
45
+ *,
46
+ rngs: nnx.Rngs,
47
+ negative_slope: float | None = None,
48
+ ) -> None:
49
+ """Initialize the paired projection module."""
50
+ super().__init__()
51
+ self.left = nnx.Linear(in_features=in_features, out_features=out_features, rngs=rngs)
52
+ self.right = nnx.Linear(in_features=in_features, out_features=out_features, rngs=rngs)
53
+ self.negative_slope = negative_slope
54
+
55
+ def __call__(
56
+ self,
57
+ node_features: Float[Array, "n_nodes in_features"],
58
+ ) -> tuple[
59
+ Float[Array, "n_nodes out_features"],
60
+ Float[Array, "n_nodes out_features"],
61
+ ]:
62
+ """Project the same node tensor through both linear paths."""
63
+ return self.left(node_features), self.right(node_features)
64
+
65
+
66
+ class GraphAttentionLayer(nnx.Module):
67
+ """Multi-head graph attention layer for message passing.
68
+
69
+ Computes attention-weighted message aggregation over graph edges.
70
+ Each attention head independently computes query/key/value projections,
71
+ adds an edge-feature bias to attention scores, normalizes via
72
+ segment-softmax, and aggregates weighted values per target node.
73
+
74
+ Args:
75
+ in_features: Input node feature dimension.
76
+ out_features: Output feature dimension (must be divisible by num_heads).
77
+ num_heads: Number of parallel attention heads.
78
+ edge_features: Edge feature dimension.
79
+ dropout_rate: Dropout rate applied to attention weights.
80
+ rngs: Flax NNX random number generators.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ in_features: int,
86
+ out_features: int,
87
+ num_heads: int,
88
+ edge_features: int,
89
+ dropout_rate: float,
90
+ *,
91
+ rngs: nnx.Rngs,
92
+ ) -> None:
93
+ """Initialize the graph attention layer.
94
+
95
+ Args:
96
+ in_features: Input feature dimension.
97
+ out_features: Output feature dimension.
98
+ num_heads: Number of attention heads.
99
+ edge_features: Edge feature dimension.
100
+ dropout_rate: Dropout rate.
101
+ rngs: Random number generators.
102
+ """
103
+ super().__init__()
104
+ if num_heads <= 0:
105
+ raise ValueError("num_heads must be positive")
106
+ if out_features <= 0:
107
+ raise ValueError("out_features must be positive")
108
+ if out_features % num_heads != 0:
109
+ raise ValueError("out_features must be divisible by num_heads")
110
+ if edge_features <= 0:
111
+ raise ValueError("edge_features must be positive")
112
+ if not 0.0 <= dropout_rate < 1.0:
113
+ raise ValueError("dropout_rate must be in [0, 1)")
114
+
115
+ # Node feature projections
116
+ self.query_proj = nnx.Linear(
117
+ in_features=in_features,
118
+ out_features=out_features,
119
+ rngs=rngs,
120
+ )
121
+ self.key_proj = nnx.Linear(
122
+ in_features=in_features,
123
+ out_features=out_features,
124
+ rngs=rngs,
125
+ )
126
+ self.value_proj = nnx.Linear(
127
+ in_features=in_features,
128
+ out_features=out_features,
129
+ rngs=rngs,
130
+ )
131
+
132
+ # Edge feature projection
133
+ self.edge_proj = nnx.Linear(
134
+ in_features=edge_features,
135
+ out_features=num_heads,
136
+ rngs=rngs,
137
+ )
138
+
139
+ # Output projection
140
+ self.output_proj = nnx.Linear(
141
+ in_features=out_features,
142
+ out_features=out_features,
143
+ rngs=rngs,
144
+ )
145
+
146
+ self.dropout = nnx.Dropout(rate=dropout_rate, rngs=rngs) if dropout_rate > 0 else None
147
+
148
+ @property
149
+ def num_heads(self) -> int:
150
+ """Number of attention heads."""
151
+ return self.edge_proj.out_features
152
+
153
+ @property
154
+ def head_dim(self) -> int:
155
+ """Per-head hidden dimension."""
156
+ return self.query_proj.out_features // self.num_heads
157
+
158
+ @property
159
+ def scale(self) -> float:
160
+ """Dot-product attention scaling factor."""
161
+ return self.head_dim**-0.5
162
+
163
+ def __call__(
164
+ self,
165
+ node_features: Float[Array, "n_nodes in_features"],
166
+ edge_index: Int[Array, "2 n_edges"],
167
+ edge_features: Float[Array, "n_edges edge_features"],
168
+ *,
169
+ deterministic: bool = True,
170
+ ) -> Float[Array, "n_nodes out_features"]:
171
+ """Run one graph-attention update step.
172
+
173
+ Args:
174
+ node_features: Node feature matrix of shape ``(n_nodes, in_features)``.
175
+ edge_index: Edge indices ``(source, target)`` of shape ``(2, n_edges)``.
176
+ edge_features: Edge feature matrix of shape ``(n_edges, edge_features)``.
177
+ deterministic: Whether to disable stochastic dropout.
178
+
179
+ Returns:
180
+ Updated node features of shape ``(n_nodes, out_features)``.
181
+ """
182
+ n_nodes = node_features.shape[0]
183
+ n_edges = edge_index.shape[1]
184
+ out_features = self.num_heads * self.head_dim
185
+
186
+ # Handle empty graph: no edges means no messages to aggregate
187
+ if n_edges == 0:
188
+ return self.output_proj(jnp.zeros((n_nodes, out_features)))
189
+
190
+ sources = edge_index[0] # (n_edges,)
191
+ targets = edge_index[1] # (n_edges,)
192
+
193
+ # Project all nodes
194
+ queries = self.query_proj(node_features) # (n_nodes, out_features)
195
+ keys = self.key_proj(node_features)
196
+ values = self.value_proj(node_features)
197
+
198
+ # Reshape for multi-head attention
199
+ queries = queries.reshape(n_nodes, self.num_heads, self.head_dim)
200
+ keys = keys.reshape(n_nodes, self.num_heads, self.head_dim)
201
+ values = values.reshape(n_nodes, self.num_heads, self.head_dim)
202
+
203
+ # Get source and target features for each edge
204
+ query_targets = queries[targets] # (n_edges, num_heads, head_dim)
205
+ key_sources = keys[sources] # (n_edges, num_heads, head_dim)
206
+ value_sources = values[sources] # (n_edges, num_heads, head_dim)
207
+
208
+ # Compute attention scores
209
+ attn_scores = (
210
+ jnp.sum(query_targets * key_sources, axis=-1) * self.scale
211
+ ) # (n_edges, num_heads)
212
+
213
+ # Add edge feature bias
214
+ edge_bias = self.edge_proj(edge_features) # (n_edges, num_heads)
215
+ attn_scores = attn_scores + edge_bias
216
+
217
+ # Normalize attention per target node using segment_max/segment_sum
218
+ # This is equivalent to softmax over incoming edges per node
219
+ max_scores = jax.ops.segment_max(
220
+ attn_scores, targets, num_segments=n_nodes
221
+ ) # (n_nodes, num_heads)
222
+ attn_scores = attn_scores - max_scores[targets] # Stability
223
+ attn_exp = jnp.exp(attn_scores)
224
+
225
+ # Sum of exp scores per target node
226
+ attn_sum = (
227
+ jax.ops.segment_sum(attn_exp, targets, num_segments=n_nodes) + 1e-10
228
+ ) # (n_nodes, num_heads)
229
+
230
+ # Normalize
231
+ attn_probs = attn_exp / attn_sum[targets] # (n_edges, num_heads)
232
+
233
+ # Apply dropout
234
+ if self.dropout is not None and not deterministic:
235
+ attn_probs = self.dropout(attn_probs)
236
+
237
+ # Weighted sum of values
238
+ weighted_values = attn_probs[:, :, None] * value_sources # (n_edges, num_heads, head_dim)
239
+
240
+ # Aggregate to target nodes
241
+ aggregated = jax.ops.segment_sum(
242
+ weighted_values.reshape(n_edges, -1), targets, num_segments=n_nodes
243
+ ) # (n_nodes, out_features)
244
+
245
+ # Output projection
246
+ return self.output_proj(aggregated)
247
+
248
+
249
+ class GraphAttentionBlock(nnx.Module):
250
+ """Full GNN block: graph attention + LayerNorm + residual + feedforward.
251
+
252
+ Combines a :class:`GraphAttentionLayer` with pre-norm residual connections
253
+ and a two-layer feedforward network (4x expansion), following the
254
+ standard Transformer block pattern adapted for graphs.
255
+
256
+ Architecture::
257
+
258
+ x -> GraphAttentionLayer -> + -> LayerNorm -> FFN -> + -> LayerNorm -> out
259
+ |___________________________| |_________|
260
+
261
+ Args:
262
+ hidden_dim: Hidden dimension (both input and output).
263
+ num_heads: Number of attention heads.
264
+ edge_features: Edge feature dimension.
265
+ dropout_rate: Dropout rate.
266
+ rngs: Flax NNX random number generators.
267
+ """
268
+
269
+ def __init__(
270
+ self,
271
+ hidden_dim: int,
272
+ num_heads: int,
273
+ edge_features: int,
274
+ dropout_rate: float,
275
+ *,
276
+ rngs: nnx.Rngs,
277
+ ) -> None:
278
+ """Initialize the GNN block.
279
+
280
+ Args:
281
+ hidden_dim: Hidden dimension.
282
+ num_heads: Number of attention heads.
283
+ edge_features: Edge feature dimension.
284
+ dropout_rate: Dropout rate.
285
+ rngs: Random number generators.
286
+ """
287
+ super().__init__()
288
+
289
+ self.attention = GraphAttentionLayer(
290
+ in_features=hidden_dim,
291
+ out_features=hidden_dim,
292
+ num_heads=num_heads,
293
+ edge_features=edge_features,
294
+ dropout_rate=dropout_rate,
295
+ rngs=rngs,
296
+ )
297
+
298
+ self.layer_norm1 = nnx.LayerNorm(num_features=hidden_dim, rngs=rngs)
299
+ self.layer_norm2 = nnx.LayerNorm(num_features=hidden_dim, rngs=rngs)
300
+
301
+ # Feedforward with 4x expansion
302
+ self.ff_linear1 = nnx.Linear(
303
+ in_features=hidden_dim,
304
+ out_features=hidden_dim * 4,
305
+ rngs=rngs,
306
+ )
307
+ self.ff_linear2 = nnx.Linear(
308
+ in_features=hidden_dim * 4,
309
+ out_features=hidden_dim,
310
+ rngs=rngs,
311
+ )
312
+
313
+ def __call__(
314
+ self,
315
+ node_features: Float[Array, "n_nodes hidden_dim"],
316
+ edge_index: Int[Array, "2 n_edges"],
317
+ edge_features: Float[Array, "n_edges edge_features"],
318
+ *,
319
+ deterministic: bool = True,
320
+ ) -> Float[Array, "n_nodes hidden_dim"]:
321
+ """Apply the GNN block.
322
+
323
+ Args:
324
+ node_features: Node features of shape ``(n_nodes, hidden_dim)``.
325
+ edge_index: Edge indices of shape ``(2, n_edges)``.
326
+ edge_features: Edge features of shape ``(n_edges, edge_features)``.
327
+ deterministic: If True, disable dropout.
328
+
329
+ Returns:
330
+ Updated node features of shape ``(n_nodes, hidden_dim)``.
331
+ """
332
+ # Attention with residual
333
+ attended = self.attention(
334
+ node_features, edge_index, edge_features, deterministic=deterministic
335
+ )
336
+ x = self.layer_norm1(node_features + attended)
337
+
338
+ # Feedforward with residual
339
+ ff_out = self.ff_linear2(nnx.gelu(self.ff_linear1(x)))
340
+ x = self.layer_norm2(x + ff_out)
341
+
342
+ return x
343
+
344
+
345
+ class GATv2Layer(nnx.Module):
346
+ """GATv2 multi-head graph attention layer.
347
+
348
+ Unlike the original GAT (``GraphAttentionLayer``), GATv2 applies LeakyReLU
349
+ *before* computing the attention scalar, which makes the attention function
350
+ strictly more expressive (it can represent any monotonic scoring function
351
+ over concatenated source/target features).
352
+
353
+ GATv2 attention::
354
+
355
+ e_{ij} = a^T * LeakyReLU(W_l * h_i + W_r * h_j + edge_bias)
356
+
357
+ This is the key difference from GAT, where the nonlinearity is applied
358
+ *after* the attention dot product.
359
+
360
+ Reference: Brody, Alon, Yahav. "How Attentive are Graph Attention
361
+ Networks?" (ICLR 2022).
362
+
363
+ Args:
364
+ in_features: Input node feature dimension.
365
+ out_features: Output feature dimension (must be divisible by num_heads).
366
+ num_heads: Number of parallel attention heads.
367
+ edge_features: Edge feature dimension.
368
+ dropout_rate: Dropout rate applied to attention weights.
369
+ negative_slope: Negative slope for LeakyReLU (default 0.2).
370
+ rngs: Flax NNX random number generators.
371
+ """
372
+
373
+ def __init__(
374
+ self,
375
+ in_features: int,
376
+ out_features: int,
377
+ num_heads: int,
378
+ edge_features: int,
379
+ dropout_rate: float,
380
+ negative_slope: float = 0.2,
381
+ *,
382
+ rngs: nnx.Rngs,
383
+ ) -> None:
384
+ """Initialize the GATv2 layer.
385
+
386
+ Args:
387
+ in_features: Input feature dimension.
388
+ out_features: Output feature dimension.
389
+ num_heads: Number of attention heads.
390
+ edge_features: Edge feature dimension.
391
+ dropout_rate: Dropout rate.
392
+ negative_slope: Negative slope for LeakyReLU.
393
+ rngs: Random number generators.
394
+ """
395
+ super().__init__()
396
+ if num_heads <= 0:
397
+ raise ValueError("num_heads must be positive")
398
+ if out_features <= 0:
399
+ raise ValueError("out_features must be positive")
400
+ if out_features % num_heads != 0:
401
+ raise ValueError("out_features must be divisible by num_heads")
402
+ if edge_features <= 0:
403
+ raise ValueError("edge_features must be positive")
404
+ if negative_slope < 0.0:
405
+ raise ValueError("negative_slope must be non-negative")
406
+ if not 0.0 <= dropout_rate < 1.0:
407
+ raise ValueError("dropout_rate must be in [0, 1)")
408
+
409
+ # GATv2 uses separate left/right projections (not Q/K/V)
410
+ self.attn_projections = _BidirectionalProjection(
411
+ in_features=in_features,
412
+ out_features=out_features,
413
+ rngs=rngs,
414
+ negative_slope=negative_slope,
415
+ )
416
+
417
+ # Edge feature projection to per-head bias
418
+ self.edge_proj = nnx.Linear(
419
+ in_features=edge_features,
420
+ out_features=num_heads,
421
+ rngs=rngs,
422
+ )
423
+
424
+ # Per-head attention vector a^T (applied after LeakyReLU)
425
+ self.attn_vector = nnx.Param(
426
+ jax.random.normal(rngs.params(), (num_heads, self.head_dim)) * 0.01
427
+ )
428
+
429
+ # Value projection and output projection
430
+ self.value_proj = nnx.Linear(
431
+ in_features=in_features,
432
+ out_features=out_features,
433
+ rngs=rngs,
434
+ )
435
+ self.output_proj = nnx.Linear(
436
+ in_features=out_features,
437
+ out_features=out_features,
438
+ rngs=rngs,
439
+ )
440
+
441
+ self.dropout = nnx.Dropout(rate=dropout_rate, rngs=rngs) if dropout_rate > 0 else None
442
+
443
+ @property
444
+ def num_heads(self) -> int:
445
+ """Number of attention heads."""
446
+ return self.edge_proj.out_features
447
+
448
+ @property
449
+ def head_dim(self) -> int:
450
+ """Per-head hidden dimension."""
451
+ return self.attn_projections.left.out_features // self.num_heads
452
+
453
+ @property
454
+ def negative_slope(self) -> float:
455
+ """Negative slope used by the LeakyReLU attention scorer."""
456
+ if self.attn_projections.negative_slope is None:
457
+ raise ValueError("negative_slope is not configured")
458
+ return self.attn_projections.negative_slope
459
+
460
+ def __call__(
461
+ self,
462
+ node_features: Float[Array, "n_nodes in_features"],
463
+ edge_index: Int[Array, "2 n_edges"],
464
+ edge_features: Float[Array, "n_edges edge_features"],
465
+ *,
466
+ deterministic: bool = True,
467
+ ) -> Float[Array, "n_nodes out_features"]:
468
+ """Run one GATv2 attention update step.
469
+
470
+ Args:
471
+ node_features: Node feature matrix ``(n_nodes, in_features)``.
472
+ edge_index: Edge indices ``(source, target)`` of shape ``(2, n_edges)``.
473
+ edge_features: Edge feature matrix ``(n_edges, edge_features)``.
474
+ deterministic: Whether to disable stochastic dropout.
475
+
476
+ Returns:
477
+ Updated node features of shape ``(n_nodes, out_features)``.
478
+ """
479
+ n_nodes = node_features.shape[0]
480
+ n_edges = edge_index.shape[1]
481
+ out_features = self.num_heads * self.head_dim
482
+
483
+ # Handle empty graph
484
+ if n_edges == 0:
485
+ return self.output_proj(jnp.zeros((n_nodes, out_features)))
486
+
487
+ sources = edge_index[0]
488
+ targets = edge_index[1]
489
+
490
+ # Left/right projections for all nodes
491
+ left, right = self.attn_projections(node_features)
492
+
493
+ # Reshape to per-head: (n_nodes, num_heads, head_dim)
494
+ left = left.reshape(n_nodes, self.num_heads, self.head_dim)
495
+ right = right.reshape(n_nodes, self.num_heads, self.head_dim)
496
+
497
+ # Gather per-edge: left for targets, right for sources
498
+ left_targets = left[targets] # (n_edges, num_heads, head_dim)
499
+ right_sources = right[sources]
500
+
501
+ # GATv2 key: LeakyReLU BEFORE the attention dot product
502
+ combined = left_targets + right_sources # (n_edges, num_heads, head_dim)
503
+ activated = jax.nn.leaky_relu(combined, negative_slope=self.negative_slope)
504
+
505
+ # Attention score: a^T * activated => (n_edges, num_heads)
506
+ attn_scores = jnp.sum(activated * self.attn_vector[...][None, :, :], axis=-1)
507
+
508
+ # Add edge feature bias
509
+ edge_bias = self.edge_proj(edge_features) # (n_edges, num_heads)
510
+ attn_scores = attn_scores + edge_bias
511
+
512
+ # Segment-softmax normalization over incoming edges per target node
513
+ max_scores = jax.ops.segment_max(attn_scores, targets, num_segments=n_nodes)
514
+ attn_scores = attn_scores - max_scores[targets]
515
+ attn_exp = jnp.exp(attn_scores)
516
+
517
+ attn_sum = jax.ops.segment_sum(attn_exp, targets, num_segments=n_nodes) + 1e-10
518
+ attn_probs = attn_exp / attn_sum[targets] # (n_edges, num_heads)
519
+
520
+ # Apply dropout
521
+ if self.dropout is not None and not deterministic:
522
+ attn_probs = self.dropout(attn_probs)
523
+
524
+ # Value aggregation
525
+ values = self.value_proj(node_features).reshape(n_nodes, self.num_heads, self.head_dim)
526
+ value_sources = values[sources] # (n_edges, num_heads, head_dim)
527
+ weighted_values = attn_probs[:, :, None] * value_sources
528
+
529
+ aggregated = jax.ops.segment_sum(
530
+ weighted_values.reshape(n_edges, -1), targets, num_segments=n_nodes
531
+ )
532
+
533
+ return self.output_proj(aggregated)
534
+
535
+
536
+ class GATv2Block(nnx.Module):
537
+ """Full GNN block using GATv2 attention + LayerNorm + residual + FFN.
538
+
539
+ Architecture::
540
+
541
+ x -> GATv2Layer -> + -> LayerNorm -> FFN -> + -> LayerNorm -> out
542
+ |__________________| |_________|
543
+
544
+ Args:
545
+ hidden_dim: Hidden dimension (both input and output).
546
+ num_heads: Number of attention heads.
547
+ edge_features: Edge feature dimension.
548
+ dropout_rate: Dropout rate.
549
+ negative_slope: Negative slope for LeakyReLU in GATv2 attention.
550
+ rngs: Flax NNX random number generators.
551
+ """
552
+
553
+ def __init__(
554
+ self,
555
+ hidden_dim: int,
556
+ num_heads: int,
557
+ edge_features: int,
558
+ dropout_rate: float,
559
+ negative_slope: float = 0.2,
560
+ *,
561
+ rngs: nnx.Rngs,
562
+ ) -> None:
563
+ """Initialize the GATv2 block.
564
+
565
+ Args:
566
+ hidden_dim: Hidden dimension.
567
+ num_heads: Number of attention heads.
568
+ edge_features: Edge feature dimension.
569
+ dropout_rate: Dropout rate.
570
+ negative_slope: Negative slope for LeakyReLU.
571
+ rngs: Random number generators.
572
+ """
573
+ super().__init__()
574
+
575
+ self.attention = GATv2Layer(
576
+ in_features=hidden_dim,
577
+ out_features=hidden_dim,
578
+ num_heads=num_heads,
579
+ edge_features=edge_features,
580
+ dropout_rate=dropout_rate,
581
+ negative_slope=negative_slope,
582
+ rngs=rngs,
583
+ )
584
+
585
+ self.layer_norm1 = nnx.LayerNorm(num_features=hidden_dim, rngs=rngs)
586
+ self.layer_norm2 = nnx.LayerNorm(num_features=hidden_dim, rngs=rngs)
587
+
588
+ # Feedforward with 4x expansion
589
+ self.ff_linear1 = nnx.Linear(
590
+ in_features=hidden_dim,
591
+ out_features=hidden_dim * 4,
592
+ rngs=rngs,
593
+ )
594
+ self.ff_linear2 = nnx.Linear(
595
+ in_features=hidden_dim * 4,
596
+ out_features=hidden_dim,
597
+ rngs=rngs,
598
+ )
599
+
600
+ def __call__(
601
+ self,
602
+ node_features: Float[Array, "n_nodes hidden_dim"],
603
+ edge_index: Int[Array, "2 n_edges"],
604
+ edge_features: Float[Array, "n_edges edge_features"],
605
+ *,
606
+ deterministic: bool = True,
607
+ ) -> Float[Array, "n_nodes hidden_dim"]:
608
+ """Apply the GATv2 block.
609
+
610
+ Args:
611
+ node_features: Node features ``(n_nodes, hidden_dim)``.
612
+ edge_index: Edge indices ``(2, n_edges)``.
613
+ edge_features: Edge features ``(n_edges, edge_features)``.
614
+ deterministic: If True, disable dropout.
615
+
616
+ Returns:
617
+ Updated node features ``(n_nodes, hidden_dim)``.
618
+ """
619
+ # Attention with residual
620
+ attended = self.attention(
621
+ node_features, edge_index, edge_features, deterministic=deterministic
622
+ )
623
+ x = self.layer_norm1(node_features + attended)
624
+
625
+ # Feedforward with residual
626
+ ff_out = self.ff_linear2(nnx.gelu(self.ff_linear1(x)))
627
+ x = self.layer_norm2(x + ff_out)
628
+
629
+ return x