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,222 @@
1
+ """Sequence embedding operators.
2
+
3
+ This module provides operators for converting one-hot encoded
4
+ DNA sequences into dense embeddings using convolutional networks.
5
+
6
+ Key technique: Use 1D convolutions to extract local sequence features,
7
+ then aggregate into a fixed-size representation.
8
+ """
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import jax
15
+ import jax.numpy as jnp
16
+ from datarax.core.config import OperatorConfig
17
+ from datarax.core.operator import OperatorModule
18
+ from flax import nnx
19
+ from jaxtyping import Array, Float, PyTree
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SequenceEmbeddingConfig(OperatorConfig):
26
+ """Configuration for SequenceEmbedding.
27
+
28
+ Attributes:
29
+ embedding_dim: Dimension of output embedding.
30
+ method: Embedding method ("conv" for convolutional).
31
+ kernel_size: Convolution kernel size.
32
+ num_conv_layers: Number of convolutional layers.
33
+ """
34
+
35
+ embedding_dim: int = 64
36
+ method: str = "conv"
37
+ kernel_size: int = 7
38
+ num_conv_layers: int = 3
39
+
40
+
41
+ class SequenceEmbedding(OperatorModule):
42
+ """Convolutional sequence embedding operator.
43
+
44
+ This operator converts one-hot encoded DNA sequences into dense
45
+ embeddings using a stack of 1D convolutions followed by global
46
+ average pooling.
47
+
48
+ The architecture:
49
+ 1. Input: one-hot sequence (length, 4)
50
+ 2. 1D convolutions with ReLU activation
51
+ 3. Per-position features (length, embedding_dim)
52
+ 4. Global average pooling -> fixed embedding (embedding_dim,)
53
+
54
+ Args:
55
+ config: SequenceEmbeddingConfig with model parameters.
56
+ rngs: Flax NNX random number generators.
57
+ name: Optional operator name.
58
+
59
+ Example:
60
+ ```python
61
+ config = SequenceEmbeddingConfig(embedding_dim=64)
62
+ embedder = SequenceEmbedding(config, rngs=nnx.Rngs(42))
63
+ data = {"sequence": encoded_seq}
64
+ result, state, meta = embedder.apply(data, {}, None)
65
+ ```
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ config: SequenceEmbeddingConfig,
71
+ *,
72
+ rngs: nnx.Rngs | None = None,
73
+ name: str | None = None,
74
+ ):
75
+ """Initialize the sequence embedding operator.
76
+
77
+ Args:
78
+ config: Embedding configuration.
79
+ rngs: Random number generators for initialization.
80
+ name: Optional operator name.
81
+ """
82
+ super().__init__(config, rngs=rngs, name=name)
83
+
84
+ if rngs is None:
85
+ rngs = nnx.Rngs(0)
86
+
87
+ self.embedding_dim = config.embedding_dim
88
+ self.kernel_size = config.kernel_size
89
+
90
+ # Build convolutional layers
91
+ # Using Linear layers to simulate 1D convolution via sliding windows
92
+ # This is more compatible with varying sequence lengths
93
+ alphabet_size = 4
94
+ conv_layers: list[nnx.Linear] = []
95
+
96
+ # First layer: alphabet -> embedding_dim
97
+ # Input features: kernel_size * alphabet_size
98
+ first_in = config.kernel_size * alphabet_size
99
+ conv_layers.append(
100
+ nnx.Linear(in_features=first_in, out_features=config.embedding_dim, rngs=rngs)
101
+ )
102
+
103
+ # Subsequent layers: embedding_dim -> embedding_dim
104
+ for _ in range(config.num_conv_layers - 1):
105
+ conv_layers.append(
106
+ nnx.Linear(
107
+ in_features=config.kernel_size * config.embedding_dim,
108
+ out_features=config.embedding_dim,
109
+ rngs=rngs,
110
+ )
111
+ )
112
+
113
+ self.conv_layers = nnx.List(conv_layers)
114
+
115
+ def _extract_windows(
116
+ self,
117
+ sequence: Float[Array, "length features"],
118
+ kernel_size: int,
119
+ ) -> Float[Array, "length window_features"]:
120
+ """Extract sliding windows from sequence.
121
+
122
+ Args:
123
+ sequence: Input sequence (length, features).
124
+ kernel_size: Size of the sliding window.
125
+
126
+ Returns:
127
+ Windows of shape (length, kernel_size * features).
128
+ """
129
+ seq_len, num_features = sequence.shape
130
+ half_k = kernel_size // 2
131
+
132
+ # Pad sequence for edge handling
133
+ padded = jnp.pad(sequence, ((half_k, half_k), (0, 0)), mode="constant", constant_values=0.0)
134
+
135
+ # Extract windows using vmap
136
+ def extract_window(center: Array | int) -> Float[Array, "window_features"]:
137
+ window = jax.lax.dynamic_slice(padded, (center, 0), (kernel_size, num_features))
138
+ return window.flatten()
139
+
140
+ windows = jax.vmap(extract_window)(jnp.arange(seq_len))
141
+ return windows
142
+
143
+ def _apply_conv_layer(
144
+ self,
145
+ sequence: Float[Array, "length features"],
146
+ layer: nnx.Linear,
147
+ kernel_size: int,
148
+ ) -> Float[Array, "length out_features"]:
149
+ """Apply a convolutional layer using sliding windows.
150
+
151
+ Args:
152
+ sequence: Input sequence.
153
+ layer: Linear layer to apply to each window.
154
+ kernel_size: Window size.
155
+
156
+ Returns:
157
+ Output features at each position.
158
+ """
159
+ # Extract windows
160
+ windows = self._extract_windows(sequence, kernel_size)
161
+
162
+ # Apply linear transformation to each window
163
+ output = jax.vmap(layer)(windows)
164
+
165
+ return output
166
+
167
+ def apply(
168
+ self,
169
+ data: PyTree,
170
+ state: PyTree,
171
+ metadata: dict[str, Any] | None,
172
+ random_params: Any = None,
173
+ stats: dict[str, Any] | None = None,
174
+ ) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
175
+ """Apply sequence embedding to sequence data.
176
+
177
+ This method extracts dense embeddings from one-hot encoded
178
+ DNA sequences using convolutional feature extraction.
179
+
180
+ Args:
181
+ data: Dictionary containing:
182
+ - "sequence": One-hot encoded sequence (length, alphabet_size)
183
+ state: Element state (passed through unchanged)
184
+ metadata: Element metadata (passed through unchanged)
185
+ random_params: Not used (deterministic operator)
186
+ stats: Not used
187
+
188
+ Returns:
189
+ Tuple of (transformed_data, state, metadata):
190
+ - transformed_data contains:
191
+
192
+ - "sequence": Original sequence
193
+ - "embedding": Global sequence embedding (embedding_dim,)
194
+ - "position_embeddings": Per-position features (length, embedding_dim)
195
+ - state is passed through unchanged
196
+ - metadata is passed through unchanged
197
+ """
198
+ sequence = data["sequence"]
199
+
200
+ # Apply first conv layer
201
+ x = self._apply_conv_layer(sequence, self.conv_layers[0], self.kernel_size)
202
+ x = nnx.relu(x)
203
+
204
+ # Apply remaining conv layers
205
+ for layer in self.conv_layers[1:]:
206
+ x = self._apply_conv_layer(x, layer, self.kernel_size)
207
+ x = nnx.relu(x)
208
+
209
+ # x is now (length, embedding_dim)
210
+ position_embeddings = x
211
+
212
+ # Global average pooling to get fixed-size embedding
213
+ embedding = jnp.mean(position_embeddings, axis=0)
214
+
215
+ # Build output data
216
+ transformed_data = {
217
+ "sequence": sequence,
218
+ "embedding": embedding,
219
+ "position_embeddings": position_embeddings,
220
+ }
221
+
222
+ return transformed_data, state, metadata
@@ -0,0 +1,400 @@
1
+ """Differentiable PHATE dimensionality reduction.
2
+
3
+ This module implements a differentiable version of PHATE (Potential of
4
+ Heat-diffusion for Affinity-based Trajectory Embedding) for dimensionality
5
+ reduction with end-to-end gradient flow.
6
+
7
+ PHATE embeds high-dimensional data by:
8
+
9
+ 1. Building an alpha-decay affinity kernel from pairwise distances.
10
+ 2. Symmetrizing and row-normalizing to a Markov transition matrix.
11
+ 3. Powering the matrix to diffusion time *t* via eigendecomposition.
12
+ 4. Computing potential distances (log or sqrt transform of diffused matrix).
13
+ 5. Applying classical MDS to the potential distance matrix for embedding.
14
+
15
+ Reference:
16
+ Moon et al., *Visualizing transitions and structure for biological data
17
+ exploration*, Nature Biotechnology, 2019.
18
+ """
19
+
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ import jax
25
+ import jax.numpy as jnp
26
+ from datarax.core.config import OperatorConfig
27
+ from datarax.core.operator import OperatorModule
28
+ from flax import nnx
29
+
30
+ from diffbio.constants import DISTANCE_MASK_SENTINEL, EPSILON
31
+ from diffbio.core import soft_ops
32
+ from diffbio.core.graph_utils import compute_pairwise_distances, symmetrize_graph
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Regularization added to eigenvalues to prevent NaN gradients from
37
+ # repeated or near-zero eigenvalues in ``jnp.linalg.eigh``.
38
+ _EIGENVALUE_REGULARIZATION = 1e-6
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PHATEConfig(OperatorConfig):
43
+ """Configuration for differentiable PHATE.
44
+
45
+ Attributes:
46
+ n_components: Number of dimensions in the embedding.
47
+ n_neighbors: Number of nearest neighbors for local bandwidth.
48
+ decay: Exponent for the alpha-decaying kernel. Higher values
49
+ produce sharper kernel tails (PHATE default 40).
50
+ diffusion_t: Power to which the diffusion operator is raised.
51
+ Controls the level of diffusion smoothing.
52
+ gamma: Informational distance constant. ``gamma=1`` gives the log
53
+ potential, ``gamma=0`` gives the sqrt potential.
54
+ input_features: Number of input features (used for projection network).
55
+ hidden_dim: Hidden dimension for the projection network.
56
+ """
57
+
58
+ n_components: int = 2
59
+ n_neighbors: int = 5
60
+ decay: float = 40.0
61
+ diffusion_t: int = 10
62
+ gamma: float = 1.0
63
+ input_features: int = 64
64
+ hidden_dim: int = 32
65
+
66
+
67
+ class DifferentiablePHATE(OperatorModule):
68
+ """Differentiable PHATE for dimensionality reduction.
69
+
70
+ Implements the full PHATE pipeline in a differentiable manner using JAX:
71
+
72
+ 1. Pairwise distances via ``compute_pairwise_distances`` (DRY).
73
+ 2. Alpha-decay affinity kernel: ``K(i,j) = exp(-(d(i,j)/sigma_i)^decay)``
74
+ where ``sigma_i`` is the distance to the k-th neighbor.
75
+ 3. Symmetrize via ``symmetrize_graph`` (DRY).
76
+ 4. Row-normalize to Markov matrix ``M``.
77
+ 5. Diffusion ``M^t`` via eigendecomposition.
78
+ 6. Potential distance: ``-log(M^t + eps)`` for ``gamma=1`` (log),
79
+ or ``(M^t)^((1-gamma)/2) / ((1-gamma)/2)`` otherwise.
80
+ 7. Classical MDS on the potential distance matrix: center, eigendecompose,
81
+ take top ``n_components`` eigenvectors.
82
+
83
+ Example:
84
+ ```python
85
+ config = PHATEConfig(n_components=2, n_neighbors=5, diffusion_t=10)
86
+ phate = DifferentiablePHATE(config, rngs=rngs)
87
+
88
+ data = {"features": high_dim_data} # (n_samples, n_features)
89
+ result, state, metadata = phate.apply(data, {}, None)
90
+ embedding = result["embedding"] # (n_samples, n_components)
91
+ ```
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ config: PHATEConfig,
97
+ *,
98
+ rngs: nnx.Rngs | None = None,
99
+ ) -> None:
100
+ """Initialize the differentiable PHATE operator.
101
+
102
+ Args:
103
+ config: Configuration for PHATE.
104
+ rngs: Random number generators for parameter initialization.
105
+ """
106
+ super().__init__(config, rngs=rngs)
107
+ self.config = config
108
+
109
+ def _build_alpha_decay_affinity(
110
+ self,
111
+ distances: jax.Array,
112
+ k: int,
113
+ decay: float,
114
+ ) -> jax.Array:
115
+ """Build alpha-decaying kernel following PHATE.
116
+
117
+ Computes a locality-adaptive Gaussian kernel:
118
+ ``K(i,j) = exp(-alpha * (d(i,j) / sigma_i)^2)``
119
+ where ``sigma_i`` is the distance to the k-th nearest neighbor and
120
+ ``alpha = decay / 2``.
121
+
122
+ This is a differentiable relaxation of the original PHATE kernel
123
+ ``exp(-(d/sigma)^decay)`` which has vanishing gradients for high decay.
124
+ The Gaussian formulation preserves the same adaptive-bandwidth locality
125
+ structure while maintaining stable gradient flow.
126
+
127
+ Args:
128
+ distances: Pairwise distance matrix of shape ``(n, n)`` with
129
+ diagonal masked to ``DISTANCE_MASK_SENTINEL``.
130
+ k: Number of neighbors for local bandwidth estimation.
131
+ decay: Controls kernel sharpness. Mapped to Gaussian scale
132
+ ``alpha = decay / 2``. Higher values produce sharper falloff.
133
+
134
+ Returns:
135
+ Affinity matrix of shape ``(n, n)`` with zero diagonal.
136
+ """
137
+ n = distances.shape[0]
138
+ k_eff = min(k, n - 1)
139
+
140
+ # Local bandwidth: distance to the k-th nearest neighbor
141
+ sorted_dists = soft_ops.sort(distances, axis=-1, softness=0.1)
142
+ sigma = jnp.maximum(sorted_dists[:, k_eff - 1], EPSILON)
143
+
144
+ # Gaussian kernel with adaptive bandwidth:
145
+ # K(i,j) = exp(-alpha * (d(i,j) / sigma_i)^2)
146
+ # alpha = decay / 2 maps the PHATE decay parameter to
147
+ # the Gaussian scale while preserving locality structure.
148
+ alpha = decay / 2.0
149
+ ratio = distances / sigma[:, None]
150
+ affinity = jnp.exp(-alpha * ratio**2)
151
+
152
+ # Zero diagonal
153
+ affinity = affinity * (1.0 - jnp.eye(n))
154
+
155
+ return affinity
156
+
157
+ def _build_markov_matrix(self, affinity_sym: jax.Array) -> jax.Array:
158
+ """Row-normalize a symmetric affinity matrix to a Markov matrix.
159
+
160
+ Args:
161
+ affinity_sym: Symmetric affinity matrix of shape ``(n, n)``.
162
+
163
+ Returns:
164
+ Row-stochastic Markov matrix of shape ``(n, n)``.
165
+ """
166
+ row_sums = jnp.sum(affinity_sym, axis=1, keepdims=True)
167
+ return affinity_sym / jnp.maximum(row_sums, EPSILON)
168
+
169
+ def _diffuse_eigendecomposition(
170
+ self,
171
+ markov: jax.Array,
172
+ t: int,
173
+ ) -> jax.Array:
174
+ """Compute ``M^t`` via eigendecomposition.
175
+
176
+ Symmetrizes the Markov matrix via the similarity transform
177
+ ``S = D^{1/2} M D^{-1/2}`` (where ``D = diag(rowsums)``),
178
+ eigendecomposes the symmetric ``S``, powers the eigenvalues,
179
+ and reconstructs ``M^t = D^{-1/2} V diag(lambda^t) V^T D^{1/2}``.
180
+
181
+ A small regularization is added to diagonal of ``S`` before
182
+ eigendecomposition so that repeated eigenvalues are slightly split,
183
+ preventing NaN gradients in the backward pass.
184
+
185
+ Args:
186
+ markov: Row-stochastic Markov matrix of shape ``(n, n)``.
187
+ t: Diffusion time (power to raise the matrix to).
188
+
189
+ Returns:
190
+ Diffusion operator ``M^t`` of shape ``(n, n)``.
191
+ """
192
+ n = markov.shape[0]
193
+
194
+ if t == 0:
195
+ return jnp.eye(n)
196
+
197
+ # Similarity transform to symmetric matrix with same spectrum
198
+ degree = jnp.sum(markov, axis=1)
199
+ degree = jnp.maximum(degree, EPSILON)
200
+ d_sqrt = jnp.sqrt(degree)
201
+ d_inv_sqrt = 1.0 / d_sqrt
202
+
203
+ sym_matrix = d_sqrt[:, None] * markov * d_inv_sqrt[None, :]
204
+
205
+ # Force exact symmetry
206
+ sym_matrix = 0.5 * (sym_matrix + sym_matrix.T)
207
+
208
+ # Add tiny diagonal regularization to split repeated eigenvalues
209
+ reg = _EIGENVALUE_REGULARIZATION * jnp.eye(n)
210
+ sym_matrix = sym_matrix + reg
211
+
212
+ # Eigendecompose
213
+ eigenvalues, eigenvectors = jnp.linalg.eigh(sym_matrix)
214
+
215
+ # Clamp eigenvalues to non-negative, normalize by max
216
+ eigenvalues = jnp.maximum(eigenvalues, 0.0)
217
+ lambda_max = jnp.maximum(eigenvalues[-1], EPSILON)
218
+ eigenvalues_normalized = eigenvalues / lambda_max
219
+
220
+ # Power the normalized eigenvalues
221
+ eigenvalues_t = eigenvalues_normalized**t
222
+
223
+ # Reconstruct: M^t = D^{-1/2} V diag(lambda^t) V^T D^{1/2}
224
+ v_left = d_inv_sqrt[:, None] * eigenvectors
225
+ v_right = d_sqrt[:, None] * eigenvectors
226
+ diffusion_op = v_left @ jnp.diag(eigenvalues_t) @ v_right.T
227
+
228
+ # Ensure row-stochasticity after reconstruction
229
+ row_sums = jnp.sum(diffusion_op, axis=1, keepdims=True)
230
+ diffusion_op = diffusion_op / jnp.maximum(row_sums, EPSILON)
231
+
232
+ # Clamp to non-negative (numerical noise can create tiny negatives)
233
+ diffusion_op = jnp.maximum(diffusion_op, 0.0)
234
+
235
+ return diffusion_op
236
+
237
+ def _compute_potential_distances(
238
+ self,
239
+ diffusion_op: jax.Array,
240
+ gamma: float,
241
+ ) -> jax.Array:
242
+ """Compute potential distances from the diffusion operator.
243
+
244
+ Following PHATE:
245
+ - ``gamma=1``: log potential ``D = -log(M^t + eps)``
246
+ - ``gamma=0``: sqrt potential ``D = (M^t)^{1/2} / (1/2)``
247
+ - ``gamma=-1``: identity (no transform)
248
+ - general: ``D = (M^t)^c / c`` where ``c = (1-gamma)/2``
249
+
250
+ The result is symmetrized for use in MDS.
251
+
252
+ Args:
253
+ diffusion_op: Diffusion operator ``M^t`` of shape ``(n, n)``.
254
+ gamma: Informational distance constant.
255
+
256
+ Returns:
257
+ Symmetric potential distance matrix of shape ``(n, n)``.
258
+ """
259
+ if gamma == 1.0:
260
+ # Log potential (standard PHATE)
261
+ potential = -jnp.log(diffusion_op + EPSILON)
262
+ elif gamma == -1.0:
263
+ # Identity (no transform)
264
+ potential = diffusion_op
265
+ else:
266
+ # General power transform, covers gamma=0 (sqrt) etc.
267
+ c = (1.0 - gamma) / 2.0
268
+ potential = jnp.power(diffusion_op + EPSILON, c) / jnp.maximum(c, EPSILON)
269
+
270
+ # Symmetrize for MDS
271
+ potential = 0.5 * (potential + potential.T)
272
+
273
+ return potential
274
+
275
+ def _classical_mds(
276
+ self,
277
+ distance_matrix: jax.Array,
278
+ n_components: int,
279
+ ) -> jax.Array:
280
+ """Perform classical MDS on a distance matrix.
281
+
282
+ Classical MDS (Torgerson scaling):
283
+
284
+ 1. Square the distances: ``D2 = D^2``
285
+ 2. Double-center: ``B = -0.5 * (D2 - row_mean - col_mean + grand_mean)``
286
+ 3. Eigendecompose B and take the top ``n_components`` eigenvectors
287
+ scaled by ``sqrt(eigenvalue)``.
288
+
289
+ A small diagonal regularization is added to the centered matrix
290
+ before eigendecomposition to split repeated eigenvalues and ensure
291
+ stable gradients.
292
+
293
+ Args:
294
+ distance_matrix: Symmetric distance matrix of shape ``(n, n)``.
295
+ n_components: Number of embedding dimensions.
296
+
297
+ Returns:
298
+ Embedding of shape ``(n, n_components)``.
299
+ """
300
+ n = distance_matrix.shape[0]
301
+ d_squared = distance_matrix**2
302
+
303
+ # Double centering
304
+ row_mean = jnp.mean(d_squared, axis=1, keepdims=True)
305
+ col_mean = jnp.mean(d_squared, axis=0, keepdims=True)
306
+ grand_mean = jnp.mean(d_squared)
307
+ centered = -0.5 * (d_squared - row_mean - col_mean + grand_mean)
308
+
309
+ # Force symmetry
310
+ centered = 0.5 * (centered + centered.T)
311
+
312
+ # Regularize to split degenerate eigenvalues for stable gradients
313
+ centered = centered + _EIGENVALUE_REGULARIZATION * jnp.eye(n)
314
+
315
+ # Eigendecompose: eigh returns eigenvalues in ascending order
316
+ eigenvalues, eigenvectors = jnp.linalg.eigh(centered)
317
+
318
+ # Take the top n_components (largest eigenvalues = last ones)
319
+ top_eigenvalues = eigenvalues[-n_components:]
320
+ top_eigenvectors = eigenvectors[:, -n_components:]
321
+
322
+ # Clamp to non-negative for sqrt
323
+ top_eigenvalues = jnp.maximum(top_eigenvalues, EPSILON)
324
+
325
+ # Embedding: eigenvectors scaled by sqrt(eigenvalues)
326
+ embedding = top_eigenvectors * jnp.sqrt(top_eigenvalues)[None, :]
327
+
328
+ # Reverse so the largest component comes first
329
+ embedding = embedding[:, ::-1]
330
+
331
+ return embedding
332
+
333
+ def apply(
334
+ self,
335
+ data: dict[str, Any],
336
+ state: dict[str, Any],
337
+ metadata: dict | None,
338
+ random_params: dict | None = None,
339
+ stats: dict | None = None,
340
+ ) -> tuple[dict, dict, dict | None]:
341
+ """Apply PHATE dimensionality reduction.
342
+
343
+ Args:
344
+ data: Dictionary containing:
345
+ - ``"features"``: High-dimensional features ``(n_samples, n_features)``
346
+ state: Operator state dictionary.
347
+ metadata: Optional metadata dictionary.
348
+ random_params: Optional random parameters (unused).
349
+ stats: Optional statistics dictionary (unused).
350
+
351
+ Returns:
352
+ Tuple of ``(output_data, state, metadata)`` where output_data contains:
353
+
354
+ - ``"features"``: Original high-dimensional features
355
+ - ``"embedding"``: Low-dimensional PHATE embedding
356
+ ``(n_samples, n_components)``
357
+ - ``"potential_distances"``: Symmetric potential distance matrix
358
+ ``(n_samples, n_samples)``
359
+ - ``"diffusion_operator"``: Row-stochastic diffusion matrix
360
+ ``M^t`` ``(n_samples, n_samples)``
361
+ """
362
+ del random_params, stats # Unused
363
+
364
+ features = data["features"]
365
+ n_samples = features.shape[0]
366
+
367
+ # Step 1: Pairwise distances (DRY: reuse graph_utils)
368
+ distances = compute_pairwise_distances(features, metric="euclidean")
369
+
370
+ # Mask diagonal for neighbor computation
371
+ distances_masked = distances + jnp.eye(n_samples) * DISTANCE_MASK_SENTINEL
372
+
373
+ # Step 2: Alpha-decay affinity kernel
374
+ affinity = self._build_alpha_decay_affinity(
375
+ distances_masked, self.config.n_neighbors, self.config.decay
376
+ )
377
+
378
+ # Step 3: Symmetrize (DRY: reuse graph_utils)
379
+ affinity_sym = symmetrize_graph(affinity)
380
+
381
+ # Step 4: Row-normalize to Markov matrix
382
+ markov = self._build_markov_matrix(affinity_sym)
383
+
384
+ # Step 5: Diffusion via eigendecomposition
385
+ diffusion_op = self._diffuse_eigendecomposition(markov, self.config.diffusion_t)
386
+
387
+ # Step 6: Potential distances
388
+ potential_distances = self._compute_potential_distances(diffusion_op, self.config.gamma)
389
+
390
+ # Step 7: Classical MDS embedding
391
+ embedding = self._classical_mds(potential_distances, self.config.n_components)
392
+
393
+ output_data = {
394
+ **data,
395
+ "embedding": embedding,
396
+ "potential_distances": potential_distances,
397
+ "diffusion_operator": diffusion_op,
398
+ }
399
+
400
+ return output_data, state, metadata