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.
- {polystep-0.12.0 → polystep-0.13.0}/CHANGELOG.md +6 -0
- {polystep-0.12.0 → polystep-0.13.0}/CITATION.cff +2 -2
- {polystep-0.12.0 → polystep-0.13.0}/CONTRIBUTING.md +9 -2
- {polystep-0.12.0 → polystep-0.13.0}/PKG-INFO +8 -8
- {polystep-0.12.0 → polystep-0.13.0}/README.md +6 -5
- {polystep-0.12.0 → polystep-0.13.0}/docs/performance.md +5 -1
- {polystep-0.12.0 → polystep-0.13.0}/docs/reproducibility.md +6 -1
- {polystep-0.12.0 → polystep-0.13.0}/pyproject.toml +1 -2
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/__init__.py +1 -1
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/ask_tell.py +2 -2
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/utils.py +5 -4
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cost_nn.py +42 -20
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/optimizer.py +6 -12
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solver.py +5 -5
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/_shared.py +5 -4
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/greedy.py +3 -1
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/kl_softmax.py +12 -12
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/sinkhorn.py +1 -5
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/PKG-INFO +8 -8
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/SOURCES.txt +0 -1
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/requires.txt +1 -2
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_ablation_solvers.py +14 -55
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_baseline_fairness.py +2 -90
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_baselines.py +16 -18
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_end_to_end_learning.py +0 -40
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_evaluator_regressions.py +64 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_fused_projection.py +2 -2
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_input_validation.py +7 -6
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_kl_softmax.py +18 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_mnist.py +19 -0
- polystep-0.13.0/tests/test_no_test_set_leakage.py +109 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_nondiff_models.py +1 -2
- polystep-0.13.0/tests/test_practical.py +73 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_search_experiments.py +34 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_silent_failures.py +8 -4
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_softmax.py +1 -46
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_contract.py +9 -30
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_delta.py +1 -1
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_timeseries_val_selection.py +1 -1
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_tuning_sweep.py +42 -48
- polystep-0.12.0/tests/test_no_test_set_leakage.py +0 -165
- polystep-0.12.0/tests/test_practical.py +0 -41
- polystep-0.12.0/tests/test_sweep_protocol.py +0 -214
- {polystep-0.12.0 → polystep-0.13.0}/LICENSE +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/LIMITATIONS.md +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/MANIFEST.in +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/docs/api_overview.md +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/docs/determinism.md +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/setup.cfg +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_compiled.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_serialization.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_blockwise.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_core.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_momentum.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/_step_monolithic.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/adaptive_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/api.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/core.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/baselines/methods.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/cartpole.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/gym_evaluator.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/metrics.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/rl/policies.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/benchmarks/threads.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/blockwise.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cma.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/cma_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/costs.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/dynamics.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/epsilon.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/factored_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/geometry.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/hybrid_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/attention.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/layers/rnn.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/base.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/objectives/synthetic.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/projection/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/projection/sparse.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/py.typed +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/quadratic_model.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/base.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/softmax.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/solvers/tempered_softmax.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep/transform.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/dependency_links.txt +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/src/polystep.egg-info/top_level.txt +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/__init__.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/conftest.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_adaptive_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_amortization.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_api.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_ask_tell.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_benchmarks.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_biased_rotation.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_blockwise.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_candidate_autocast.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_checkpoint_resume.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_cma.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_cma_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_compiled.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_controlled.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_cost_nn.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_costs.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_determinism.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_dfo.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_diagnostics.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_dynamics.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_epsilon.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_evaluator_compile_forward.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_factored_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_fastpath_parity.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_geometry.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_hybrid_subspace.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_integration.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_layers.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_math_guards.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_mixed_precision.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_nondiff_data.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_numerical_stress.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_objectives.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_optimizer.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_per_sample.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_probe_jitter.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_quadratic_model.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_regressions.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_rl_benchmarks.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sequential_fast_path.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sinkhorn.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sinkhorn_numerics.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_site_vmap.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_softmax_integration.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_delta_evaluator.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_integration.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_sparse_projection.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_blockwise.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_subspace_gain.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_transform.py +0 -0
- {polystep-0.12.0 → polystep-0.13.0}/tests/test_trust_region.py +0 -0
- {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.
|
|
10
|
-
date-released: "2026-09-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 --
|
|
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/
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
x =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1119
|
+
return validate_positive(self._progressive_epsilon.at(iteration), "epsilon")
|
|
1126
1120
|
if hasattr(self.epsilon, "at"):
|
|
1127
|
-
return
|
|
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
|
|
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 = "") ->
|
|
14
|
-
"""
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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}. "
|