polystep 0.7.0__tar.gz → 0.8.0__tar.gz

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 (107) hide show
  1. {polystep-0.7.0 → polystep-0.8.0}/PKG-INFO +15 -7
  2. {polystep-0.7.0 → polystep-0.8.0}/README.md +8 -5
  3. {polystep-0.7.0 → polystep-0.8.0}/pyproject.toml +7 -2
  4. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/__init__.py +1 -1
  5. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/_step_blockwise.py +3 -7
  6. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/_step_monolithic.py +27 -7
  7. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/adaptive_subspace.py +1 -1
  8. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/api.py +63 -25
  9. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/cost_nn.py +145 -40
  10. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/geometry.py +3 -4
  11. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/hybrid_subspace.py +24 -3
  12. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/optimizer.py +132 -9
  13. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/projection/sparse.py +10 -2
  14. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/quadratic_model.py +4 -6
  15. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solver.py +27 -15
  16. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/greedy.py +14 -2
  17. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/sinkhorn.py +6 -6
  18. {polystep-0.7.0 → polystep-0.8.0}/src/polystep.egg-info/PKG-INFO +15 -7
  19. {polystep-0.7.0 → polystep-0.8.0}/src/polystep.egg-info/SOURCES.txt +2 -1
  20. {polystep-0.7.0 → polystep-0.8.0}/src/polystep.egg-info/requires.txt +6 -1
  21. polystep-0.8.0/tests/test_compile_forward_inplace.py +88 -0
  22. polystep-0.7.0/tests/test_bug_regressions.py → polystep-0.8.0/tests/test_correctness_regressions.py +4 -2
  23. {polystep-0.7.0 → polystep-0.8.0}/tests/test_mixed_precision.py +18 -7
  24. {polystep-0.7.0 → polystep-0.8.0}/LICENSE +0 -0
  25. {polystep-0.7.0 → polystep-0.8.0}/setup.cfg +0 -0
  26. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/_compiled.py +0 -0
  27. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/_step_momentum.py +0 -0
  28. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/ask_tell.py +0 -0
  29. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/__init__.py +0 -0
  30. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/baselines.py +0 -0
  31. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/rl/__init__.py +0 -0
  32. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/rl/cartpole.py +0 -0
  33. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/rl/gym_evaluator.py +0 -0
  34. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/rl/metrics.py +0 -0
  35. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/rl/policies.py +0 -0
  36. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/benchmarks/utils.py +0 -0
  37. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/blockwise.py +0 -0
  38. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/cma.py +0 -0
  39. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/cma_subspace.py +0 -0
  40. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/costs.py +0 -0
  41. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/dynamics.py +0 -0
  42. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/epsilon.py +0 -0
  43. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/layers/__init__.py +0 -0
  44. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/layers/attention.py +0 -0
  45. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/layers/rnn.py +0 -0
  46. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/objectives/__init__.py +0 -0
  47. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/objectives/base.py +0 -0
  48. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/objectives/synthetic.py +0 -0
  49. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/projection/__init__.py +0 -0
  50. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/py.typed +0 -0
  51. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/__init__.py +0 -0
  52. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/_prelude.py +0 -0
  53. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/base.py +0 -0
  54. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/kl_softmax.py +0 -0
  55. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/softmax.py +0 -0
  56. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/solvers/tempered_softmax.py +0 -0
  57. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/subspace.py +0 -0
  58. {polystep-0.7.0 → polystep-0.8.0}/src/polystep/transform.py +0 -0
  59. {polystep-0.7.0 → polystep-0.8.0}/src/polystep.egg-info/dependency_links.txt +0 -0
  60. {polystep-0.7.0 → polystep-0.8.0}/src/polystep.egg-info/top_level.txt +0 -0
  61. {polystep-0.7.0 → polystep-0.8.0}/tests/test_ablation_solvers.py +0 -0
  62. {polystep-0.7.0 → polystep-0.8.0}/tests/test_adaptive_mnist.py +0 -0
  63. {polystep-0.7.0 → polystep-0.8.0}/tests/test_adaptive_subspace.py +0 -0
  64. {polystep-0.7.0 → polystep-0.8.0}/tests/test_api.py +0 -0
  65. {polystep-0.7.0 → polystep-0.8.0}/tests/test_ask_tell.py +0 -0
  66. {polystep-0.7.0 → polystep-0.8.0}/tests/test_baseline_fairness.py +0 -0
  67. {polystep-0.7.0 → polystep-0.8.0}/tests/test_benchmarks.py +0 -0
  68. {polystep-0.7.0 → polystep-0.8.0}/tests/test_biased_rotation.py +0 -0
  69. {polystep-0.7.0 → polystep-0.8.0}/tests/test_blockwise.py +0 -0
  70. {polystep-0.7.0 → polystep-0.8.0}/tests/test_cma.py +0 -0
  71. {polystep-0.7.0 → polystep-0.8.0}/tests/test_cma_subspace.py +0 -0
  72. {polystep-0.7.0 → polystep-0.8.0}/tests/test_compiled.py +0 -0
  73. {polystep-0.7.0 → polystep-0.8.0}/tests/test_cost_nn.py +0 -0
  74. {polystep-0.7.0 → polystep-0.8.0}/tests/test_costs.py +0 -0
  75. {polystep-0.7.0 → polystep-0.8.0}/tests/test_dfo_features.py +0 -0
  76. {polystep-0.7.0 → polystep-0.8.0}/tests/test_dynamics.py +0 -0
  77. {polystep-0.7.0 → polystep-0.8.0}/tests/test_fullspace_chunk_bound.py +0 -0
  78. {polystep-0.7.0 → polystep-0.8.0}/tests/test_fused_projection_rebuild.py +0 -0
  79. {polystep-0.7.0 → polystep-0.8.0}/tests/test_geometry.py +0 -0
  80. {polystep-0.7.0 → polystep-0.8.0}/tests/test_hardening.py +0 -0
  81. {polystep-0.7.0 → polystep-0.8.0}/tests/test_hybrid_mnist.py +0 -0
  82. {polystep-0.7.0 → polystep-0.8.0}/tests/test_hybrid_subspace.py +0 -0
  83. {polystep-0.7.0 → polystep-0.8.0}/tests/test_integration.py +0 -0
  84. {polystep-0.7.0 → polystep-0.8.0}/tests/test_kl_softmax.py +0 -0
  85. {polystep-0.7.0 → polystep-0.8.0}/tests/test_layers.py +0 -0
  86. {polystep-0.7.0 → polystep-0.8.0}/tests/test_mixed_precision_step.py +0 -0
  87. {polystep-0.7.0 → polystep-0.8.0}/tests/test_mnist.py +0 -0
  88. {polystep-0.7.0 → polystep-0.8.0}/tests/test_no_test_set_leakage.py +0 -0
  89. {polystep-0.7.0 → polystep-0.8.0}/tests/test_nondiff_data.py +0 -0
  90. {polystep-0.7.0 → polystep-0.8.0}/tests/test_nondiff_models.py +0 -0
  91. {polystep-0.7.0 → polystep-0.8.0}/tests/test_numerical_stress.py +0 -0
  92. {polystep-0.7.0 → polystep-0.8.0}/tests/test_optimizer.py +0 -0
  93. {polystep-0.7.0 → polystep-0.8.0}/tests/test_quadratic_model.py +0 -0
  94. {polystep-0.7.0 → polystep-0.8.0}/tests/test_rl_benchmarks.py +0 -0
  95. {polystep-0.7.0 → polystep-0.8.0}/tests/test_sinkhorn.py +0 -0
  96. {polystep-0.7.0 → polystep-0.8.0}/tests/test_sinkhorn_correctness.py +0 -0
  97. {polystep-0.7.0 → polystep-0.8.0}/tests/test_softmax.py +0 -0
  98. {polystep-0.7.0 → polystep-0.8.0}/tests/test_softmax_integration.py +0 -0
  99. {polystep-0.7.0 → polystep-0.8.0}/tests/test_solver_numerics.py +0 -0
  100. {polystep-0.7.0 → polystep-0.8.0}/tests/test_sparse_integration.py +0 -0
  101. {polystep-0.7.0 → polystep-0.8.0}/tests/test_sparse_projection.py +0 -0
  102. {polystep-0.7.0 → polystep-0.8.0}/tests/test_subspace.py +0 -0
  103. {polystep-0.7.0 → polystep-0.8.0}/tests/test_subspace_blockwise.py +0 -0
  104. {polystep-0.7.0 → polystep-0.8.0}/tests/test_transform.py +0 -0
  105. {polystep-0.7.0 → polystep-0.8.0}/tests/test_trust_region.py +0 -0
  106. {polystep-0.7.0 → polystep-0.8.0}/tests/test_turbo.py +0 -0
  107. {polystep-0.7.0 → polystep-0.8.0}/tests/test_vmap_layer_compatibility.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polystep
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: PyTorch PolyStep Optimizer - gradient-free neural network training via entropic optimal transport
5
5
  Author: An T. Le
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-timeout>=2.2; extra == "dev"
32
32
  Requires-Dist: pytest-rerunfailures>=14.0; extra == "dev"
