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,508 @@
1
+ """Differentiable projection onto the transport polytope.
2
+
3
+ Projects a cost matrix onto the transport polytope between two marginal
4
+ distributions using regularized optimal transport. Multiple regularizers
5
+ control the smoothness of the resulting gradient:
6
+
7
+ - **smooth** (C-infinity): Entropic/softmax regularizer. Solved via
8
+ Sinkhorn or L-BFGS on the dual.
9
+ - **c0** (continuous): Euclidean/L2 regularizer (p=2 p-norm). Solved
10
+ via L-BFGS.
11
+ - **c1** (once differentiable): p=3/2 p-norm regularizer. Solved via
12
+ L-BFGS.
13
+ - **c2** (twice differentiable): p=4/3 p-norm regularizer. Solved via
14
+ L-BFGS.
15
+
16
+ All mathematical implementations are preserved exactly and support JAX
17
+ autodiff via implicit differentiation or recursive checkpointing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Literal
23
+
24
+ import jax
25
+ import jax.numpy as jnp
26
+ from jax import Array
27
+
28
+ from diffbio.core.soft_ops._utils import validate_softness
29
+
30
+ # -- Optional dependency: optimistix (L-BFGS solver) ----------------------
31
+ try:
32
+ import optimistix as optx
33
+
34
+ HAS_OPTIMISTIX = True
35
+ except ImportError:
36
+ HAS_OPTIMISTIX = False
37
+
38
+ # -- Optional dependency: lineax (linear solvers) -------------------------
39
+ try:
40
+ import lineax as lx
41
+
42
+ HAS_LINEAX = True
43
+ except ImportError:
44
+ HAS_LINEAX = False
45
+
46
+ # -- Optional dependency: OTT-JAX (Sinkhorn solver) -----------------------
47
+ try:
48
+ from ott.geometry import geometry
49
+ from ott.problems.linear import linear_problem
50
+ from ott.solvers.linear import (
51
+ implicit_differentiation as idiff,
52
+ sinkhorn,
53
+ )
54
+
55
+ HAS_OTT = True
56
+ except ImportError:
57
+ HAS_OTT = False
58
+
59
+
60
+ def _transport_solver_dtype(dtype: jnp.dtype) -> jnp.dtype:
61
+ """Choose the highest-precision solver dtype supported by the active JAX config."""
62
+ return jnp.float64 if jax.config.jax_enable_x64 else dtype
63
+
64
+
65
+ def _promote_transport_inputs(
66
+ C: jax.Array,
67
+ mu: jax.Array,
68
+ nu: jax.Array,
69
+ scalar: float | Array,
70
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
71
+ """Promote OT solver inputs without requesting unsupported float64 precision."""
72
+ target_dtype = _transport_solver_dtype(C.dtype)
73
+ C = C.astype(target_dtype)
74
+ mu = mu.astype(target_dtype)
75
+ nu = nu.astype(target_dtype)
76
+ return C, mu, nu, jnp.asarray(scalar, dtype=target_dtype)
77
+
78
+
79
+ # ----------------------------------------------------------------------- #
80
+ # Entropic projection via Sinkhorn (requires OTT-JAX)
81
+ # ----------------------------------------------------------------------- #
82
+
83
+
84
+ def _proj_transport_polytope_entropic_sinkhorn(
85
+ C: jax.Array,
86
+ mu: jax.Array,
87
+ nu: jax.Array,
88
+ tol: float = 1e-6,
89
+ max_iter: int = 1000,
90
+ epsilon: float | Array = 1.0,
91
+ ) -> jax.Array:
92
+ """Solve entropic OT via Sinkhorn with implicit differentiation.
93
+
94
+ Args:
95
+ C: Cost matrix of shape ``(n, m)``.
96
+ mu: Source marginal of shape ``(n,)``.
97
+ nu: Target marginal of shape ``(m,)``.
98
+ tol: Convergence tolerance.
99
+ max_iter: Maximum Sinkhorn iterations.
100
+ epsilon: Entropic regularization strength.
101
+
102
+ Returns:
103
+ Transport plan of shape ``(n, m)``.
104
+ """
105
+ if not HAS_OTT:
106
+ msg = (
107
+ "OTT-JAX is required for Sinkhorn-based entropic "
108
+ "projection. Install it with: uv pip install ott-jax"
109
+ )
110
+ raise ImportError(msg)
111
+
112
+ orig_dtype = C.dtype
113
+ C, mu, nu, epsilon = _promote_transport_inputs(C, mu, nu, epsilon)
114
+
115
+ # Avoid exact zeros (helps implicit differentiation a lot)
116
+ tiny = 1e-12
117
+ mu = jnp.clip(mu, tiny)
118
+ mu = mu / jnp.sum(mu)
119
+ nu = jnp.clip(nu, tiny)
120
+ nu = nu / jnp.sum(nu)
121
+
122
+ geom = geometry.Geometry(cost_matrix=C, epsilon=epsilon) # pyright: ignore[reportArgumentType]
123
+ prob = linear_problem.LinearProblem(geom, a=mu, b=nu)
124
+
125
+ implicit = idiff.ImplicitDiff(
126
+ solver_kwargs={"ridge_identity": 1e-6},
127
+ )
128
+
129
+ solver = sinkhorn.Sinkhorn(
130
+ lse_mode=True,
131
+ threshold=tol,
132
+ max_iterations=max_iter,
133
+ implicit_diff=implicit,
134
+ )
135
+
136
+ out = solver(prob)
137
+ return out.matrix.astype(orig_dtype)
138
+
139
+
140
+ # ----------------------------------------------------------------------- #
141
+ # Entropic projection via L-BFGS (requires optimistix + lineax)
142
+ # ----------------------------------------------------------------------- #
143
+
144
+
145
+ def _proj_transport_polytope_entropic_lbfgs(
146
+ C: jnp.ndarray, # (n, m)
147
+ mu: jnp.ndarray, # (n,)
148
+ nu: jnp.ndarray, # (m,)
149
+ epsilon: float | Array, # scalar
150
+ tol: float,
151
+ max_steps: int,
152
+ gauge_fix: bool = True,
153
+ implicit_diff: bool = True,
154
+ ) -> jnp.ndarray:
155
+ """Solve entropic OT via L-BFGS on the dual.
156
+
157
+ Args:
158
+ C: Cost matrix of shape ``(n, m)``.
159
+ mu: Source marginal of shape ``(n,)``.
160
+ nu: Target marginal of shape ``(m,)``.
161
+ epsilon: Entropic regularization strength (scalar).
162
+ tol: Convergence tolerance.
163
+ max_steps: Maximum L-BFGS steps.
164
+ gauge_fix: If True, fix ``g[0] = 0`` to avoid singular
165
+ Hessians in implicit differentiation.
166
+ implicit_diff: If True, use implicit adjoint; otherwise
167
+ recursive checkpointing.
168
+
169
+ Returns:
170
+ Transport plan of shape ``(n, m)``.
171
+ """
172
+ if not HAS_OPTIMISTIX:
173
+ msg = (
174
+ "optimistix is required for L-BFGS transport "
175
+ "projection. Install it with: uv pip install optimistix"
176
+ )
177
+ raise ImportError(msg)
178
+ if not HAS_LINEAX:
179
+ msg = (
180
+ "lineax is required for L-BFGS transport "
181
+ "projection. Install it with: uv pip install lineax"
182
+ )
183
+ raise ImportError(msg)
184
+
185
+ orig_dtype = C.dtype
186
+ C, mu, nu, epsilon = _promote_transport_inputs(C, mu, nu, epsilon)
187
+
188
+ mu = jnp.clip(mu, 1e-12)
189
+ nu = jnp.clip(nu, 1e-12)
190
+ mu = mu / jnp.sum(mu)
191
+ nu = nu / jnp.sum(nu)
192
+ n, m = C.shape
193
+
194
+ if gauge_fix:
195
+ # Gauge fix: set g0 = 0, optimise f and g_rest to avoid
196
+ # singular system on implicit diff
197
+ y0 = (
198
+ jnp.zeros((n,), C.dtype),
199
+ jnp.zeros((m - 1,), C.dtype),
200
+ ) # (f, g_rest)
201
+ else:
202
+ y0 = (
203
+ jnp.zeros((n,), C.dtype),
204
+ jnp.zeros((m,), C.dtype),
205
+ ) # (f, g)
206
+
207
+ def neg_dual(
208
+ y: tuple[jnp.ndarray, jnp.ndarray],
209
+ args: tuple[
210
+ jnp.ndarray,
211
+ jnp.ndarray,
212
+ jnp.ndarray,
213
+ jnp.ndarray,
214
+ bool,
215
+ ],
216
+ ) -> jnp.ndarray:
217
+ """Compute the negative entropic OT dual objective for L-BFGS minimization."""
218
+ C_, mu_, nu_, eps_, gauge_fix_ = args
219
+ if gauge_fix_:
220
+ f, g_rest = y
221
+ g = jnp.concatenate(
222
+ [jnp.zeros((1,), C_.dtype), g_rest],
223
+ axis=0,
224
+ ) # (m,)
225
+ else:
226
+ f, g = y
227
+ Z = (f[:, None] + g[None, :] - C_) / eps_
228
+ return -(jnp.dot(mu_, f) + jnp.dot(nu_, g) - eps_ * jnp.sum(jnp.exp(Z)))
229
+
230
+ solver = optx.LBFGS(rtol=tol, atol=tol)
231
+ if implicit_diff:
232
+ adj = optx.ImplicitAdjoint(
233
+ linear_solver=lx.AutoLinearSolver(well_posed=False),
234
+ )
235
+ else:
236
+ adj = optx.RecursiveCheckpointAdjoint()
237
+ sol = optx.minimise(
238
+ neg_dual,
239
+ solver=solver,
240
+ y0=y0,
241
+ args=(C, mu, nu, epsilon, gauge_fix),
242
+ max_steps=max_steps,
243
+ adjoint=adj,
244
+ throw=True,
245
+ )
246
+
247
+ if gauge_fix:
248
+ f, g_rest = sol.value
249
+ g = jnp.concatenate(
250
+ [jnp.zeros((1,), C.dtype), g_rest],
251
+ axis=0,
252
+ )
253
+ else:
254
+ f, g = sol.value
255
+ Gamma = jnp.exp((f[:, None] + g[None, :] - C) / epsilon)
256
+ return Gamma.astype(orig_dtype)
257
+
258
+
259
+ # ----------------------------------------------------------------------- #
260
+ # P-norm projection via L-BFGS (requires optimistix + lineax)
261
+ # ----------------------------------------------------------------------- #
262
+
263
+
264
+ def _proj_transport_polytope_pnorm_lbfgs(
265
+ C: jnp.ndarray, # (n, m)
266
+ mu: jnp.ndarray, # (n,)
267
+ nu: jnp.ndarray, # (m,)
268
+ lam: float | Array, # scalar
269
+ tol: float,
270
+ max_steps: int,
271
+ gauge_fix: bool = True,
272
+ p: float = 6 / 5, # 1 < p <= 2
273
+ implicit_diff: bool = True,
274
+ ) -> jnp.ndarray:
275
+ """Solve p-norm regularized OT via L-BFGS on the dual.
276
+
277
+ Args:
278
+ C: Cost matrix of shape ``(n, m)``.
279
+ mu: Source marginal of shape ``(n,)``.
280
+ nu: Target marginal of shape ``(m,)``.
281
+ lam: Regularization strength (scalar).
282
+ tol: Convergence tolerance.
283
+ max_steps: Maximum L-BFGS steps.
284
+ gauge_fix: If True, fix ``g[0] = 0`` to avoid singular
285
+ Hessians in implicit differentiation.
286
+ p: Exponent for p-norm regularizer (1 < p <= 2).
287
+ implicit_diff: If True, use implicit adjoint; otherwise
288
+ recursive checkpointing.
289
+
290
+ Returns:
291
+ Transport plan of shape ``(n, m)``.
292
+ """
293
+ if not HAS_OPTIMISTIX:
294
+ msg = (
295
+ "optimistix is required for L-BFGS transport "
296
+ "projection. Install it with: uv pip install optimistix"
297
+ )
298
+ raise ImportError(msg)
299
+ if not HAS_LINEAX:
300
+ msg = (
301
+ "lineax is required for L-BFGS transport "
302
+ "projection. Install it with: uv pip install lineax"
303
+ )
304
+ raise ImportError(msg)
305
+
306
+ orig_dtype = C.dtype
307
+ C, mu, nu, lam = _promote_transport_inputs(C, mu, nu, lam)
308
+
309
+ mu = jnp.clip(mu, 1e-12)
310
+ nu = jnp.clip(nu, 1e-12)
311
+ mu = mu / jnp.sum(mu)
312
+ nu = nu / jnp.sum(nu)
313
+ n, m = C.shape
314
+ q = p / (p - 1.0) # conjugate exponent
315
+ lam_pow = lam ** (-(q - 1.0)) # lam^{-(q-1)}
316
+
317
+ if gauge_fix:
318
+ # Gauge fix: set g0 = 0, optimise f and g_rest to avoid
319
+ # singular system on implicit diff
320
+ y0 = (
321
+ jnp.zeros((n,), C.dtype),
322
+ jnp.zeros((m - 1,), C.dtype),
323
+ )
324
+ else:
325
+ y0 = (
326
+ jnp.zeros((n,), C.dtype),
327
+ jnp.zeros((m,), C.dtype),
328
+ )
329
+
330
+ def neg_dual(
331
+ y: tuple[jnp.ndarray, jnp.ndarray],
332
+ args: tuple[
333
+ jnp.ndarray,
334
+ jnp.ndarray,
335
+ jnp.ndarray,
336
+ jnp.ndarray,
337
+ bool,
338
+ ],
339
+ ) -> jnp.ndarray:
340
+ """Compute the negative p-norm OT dual objective for L-BFGS minimization."""
341
+ C_, mu_, nu_, lam_pow_, gauge_fix_ = args
342
+ if gauge_fix_:
343
+ f, g_rest = y
344
+ g = jnp.concatenate(
345
+ [jnp.zeros((1,), C_.dtype), g_rest],
346
+ axis=0,
347
+ )
348
+ else:
349
+ f, g = y
350
+ S = f[:, None] + g[None, :] - C_
351
+ P = jnp.maximum(S, 0.0)
352
+ dual = jnp.dot(mu_, f) + jnp.dot(nu_, g) - (lam_pow_ / q) * jnp.sum(P**q)
353
+ return -dual
354
+
355
+ solver = optx.LBFGS(rtol=tol, atol=tol)
356
+ if implicit_diff:
357
+ adj = optx.ImplicitAdjoint(
358
+ linear_solver=lx.AutoLinearSolver(well_posed=False),
359
+ )
360
+ else:
361
+ adj = optx.RecursiveCheckpointAdjoint()
362
+ sol = optx.minimise(
363
+ neg_dual,
364
+ solver=solver,
365
+ y0=y0,
366
+ args=(C, mu, nu, lam_pow, gauge_fix),
367
+ max_steps=max_steps,
368
+ adjoint=adj,
369
+ throw=True,
370
+ )
371
+
372
+ if gauge_fix:
373
+ f, g_rest = sol.value
374
+ g = jnp.concatenate(
375
+ [jnp.zeros((1,), C.dtype), g_rest],
376
+ axis=0,
377
+ )
378
+ else:
379
+ f, g = sol.value
380
+ S = f[:, None] + g[None, :] - C
381
+ # = lambda^{-(q-1)} [S]_+^{q-1}
382
+ Gamma = lam_pow * jnp.maximum(S, 0.0) ** (q - 1.0)
383
+ return Gamma.astype(orig_dtype)
384
+
385
+
386
+ # ----------------------------------------------------------------------- #
387
+ # Public dispatch function
388
+ # ----------------------------------------------------------------------- #
389
+
390
+
391
+ def proj_transport_polytope(
392
+ cost: Array, # (..., n, m)
393
+ mu: Array, # ([n],)
394
+ nu: Array, # ([m],)
395
+ softness: float | Array = 0.1,
396
+ mode: Literal["smooth", "c0", "c1", "c2"] = "smooth",
397
+ use_entropic_ot_sinkhorn_on_entropic: bool = True,
398
+ sinkhorn_tol: float = 1e-5,
399
+ sinkhorn_max_iter: int = 10000,
400
+ lbfgs_tol: float = 1e-5,
401
+ lbfgs_max_iter: int = 10000,
402
+ implicit_diff: bool = True,
403
+ ) -> Array: # (..., [n], m)
404
+ """Project a cost matrix onto the transport polytope.
405
+
406
+ Solves the regularized optimal transport problem::
407
+
408
+ min_G <C, G> + softness * R(G)
409
+ s.t. G 1_m = mu, G^T 1_n = nu, G >= 0
410
+
411
+ where ``R(G)`` is the regularizer determined by ``mode``.
412
+
413
+ Args:
414
+ cost: Input cost array of shape ``(..., n, m)``.
415
+ mu: Source marginal distribution of shape ``([n],)``.
416
+ nu: Target marginal distribution of shape ``([m],)``.
417
+ softness: Controls the strength of the regularizer.
418
+ Must be positive.
419
+ mode: Controls the type of regularizer:
420
+
421
+ - ``"smooth"``: C-infinity smooth (entropic/softmax
422
+ regularizer). Solved via Sinkhorn or L-BFGS.
423
+ - ``"c0"``: C0 continuous (Euclidean/L2 regularizer).
424
+ Solved via L-BFGS.
425
+ - ``"c1"``: C1 differentiable (p=3/2 p-norm). Solved
426
+ via L-BFGS.
427
+ - ``"c2"``: C2 twice differentiable (p=4/3 p-norm).
428
+ Solved via L-BFGS.
429
+ use_entropic_ot_sinkhorn_on_entropic: If True (default),
430
+ use Sinkhorn for ``"smooth"`` mode. If False, use
431
+ L-BFGS on the dual.
432
+ sinkhorn_tol: Convergence tolerance for Sinkhorn.
433
+ sinkhorn_max_iter: Maximum Sinkhorn iterations.
434
+ lbfgs_tol: Convergence tolerance for L-BFGS.
435
+ lbfgs_max_iter: Maximum L-BFGS iterations.
436
+ implicit_diff: If True (default), use implicit
437
+ differentiation for L-BFGS backward pass. More
438
+ numerically stable gradients, especially at low
439
+ softness.
440
+
441
+ Returns:
442
+ Positive array of shape ``(..., [n], m)`` representing
443
+ the transport plan between ``mu`` and ``nu``. Sums to 1
444
+ over the second-to-last dimension and approximately sums
445
+ to 1 over the last dimension.
446
+
447
+ Note:
448
+ Internal solvers upcast to float64 when possible for
449
+ numerical stability. This requires
450
+ ``jax.config.update("jax_enable_x64", True)`` (or the
451
+ ``JAX_ENABLE_X64=1`` env var). Without it the upcast is
452
+ silently ignored and the solver may produce non-finite
453
+ gradients at larger problem sizes (typically n >= 2048).
454
+ """
455
+ validate_softness(softness)
456
+ *batch_sizes, n, m = cost.shape
457
+ C = cost.reshape(-1, n, m) # (B, n, m)
458
+
459
+ if mode == "smooth":
460
+ use_entropic_ot_sinkhorn = use_entropic_ot_sinkhorn_on_entropic
461
+
462
+ if use_entropic_ot_sinkhorn:
463
+ proj_fn = lambda c: _proj_transport_polytope_entropic_sinkhorn(
464
+ c,
465
+ mu=mu,
466
+ nu=nu,
467
+ max_iter=sinkhorn_max_iter,
468
+ tol=sinkhorn_tol,
469
+ epsilon=softness,
470
+ )
471
+ else:
472
+ proj_fn = lambda c: _proj_transport_polytope_entropic_lbfgs(
473
+ c,
474
+ mu=mu,
475
+ nu=nu,
476
+ epsilon=softness,
477
+ tol=lbfgs_tol,
478
+ max_steps=lbfgs_max_iter,
479
+ implicit_diff=implicit_diff,
480
+ )
481
+
482
+ else:
483
+ if mode == "c0":
484
+ # Curvature of (1/2)||y||^2 at transport polytope
485
+ # center: R''=1
486
+ p = 2
487
+ elif mode == "c1":
488
+ p = 3 / 2
489
+ elif mode == "c2":
490
+ p = 4 / 3
491
+ else:
492
+ msg = f"Invalid mode: {mode}"
493
+ raise ValueError(msg)
494
+ proj_fn = lambda c: _proj_transport_polytope_pnorm_lbfgs(
495
+ c,
496
+ mu=mu,
497
+ nu=nu,
498
+ lam=softness,
499
+ tol=lbfgs_tol,
500
+ max_steps=lbfgs_max_iter,
501
+ p=p,
502
+ implicit_diff=implicit_diff,
503
+ )
504
+
505
+ Gamma = jax.vmap(proj_fn, in_axes=(0,))(C) # (B, n, m)
506
+
507
+ y = (Gamma * n).reshape(*batch_sizes, n, m) # (..., [n], m)
508
+ return y
@@ -0,0 +1,204 @@
1
+ """Soft bitonic sorting network.
2
+
3
+ Implements differentiable sorting and argsort using a bitonic sorting
4
+ network with soft compare-and-swap operations. The network has
5
+ O(n log^2 n) comparisons and produces smooth approximations to the
6
+ sorted output and permutation matrix.
7
+
8
+ Input is padded to the nearest power of 2 for the bitonic network,
9
+ then truncated back to the original length.
10
+ """
11
+
12
+ import jax
13
+ import jax.numpy as jnp
14
+ from jax import Array
15
+
16
+ from diffbio.core.soft_ops.elementwise import SigmoidalMode
17
+
18
+
19
+ def _soft_compare_and_swap(
20
+ a: Array,
21
+ b: Array,
22
+ softness: float | Array,
23
+ mode: SigmoidalMode,
24
+ ) -> tuple[Array, Array, Array]:
25
+ """Soft compare-and-swap via sigmoidal mixing.
26
+
27
+ Returns ``(soft_min, soft_max, sigma)`` where ``sigma`` is the
28
+ probability that ``a < b``.
29
+ """
30
+ from diffbio.core.soft_ops.elementwise import sigmoidal
31
+
32
+ sigma = sigmoidal(a - b, softness=softness, mode=mode)
33
+ soft_min = sigma * b + (1.0 - sigma) * a
34
+ soft_max = sigma * a + (1.0 - sigma) * b
35
+ return soft_min, soft_max, sigma
36
+
37
+
38
+ def _bitonic_sort_ascending(
39
+ x: Array,
40
+ softness: float | Array,
41
+ mode: SigmoidalMode,
42
+ ) -> Array:
43
+ """1-D ascending bitonic sort. ``x`` must have power-of-2 length."""
44
+ n = x.shape[0]
45
+ num_phases = (n.bit_length() - 1) if n > 1 else 0
46
+
47
+ for phase in range(num_phases):
48
+ for sub_step in range(phase + 1):
49
+ d = 1 << (phase - sub_step)
50
+ indices = jnp.arange(n)
51
+ partner = indices ^ d
52
+ block_size = 1 << (phase + 1)
53
+ ascending_block = (indices & block_size) == 0
54
+
55
+ soft_min, soft_max, _ = _soft_compare_and_swap(
56
+ x,
57
+ x[partner],
58
+ softness,
59
+ mode,
60
+ )
61
+ x = jnp.where(
62
+ ascending_block,
63
+ jnp.where(indices < partner, soft_min, soft_max),
64
+ jnp.where(indices < partner, soft_max, soft_min),
65
+ )
66
+ return x
67
+
68
+
69
+ def sort_via_sorting_network(
70
+ x: Array,
71
+ softness: float | Array,
72
+ mode: SigmoidalMode,
73
+ descending: bool,
74
+ standardized: bool = False,
75
+ ) -> Array:
76
+ """Sort along the last axis using a soft bitonic sorting network.
77
+
78
+ Args:
79
+ x: Input array of shape ``(..., n)``.
80
+ softness: Controls sharpness of compare-and-swap.
81
+ mode: Smoothness mode for sigmoidal.
82
+ descending: If True, sort in descending order.
83
+ standardized: If True, input is already in (0, 1) from sigmoid.
84
+
85
+ Returns:
86
+ Sorted array of shape ``(..., n)``.
87
+ """
88
+ *batch_shape, n = x.shape
89
+
90
+ n_padded = 1 << (n - 1).bit_length() if n > 1 else 2
91
+ if n_padded > n:
92
+ pad_val = 1.0 if standardized else jnp.max(x) + 1.0
93
+ pad_width = [(0, 0)] * len(batch_shape) + [(0, n_padded - n)]
94
+ x = jnp.pad(x, pad_width, constant_values=pad_val)
95
+
96
+ if batch_shape:
97
+ x_flat = x.reshape(-1, n_padded)
98
+ sorted_flat = jax.vmap(
99
+ lambda row: _bitonic_sort_ascending(row, softness, mode),
100
+ )(x_flat)
101
+ sorted_x = sorted_flat.reshape(*batch_shape, n_padded)
102
+ else:
103
+ sorted_x = _bitonic_sort_ascending(x, softness, mode)
104
+
105
+ if n_padded > n:
106
+ sorted_x = sorted_x[..., :n]
107
+ if descending:
108
+ sorted_x = jnp.flip(sorted_x, axis=-1)
109
+ return sorted_x
110
+
111
+
112
+ def _bitonic_argsort_ascending(
113
+ x: Array,
114
+ softness: float | Array,
115
+ mode: SigmoidalMode,
116
+ ) -> tuple[Array, Array]:
117
+ """1-D ascending bitonic sort with permutation tracking.
118
+
119
+ Returns ``(sorted_x, P)`` where ``P`` is an ``(n, n)`` soft
120
+ permutation matrix: ``P[sorted_pos, original_elem]`` is the
121
+ probability that ``original_elem`` ends up at ``sorted_pos``.
122
+ """
123
+ n = x.shape[0]
124
+ perm = jnp.eye(n)
125
+ num_phases = (n.bit_length() - 1) if n > 1 else 0
126
+
127
+ for phase in range(num_phases):
128
+ for sub_step in range(phase + 1):
129
+ d = 1 << (phase - sub_step)
130
+ indices = jnp.arange(n)
131
+ partner = indices ^ d
132
+ block_size = 1 << (phase + 1)
133
+ ascending_block = (indices & block_size) == 0
134
+
135
+ soft_min, soft_max, sigma = _soft_compare_and_swap(
136
+ x,
137
+ x[partner],
138
+ softness,
139
+ mode,
140
+ )
141
+ x = jnp.where(
142
+ ascending_block,
143
+ jnp.where(indices < partner, soft_min, soft_max),
144
+ jnp.where(indices < partner, soft_max, soft_min),
145
+ )
146
+
147
+ gets_min = (ascending_block & (indices < partner)) | (
148
+ ~ascending_block & (indices >= partner)
149
+ )
150
+ mix = jnp.where(gets_min, sigma, 1.0 - sigma)
151
+
152
+ perm_partner = perm[partner]
153
+ perm = (1.0 - mix[:, None]) * perm + mix[:, None] * perm_partner
154
+
155
+ return x, perm
156
+
157
+
158
+ def argsort_via_sorting_network(
159
+ x: Array,
160
+ softness: float | Array,
161
+ mode: SigmoidalMode,
162
+ descending: bool,
163
+ standardized: bool = False,
164
+ ) -> Array:
165
+ """Argsort via soft bitonic sorting network.
166
+
167
+ Returns a soft permutation matrix ``P`` of shape ``(..., n, n)``
168
+ where ``P[..., sorted_pos, original_elem]`` is the probability
169
+ that ``original_elem`` ends up at ``sorted_pos``.
170
+
171
+ Args:
172
+ x: Input array of shape ``(..., n)``.
173
+ softness: Controls sharpness of compare-and-swap.
174
+ mode: Smoothness mode for sigmoidal.
175
+ descending: If True, reverse the sort order.
176
+ standardized: If True, input is already in (0, 1).
177
+
178
+ Returns:
179
+ Soft permutation matrix of shape ``(..., n, n)``.
180
+ """
181
+ *batch_shape, n = x.shape
182
+
183
+ n_padded = 1 << (n - 1).bit_length() if n > 1 else 2
184
+ if n_padded > n:
185
+ pad_val = 1.0 if standardized else jnp.max(x) + 1.0
186
+ pad_width = [(0, 0)] * len(batch_shape) + [(0, n_padded - n)]
187
+ x = jnp.pad(x, pad_width, constant_values=pad_val)
188
+
189
+ if batch_shape:
190
+ x_flat = x.reshape(-1, n_padded)
191
+ _, perm_flat = jax.vmap(
192
+ lambda row: _bitonic_argsort_ascending(row, softness, mode),
193
+ )(x_flat)
194
+ perm = perm_flat.reshape(*batch_shape, n_padded, n_padded)
195
+ else:
196
+ _, perm = _bitonic_argsort_ascending(x, softness, mode)
197
+
198
+ if n_padded > n:
199
+ perm = perm[..., :n, :n]
200
+ perm = perm / jnp.clip(jnp.sum(perm, axis=-1, keepdims=True), min=1e-10)
201
+
202
+ if descending:
203
+ perm = jnp.flip(perm, axis=-2)
204
+ return perm