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,149 @@
1
+ """Graph utility functions for k-NN graph construction and similarity computation.
2
+
3
+ Provides reusable, differentiable functions for pairwise distance computation,
4
+ k-nearest-neighbor graph construction, fuzzy set membership, and graph
5
+ symmetrization. These primitives underpin UMAP, trajectory inference,
6
+ imputation, and other graph-based operators.
7
+ """
8
+
9
+ import jax
10
+ import jax.numpy as jnp
11
+
12
+ from diffbio.core import soft_ops
13
+
14
+ __all__ = [
15
+ "compute_pairwise_distances",
16
+ "compute_knn_graph",
17
+ "compute_fuzzy_membership",
18
+ "symmetrize_graph",
19
+ ]
20
+
21
+
22
+ def compute_pairwise_distances(
23
+ features: jax.Array,
24
+ metric: str = "euclidean",
25
+ ) -> jax.Array:
26
+ """Compute pairwise distance matrix between all samples.
27
+
28
+ Args:
29
+ features: Input feature matrix of shape ``(n_samples, n_features)``.
30
+ metric: Distance metric, either ``"euclidean"`` or ``"cosine"``.
31
+
32
+ Returns:
33
+ Distance matrix of shape ``(n_samples, n_samples)`` where entry
34
+ ``(i, j)`` is the distance from sample *i* to sample *j*.
35
+ """
36
+ if metric == "cosine":
37
+ norms = jnp.linalg.norm(features, axis=-1, keepdims=True)
38
+ features_norm = features / jnp.maximum(norms, jnp.finfo(features.dtype).eps)
39
+ similarity = jnp.dot(features_norm, features_norm.T)
40
+ distances = jnp.clip(1.0 - similarity, 0.0, 2.0)
41
+ else:
42
+ diff = features[:, None, :] - features[None, :, :]
43
+ distances = jnp.sqrt(jnp.sum(diff**2, axis=-1) + 1e-16)
44
+
45
+ # Self-distance is zero by definition; float32 matmul cannot guarantee this
46
+ n = features.shape[0]
47
+ distances = distances.at[jnp.diag_indices(n)].set(0.0)
48
+
49
+ return distances
50
+
51
+
52
+ def compute_knn_graph(
53
+ distances: jax.Array,
54
+ k: int,
55
+ ) -> tuple[jax.Array, jax.Array]:
56
+ """Build a k-nearest-neighbor graph from a dense distance matrix.
57
+
58
+ For each node the *k* closest neighbours (by distance) are selected.
59
+ Self-connections are assumed to already be masked out by setting the
60
+ diagonal to a large value before calling this function.
61
+
62
+ Args:
63
+ distances: Dense distance matrix of shape ``(n, n)``. The diagonal
64
+ should contain large sentinel values (e.g. ``DISTANCE_MASK_SENTINEL``) so that
65
+ self-loops are never selected.
66
+ k: Number of nearest neighbours per node. Clipped to ``n - 1``
67
+ when larger than the number of samples minus one.
68
+
69
+ Returns:
70
+ A tuple ``(edge_indices, edge_weights)`` where:
71
+
72
+ - ``edge_indices`` has shape ``(n * k_eff, 2)`` with each row
73
+ ``[source, target]``.
74
+ - ``edge_weights`` has shape ``(n * k_eff,)`` containing the
75
+ corresponding distances.
76
+
77
+ ``k_eff = min(k, n - 1)``.
78
+ """
79
+ n = distances.shape[0]
80
+ k_eff = min(k, n - 1)
81
+
82
+ # Argsort each row; first k_eff entries are the nearest neighbours
83
+ sorted_indices = jnp.argsort(distances, axis=-1)
84
+ knn_indices = sorted_indices[:, :k_eff] # (n, k_eff)
85
+
86
+ # Source indices: each node repeated k_eff times
87
+ sources = jnp.repeat(jnp.arange(n), k_eff) # (n * k_eff,)
88
+ targets = knn_indices.reshape(-1) # (n * k_eff,)
89
+
90
+ edge_indices = jnp.stack([sources, targets], axis=-1) # (n * k_eff, 2)
91
+ edge_weights = distances[sources, targets] # (n * k_eff,)
92
+
93
+ return edge_indices, edge_weights
94
+
95
+
96
+ def compute_fuzzy_membership(
97
+ distances: jax.Array,
98
+ k: int,
99
+ softness: float = 0.1,
100
+ ) -> jax.Array:
101
+ """Compute fuzzy set membership using a Gaussian kernel with local bandwidth.
102
+
103
+ The bandwidth (sigma) for each sample is set to the distance to its *k*-th
104
+ nearest neighbour, making the kernel adapt to local density. The diagonal
105
+ of the output is forced to zero (no self-similarity).
106
+
107
+ Args:
108
+ distances: Dense distance matrix of shape ``(n, n)``. The diagonal
109
+ should contain large sentinel values so that self-distances are
110
+ excluded from the bandwidth computation.
111
+ k: Number of neighbours used to determine the local bandwidth.
112
+ Clipped to ``n - 1`` when larger.
113
+
114
+ Returns:
115
+ Fuzzy membership matrix of shape ``(n, n)`` with values in ``[0, 1]``.
116
+ """
117
+ n = distances.shape[0]
118
+ k_eff = min(k, n - 1)
119
+
120
+ # Local bandwidth: distance to the k-th nearest neighbour
121
+ sorted_dists = soft_ops.sort(distances, axis=-1, softness=softness)
122
+ sigma = sorted_dists[:, k_eff - 1 : k_eff].squeeze(-1)
123
+ sigma = jnp.maximum(sigma, 1e-8)
124
+
125
+ # Gaussian kernel with local bandwidth
126
+ p_ij = jnp.exp(-distances / sigma[:, None])
127
+
128
+ # Zero out self-similarity
129
+ p_ij = p_ij * (1.0 - jnp.eye(n))
130
+
131
+ return p_ij
132
+
133
+
134
+ def symmetrize_graph(adjacency: jax.Array) -> jax.Array:
135
+ """Symmetrize a directed adjacency matrix via fuzzy set union.
136
+
137
+ Applies the probabilistic (fuzzy) union:
138
+ ``p_sym = p + p^T - p * p^T``
139
+
140
+ This ensures the output is symmetric and, when inputs are in ``[0, 1]``,
141
+ the outputs remain in ``[0, 1]``.
142
+
143
+ Args:
144
+ adjacency: Directed adjacency / membership matrix of shape ``(n, n)``.
145
+
146
+ Returns:
147
+ Symmetric adjacency matrix of shape ``(n, n)``.
148
+ """
149
+ return adjacency + adjacency.T - adjacency * adjacency.T
@@ -0,0 +1,270 @@
1
+ """Reusable neural network components for DiffBio.
2
+
3
+ This module provides neural network building blocks that are specific to
4
+ bioinformatics applications and not available in Flax NNX built-ins.
5
+
6
+ IMPORTANT: For standard components, use Flax NNX built-ins:
7
+ - nnx.MultiHeadAttention for attention
8
+ - nnx.Linear for dense layers
9
+ - nnx.Conv for convolutions
10
+ - nnx.LayerNorm for normalization
11
+ - nnx.Dropout for dropout
12
+ - nnx.Sequential for layer composition
13
+
14
+ For reusable components, import from artifex:
15
+ - PositionalEncoding (sinusoidal, learned, RoPE)
16
+ - ResidualBlock1D, ResidualBlock2D
17
+ - TransformerBlock
18
+ - Various loss functions
19
+
20
+ This module only provides DiffBio-specific components:
21
+ - GumbelSoftmaxModule: Differentiable discrete sampling
22
+ - GraphMessagePassing: GNN message passing for graph-structured data
23
+ """
24
+
25
+ from typing import Literal
26
+
27
+ import jax
28
+ import jax.numpy as jnp
29
+ from artifex.generative_models.core.base import MLP
30
+ from flax import nnx
31
+ from jaxtyping import Array, Float, Int
32
+
33
+ from diffbio.constants import DEFAULT_TEMPERATURE, EPSILON
34
+ from diffbio.utils.nn_utils import get_rng_key
35
+
36
+ # =============================================================================
37
+ # Re-export from artifex (import when available, provide stubs otherwise)
38
+ # =============================================================================
39
+
40
+ from artifex.generative_models.core.layers.positional import (
41
+ PositionalEncoding,
42
+ RotaryPositionalEncoding as RoPE,
43
+ SinusoidalPositionalEncoding,
44
+ )
45
+ from artifex.generative_models.core.layers.residual import (
46
+ Conv1DResidualBlock as ResidualBlock1D,
47
+ Conv2DResidualBlock as ResidualBlock2D,
48
+ )
49
+
50
+
51
+ __all__ = [
52
+ # DiffBio-specific components
53
+ "GumbelSoftmaxModule",
54
+ "GraphMessagePassing",
55
+ # Re-exported from artifex
56
+ "PositionalEncoding",
57
+ "SinusoidalPositionalEncoding",
58
+ "RoPE",
59
+ "ResidualBlock1D",
60
+ "ResidualBlock2D",
61
+ ]
62
+
63
+
64
+ class GumbelSoftmaxModule(nnx.Module):
65
+ """Neural network module for Gumbel-softmax sampling.
66
+
67
+ This module wraps the gumbel_softmax function as an nnx.Module,
68
+ providing differentiable categorical sampling during forward pass.
69
+
70
+ Useful for:
71
+
72
+ - Discrete latent variable models (VQ-VAE variants)
73
+ - Hard attention mechanisms
74
+ - Discrete sequence generation
75
+
76
+ Args:
77
+ temperature: Initial temperature for sampling.
78
+ hard: If True, use straight-through estimator for discrete samples.
79
+ rngs: Flax NNX random number generators.
80
+
81
+ Example:
82
+ ```python
83
+ module = GumbelSoftmaxModule(temperature=0.5, rngs=nnx.Rngs(42))
84
+ logits = jnp.array([[1.0, 2.0, 3.0]])
85
+ samples = module(logits)
86
+ ```
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ temperature: float = DEFAULT_TEMPERATURE,
92
+ hard: bool = False,
93
+ *,
94
+ rngs: nnx.Rngs,
95
+ ):
96
+ """Initialize GumbelSoftmaxModule.
97
+
98
+ Args:
99
+ temperature: Temperature for Gumbel-softmax.
100
+ hard: Whether to use hard (one-hot) samples.
101
+ rngs: Random number generators.
102
+ """
103
+ super().__init__()
104
+ self.temperature = temperature
105
+ self.hard = hard
106
+ self.rngs = rngs
107
+
108
+ def __call__(self, logits: Float[Array, "... n"]) -> Float[Array, "... n"]:
109
+ """Apply Gumbel-softmax sampling.
110
+
111
+ Args:
112
+ logits: Unnormalized log-probabilities of shape (..., n).
113
+
114
+ Returns:
115
+ Samples of same shape as logits.
116
+ """
117
+ key = get_rng_key(self.rngs, "dropout", fallback_seed=0)
118
+ gumbel_noise = jax.random.gumbel(key, logits.shape)
119
+ perturbed = (logits + gumbel_noise) / self.temperature
120
+ soft_sample = jax.nn.softmax(perturbed, axis=-1)
121
+ if self.hard:
122
+ hard_sample = jax.nn.one_hot(
123
+ jnp.argmax(soft_sample, axis=-1),
124
+ logits.shape[-1],
125
+ )
126
+ return hard_sample - jax.lax.stop_gradient(soft_sample) + soft_sample
127
+ return soft_sample
128
+
129
+
130
+ class GraphMessagePassing(nnx.Module):
131
+ """Graph neural network message passing layer.
132
+
133
+ Implements a standard message passing scheme:
134
+ 1. Compute messages from source nodes and edge features
135
+ 2. Aggregate messages at destination nodes
136
+ 3. Update node features with aggregated messages
137
+
138
+ Supports different aggregation functions (sum, mean, max).
139
+
140
+ Args:
141
+ node_features: Input node feature dimension.
142
+ edge_features: Edge feature dimension.
143
+ hidden_dim: Output hidden dimension.
144
+ aggregation: Aggregation function ("sum", "mean", "max").
145
+ rngs: Flax NNX random number generators.
146
+
147
+ Example:
148
+ ```python
149
+ layer = GraphMessagePassing(
150
+ node_features=32, edge_features=8, hidden_dim=64,
151
+ rngs=nnx.Rngs(42)
152
+ )
153
+ node_feat = jnp.ones((5, 32)) # 5 nodes
154
+ edge_feat = jnp.ones((8, 8)) # 8 edges
155
+ edge_index = jnp.array([[0,0,1,1,2,2,3,4], [1,2,2,3,3,4,4,0]])
156
+ output = layer(node_feat, edge_feat, edge_index)
157
+ ```
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ node_features: int,
163
+ edge_features: int,
164
+ hidden_dim: int,
165
+ aggregation: Literal["sum", "mean", "max"] = "sum",
166
+ *,
167
+ rngs: nnx.Rngs,
168
+ ):
169
+ """Initialize GraphMessagePassing layer.
170
+
171
+ Args:
172
+ node_features: Input node feature dimension.
173
+ edge_features: Edge feature dimension.
174
+ hidden_dim: Output dimension.
175
+ aggregation: Aggregation method.
176
+ rngs: Random number generators.
177
+ """
178
+ super().__init__()
179
+ if node_features <= 0:
180
+ raise ValueError(f"node_features must be positive, got {node_features}")
181
+ if edge_features <= 0:
182
+ raise ValueError(f"edge_features must be positive, got {edge_features}")
183
+ if hidden_dim <= 0:
184
+ raise ValueError(f"hidden_dim must be positive, got {hidden_dim}")
185
+ if aggregation not in {"sum", "mean", "max"}:
186
+ raise ValueError(f"Unknown aggregation: {aggregation}")
187
+
188
+ self.hidden_dim = nnx.static(hidden_dim)
189
+ self.aggregation = nnx.static(aggregation)
190
+ self.message_mlp = MLP(
191
+ [hidden_dim, hidden_dim],
192
+ in_features=node_features + edge_features,
193
+ activation=nnx.relu,
194
+ rngs=rngs,
195
+ )
196
+ self.update_mlp = MLP(
197
+ [hidden_dim, hidden_dim],
198
+ in_features=node_features + hidden_dim,
199
+ activation=nnx.relu,
200
+ rngs=rngs,
201
+ )
202
+
203
+ def __call__(
204
+ self,
205
+ node_features: Float[Array, "num_nodes node_feat"],
206
+ edge_features: Float[Array, "num_edges edge_feat"],
207
+ edge_index: Int[Array, "2 num_edges"],
208
+ ) -> Float[Array, "num_nodes hidden_dim"]:
209
+ """Apply message passing.
210
+
211
+ Args:
212
+ node_features: Node feature matrix (num_nodes, node_features).
213
+ edge_features: Edge feature matrix (num_edges, edge_features).
214
+ edge_index: Edge indices [source, dest] of shape (2, num_edges).
215
+
216
+ Returns:
217
+ Updated node features (num_nodes, hidden_dim).
218
+ """
219
+ num_nodes = node_features.shape[0]
220
+ num_edges = edge_index.shape[1]
221
+
222
+ # Handle empty graph case
223
+ if num_edges == 0:
224
+ # No messages, just transform node features
225
+ update_output = self.update_mlp(
226
+ jnp.concatenate([node_features, jnp.zeros((num_nodes, self.hidden_dim))], axis=-1)
227
+ )
228
+ if isinstance(update_output, tuple):
229
+ return update_output[0]
230
+ return update_output
231
+
232
+ # Extract source and destination node indices
233
+ source_idx = edge_index[0]
234
+ dest_idx = edge_index[1]
235
+
236
+ # Get source node features for each edge
237
+ source_features = node_features[source_idx] # (num_edges, node_feat)
238
+
239
+ # Compute messages: MLP(concat(source_features, edge_features))
240
+ message_input = jnp.concatenate([source_features, edge_features], axis=-1)
241
+ messages = self.message_mlp(message_input)
242
+ if isinstance(messages, tuple):
243
+ messages = messages[0]
244
+
245
+ # Aggregate messages at destination nodes
246
+ if self.aggregation == "sum":
247
+ aggregated = jax.ops.segment_sum(messages, dest_idx, num_segments=num_nodes)
248
+ elif self.aggregation == "mean":
249
+ sum_messages = jax.ops.segment_sum(messages, dest_idx, num_segments=num_nodes)
250
+ counts = jax.ops.segment_sum(jnp.ones(num_edges), dest_idx, num_segments=num_nodes)
251
+ aggregated = sum_messages / (counts[:, None] + EPSILON)
252
+ elif self.aggregation == "max":
253
+ # segment_max with default of -inf for empty segments
254
+ aggregated = jax.ops.segment_max(
255
+ messages,
256
+ dest_idx,
257
+ num_segments=num_nodes,
258
+ indices_are_sorted=False,
259
+ )
260
+ # Replace -inf with 0 for nodes with no incoming edges
261
+ aggregated = jnp.where(jnp.isinf(aggregated), jnp.zeros_like(aggregated), aggregated)
262
+ else:
263
+ raise ValueError(f"Unknown aggregation: {self.aggregation}")
264
+
265
+ # Update node features: MLP(concat(node_features, aggregated))
266
+ update_input = jnp.concatenate([node_features, aggregated], axis=-1)
267
+ updated = self.update_mlp(update_input)
268
+ if isinstance(updated, tuple):
269
+ return updated[0]
270
+ return updated
@@ -0,0 +1,133 @@
1
+ """Optimal transport layers for differentiable assignment and matching.
2
+
3
+ Ownership note: DiffBio retains this transport-plan layer because downstream
4
+ operators need a differentiable marginal-constrained plan module. Calibrax
5
+ currently exposes scalar Sinkhorn-style metrics, not this operator contract.
6
+
7
+ This module provides differentiable optimal transport solvers using the
8
+ Sinkhorn algorithm in log-domain for numerical stability.
9
+
10
+ Components:
11
+
12
+ - **SinkhornLayer**: Computes the entropy-regularised optimal transport plan
13
+ between two discrete distributions given a cost matrix, using the
14
+ Sinkhorn-Knopp algorithm in log-domain.
15
+ """
16
+
17
+ import jax
18
+ import jax.numpy as jnp
19
+ from flax import nnx
20
+ from jaxtyping import Array, Float
21
+
22
+ __all__ = [
23
+ "SinkhornLayer",
24
+ ]
25
+
26
+
27
+ class SinkhornLayer(nnx.Module):
28
+ """Sinkhorn optimal transport layer (log-domain).
29
+
30
+ Computes the entropy-regularised optimal transport plan between two
31
+ discrete marginal distributions ``a`` and ``b`` given a cost matrix ``C``,
32
+ by solving::
33
+
34
+ min_{P >= 0} <P, C> - epsilon * H(P)
35
+ s.t. P @ 1 = a, P^T @ 1 = b
36
+
37
+ The algorithm runs in log-domain for numerical stability::
38
+
39
+ f, g = 0, 0
40
+ for _ in range(num_iters):
41
+ f = epsilon * log(a) - epsilon * logsumexp((-C + g) / epsilon, axis=1)
42
+ g = epsilon * log(b) - epsilon * logsumexp((-C + f) / epsilon, axis=0)
43
+ P = exp((f[:, None] + g[None, :] - C) / epsilon)
44
+
45
+ Args:
46
+ epsilon: Entropy regularisation strength (larger = smoother plan).
47
+ num_iters: Number of Sinkhorn iterations.
48
+ rngs: Flax NNX random number generators (unused, kept for API consistency).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ epsilon: float,
54
+ num_iters: int,
55
+ *,
56
+ rngs: nnx.Rngs,
57
+ ) -> None:
58
+ """Initialize the Sinkhorn layer.
59
+
60
+ Args:
61
+ epsilon: Regularisation strength.
62
+ num_iters: Number of Sinkhorn iterations.
63
+ rngs: Random number generators (for API consistency).
64
+ """
65
+ super().__init__()
66
+ self.epsilon = nnx.static(epsilon)
67
+ self.num_iters = nnx.static(num_iters)
68
+
69
+ def __call__(
70
+ self,
71
+ cost: Float[Array, "n m"],
72
+ a: Float[Array, " n"],
73
+ b: Float[Array, " m"],
74
+ ) -> Float[Array, "n m"]:
75
+ """Compute the optimal transport plan.
76
+
77
+ Args:
78
+ cost: Cost matrix of shape ``(n, m)``.
79
+ a: Source marginal distribution of shape ``(n,)``, must sum to 1.
80
+ b: Target marginal distribution of shape ``(m,)``, must sum to 1.
81
+
82
+ Returns:
83
+ Transport plan of shape ``(n, m)`` satisfying (approximately)
84
+ ``P @ 1 = a`` and ``P^T @ 1 = b``.
85
+ """
86
+ return _sinkhorn_log_domain(cost, a, b, self.epsilon, self.num_iters)
87
+
88
+
89
+ def _sinkhorn_log_domain(
90
+ cost: Float[Array, "n m"],
91
+ a: Float[Array, " n"],
92
+ b: Float[Array, " m"],
93
+ epsilon: float,
94
+ num_iters: int,
95
+ ) -> Float[Array, "n m"]:
96
+ """Run the Sinkhorn algorithm in log-domain.
97
+
98
+ Args:
99
+ cost: Cost matrix ``(n, m)``.
100
+ a: Source marginal ``(n,)``.
101
+ b: Target marginal ``(m,)``.
102
+ epsilon: Regularisation parameter.
103
+ num_iters: Number of iterations.
104
+
105
+ Returns:
106
+ Transport plan ``(n, m)``.
107
+ """
108
+ log_a = jnp.log(a + 1e-30)
109
+ log_b = jnp.log(b + 1e-30)
110
+
111
+ f = jnp.zeros_like(a)
112
+ g = jnp.zeros_like(b)
113
+
114
+ def _step(
115
+ carry: tuple[Float[Array, " n"], Float[Array, " m"]], _: None
116
+ ) -> tuple[tuple[Float[Array, " n"], Float[Array, " m"]], None]:
117
+ """Perform one Sinkhorn iteration updating dual variables f and g."""
118
+ f_prev, g_prev = carry
119
+ # f update: epsilon * log(a) - epsilon * logsumexp((-C + g) / epsilon, axis=1)
120
+ f_new = epsilon * log_a - epsilon * jax.scipy.special.logsumexp(
121
+ (-cost + g_prev[None, :]) / epsilon, axis=1
122
+ )
123
+ # g update: epsilon * log(b) - epsilon * logsumexp((-C + f) / epsilon, axis=0)
124
+ g_new = epsilon * log_b - epsilon * jax.scipy.special.logsumexp(
125
+ (-cost + f_new[:, None]) / epsilon, axis=0
126
+ )
127
+ return (f_new, g_new), None
128
+
129
+ (f, g), _ = jax.lax.scan(_step, (f, g), None, length=num_iters)
130
+
131
+ # Recover the transport plan
132
+ log_plan = (f[:, None] + g[None, :] - cost) / epsilon
133
+ return jnp.exp(log_plan)