33
33
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
34
- Requires-Dist: ruff>=0.15.12; extra == "dev"
34
+ Requires-Dist: ruff==0.15.12; extra == "dev"
35
35
  Provides-Extra: experiments
36
36
  Requires-Dist: scipy>=1.11; extra == "experiments"
37
37
  Requires-Dist: statsmodels>=0.14; extra == "experiments"
@@ -39,6 +39,11 @@ Requires-Dist: pandas>=2.0; extra == "experiments"
39
39
  Requires-Dist: python-sat; extra == "experiments"
40
40
  Requires-Dist: torchvision>=0.25; extra == "experiments"
41
41
  Requires-Dist: cma>=3.3; extra == "experiments"
42
+ Requires-Dist: evotorch>=0.5; extra == "experiments"
43
+ Requires-Dist: nevergrad>=1.0; extra == "experiments"
44
+ Requires-Dist: snntorch>=0.7; extra == "experiments"
45
+ Requires-Dist: tabulate>=0.9; extra == "experiments"
46
+ Requires-Dist: datasets>=2.14; extra == "experiments"
42
47
  Requires-Dist: gymnasium>=0.29; extra == "experiments"
43
48
  Provides-Extra: rl
44
49
  Requires-Dist: gymnasium[box2d]>=0.29; extra == "rl"
