polystep 0.12.0__tar.gz → 0.13.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 (147) hide show
  1. {polystep-0.12.0 → polystep-0.13.0}/CHANGELOG.md +6 -0
  2. {polystep-0.12.0 → polystep-0.13.0}/CITATION.cff +2 -2
  3. {polystep-0.12.0 → polystep-0.13.0}/CONTRIBUTING.md +9 -2
  4. {polystep-0.12.0 → polystep-0.13.0}/PKG-INFO +8 -8
  5. {polystep-0.12.0 → polystep-0.13.0}/README.md +6 -5
  6. {polystep-0.12.0 → polystep-0.13.0}/docs/performance.md +5 -1
  7. {polystep-0.12.0 → polystep-0.13.0}/docs/reproducibility.md +6 -1
  8. {polystep-0.12.0 → polystep-0.13.0}/pyproject.toml +1 -2
  9. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/__init__.py +1 -1
  10. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/ask_tell.py +2 -2
  11. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/utils.py +5 -4
  12. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cost_nn.py +42 -20
  13. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/optimizer.py +6 -12
  14. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solver.py +5 -5
  15. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/_shared.py +5 -4
  16. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/greedy.py +3 -1
  17. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/kl_softmax.py +12 -12
  18. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/sinkhorn.py +1 -5
  19. {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/PKG-INFO +8 -8
  20. {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/SOURCES.txt +0 -1
  21. {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/requires.txt +1 -2
  22. {polystep-0.12.0 → polystep-0.13.0}/tests/test_ablation_solvers.py +14 -55
  23. {polystep-0.12.0 → polystep-0.13.0}/tests/test_baseline_fairness.py +2 -90
  24. {polystep-0.12.0 → polystep-0.13.0}/tests/test_baselines.py +16 -18
  25. {polystep-0.12.0 → polystep-0.13.0}/tests/test_end_to_end_learning.py +0 -40
  26. {polystep-0.12.0 → polystep-0.13.0}/tests/test_evaluator_regressions.py +64 -0
  27. {polystep-0.12.0 → polystep-0.13.0}/tests/test_fused_projection.py +2 -2
  28. {polystep-0.12.0 → polystep-0.13.0}/tests/test_input_validation.py +7 -6
  29. {polystep-0.12.0 → polystep-0.13.0}/tests/test_kl_softmax.py +18 -0
  30. {polystep-0.12.0 → polystep-0.13.0}/tests/test_mnist.py +19 -0
  31. polystep-0.13.0/tests/test_no_test_set_leakage.py +109 -0
  32. {polystep-0.12.0 → polystep-0.13.0}/tests/test_nondiff_models.py +1 -2
  33. polystep-0.13.0/tests/test_practical.py +73 -0
  34. {polystep-0.12.0 → polystep-0.13.0}/tests/test_search_experiments.py +34 -0
  35. {polystep-0.12.0 → polystep-0.13.0}/tests/test_silent_failures.py +8 -4
  36. {polystep-0.12.0 → polystep-0.13.0}/tests/test_softmax.py +1 -46
  37. {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_contract.py +9 -30
  38. {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_delta.py +1 -1
  39. {polystep-0.12.0 → polystep-0.13.0}/tests/test_timeseries_val_selection.py +1 -1
  40. {polystep-0.12.0 → polystep-0.13.0}/tests/test_tuning_sweep.py +42 -48
  41. polystep-0.12.0/tests/test_no_test_set_leakage.py +0 -165
  42. polystep-0.12.0/tests/test_practical.py +0 -41
  43. polystep-0.12.0/tests/test_sweep_protocol.py +0 -214
  44. {polystep-0.12.0 → polystep-0.13.0}/LICENSE +0 -0
  45. {polystep-0.12.0 → polystep-0.13.0}/LIMITATIONS.md +0 -0
  46. {polystep-0.12.0 → polystep-0.13.0}/MANIFEST.in +0 -0
  47. {polystep-0.12.0 → polystep-0.13.0}/docs/api_overview.md +0 -0
  48. {polystep-0.12.0 → polystep-0.13.0}/docs/determinism.md +0 -0
  49. {polystep-0.12.0 → polystep-0.13.0}/setup.cfg +0 -0
  50. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_compiled.py +0 -0
  51. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_serialization.py +0 -0
  52. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_blockwise.py +0 -0
  53. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_core.py +0 -0
  54. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_momentum.py +0 -0
  55. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_monolithic.py +0 -0
  56. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/adaptive_subspace.py +0 -0
  57. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/api.py +0 -0
  58. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/__init__.py +0 -0
  59. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/core.py +0 -0
  60. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/methods.py +0 -0
  61. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/__init__.py +0 -0
  62. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/__init__.py +0 -0
  63. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/cartpole.py +0 -0
  64. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/gym_evaluator.py +0 -0
  65. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/metrics.py +0 -0
  66. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/policies.py +0 -0
  67. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/threads.py +0 -0
  68. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/blockwise.py +0 -0
  69. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cma.py +0 -0
  70. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cma_subspace.py +0 -0
  71. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/costs.py +0 -0
  72. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/dynamics.py +0 -0
  73. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/epsilon.py +0 -0
  74. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/factored_subspace.py +0 -0
  75. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/geometry.py +0 -0
  76. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/hybrid_subspace.py +0 -0
  77. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/__init__.py +0 -0
  78. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/attention.py +0 -0
  79. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/rnn.py +0 -0
  80. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/__init__.py +0 -0
  81. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/base.py +0 -0
  82. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/synthetic.py +0 -0
  83. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/projection/__init__.py +0 -0
  84. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/projection/sparse.py +0 -0
  85. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/py.typed +0 -0
  86. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/quadratic_model.py +0 -0
  87. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/__init__.py +0 -0
  88. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/base.py +0 -0
  89. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/softmax.py +0 -0
  90. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/tempered_softmax.py +0 -0
  91. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/subspace.py +0 -0
  92. {polystep-0.12.0 → polystep-0.13.0}/src/polystep/transform.py +0 -0
  93. {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/dependency_links.txt +0 -0
  94. {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/top_level.txt +0 -0
  95. {polystep-0.12.0 → polystep-0.13.0}/tests/__init__.py +0 -0
  96. {polystep-0.12.0 → polystep-0.13.0}/tests/conftest.py +0 -0
  97. {polystep-0.12.0 → polystep-0.13.0}/tests/test_adaptive_subspace.py +0 -0
  98. {polystep-0.12.0 → polystep-0.13.0}/tests/test_amortization.py +0 -0
  99. {polystep-0.12.0 → polystep-0.13.0}/tests/test_api.py +0 -0
  100. {polystep-0.12.0 → polystep-0.13.0}/tests/test_ask_tell.py +0 -0
  101. {polystep-0.12.0 → polystep-0.13.0}/tests/test_benchmarks.py +0 -0
  102. {polystep-0.12.0 → polystep-0.13.0}/tests/test_biased_rotation.py +0 -0
  103. {polystep-0.12.0 → polystep-0.13.0}/tests/test_blockwise.py +0 -0
  104. {polystep-0.12.0 → polystep-0.13.0}/tests/test_candidate_autocast.py +0 -0
  105. {polystep-0.12.0 → polystep-0.13.0}/tests/test_checkpoint_resume.py +0 -0
  106. {polystep-0.12.0 → polystep-0.13.0}/tests/test_cma.py +0 -0
  107. {polystep-0.12.0 → polystep-0.13.0}/tests/test_cma_subspace.py +0 -0
  108. {polystep-0.12.0 → polystep-0.13.0}/tests/test_compiled.py +0 -0
  109. {polystep-0.12.0 → polystep-0.13.0}/tests/test_controlled.py +0 -0
  110. {polystep-0.12.0 → polystep-0.13.0}/tests/test_cost_nn.py +0 -0
  111. {polystep-0.12.0 → polystep-0.13.0}/tests/test_costs.py +0 -0
  112. {polystep-0.12.0 → polystep-0.13.0}/tests/test_determinism.py +0 -0
  113. {polystep-0.12.0 → polystep-0.13.0}/tests/test_dfo.py +0 -0
  114. {polystep-0.12.0 → polystep-0.13.0}/tests/test_diagnostics.py +0 -0
  115. {polystep-0.12.0 → polystep-0.13.0}/tests/test_dynamics.py +0 -0
  116. {polystep-0.12.0 → polystep-0.13.0}/tests/test_epsilon.py +0 -0
  117. {polystep-0.12.0 → polystep-0.13.0}/tests/test_evaluator_compile_forward.py +0 -0
  118. {polystep-0.12.0 → polystep-0.13.0}/tests/test_factored_subspace.py +0 -0
  119. {polystep-0.12.0 → polystep-0.13.0}/tests/test_fastpath_parity.py +0 -0
  120. {polystep-0.12.0 → polystep-0.13.0}/tests/test_geometry.py +0 -0
  121. {polystep-0.12.0 → polystep-0.13.0}/tests/test_hybrid_subspace.py +0 -0
  122. {polystep-0.12.0 → polystep-0.13.0}/tests/test_integration.py +0 -0
  123. {polystep-0.12.0 → polystep-0.13.0}/tests/test_layers.py +0 -0
  124. {polystep-0.12.0 → polystep-0.13.0}/tests/test_math_guards.py +0 -0
  125. {polystep-0.12.0 → polystep-0.13.0}/tests/test_mixed_precision.py +0 -0
  126. {polystep-0.12.0 → polystep-0.13.0}/tests/test_nondiff_data.py +0 -0
  127. {polystep-0.12.0 → polystep-0.13.0}/tests/test_numerical_stress.py +0 -0
  128. {polystep-0.12.0 → polystep-0.13.0}/tests/test_objectives.py +0 -0
  129. {polystep-0.12.0 → polystep-0.13.0}/tests/test_optimizer.py +0 -0
  130. {polystep-0.12.0 → polystep-0.13.0}/tests/test_per_sample.py +0 -0
  131. {polystep-0.12.0 → polystep-0.13.0}/tests/test_probe_jitter.py +0 -0
  132. {polystep-0.12.0 → polystep-0.13.0}/tests/test_quadratic_model.py +0 -0
  133. {polystep-0.12.0 → polystep-0.13.0}/tests/test_regressions.py +0 -0
  134. {polystep-0.12.0 → polystep-0.13.0}/tests/test_rl_benchmarks.py +0 -0
  135. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sequential_fast_path.py +0 -0
  136. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sinkhorn.py +0 -0
  137. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sinkhorn_numerics.py +0 -0
  138. {polystep-0.12.0 → polystep-0.13.0}/tests/test_site_vmap.py +0 -0
  139. {polystep-0.12.0 → polystep-0.13.0}/tests/test_softmax_integration.py +0 -0
  140. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_delta_evaluator.py +0 -0
  141. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_integration.py +0 -0
  142. {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_projection.py +0 -0
  143. {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_blockwise.py +0 -0
  144. {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_gain.py +0 -0
  145. {polystep-0.12.0 → polystep-0.13.0}/tests/test_transform.py +0 -0
  146. {polystep-0.12.0 → polystep-0.13.0}/tests/test_trust_region.py +0 -0
  147. {polystep-0.12.0 → polystep-0.13.0}/tests/test_vmap_layer_compatibility.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 - 2026-09-22
4
+
5
+ - Fix KL row mass, nonfinite inputs, evaluator hooks, and chunk handling.
6
+ - Reuse shared MLP forward work and load only requested MNIST images.
7
+ - Fix example checkpoint selection and evaluation budgets; trim demo dependencies.
8
+
3
9
  ## 0.12.0 - 2026-09-17
4
10
 
5
11
  - Fix cross-entropy targets, stale caches, attention masks, nonfinite best values,
@@ -6,8 +6,8 @@ authors:
6
6
  - family-names: Le
7
7
  given-names: An T.
8
8
  license: Apache-2.0
9
- version: 0.12.0
10
- date-released: "2026-09-17"
9
+ version: 0.13.0
10
+ date-released: "2026-09-22"
11
11
  repository-code: "https://github.com/anindex/polystep"
12
12
  keywords:
13
13
  - gradient-free optimization
@@ -26,7 +26,12 @@ Tests use one CPU thread by default; `POLYSTEP_TEST_THREADS` overrides it. Slow
26
26
  may download MNIST. Tests importing `experiments/` must skip when that directory is
27
27
  absent, since it is excluded from distributions.
28
28
 
29
- CI also builds the source distribution and runs its tests. Before a release:
29
+ Pushes and pull requests test Python 3.11 with PyTorch 2.8 and Python 3.14 with
30
+ current PyTorch. Scheduled and manual runs add Python 3.12/3.13, coverage, real-data
31
+ learning checks, and the shipped tests against the installed wheel. Every run
32
+ builds both distributions and checks the wheel's imports and type marker.
33
+
34
+ Before a release:
30
35
 
31
36
  ```bash
32
37
  uv build
@@ -39,7 +44,9 @@ Update `polystep.__version__`, the version and date in `CITATION.cff`, and
39
44
  `CHANGELOG.md`. `pyproject.toml` reads the package version automatically.
40
45
 
41
46
  Publishing a GitHub Release tagged `vX.Y.Z` triggers the PyPI workflow. It checks
42
- version consistency, lint, tests, and package metadata before publishing.
47
+ version consistency, lint, tests, and package metadata before publishing. The
48
+ release also runs the shipped tests against the wheel. Only the separate publish
49
+ job has OIDC permission; it uses the `pypi` environment and PyPI Trusted Publishing.
43
50
 
44
51
  ## Issues
45
52
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polystep
3
- Version: 0.12.0
3
+ Version: 0.13.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
@@ -27,13 +27,12 @@ License-File: LICENSE
27
27
  Requires-Dist: torch>=2.8
28
28
  Provides-Extra: examples
29
29
  Requires-Dist: numpy>=2.0; extra == "examples"
30
- Requires-Dist: torchvision>=0.25; extra == "examples"
31
30
  Requires-Dist: matplotlib>=3.8; extra == "examples"
32
31
  Requires-Dist: Pillow>=10.0; extra == "examples"
33
32
  Requires-Dist: gymnasium[classic-control]>=0.29; extra == "examples"
34
- Requires-Dist: python-sat; extra == "examples"
35
33
  Provides-Extra: dev
36
34
  Requires-Dist: numpy>=2.0; extra == "dev"
35
+ Requires-Dist: setuptools>=77; extra == "dev"
37
36
  Requires-Dist: pytest>=8.0; extra == "dev"
38
37
  Requires-Dist: pytest-timeout>=2.2; extra == "dev"
39
38
  Requires-Dist: pytest-rerunfailures>=14.0; extra == "dev"
@@ -66,6 +65,8 @@ Dynamic: license-file
66
65
 
67
66
  Gradient-free neural network training via optimal transport.
68
67
 
68
+ Accepted at [Transactions on Machine Learning Research (TMLR)](https://openreview.net/forum?id=8mlcqTTMuU).
69
+
69
70
  PolyStep evaluates finite-radius parameter perturbations and updates parameters
70
71
  through a weighted barycenter. It supports spiking networks, quantized layers, hard
71
72
  routing, and other models without useful gradients. For differentiable models,
@@ -77,11 +78,9 @@ backpropagation is usually faster.
77
78
 
78
79
  <p align="center">
79
80
  <img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/method_diagram.png" width="840"
80
- alt="PolyStep: subspace projection, polytope probes, loss evaluation, and a weighted parameter update.">
81
+ alt="PolyStep: search coordinates, polytope probes, cost evaluation, soft assignment, and a barycentric update.">
81
82
  </p>
82
83
 
83
- > Want to play around with parameters? [**Viet T. Nguyen**](https://vietngth.github.io/) built a gorgeous interactive walkthrough that animates every step of the method -> **[explore the PolyStep visualization](https://vietngth.github.io/polystep-visualization/)**.
84
-
85
84
  ## Installation
86
85
 
87
86
  ```bash
@@ -178,7 +177,7 @@ studies are reported separately in the [experiment index](experiments/EXPERIMENT
178
177
  <!-- BENCH:START -->
179
178
  ### Non-differentiable tasks
180
179
 
181
- Test accuracy %. A dash means the cell is not in this release.
180
+ Test accuracy %. A dash means the result is not in this release.
182
181
 
183
182
  | Task | PolyStep | CMA-ES | OpenAI-ES | SPSA | Adam (surrogate) | Non-diff op |
184
183
  |---|---|---|---|---|---|---|
@@ -230,7 +229,8 @@ on the differentiable tasks, and specialized solvers lead on MAX-SAT. See
230
229
 
231
230
  ## Acknowledgments
232
231
 
233
- A huge thank you to [**Viet**](https://vietngth.github.io/) for building a beautiful interactive [PolyStep visualization](https://vietngth.github.io/polystep-visualization/), it brings the method to life and makes every step click!
232
+ Thanks to [Viet T. Nguyen](https://vietngth.github.io/) for the
233
+ [interactive visualization](https://vietngth.github.io/polystep-visualization/).
234
234
 
235
235
  ## License
236
236
 
@@ -8,6 +8,8 @@
8
8
 
9
9
  Gradient-free neural network training via optimal transport.
10
10
 
11
+ Accepted at [Transactions on Machine Learning Research (TMLR)](https://openreview.net/forum?id=8mlcqTTMuU).
12
+
11
13
  PolyStep evaluates finite-radius parameter perturbations and updates parameters
12
14
  through a weighted barycenter. It supports spiking networks, quantized layers, hard
13
15
  routing, and other models without useful gradients. For differentiable models,
@@ -19,11 +21,9 @@ backpropagation is usually faster.
19
21
 
20
22
  <p align="center">
21
23
  <img src="https://raw.githubusercontent.com/anindex/polystep/main/docs/figures/method_diagram.png" width="840"
22
- alt="PolyStep: subspace projection, polytope probes, loss evaluation, and a weighted parameter update.">
24
+ alt="PolyStep: search coordinates, polytope probes, cost evaluation, soft assignment, and a barycentric update.">
23
25
  </p>
24
26
 
25
- > Want to play around with parameters? [**Viet T. Nguyen**](https://vietngth.github.io/) built a gorgeous interactive walkthrough that animates every step of the method -> **[explore the PolyStep visualization](https://vietngth.github.io/polystep-visualization/)**.
26
-
27
27
  ## Installation
28
28
 
29
29
  ```bash
@@ -120,7 +120,7 @@ studies are reported separately in the [experiment index](experiments/EXPERIMENT
120
120
  <!-- BENCH:START -->
121
121
  ### Non-differentiable tasks
122
122
 
123
- Test accuracy %. A dash means the cell is not in this release.
123
+ Test accuracy %. A dash means the result is not in this release.
124
124
 
125
125
  | Task | PolyStep | CMA-ES | OpenAI-ES | SPSA | Adam (surrogate) | Non-diff op |
126
126
  |---|---|---|---|---|---|---|
@@ -172,7 +172,8 @@ on the differentiable tasks, and specialized solvers lead on MAX-SAT. See
172
172
 
173
173
  ## Acknowledgments
174
174
 
175
- A huge thank you to [**Viet**](https://vietngth.github.io/) for building a beautiful interactive [PolyStep visualization](https://vietngth.github.io/polystep-visualization/), it brings the method to life and makes every step click!
175
+ Thanks to [Viet T. Nguyen](https://vietngth.github.io/) for the
176
+ [interactive visualization](https://vietngth.github.io/polystep-visualization/).
176
177
 
177
178
  ## License
178
179
 
@@ -32,6 +32,10 @@ Delta paths reuse base activations within each candidate sweep. Reuse applies to
32
32
  supported builtin layers and ends before the next batch or fidelity level.
33
33
  Custom layers can declare the contracts in the [API reference](api_overview.md#custom-layers).
34
34
 
35
+ Dense MLP evaluation also shares activations until the first changed layer when
36
+ unchanged candidate parameters are expanded views. Shared weights use one linear
37
+ operation across candidates. `chunk_size` bounds activation batches on this path.
38
+
35
39
  ## Subspaces and memory
36
40
 
37
41
  Use `HybridSubspace.from_layout(..., max_subspace_dim=...)` to bound candidate count;
@@ -121,7 +125,7 @@ PYTHONPATH=src:. python experiments/scripts/bench_forward_backends.py --profile
121
125
  PYTHONPATH=src:. python experiments/scripts/bench_forward_backends.py --prefix --repeats 7 --warmup 2
122
126
  PYTHONPATH=src:. python experiments/scripts/bench_forward_backends.py --attention --batch 16
123
127
  PYTHONPATH=src:. python experiments/scripts/bench_forward_backends.py --compile --profile
124
- PYTHONPATH=src:. python experiments/scripts/bench_polytope.py --suite --seeds 0 1 2 3 4 --budget 4096 --wall
128
+ PYTHONPATH=src:. python experiments/scripts/bench_polytope.py --seeds 0 1 2 3 4 --budget 4096 --wall
125
129
  ```
126
130
 
127
131
  Use `--device cuda` for GPU measurements and `--output` to keep separate result files.
@@ -32,11 +32,13 @@ python experiments/scripts/tune_gallery.py
32
32
  python experiments/scripts/tune_polystep.py
33
33
  bash experiments/scripts/run_revision.sh
34
34
  python experiments/scripts/check_grid_complete.py experiments/results/revision
35
- python experiments/scripts/generate_paper_tables.py \
35
+ python experiments/scripts/generate_tables.py \
36
36
  --results-dir experiments/results/revision \
37
37
  --readme README.md --index experiments/EXPERIMENT_INDEX.md
38
38
  ```
39
39
 
40
+ The table generator updates Markdown only and rejects test-selected results.
41
+
40
42
  Individual runners accept `--methods` and `--seeds`:
41
43
 
42
44
  ```bash
@@ -73,3 +75,6 @@ python experiments/scripts/aggregate_results.py experiments/results/revision --w
73
75
 
74
76
  See [determinism](determinism.md), [controlled experiments](../experiments/CONTROLLED_EXPERIMENTS.md),
75
77
  and [forward benchmarks](performance.md#benchmarks) for their specific checks.
78
+
79
+ With the datasets and CUDA available, run the practical-study update checks with
80
+ `PYTHONPATH=src:. pytest tests/test_practical.py -m gpu`.
@@ -38,15 +38,14 @@ Issues = "https://github.com/anindex/polystep/issues"
38
38
  # Lightweight: just enough for examples/ demos
39
39
  examples = [
40
40
  "numpy>=2.0",
41
- "torchvision>=0.25",
42
41
  "matplotlib>=3.8",
43
42
  "Pillow>=10.0",
44
43
  "gymnasium[classic-control]>=0.29",
45
- "python-sat", # example 04 generates its 3-SAT instance through pysat
46
44
  ]
47
45
  # Core dev: lint + test the library itself (no experiment deps)
48
46
  dev = [
49
47
  "numpy>=2.0", # the test suite loads MNIST through it
48
+ "setuptools>=77", # PyTorch 2.8's compiler imports it at runtime
50
49
  "pytest>=8.0",
51
50
  "pytest-timeout>=2.2",
52
51
  "pytest-rerunfailures>=14.0",
@@ -1,6 +1,6 @@
1
1
  """polystep: PyTorch PolyStep Optimizer."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.13.0"
4
4
 
5
5
  __all__ = [
6
6
  # Core solver
@@ -11,6 +11,7 @@ import torch
11
11
  from ._compiled import _barycentric_projection, _rotate_and_translate
12
12
  from .geometry import get_orthoplex_vertices, get_random_rotation_matrices
13
13
  from .solvers import SinkhornSolver, SoftmaxSolver
14
+ from .solvers._shared import validate_positive
14
15
 
15
16
  __all__ = ["PolyStepES", "minimize"]
16
17
 
@@ -52,8 +53,7 @@ class PolyStepES:
52
53
  raise ValueError(f"dim must be >= 1, got {dim}.")
53
54
  if num_particles < 1:
54
55
  raise ValueError(f"num_particles must be >= 1, got {num_particles}.")
55
- if not epsilon > 0:
56
- raise ValueError(f"epsilon must be > 0, got {epsilon}.")
56
+ validate_positive(epsilon, "epsilon")
57
57
  if not (math.isfinite(step_radius) and step_radius >= 0):
58
58
  raise ValueError(f"step_radius must be finite and >= 0, got {step_radius}.")
59
59
  self.dim = dim
@@ -64,11 +64,12 @@ def _download_mnist(data_dir: str) -> None:
64
64
  download_file(MNIST_URL + filename, filepath)
65
65
 
66
66
 
67
- def _load_mnist_images(filepath: str) -> np.ndarray:
67
+ def _load_mnist_images(filepath: str, limit: int = 0) -> np.ndarray:
68
68
  """Load MNIST images from gzipped IDX file."""
69
69
  with gzip.open(filepath, "rb") as f:
70
70
  _magic, num, rows, cols = pystruct.unpack(">IIII", f.read(16))
71
- images = np.frombuffer(f.read(), dtype=np.uint8)
71
+ num = min(num, limit) if limit > 0 else num
72
+ images = np.frombuffer(f.read(num * rows * cols), dtype=np.uint8)
72
73
  images = images.reshape(num, 1, rows, cols)
73
74
  return images.astype(np.float32) / 255.0
74
75
 
@@ -92,9 +93,9 @@ def get_mnist_loaders(
92
93
  data_dir = data_dir or _default_data_dir("mnist")
93
94
  _download_mnist(data_dir)
94
95
 
95
- train_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["train_images"]))
96
+ train_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["train_images"]), max_train)
96
97
  train_labels = _load_mnist_labels(os.path.join(data_dir, MNIST_FILES["train_labels"]))
97
- test_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["test_images"]))
98
+ test_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["test_images"]), max_test)
98
99
  test_labels = _load_mnist_labels(os.path.join(data_dir, MNIST_FILES["test_labels"]))
99
100
 
100
101
  if normalize:
@@ -8,6 +8,7 @@ the one parameter a candidate perturbs.
8
8
  from __future__ import annotations
9
9
 
10
10
  import warnings
11
+ from bisect import bisect_right
11
12
  from contextlib import contextmanager
12
13
  from typing import Callable, Optional, TYPE_CHECKING, Union
13
14
 
@@ -113,7 +114,7 @@ def _batched_loss_kind(loss_fn) -> Optional[str]:
113
114
  A configured loss has no reproduction and falls back to vmap; a subclass that
114
115
  overrides ``forward`` would score candidates on a different objective.
115
116
  """
116
- if getattr(loss_fn, "reduction", None) != "mean":
117
+ if getattr(loss_fn, "reduction", None) != "mean" or _has_hooks(loss_fn):
117
118
  return None
118
119
  for base, kind in ((nn.CrossEntropyLoss, "cross_entropy"), (nn.MSELoss, "mse"), (nn.L1Loss, "l1")):
119
120
  if not isinstance(loss_fn, base):
@@ -208,6 +209,8 @@ class NNCostEvaluator:
208
209
  per_sample: bool = False,
209
210
  autocast_dtype: Optional[torch.dtype] = None,
210
211
  ):
212
+ if chunk_size not in (None, "auto") and (type(chunk_size) is not int or chunk_size < 1):
213
+ raise ValueError("chunk_size must be None, 'auto', or a positive integer")
211
214
  self.model = model
212
215
  self.loss_fn = loss_fn
213
216
  self.autocast_dtype = autocast_dtype
@@ -297,6 +300,7 @@ class NNCostEvaluator:
297
300
  self._subspace_backup = None
298
301
  self._cast_cache = None
299
302
  self._input_dtype_cache = _UNSET
303
+ self._chunk_size_cached = _UNSET
300
304
  loss_kind = _batched_loss_kind(self.loss_fn)
301
305
  self._batched_linear = (
302
306
  BatchedLinearEvaluator.try_build(self.model, self.loss_fn, loss_kind) if loss_kind is not None else None
@@ -385,7 +389,18 @@ class NNCostEvaluator:
385
389
  and not self.per_sample
386
390
  and not (self._batched_linear.loss_kind == "cross_entropy" and targets.dim() != 1)
387
391
  ):
388
- return self._batched_linear.evaluate(stacked_params, inputs, targets)
392
+ n = next(iter(stacked_params.values())).shape[0]
393
+ chunk = self.chunk_size
394
+ if chunk is None or n <= chunk:
395
+ return self._batched_linear.evaluate(stacked_params, inputs, targets)
396
+ return torch.cat(
397
+ [
398
+ self._batched_linear.evaluate(
399
+ {k: v[start : start + chunk] for k, v in stacked_params.items()}, inputs, targets
400
+ )
401
+ for start in range(0, n, chunk)
402
+ ]
403
+ )
389
404
 
390
405
  if self._vmap_failed:
391
406
  result = self._evaluate_loop(stacked_params, inputs, targets)
@@ -856,7 +871,7 @@ def _plan_children(model: nn.Module):
856
871
  for name, mod in model._modules.items():
857
872
  if mod is None:
858
873
  continue
859
- if isinstance(mod, nn.Sequential):
874
+ if isinstance(mod, nn.Sequential) and type(mod).forward is nn.Sequential.forward and not _has_hooks(mod):
860
875
  for sub, m in mod._modules.items():
861
876
  if m is not None:
862
877
  yield f"{name}.{sub}", m
@@ -995,42 +1010,49 @@ class BatchedLinearEvaluator:
995
1010
  ) -> torch.Tensor:
996
1011
  """Batched forward via bmm for Linear layers."""
997
1012
  N = next(iter(stacked_params.values())).shape[0]
998
- # Expand the input across the N param configs.
999
- if inputs.dim() == 2:
1000
- x = inputs.unsqueeze(0).expand(N, -1, -1) # (N, B, in_feat)
1001
- else:
1002
- # Flatten spatial dims for non-2D inputs.
1003
- x = inputs.reshape(inputs.shape[0], -1).unsqueeze(0).expand(N, -1, -1)
1013
+ # Keep the prefix shared until a candidate actually changes a layer.
1014
+ x = inputs if inputs.dim() == 2 else inputs.reshape(inputs.shape[0], -1)
1004
1015
 
1005
1016
  for name, tag, module in self._layer_keys:
1006
1017
  if tag == "linear":
1007
1018
  w_key = f"{name}.weight"
1008
1019
  b_key = f"{name}.bias"
1009
1020
  wq, bq = _weight_transforms(module)
1010
- # Frozen params are constant across candidates, so the module's tensor
1011
- # stands in; bmm does not broadcast its batch dim, hence the expand.
1012
1021
  W = stacked_params.get(w_key)
1013
1022
  if W is None:
1014
- W = module.weight.unsqueeze(0).expand(N, -1, -1)
1023
+ W = module.weight
1024
+ elif W.stride(0) == 0:
1025
+ W = W[0] # expanded view: every candidate shares this weight
1015
1026
  if wq is not None:
1016
1027
  # Elementwise, so transforming the whole stack equals per-candidate.
1017
1028
  W = wq(W)
1018
1029
  bias = stacked_params.get(b_key)
1019
1030
  if bias is None and getattr(module, "bias", None) is not None:
1020
- bias = module.bias.unsqueeze(0)
1031
+ bias = module.bias
1032
+ elif bias is not None and bias.stride(0) == 0:
1033
+ bias = bias[0]
1021
1034
  if bias is not None and bq is not None:
1022
1035
  bias = bq(bias)
1023
- # baddbmm folds the bias into the same kernel.
1024
- if bias is not None:
1025
- x = torch.baddbmm(bias.unsqueeze(1), x, W.transpose(1, 2))
1036
+ if W.dim() == 2:
1037
+ # One GEMM for shared weights, including a shared prefix.
1038
+ x = nn.functional.linear(x, W, bias if bias is not None and bias.dim() == 1 else None)
1039
+ if bias is not None and bias.dim() == 2:
1040
+ x = x + bias.unsqueeze(1)
1026
1041
  else:
1027
- x = torch.bmm(x, W.transpose(1, 2))
1042
+ if x.dim() == 2:
1043
+ x = x.unsqueeze(0).expand(N, -1, -1)
1044
+ if bias is not None:
1045
+ x = torch.baddbmm(bias.unsqueeze(-2), x, W.transpose(1, 2))
1046
+ else:
1047
+ x = torch.bmm(x, W.transpose(1, 2))
1028
1048
  elif tag in ("flatten", "dropout"):
1029
1049
  pass # input is pre-flattened; dropout is identity in eval mode
1030
1050
  else:
1031
1051
  # Apply the real module so its configuration is exact.
1032
1052
  x = module(x)
1033
1053
 
1054
+ if x.dim() == 2:
1055
+ x = x.unsqueeze(0).expand(N, -1, -1)
1034
1056
  return _reduce_per_candidate(x, targets, self.loss_fn, self.loss_kind)
1035
1057
 
1036
1058
 
@@ -1046,7 +1068,7 @@ def _resolve_layout_entry(self, offsets: torch.Tensor, pdim: int, span=None):
1046
1068
  else:
1047
1069
  lo, hi = span
1048
1070
 
1049
- idx = int(torch.searchsorted(self._starts, torch.tensor(lo), right=True)) - 1
1071
+ idx = bisect_right(self._starts, lo) - 1
1050
1072
  if idx < 0 or idx >= len(self._keys):
1051
1073
  return None
1052
1074
  entry = self._entry_by_key[self._keys[idx]]
@@ -1078,7 +1100,7 @@ class SiteVmapEvaluator:
1078
1100
  self._buffers = buffers
1079
1101
  self._owner = owner
1080
1102
  self._entry_by_key = {e.key: e for e in layout.entries}
1081
- self._starts = torch.tensor([e.offset for e in layout.entries], dtype=torch.long)
1103
+ self._starts = [e.offset for e in layout.entries]
1082
1104
  self._keys = [e.key for e in layout.entries]
1083
1105
  self._compiled_sites = {}
1084
1106
  self._compile_failed_sites = set()
@@ -1245,7 +1267,7 @@ class SparseDeltaEvaluator:
1245
1267
  self._site[f"{name}.bias"] = (idx, False)
1246
1268
  self._entry_by_key = {e.key: e for e in layout.entries}
1247
1269
  # Flat spans of the entries this path can perturb, for the containment test.
1248
- self._starts = torch.tensor([e.offset for e in layout.entries], dtype=torch.long)
1270
+ self._starts = [e.offset for e in layout.entries]
1249
1271
  self._keys = [e.key for e in layout.entries]
1250
1272
 
1251
1273
  @classmethod
@@ -65,6 +65,7 @@ from .solvers import (
65
65
  TopKMeanSolver,
66
66
  )
67
67
  from .solver import SolverState
68
+ from .solvers._shared import validate_positive
68
69
  from .adaptive_subspace import AdaptiveSubspace
69
70
  from .cma import compute_cma_hyperparameters
70
71
  from .cma_subspace import CMAAdaptiveSubspace
@@ -1112,20 +1113,13 @@ class PolyStepOptimizer(SerializationMixin):
1112
1113
  """Actual projection type in use ('dense' or 'sparse')."""
1113
1114
  return self._actual_projection_type
1114
1115
 
1115
- @staticmethod
1116
- def _check_temperature(value: float, name: str) -> float:
1117
- """Reject a non-positive temperature the fused kernel would divide by unguarded."""
1118
- if not value > 0:
1119
- raise ValueError(f"{name} must resolve to > 0, got {value}.")
1120
- return value
1121
-
1122
1116
  def _get_epsilon(self, iteration: int) -> float:
1123
1117
  """Resolve epsilon at current iteration."""
1124
1118
  if self._progressive_epsilon is not None:
1125
- return self._check_temperature(self._progressive_epsilon.at(iteration), "epsilon")
1119
+ return validate_positive(self._progressive_epsilon.at(iteration), "epsilon")
1126
1120
  if hasattr(self.epsilon, "at"):
1127
- return self._check_temperature(self.epsilon.at(iteration), "epsilon")
1128
- return self.epsilon
1121
+ return validate_positive(self.epsilon.at(iteration), "epsilon")
1122
+ return validate_positive(self.epsilon, "epsilon")
1129
1123
 
1130
1124
  def _get_step_radius(self, iteration: int) -> float:
1131
1125
  """Resolve step_radius at current iteration (supports schedule objects)."""
@@ -1220,8 +1214,8 @@ class PolyStepOptimizer(SerializationMixin):
1220
1214
  if self.ent_epsilon is None:
1221
1215
  return None
1222
1216
  if hasattr(self.ent_epsilon, "at"):
1223
- return self._check_temperature(self.ent_epsilon.at(iteration), "ent_epsilon")
1224
- return self.ent_epsilon
1217
+ return validate_positive(self.ent_epsilon.at(iteration), "ent_epsilon")
1218
+ return validate_positive(self.ent_epsilon, "ent_epsilon")
1225
1219
 
1226
1220
  def _bf16_supported(self) -> bool:
1227
1221
  """Whether BF16 is supported on the model's device."""
@@ -12,7 +12,7 @@ from .costs import compute_cost_matrix
12
12
  from .epsilon import feed_solver_stats, resolve_radius, LinearEpsilon
13
13
  from .geometry import get_random_rotation_matrices, POLYTOPE_MAP
14
14
  from .solvers import SinkhornSolver, SoftmaxSolver
15
- from .solvers._shared import solver_health
15
+ from .solvers._shared import solver_health, validate_positive
16
16
 
17
17
 
18
18
  @dataclass
@@ -173,16 +173,16 @@ class PolyStep:
173
173
  def _get_epsilon(self, iteration: int) -> float:
174
174
  """Resolve epsilon at ``iteration``; duck-types on ``.at()`` so schedules and plain floats both work."""
175
175
  if hasattr(self.epsilon, "at"):
176
- return self.epsilon.at(iteration)
177
- return self.epsilon
176
+ return validate_positive(self.epsilon.at(iteration), "epsilon")
177
+ return validate_positive(self.epsilon, "epsilon")
178
178
 
179
179
  def _get_ent_epsilon(self, iteration: int) -> Optional[float]:
180
180
  """Resolve ent_epsilon at current iteration (supports schedule objects)."""
181
181
  if self.ent_epsilon is None:
182
182
  return None
183
183
  if hasattr(self.ent_epsilon, "at"):
184
- return self.ent_epsilon.at(iteration)
185
- return self.ent_epsilon
184
+ return validate_positive(self.ent_epsilon.at(iteration), "ent_epsilon")
185
+ return validate_positive(self.ent_epsilon, "ent_epsilon")
186
186
 
187
187
  @torch.inference_mode()
188
188
  def step(
@@ -10,13 +10,14 @@ import torch
10
10
  from .base import SolverResult
11
11
 
12
12
 
13
- def validate_positive(value: float, name: str, context: str = "") -> None:
14
- """Raise ValueError unless ``value > 0``."""
15
- if not value > 0:
16
- msg = f"{name} must be > 0, got {value}."
13
+ def validate_positive(value: float, name: str, context: str = "") -> float:
14
+ """Return a positive finite value, or raise ValueError."""
15
+ if not (math.isfinite(value) and value > 0):
16
+ msg = f"{name} must be > 0 and finite, got {value}."
17
17
  if context:
18
18
  msg += " " + context
19
19
  raise ValueError(msg)
20
+ return value
20
21
 
21
22
 
22
23
  def solver_health(transport: torch.Tensor, displacement: torch.Tensor, step_radius: float):
@@ -89,13 +89,15 @@ class TopKMeanSolver:
89
89
  _, topk_indices = C_raw.topk(k_eff, dim=-1, largest=False) # (P, k_eff)
90
90
 
91
91
  # topk does not honour the sanitize mask, so drop infeasible picks and spread mass over what is left; the row still sums to a.
92
- feasible = ~(torch.isnan(cost_matrix) | (cost_matrix == float("inf")))
92
+ feasible = torch.isfinite(cost_matrix)
93
93
  keep = feasible.gather(1, topk_indices)
94
94
  keep = keep | ~keep.any(dim=1, keepdim=True)
95
95
 
96
96
  transport = torch.zeros(P, V, device=device, dtype=dtype)
97
97
  mass = a.unsqueeze(1) / keep.sum(dim=1, keepdim=True) * keep
98
98
  transport.scatter_(1, topk_indices, mass.to(dtype))
99
+ informative = (C_raw.amax(dim=-1) > C_raw.amin(dim=-1)).unsqueeze(1)
100
+ transport = torch.where(informative, transport, a.unsqueeze(1).expand(P, V) / V)
99
101
 
100
102
  # scale_cost is unused: topk is invariant to a positive divisor.
101
103
  ent_cost = (C_raw * transport).sum().item()
@@ -11,7 +11,6 @@ import torch
11
11
  from ._shared import (
12
12
  align_dual,
13
13
  align_marginal,
14
- exp_plan,
15
14
  prepare_cost,
16
15
  validate_positive,
17
16
  warn_tiny_temperature,
@@ -33,10 +32,7 @@ class KLSoftmaxSolver:
33
32
  last_marginal_violation: Optional[float] = field(default=None, init=False, repr=False)
34
33
 
35
34
  def __post_init__(self) -> None:
36
- if self.epsilon <= 0:
37
- raise ValueError(
38
- f"epsilon must be > 0, got {self.epsilon}. epsilon is the entropic temperature and must be positive."
39
- )
35
+ validate_positive(self.epsilon, "epsilon")
40
36
  if self.lam < 0:
41
37
  raise ValueError(f"lam must be >= 0, got {self.lam}. lam is the KL penalty on the column marginal.")
42
38
  if self.threshold < 0:
@@ -51,7 +47,10 @@ class KLSoftmaxSolver:
51
47
  return 0.0
52
48
  if math.isinf(self.lam):
53
49
  return 1.0
54
- return float(self.lam / (self.lam + self.epsilon))
50
+ if self.lam < self.epsilon:
51
+ ratio = self.lam / self.epsilon
52
+ return float(ratio / (1.0 + ratio))
53
+ return float(1.0 / (1.0 + self.epsilon / self.lam))
55
54
 
56
55
  def solve(
57
56
  self,
@@ -98,7 +97,6 @@ class KLSoftmaxSolver:
98
97
 
99
98
  # Softmax limit, closed form in one iteration. The bound is the dtype's smallest normal: below it the /alpha residual becomes 0/0 = NaN.
100
99
  if alpha < torch.finfo(dtype).tiny:
101
- f = eps * (log_a - torch.logsumexp(-C / eps, dim=1))
102
100
  g = torch.zeros_like(g)
103
101
  converged = True
104
102
  n_iters = 1
@@ -129,11 +127,13 @@ class KLSoftmaxSolver:
129
127
  break
130
128
  f, g = f_new, g_new
131
129
 
132
- # One more f-update so P1 == a holds, since the loop leaves f one step behind g.
133
- f = eps * (log_a - torch.logsumexp((g.unsqueeze(0) - C) / eps, dim=1))
134
-
135
- # Clamped exponent, not a zero-fill: zeroing overflowed entries would drop the mass that makes P1 == a.
136
- P = exp_plan(f, g, C, eps)
130
+ # Enforce rows directly: exp((f + g - C)/eps) loses their mass when
131
+ # f and C nearly cancel. Shift before division, including at lam=0.
132
+ scores = g.unsqueeze(0) - C
133
+ row_max = scores.amax(dim=1, keepdim=True)
134
+ logits = (scores - row_max) / eps
135
+ P = torch.softmax(logits, dim=1) * a.unsqueeze(1)
136
+ f = eps * (log_a - torch.logsumexp(logits, dim=1)) - row_max.squeeze(1)
137
137
 
138
138
  # Undo both frame changes so cost is <C_raw, P> (sum(P) == a.sum()).
139
139
  cost = ((C * P).sum() * cost_scale + cost_shift * a.sum()).item()
@@ -76,11 +76,7 @@ class SinkhornSolver:
76
76
 
77
77
  def __post_init__(self):
78
78
  """Initialize compiled function registry and validate parameters."""
79
- if self.epsilon <= 0:
80
- raise ValueError(
81
- f"epsilon must be > 0, got {self.epsilon}. "
82
- f"A zero or negative epsilon causes division by zero in log-domain Sinkhorn iterations."
83
- )
79
+ validate_positive(self.epsilon, "epsilon")
84
80
  if self.omega < 0.5 or self.omega > 1.95:
85
81
  raise ValueError(
86
82
  f"omega must be in [0.5, 1.95], got {self.omega}. "