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,1864 @@
1
+ """Differentiable projections onto the permutahedron.
2
+
3
+ Provides multiple projection algorithms with varying smoothness:
4
+ - Entropic (log-KL) projection via isotonic regression (``smooth`` mode)
5
+ - Euclidean / L2 projection via PAV (``c0`` mode)
6
+ - p-norm p=3/2 projection via PAV with closed-form block solvers (``c1``)
7
+ - p-norm p=4/3 projection via PAV with closed-form block solvers (``c2``)
8
+ - C-infinity smooth ESP+LBFGS method (``smooth_sort`` mode)
9
+
10
+ All implementations preserve exact mathematical behavior and support
11
+ JAX autodiff via custom VJP rules.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Literal
17
+
18
+ import jax
19
+ import jax.numpy as jnp
20
+ from jax import Array, lax
21
+ from jax.ops import segment_sum
22
+
23
+ from diffbio.core.soft_ops._utils import validate_softness
24
+
25
+ try:
26
+ import optimistix as optx
27
+
28
+ HAS_OPTIMISTIX = True
29
+ except ImportError:
30
+ HAS_OPTIMISTIX = False
31
+
32
+
33
+ def _high_precision_dtype() -> jnp.dtype:
34
+ """Return float64 when x64 is enabled, otherwise float32."""
35
+ return jnp.result_type(float)
36
+
37
+
38
+ def _inv_permutation(p: jax.Array) -> jax.Array:
39
+ """Compute the inverse of a permutation array."""
40
+ inv = jnp.empty_like(p)
41
+ return inv.at[p].set(jnp.arange(p.shape[0], dtype=p.dtype))
42
+
43
+
44
+ # -- smooth: entropic LP-LBFGS ----------------------------------------
45
+
46
+
47
+ def _diff_T(beta: jax.Array) -> jax.Array:
48
+ """Compute D^T beta where D is the (n-1)x n difference matrix.
49
+
50
+ D is defined by (Dy)_i = y_i - y_{i+1}.
51
+ D^T beta = [beta0, beta1-beta0, ..., beta_{n-2}-beta_{n-3},
52
+ -beta_{n-2}].
53
+ """
54
+ left = jnp.concatenate(
55
+ [jnp.zeros((1,), dtype=beta.dtype), beta],
56
+ axis=0,
57
+ ) # (n,)
58
+ right = jnp.concatenate(
59
+ [beta, jnp.zeros((1,), dtype=beta.dtype)],
60
+ axis=0,
61
+ ) # (n,)
62
+ return right - left
63
+
64
+
65
+ def _alpha_nu_from_beta(
66
+ z_s: jax.Array,
67
+ beta: jax.Array,
68
+ ) -> tuple[jax.Array, jax.Array]:
69
+ """Recover dual variables alpha and nu from beta.
70
+
71
+ Uses stationarity: z_s - A^T alpha + D^T beta - nu*1 = 0
72
+ with (A^T alpha)_n = 0.
73
+ """
74
+ dtb = _diff_T(beta) # (n,)
75
+ nu = z_s[-1] + dtb[-1] # scalar, enforces last component = 0
76
+ u = z_s + dtb - nu # (n,), u[-1] == 0
77
+ alpha = u[:-1] - u[1:] # (n-1,), since u_i - u_{i+1} = alpha_i
78
+ return alpha, nu
79
+
80
+
81
+ def _reconstruct_y_from_slacks_and_gaps(
82
+ b: jax.Array, # (n-1,) prefix bounds
83
+ b_n: jax.Array, # scalar sum bound
84
+ s: jax.Array, # (n-1,) prefix slacks, s>0
85
+ d: jax.Array, # (n-1,) gaps, d>0
86
+ ) -> jax.Array:
87
+ """Reconstruct primal y from slacks and gaps.
88
+
89
+ We want Ay = b - s and Dy = d and 1^T y = b_n.
90
+ Uses d to parameterize y = y_n + tail_sums(d), then picks
91
+ y_n to best match Ay=b-s (including the sum equation k=n).
92
+ """
93
+ n = d.shape[0] + 1
94
+ dtype = d.dtype
95
+
96
+ # tail sums t_i = sum_{j=i}^{n-1} d_j, with t_n = 0
97
+ tail = jnp.flip(
98
+ jnp.cumsum(jnp.flip(d, axis=0), axis=0),
99
+ axis=0,
100
+ ) # (n-1,)
101
+ t = jnp.concatenate(
102
+ [tail, jnp.zeros((1,), dtype=dtype)],
103
+ axis=0,
104
+ ) # (n,)
105
+
106
+ # prefix sums of t: T_k = sum_{i<=k} t_i, k=1..n-1
107
+ T = jnp.cumsum(t, axis=0)[:-1] # (n-1,)
108
+
109
+ r = b - s # target prefix sums of y, (n-1,)
110
+ rhs = r - T # (n-1,) rhs_k in k*y_n = rhs_k
111
+
112
+ ks = jnp.arange(1, n, dtype=dtype) # 1..n-1
113
+ rhs_n = b_n - jnp.sum(t) # n*y_n = rhs_n
114
+
115
+ num = jnp.sum(ks * rhs) + (n * rhs_n)
116
+ den = jnp.sum(ks * ks) + (n * n)
117
+ y_n = num / den
118
+
119
+ y = y_n + t
120
+ return y
121
+
122
+
123
+ def _smooth_majorization_bounds(
124
+ w: jax.Array,
125
+ tau: jax.Array,
126
+ checkpointed: bool = True,
127
+ ) -> tuple[jax.Array, jax.Array]:
128
+ """C-infinity smooth majorization bounds via ESPs.
129
+
130
+ Computes b_k = tau * log e_k(exp(w/tau)) for k = 1 ... n-1,
131
+ where e_k is the k-th elementary symmetric polynomial.
132
+
133
+ This is the log-sum-exp over all size-k subsets:
134
+ b_k = tau * log sum_{|S|=k} exp(sum_{i in S} w_i / tau)
135
+ and serves as a C-infinity relaxation of the hard order-statistic
136
+ partial sums b_k = sum_{j<=k} w_{(j)}.
137
+
138
+ Uses the recurrence
139
+ E[k][j] = E[k][j-1] + exp(w_j/tau) * E[k-1][j-1]
140
+ in log-space (logaddexp) for numerical stability. O(n^2).
141
+
142
+ When ``checkpointed=True`` (default), uses optimal online gradient
143
+ checkpointing (Stumm & Walther 2010) to reduce memory from O(n^2)
144
+ to O(n*sqrt(n)) at the cost of ~2x forward compute during backward.
145
+ """
146
+ n = w.shape[0]
147
+ x = w / tau # (n,)
148
+
149
+ # Use a large finite sentinel instead of -inf to avoid NaN grads
150
+ # from logaddexp(-inf, -inf) at impossible subset sizes (k > j+1).
151
+ _FLOOR = jnp.array(-1e30, dtype=w.dtype)
152
+ # log_E[k] = log(E[k][j]) after processing j elements; k = 0..n-1
153
+ log_E = jnp.full((n,), _FLOOR, dtype=w.dtype)
154
+ log_E = log_E.at[0].set(0.0) # e_0 = 1
155
+
156
+ def _scan_step(
157
+ log_E: jax.Array,
158
+ x_j: jax.Array,
159
+ ) -> tuple[jax.Array, None]:
160
+ """Update log-ESP state by incorporating element x_j."""
161
+ # log E[k][j] = logaddexp(
162
+ # log E[k][j-1], x_j + log E[k-1][j-1]
163
+ # )
164
+ log_E_prev = jnp.concatenate(
165
+ [jnp.array([_FLOOR], dtype=log_E.dtype), log_E[:-1]],
166
+ )
167
+ log_E = jnp.logaddexp(log_E, x_j + log_E_prev)
168
+ return log_E, None
169
+
170
+ if checkpointed:
171
+
172
+ @jax.checkpoint
173
+ def _checkpointed_scan_step(
174
+ log_E: jax.Array,
175
+ x_j: jax.Array,
176
+ ) -> tuple[jax.Array, None]:
177
+ """Gradient-checkpointed wrapper around _scan_step."""
178
+ return _scan_step(log_E, x_j)
179
+
180
+ log_E, _ = lax.scan(_checkpointed_scan_step, log_E, x)
181
+ else:
182
+ log_E, _ = lax.scan(_scan_step, log_E, x)
183
+
184
+ # b_k = tau * log(e_k) for k = 1..n-1
185
+ b = tau * log_E[1:] # (n-1,)
186
+ b_n = jnp.sum(w) # exact: tau * log(e_n) = sum w_i
187
+ return b, b_n
188
+
189
+
190
+ def _tridiag_L_matvec(r: jax.Array) -> jax.Array:
191
+ """Compute L*r where L is the tridiagonal second-difference matrix.
192
+
193
+ L[i,i] = 2, L[i,i-1] = L[i,i+1] = -1.
194
+ Result: (L*r)_i = 2*r[i] - r[i-1] - r[i+1], with boundary handling.
195
+ """
196
+ result = 2.0 * r
197
+ result = result.at[:-1].add(-r[1:])
198
+ result = result.at[1:].add(-r[:-1])
199
+ return result
200
+
201
+
202
+ def _proj_permutahedron_entropic_lp_lbfgs(
203
+ z: jax.Array, # (n,)
204
+ w: jax.Array, # (n,)
205
+ softness: float = 1.0, # tau for majorization slacks
206
+ softness_mono: float | None = None, # tau_m for mono gaps
207
+ max_iter: int = 200,
208
+ tol: float = 1e-5,
209
+ history_length: int = 5,
210
+ l2_beta: float = 0.0, # optional stabilizer on beta
211
+ throw: bool = True,
212
+ ) -> jax.Array:
213
+ """Linear projection via entropic regularization on slacks and gaps.
214
+
215
+ Solves the eliminated dual over beta (unconstrained) via
216
+ optimistix.LBFGS.
217
+
218
+ Requires the ``optimistix`` package.
219
+ """
220
+ if not HAS_OPTIMISTIX:
221
+ msg = (
222
+ "optimistix is required for "
223
+ "_proj_permutahedron_entropic_lp_lbfgs. "
224
+ "Install it with: pip install optimistix"
225
+ )
226
+ raise ImportError(msg)
227
+ z = jnp.asarray(z)
228
+ w = jnp.asarray(w)
229
+ if z.ndim != 1 or w.ndim != 1 or z.shape != w.shape:
230
+ msg = f"z,w must be 1D and same shape; got {z.shape}, {w.shape}"
231
+ raise ValueError(msg)
232
+ n = z.shape[0]
233
+ if n <= 1:
234
+ return w
235
+
236
+ # Upcast to highest available float for solver precision.
237
+ orig_dtype = z.dtype
238
+ _hp = _high_precision_dtype()
239
+ if z.dtype != _hp:
240
+ z = z.astype(_hp)
241
+ w = w.astype(_hp)
242
+
243
+ tau = jnp.asarray(softness, dtype=z.dtype)
244
+ tau_m = tau if softness_mono is None else jnp.asarray(softness_mono, dtype=z.dtype)
245
+
246
+ # choose chamber by sorting z
247
+ pz = jnp.argsort(-z, stable=True)
248
+ iz = _inv_permutation(pz)
249
+ z_s = z[pz]
250
+
251
+ # permutahedron bounds from sorted w
252
+ pw = jnp.argsort(-w, stable=True)
253
+ w_s = w[pw]
254
+ b_full = jnp.cumsum(w_s)
255
+ b = b_full[:-1] # (n-1,)
256
+ b_n = b_full[-1] # scalar
257
+
258
+ # dual after elimination: minimize
259
+ # g(beta) = <alpha(beta), b> + nu(beta)*b_n
260
+ # + tau * sum exp(-alpha/tau)
261
+ # + tau_m * sum exp(-beta/tau_m)
262
+ # where alpha,nu satisfy stationarity.
263
+ def dual_obj(
264
+ beta: jax.Array,
265
+ args: tuple,
266
+ ) -> jax.Array:
267
+ """Evaluate the eliminated dual objective over beta."""
268
+ z_s, b, b_n, tau, tau_m = args
269
+ alpha, nu = _alpha_nu_from_beta(z_s, beta)
270
+
271
+ val = jnp.dot(alpha, b) + nu * b_n
272
+ val = val + tau * jnp.sum(jnp.exp(-alpha / tau))
273
+ val = val + tau_m * jnp.sum(jnp.exp(-beta / tau_m))
274
+ if l2_beta:
275
+ val = val + 0.5 * jnp.asarray(
276
+ l2_beta,
277
+ z.dtype,
278
+ ) * jnp.sum(beta * beta)
279
+ return val
280
+
281
+ beta0 = jnp.zeros((n - 1,), dtype=z.dtype)
282
+ solver = optx.LBFGS(
283
+ rtol=tol,
284
+ atol=tol,
285
+ history_length=history_length,
286
+ )
287
+
288
+ sol = optx.minimise(
289
+ dual_obj,
290
+ solver,
291
+ beta0,
292
+ args=(z_s, b, b_n, tau, tau_m),
293
+ max_steps=max_iter,
294
+ throw=throw,
295
+ )
296
+ beta_star = sol.value
297
+
298
+ alpha_star, _ = _alpha_nu_from_beta(z_s, beta_star)
299
+
300
+ # primal variables from KKT
301
+ s = jnp.exp(-alpha_star / tau) # (n-1,)
302
+ d = jnp.exp(-beta_star / tau_m) # (n-1,)
303
+
304
+ y_s = _reconstruct_y_from_slacks_and_gaps(b, b_n, s, d)
305
+ result = y_s[iz]
306
+ if result.dtype != orig_dtype:
307
+ result = result.astype(orig_dtype)
308
+ return result
309
+
310
+
311
+ def _hessian_beta_matvec(
312
+ v: jax.Array,
313
+ w_alpha: jax.Array,
314
+ w_beta: jax.Array,
315
+ ) -> jax.Array:
316
+ """Matvec with L*diag(w_a)*L*v + diag(w_b)*v. L is symmetric."""
317
+ Lv = _tridiag_L_matvec(v)
318
+ return _tridiag_L_matvec(w_alpha * Lv) + w_beta * v
319
+
320
+
321
+ def _make_proj_permutahedron_entropic_lp(
322
+ tol: float = 1e-5,
323
+ max_iter: int = 200,
324
+ softness: float = 1.0,
325
+ softness_mono: float | None = None,
326
+ bounds_softness: float | Array = 1.0,
327
+ checkpointed: bool = True,
328
+ ) -> callable:
329
+ """Return a ``(z, w) -> result`` function with C-inf smooth grads.
330
+
331
+ Uses smooth majorization bounds (elementary symmetric polynomials)
332
+ for C-infinity gradients w.r.t. ``w``. The ``custom_vjp`` wraps
333
+ only the LBFGS solver ``(z, b, b_n) -> y``, providing analytical
334
+ gradients for ``z`` and pass-through gradients for ``b`` / ``b_n``.
335
+ Gradients from ``b`` / ``b_n`` to ``w`` flow via standard JAX
336
+ autodiff through :func:`_smooth_majorization_bounds`.
337
+
338
+ Requires the ``optimistix`` package.
339
+ """
340
+ if not HAS_OPTIMISTIX:
341
+ msg = (
342
+ "optimistix is required for "
343
+ "_make_proj_permutahedron_entropic_lp. "
344
+ "Install it with: pip install optimistix"
345
+ )
346
+ raise ImportError(msg)
347
+
348
+ # -- inner solver with custom_vjp on (z, b, b_n) --
349
+
350
+ @jax.custom_vjp
351
+ def _solver(
352
+ z: jax.Array,
353
+ b: jax.Array,
354
+ b_n: jax.Array,
355
+ ) -> jax.Array:
356
+ """Solve the entropic LP dual via LBFGS and recover primal y."""
357
+ orig_dtype = z.dtype
358
+ _hp = _high_precision_dtype()
359
+ if z.dtype != _hp:
360
+ z = z.astype(_hp)
361
+ b = b.astype(_hp)
362
+ b_n = b_n.astype(_hp)
363
+
364
+ tau = jnp.asarray(softness, dtype=z.dtype)
365
+ tau_m = tau if softness_mono is None else jnp.asarray(softness_mono, dtype=z.dtype)
366
+
367
+ perm_z = jnp.argsort(-z, stable=True)
368
+ inv_perm_z = _inv_permutation(perm_z)
369
+ z_s = z[perm_z]
370
+
371
+ def dual_obj(
372
+ beta: jax.Array,
373
+ args: tuple,
374
+ ) -> jax.Array:
375
+ """Evaluate the entropic dual objective for LBFGS."""
376
+ z_s_, b_, b_n_, tau_, tau_m_ = args
377
+ alpha, nu = _alpha_nu_from_beta(z_s_, beta)
378
+ val = jnp.dot(alpha, b_) + nu * b_n_
379
+ val = val + tau_ * jnp.sum(
380
+ jnp.exp(-alpha / tau_),
381
+ )
382
+ val = val + tau_m_ * jnp.sum(
383
+ jnp.exp(-beta / tau_m_),
384
+ )
385
+ return val
386
+
387
+ n = z.shape[0]
388
+ beta0 = jnp.zeros((n - 1,), dtype=z.dtype)
389
+ solver_lbfgs = optx.LBFGS(
390
+ rtol=tol,
391
+ atol=tol,
392
+ history_length=5,
393
+ )
394
+ sol = optx.minimise(
395
+ dual_obj,
396
+ solver_lbfgs,
397
+ beta0,
398
+ args=(z_s, b, b_n, tau, tau_m),
399
+ max_steps=max_iter,
400
+ throw=False,
401
+ )
402
+ beta_star = sol.value
403
+ alpha_star, _ = _alpha_nu_from_beta(z_s, beta_star)
404
+
405
+ s = jnp.exp(-alpha_star / tau)
406
+ d = jnp.exp(-beta_star / tau_m)
407
+
408
+ y_s = _reconstruct_y_from_slacks_and_gaps(
409
+ b,
410
+ b_n,
411
+ s,
412
+ d,
413
+ )
414
+ result = y_s[inv_perm_z]
415
+ if result.dtype != orig_dtype:
416
+ result = result.astype(orig_dtype)
417
+ return result
418
+
419
+ def _solver_fwd(
420
+ z: jax.Array,
421
+ b: jax.Array,
422
+ b_n: jax.Array,
423
+ ) -> tuple[jax.Array, tuple]:
424
+ """Forward pass for the entropic LP solver custom VJP."""
425
+ z = jnp.asarray(z)
426
+ b = jnp.asarray(b)
427
+ b_n = jnp.asarray(b_n)
428
+ n = z.shape[0]
429
+
430
+ orig_dtype = z.dtype
431
+ _hp = _high_precision_dtype()
432
+ if z.dtype != _hp:
433
+ z = z.astype(_hp)
434
+ b = b.astype(_hp)
435
+ b_n = b_n.astype(_hp)
436
+
437
+ tau = jnp.asarray(softness, dtype=z.dtype)
438
+ tau_m = tau if softness_mono is None else jnp.asarray(softness_mono, dtype=z.dtype)
439
+
440
+ perm_z = jnp.argsort(-z, stable=True)
441
+ inv_perm_z = _inv_permutation(perm_z)
442
+ z_s = z[perm_z]
443
+
444
+ if n <= 1:
445
+ result = jnp.full_like(z, b_n)
446
+ if orig_dtype != _hp:
447
+ result = result.astype(orig_dtype)
448
+ s = jnp.empty((0,), dtype=z.dtype)
449
+ d = jnp.empty((0,), dtype=z.dtype)
450
+ return result, (
451
+ perm_z,
452
+ inv_perm_z,
453
+ s,
454
+ d,
455
+ tau,
456
+ tau_m,
457
+ )
458
+
459
+ def dual_obj(
460
+ beta: jax.Array,
461
+ args: tuple,
462
+ ) -> jax.Array:
463
+ """Evaluate the entropic dual objective for forward LBFGS."""
464
+ z_s_, b_, b_n_, tau_, tau_m_ = args
465
+ alpha, nu = _alpha_nu_from_beta(z_s_, beta)
466
+ val = jnp.dot(alpha, b_) + nu * b_n_
467
+ val = val + tau_ * jnp.sum(
468
+ jnp.exp(-alpha / tau_),
469
+ )
470
+ val = val + tau_m_ * jnp.sum(
471
+ jnp.exp(-beta / tau_m_),
472
+ )
473
+ return val
474
+
475
+ beta0 = jnp.zeros((n - 1,), dtype=z.dtype)
476
+ solver_lbfgs = optx.LBFGS(
477
+ rtol=tol,
478
+ atol=tol,
479
+ history_length=5,
480
+ )
481
+ sol = optx.minimise(
482
+ dual_obj,
483
+ solver_lbfgs,
484
+ beta0,
485
+ args=(z_s, b, b_n, tau, tau_m),
486
+ max_steps=max_iter,
487
+ throw=False,
488
+ )
489
+ beta_star = sol.value
490
+ alpha_star, _ = _alpha_nu_from_beta(z_s, beta_star)
491
+
492
+ s = jnp.exp(-alpha_star / tau)
493
+ d = jnp.exp(-beta_star / tau_m)
494
+
495
+ y_s = _reconstruct_y_from_slacks_and_gaps(
496
+ b,
497
+ b_n,
498
+ s,
499
+ d,
500
+ )
501
+ result = y_s[inv_perm_z]
502
+ if orig_dtype != jnp.float64:
503
+ result = result.astype(orig_dtype)
504
+
505
+ return result, (
506
+ perm_z,
507
+ inv_perm_z,
508
+ s,
509
+ d,
510
+ tau,
511
+ tau_m,
512
+ )
513
+
514
+ def _solver_bwd(
515
+ residuals: tuple,
516
+ g: jax.Array,
517
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
518
+ """Backward pass for the entropic LP solver custom VJP."""
519
+ perm_z, inv_perm_z, s, d, tau, tau_m = residuals
520
+ orig_dtype = g.dtype
521
+ g = jnp.asarray(g)
522
+ _hp = _high_precision_dtype()
523
+ if g.dtype != _hp:
524
+ g = g.astype(_hp)
525
+
526
+ g_s = g[perm_z]
527
+
528
+ inv_s = 1.0 / s
529
+ inv_d = 1.0 / d
530
+
531
+ # Solve H_y*lam + mu*1 = g_s with 1^T*lam = 0.
532
+ # Substitute lam = D^T*eta (zero-mean since 1^T*D^T = 0)
533
+ # to get M*eta = D*g_s where
534
+ # M = diag(1/w_alpha) + L*diag(1/w_beta)*L.
535
+ w_alpha = s / tau
536
+ w_beta = d / tau_m
537
+ rhs = g_s[:-1] - g_s[1:] # D*g_s
538
+
539
+ def m_matvec(v: jax.Array) -> jax.Array:
540
+ """Compute M*v for the CG linear solve."""
541
+ return _hessian_beta_matvec(
542
+ v,
543
+ 1.0 / w_beta,
544
+ 1.0 / w_alpha,
545
+ )
546
+
547
+ eta, _ = jax.scipy.sparse.linalg.cg(
548
+ m_matvec,
549
+ rhs,
550
+ tol=tol,
551
+ maxiter=50,
552
+ )
553
+ lam = jnp.concatenate(
554
+ [eta[:1], eta[1:] - eta[:-1], -eta[-1:]],
555
+ ) # D^T*eta
556
+
557
+ def _reverse_cumsum(u: jax.Array) -> jax.Array:
558
+ """Compute reverse cumulative sum of u."""
559
+ return jnp.flip(jnp.cumsum(jnp.flip(u)))
560
+
561
+ def _at_matvec(u: jax.Array) -> jax.Array:
562
+ """A^T u: (n-1,) -> (n,), prefix-sum matrix."""
563
+ return jnp.concatenate(
564
+ [
565
+ _reverse_cumsum(u),
566
+ jnp.zeros((1,), dtype=u.dtype),
567
+ ],
568
+ )
569
+
570
+ def h_y_matvec(v: jax.Array) -> jax.Array:
571
+ """H_y v = tau*A^T diag(1/s) A v + tau_m*D^T diag(1/d) D v."""
572
+ Av = jnp.cumsum(v)[:-1]
573
+ Dv = v[:-1] - v[1:]
574
+ return tau * _at_matvec(Av * inv_s) + tau_m * _diff_T(Dv * inv_d)
575
+
576
+ h_lam = h_y_matvec(lam)
577
+ mu = jnp.mean(g_s - h_lam)
578
+
579
+ # Gradients for (z, b, b_n) -- no chain-rule to w here;
580
+ # autodiff through _smooth_majorization_bounds handles that.
581
+ grad_z_s = lam
582
+ grad_b = tau * jnp.cumsum(lam)[:-1] * inv_s
583
+ grad_b_n = mu
584
+
585
+ grad_z = grad_z_s[inv_perm_z]
586
+ if orig_dtype != jnp.float64:
587
+ grad_z = grad_z.astype(orig_dtype)
588
+ grad_b = grad_b.astype(orig_dtype)
589
+ grad_b_n = grad_b_n.astype(orig_dtype)
590
+
591
+ return (grad_z, grad_b, grad_b_n)
592
+
593
+ _solver.defvjp(_solver_fwd, _solver_bwd)
594
+
595
+ # -- outer function: smooth bounds + solver --
596
+
597
+ def _proj_fn(z: jax.Array, w: jax.Array) -> jax.Array:
598
+ """Project z onto the permutahedron of w using smooth bounds."""
599
+ n = z.shape[0]
600
+ if n <= 1:
601
+ return w
602
+ tau_bounds = jnp.asarray(bounds_softness, dtype=w.dtype)
603
+ b, b_n = _smooth_majorization_bounds(
604
+ w,
605
+ tau_bounds,
606
+ checkpointed,
607
+ )
608
+ return _solver(z, b, b_n)
609
+
610
+ return _proj_fn
611
+
612
+
613
+ # -- c0: euclidean (q=2) ----------------------------------------------
614
+
615
+
616
+ def _pav_isotonic_decreasing_pnorm_q2(
617
+ y: jax.Array,
618
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
619
+ """PAV isotonic regression for the q=2 (euclidean) case."""
620
+ y = jnp.asarray(y)
621
+ n = y.shape[0]
622
+ dtype = y.dtype
623
+
624
+ starts0 = jnp.full((n,), n, dtype=jnp.int32)
625
+ sums0 = jnp.zeros((n,), dtype=dtype)
626
+ lens0 = jnp.ones((n,), dtype=jnp.int32)
627
+ m0 = jnp.int32(0)
628
+
629
+ def merge_cond(
630
+ state: tuple,
631
+ ) -> jax.Array:
632
+ """Check whether the top two blocks violate isotonic order."""
633
+ starts, sums, lens, m = state
634
+ return (m >= 2) & (
635
+ (sums[m - 2] / lens[m - 2].astype(dtype)) < (sums[m - 1] / lens[m - 1].astype(dtype))
636
+ )
637
+
638
+ def merge_body(
639
+ state: tuple,
640
+ ) -> tuple:
641
+ """Merge the top two blocks on the stack."""
642
+ starts, sums, lens, m = state
643
+ i_prev = m - 2
644
+ i_top = m - 1
645
+
646
+ sums = sums.at[i_prev].set(
647
+ sums[i_prev] + sums[i_top],
648
+ )
649
+ lens = lens.at[i_prev].set(
650
+ lens[i_prev] + lens[i_top],
651
+ )
652
+
653
+ sums = sums.at[i_top].set(
654
+ jnp.array(0, dtype=dtype),
655
+ )
656
+ lens = lens.at[i_top].set(jnp.int32(1))
657
+ starts = starts.at[i_top].set(jnp.int32(n))
658
+
659
+ return (starts, sums, lens, m - 1)
660
+
661
+ def for_body(
662
+ i: jax.Array,
663
+ state: tuple,
664
+ ) -> tuple:
665
+ """Push element i onto the stack and merge violating blocks."""
666
+ starts, sums, lens, m = state
667
+ starts = starts.at[m].set(jnp.int32(i))
668
+ sums = sums.at[m].set(y[i])
669
+ lens = lens.at[m].set(jnp.int32(1))
670
+ m = m + 1
671
+ return lax.while_loop(
672
+ merge_cond,
673
+ merge_body,
674
+ (starts, sums, lens, m),
675
+ )
676
+
677
+ starts, sums, lens, m = lax.fori_loop(
678
+ 0,
679
+ n,
680
+ for_body,
681
+ (starts0, sums0, lens0, m0),
682
+ )
683
+
684
+ idx = jnp.arange(n, dtype=jnp.int32)
685
+ starts = jnp.where(idx < m, starts, jnp.int32(n))
686
+ sums = jnp.where(
687
+ idx < m,
688
+ sums,
689
+ jnp.array(0, dtype=dtype),
690
+ )
691
+ lens = jnp.where(idx < m, lens, jnp.int32(1))
692
+
693
+ avgs = sums / lens.astype(dtype)
694
+ block_idx = jnp.searchsorted(starts, idx, side="right") - jnp.int32(1)
695
+ v = avgs[block_idx]
696
+ return v, block_idx, starts, lens
697
+
698
+
699
+ @jax.custom_vjp
700
+ def _proj_permutahedron_pnorm_q2(
701
+ z: jax.Array,
702
+ w: jax.Array,
703
+ ) -> jax.Array:
704
+ """Project z onto the permutahedron of w using q=2 (euclidean)."""
705
+ z = jnp.asarray(z)
706
+ w = jnp.asarray(w)
707
+
708
+ perm_z = jnp.argsort(-z, stable=True)
709
+ z_sorted = z[perm_z]
710
+ inv_perm_z = _inv_permutation(perm_z)
711
+
712
+ perm_w = jnp.argsort(-w, stable=True)
713
+ w_sorted = w[perm_w]
714
+
715
+ y = z_sorted - w_sorted
716
+ v, _, _, _ = _pav_isotonic_decreasing_pnorm_q2(y)
717
+
718
+ p_sorted = z_sorted - v
719
+ return p_sorted[inv_perm_z]
720
+
721
+
722
+ def _proj_permutahedron_pnorm_q2_fwd(
723
+ z: jax.Array,
724
+ w: jax.Array,
725
+ ) -> tuple[jax.Array, tuple]:
726
+ """Forward pass for q=2 projection custom VJP."""
727
+ z = jnp.asarray(z)
728
+ w = jnp.asarray(w)
729
+
730
+ perm_z = jnp.argsort(-z, stable=True)
731
+ z_sorted = z[perm_z]
732
+ inv_perm_z = _inv_permutation(perm_z)
733
+
734
+ perm_w = jnp.argsort(-w, stable=True)
735
+ w_sorted = w[perm_w]
736
+ inv_perm_w = _inv_permutation(perm_w)
737
+
738
+ y = z_sorted - w_sorted
739
+ v, block_idx, _, lens = _pav_isotonic_decreasing_pnorm_q2(y)
740
+
741
+ p_sorted = z_sorted - v
742
+ p = p_sorted[inv_perm_z]
743
+
744
+ aux = (
745
+ perm_z,
746
+ inv_perm_z,
747
+ perm_w,
748
+ inv_perm_w,
749
+ block_idx,
750
+ lens,
751
+ )
752
+ return p, aux
753
+
754
+
755
+ def _proj_permutahedron_pnorm_q2_bwd(
756
+ aux: tuple,
757
+ g: jax.Array,
758
+ ) -> tuple[jax.Array, jax.Array]:
759
+ """Backward pass for q=2 projection custom VJP."""
760
+ (
761
+ perm_z,
762
+ inv_perm_z,
763
+ perm_w,
764
+ inv_perm_w,
765
+ block_idx,
766
+ lens,
767
+ ) = aux
768
+ g = jnp.asarray(g)
769
+ dtype = g.dtype
770
+ n = g.shape[0]
771
+
772
+ g_sorted = g[perm_z]
773
+ block_sum_g = segment_sum(
774
+ g_sorted,
775
+ block_idx,
776
+ num_segments=n,
777
+ )
778
+ Jt_g = block_sum_g[block_idx] / lens[block_idx].astype(
779
+ dtype,
780
+ ) # symmetric => same as J g
781
+
782
+ grad_z_sorted = g_sorted - Jt_g
783
+ grad_w_sorted = Jt_g
784
+
785
+ return (
786
+ grad_z_sorted[inv_perm_z],
787
+ grad_w_sorted[inv_perm_w],
788
+ )
789
+
790
+
791
+ _proj_permutahedron_pnorm_q2.defvjp(
792
+ _proj_permutahedron_pnorm_q2_fwd,
793
+ _proj_permutahedron_pnorm_q2_bwd,
794
+ )
795
+
796
+
797
+ # -- c1: p-norm q=3 (p=3/2) -------------------------------------------
798
+
799
+
800
+ def _solve_block_gamma_q3(
801
+ s_sorted: jax.Array,
802
+ prefix_s: jax.Array,
803
+ prefix_s2: jax.Array,
804
+ start: jax.Array,
805
+ length: jax.Array,
806
+ sum_w: jax.Array,
807
+ min_s: jax.Array,
808
+ max_s: jax.Array,
809
+ ) -> jax.Array:
810
+ r"""Analytical solver for sum_block (gamma - s)|gamma - s| + sum_w = 0.
811
+
812
+ The function g(gamma) is piecewise quadratic with breakpoints at
813
+ each s_i. For each possible split point k (separating elements
814
+ >= gamma from those < gamma), g is a quadratic a*gamma^2 + b*gamma
815
+ + c whose coefficients are computed from prefix sums. We solve all
816
+ quadratics in parallel and select the root that falls in its valid
817
+ interval.
818
+ """
819
+ dtype = s_sorted.dtype
820
+ n = s_sorted.shape[0]
821
+ end = start + length
822
+
823
+ # Possible split points k in {0, 1, ..., n}.
824
+ # Split k: elements [start, k) have s >= gamma,
825
+ # [k, end) have s < gamma.
826
+ ks = jnp.arange(n + 1)
827
+
828
+ # Quadratic coefficients g_k(gamma) = a*gamma^2 + b*gamma + c = 0
829
+ n_hi = (ks - start).astype(dtype)
830
+ n_lo = (end - ks).astype(dtype)
831
+ S_hi = prefix_s[ks] - prefix_s[start]
832
+ S_lo = prefix_s[end] - prefix_s[ks]
833
+ M2_hi = prefix_s2[ks] - prefix_s2[start]
834
+ M2_lo = prefix_s2[end] - prefix_s2[ks]
835
+
836
+ a = n_lo - n_hi
837
+ b = 2.0 * (S_hi - S_lo)
838
+ c = (M2_lo - M2_hi) + sum_w
839
+
840
+ # The ascending zero-crossing is always at
841
+ # (-b + sqrt(disc)) / (2a).
842
+ disc = b * b - 4.0 * a * c
843
+ sqrt_disc = jnp.sqrt(jnp.maximum(disc, 0.0))
844
+ a_safe = jnp.where(a != 0.0, a, 1.0)
845
+ b_safe = jnp.where(b != 0.0, b, 1.0)
846
+ gamma_quad = (-b + sqrt_disc) / (2.0 * a_safe)
847
+ gamma_lin = -c / b_safe
848
+ gamma_k = jnp.where(a != 0.0, gamma_quad, gamma_lin)
849
+
850
+ # Validity interval (s sorted descending):
851
+ # k = start: gamma > s[start], upper = +inf
852
+ # start < k < end: s[k] < gamma <= s[k-1]
853
+ # k = end: lower = -inf, gamma <= s[end-1]
854
+ s_at_k = s_sorted[jnp.clip(ks, 0, n - 1)]
855
+ s_at_km1 = s_sorted[jnp.clip(ks - 1, 0, n - 1)]
856
+ lo_bound = jnp.where(ks < end, s_at_k, -jnp.inf)
857
+ hi_bound = jnp.where(ks > start, s_at_km1, jnp.inf)
858
+
859
+ eps = jnp.finfo(dtype).eps ** 0.75
860
+ valid = (
861
+ (ks >= start)
862
+ & (ks <= end)
863
+ & (gamma_k > lo_bound - eps)
864
+ & (gamma_k <= hi_bound + eps)
865
+ & (disc >= -eps)
866
+ & ((a != 0.0) | (b != 0.0))
867
+ )
868
+
869
+ best = jnp.argmax(valid)
870
+ result = gamma_k[best]
871
+ return jnp.where(
872
+ jnp.any(valid),
873
+ result,
874
+ (min_s + max_s) * 0.5,
875
+ )
876
+
877
+
878
+ def _pav_isotonic_decreasing_pnorm_q3(
879
+ s: jax.Array,
880
+ w: jax.Array,
881
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
882
+ """PAV isotonic regression for the q=3 (p=3/2) case."""
883
+ s = jnp.asarray(s)
884
+ w = jnp.asarray(w)
885
+ n = s.shape[0]
886
+ dtype = s.dtype
887
+
888
+ _zero = jnp.zeros((1,), dtype=dtype)
889
+ prefix_s = jnp.concatenate([_zero, jnp.cumsum(s)])
890
+ prefix_s2 = jnp.concatenate([_zero, jnp.cumsum(s * s)])
891
+
892
+ starts0 = jnp.full((n,), n, dtype=jnp.int32)
893
+ lens0 = jnp.ones((n,), dtype=jnp.int32)
894
+ sumw0 = jnp.zeros((n,), dtype=dtype)
895
+ mins0 = jnp.full((n,), jnp.inf, dtype=dtype)
896
+ maxs0 = jnp.full((n,), -jnp.inf, dtype=dtype)
897
+ gam0 = jnp.zeros((n,), dtype=dtype)
898
+ m0 = jnp.int32(0)
899
+
900
+ def merge_cond(state: tuple) -> jax.Array:
901
+ """Check whether the top two q=3 blocks violate isotonic order."""
902
+ starts, lens, sumw, mins, maxs, gam, m = state
903
+ return (m >= 2) & (gam[m - 2] < gam[m - 1])
904
+
905
+ def merge_body(state: tuple) -> tuple:
906
+ """Merge the top two q=3 blocks on the stack."""
907
+ starts, lens, sumw, mins, maxs, gam, m = state
908
+ i_prev = m - 2
909
+ i_top = m - 1
910
+
911
+ lens_new = lens[i_prev] + lens[i_top]
912
+ sumw_new = sumw[i_prev] + sumw[i_top]
913
+ mins_new = jnp.minimum(mins[i_prev], mins[i_top])
914
+ maxs_new = jnp.maximum(maxs[i_prev], maxs[i_top])
915
+
916
+ gam_new = _solve_block_gamma_q3(
917
+ s,
918
+ prefix_s,
919
+ prefix_s2,
920
+ starts[i_prev],
921
+ lens_new,
922
+ sumw_new,
923
+ mins_new,
924
+ maxs_new,
925
+ )
926
+
927
+ lens = lens.at[i_prev].set(lens_new)
928
+ sumw = sumw.at[i_prev].set(sumw_new)
929
+ mins = mins.at[i_prev].set(mins_new)
930
+ maxs = maxs.at[i_prev].set(maxs_new)
931
+ gam = gam.at[i_prev].set(gam_new)
932
+
933
+ starts = starts.at[i_top].set(jnp.int32(n))
934
+ lens = lens.at[i_top].set(jnp.int32(1))
935
+ sumw = sumw.at[i_top].set(
936
+ jnp.array(0, dtype=dtype),
937
+ )
938
+ mins = mins.at[i_top].set(
939
+ jnp.array(jnp.inf, dtype=dtype),
940
+ )
941
+ maxs = maxs.at[i_top].set(
942
+ jnp.array(-jnp.inf, dtype=dtype),
943
+ )
944
+ gam = gam.at[i_top].set(
945
+ jnp.array(0, dtype=dtype),
946
+ )
947
+
948
+ return (
949
+ starts,
950
+ lens,
951
+ sumw,
952
+ mins,
953
+ maxs,
954
+ gam,
955
+ m - 1,
956
+ )
957
+
958
+ def for_body(i: jax.Array, state: tuple) -> tuple:
959
+ """Push element i onto the q=3 stack and merge violating blocks."""
960
+ starts, lens, sumw, mins, maxs, gam, m = state
961
+
962
+ si = s[i]
963
+ wi = w[i]
964
+ starts = starts.at[m].set(jnp.int32(i))
965
+ lens = lens.at[m].set(jnp.int32(1))
966
+ sumw = sumw.at[m].set(wi)
967
+ mins = mins.at[m].set(si)
968
+ maxs = maxs.at[m].set(si)
969
+
970
+ gam_i = _solve_block_gamma_q3(
971
+ s,
972
+ prefix_s,
973
+ prefix_s2,
974
+ i,
975
+ jnp.int32(1),
976
+ wi,
977
+ si,
978
+ si,
979
+ )
980
+ gam = gam.at[m].set(gam_i)
981
+
982
+ m = m + 1
983
+ return lax.while_loop(
984
+ merge_cond,
985
+ merge_body,
986
+ (starts, lens, sumw, mins, maxs, gam, m),
987
+ )
988
+
989
+ starts, lens, sumw, mins, maxs, gam, m = lax.fori_loop(
990
+ 0,
991
+ n,
992
+ for_body,
993
+ (starts0, lens0, sumw0, mins0, maxs0, gam0, m0),
994
+ )
995
+
996
+ idx = jnp.arange(n, dtype=jnp.int32)
997
+ starts = jnp.where(idx < m, starts, jnp.int32(n))
998
+ lens = jnp.where(idx < m, lens, jnp.int32(1))
999
+ gam = jnp.where(
1000
+ idx < m,
1001
+ gam,
1002
+ jnp.array(0, dtype=dtype),
1003
+ )
1004
+
1005
+ block_idx = jnp.searchsorted(starts, idx, side="right") - jnp.int32(1)
1006
+ v = gam[block_idx]
1007
+ return v, block_idx, lens
1008
+
1009
+
1010
+ @jax.custom_vjp
1011
+ def _proj_permutahedron_pnorm_q3(
1012
+ z: jax.Array,
1013
+ w: jax.Array,
1014
+ ) -> jax.Array:
1015
+ """Project z onto the permutahedron of w using q=3 (p=3/2)."""
1016
+ z = jnp.asarray(z)
1017
+ w = jnp.asarray(w)
1018
+ orig_dtype = z.dtype
1019
+ _hp = _high_precision_dtype()
1020
+ z, w = z.astype(_hp), w.astype(_hp)
1021
+
1022
+ perm_z = jnp.argsort(-z, stable=True)
1023
+ z_sorted = z[perm_z]
1024
+ inv_perm_z = _inv_permutation(perm_z)
1025
+
1026
+ perm_w = jnp.argsort(-w, stable=True)
1027
+ w_sorted = w[perm_w]
1028
+
1029
+ v, block_idx, lens = _pav_isotonic_decreasing_pnorm_q3(
1030
+ z_sorted,
1031
+ w_sorted,
1032
+ )
1033
+
1034
+ t = z_sorted - v
1035
+ # q=3 => nabla R^*(t) = t|t|^{q-2} = t|t|
1036
+ y_sorted = t * jnp.abs(t)
1037
+
1038
+ return y_sorted[inv_perm_z].astype(orig_dtype)
1039
+
1040
+
1041
+ def _proj_permutahedron_pnorm_q3_fwd(
1042
+ z: jax.Array,
1043
+ w: jax.Array,
1044
+ ) -> tuple[jax.Array, tuple]:
1045
+ """Forward pass for q=3 projection custom VJP."""
1046
+ z = jnp.asarray(z)
1047
+ w = jnp.asarray(w)
1048
+ orig_dtype = z.dtype
1049
+ _hp = _high_precision_dtype()
1050
+ z, w = z.astype(_hp), w.astype(_hp)
1051
+
1052
+ perm_z = jnp.argsort(-z, stable=True)
1053
+ z_sorted = z[perm_z]
1054
+ inv_perm_z = _inv_permutation(perm_z)
1055
+
1056
+ perm_w = jnp.argsort(-w, stable=True)
1057
+ w_sorted = w[perm_w]
1058
+ inv_perm_w = _inv_permutation(perm_w)
1059
+
1060
+ v, block_idx, lens = _pav_isotonic_decreasing_pnorm_q3(
1061
+ z_sorted,
1062
+ w_sorted,
1063
+ )
1064
+
1065
+ t = z_sorted - v
1066
+ y_sorted = t * jnp.abs(t)
1067
+ y = y_sorted[inv_perm_z].astype(orig_dtype)
1068
+
1069
+ aux = (
1070
+ perm_z,
1071
+ inv_perm_z,
1072
+ perm_w,
1073
+ inv_perm_w,
1074
+ block_idx,
1075
+ lens,
1076
+ t,
1077
+ y,
1078
+ )
1079
+ return y, aux
1080
+
1081
+
1082
+ def _proj_permutahedron_pnorm_q3_bwd(
1083
+ aux: tuple,
1084
+ g: jax.Array,
1085
+ ) -> tuple[jax.Array, jax.Array]:
1086
+ """Backward pass for q=3 projection custom VJP."""
1087
+ (
1088
+ perm_z,
1089
+ inv_perm_z,
1090
+ perm_w,
1091
+ inv_perm_w,
1092
+ block_idx,
1093
+ lens,
1094
+ t,
1095
+ y,
1096
+ ) = aux
1097
+ orig_dtype = g.dtype
1098
+ _hp = _high_precision_dtype()
1099
+ g = jnp.asarray(g).astype(_hp)
1100
+ n = g.shape[0]
1101
+ dtype = _hp
1102
+
1103
+ # upstream grad on y_sorted
1104
+ g_sorted_y = g[perm_z]
1105
+
1106
+ # y = t|t| => dy/dt = 2|t|
1107
+ g_t = g_sorted_y * (2.0 * jnp.abs(t))
1108
+
1109
+ # weights for (d v / d s)^T g_t : alpha_i propto |t_i|^{q-2} = |t_i|
1110
+ weight = jnp.abs(t)
1111
+ denom_block = segment_sum(
1112
+ weight,
1113
+ block_idx,
1114
+ num_segments=n,
1115
+ )
1116
+ denom = denom_block[block_idx]
1117
+
1118
+ sumg_block = segment_sum(
1119
+ g_t,
1120
+ block_idx,
1121
+ num_segments=n,
1122
+ )
1123
+ sumg = sumg_block[block_idx]
1124
+
1125
+ lens_elem = lens[block_idx].astype(dtype)
1126
+ alpha = jnp.where(
1127
+ denom > 0,
1128
+ weight / denom,
1129
+ jnp.array(1.0, dtype=dtype) / lens_elem,
1130
+ )
1131
+
1132
+ jtg_s = alpha * sumg
1133
+ grad_z_sorted = g_t - jtg_s
1134
+
1135
+ # d gamma/dw_k = -1/(2 sum |t|)
1136
+ # => dt/dw_k = +1/(2 sum |t|)
1137
+ grad_w_sorted = jnp.where(
1138
+ denom > 0,
1139
+ sumg / (2.0 * denom),
1140
+ jnp.array(0.0, dtype=dtype),
1141
+ )
1142
+
1143
+ return (
1144
+ grad_z_sorted[inv_perm_z].astype(orig_dtype),
1145
+ grad_w_sorted[inv_perm_w].astype(orig_dtype),
1146
+ )
1147
+
1148
+
1149
+ _proj_permutahedron_pnorm_q3.defvjp(
1150
+ _proj_permutahedron_pnorm_q3_fwd,
1151
+ _proj_permutahedron_pnorm_q3_bwd,
1152
+ )
1153
+
1154
+
1155
+ # -- c2: p-norm q=4 (p=4/3) -------------------------------------------
1156
+
1157
+
1158
+ def _solve_block_gamma_q4(
1159
+ len_b: jax.Array,
1160
+ sum_w: jax.Array,
1161
+ m1: jax.Array,
1162
+ m2: jax.Array,
1163
+ m3: jax.Array,
1164
+ min_s: jax.Array,
1165
+ max_s: jax.Array,
1166
+ ) -> jax.Array:
1167
+ r"""Closed-form solver for the q=4 block gamma.
1168
+
1169
+ Solves sum_block (gamma - s)^3 + sum_w = 0 via Cardano's
1170
+ hyperbolic method. In shifted form u = gamma - c, this is the
1171
+ depressed cubic u^3 + pu + q = 0 with p = 3*mu_2/n >= 0, which
1172
+ always has exactly one real root.
1173
+ """
1174
+ dtype = m1.dtype
1175
+ len_f = len_b.astype(dtype)
1176
+ c = m1 / len_f # mean of s values in block
1177
+ # Central moments: mu_k = sum (s_i - c)^k
1178
+ mu2 = m2 - 2.0 * c * m1 + len_f * c * c
1179
+ mu3 = m3 - 3.0 * c * m2 + 3.0 * c * c * m1 - len_f * c**3
1180
+
1181
+ # Depressed cubic: u^3 + p*u + q = 0
1182
+ p = 3.0 * mu2 / len_f # >= 0 (sum of squares)
1183
+ q = (sum_w - mu3) / len_f
1184
+
1185
+ # Hyperbolic Cardano:
1186
+ # u = -sign(q) * 2*sqrt(p/3)
1187
+ # * sinh(arcsinh(A)/3)
1188
+ # where A = 3|q| / (2*p*sqrt(p/3))
1189
+ sp3 = jnp.sqrt(jnp.maximum(p / 3.0, 0.0)) # sqrt(p/3)
1190
+ denom = (
1191
+ 2.0
1192
+ * jnp.maximum(
1193
+ p,
1194
+ jnp.finfo(dtype).tiny,
1195
+ )
1196
+ * sp3
1197
+ )
1198
+ A = 3.0 * jnp.abs(q) / denom
1199
+ u_hyp = -jnp.sign(q) * 2.0 * sp3 * jnp.sinh(jnp.arcsinh(A) / 3.0)
1200
+
1201
+ # When p ~ 0 (all s equal): u^3 + q = 0 => u = cbrt(-q)
1202
+ u_cbrt = -jnp.sign(q) * jnp.abs(q) ** (1.0 / 3.0)
1203
+
1204
+ u = jnp.where(
1205
+ p
1206
+ > jnp.finfo(dtype).eps
1207
+ * jnp.maximum(
1208
+ jnp.abs(q),
1209
+ 1.0,
1210
+ ),
1211
+ u_hyp,
1212
+ u_cbrt,
1213
+ )
1214
+ return u + c
1215
+
1216
+
1217
+ def _pav_isotonic_decreasing_pnorm_q4(
1218
+ s: jax.Array,
1219
+ w: jax.Array,
1220
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
1221
+ """PAV isotonic regression for the q=4 (p=4/3) case."""
1222
+ s = jnp.asarray(s)
1223
+ w = jnp.asarray(w)
1224
+ n = s.shape[0]
1225
+ dtype = s.dtype
1226
+
1227
+ starts0 = jnp.full((n,), n, dtype=jnp.int32)
1228
+ lens0 = jnp.ones((n,), dtype=jnp.int32)
1229
+ sumw0 = jnp.zeros((n,), dtype=dtype)
1230
+ m10 = jnp.zeros((n,), dtype=dtype)
1231
+ m20 = jnp.zeros((n,), dtype=dtype)
1232
+ m30 = jnp.zeros((n,), dtype=dtype)
1233
+ mins0 = jnp.full((n,), jnp.inf, dtype=dtype)
1234
+ maxs0 = jnp.full((n,), -jnp.inf, dtype=dtype)
1235
+ gam0 = jnp.zeros((n,), dtype=dtype)
1236
+ m0 = jnp.int32(0)
1237
+
1238
+ def merge_cond(state: tuple) -> jax.Array:
1239
+ """Check whether the top two q=4 blocks violate isotonic order."""
1240
+ starts, lens, sumw, m1, m2, m3, mins, maxs, gam, m = state
1241
+ return (m >= 2) & (gam[m - 2] < gam[m - 1])
1242
+
1243
+ def merge_body(state: tuple) -> tuple:
1244
+ """Merge the top two q=4 blocks on the stack."""
1245
+ starts, lens, sumw, m1, m2, m3, mins, maxs, gam, m = state
1246
+ i_prev = m - 2
1247
+ i_top = m - 1
1248
+
1249
+ lens_new = lens[i_prev] + lens[i_top]
1250
+ sumw_new = sumw[i_prev] + sumw[i_top]
1251
+ m1_new = m1[i_prev] + m1[i_top]
1252
+ m2_new = m2[i_prev] + m2[i_top]
1253
+ m3_new = m3[i_prev] + m3[i_top]
1254
+ mins_new = jnp.minimum(mins[i_prev], mins[i_top])
1255
+ maxs_new = jnp.maximum(maxs[i_prev], maxs[i_top])
1256
+
1257
+ gam_new = _solve_block_gamma_q4(
1258
+ lens_new,
1259
+ sumw_new,
1260
+ m1_new,
1261
+ m2_new,
1262
+ m3_new,
1263
+ mins_new,
1264
+ maxs_new,
1265
+ )
1266
+
1267
+ lens = lens.at[i_prev].set(lens_new)
1268
+ sumw = sumw.at[i_prev].set(sumw_new)
1269
+ m1 = m1.at[i_prev].set(m1_new)
1270
+ m2 = m2.at[i_prev].set(m2_new)
1271
+ m3 = m3.at[i_prev].set(m3_new)
1272
+ mins = mins.at[i_prev].set(mins_new)
1273
+ maxs = maxs.at[i_prev].set(maxs_new)
1274
+ gam = gam.at[i_prev].set(gam_new)
1275
+
1276
+ starts = starts.at[i_top].set(jnp.int32(n))
1277
+ lens = lens.at[i_top].set(jnp.int32(1))
1278
+ sumw = sumw.at[i_top].set(
1279
+ jnp.array(0, dtype=dtype),
1280
+ )
1281
+ m1 = m1.at[i_top].set(
1282
+ jnp.array(0, dtype=dtype),
1283
+ )
1284
+ m2 = m2.at[i_top].set(
1285
+ jnp.array(0, dtype=dtype),
1286
+ )
1287
+ m3 = m3.at[i_top].set(
1288
+ jnp.array(0, dtype=dtype),
1289
+ )
1290
+ mins = mins.at[i_top].set(
1291
+ jnp.array(jnp.inf, dtype=dtype),
1292
+ )
1293
+ maxs = maxs.at[i_top].set(
1294
+ jnp.array(-jnp.inf, dtype=dtype),
1295
+ )
1296
+ gam = gam.at[i_top].set(
1297
+ jnp.array(0, dtype=dtype),
1298
+ )
1299
+
1300
+ return (
1301
+ starts,
1302
+ lens,
1303
+ sumw,
1304
+ m1,
1305
+ m2,
1306
+ m3,
1307
+ mins,
1308
+ maxs,
1309
+ gam,
1310
+ m - 1,
1311
+ )
1312
+
1313
+ def for_body(i: jax.Array, state: tuple) -> tuple:
1314
+ """Push element i onto the q=4 stack and merge violating blocks."""
1315
+ starts, lens, sumw, m1, m2, m3, mins, maxs, gam, m = state
1316
+
1317
+ si = s[i]
1318
+ wi = w[i]
1319
+ starts = starts.at[m].set(jnp.int32(i))
1320
+ lens = lens.at[m].set(jnp.int32(1))
1321
+ sumw = sumw.at[m].set(wi)
1322
+ m1 = m1.at[m].set(si)
1323
+ m2 = m2.at[m].set(si * si)
1324
+ m3 = m3.at[m].set(si * si * si)
1325
+ mins = mins.at[m].set(si)
1326
+ maxs = maxs.at[m].set(si)
1327
+
1328
+ gam_i = _solve_block_gamma_q4(
1329
+ jnp.int32(1),
1330
+ wi,
1331
+ si,
1332
+ si * si,
1333
+ si * si * si,
1334
+ si,
1335
+ si,
1336
+ )
1337
+ gam = gam.at[m].set(gam_i)
1338
+
1339
+ m = m + 1
1340
+ return lax.while_loop(
1341
+ merge_cond,
1342
+ merge_body,
1343
+ (
1344
+ starts,
1345
+ lens,
1346
+ sumw,
1347
+ m1,
1348
+ m2,
1349
+ m3,
1350
+ mins,
1351
+ maxs,
1352
+ gam,
1353
+ m,
1354
+ ),
1355
+ )
1356
+
1357
+ (
1358
+ starts,
1359
+ lens,
1360
+ sumw,
1361
+ m1,
1362
+ m2,
1363
+ m3,
1364
+ mins,
1365
+ maxs,
1366
+ gam,
1367
+ m,
1368
+ ) = lax.fori_loop(
1369
+ 0,
1370
+ n,
1371
+ for_body,
1372
+ (
1373
+ starts0,
1374
+ lens0,
1375
+ sumw0,
1376
+ m10,
1377
+ m20,
1378
+ m30,
1379
+ mins0,
1380
+ maxs0,
1381
+ gam0,
1382
+ m0,
1383
+ ),
1384
+ )
1385
+
1386
+ idx = jnp.arange(n, dtype=jnp.int32)
1387
+ starts = jnp.where(idx < m, starts, jnp.int32(n))
1388
+ lens = jnp.where(idx < m, lens, jnp.int32(1))
1389
+ gam = jnp.where(
1390
+ idx < m,
1391
+ gam,
1392
+ jnp.array(0, dtype=dtype),
1393
+ )
1394
+
1395
+ block_idx = jnp.searchsorted(starts, idx, side="right") - jnp.int32(1)
1396
+ v = gam[block_idx]
1397
+ return v, block_idx, lens
1398
+
1399
+
1400
+ @jax.custom_vjp
1401
+ def _proj_permutahedron_pnorm_q4(
1402
+ z: jax.Array,
1403
+ w: jax.Array,
1404
+ ) -> jax.Array:
1405
+ """Project z onto the permutahedron of w using q=4 (p=4/3)."""
1406
+ z = jnp.asarray(z)
1407
+ w = jnp.asarray(w)
1408
+ orig_dtype = z.dtype
1409
+ _hp = _high_precision_dtype()
1410
+ z, w = z.astype(_hp), w.astype(_hp)
1411
+
1412
+ perm_z = jnp.argsort(-z, stable=True)
1413
+ z_sorted = z[perm_z]
1414
+ inv_perm_z = _inv_permutation(perm_z)
1415
+
1416
+ perm_w = jnp.argsort(-w, stable=True)
1417
+ w_sorted = w[perm_w]
1418
+ v, block_idx, lens = _pav_isotonic_decreasing_pnorm_q4(
1419
+ z_sorted,
1420
+ w_sorted,
1421
+ )
1422
+
1423
+ t = z_sorted - v # (n,)
1424
+ # q=4 => nabla R^*(t) = t|t|^{q-2} = t^3
1425
+ y_sorted = t * (jnp.abs(t) ** 2)
1426
+
1427
+ return y_sorted[inv_perm_z].astype(orig_dtype)
1428
+
1429
+
1430
+ def _proj_permutahedron_pnorm_q4_fwd(
1431
+ z: jax.Array,
1432
+ w: jax.Array,
1433
+ ) -> tuple[jax.Array, tuple]:
1434
+ """Forward pass for q=4 projection custom VJP."""
1435
+ z = jnp.asarray(z)
1436
+ w = jnp.asarray(w)
1437
+ orig_dtype = z.dtype
1438
+ _hp = _high_precision_dtype()
1439
+ z, w = z.astype(_hp), w.astype(_hp)
1440
+
1441
+ perm_z = jnp.argsort(-z, stable=True)
1442
+ z_sorted = z[perm_z]
1443
+ inv_perm_z = _inv_permutation(perm_z)
1444
+
1445
+ perm_w = jnp.argsort(-w, stable=True)
1446
+ w_sorted = w[perm_w]
1447
+ inv_perm_w = _inv_permutation(perm_w)
1448
+
1449
+ v, block_idx, lens = _pav_isotonic_decreasing_pnorm_q4(
1450
+ z_sorted,
1451
+ w_sorted,
1452
+ )
1453
+
1454
+ t = z_sorted - v
1455
+ y_sorted = t * (jnp.abs(t) ** 2)
1456
+ y = y_sorted[inv_perm_z].astype(orig_dtype)
1457
+
1458
+ aux = (
1459
+ perm_z,
1460
+ inv_perm_z,
1461
+ perm_w,
1462
+ inv_perm_w,
1463
+ block_idx,
1464
+ lens,
1465
+ t,
1466
+ y,
1467
+ )
1468
+ return y, aux
1469
+
1470
+
1471
+ def _proj_permutahedron_pnorm_q4_bwd(
1472
+ aux: tuple,
1473
+ g: jax.Array,
1474
+ ) -> tuple[jax.Array, jax.Array]:
1475
+ """Backward pass for q=4 projection custom VJP."""
1476
+ (
1477
+ perm_z,
1478
+ inv_perm_z,
1479
+ perm_w,
1480
+ inv_perm_w,
1481
+ block_idx,
1482
+ lens,
1483
+ t,
1484
+ y,
1485
+ ) = aux
1486
+ orig_dtype = g.dtype
1487
+ _hp = _high_precision_dtype()
1488
+ g = jnp.asarray(g).astype(_hp)
1489
+ n = g.shape[0]
1490
+ dtype = _hp
1491
+
1492
+ # upstream grad on y_sorted
1493
+ g_sorted_y = g[perm_z]
1494
+
1495
+ # y = t^3 => dy/dt = 3 t^2
1496
+ g_t = g_sorted_y * (3.0 * (t * t))
1497
+
1498
+ # weights for (d v / d s)^T g_t :
1499
+ # alpha_i propto |t_i|^{q-2} = |t_i|^2
1500
+ weight = jnp.abs(t) ** 2 # (n,)
1501
+ denom_block = segment_sum(
1502
+ weight,
1503
+ block_idx,
1504
+ num_segments=n,
1505
+ )
1506
+ denom = denom_block[block_idx]
1507
+
1508
+ sumg_block = segment_sum(
1509
+ g_t,
1510
+ block_idx,
1511
+ num_segments=n,
1512
+ )
1513
+ sumg = sumg_block[block_idx]
1514
+
1515
+ lens_elem = lens[block_idx].astype(dtype)
1516
+ alpha = jnp.where(
1517
+ denom > 0,
1518
+ weight / denom,
1519
+ jnp.array(1.0, dtype=dtype) / lens_elem,
1520
+ )
1521
+
1522
+ jtg_s = alpha * sumg
1523
+ grad_z_sorted = g_t - jtg_s
1524
+
1525
+ # d gamma/dw_k = -1/(3 sum |t|^2)
1526
+ # => dt/dw_k = +1/(3 sum |t|^2)
1527
+ grad_w_sorted = jnp.where(
1528
+ denom > 0,
1529
+ sumg / (3.0 * denom),
1530
+ jnp.array(0.0, dtype=dtype),
1531
+ )
1532
+
1533
+ return (
1534
+ grad_z_sorted[inv_perm_z].astype(orig_dtype),
1535
+ grad_w_sorted[inv_perm_w].astype(orig_dtype),
1536
+ )
1537
+
1538
+
1539
+ _proj_permutahedron_pnorm_q4.defvjp(
1540
+ _proj_permutahedron_pnorm_q4_fwd,
1541
+ _proj_permutahedron_pnorm_q4_bwd,
1542
+ )
1543
+
1544
+
1545
+ # -- entropic isotonic regression (smooth mode) -----------------------
1546
+
1547
+
1548
+ def _pav_isotonic_decreasing_entropic(
1549
+ s: jax.Array,
1550
+ w: jax.Array,
1551
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array, jax.Array]:
1552
+ """Entropic isotonic regression via PAV.
1553
+
1554
+ Solve v_E(s,w) = argmin_{v1>=...>=vn}
1555
+ <exp(s - v), 1> + <exp(w), v>.
1556
+ Returns v and block structure.
1557
+ """
1558
+ s = jnp.asarray(s)
1559
+ w = jnp.asarray(w)
1560
+ n = s.shape[0]
1561
+ dtype = s.dtype
1562
+ neg_inf = jnp.array(-jnp.inf, dtype=dtype)
1563
+
1564
+ starts0 = jnp.full((n,), n, dtype=jnp.int32)
1565
+ # logsumexp over s in block
1566
+ logS0 = jnp.full((n,), neg_inf, dtype=dtype)
1567
+ # logsumexp over w in block
1568
+ logW0 = jnp.full((n,), neg_inf, dtype=dtype)
1569
+ m0 = jnp.int32(0)
1570
+
1571
+ def gamma(
1572
+ logS: jax.Array,
1573
+ logW: jax.Array,
1574
+ ) -> jax.Array:
1575
+ """Compute the entropic block level as logS - logW."""
1576
+ return logS - logW
1577
+
1578
+ def merge_cond(state: tuple) -> jax.Array:
1579
+ """Check whether the top two entropic blocks violate isotonic order."""
1580
+ starts, logS, logW, m = state
1581
+ return (m >= 2) & (gamma(logS[m - 2], logW[m - 2]) < gamma(logS[m - 1], logW[m - 1]))
1582
+
1583
+ def merge_body(state: tuple) -> tuple:
1584
+ """Merge the top two entropic blocks on the stack."""
1585
+ starts, logS, logW, m = state
1586
+ i_prev = m - 2
1587
+ i_top = m - 1
1588
+
1589
+ logS = logS.at[i_prev].set(
1590
+ jnp.logaddexp(logS[i_prev], logS[i_top]),
1591
+ )
1592
+ logW = logW.at[i_prev].set(
1593
+ jnp.logaddexp(logW[i_prev], logW[i_top]),
1594
+ )
1595
+
1596
+ logS = logS.at[i_top].set(neg_inf)
1597
+ logW = logW.at[i_top].set(neg_inf)
1598
+ starts = starts.at[i_top].set(jnp.int32(n))
1599
+
1600
+ return (starts, logS, logW, m - 1)
1601
+
1602
+ def for_body(
1603
+ i: jax.Array,
1604
+ state: tuple,
1605
+ ) -> tuple:
1606
+ """Push element i onto the entropic stack and merge violations."""
1607
+ starts, logS, logW, m = state
1608
+ starts = starts.at[m].set(jnp.int32(i))
1609
+ logS = logS.at[m].set(s[i])
1610
+ logW = logW.at[m].set(w[i])
1611
+ m = m + 1
1612
+ return lax.while_loop(
1613
+ merge_cond,
1614
+ merge_body,
1615
+ (starts, logS, logW, m),
1616
+ )
1617
+
1618
+ starts_out, logS_out, logW_out, m = lax.fori_loop(
1619
+ 0,
1620
+ n,
1621
+ for_body,
1622
+ (starts0, logS0, logW0, m0),
1623
+ )
1624
+ starts: Array = jnp.asarray(starts_out)
1625
+ logS: Array = jnp.asarray(logS_out)
1626
+ logW: Array = jnp.asarray(logW_out)
1627
+
1628
+ idx = jnp.arange(n, dtype=jnp.int32)
1629
+ starts = jnp.where(idx < m, starts, jnp.int32(n))
1630
+ logS = jnp.where(idx < m, logS, neg_inf)
1631
+ logW = jnp.where(idx < m, logW, neg_inf)
1632
+
1633
+ block_idx: Array = jnp.searchsorted(starts, idx, side="right") - jnp.int32(1)
1634
+ gammas = logS - logW
1635
+ v: Array = gammas[block_idx]
1636
+ return v, block_idx, starts, logS, logW
1637
+
1638
+
1639
+ @jax.custom_vjp
1640
+ def _proj_permutahedron_entropic(
1641
+ z: jax.Array,
1642
+ w: jax.Array,
1643
+ ) -> jax.Array:
1644
+ """Project z onto the permutahedron of w using entropic mode."""
1645
+ z = jnp.asarray(z)
1646
+ w = jnp.asarray(w)
1647
+
1648
+ perm_z = jnp.argsort(-z, stable=True)
1649
+ z_sorted = z[perm_z]
1650
+ inv_perm_z = _inv_permutation(perm_z)
1651
+
1652
+ perm_w = jnp.argsort(-w, stable=True)
1653
+ w_sorted = w[perm_w]
1654
+
1655
+ v, _, _, _, _ = _pav_isotonic_decreasing_entropic(
1656
+ z_sorted,
1657
+ w_sorted,
1658
+ )
1659
+ p_sorted = z_sorted - v
1660
+ return p_sorted[inv_perm_z]
1661
+
1662
+
1663
+ def _proj_permutahedron_entropic_fwd(
1664
+ z: jax.Array,
1665
+ w: jax.Array,
1666
+ ) -> tuple[jax.Array, tuple]:
1667
+ """Forward pass for entropic projection custom VJP."""
1668
+ z = jnp.asarray(z)
1669
+ w = jnp.asarray(w)
1670
+
1671
+ perm_z = jnp.argsort(-z, stable=True)
1672
+ z_sorted = z[perm_z]
1673
+ inv_perm_z = _inv_permutation(perm_z)
1674
+
1675
+ perm_w = jnp.argsort(-w, stable=True)
1676
+ w_sorted = w[perm_w]
1677
+ inv_perm_w = _inv_permutation(perm_w)
1678
+
1679
+ v, block_idx, _, logS, logW = _pav_isotonic_decreasing_entropic(z_sorted, w_sorted)
1680
+ p_sorted = z_sorted - v
1681
+ p = p_sorted[inv_perm_z]
1682
+
1683
+ aux = (
1684
+ perm_z,
1685
+ inv_perm_z,
1686
+ perm_w,
1687
+ inv_perm_w,
1688
+ block_idx,
1689
+ z_sorted,
1690
+ w_sorted,
1691
+ logS,
1692
+ logW,
1693
+ )
1694
+ return p, aux
1695
+
1696
+
1697
+ def _proj_permutahedron_entropic_bwd(
1698
+ aux: tuple,
1699
+ g: jax.Array,
1700
+ ) -> tuple[jax.Array, jax.Array]:
1701
+ """Backward pass for entropic projection custom VJP."""
1702
+ (
1703
+ perm_z,
1704
+ inv_perm_z,
1705
+ perm_w,
1706
+ inv_perm_w,
1707
+ block_idx,
1708
+ z_sorted,
1709
+ w_sorted,
1710
+ logS,
1711
+ logW,
1712
+ ) = aux
1713
+ g = jnp.asarray(g)
1714
+ n = g.shape[0]
1715
+
1716
+ g_sorted = g[perm_z]
1717
+
1718
+ # per-block sum of incoming gradients
1719
+ block_sum_g = segment_sum(
1720
+ g_sorted,
1721
+ block_idx,
1722
+ num_segments=n,
1723
+ ) # (n,)
1724
+
1725
+ # softmax weights within each block
1726
+ logS_b = logS[block_idx]
1727
+ logW_b = logW[block_idx]
1728
+ p_s = jnp.exp(z_sorted - logS_b)
1729
+ q_w = jnp.exp(w_sorted - logW_b)
1730
+
1731
+ sum_g = block_sum_g[block_idx]
1732
+ Jt_g_s = p_s * sum_g # (d v / d s)^T g
1733
+ Jt_g_w = (-q_w) * sum_g # (d v / d w)^T g
1734
+
1735
+ grad_z_sorted = g_sorted - Jt_g_s
1736
+ # dp/dw = -(d v / d w) => vjp adds minus
1737
+ grad_w_sorted = -Jt_g_w
1738
+
1739
+ return (
1740
+ grad_z_sorted[inv_perm_z],
1741
+ grad_w_sorted[inv_perm_w],
1742
+ )
1743
+
1744
+
1745
+ _proj_permutahedron_entropic.defvjp(
1746
+ _proj_permutahedron_entropic_fwd,
1747
+ _proj_permutahedron_entropic_bwd,
1748
+ )
1749
+
1750
+
1751
+ # -- dispatcher --------------------------------------------------------
1752
+
1753
+
1754
+ def proj_permutahedron(
1755
+ z: Array, # (..., n)
1756
+ w: Array, # (..., n)
1757
+ softness: float | Array = 0.1,
1758
+ mode: Literal["smooth", "c0", "c1", "c2"] = "smooth",
1759
+ ) -> Array: # (..., n)
1760
+ """Project ``z`` onto the permutahedron of ``w``.
1761
+
1762
+ Solves the optimization problem::
1763
+
1764
+ min_y <z, y> + softness * R(y)
1765
+ s.t. y in Perm(w)
1766
+
1767
+ where R(y) is the regularizer determined by ``mode``.
1768
+
1769
+ Args:
1770
+ z: Input array of shape ``(..., n)`` to be projected.
1771
+ w: Array of shape ``(..., n)`` defining the permutahedron.
1772
+ softness: Controls the strength of the regularizer.
1773
+ Must be positive.
1774
+ mode: Controls the type of regularizer:
1775
+ - ``"smooth"``: Entropic (log-KL) projection onto the
1776
+ permutahedron. Solved via isotonic regression. Not
1777
+ fully C-infinity due to argsort discontinuities at
1778
+ the boundary of sorting chambers.
1779
+ - ``"c0"``: C0 continuous (euclidean/L2 regularizer).
1780
+ Euclidean projection onto the permutahedron.
1781
+ - ``"c1"``: C1 differentiable (p=3/2 p-norm). p-norm
1782
+ projection via PAV with closed-form block solvers.
1783
+ - ``"c2"``: C2 twice differentiable (p=4/3 p-norm).
1784
+ p-norm projection via PAV with closed-form block
1785
+ solvers.
1786
+
1787
+ Returns:
1788
+ Array of shape ``(..., n)`` representing the projected
1789
+ values onto the permutahedron of ``w``.
1790
+ """
1791
+ if z.shape != w.shape:
1792
+ msg = f"Shapes of z and w must match, but got z.shape={z.shape} and w.shape={w.shape}."
1793
+ raise ValueError(msg)
1794
+ validate_softness(softness)
1795
+ *batch_sizes, n = z.shape
1796
+ z_batched = z.reshape(-1, n) # (B, n)
1797
+ w_batched = w.reshape(-1, n) # (B, n)
1798
+ z_batched = z_batched / softness
1799
+ if mode == "smooth":
1800
+ proj_fn = _proj_permutahedron_entropic
1801
+ elif mode == "c0":
1802
+ # Curvature of (1/2)||y||^2: R''=1, no scaling needed
1803
+ proj_fn = _proj_permutahedron_pnorm_q2
1804
+ elif mode == "c1":
1805
+ proj_fn = _proj_permutahedron_pnorm_q3
1806
+ elif mode == "c2":
1807
+ proj_fn = _proj_permutahedron_pnorm_q4
1808
+ else:
1809
+ msg = f"Invalid mode: {mode}"
1810
+ raise ValueError(msg)
1811
+ soft_values = jax.vmap(proj_fn, in_axes=(0, 0))(
1812
+ z_batched,
1813
+ w_batched,
1814
+ ) # (B, n)
1815
+ soft_values = soft_values.reshape(*batch_sizes, n)
1816
+ return soft_values
1817
+
1818
+
1819
+ def proj_permutahedron_smooth_sort(
1820
+ z: Array, # (..., n)
1821
+ w: Array, # (..., n)
1822
+ softness: float | Array = 0.1,
1823
+ lbfgs_tol: float = 1e-5,
1824
+ lbfgs_max_iter: int = 10000,
1825
+ ) -> Array: # (..., n)
1826
+ """Project ``z`` onto the permutahedron of ``w`` via C-inf ESP+LBFGS.
1827
+
1828
+ Uses smooth majorization bounds (elementary symmetric polynomials)
1829
+ for C-infinity gradients w.r.t. ``w``, and an LBFGS dual solver
1830
+ for the entropic LP relaxation.
1831
+
1832
+ Requires the ``optimistix`` package.
1833
+
1834
+ Args:
1835
+ z: Input array of shape ``(..., n)`` to be projected.
1836
+ w: Array of shape ``(..., n)`` defining the permutahedron.
1837
+ softness: Controls the strength of the regularizer.
1838
+ Must be positive.
1839
+ lbfgs_tol: Tolerance for the LBFGS solver.
1840
+ lbfgs_max_iter: Maximum iterations for the LBFGS solver.
1841
+
1842
+ Returns:
1843
+ Array of shape ``(..., n)`` representing the projected
1844
+ values onto the permutahedron of ``w``.
1845
+ """
1846
+ if z.shape != w.shape:
1847
+ msg = f"Shapes of z and w must match, but got z.shape={z.shape} and w.shape={w.shape}."
1848
+ raise ValueError(msg)
1849
+ validate_softness(softness)
1850
+ *batch_sizes, n = z.shape
1851
+ z_batched = z.reshape(-1, n) # (B, n)
1852
+ w_batched = w.reshape(-1, n) # (B, n)
1853
+ z_batched = z_batched / softness
1854
+ proj_fn = _make_proj_permutahedron_entropic_lp(
1855
+ tol=lbfgs_tol,
1856
+ max_iter=lbfgs_max_iter,
1857
+ bounds_softness=jnp.minimum(softness, 1.0),
1858
+ )
1859
+ soft_values = jax.vmap(proj_fn, in_axes=(0, 0))(
1860
+ z_batched,
1861
+ w_batched,
1862
+ ) # (B, n)
1863
+ soft_values = soft_values.reshape(*batch_sizes, n)
1864
+ return soft_values