@@ -68,7 +73,7 @@ Based on the Sinkhorn Step algorithm ([Le et al., NeurIPS 2023](https://arxiv.or
68
73
  4. **Update** the parameters by barycentric projection from the weighted vertices.
69
74
 
70
75
  <p align="center">
71
- <img src="docs/figures/method_diagram.png" width="840"
76
+ <img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/method_diagram.png" width="840"
72
77
  alt="One PolyStep: subspace projection, polytope probes, cost matrix, soft entropic-OT assignment, and barycentric projection, plus the softmax-to-full-OT solver continuum.">
73
78
  </p>
74
79
 
@@ -161,8 +166,8 @@ See [`examples/`](examples/) for runnable demos covering SNN, RL, MAX-SAT, MNIST
161
166
 
162
167
  <table>
163
168
  <tr>
164
- <td align="center"><img src="docs/figures/polystep_snn_progress.gif" width="330"><br><sub>Spiking net (hard LIF thresholds) trained with forward passes only</sub></td>
165
- <td align="center"><img src="docs/figures/rl_cartpole_policy.gif" width="330"><br><sub>CartPole policy search: no value function, no gradients</sub></td>
169
+ <td align="center"><img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/polystep_snn_progress.gif" width="330"><br><sub>Spiking net (hard LIF thresholds) trained with forward passes only</sub></td>
170
+ <td align="center"><img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/rl_cartpole_policy.gif" width="330"><br><sub>CartPole policy search: no value function, no gradients</sub></td>
166
171
  </tr>
167
172
  </table>
168
173
 
@@ -182,7 +187,7 @@ If your model is fully differentiable, Adam/SGD will be faster and more accurate
182
187
 
183
188
  Zeroth-order optimization splits into two camps. Memory-efficient fine-tuning (MeZO and successors) runs zeroth-order updates on differentiable networks to skip the backprop tape, but still assumes a useful local gradient. Evolution strategies and SPSA (CMA-ES, OpenAI-ES) optimize black-box objectives by estimating a local gradient from small perturbations.
184
189
 
185
- PolyStep is a randomized direct search: it probes a finite-radius polytope around the current parameters and moves toward the lowest-cost vertices through an optimal-transport barycenter. When the loss is piecewise-constant or genuinely non-differentiable, the local gradient is zero almost everywhere, so finite-difference estimates carry no signal and ES/SPSA stall, while a finite radius steps across the flat regions. This is the setting where direct search (generalized pattern search, MADS) has convergence guarantees on nonsmooth and discontinuous objectives that gradient-estimate methods lack. Example 09 shows the separation on a hard decision tree.
190
+ PolyStep is a randomized direct search: it probes a finite-radius polytope around the current parameters and moves toward the lowest-cost vertices through an optimal-transport barycenter. When the loss is piecewise-constant or truly non-differentiable, the local gradient is zero almost everywhere, so finite-difference estimates carry no signal and ES/SPSA stall, while a finite radius steps across the flat regions. This is the setting where direct search (generalized pattern search, MADS) has convergence guarantees on nonsmooth and discontinuous objectives that gradient-estimate methods lack. Example 09 shows the separation on a hard decision tree.
186
191
 
187
192
  Recent gradient-free work targets the same regime, including low-rank evolution strategies for spiking networks (arXiv:2605.30361) and gradient-free trust regions for recurrent spiking networks (arXiv:2601.21572). PolyStep is complementary: a general-purpose direct-search optimizer with subspace compression.
188
193
 
@@ -227,7 +232,9 @@ Among gradient-free optimizers PolyStep is the strongest on every task here, at
227
232
  - **Softmax OT solver** with an entropic Sinkhorn alternative.
228
233
  - **Subspace compression**: `HybridSubspace` (recommended), `AdaptiveSubspace`, and sparse projection for very large models.
229
234
  - **Block-wise OT** for per-layer decomposition.
230
- - **`torch.compile`** opt-in on hot paths.
235
+ - **`torch.compile`** opt-in on the OT hot paths (`compile=True`) and on the
236
+ candidate forward (`compile_evaluator` = fusion; `compile_forward` = CUDA
237
+ graphs on the in-place path). See [`docs/performance.md`](docs/performance.md).
231
238
  - **Vmap-safe layers**: drop-in attention and LSTM that play nicely with `torch.vmap`.
232
239
  - **Sub-linear memory**: forward-only evaluation, no BPTT activation tape (~30x savings at long SNN horizons).
233
240
  - **CMA-ES inspired adaptation** of subspace covariance (experimental, monolithic step only; `use_adaptive_radius` is the stable default).
@@ -259,6 +266,7 @@ docs/ API overview, reproducibility guide
259
266
  | [`examples/`](examples/) | 8 runnable demos with output figures |
260
267
  | [`experiments/`](experiments/) | Full paper reproduction harness |
261
268
  | [`docs/api_overview.md`](docs/api_overview.md) | API reference |
269
+ | [`docs/performance.md`](docs/performance.md) | Per-step cost & the compile flags (`compile_evaluator`, `compile_forward`) |
262
270
  | [`LIMITATIONS.md`](LIMITATIONS.md) | Known limitations |
263
271
  | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Contribution guidelines |
264
272
  | [`CHANGELOG.md`](CHANGELOG.md) | Release history |
@@ -20,7 +20,7 @@ Based on the Sinkhorn Step algorithm ([Le et al., NeurIPS 2023](https://arxiv.or
20
20
  4. **Update** the parameters by barycentric projection from the weighted vertices.
21
21
 
22
22
  <p align="center">
23
- <img src="docs/figures/method_diagram.png" width="840"
23
+ <img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/method_diagram.png" width="840"
24
24
  alt="One PolyStep: subspace projection, polytope probes, cost matrix, soft entropic-OT assignment, and barycentric projection, plus the softmax-to-full-OT solver continuum.">
25
25
  </p>
26
26
 
@@ -113,8 +113,8 @@ See [`examples/`](examples/) for runnable demos covering SNN, RL, MAX-SAT, MNIST
113
113
 
114
114
  <table>
115
115
  <tr>
116
- <td align="center"><img src="docs/figures/polystep_snn_progress.gif" width="330"><br><sub>Spiking net (hard LIF thresholds) trained with forward passes only</sub></td>
117
- <td align="center"><img src="docs/figures/rl_cartpole_policy.gif" width="330"><br><sub>CartPole policy search: no value function, no gradients</sub></td>
116
+ <td align="center"><img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/polystep_snn_progress.gif" width="330"><br><sub>Spiking net (hard LIF thresholds) trained with forward passes only</sub></td>
117
+ <td align="center"><img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/rl_cartpole_policy.gif" width="330"><br><sub>CartPole policy search: no value function, no gradients</sub></td>
118
118
  </tr>
119
119
  </table>
120
120
 
@@ -134,7 +134,7 @@ If your model is fully differentiable, Adam/SGD will be faster and more accurate
134
134
 
135
135
  Zeroth-order optimization splits into two camps. Memory-efficient fine-tuning (MeZO and successors) runs zeroth-order updates on differentiable networks to skip the backprop tape, but still assumes a useful local gradient. Evolution strategies and SPSA (CMA-ES, OpenAI-ES) optimize black-box objectives by estimating a local gradient from small perturbations.
136
136
 
137
- PolyStep is a randomized direct search: it probes a finite-radius polytope around the current parameters and moves toward the lowest-cost vertices through an optimal-transport barycenter. When the loss is piecewise-constant or genuinely non-differentiable, the local gradient is zero almost everywhere, so finite-difference estimates carry no signal and ES/SPSA stall, while a finite radius steps across the flat regions. This is the setting where direct search (generalized pattern search, MADS) has convergence guarantees on nonsmooth and discontinuous objectives that gradient-estimate methods lack. Example 09 shows the separation on a hard decision tree.
137
+ PolyStep is a randomized direct search: it probes a finite-radius polytope around the current parameters and moves toward the lowest-cost vertices through an optimal-transport barycenter. When the loss is piecewise-constant or truly non-differentiable, the local gradient is zero almost everywhere, so finite-difference estimates carry no signal and ES/SPSA stall, while a finite radius steps across the flat regions. This is the setting where direct search (generalized pattern search, MADS) has convergence guarantees on nonsmooth and discontinuous objectives that gradient-estimate methods lack. Example 09 shows the separation on a hard decision tree.
138
138
 
139
139
  Recent gradient-free work targets the same regime, including low-rank evolution strategies for spiking networks (arXiv:2605.30361) and gradient-free trust regions for recurrent spiking networks (arXiv:2601.21572). PolyStep is complementary: a general-purpose direct-search optimizer with subspace compression.
140
140
 
@@ -179,7 +179,9 @@ Among gradient-free optimizers PolyStep is the strongest on every task here, at
179
179
  - **Softmax OT solver** with an entropic Sinkhorn alternative.
180
180
  - **Subspace compression**: `HybridSubspace` (recommended), `AdaptiveSubspace`, and sparse projection for very large models.
181
181
  - **Block-wise OT** for per-layer decomposition.
182
- - **`torch.compile`** opt-in on hot paths.
182
+ - **`torch.compile`** opt-in on the OT hot paths (`compile=True`) and on the
183
+ candidate forward (`compile_evaluator` = fusion; `compile_forward` = CUDA
184
+ graphs on the in-place path). See [`docs/performance.md`](docs/performance.md).
183
185
  - **Vmap-safe layers**: drop-in attention and LSTM that play nicely with `torch.vmap`.
184
186
  - **Sub-linear memory**: forward-only evaluation, no BPTT activation tape (~30x savings at long SNN horizons).
185
187
  - **CMA-ES inspired adaptation** of subspace covariance (experimental, monolithic step only; `use_adaptive_radius` is the stable default).
@@ -211,6 +213,7 @@ docs/ API overview, reproducibility guide
211
213
  | [`examples/`](examples/) | 8 runnable demos with output figures |
212
214
  | [`experiments/`](experiments/) | Full paper reproduction harness |
213
215
  | [`docs/api_overview.md`](docs/api_overview.md) | API reference |
216
+ | [`docs/performance.md`](docs/performance.md) | Per-step cost & the compile flags (`compile_evaluator`, `compile_forward`) |
214
217
  | [`LIMITATIONS.md`](LIMITATIONS.md) | Known limitations |
215
218
  | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Contribution guidelines |
216
219
  | [`CHANGELOG.md`](CHANGELOG.md) | Release history |
@@ -43,7 +43,7 @@ dev = [
43
43
  "pytest-timeout>=2.2",
44
44
  "pytest-rerunfailures>=14.0",
45
45
  "pytest-cov>=5.0",
46
- "ruff>=0.15.12",
46
+ "ruff==0.15.12", # pinned to match CI so `ruff format` never disagrees local vs CI
47
47
  ]
48
48
  # Full experiment reproduction (paper results, ablations, baselines)
49
49
  experiments = [
@@ -52,7 +52,12 @@ experiments = [
52
52
  "pandas>=2.0",
53
53
  "python-sat", # unusual pre-release versioning, left unpinned to stay installable
54
54
  "torchvision>=0.25",
55
- "cma>=3.3",
55
+ "cma>=3.3", # run_elevation / run_moe / run_maxsat baselines
56
+ "evotorch>=0.5", # CMAES baseline (benchmarks.baselines.train_cmaes)
57
+ "nevergrad>=1.0", # nevergrad baseline (benchmarks.baselines)
58
+ "snntorch>=0.7", # SNN spike datasets (benchmarks.utils)
59
+ "tabulate>=0.9", # results tables (benchmarks.utils)
60
+ "datasets>=2.14", # HuggingFace SST-2 / text loaders (benchmarks.utils)
56
61
  "gymnasium>=0.29",
57
62
  ]
58
63
  # RL experiments (Box2D envs)
@@ -1,6 +1,6 @@
1
1
  """polystep: PyTorch PolyStep Optimizer."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
4
4
 
5
5
  __all__ = [
6
6
  # Core solver
@@ -55,9 +55,7 @@ def step_blockwise(opt, closure: Callable) -> float:
55
55
  # Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
56
56
  probe_r = opt._apply_probe_radius_jitter(probe_r)
57
57
 
58
- # Ensure 2D
59
- if X.dim() == 1:
60
- X = X.unsqueeze(0)
58
+ # state.X is always 2-D (num_particles, particle_dim); no reshape needed.
61
59
 
62
60
  # Convert layout-indexed flat to block-indexed flat before splitting.
63
61
  # Per-layer blocks pad each entry independently, creating different
@@ -484,9 +482,7 @@ def step_subspace_blockwise(opt, closure: Callable) -> float:
484
482
  # Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
485
483
  probe_r = opt._apply_probe_radius_jitter(probe_r)
486
484
 
487
- # Ensure 2D
488
- if X.dim() == 1:
489
- X = X.unsqueeze(0)
485
+ # state.X is always 2-D (num_sub_particles, subspace_particle_dim); no reshape needed.
490
486
 
491
487
  # Get subspace dimension
492
488
  sub_dim = opt.subspace.subspace_dim
@@ -645,7 +641,7 @@ def step_subspace_blockwise(opt, closure: Callable) -> float:
645
641
  # projection: (full_dim, sub_dim)
646
642
  # reconstruct_batch needs projection argument for AdaptiveSubspace
647
643
  # Match dtype with projection for mixed precision compatibility
648
- if opt._mixed_precision and state.projection is not None:
644
+ if opt._mixed_precision and state.projection is not None and state.projection.dtype is not None:
649
645
  base_batch = base_batch.to(dtype=state.projection.dtype)
650
646
  if opt._adaptive or opt._cma_subspace:
651
647
  chunk_params = state.subspace.reconstruct_batch(
@@ -474,8 +474,12 @@ def step_monolithic(opt, closure: Callable) -> float:
474
474
  ot_result = opt.solver.solve(**solve_kwargs)
475
475
  state.last_solve_eps = ot_epsilon
476
476
 
477
- # Auto-epsilon feedback: update progressive epsilon from solver stats
478
- if opt._progressive_epsilon is not None:
477
+ # Update progressive epsilon from solver stats. Skip in fixed-iteration
478
+ # Sinkhorn mode (threshold <= 0): there the solver always returns
479
+ # converged=True with n_iters == max_iterations, so the ratio is 1.0 and the
480
+ # increase branch would push epsilon to max_epsilon every step. Progressive
481
+ # epsilon is only paired with SinkhornSolver, so threshold always exists.
482
+ if opt._progressive_epsilon is not None and getattr(opt.solver, "threshold", 1.0) > 0:
479
483
  opt._progressive_epsilon.update(
480
484
  n_iters=ot_result.n_iters,
481
485
  max_iterations=getattr(opt.solver, "max_iterations", 1),
@@ -972,11 +976,27 @@ def step_monolithic(opt, closure: Callable) -> float:
972
976
  state.base_params = new_base
973
977
  # Reset subspace coordinates to zero
974
978
  state.X = torch.zeros_like(state.X)
975
- # Regenerate ALL per-layer projections
976
- state.hybrid_projections = hybrid_sub.init_projections(
977
- state.X.device,
978
- state.X.dtype,
979
- )
979
+ # Regenerate ALL per-layer projections. Default: fresh RANDOM redraw. Opt-in
980
+ # absorb_aligned_active: bias the new basis toward the just-completed window's productive
981
+ # directions (displacement-SVD) BEFORE the history is zeroed below. Safe here because the
982
+ # duals are reset in this same block, so no live Sinkhorn dual is disrupted (the reason
983
+ # per-step rotation is disabled). Lets a smaller rank track descent across absorbs.
984
+ if getattr(hybrid_sub, "absorb_aligned_active", False) and state.displacement_history_count > 0:
985
+ hist = state.displacement_history[: state.displacement_history_count]
986
+ svd_ratio = hybrid_sub.get_svd_ratio(state.iteration_count, opt.max_iterations or 1)
987
+ state.hybrid_projections = hybrid_sub._rotate_all_displacement(
988
+ state.hybrid_projections,
989
+ hist,
990
+ svd_ratio,
991
+ state.X.device,
992
+ state.X.dtype,
993
+ state.iteration_count,
994
+ )
995
+ else:
996
+ state.hybrid_projections = hybrid_sub.init_projections(
997
+ state.X.device,
998
+ state.X.dtype,
999
+ )
980
1000
  # Reset displacement history
981
1001
  state.displacement_history.zero_()
982
1002
  state.displacement_history_idx = 0
@@ -542,7 +542,7 @@ class AdaptiveSubspace:
542
542
  Returns:
543
543
  SVD ratio in [svd_ratio_init, svd_ratio_final].
544
544
  """
545
- progress = min(1.0, step / max(1, total_steps))
545
+ progress = min(1.0, step / max(1, total_steps or 1))
546
546
  return self.svd_ratio_init + progress * (self.svd_ratio_final - self.svd_ratio_init)
547
547
 
548
548
  # ------------------------------------------------------------------
@@ -8,6 +8,7 @@ epoch iteration, or batch handling.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import copy
11
12
  from dataclasses import dataclass
12
13
  from typing import Callable, List, Optional
13
14
 
@@ -76,12 +77,20 @@ class TrainConfig:
76
77
  log_every: Step interval for built-in logging. Must be > 0.
77
78
  callbacks: List of ``TrainCallback`` instances. ``None`` is
78
79
  normalized to an empty list.
80
+ restore_best: If ``True`` (default), snapshot the model weights
81
+ whenever the tracked loss reaches a new minimum and restore that
82
+ best snapshot before ``train()`` returns. Gradient-free search is
83
+ noisy and updates the model in place, so the *last* step is not
84
+ necessarily the best; this reverts a late divergence. The tracked
85
+ loss is the exact full-batch loss when a callback consumes per-step
86
+ metrics, otherwise the (cheap, already-computed) OT cost proxy.
79
87
  """
80
88
 
81
89
  epochs: int = 10
82
90
  batch_size: int = 32
83
91
  log_every: int = 10
84
92
  callbacks: Optional[List[TrainCallback]] = None
93
+ restore_best: bool = True
85
94
 
86
95
  def __post_init__(self):
87
96
  if self.epochs <= 0:
@@ -134,7 +143,8 @@ def train(
134
143
  evaluator = NNCostEvaluator(
135
144
  model,
136
145
  loss_fn=loss_fn,
137
- compile_vmap=getattr(optimizer, "_compile_evaluator", False),
146
+ compile_vmap=getattr(optimizer, "compile_evaluator", False),
147
+ compile_forward=getattr(optimizer, "compile_forward", False),
138
148
  )
139
149
  callbacks = list(config.callbacks)
140
150
  global_step = 0
@@ -146,6 +156,10 @@ def train(
146
156
  except StopIteration:
147
157
  raise ValueError("Model has no trainable parameters")
148
158
 
159
+ # Track the lowest-loss weights so a noisy run is not left at a late step.
160
+ best_loss = float("inf")
161
+ best_state = None
162
+
149
163
  for epoch in range(config.epochs):
150
164
  epoch_loss_sum = 0.0
151
165
  epoch_loss_count = 0
@@ -158,8 +172,17 @@ def train(
158
172
  cost_bs = getattr(optimizer, "cost_batch_size", None)
159
173
  if cost_bs is not None and cost_bs < inputs.shape[0]:
160
174
  gen = getattr(optimizer, "_generator", None)
161
- if gen is not None and gen.device.type == inputs.device.type:
162
- idx = torch.randperm(inputs.shape[0], device=inputs.device, generator=gen)[:cost_bs]
175
+ if gen is not None:
176
+ if gen.device == inputs.device:
177
+ idx = torch.randperm(inputs.shape[0], device=inputs.device, generator=gen)[:cost_bs]
178
+ else:
179
+ # Devices differ (seeded CPU generator with CUDA inputs,
180
+ # or cuda:0 vs cuda:1). randperm needs the generator and
181
+ # tensor on the same device: sample on the generator's
182
+ # device to keep the seed, then move the index.
183
+ idx = torch.randperm(inputs.shape[0], device=gen.device, generator=gen)[:cost_bs].to(
184
+ inputs.device
185
+ )
163
186
  else:
164
187
  idx = torch.randperm(inputs.shape[0], device=inputs.device)[:cost_bs]
165
188
  cost_inputs = inputs[idx]
@@ -174,32 +197,43 @@ def train(
174
197
 
175
198
  optimizer.step(closure)
176
199
 
177
- # Compute training loss separately (not the OT cost)
178
- with torch.no_grad():
179
- output = model(inputs)
180
- train_loss = loss_fn(output, targets).item()
200
+ state = optimizer.state
201
+ ot_cost = state.costs[-1] if state.costs else 0.0
202
+
203
+ if callbacks:
204
+ # A callback consumes per-step metrics: compute the exact
205
+ # full-batch training loss (separate from the OT cost) and build
206
+ # the full metrics dict (velocity_mag included).
207
+ with torch.no_grad():
208
+ output = model(inputs)
209
+ train_loss = loss_fn(output, targets).item()
210
+ metrics = {
211
+ "step": global_step,
212
+ "epoch": epoch,
213
+ "loss": train_loss,
214
+ "ot_cost": ot_cost,
215
+ "displacement": (state.displacement_sqnorms[-1] if state.displacement_sqnorms else 0.0),
216
+ "velocity_mag": (torch.norm(state.velocity).item() if state.velocity is not None else 0.0),
217
+ "converged": (state.linear_convergence[-1] if state.linear_convergence else False),
218
+ "absorb_count": getattr(state, "absorb_count", 0),
219
+ }
220
+ for cb in callbacks:
221
+ if cb.on_step_end(metrics):
222
+ stop = True
223
+ break
224
+ else:
225
+ # No callback consumes per-step metrics: skip the extra
226
+ # full-batch forward + its host syncs (.item()/velocity norm)
227
+ # and track the OT cost as the loss signal for logging / best.
228
+ train_loss = ot_cost
181
229
 
182
230
  epoch_loss_sum += train_loss
183
231
  epoch_loss_count += 1
184
232
 
185
- # Build metrics dict
186
- state = optimizer.state
187
- metrics = {
188
- "step": global_step,
189
- "epoch": epoch,
190
- "loss": train_loss,
191
- "ot_cost": state.costs[-1] if state.costs else 0.0,
192
- "displacement": (state.displacement_sqnorms[-1] if state.displacement_sqnorms else 0.0),
193
- "velocity_mag": (torch.norm(state.velocity).item() if state.velocity is not None else 0.0),
194
- "converged": (state.linear_convergence[-1] if state.linear_convergence else False),
195
- "absorb_count": getattr(state, "absorb_count", 0),
196
- }
197
-
198
- # Invoke on_step_end callbacks
199
- for cb in callbacks:
200
- if cb.on_step_end(metrics):
201
- stop = True
202
- break
233
+ # Snapshot the weights at the lowest tracked loss.
234
+ if config.restore_best and train_loss < best_loss:
235
+ best_loss = train_loss
236
+ best_state = copy.deepcopy(model.state_dict())
203
237
 
204
238
  if stop:
205
239
  break
@@ -218,6 +252,10 @@ def train(
218
252
  for cb in callbacks:
219
253
  cb.on_epoch_end(epoch_metrics)
220
254
 
255
+ # Restore the best-seen weights (load_state_dict copies in place, keeping dtype/device).
256
+ if config.restore_best and best_state is not None:
257
+ model.load_state_dict(best_state)
258
+
221
259
  return model
222
260
 
223
261